diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1187641c20..b3da40e3186 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -63,7 +63,8 @@ /packages/build-utils @MetaMask/wallet-framework-engineers /packages/composable-controller @MetaMask/wallet-framework-engineers /packages/controller-utils @MetaMask/wallet-framework-engineers -/packages/sample-controllers @MetaMask/wallet-framework-engineers +/packages/error-reporting-service @MetaMask/wallet-framework-engineers +/packages/sample-controllers @MetaMask/wallet-framework-engineers /packages/polling-controller @MetaMask/wallet-framework-engineers /packages/preferences-controller @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index 1f3d2c522c5..6010bd84f47 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,11 @@ Each package in this repository has its own README where you can find installati - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/controller-utils`](packages/controller-utils) +- [`@metamask/delegation-controller`](packages/delegation-controller) - [`@metamask/earn-controller`](packages/earn-controller) - [`@metamask/eip1193-permission-middleware`](packages/eip1193-permission-middleware) - [`@metamask/ens-controller`](packages/ens-controller) +- [`@metamask/error-reporting-service`](packages/error-reporting-service) - [`@metamask/eth-json-rpc-provider`](packages/eth-json-rpc-provider) - [`@metamask/gas-fee-controller`](packages/gas-fee-controller) - [`@metamask/json-rpc-engine`](packages/json-rpc-engine) @@ -88,9 +90,11 @@ linkStyle default opacity:0.5 chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); composable_controller(["@metamask/composable-controller"]); controller_utils(["@metamask/controller-utils"]); + delegation_controller(["@metamask/delegation-controller"]); earn_controller(["@metamask/earn-controller"]); eip1193_permission_middleware(["@metamask/eip1193-permission-middleware"]); ens_controller(["@metamask/ens-controller"]); + error_reporting_service(["@metamask/error-reporting-service"]); eth_json_rpc_provider(["@metamask/eth-json-rpc-provider"]); gas_fee_controller(["@metamask/gas-fee-controller"]); json_rpc_engine(["@metamask/json-rpc-engine"]); @@ -138,20 +142,27 @@ linkStyle default opacity:0.5 assets_controllers --> network_controller; assets_controllers --> permission_controller; assets_controllers --> preferences_controller; + assets_controllers --> transaction_controller; base_controller --> json_rpc_engine; bridge_controller --> base_controller; bridge_controller --> controller_utils; + bridge_controller --> gas_fee_controller; bridge_controller --> multichain_network_controller; bridge_controller --> polling_controller; bridge_controller --> accounts_controller; + bridge_controller --> assets_controllers; bridge_controller --> eth_json_rpc_provider; bridge_controller --> network_controller; + bridge_controller --> remote_feature_flag_controller; bridge_controller --> transaction_controller; bridge_status_controller --> base_controller; - bridge_status_controller --> bridge_controller; bridge_status_controller --> controller_utils; bridge_status_controller --> polling_controller; + bridge_status_controller --> user_operation_controller; bridge_status_controller --> accounts_controller; + bridge_status_controller --> bridge_controller; + bridge_status_controller --> gas_fee_controller; + bridge_status_controller --> multichain_transactions_controller; bridge_status_controller --> network_controller; bridge_status_controller --> transaction_controller; chain_agnostic_permission --> controller_utils; @@ -159,6 +170,9 @@ linkStyle default opacity:0.5 chain_agnostic_permission --> permission_controller; composable_controller --> base_controller; composable_controller --> json_rpc_engine; + delegation_controller --> base_controller; + delegation_controller --> accounts_controller; + delegation_controller --> keyring_controller; earn_controller --> base_controller; earn_controller --> controller_utils; earn_controller --> accounts_controller; @@ -187,10 +201,13 @@ linkStyle default opacity:0.5 multichain --> network_controller; multichain --> permission_controller; multichain_api_middleware --> chain_agnostic_permission; + multichain_api_middleware --> controller_utils; multichain_api_middleware --> json_rpc_engine; multichain_api_middleware --> network_controller; multichain_api_middleware --> permission_controller; + multichain_api_middleware --> multichain_transactions_controller; multichain_network_controller --> base_controller; + multichain_network_controller --> controller_utils; multichain_network_controller --> accounts_controller; multichain_network_controller --> keyring_controller; multichain_network_controller --> network_controller; diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 648cc37f028..bd3a0d7e21e 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -2,9 +2,6 @@ "packages/accounts-controller/src/AccountsController.test.ts": { "import-x/namespace": 1 }, - "packages/address-book-controller/src/AddressBookController.ts": { - "jsdoc/check-tag-names": 13 - }, "packages/approval-controller/src/ApprovalController.test.ts": { "import-x/order": 1, "jest/no-conditional-in-test": 16 @@ -73,9 +70,6 @@ "packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts": { "prettier/prettier": 1 }, - "packages/assets-controllers/src/TokenBalancesController.test.ts": { - "import-x/order": 1 - }, "packages/assets-controllers/src/TokenBalancesController.ts": { "@typescript-eslint/prefer-readonly": 4, "jsdoc/check-tag-names": 4, @@ -96,10 +90,6 @@ "import-x/order": 3, "jest/no-conditional-in-test": 2 }, - "packages/assets-controllers/src/TokenListController.ts": { - "jsdoc/check-tag-names": 1, - "jsdoc/tag-lines": 7 - }, "packages/assets-controllers/src/TokenRatesController.test.ts": { "import-x/order": 3 }, @@ -437,9 +427,6 @@ "packages/polling-controller/src/AbstractPollingController.ts": { "@typescript-eslint/prefer-readonly": 1 }, - "packages/preferences-controller/src/PreferencesController.test.ts": { - "prettier/prettier": 4 - }, "packages/queued-request-controller/src/QueuedRequestController.ts": { "@typescript-eslint/prefer-readonly": 2 }, diff --git a/package.json b/package.json index b9f75c1df6f..a3218725be7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "392.0.0", + "version": "412.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 028decaa98a..0ff09f7ad04 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Populate `.options.entropySource` for new `InternalAccount`s before publishing `:accountAdded` ([#5841](https://github.com/MetaMask/core/pull/5841)) + +## [29.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + ## [28.0.0] ### Added @@ -528,7 +538,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#1637](https://github.com/MetaMask/core/pull/1637)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@28.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@29.0.0...HEAD +[29.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@28.0.0...@metamask/accounts-controller@29.0.0 [28.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@27.0.0...@metamask/accounts-controller@28.0.0 [27.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@26.1.0...@metamask/accounts-controller@27.0.0 [26.1.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@26.0.0...@metamask/accounts-controller@26.1.0 diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index 621b1357567..dadc4f1b3f6 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/accounts-controller", - "version": "28.0.0", + "version": "29.0.0", "description": "Manages internal accounts", "keywords": [ "MetaMask", @@ -63,8 +63,8 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", - "@metamask/network-controller": "^23.4.0", + "@metamask/keyring-controller": "^22.0.0", + "@metamask/network-controller": "^23.5.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@types/jest": "^27.4.1", @@ -77,7 +77,7 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/keyring-controller": "^21.0.0", + "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.0.0", diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 8a5fd21cbca..d842fb46ab3 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -7,9 +7,9 @@ import type { } from '@metamask/keyring-api'; import { BtcAccountType, + BtcScope, EthAccountType, EthScope, - BtcScope, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -17,8 +17,8 @@ import type { NetworkClientId } from '@metamask/network-controller'; import type { SnapControllerState } from '@metamask/snaps-controllers'; import { SnapStatus } from '@metamask/snaps-utils'; import type { CaipChainId } from '@metamask/utils'; -import * as uuid from 'uuid'; import type { V4Options } from 'uuid'; +import * as uuid from 'uuid'; import type { AccountsControllerActions, @@ -180,6 +180,29 @@ function setLastSelectedAsAny(account: InternalAccount): InternalAccount { return deepClonedAccount; } +/** + * Sets the `entropySource` property of the given `account` to the specified + * keyringId value. + * + * @param account - The account to modify. + * @param keyringId - The keyring ID to set as entropySource. + * @returns The modified account. + */ +function populateEntropySource( + account: InternalAccount, + keyringId: string, +): InternalAccount { + return JSON.parse( + JSON.stringify({ + ...account, + options: { + ...account.options, + entropySource: keyringId, + }, + }), + ) as InternalAccount; +} + /** * Builds a new instance of the Messenger class for the AccountsController. * @@ -501,12 +524,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -534,7 +555,7 @@ describe('AccountsController', () => { messenger.publish( 'KeyringController:stateChange', - { isUnlocked: true, keyrings: [], keyringsMetadata: [] }, + { isUnlocked: true, keyrings: [] }, [], ); @@ -553,12 +574,10 @@ describe('AccountsController', () => { accounts: [mockAccount.address, mockAccount2.address], type: KeyringTypes.hd, id: '123', - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -595,12 +614,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -627,7 +644,7 @@ describe('AccountsController', () => { expect(accounts).toStrictEqual([ mockAccount, - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ]); }); @@ -654,20 +671,18 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: KeyringTypes.snap, accounts: [mockAccount3.address, mockAccount4.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', - }, - { - id: 'mock-id2', - name: 'mock-name2', + metadata: { + id: 'mock-id2', + name: 'mock-name2', + }, }, ], }; @@ -731,20 +746,18 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: KeyringTypes.snap, accounts: [mockAccount3.address, mockAccount4.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', - }, - { - id: 'mock-id2', - name: 'mock-name2', + metadata: { + id: 'mock-id2', + name: 'mock-name2', + }, }, ], }; @@ -792,6 +805,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: KeyringTypes.snap, @@ -799,16 +816,10 @@ describe('AccountsController', () => { // to the state (like if the Snap did remove it right before the keyring controller // state change got triggered). accounts: [mockAccount3.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', - }, - { - id: 'mock-id2', - name: 'mock-name2', + metadata: { + id: 'mock-id2', + name: 'mock-name2', + }, }, ], }; @@ -851,12 +862,10 @@ describe('AccountsController', () => { mockAccount2.address, mockAccount3.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -890,6 +899,9 @@ describe('AccountsController', () => { name: 'Account 3', address: mockAccount3.address, keyringType: KeyringTypes.hd, + options: { + entropySource: 'mock-id', + }, }), ), ]); @@ -919,12 +931,10 @@ describe('AccountsController', () => { mockAccount2.address, mockAccount3.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -957,7 +967,9 @@ describe('AccountsController', () => { name: 'Account 3', address: mockAccount3.address, keyringType: KeyringTypes.hd, - options: {}, + options: { + entropySource: 'mock-id', + }, }), ]); }); @@ -982,20 +994,18 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: KeyringTypes.snap, accounts: [mockAccount3.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', - }, - { - id: 'mock-id2', - name: 'mock-name2', + metadata: { + id: 'mock-id2', + name: 'mock-name2', + }, }, ], }; @@ -1032,12 +1042,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1064,7 +1072,7 @@ describe('AccountsController', () => { expect(accounts).toStrictEqual([ mockAccount, - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ]); expect(accountsController.getSelectedAccount().id).toBe(mockAccount.id); }); @@ -1093,12 +1101,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1115,7 +1121,7 @@ describe('AccountsController', () => { // 2. AccountsController:stateChange 3, 'AccountsController:accountAdded', - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ); }); }); @@ -1132,12 +1138,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1179,12 +1183,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1245,12 +1247,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1324,12 +1324,10 @@ describe('AccountsController', () => { mockAccountWithoutLastSelected.address, mockAccount2.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1400,12 +1398,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address, mockAccount2.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1439,6 +1435,9 @@ describe('AccountsController', () => { name: 'Account 1', address: '0x456', keyringType: KeyringTypes.hd, + options: { + entropySource: 'mock-id', + }, }); mockUUIDWithNormalAccounts([ @@ -1452,12 +1451,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockReinitialisedAccount.address], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1554,12 +1551,10 @@ describe('AccountsController', () => { mockExistingAccount1.address, mockExistingAccount2.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1799,12 +1794,13 @@ describe('AccountsController', () => { 'KeyringController:getState', mockGetState.mockReturnValue({ keyrings: [ - { type: KeyringTypes.hd, accounts: [mockAddress1, mockAddress2] }, - ], - keyringsMetadata: [ { - id: 'mock-keyring-id-0', - name: 'mock-keyring-id-name', + type: KeyringTypes.hd, + accounts: [mockAddress1, mockAddress2], + metadata: { + id: 'mock-keyring-id-0', + name: 'mock-keyring-id-name', + }, }, ], }), @@ -1863,12 +1859,10 @@ describe('AccountsController', () => { { type: KeyringTypes.snap, accounts: [mockSnapAccount, mockSnapAccount2], - }, - ], - keyringsMetadata: [ - { - id: 'mock-keyring-id-1', - name: 'mock-keyring-id-name', + metadata: { + id: 'mock-keyring-id-1', + name: 'mock-keyring-id-name', + }, }, ], }), @@ -1962,12 +1956,13 @@ describe('AccountsController', () => { 'KeyringController:getState', mockGetState.mockReturnValue({ keyrings: [ - { type: KeyringTypes.hd, accounts: [mockAddress1, mockAddress2] }, - ], - keyringsMetadata: [ { - id: 'mock-keyring-id-0', - name: 'mock-keyring-id-name', + type: KeyringTypes.hd, + accounts: [mockAddress1, mockAddress2], + metadata: { + id: 'mock-keyring-id-0', + name: 'mock-keyring-id-name', + }, }, ], }), @@ -2035,17 +2030,21 @@ describe('AccountsController', () => { 'KeyringController:getState', mockGetState.mockReturnValue({ keyrings: [ - { type: KeyringTypes.hd, accounts: [mockAddress1] }, - { type: KeyringTypes.snap, accounts: ['0x1234'] }, - ], - keyringsMetadata: [ { - id: 'mock-keyring-id-0', - name: 'mock-keyring-id-name', + type: KeyringTypes.hd, + accounts: [mockAddress1], + metadata: { + id: 'mock-keyring-id-0', + name: 'mock-keyring-id-name', + }, }, { - id: 'mock-keyring-id-1', - name: 'mock-keyring-id-name2', + type: KeyringTypes.snap, + accounts: ['0x1234'], + metadata: { + id: 'mock-keyring-id-1', + name: 'mock-keyring-id-name2', + }, }, ], }), @@ -2103,17 +2102,21 @@ describe('AccountsController', () => { 'KeyringController:getState', mockGetState.mockReturnValue({ keyrings: [ - { type: KeyringTypes.snap, accounts: ['0x1234'] }, - { type: KeyringTypes.hd, accounts: [mockAddress1] }, - ], - keyringsMetadata: [ { - id: 'mock-keyring-id-0', - name: 'mock-keyring-id-name', + type: KeyringTypes.snap, + accounts: ['0x1234'], + metadata: { + id: 'mock-keyring-id-0', + name: 'mock-keyring-id-name', + }, }, { - id: 'mock-keyring-id-1', - name: 'mock-keyring-id-name2', + type: KeyringTypes.hd, + accounts: [mockAddress1], + metadata: { + id: 'mock-keyring-id-1', + name: 'mock-keyring-id-name2', + }, }, ], }), @@ -2168,11 +2171,14 @@ describe('AccountsController', () => { messenger.registerActionHandler( 'KeyringController:getState', mockGetState.mockReturnValue({ - keyrings: [{ type: keyringType, accounts: [mockAddress1] }], - keyringsMetadata: [ + keyrings: [ { - id: 'mock-keyring-id-0', - name: 'mock-keyring-id-name', + type: keyringType, + accounts: [mockAddress1], + metadata: { + id: 'mock-keyring-id-0', + name: 'mock-keyring-id-name', + }, }, ], }), @@ -2312,17 +2318,21 @@ describe('AccountsController', () => { 'KeyringController:getState', mockGetState.mockReturnValue({ keyrings: [ - { type: KeyringTypes.snap, accounts: ['0x1234'] }, - { type: KeyringTypes.hd, accounts: [mockAddress1] }, - ], - keyringsMetadata: [ { - id: 'mock-keyring-id-1', - name: 'mock-keyring-id-name', + type: KeyringTypes.snap, + accounts: ['0x1234'], + metadata: { + id: 'mock-keyring-id-1', + name: 'mock-keyring-id-name', + }, }, { - id: 'mock-keyring-id-2', - name: 'mock-keyring-id-name2', + type: KeyringTypes.hd, + accounts: [mockAddress1], + metadata: { + id: 'mock-keyring-id-2', + name: 'mock-keyring-id-name2', + }, }, ], }), @@ -3076,20 +3086,18 @@ describe('AccountsController', () => { { type: 'HD Key Tree', accounts: [mockAccount.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: 'Simple Key Pair', accounts: simpleAddressess, - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', - }, - { - id: 'mock-id2', - name: 'mock-name2', + metadata: { + id: 'mock-id2', + name: 'mock-name2', + }, }, ], }; diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 1abb03a64c9..770fe5509aa 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -684,11 +684,11 @@ export class AccountsController extends BaseController< */ async #listNormalAccounts(): Promise { const internalAccounts: InternalAccount[] = []; - const { keyrings, keyringsMetadata } = this.messagingSystem.call( + const { keyrings } = this.messagingSystem.call( 'KeyringController:getState', ); - for (const [keyringIndex, keyring] of keyrings.entries()) { + for (const keyring of keyrings) { const keyringType = keyring.type; if (!isNormalKeyringType(keyringType as KeyringTypes)) { // We only consider "normal accounts" here, so keep looping @@ -702,7 +702,7 @@ export class AccountsController extends BaseController< if (isHdKeyringType(keyring.type as KeyringTypes)) { options = { - entropySource: keyringsMetadata[keyringIndex].id, + entropySource: keyring.metadata.id, // NOTE: We are not using the `hdPath` from the associated keyring here and // getting the keyring instance here feels a bit overkill. // This will be naturally fixed once every keyring start using `KeyringAccount` and implement the keyring API. @@ -791,6 +791,7 @@ export class AccountsController extends BaseController< added: [] as { address: string; type: string; + options: InternalAccount['options']; }[], updated: [] as InternalAccount[], removed: [] as InternalAccount[], @@ -836,6 +837,11 @@ export class AccountsController extends BaseController< patch.added.push({ address, type: keyring.type, + // Automatically injects `entropySource` for HD accounts only. + options: + keyring.type === KeyringTypes.hd + ? { entropySource: keyring.metadata.id } + : {}, }); } @@ -902,6 +908,10 @@ export class AccountsController extends BaseController< importTime: Date.now(), lastSelected, }, + options: { + ...account.options, + ...added.options, + }, }; diff.added.push(internalAccounts.accounts[account.id]); diff --git a/packages/address-book-controller/CHANGELOG.md b/packages/address-book-controller/CHANGELOG.md index 9fc7ccc604c..016262f78b4 100644 --- a/packages/address-book-controller/CHANGELOG.md +++ b/packages/address-book-controller/CHANGELOG.md @@ -7,10 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add contact event system ([#5779](https://github.com/MetaMask/core/pull/5779)) + - Add `AddressBookControllerContactUpdatedEvent` and `AddressBookControllerContactDeletedEvent` types for contact events + - Add `list` method on `AddressBookController` to get all address book entries as an array + - Register message handlers for `list`, `set`, and `delete` actions + - Add `lastUpdatedAt` property to `AddressBookEntry` to track when contacts were last modified + ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Fix `delete` method to clean up empty chainId objects when the last address in a chain is deleted ([#5779](https://github.com/MetaMask/core/pull/5779)) ## [6.0.3] diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index 338008b4349..53b25237e3f 100644 --- a/packages/address-book-controller/package.json +++ b/packages/address-book-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0" }, "devDependencies": { diff --git a/packages/address-book-controller/src/AddressBookController.test.ts b/packages/address-book-controller/src/AddressBookController.test.ts index f2fa616652a..060948a59cb 100644 --- a/packages/address-book-controller/src/AddressBookController.test.ts +++ b/packages/address-book-controller/src/AddressBookController.test.ts @@ -1,9 +1,12 @@ import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; import type { AddressBookControllerActions, AddressBookControllerEvents, + AddressBookControllerContactUpdatedEvent, + AddressBookControllerContactDeletedEvent, } from './AddressBookController'; import { AddressBookController, @@ -12,34 +15,69 @@ import { } from './AddressBookController'; /** - * Constructs a restricted controller messenger. + * Helper function to create test fixtures * - * @returns A restricted controller messenger. + * @returns Test fixtures including messenger, controller, and event listeners */ -function getRestrictedMessenger() { +function arrangeMocks() { const messenger = new Messenger< AddressBookControllerActions, AddressBookControllerEvents >(); - return messenger.getRestricted({ + const restrictedMessenger = messenger.getRestricted({ name: controllerName, allowedActions: [], allowedEvents: [], }); + const controller = new AddressBookController({ + messenger: restrictedMessenger, + }); + + // Set up mock event listeners + const contactUpdatedListener = jest.fn(); + const contactDeletedListener = jest.fn(); + + // Subscribe to events + messenger.subscribe( + 'AddressBookController:contactUpdated' as AddressBookControllerContactUpdatedEvent['type'], + contactUpdatedListener, + ); + messenger.subscribe( + 'AddressBookController:contactDeleted' as AddressBookControllerContactDeletedEvent['type'], + contactDeletedListener, + ); + + return { + controller, + contactUpdatedListener, + contactDeletedListener, + }; } describe('AddressBookController', () => { - it('should set default state', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + // Mock Date.now to return a fixed value for tests + const originalDateNow = Date.now; + const MOCK_TIMESTAMP = 1000000000000; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => MOCK_TIMESTAMP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + Date.now = originalDateNow; + }); + + it('sets default state', () => { + const { controller } = arrangeMocks(); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should add a contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); expect(controller.state).toStrictEqual({ @@ -52,16 +90,15 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with chainId and memo', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with chainId and memo', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -80,16 +117,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.externallyOwnedAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with address type contract accounts', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with address type contract accounts', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -108,16 +144,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.contractAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with address type non accounts', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with address type non accounts', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -136,16 +171,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.nonAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add multiple contact entries with different chainIds', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds multiple contact entries with different chainIds', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -170,6 +204,7 @@ describe('AddressBookController', () => { memo: 'account 2', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, [toHex(2)]: { @@ -180,16 +215,15 @@ describe('AddressBookController', () => { memo: 'account 2', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should update a contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('updates a contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'bar'); @@ -204,35 +238,30 @@ describe('AddressBookController', () => { memo: '', name: 'bar', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should not add invalid contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('does not add invalid contact entry', () => { + const { controller } = arrangeMocks(); // @ts-expect-error Intentionally invalid entry controller.set('0x01', 'foo', AddressType.externallyOwnedAccounts); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should remove one contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('removes one contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.delete(toHex(1), '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should remove only one contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('removes only one contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -248,16 +277,15 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add two contact entries with the same chainId', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds two contact entries with the same chainId', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -272,6 +300,7 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D': { address: '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D', @@ -280,16 +309,15 @@ describe('AddressBookController', () => { memo: '', name: 'bar', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should correctly mark ens entries', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('marks correctly ens entries', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'metamask.eth', @@ -305,16 +333,15 @@ describe('AddressBookController', () => { memo: '', name: 'metamask.eth', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should clear all contact entries', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('clears all contact entries', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -322,29 +349,23 @@ describe('AddressBookController', () => { expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should return true to indicate an address book entry has been added', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns true to indicate an address book entry has been added', () => { + const { controller } = arrangeMocks(); expect( controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'), ).toBe(true); }); - it('should return false to indicate an address book entry has NOT been added', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been added', () => { + const { controller } = arrangeMocks(); expect( // @ts-expect-error Intentionally invalid entry controller.set('0x00', 'foo', AddressType.externallyOwnedAccounts), ).toBe(false); }); - it('should return true to indicate an address book entry has been deleted', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns true to indicate an address book entry has been deleted', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); expect( @@ -352,27 +373,21 @@ describe('AddressBookController', () => { ).toBe(true); }); - it('should return false to indicate an address book entry has NOT been deleted due to unsafe input', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been deleted due to unsafe input', () => { + const { controller } = arrangeMocks(); // @ts-expect-error Suppressing error to test runtime behavior expect(controller.delete('__proto__', '0x01')).toBe(false); expect(controller.delete(toHex(1), 'constructor')).toBe(false); }); - it('should return false to indicate an address book entry has NOT been deleted', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been deleted', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', '0x00'); expect(controller.delete(toHex(1), '0x01')).toBe(false); }); - it('should normalize addresses so adding and removing entries work across casings', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('normalizes addresses so adding and removing entries work across casings', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -388,9 +403,219 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); + + it('emits contactUpdated event when adding a contact', () => { + const { controller, contactUpdatedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + + expect(contactUpdatedListener).toHaveBeenCalledTimes(1); + expect(contactUpdatedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'foo', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('emits contactUpdated event when updating a contact', () => { + const { controller, contactUpdatedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + + // Clear the mock to reset call count since the first set also triggers the event + contactUpdatedListener.mockClear(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'bar'); + + expect(contactUpdatedListener).toHaveBeenCalledTimes(1); + expect(contactUpdatedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'bar', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('emits contactDeleted event when deleting a contact', () => { + const { controller, contactDeletedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + controller.delete(toHex(1), '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'); + + expect(contactDeletedListener).toHaveBeenCalledTimes(1); + expect(contactDeletedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'foo', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('does not emit events for contacts with chainId "*" (wallet accounts)', () => { + const { controller, contactUpdatedListener, contactDeletedListener } = + arrangeMocks(); + + // Add with chainId "*" + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'foo', + '*' as unknown as Hex, + ); + expect(contactUpdatedListener).not.toHaveBeenCalled(); + + // Update with chainId "*" + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'bar', + '*' as unknown as Hex, + ); + expect(contactUpdatedListener).not.toHaveBeenCalled(); + + // Delete with chainId "*" + controller.delete( + '*' as unknown as Hex, + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + ); + expect(contactDeletedListener).not.toHaveBeenCalled(); + }); + + it('lists all contacts', () => { + const { controller } = arrangeMocks(); + + // Add multiple contacts to chain 1 + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'Alice', + toHex(1), + 'First contact', + ); + controller.set( + '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', + 'Bob', + toHex(1), + 'Second contact', + ); + controller.set( + '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + 'Charlie', + toHex(1), + ); + + // Add multiple contacts to chain 2 + controller.set( + '0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359', + 'David', + toHex(2), + 'Chain 2 contact', + ); + controller.set( + '0x4e83362442B8d1beC281594ceA3050c8EB01311C', + 'Eve', + toHex(2), + ); + + // Add contact to chain 137 (Polygon) + controller.set( + '0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB', + 'Frank', + toHex(137), + 'Polygon contact', + ); + + const contacts = controller.list(); + + // Should have all 6 contacts + expect(contacts).toHaveLength(6); + + // Verify chain 1 contacts + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + name: 'Alice', + memo: 'First contact', + }), + ); + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D', + chainId: toHex(1), + name: 'Bob', + memo: 'Second contact', + }), + ); + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + chainId: toHex(1), + name: 'Charlie', + memo: '', + }), + ); + + // Verify chain 2 contacts + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359', + chainId: toHex(2), + name: 'David', + memo: 'Chain 2 contact', + }), + ); + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0x4E83362442B8d1beC281594cEa3050c8EB01311C', + chainId: toHex(2), + name: 'Eve', + memo: '', + }), + ); + + // Verify chain 137 contact + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB', + chainId: toHex(137), + name: 'Frank', + memo: 'Polygon contact', + }), + ); + + // Verify that contacts from different chains are all included + const chainIds = contacts.map((contact) => contact.chainId); + expect(chainIds).toContain(toHex(1)); + expect(chainIds).toContain(toHex(2)); + expect(chainIds).toContain(toHex(137)); + + // Verify we have the expected number of contacts per chain + const chain1Contacts = contacts.filter( + (contact) => contact.chainId === toHex(1), + ); + const chain2Contacts = contacts.filter( + (contact) => contact.chainId === toHex(2), + ); + const chain137Contacts = contacts.filter( + (contact) => contact.chainId === toHex(137), + ); + + expect(chain1Contacts).toHaveLength(3); + expect(chain2Contacts).toHaveLength(2); + expect(chain137Contacts).toHaveLength(1); + }); }); diff --git a/packages/address-book-controller/src/AddressBookController.ts b/packages/address-book-controller/src/AddressBookController.ts index cf1f1239d7b..b7637b22049 100644 --- a/packages/address-book-controller/src/AddressBookController.ts +++ b/packages/address-book-controller/src/AddressBookController.ts @@ -14,16 +14,14 @@ import { import type { Hex } from '@metamask/utils'; /** - * @type ContactEntry - * * ContactEntry representation - * @property address - Hex address of a recipient account - * @property name - Nickname associated with this address - * @property importTime - Data time when an account as created/imported */ export type ContactEntry = { + /** Hex address of a recipient account */ address: string; + /** Nickname associated with this address */ name: string; + /** Data time when an account as created/imported */ importTime?: number; }; @@ -31,44 +29,36 @@ export type ContactEntry = { * The type of address. */ export enum AddressType { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention externallyOwnedAccounts = 'EXTERNALLY_OWNED_ACCOUNTS', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention contractAccounts = 'CONTRACT_ACCOUNTS', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention nonAccounts = 'NON_ACCOUNTS', } /** - * @type AddressBookEntry - * - * AddressBookEntry representation - * @property address - Hex address of a recipient account - * @property name - Nickname associated with this address - * @property chainId - Chain id identifies the current chain - * @property memo - User's note about address - * @property isEns - is the entry an ENS name - * @property addressType - is the type of this address + * AddressBookEntry represents a contact in the address book. */ export type AddressBookEntry = { + /** Hex address of a recipient account */ address: string; + /** Nickname associated with this address */ name: string; + /** Chain id identifies the current chain */ chainId: Hex; + /** User's note about address */ memo: string; + /** Indicates if the entry is an ENS name */ isEns: boolean; + /** The type of this address */ addressType?: AddressType; + /** Timestamp of when this entry was last updated */ + lastUpdatedAt?: number; }; /** - * @type AddressBookState - * - * Address book controller state - * @property addressBook - Array of contact entry objects + * State for the AddressBookController. */ export type AddressBookControllerState = { + /** Map of chainId to address to contact entries */ addressBook: { [chainId: Hex]: { [address: string]: AddressBookEntry } }; }; @@ -77,6 +67,12 @@ export type AddressBookControllerState = { */ export const controllerName = 'AddressBookController'; +/** + * Special chainId used for wallet's own accounts (internal MetaMask accounts). + * These entries don't trigger sync events as they are not user-created contacts. + */ +const WALLET_ACCOUNTS_CHAIN_ID = '*'; + /** * The action that can be performed to get the state of the {@link AddressBookController}. */ @@ -85,10 +81,54 @@ export type AddressBookControllerGetStateAction = ControllerGetStateAction< AddressBookControllerState >; +/** + * The action that can be performed to list contacts from the {@link AddressBookController}. + */ +export type AddressBookControllerListAction = { + type: `${typeof controllerName}:list`; + handler: AddressBookController['list']; +}; + +/** + * The action that can be performed to set a contact in the {@link AddressBookController}. + */ +export type AddressBookControllerSetAction = { + type: `${typeof controllerName}:set`; + handler: AddressBookController['set']; +}; + +/** + * The action that can be performed to delete a contact from the {@link AddressBookController}. + */ +export type AddressBookControllerDeleteAction = { + type: `${typeof controllerName}:delete`; + handler: AddressBookController['delete']; +}; + +/** + * Event emitted when a contact is added or updated + */ +export type AddressBookControllerContactUpdatedEvent = { + type: `${typeof controllerName}:contactUpdated`; + payload: [AddressBookEntry]; +}; + +/** + * Event emitted when a contact is deleted + */ +export type AddressBookControllerContactDeletedEvent = { + type: `${typeof controllerName}:contactDeleted`; + payload: [AddressBookEntry]; +}; + /** * The actions that can be performed using the {@link AddressBookController}. */ -export type AddressBookControllerActions = AddressBookControllerGetStateAction; +export type AddressBookControllerActions = + | AddressBookControllerGetStateAction + | AddressBookControllerListAction + | AddressBookControllerSetAction + | AddressBookControllerDeleteAction; /** * The event that {@link AddressBookController} can emit. @@ -101,7 +141,10 @@ export type AddressBookControllerStateChangeEvent = ControllerStateChangeEvent< /** * The events that {@link AddressBookController} can emit. */ -export type AddressBookControllerEvents = AddressBookControllerStateChangeEvent; +export type AddressBookControllerEvents = + | AddressBookControllerStateChangeEvent + | AddressBookControllerContactUpdatedEvent + | AddressBookControllerContactDeletedEvent; const addressBookControllerMetadata = { addressBook: { persist: true, anonymous: false }, @@ -159,6 +202,27 @@ export class AddressBookController extends BaseController< name: controllerName, state: mergedState, }); + + this.#registerMessageHandlers(); + } + + /** + * Returns all address book entries as an array. + * + * @returns Array of all address book entries. + */ + list(): AddressBookEntry[] { + const { addressBook } = this.state; + + return Object.keys(addressBook).reduce( + (acc, chainId) => { + const chainIdHex = chainId as Hex; + const chainContacts = Object.values(addressBook[chainIdHex]); + + return [...acc, ...chainContacts]; + }, + [], + ); } /** @@ -188,13 +252,30 @@ export class AddressBookController extends BaseController< return false; } + const deletedEntry = { ...this.state.addressBook[chainId][address] }; + this.update((state) => { - delete state.addressBook[chainId][address]; - if (Object.keys(state.addressBook[chainId]).length === 0) { - delete state.addressBook[chainId]; + const chainContacts = state.addressBook[chainId]; + if (chainContacts?.[address]) { + delete chainContacts[address]; + + // Clean up empty chainId objects + if (Object.keys(chainContacts).length === 0) { + delete state.addressBook[chainId]; + } } }); + // Skip sending delete event for global contacts with chainId '*' + // These entries with chainId='*' are the wallet's own accounts (internal MetaMask accounts), + // not user-created contacts. They don't need to trigger sync events. + if (String(chainId) !== WALLET_ACCOUNTS_CHAIN_ID) { + this.messagingSystem.publish( + 'AddressBookController:contactDeleted', + deletedEntry, + ); + } + return true; } @@ -227,8 +308,8 @@ export class AddressBookController extends BaseController< memo, name, addressType, + lastUpdatedAt: Date.now(), }; - const ensName = normalizeEnsName(name); if (ensName) { entry.name = ensName; @@ -245,8 +326,36 @@ export class AddressBookController extends BaseController< }; }); + // Skip sending update event for global contacts with chainId '*' + // These entries with chainId='*' are the wallet's own accounts (internal MetaMask accounts), + // not user-created contacts. They don't need to trigger sync events. + if (String(chainId) !== WALLET_ACCOUNTS_CHAIN_ID) { + this.messagingSystem.publish( + 'AddressBookController:contactUpdated', + entry, + ); + } + return true; } + + /** + * Registers message handlers for the AddressBookController. + */ + #registerMessageHandlers() { + this.messagingSystem.registerActionHandler( + `${controllerName}:list`, + this.list.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${controllerName}:set`, + this.set.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${controllerName}:delete`, + this.delete.bind(this), + ); + } } export default AddressBookController; diff --git a/packages/address-book-controller/src/index.ts b/packages/address-book-controller/src/index.ts index 85ae3c72bd2..7df7d5fe576 100644 --- a/packages/address-book-controller/src/index.ts +++ b/packages/address-book-controller/src/index.ts @@ -3,8 +3,13 @@ export type { AddressBookEntry, AddressBookControllerState, AddressBookControllerGetStateAction, + AddressBookControllerListAction, + AddressBookControllerSetAction, + AddressBookControllerDeleteAction, AddressBookControllerActions, AddressBookControllerStateChangeEvent, + AddressBookControllerContactUpdatedEvent, + AddressBookControllerContactDeletedEvent, AddressBookControllerEvents, AddressBookControllerMessenger, ContactEntry, diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f88d9b503a7..ce291cda394 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add phishing protection for NFT metadata URLs in `NftController` ([#5598](https://github.com/MetaMask/core/pull/5598)) + - NFT metadata URLs are now scanned for malicious content using the `PhishingController` + - Malicious URLs in NFT metadata fields (image, externalLink, etc.) are automatically sanitized + +### Changed + +- **BREAKING:** Add peer dependency on `@metamask/phishing-controller` ^12.5.0 ([#5598](https://github.com/MetaMask/core/pull/5598)) + +## [65.0.0] + +### Added + +- **BREAKING:** Add event listener for `TransactionController:transactionConfirmed` on `TokenDetectionController` to trigger token detection ([#5859](https://github.com/MetaMask/core/pull/5859)) + +### Changed + +- **BREAKING:** Add event listener for `KeyringController:accountRemoved` instead of `AccountsController:accountRemoved` in `TokenBalancesController` and `TokensController` ([#5859](https://github.com/MetaMask/core/pull/5859)) + +## [64.0.0] + +### Added + +- **BREAKING:** Add event listener for `AccountsController:accountRemoved` on `TokenBalancesController` to remove token balances for the removed account ([#5726](https://github.com/MetaMask/core/pull/5726)) + +- **BREAKING:** Add event listener for `AccountsController:accountRemoved` on `TokensController` to remove tokens for the removed account ([#5726](https://github.com/MetaMask/core/pull/5726)) + +- **BREAKING:** Add `listAccounts` action to `TokensController` ([#5726](https://github.com/MetaMask/core/pull/5726)) + +- **BREAKING:** Add `listAccounts` action to `TokenBalancesController` ([#5726](https://github.com/MetaMask/core/pull/5726)) + +### Changed + +- TokenBalancesController will now check if balances has changed before updating the state ([#5726](https://github.com/MetaMask/core/pull/5726)) + +## [63.1.0] + +### Changed + +- Added optional `account` parameter to `fetchHistoricalPricesForAsset` method in `MultichainAssetsRatesController` ([#5833](https://github.com/MetaMask/core/pull/5833)) +- Updated `TokenListController` `fetchTokenList` method to bail if cache is valid ([#5804](https://github.com/MetaMask/core/pull/5804)) + - also cleaned up internal state update logic +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [63.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/preferences-controller` peer dependency to `^18.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + ## [62.0.0] ### Added @@ -1620,7 +1674,11 @@ 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@62.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@65.0.0...HEAD +[65.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@64.0.0...@metamask/assets-controllers@65.0.0 +[64.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.1.0...@metamask/assets-controllers@64.0.0 +[63.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.0.0...@metamask/assets-controllers@63.1.0 +[63.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@62.0.0...@metamask/assets-controllers@63.0.0 [62.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@61.1.0...@metamask/assets-controllers@62.0.0 [61.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@61.0.0...@metamask/assets-controllers@61.1.0 [61.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@60.0.0...@metamask/assets-controllers@61.0.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 66ee77859e9..7791feb7462 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "62.0.0", + "version": "65.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", @@ -56,7 +56,7 @@ "@metamask/abi-utils": "^2.0.3", "@metamask/base-controller": "^8.0.1", "@metamask/contract-metadata": "^2.4.0", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-query": "^4.0.0", "@metamask/keyring-api": "^17.4.0", "@metamask/metamask-eth-abis": "^3.1.1", @@ -77,20 +77,21 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@metamask/keyring-internal-api": "^6.0.1", "@metamask/keyring-snap-client": "^4.1.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", - "@metamask/preferences-controller": "^17.0.0", + "@metamask/phishing-controller": "^12.5.0", + "@metamask/preferences-controller": "^18.1.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/snaps-sdk": "^6.22.0", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", @@ -106,15 +107,16 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.0.0", - "@metamask/keyring-controller": "^21.0.0", + "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/permission-controller": "^11.0.0", - "@metamask/preferences-controller": "^17.0.0", + "@metamask/phishing-controller": "^12.5.0", + "@metamask/preferences-controller": "^18.0.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.0.0", - "@metamask/transaction-controller": "^55.0.0", + "@metamask/transaction-controller": "^56.0.0", "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts index 356b4bd4e20..937d4626697 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts @@ -1,5 +1,6 @@ import { BtcAccountType } from '@metamask/keyring-api'; +import * as calculateDefiMetrics from './calculate-defi-metrics'; import type { DeFiPositionsControllerMessenger } from './DeFiPositionsController'; import { DeFiPositionsController, @@ -44,18 +45,24 @@ type MainMessenger = Messenger< * * @param config - Configuration for the mock setup * @param config.isEnabled - Whether the controller is enabled + * @param config.mockTrackEvent - The mock track event function * @param config.mockFetchPositions - The mock fetch positions function * @param config.mockGroupDeFiPositions - The mock group positions function + * @param config.mockCalculateDefiMetrics - The mock calculate metrics function * @returns The controller instance, trigger functions, and spies */ function setupController({ isEnabled, + mockTrackEvent, mockFetchPositions = jest.fn(), mockGroupDeFiPositions = jest.fn(), + mockCalculateDefiMetrics = jest.fn(), }: { isEnabled?: () => boolean; mockFetchPositions?: jest.Mock; mockGroupDeFiPositions?: jest.Mock; + mockCalculateDefiMetrics?: jest.Mock; + mockTrackEvent?: jest.Mock; } = {}) { const messenger: MainMessenger = new Messenger(); @@ -88,11 +95,18 @@ function setupController({ 'groupDeFiPositions', ); + const calculateDefiMetricsSpy = jest.spyOn( + calculateDefiMetrics, + 'calculateDeFiPositionMetrics', + ); + calculateDefiMetricsSpy.mockImplementation(mockCalculateDefiMetrics); + groupDeFiPositionsSpy.mockImplementation(mockGroupDeFiPositions); const controller = new DeFiPositionsController({ messenger: restrictedMessenger, isEnabled, + trackEvent: mockTrackEvent, }); const updateSpy = jest.spyOn(controller, 'update' as never); @@ -130,6 +144,8 @@ function setupController({ updateSpy, mockFetchPositions, mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, }; } @@ -199,6 +215,7 @@ describe('DeFiPositionsController', () => { [OWNER_ACCOUNTS[0].address]: 'mock-grouped-data-1', [OWNER_ACCOUNTS[1].address]: null, }, + allDeFiPositionsCount: {}, }); expect(buildPositionsFetcherSpy).toHaveBeenCalled(); @@ -262,6 +279,7 @@ describe('DeFiPositionsController', () => { allDeFiPositions: { [OWNER_ACCOUNTS[0].address]: 'mock-grouped-data-1', }, + allDeFiPositionsCount: {}, }); expect(buildPositionsFetcherSpy).toHaveBeenCalled(); @@ -330,6 +348,7 @@ describe('DeFiPositionsController', () => { allDeFiPositions: { [newAccountAddress]: 'mock-grouped-data-1', }, + allDeFiPositionsCount: {}, }); expect(buildPositionsFetcherSpy).toHaveBeenCalled(); @@ -373,4 +392,114 @@ describe('DeFiPositionsController', () => { expect(updateSpy).not.toHaveBeenCalled(); }); + + it('updates defi count and calls metrics', async () => { + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const mockTrackEvent = jest.fn(); + + const mockMetric1 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 1, + totalMarketValueUSD: 1, + }, + }; + + const mockMetric2 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 2, + totalMarketValueUSD: 2, + }, + }; + + const mockCalculateDefiMetrics = jest + .fn() + .mockReturnValueOnce(mockMetric1) + .mockReturnValueOnce(mockMetric2); + + const { controller } = setupController({ + mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, + }); + + await controller._executePoll(); + + expect(mockCalculateDefiMetrics).toHaveBeenCalled(); + expect(mockCalculateDefiMetrics).toHaveBeenCalledWith( + controller.state.allDeFiPositions[OWNER_ACCOUNTS[0].address], + ); + + expect(controller.state.allDeFiPositionsCount).toStrictEqual({ + [OWNER_ACCOUNTS[0].address]: mockMetric1.properties.totalPositions, + [OWNER_ACCOUNTS[1].address]: mockMetric2.properties.totalPositions, + }); + + expect(mockTrackEvent).toHaveBeenNthCalledWith(1, mockMetric1); + expect(mockTrackEvent).toHaveBeenNthCalledWith(2, mockMetric2); + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + expect(mockTrackEvent).toHaveBeenNthCalledWith(1, mockMetric1); + expect(mockTrackEvent).toHaveBeenNthCalledWith(2, mockMetric2); + }); + + it('only calls track metric when position count changes', async () => { + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + const mockTrackEvent = jest.fn(); + + const mockMetric1 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 1, + totalMarketValueUSD: 1, + }, + }; + + const mockMetric2 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 2, + totalMarketValueUSD: 2, + }, + }; + + const mockCalculateDefiMetrics = jest + .fn() + .mockReturnValueOnce(mockMetric1) + .mockReturnValueOnce(mockMetric2) + .mockReturnValueOnce(mockMetric2); + + const { controller, triggerTransactionConfirmed } = setupController({ + mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, + }); + + triggerTransactionConfirmed(OWNER_ACCOUNTS[0].address); + triggerTransactionConfirmed(OWNER_ACCOUNTS[0].address); + triggerTransactionConfirmed(OWNER_ACCOUNTS[0].address); + await flushPromises(); + + expect(mockCalculateDefiMetrics).toHaveBeenCalled(); + expect(mockCalculateDefiMetrics).toHaveBeenCalledWith( + controller.state.allDeFiPositions[OWNER_ACCOUNTS[0].address], + ); + + expect(controller.state.allDeFiPositionsCount).toStrictEqual({ + [OWNER_ACCOUNTS[0].address]: mockMetric2.properties.totalPositions, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + expect(mockTrackEvent).toHaveBeenNthCalledWith(1, mockMetric1); + expect(mockTrackEvent).toHaveBeenNthCalledWith(2, mockMetric2); + }); }); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts index c9c11f499c7..fb4c4280590 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts @@ -14,6 +14,7 @@ import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { calculateDeFiPositionMetrics } from './calculate-defi-metrics'; import type { DefiPositionResponse } from './fetch-positions'; import { buildPositionFetcher } from './fetch-positions'; import { @@ -28,10 +29,27 @@ const FETCH_POSITIONS_BATCH_SIZE = 10; const controllerName = 'DeFiPositionsController'; -type GroupedDeFiPositionsPerChain = { +export type GroupedDeFiPositionsPerChain = { [chain: Hex]: GroupedDeFiPositions; }; +export type TrackingEventPayload = { + event: string; + category: string; + properties: { + totalPositions: number; + totalMarketValueUSD: number; + breakdown?: { + protocolId: string; + marketValueUSD: number; + chainId: Hex; + count: number; + }[]; + }; +}; + +type TrackEventHook = (event: TrackingEventPayload) => void; + export type DeFiPositionsControllerState = { /** * Object containing DeFi positions per account and network @@ -39,6 +57,13 @@ export type DeFiPositionsControllerState = { allDeFiPositions: { [accountAddress: string]: GroupedDeFiPositionsPerChain | null; }; + + /** + * Object containing DeFi positions count per account + */ + allDeFiPositionsCount: { + [accountAddress: string]: number; + }; }; const controllerMetadata: StateMetadata = { @@ -46,12 +71,17 @@ const controllerMetadata: StateMetadata = { persist: false, anonymous: false, }, + allDeFiPositionsCount: { + persist: false, + anonymous: false, + }, }; export const getDefaultDefiPositionsControllerState = (): DeFiPositionsControllerState => { return { allDeFiPositions: {}, + allDeFiPositionsCount: {}, }; }; @@ -111,19 +141,24 @@ export class DeFiPositionsController extends StaticIntervalPollingController()< readonly #isEnabled: () => boolean; + readonly #trackEvent?: TrackEventHook; + /** * DeFiPositionsController constuctor * * @param options - Constructor options. * @param options.messenger - The controller messenger. * @param options.isEnabled - Function that returns whether the controller is enabled. (default: () => true) + * @param options.trackEvent - Function to track events. (default: undefined) */ constructor({ messenger, isEnabled = () => true, + trackEvent, }: { messenger: DeFiPositionsControllerMessenger; isEnabled?: () => boolean; + trackEvent?: TrackEventHook; }) { super({ name: controllerName, @@ -166,6 +201,8 @@ export class DeFiPositionsController extends StaticIntervalPollingController()< await this.#updateAccountPositions(account.address); }, ); + + this.#trackEvent = trackEvent; } async _executePoll(): Promise { @@ -240,9 +277,41 @@ export class DeFiPositionsController extends StaticIntervalPollingController()< try { const defiPositionsResponse = await this.#fetchPositions(accountAddress); - return groupDeFiPositions(defiPositionsResponse); + const groupedDeFiPositions = groupDeFiPositions(defiPositionsResponse); + + try { + this.#updatePositionsCountMetrics(groupedDeFiPositions, accountAddress); + } catch (error) { + console.error( + `Failed to update positions count for account ${accountAddress}:`, + error, + ); + } + + return groupedDeFiPositions; } catch { return null; } } + + #updatePositionsCountMetrics( + groupedDeFiPositions: GroupedDeFiPositionsPerChain, + accountAddress: string, + ) { + // If no track event passed then skip the metrics update + if (!this.#trackEvent) { + return; + } + + const defiMetrics = calculateDeFiPositionMetrics(groupedDeFiPositions); + const { totalPositions } = defiMetrics.properties; + + if (totalPositions !== this.state.allDeFiPositionsCount[accountAddress]) { + this.update((state) => { + state.allDeFiPositionsCount[accountAddress] = totalPositions; + }); + + this.#trackEvent?.(defiMetrics); + } + } } diff --git a/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts new file mode 100644 index 00000000000..4daf1df0744 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts @@ -0,0 +1,305 @@ +import type { Hex } from '@metamask/utils'; + +import type { GroupedDeFiPositions } from '../group-defi-positions'; + +export const MOCK_EXPECTED_RESULT: { [key: Hex]: GroupedDeFiPositions } = { + '0x1': { + aggregatedMarketValue: 20540, + protocols: { + 'aave-v3': { + protocolDetails: { + name: 'Aave V3', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + }, + aggregatedMarketValue: 540, + positionTypes: { + supply: { + aggregatedMarketValue: 1540, + positions: [ + [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + marketValue: 40, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + marketValue: 40, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + marketValue: 1500, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + marketValue: 1500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + ], + }, + borrow: { + aggregatedMarketValue: 1000, + positions: [ + [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + marketValue: 1000, + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + marketValue: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + ], + }, + }, + }, + lido: { + protocolDetails: { + name: 'Lido', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + aggregatedMarketValue: 20000, + positionTypes: { + stake: { + aggregatedMarketValue: 20000, + positions: [ + [ + { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + balanceRaw: '800000000000000000000', + balance: 800, + marketValue: 20000, + type: 'protocol', + tokens: [ + { + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + name: 'Liquid staked Ether 2.0', + symbol: 'stETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + marketValue: 20000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, + '0x2105': { + aggregatedMarketValue: 9580, + protocols: { + 'uniswap-v3': { + protocolDetails: { + name: 'Uniswap V3', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + }, + aggregatedMarketValue: 9580, + positionTypes: { + supply: { + aggregatedMarketValue: 9580, + positions: [ + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940758', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + marketValue: 513, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '100000000000000000000', + type: 'underlying', + balance: 100, + price: 0.1, + marketValue: 10, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '10000000000000000000', + type: 'underlying-claimable', + balance: 10, + price: 0.1, + marketValue: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '500000000', + type: 'underlying', + balance: 500, + price: 1, + marketValue: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940760', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '2000000000000000000', + balance: 2, + marketValue: 9067, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '90000000000000000000000', + type: 'underlying', + balance: 90000, + price: 0.1, + marketValue: 9000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '50000000000000000000', + type: 'underlying-claimable', + balance: 50, + price: 0.1, + marketValue: 5, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '60000000', + type: 'underlying', + balance: 60, + price: 1, + marketValue: 60, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, +}; diff --git a/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts new file mode 100644 index 00000000000..807ca125a0a --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts @@ -0,0 +1,37 @@ +import { MOCK_EXPECTED_RESULT } from './__fixtures__/mock-result'; +import { calculateDeFiPositionMetrics } from './calculate-defi-metrics'; + +describe('groupDeFiPositions', () => { + it('verifies that the resulting object is valid', () => { + const result = calculateDeFiPositionMetrics(MOCK_EXPECTED_RESULT); + + expect(result).toStrictEqual({ + category: 'DeFi', + event: 'DeFi Stats', + properties: { + breakdown: [ + { + chainId: '0x1', + count: 3, + marketValueUSD: 540, + protocolId: 'aave-v3', + }, + { + chainId: '0x1', + count: 1, + marketValueUSD: 20000, + protocolId: 'lido', + }, + { + chainId: '0x2105', + count: 2, + marketValueUSD: 9580, + protocolId: 'uniswap-v3', + }, + ], + totalMarketValueUSD: 30120, + totalPositions: 6, + }, + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts new file mode 100644 index 00000000000..e01d854db99 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts @@ -0,0 +1,64 @@ +import type { Hex } from '@metamask/utils'; + +import type { + GroupedDeFiPositionsPerChain, + TrackingEventPayload, +} from './DeFiPositionsController'; + +/** + * Calculates the total market value and total positions for a given account + * and returns a breakdown of the market value per protocol. + * + * @param accountPositionsPerChain - The account positions per chain. + * @returns An object containing the total market value, total positions, and a breakdown of the market value per protocol. + */ +export function calculateDeFiPositionMetrics( + accountPositionsPerChain: GroupedDeFiPositionsPerChain, +): TrackingEventPayload { + let totalMarketValueUSD = 0; + let totalPositions = 0; + const breakdown: { + protocolId: string; + marketValueUSD: number; + chainId: Hex; + count: number; + }[] = []; + + Object.entries(accountPositionsPerChain).forEach( + ([chainId, chainPositions]) => { + const chainTotalMarketValueUSD = chainPositions.aggregatedMarketValue; + totalMarketValueUSD += chainTotalMarketValueUSD; + + Object.entries(chainPositions.protocols).forEach( + ([protocolId, protocol]) => { + const protocolTotalMarketValueUSD = protocol.aggregatedMarketValue; + + const protocolCount = Object.values(protocol.positionTypes).reduce( + (acc, positionType) => + acc + (positionType?.positions?.flat().length || 0), + + 0, + ); + + totalPositions += protocolCount; + + breakdown.push({ + protocolId, + marketValueUSD: protocolTotalMarketValueUSD, + chainId: chainId as Hex, + count: protocolCount, + }); + }, + ); + }, + ); + return { + category: 'DeFi', + event: 'DeFi Stats', + properties: { + totalMarketValueUSD, + totalPositions, + breakdown, + }, + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts index 262dcd916a6..ab3ade89e54 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts @@ -1,4 +1,3 @@ -import type { Hex } from '@metamask/utils'; import assert from 'assert'; import { @@ -8,7 +7,7 @@ import { MOCK_DEFI_RESPONSE_MULTI_CHAIN, MOCK_DEFI_RESPONSE_NO_PRICES, } from './__fixtures__/mock-responses'; -import type { GroupedDeFiPositions } from './group-defi-positions'; +import { MOCK_EXPECTED_RESULT } from './__fixtures__/mock-result'; import { groupDeFiPositions } from './group-defi-positions'; describe('groupDeFiPositions', () => { @@ -58,308 +57,6 @@ describe('groupDeFiPositions', () => { it('verifies that the resulting object is valid', () => { const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_COMPLEX); - const expectedResult: { [key: Hex]: GroupedDeFiPositions } = { - '0x1': { - aggregatedMarketValue: 20540, - protocols: { - 'aave-v3': { - protocolDetails: { - name: 'Aave V3', - iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', - }, - aggregatedMarketValue: 540, - positionTypes: { - supply: { - aggregatedMarketValue: 1540, - positions: [ - [ - { - address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', - name: 'Aave Ethereum WETH', - symbol: 'aEthWETH', - decimals: 18, - balanceRaw: '40000000000000000', - balance: 0.04, - marketValue: 40, - type: 'protocol', - tokens: [ - { - address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - name: 'Wrapped Ether', - symbol: 'WETH', - decimals: 18, - type: 'underlying', - balanceRaw: '40000000000000000', - balance: 0.04, - price: 1000, - marketValue: 40, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - }, - ], - }, - { - address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', - name: 'Aave Ethereum WBTC', - symbol: 'aEthWBTC', - decimals: 8, - balanceRaw: '300000000', - balance: 3, - marketValue: 1500, - type: 'protocol', - tokens: [ - { - address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', - name: 'Wrapped BTC', - symbol: 'WBTC', - decimals: 8, - type: 'underlying', - balanceRaw: '300000000', - balance: 3, - price: 500, - marketValue: 1500, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', - }, - ], - }, - ], - ], - }, - borrow: { - aggregatedMarketValue: 1000, - positions: [ - [ - { - address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', - name: 'Aave Ethereum Variable Debt USDT', - symbol: 'variableDebtEthUSDT', - decimals: 6, - balanceRaw: '1000000000', - marketValue: 1000, - type: 'protocol', - tokens: [ - { - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - type: 'underlying', - balanceRaw: '1000000000', - balance: 1000, - price: 1, - marketValue: 1000, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', - }, - ], - balance: 1000, - }, - ], - ], - }, - }, - }, - lido: { - protocolDetails: { - name: 'Lido', - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', - }, - aggregatedMarketValue: 20000, - positionTypes: { - stake: { - aggregatedMarketValue: 20000, - positions: [ - [ - { - address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', - name: 'Wrapped liquid staked Ether 2.0', - symbol: 'wstETH', - decimals: 18, - balanceRaw: '800000000000000000000', - balance: 800, - marketValue: 20000, - type: 'protocol', - tokens: [ - { - address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', - name: 'Liquid staked Ether 2.0', - symbol: 'stETH', - decimals: 18, - type: 'underlying', - balanceRaw: '1000000000000000000', - balance: 10, - price: 2000, - marketValue: 20000, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', - }, - ], - }, - ], - ], - }, - }, - }, - }, - }, - '0x2105': { - aggregatedMarketValue: 9580, - protocols: { - 'uniswap-v3': { - protocolDetails: { - name: 'Uniswap V3', - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', - }, - aggregatedMarketValue: 9580, - positionTypes: { - supply: { - aggregatedMarketValue: 9580, - positions: [ - [ - { - address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', - tokenId: '940758', - name: 'GASP / USDT - 0.3%', - symbol: 'GASP / USDT - 0.3%', - decimals: 18, - balanceRaw: '1000000000000000000', - balance: 1, - marketValue: 513, - type: 'protocol', - tokens: [ - { - address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', - name: 'GASP', - symbol: 'GASP', - decimals: 18, - balanceRaw: '100000000000000000000', - type: 'underlying', - balance: 100, - price: 0.1, - marketValue: 10, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', - }, - { - address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', - name: 'GASP', - symbol: 'GASP', - decimals: 18, - balanceRaw: '10000000000000000000', - type: 'underlying-claimable', - balance: 10, - price: 0.1, - marketValue: 1, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', - }, - { - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - balanceRaw: '500000000', - type: 'underlying', - balance: 500, - price: 1, - marketValue: 500, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', - }, - { - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - balanceRaw: '2000000', - type: 'underlying-claimable', - balance: 2, - price: 1, - marketValue: 2, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', - }, - ], - }, - ], - [ - { - address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', - tokenId: '940760', - name: 'GASP / USDT - 0.3%', - symbol: 'GASP / USDT - 0.3%', - decimals: 18, - balanceRaw: '2000000000000000000', - balance: 2, - marketValue: 9067, - type: 'protocol', - tokens: [ - { - address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', - name: 'GASP', - symbol: 'GASP', - decimals: 18, - balanceRaw: '90000000000000000000000', - type: 'underlying', - balance: 90000, - price: 0.1, - marketValue: 9000, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', - }, - { - address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', - name: 'GASP', - symbol: 'GASP', - decimals: 18, - balanceRaw: '50000000000000000000', - type: 'underlying-claimable', - balance: 50, - price: 0.1, - marketValue: 5, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', - }, - { - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - balanceRaw: '60000000', - type: 'underlying', - balance: 60, - price: 1, - marketValue: 60, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', - }, - { - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - balanceRaw: '2000000', - type: 'underlying-claimable', - balance: 2, - price: 1, - marketValue: 2, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', - }, - ], - }, - ], - ], - }, - }, - }, - }, - }, - }; - - expect(result).toStrictEqual(expectedResult); + expect(result).toStrictEqual(MOCK_EXPECTED_RESULT); }); }); diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts index a50f5291bfb..829efe71f1d 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts @@ -13,7 +13,10 @@ export type GroupedDeFiPositions = { aggregatedMarketValue: number; protocols: { [protocolId: string]: { - protocolDetails: { name: string; iconUrl: string }; + protocolDetails: { + name: string; + iconUrl: string; + }; aggregatedMarketValue: number; positionTypes: { [key in PositionType]?: { diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts index d42c41acbf5..4aa92305747 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -353,9 +353,13 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro * Fetches historical prices for the current account * * @param asset - The asset to fetch historical prices for. + * @param account - optional account to fetch historical prices for * @returns The historical prices. */ - async fetchHistoricalPricesForAsset(asset: CaipAssetType): Promise { + async fetchHistoricalPricesForAsset( + asset: CaipAssetType, + account?: InternalAccount, + ): Promise { const releaseLock = await this.#mutex.acquire(); return (async () => { const currentCaipCurrency = @@ -373,9 +377,11 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro return; } - const selectedAccount = this.messagingSystem.call( - 'AccountsController:getSelectedMultichainAccount', - ); + const selectedAccount = + account ?? + this.messagingSystem.call( + 'AccountsController:getSelectedMultichainAccount', + ); try { const historicalPricesResponse = await this.#handleSnapRequest({ snapId: selectedAccount?.metadata.snap?.id as SnapId, diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index 53244cb3f77..3e4c8efc953 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -27,6 +27,8 @@ import type { NetworkClientId, } from '@metamask/network-controller'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { BulkPhishingDetectionScanResponse } from '@metamask/phishing-controller'; +import { RecommendedAction } from '@metamask/phishing-controller'; import { getDefaultPreferencesState, type PreferencesState, @@ -61,8 +63,11 @@ import type { NftControllerMessenger, AllowedActions as NftControllerAllowedActions, AllowedEvents as NftControllerAllowedEvents, + PhishingControllerBulkScanUrlsAction, + NftMetadata, } from './NftController'; import { NftController } from './NftController'; +import type { Collection } from './NftDetectionController'; const CRYPTOPUNK_ADDRESS = '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB'; const ERC721_KUDOSADDRESS = '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163'; @@ -149,6 +154,8 @@ jest.mock('uuid', () => { * `AccountsController:getAccount` action. * @param args.getSelectedAccount - Used to construct mock versions of the * `AccountsController:getSelectedAccount` action. + * @param args.bulkScanUrlsMock - Used to construct mock versions of the + * `PhishingController:bulkScanUrls` action. * @param args.defaultSelectedAccount - The default selected account to use in * @returns A collection of test controllers and mocks. */ @@ -162,6 +169,7 @@ function setupController({ getERC1155TokenURI, getAccount, getSelectedAccount, + bulkScanUrlsMock, mockNetworkClientConfigurationsByNetworkClientId = {}, defaultSelectedAccount = OWNER_ACCOUNT, }: { @@ -198,6 +206,10 @@ function setupController({ ReturnType, Parameters >; + bulkScanUrlsMock?: jest.Mock< + Promise, + [string[]] + >; mockNetworkClientConfigurationsByNetworkClientId?: Record< NetworkClientId, NetworkClientConfiguration @@ -313,7 +325,20 @@ function setupController({ showApprovalRequest: jest.fn(), }); - const nftControllerMessenger = messenger.getRestricted({ + // Register the phishing controller mock if provided + if (bulkScanUrlsMock) { + messenger.registerActionHandler( + 'PhishingController:bulkScanUrls', + bulkScanUrlsMock, + ); + } + + const nftControllerMessenger = messenger.getRestricted< + typeof controllerName, + | PhishingControllerBulkScanUrlsAction['type'] + | NftControllerAllowedActions['type'], + NftControllerAllowedEvents['type'] + >({ name: controllerName, allowedActions: [ 'ApprovalController:addRequest', @@ -326,9 +351,9 @@ function setupController({ 'AssetsContractController:getERC721OwnerOf', 'AssetsContractController:getERC1155BalanceOf', 'AssetsContractController:getERC1155TokenURI', + 'PhishingController:bulkScanUrls', ], allowedEvents: [ - 'AccountsController:selectedAccountChange', 'AccountsController:selectedEvmAccountChange', 'PreferencesController:stateChange', 'NetworkController:networkDidChange', @@ -338,8 +363,7 @@ function setupController({ const nftController = new NftController({ chainId: ChainId.mainnet, onNftAdded: jest.fn(), - // @ts-expect-error - Added incompatible event `AccountsController:selectedAccountChange` to allowlist for testing purposes - messenger: nftControllerMessenger, + messenger: nftControllerMessenger as NftControllerMessenger, ...options, }); @@ -5141,4 +5165,400 @@ describe('NftController', () => { }); }); }); + + describe('phishing protection for NFT metadata', () => { + /** + * Tests for the NFT URL sanitization feature. + */ + it('should sanitize malicious URLs when adding NFTs', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://malicious-site.com/image.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-domain.com': { + recommendedAction: RecommendedAction.Block, + }, + 'http://safe-site.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://legitimate-domain.com': { + recommendedAction: RecommendedAction.None, + }, + }, + }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithMaliciousURLs: NftMetadata = { + name: 'Malicious NFT', + description: 'NFT with malicious links', + image: 'http://malicious-site.com/image.png', + externalLink: 'http://malicious-domain.com', + standard: ERC721, + }; + + const nftWithSafeURLs: NftMetadata = { + name: 'Safe NFT', + description: 'NFT with safe links', + image: 'http://safe-site.com/image.png', + externalLink: 'http://legitimate-domain.com', + standard: ERC721, + }; + + await nftController.addNft('0xmalicious', '1', { + nftMetadata: nftWithMaliciousURLs, + userAddress: OWNER_ADDRESS, + }); + + await nftController.addNft('0xsafe', '2', { + nftMetadata: nftWithSafeURLs, + userAddress: OWNER_ADDRESS, + }); + + expect(mockBulkScanUrls).toHaveBeenCalled(); + + const storedNfts = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet]; + + const maliciousNft = storedNfts.find( + (nft) => nft.address === '0xmalicious', + ); + const safeNft = storedNfts.find((nft) => nft.address === '0xsafe'); + + expect(maliciousNft?.image).toBeUndefined(); + expect(maliciousNft?.externalLink).toBeUndefined(); + + expect(maliciousNft?.name).toBe('Malicious NFT'); + expect(maliciousNft?.description).toBe('NFT with malicious links'); + + expect(safeNft?.image).toBe('http://safe-site.com/image.png'); + expect(safeNft?.externalLink).toBe('http://legitimate-domain.com'); + }); + + it('should handle errors during phishing detection when adding NFTs', async () => { + const mockBulkScanUrls = jest + .fn() + .mockRejectedValue(new Error('Phishing detection failed')); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const nftMetadata: NftMetadata = { + name: 'Test NFT', + description: 'Test description', + image: 'http://example.com/image.png', + externalLink: 'http://example.com', + standard: ERC721, + }; + + await nftController.addNft('0xtest', '1', { + nftMetadata, + userAddress: OWNER_ADDRESS, + }); + + expect(mockBulkScanUrls).toHaveBeenCalled(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error during bulk URL scanning:', + expect.any(Error), + ); + + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + expect(storedNft.image).toBe('http://example.com/image.png'); + expect(storedNft.externalLink).toBe('http://example.com'); + + consoleErrorSpy.mockRestore(); + }); + + it('should sanitize all URL fields when they contain malicious URLs', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://malicious-image.com/image.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-preview.com/preview.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-thumb.com/thumb.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-original.com/original.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-animation.com/animation.mp4': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-animation-orig.com/animation-orig.mp4': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-external.com': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-collection.com': { + recommendedAction: RecommendedAction.Block, + }, + }, + }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + // Create NFT with malicious URLs in all possible fields + const nftWithAllMaliciousURLs: NftMetadata = { + name: 'NFT with all URL fields', + description: 'Testing all URL fields', + image: 'http://malicious-image.com/image.png', + imagePreview: 'http://malicious-preview.com/preview.png', + imageThumbnail: 'http://malicious-thumb.com/thumb.png', + imageOriginal: 'http://malicious-original.com/original.png', + animation: 'http://malicious-animation.com/animation.mp4', + animationOriginal: + 'http://malicious-animation-orig.com/animation-orig.mp4', + externalLink: 'http://malicious-external.com', + standard: ERC721, + collection: { + id: 'collection-1', + name: 'Test Collection', + externalLink: 'http://malicious-collection.com', + } as Collection & { externalLink?: string }, + }; + + await nftController.addNft('0xallmalicious', '1', { + nftMetadata: nftWithAllMaliciousURLs, + userAddress: OWNER_ADDRESS, + }); + + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + + // Verify all URL fields were sanitized + expect(storedNft.image).toBeUndefined(); + expect(storedNft.imagePreview).toBeUndefined(); + expect(storedNft.imageThumbnail).toBeUndefined(); + expect(storedNft.imageOriginal).toBeUndefined(); + expect(storedNft.animation).toBeUndefined(); + expect(storedNft.animationOriginal).toBeUndefined(); + expect(storedNft.externalLink).toBeUndefined(); + expect( + (storedNft.collection as Collection & { externalLink?: string }) + ?.externalLink, + ).toBeUndefined(); + + // Verify non-URL fields were preserved + expect(storedNft.name).toBe('NFT with all URL fields'); + expect(storedNft.description).toBe('Testing all URL fields'); + expect(storedNft.collection?.id).toBe('collection-1'); + expect(storedNft.collection?.name).toBe('Test Collection'); + }); + + it('should handle mixed safe and malicious URLs correctly', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://safe-image.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://malicious-preview.com/preview.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://safe-external.com': { + recommendedAction: RecommendedAction.None, + }, + }, + }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithMixedURLs: NftMetadata = { + name: 'Mixed URLs NFT', + description: 'Some safe, some malicious', + image: 'http://safe-image.com/image.png', + imagePreview: 'http://malicious-preview.com/preview.png', + externalLink: 'http://safe-external.com', + standard: ERC721, + }; + + await nftController.addNft('0xmixed', '1', { + nftMetadata: nftWithMixedURLs, + userAddress: OWNER_ADDRESS, + }); + + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + + // Verify only malicious URLs were removed + expect(storedNft.image).toBe('http://safe-image.com/image.png'); + expect(storedNft.imagePreview).toBeUndefined(); + expect(storedNft.externalLink).toBe('http://safe-external.com'); + }); + + it('should handle non-http URLs and edge cases', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ results: {} }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithEdgeCases: NftMetadata = { + name: 'Edge case NFT', + description: 'Testing edge cases', + image: 'ipfs://QmTest123', // IPFS URL - should not be scanned + imagePreview: '', // Empty string + externalLink: 'https://secure-site.com', // HTTPS URL + standard: ERC721, + }; + + await nftController.addNft('0xedge', '1', { + nftMetadata: nftWithEdgeCases, + userAddress: OWNER_ADDRESS, + }); + + // Verify only HTTP(S) URLs were sent for scanning + expect(mockBulkScanUrls).toHaveBeenCalledWith([ + 'https://secure-site.com', + ]); + + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + + // Verify all fields are preserved as-is + expect(storedNft.image).toBe('ipfs://QmTest123'); + expect(storedNft.imagePreview).toBe(''); + expect(storedNft.externalLink).toBe('https://secure-site.com'); + }); + + it('should handle bulk sanitization with multiple NFTs efficiently', async () => { + let scanCallCount = 0; + const mockBulkScanUrls = jest.fn().mockImplementation(() => { + scanCallCount += 1; + return Promise.resolve({ + results: { + 'http://image-0.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-0.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-1.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-1.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-2.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-2.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-3.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-3.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-4.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-4.com': { + recommendedAction: RecommendedAction.None, + }, + }, + }); + }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + // Add multiple NFTs in sequence + const nftCount = 5; + for (let i = 0; i < nftCount; i++) { + await nftController.addNft(`0x0${i}`, `${i}`, { + nftMetadata: { + name: `NFT ${i}`, + description: `Description ${i}`, + image: `http://image-${i}.com/image.png`, + externalLink: `http://external-${i}.com`, + standard: ERC721, + }, + userAddress: OWNER_ADDRESS, + }); + } + + // Verify bulk scan was called once per NFT (not batched in this flow) + expect(scanCallCount).toBe(nftCount); + + // Verify all NFTs were added successfully + const storedNfts = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet]; + expect(storedNfts).toHaveLength(nftCount); + }); + + it('should not call phishing detection when no HTTP URLs are present', async () => { + const mockBulkScanUrls = jest.fn(); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithoutHttpUrls: NftMetadata = { + name: 'No HTTP URLs', + description: 'This NFT has no HTTP URLs', + image: 'ipfs://QmTest123', + standard: ERC721, + }; + + await nftController.addNft('0xnohttp', '1', { + nftMetadata: nftWithoutHttpUrls, + userAddress: OWNER_ADDRESS, + }); + + // Verify phishing detection was not called + expect(mockBulkScanUrls).not.toHaveBeenCalled(); + + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + expect(storedNft.image).toBe('ipfs://QmTest123'); + }); + + it('should handle collection without externalLink field', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ results: {} }); + + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithCollectionNoLink: NftMetadata = { + name: 'NFT with collection', + description: 'Collection without external link', + image: 'http://image.com/image.png', + standard: ERC721, + collection: { + id: 'collection-1', + name: 'Test Collection', + // No externalLink field + }, + }; + + await nftController.addNft('0xcollection', '1', { + nftMetadata: nftWithCollectionNoLink, + userAddress: OWNER_ADDRESS, + }); + + // Should not throw error + expect(mockBulkScanUrls).toHaveBeenCalledWith([ + 'http://image.com/image.png', + ]); + }); + }); }); diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index 795960fbe63..7ebda29e05f 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -34,6 +34,8 @@ import type { NetworkControllerNetworkDidChangeEvent, NetworkState, } from '@metamask/network-controller'; +import type { BulkPhishingDetectionScanResponse } from '@metamask/phishing-controller'; +import { RecommendedAction } from '@metamask/phishing-controller'; import type { PreferencesControllerStateChangeEvent, PreferencesState, @@ -231,6 +233,14 @@ export type NftControllerGetStateAction = ControllerGetStateAction< >; export type NftControllerActions = NftControllerGetStateAction; +/** + * Action type for bulk scanning URLs with PhishingController + */ +export type PhishingControllerBulkScanUrlsAction = { + type: 'PhishingController:bulkScanUrls'; + handler: (urls: string[]) => Promise; +}; + /** * The external actions available to the {@link NftController}. */ @@ -244,7 +254,8 @@ export type AllowedActions = | AssetsContractControllerGetERC721TokenURIAction | AssetsContractControllerGetERC721OwnerOfAction | AssetsContractControllerGetERC1155BalanceOfAction - | AssetsContractControllerGetERC1155TokenURIAction; + | AssetsContractControllerGetERC1155TokenURIAction + | PhishingControllerBulkScanUrlsAction; export type AllowedEvents = | PreferencesControllerStateChangeEvent @@ -797,7 +808,7 @@ export class NftController extends BaseController< ) : undefined, ]); - return { + const metadata = { ...nftApiMetadata, name: blockchainMetadata?.name ?? nftApiMetadata?.name ?? null, description: @@ -807,6 +818,8 @@ export class NftController extends BaseController< blockchainMetadata?.standard ?? nftApiMetadata?.standard ?? null, tokenURI: blockchainMetadata?.tokenURI ?? null, }; + // Sanitize the metadata by checking external links against phishing protection + return await this.#sanitizeNftMetadata(metadata); } /** @@ -1354,15 +1367,17 @@ export class NftController extends BaseController< asset.tokenId, networkClientId, ); + // Sanitize metadata + const sanitizedMetadata = await this.#sanitizeNftMetadata(nftMetadata); - if (nftMetadata.standard && nftMetadata.standard !== type) { + if (sanitizedMetadata.standard && sanitizedMetadata.standard !== type) { throw rpcErrors.invalidInput( - `Suggested NFT of type ${nftMetadata.standard} does not match received type ${type}`, + `Suggested NFT of type ${sanitizedMetadata.standard} does not match received type ${type}`, ); } const suggestedNftMeta: SuggestedNftMeta = { - asset: { ...asset, ...nftMetadata }, + asset: { ...asset, ...sanitizedMetadata }, type, id: random(), time: Date.now(), @@ -1371,7 +1386,7 @@ export class NftController extends BaseController< }; await this._requestApproval(suggestedNftMeta); const { address, tokenId } = asset; - const { name, standard, description, image } = nftMetadata; + const { name, standard, description, image } = sanitizedMetadata; await this.addNft(address, tokenId, { nftMetadata: { @@ -1530,13 +1545,18 @@ export class NftController extends BaseController< const chainIdToAddTo = chainId || this.#getCorrectChainId({ networkClientId }); - nftMetadata = - nftMetadata || - (await this.#getNftInformation( + if (!nftMetadata) { + const fetchedMetadata = await this.#getNftInformation( checksumHexAddress, tokenId, networkClientId, - )); + ); + // Sanitize metadata + nftMetadata = await this.#sanitizeNftMetadata(fetchedMetadata); + } else { + // Sanitize provided metadata + nftMetadata = await this.#sanitizeNftMetadata(nftMetadata); + } const newNftContracts = await this.#addNftContract({ tokenAddress: checksumHexAddress, @@ -1601,7 +1621,9 @@ export class NftController extends BaseController< address: toChecksumHexAddress(nft.address), }; }); - const nftMetadataResults = await Promise.all( + + // Get all unsanitized nft metadata + const unsanitizedResults = await Promise.all( nftsWithChecksumAdr.map(async (nft) => { const resMetadata = await this.#getNftInformation( nft.address, @@ -1615,6 +1637,21 @@ export class NftController extends BaseController< }), ); + // Extract metadata + const unsanitizedMetadata = unsanitizedResults.map( + (result) => result.newMetadata, + ); + + // Sanitize all metadata + const sanitizedMetadata = + await this.#bulkSanitizeNftMetadata(unsanitizedMetadata); + + // Reassemble the results with sanitized metadata + const nftMetadataResults = unsanitizedResults.map((result, index) => ({ + nft: result.nft, + newMetadata: sanitizedMetadata[index], + })); + // We want to avoid updating the state if the state and fetched nft info are the same const nftsWithDifferentMetadata: NftUpdate[] = []; const { allNfts } = this.state; @@ -2094,6 +2131,121 @@ export class NftController extends BaseController< return getDefaultNftControllerState(); }); } + + /** + * Sanitizes multiple NFT metadata objects by checking external links against PhishingController in a single bulk request + * + * @param metadataList - Array of NFT metadata objects to sanitize + * @returns Array of sanitized NFT metadata objects + */ + async #bulkSanitizeNftMetadata( + metadataList: NftMetadata[], + ): Promise { + // Create a copy of the metadata list to avoid mutating the input + const sanitizedMetadataList = metadataList.map((metadata) => ({ + ...metadata, + })); + + // Maps URL to a list of {metadataIndex, fieldName} to track where each URL is used + const urlMap: Record< + string, + { metadataIndex: number; fieldName: string }[] + > = {}; + + const fieldsToCheck = [ + 'externalLink', + 'image', + 'imagePreview', + 'imageThumbnail', + 'imageOriginal', + 'animation', + 'animationOriginal', + ]; + + // Collect all URLs from all metadata objects + sanitizedMetadataList.forEach((metadata, metadataIndex) => { + // Check regular fields + for (const field of fieldsToCheck) { + const url = metadata[field as keyof NftMetadata]; + if (typeof url === 'string' && url && url.startsWith('http')) { + if (!urlMap[url]) { + urlMap[url] = []; + } + urlMap[url].push({ metadataIndex, fieldName: field }); + } + } + + // Check collection links if they exist + if (metadata.collection) { + const { collection } = metadata; + if ( + 'externalLink' in collection && + typeof collection.externalLink === 'string' + ) { + const url = collection.externalLink; + if (!urlMap[url]) { + urlMap[url] = []; + } + urlMap[url].push({ + metadataIndex, + fieldName: 'collection.externalLink', + }); + } + } + }); + + const urlsToCheck = Object.keys(urlMap); + if (urlsToCheck.length === 0) { + return sanitizedMetadataList; + } + + try { + // Use bulkScanUrls to check all URLs at once + const bulkScanResponse = await this.messagingSystem.call( + 'PhishingController:bulkScanUrls', + urlsToCheck, + ); + // Apply scan results to all metadata objects + Object.entries(bulkScanResponse.results).forEach(([url, result]) => { + if (result.recommendedAction === RecommendedAction.Block) { + // Remove this URL from all metadata objects where it appears + urlMap[url].forEach(({ metadataIndex, fieldName }) => { + if ( + fieldName === 'collection.externalLink' && + sanitizedMetadataList[metadataIndex].collection // Check if collection exists + ) { + const { collection } = sanitizedMetadataList[metadataIndex]; + // Ensure collection is not undefined again just to be safe before using 'in' + if (collection && 'externalLink' in collection) { + delete (collection as Record).externalLink; + } + } else { + delete sanitizedMetadataList[metadataIndex][ + fieldName as keyof NftMetadata + ]; + } + }); + } + }); + } catch (error) { + console.error('Error during bulk URL scanning:', error); + // If bulk scan fails, we fall back to keeping all URLs + } + + return sanitizedMetadataList; + } + + /** + * Sanitizes NFT metadata by checking external links against PhishingController + * + * @param metadata - The NFT metadata to sanitize + * @returns Sanitized NFT metadata with potentially dangerous links removed + */ + async #sanitizeNftMetadata(metadata: NftMetadata): Promise { + // Use the bulk sanitize function with just a single metadata object + const sanitized = await this.#bulkSanitizeNftMetadata([metadata]); + return sanitized[0]; + } } export default NftController; diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 9d79d33468d..e8ea249d9f9 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -2,10 +2,10 @@ import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; import type { NetworkState } from '@metamask/network-controller'; import type { PreferencesState } from '@metamask/preferences-controller'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import BN from 'bn.js'; import { useFakeTimers } from 'sinon'; -import { advanceTime } from '../../../tests/helpers'; import * as multicall from './multicall'; import type { AllowedActions, @@ -16,13 +16,18 @@ import type { } from './TokenBalancesController'; import { TokenBalancesController } from './TokenBalancesController'; import type { TokensControllerState } from './TokensController'; +import { advanceTime } from '../../../tests/helpers'; +import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; +import type { InternalAccount } from '../../transaction-controller/src/types'; const setupController = ({ config, tokens = { allTokens: {}, allDetectedTokens: {} }, + listAccounts = [], }: { config?: Partial[0]>; tokens?: Partial; + listAccounts?: InternalAccount[]; } = {}) => { const messenger = new Messenger< TokenBalancesControllerActions | AllowedActions, @@ -37,11 +42,13 @@ const setupController = ({ 'PreferencesController:getState', 'TokensController:getState', 'AccountsController:getSelectedAccount', + 'AccountsController:listAccounts', ], allowedEvents: [ 'NetworkController:stateChange', 'PreferencesController:stateChange', 'TokensController:stateChange', + 'KeyringController:accountRemoved', ], }); @@ -67,6 +74,12 @@ const setupController = ({ jest.fn().mockImplementation(() => tokens), ); + const mockListAccounts = jest.fn().mockReturnValue(listAccounts); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + messenger.registerActionHandler( 'AccountsController:getSelectedAccount', jest.fn().mockImplementation(() => ({ @@ -78,12 +91,15 @@ const setupController = ({ 'NetworkController:getNetworkClientById', jest.fn().mockReturnValue({ provider: jest.fn() }), ); + const controller = new TokenBalancesController({ + messenger: tokenBalancesMessenger, + ...config, + }); + const updateSpy = jest.spyOn(controller, 'update' as never); return { - controller: new TokenBalancesController({ - messenger: tokenBalancesMessenger, - ...config, - }), + controller, + updateSpy, messenger, }; }; @@ -264,7 +280,7 @@ describe('TokenBalancesController', () => { }, }; - const { controller, messenger } = setupController({ + const { controller, messenger, updateSpy } = setupController({ tokens: initialTokens, }); @@ -302,12 +318,148 @@ describe('TokenBalancesController', () => { await advanceTime({ clock, duration: 1 }); // Verify balance was removed + expect(updateSpy).toHaveBeenCalledTimes(2); expect(controller.state.tokenBalances).toStrictEqual({ [accountAddress]: { [chainId]: {}, // Empty balances object }, }); }); + it('skips removing balances when incoming chainIds are not in the current chainIds list for tokenBalances', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // Start with a token + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ + tokens: initialTokens, + }); + + // Set initial balance + const balance = 123456; + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([ + { + success: true, + value: new BN(balance), + }, + ]); + + await controller._executePoll({ chainId }); + + // Verify initial balance is set + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(balance), + }, + }, + }); + + // Publish an update with no tokens + messenger.publish( + 'TokensController:stateChange', + { + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { [CHAIN_IDS.BASE]: {} }, + }, + [], + ); + + await advanceTime({ clock, duration: 1 }); + + // Verify initial balances are still there + expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(balance), + }, + }, + }); + }); + + it('skips removing balances when state change with tokens that are already in tokenBalances state', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // Start with a token + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ + tokens: initialTokens, + }); + + // Set initial balance + const balance = 123456; + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([ + { + success: true, + value: new BN(balance), + }, + ]); + + await controller._executePoll({ chainId }); + + // Verify initial balance is set + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(balance), + }, + }, + }); + + // Publish an update with no tokens + messenger.publish( + 'TokensController:stateChange', + { + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }, + [], + ); + + await advanceTime({ clock, duration: 1 }); + + // Verify initial balances are still there + expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(balance), + }, + }, + }); + }); it('updates balances for all accounts when multi-account balances is enabled', async () => { const chainId = '0x1'; @@ -357,6 +509,128 @@ describe('TokenBalancesController', () => { }); }); + it('does not update balances when multi-account balances is enabled and all returned values did not change', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ tokens }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + const balance1 = 100; + const balance2 = 200; + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([ + { success: true, value: new BN(balance1) }, + { success: true, value: new BN(balance2) }, + ]); + + await controller._executePoll({ chainId }); + + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [tokenAddress]: toHex(balance1), + }, + }, + [account2]: { + [chainId]: { + [tokenAddress]: toHex(balance2), + }, + }, + }); + + await controller._executePoll({ chainId }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('updates balances when multi-account balances is enabled and some returned values changed', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ tokens }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + const balance1 = 100; + const balance2 = 200; + const balance3 = 300; + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([ + { success: true, value: new BN(balance1) }, + { success: true, value: new BN(balance2) }, + ]); + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([ + { success: true, value: new BN(balance1) }, + { success: true, value: new BN(balance3) }, + ]); + + await controller._executePoll({ chainId }); + + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [tokenAddress]: toHex(balance1), + }, + }, + [account2]: { + [chainId]: { + [tokenAddress]: toHex(balance2), + }, + }, + }); + + await controller._executePoll({ chainId }); + + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [tokenAddress]: toHex(balance1), + }, + }, + [account2]: { + [chainId]: { + [tokenAddress]: toHex(balance3), + }, + }, + }); + + expect(updateSpy).toHaveBeenCalledTimes(2); + }); + it('only updates selected account balance when multi-account balances is disabled', async () => { const chainId = '0x1'; const selectedAccount = '0x0000000000000000000000000000000000000000'; @@ -471,4 +745,91 @@ describe('TokenBalancesController', () => { }); }); }); + + describe('when accountRemoved is published', () => { + it('does not update state if account removed is EVM account', async () => { + const { controller, messenger, updateSpy } = setupController(); + + messenger.publish('KeyringController:accountRemoved', 'toto'); + + expect(controller.state.tokenBalances).toStrictEqual({}); + expect(updateSpy).toHaveBeenCalledTimes(0); + }); + it('removes the balances for the removed account', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const accountAddress2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const tokenAddress2 = '0x0000000000000000000000000000000000000022'; + const account = createMockInternalAccount({ + address: accountAddress, + }); + const account2 = createMockInternalAccount({ + address: accountAddress2, + }); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + [accountAddress2]: [ + { address: tokenAddress2, symbol: 't', decimals: 0 }, + ], + }, + }, + }; + + const { controller, messenger } = setupController({ + tokens, + listAccounts: [account, account2], + }); + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + expect(controller.state.tokenBalances).toStrictEqual({}); + + const balance = 123456; + const balance2 = 200; + jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([ + { + success: true, + value: new BN(balance), + }, + { success: true, value: new BN(balance2) }, + ]); + + await controller._executePoll({ chainId }); + + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(balance), + }, + }, + [accountAddress2]: { + [chainId]: { + [tokenAddress2]: toHex(balance2), + }, + }, + }); + + messenger.publish('KeyringController:accountRemoved', account.address); + + await advanceTime({ clock, duration: 1 }); + + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress2]: { + [chainId]: { + [tokenAddress2]: toHex(balance2), + }, + }, + }); + }); + }); }); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 5c57b3eabe2..bb669906618 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -1,12 +1,20 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, +} from '@metamask/accounts-controller'; import type { RestrictedMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; -import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; +import { + isValidHexAddress, + toChecksumHexAddress, + toHex, +} from '@metamask/controller-utils'; +import type { KeyringControllerAccountRemovedEvent } from '@metamask/keyring-controller'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { NetworkControllerGetNetworkClientByIdAction, @@ -20,7 +28,7 @@ import type { PreferencesControllerStateChangeEvent, PreferencesState, } from '@metamask/preferences-controller'; -import type { Hex } from '@metamask/utils'; +import { isStrictHexString, type Hex } from '@metamask/utils'; import type BN from 'bn.js'; import type { Patch } from 'immer'; import { isEqual } from 'lodash'; @@ -80,7 +88,8 @@ export type AllowedActions = | NetworkControllerGetStateAction | TokensControllerGetStateAction | PreferencesControllerGetStateAction - | AccountsControllerGetSelectedAccountAction; + | AccountsControllerGetSelectedAccountAction + | AccountsControllerListAccountsAction; export type TokenBalancesControllerStateChangeEvent = ControllerStateChangeEvent< @@ -94,7 +103,8 @@ export type TokenBalancesControllerEvents = export type AllowedEvents = | TokensControllerStateChangeEvent | PreferencesControllerStateChangeEvent - | NetworkControllerStateChangeEvent; + | NetworkControllerStateChangeEvent + | KeyringControllerAccountRemovedEvent; export type TokenBalancesControllerMessenger = RestrictedMessenger< typeof controllerName, @@ -185,6 +195,13 @@ export class TokenBalancesController extends StaticIntervalPollingController this.#handleOnAccountRemoved(accountAddress), + ); } /** @@ -242,8 +259,9 @@ export class TokenBalancesController extends StaticIntervalPollingController { + delete state.tokenBalances[accountAddress as `0x${string}`]; + }); + } + /** * Returns an array of chain ids that have tokens. * @param allTokens - The state for imported tokens across all chains. @@ -309,6 +345,72 @@ export class TokenBalancesController extends StaticIntervalPollingController elm.address), + ); + + for (const singleToken of allCurrentTokens) { + if (!existingSet.has(singleToken)) { + this.update((state) => { + delete state.tokenBalances[currentAccount as Hex][ + currentChain as Hex + ][singleToken as `0x${string}`]; + }); + } + } + } + } + + // then we check if the state change was due to a token being added + let shouldUpdate = false; + for (const currentChain of Object.keys(currentAllTokens)) { + if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) { + continue; + } + const accountsPerChain = currentAllTokens[currentChain as Hex]; + + for (const currentAccount of Object.keys(accountsPerChain)) { + const tokensList = accountsPerChain[currentAccount as `0x${string}`]; + const tokenBalancesObject = + currentTokenBalances[currentAccount as `0x${string}`]?.[ + currentChain as Hex + ] || {}; + for (const singleToken of tokensList) { + if (!tokenBalancesObject?.[singleToken.address as `0x${string}`]) { + shouldUpdate = true; + break; + } + } + } + } + if (shouldUpdate) { + await this.updateBalances({ chainIds }).catch(console.error); + } + } + /** * Updates token balances for the given chain id. * @param input - The input for the update. @@ -341,6 +443,10 @@ export class TokenBalancesController extends StaticIntervalPollingController 0) { const provider = new Web3Provider( this.#getNetworkClient(chainId).provider, @@ -357,18 +463,34 @@ export class TokenBalancesController extends StaticIntervalPollingController { - // Reset so that when accounts or tokens are removed, - // their balances are removed rather than left stale. - for (const accountAddress of Object.keys(state.tokenBalances)) { - state.tokenBalances[accountAddress as Hex][chainId] = {}; - } + const updatedResults: (MulticallResult & { + isTokenBalanceValueChanged?: boolean; + })[] = results.map((res, i) => { + const { value } = res; + const { accountAddress, tokenAddress } = accountTokenPairs[i]; + const currentTokenBalanceValueForAccount = + currentTokenBalances.tokenBalances?.[accountAddress]?.[chainId]?.[ + tokenAddress + ]; + const isTokenBalanceValueChanged = + currentTokenBalanceValueForAccount !== toHex(value as BN); + return { + ...res, + isTokenBalanceValueChanged, + }; + }); - for (let i = 0; i < results.length; i++) { - const { success, value } = results[i]; - const { accountAddress, tokenAddress } = accountTokenPairs[i]; + // if all values of isTokenBalanceValueChanged are false, return + if (updatedResults.every((result) => !result.isTokenBalanceValueChanged)) { + return; + } - if (success) { + this.update((state) => { + for (let i = 0; i < updatedResults.length; i++) { + const { success, value, isTokenBalanceValueChanged } = + updatedResults[i]; + const { accountAddress, tokenAddress } = accountTokenPairs[i]; + if (success && isTokenBalanceValueChanged) { ((state.tokenBalances[accountAddress] ??= {})[chainId] ??= {})[ tokenAddress ] = toHex(value as BN); diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 5a237105d86..dd9dc98f43d 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -28,6 +28,7 @@ import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; import * as sinon from 'sinon'; +import { useFakeTimers } from 'sinon'; import { formatAggregatorNames } from './assetsUtil'; import * as MutliChainAccountsServiceModule from './multi-chain-accounts-service'; @@ -66,6 +67,8 @@ import { buildCustomRpcEndpoint, buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; +import type { TransactionMeta } from '../../transaction-controller/src/types'; +import { TransactionStatus } from '../../transaction-controller/src/types'; const DEFAULT_INTERVAL = 180000; @@ -182,6 +185,7 @@ function buildTokenDetectionControllerMessenger( 'NetworkController:networkDidChange', 'TokenListController:stateChange', 'PreferencesController:stateChange', + 'TransactionController:transactionConfirmed', ], }); } @@ -210,16 +214,12 @@ describe('TokenDetectionController', () => { .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleTokenList) .get( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `/token/${convertHexToDecimal(ChainId.mainnet)}?address=${ tokenAFromList.address }`, ) .reply(200, tokenAFromList) .get( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `/token/${convertHexToDecimal(ChainId.mainnet)}?address=${ tokenBFromList.address }`, @@ -750,7 +750,7 @@ describe('TokenDetectionController', () => { describe('AccountsController:selectedAccountChange', () => { let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + clock = useFakeTimers(); }); afterEach(() => { @@ -3019,6 +3019,83 @@ describe('TokenDetectionController', () => { expect(result).toStrictEqual({ chain1: { nested: 'nestedData' } }); }); }); + + describe('TransactionController:transactionConfirmed', () => { + let clock: sinon.SinonFakeTimers; + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + it('calls detectTokens when a transaction is confirmed', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useAccountsAPI: true, // USING ACCOUNTS API + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerTransactionConfirmed, + callActionSpy, + }) => { + mockMultiChainAccountsService(); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + mockGetAccount(secondSelectedAccount); + triggerTransactionConfirmed({ + chainId: '0x1', + status: TransactionStatus.confirmed, + } as unknown as TransactionMeta); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + [sampleTokenA], + { + chainId: ChainId.mainnet, + selectedAddress: secondSelectedAccount.address, + }, + ); + }, + ); + }); + }); }); /** @@ -3028,8 +3105,6 @@ describe('TokenDetectionController', () => { * @returns The constructed path. */ function getTokensPath(chainId: Hex) { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return `/tokens/${convertHexToDecimal( chainId, )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false`; @@ -3053,6 +3128,7 @@ type WithControllerCallback = ({ triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, + triggerTransactionConfirmed, }: { controller: TokenDetectionController; mockGetAccount: (internalAccount: InternalAccount) => void; @@ -3077,6 +3153,7 @@ type WithControllerCallback = ({ triggerPreferencesStateChange: (state: PreferencesState) => void; triggerSelectedAccountChange: (account: InternalAccount) => void; triggerNetworkDidChange: (state: NetworkState) => void; + triggerTransactionConfirmed: (transactionMeta: TransactionMeta) => void; }) => Promise | ReturnValue; type WithControllerOptions = { @@ -3259,6 +3336,12 @@ async function withController( triggerNetworkDidChange: (state: NetworkState) => { messenger.publish('NetworkController:networkDidChange', state); }, + triggerTransactionConfirmed: (transactionMeta: TransactionMeta) => { + messenger.publish( + 'TransactionController:transactionConfirmed', + transactionMeta, + ); + }, }); } finally { controller.stop(); diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 2cc4a838ece..fb144ab806f 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -33,6 +33,7 @@ import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { hexToNumber } from '@metamask/utils'; import { isEqual, mapValues, isObject, get } from 'lodash'; @@ -142,7 +143,8 @@ export type AllowedEvents = | TokenListStateChange | KeyringControllerLockEvent | KeyringControllerUnlockEvent - | PreferencesControllerStateChangeEvent; + | PreferencesControllerStateChangeEvent + | TransactionControllerTransactionConfirmedEvent; export type TokenDetectionControllerMessenger = RestrictedMessenger< typeof controllerName, @@ -410,6 +412,15 @@ export class TokenDetectionController extends StaticIntervalPollingController { + await this.detectTokens({ + chainIds: [transactionMeta.chainId], + }); + }, + ); } /** diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 5926512f326..f7957c3be80 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -201,6 +201,7 @@ export class TokenListController extends StaticIntervalPollingController { const releaseLock = await this.mutex.acquire(); try { - const { tokensChainsCache } = this.state; - let tokenList: TokenListMap = {}; - // Attempt to fetch cached tokens - const cachedTokens = await safelyExecute(() => - this.#fetchFromCache(chainId), + if (this.isCacheValid(chainId)) { + return; + } + + // Fetch fresh token list from the API + const tokensFromAPI = await safelyExecute( + () => + fetchTokenListByChainId( + chainId, + this.abortController.signal, + ) as Promise, ); - if (cachedTokens) { - // Use non-expired cached tokens - tokenList = { ...cachedTokens }; - } else { - // Fetch fresh token list from the API - const tokensFromAPI = await safelyExecute( - () => - fetchTokenListByChainId( + + // Have response - process and update list + if (tokensFromAPI) { + // Format tokens from API (HTTP) and update tokenList + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ chainId, - this.abortController.signal, - ) as Promise, - ); - - if (tokensFromAPI) { - // Format tokens from API (HTTP) and update tokenList - tokenList = {}; - for (const token of tokensFromAPI) { - tokenList[token.address] = { - ...token, - aggregators: formatAggregatorNames(token.aggregators), - iconUrl: formatIconUrlWithProxy({ - chainId, - tokenAddress: token.address, - }), - }; - } - } else { - // Fallback to expired cached tokens - tokenList = { ...(tokensChainsCache[chainId]?.data || {}) }; + tokenAddress: token.address, + }), + }; } + + this.update((state) => { + const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; + state.tokensChainsCache[chainId] ??= newDataCache; + state.tokensChainsCache[chainId].data = tokenList; + state.tokensChainsCache[chainId].timestamp = Date.now(); + }); + return; } - // Update the state with a single update for both tokenList and tokenChainsCache - this.update(() => { - return { - ...this.state, - tokensChainsCache: { - ...tokensChainsCache, - [chainId]: { - timestamp: Date.now(), - data: tokenList, - }, - }, - }; - }); + // No response - fallback to previous state, or initialise empty + if (!tokensFromAPI) { + this.update((state) => { + const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; + state.tokensChainsCache[chainId] ??= newDataCache; + state.tokensChainsCache[chainId].timestamp = Date.now(); + }); + } } finally { releaseLock(); } } - /** - * Checks if the Cache timestamp is valid, - * if yes data in cache will be returned - * otherwise null will be returned. - * @param chainId - The chain ID of the network for which to fetch the cache. - * @returns The cached data, or `null` if the cache was expired. - */ - async #fetchFromCache(chainId: Hex): Promise { + isCacheValid(chainId: Hex): boolean { const { tokensChainsCache }: TokenListState = this.state; - const dataCache = tokensChainsCache[chainId]; + const timestamp: number | undefined = tokensChainsCache[chainId]?.timestamp; const now = Date.now(); - if ( - dataCache?.data && - now - dataCache?.timestamp < this.cacheRefreshThreshold - ) { - return dataCache.data; - } - return null; + return ( + timestamp !== undefined && now - timestamp < this.cacheRefreshThreshold + ); } /** diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 5a691ccb597..5dee2972864 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3339,6 +3339,136 @@ describe('TokensController', () => { ); }); }); + + describe('when accountRemoved is published', () => { + it('removes the list of tokens for the removed account', async () => { + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + [firstAddress]: [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + [secondAddress]: [ + { + address: '0x04', + symbol: 'barD', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [firstAddress]: [], + [secondAddress]: [], + }, + }, + }; + await withController( + { + options: { + state: initialState, + }, + listAccounts: [firstAccount, secondAccount], + }, + ({ controller, triggerAccountRemoved }) => { + expect(controller.state).toStrictEqual(initialState); + + triggerAccountRemoved(firstAccount.address); + + expect(controller.state).toStrictEqual({ + allTokens: { + [ChainId.mainnet]: { + [secondAddress]: [ + { + address: '0x04', + symbol: 'barD', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [secondAddress]: [], + }, + }, + }); + }, + ); + }); + + it('removes an account with no tokens', async () => { + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + [firstAddress]: [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [firstAddress]: [], + }, + }, + }; + await withController( + { + options: { + state: initialState, + }, + listAccounts: [firstAccount, secondAccount], + }, + ({ controller, triggerAccountRemoved }) => { + expect(controller.state).toStrictEqual(initialState); + + triggerAccountRemoved(secondAccount.address); + + expect(controller.state).toStrictEqual(initialState); + }, + ); + }); + }); }); type WithControllerCallback = ({ @@ -3347,6 +3477,7 @@ type WithControllerCallback = ({ messenger, approvalController, triggerSelectedAccountChange, + triggerAccountRemoved, }: { controller: TokensController; changeNetwork: (networkControllerState: { @@ -3355,6 +3486,7 @@ type WithControllerCallback = ({ messenger: UnrestrictedMessenger; approvalController: ApprovalController; triggerSelectedAccountChange: (internalAccount: InternalAccount) => void; + triggerAccountRemoved: (accountAddress: string) => void; triggerNetworkStateChange: ( networkState: NetworkState, patches: Patch[], @@ -3378,6 +3510,7 @@ type WithControllerArgs = NetworkClientConfiguration >; mocks?: WithControllerMockArgs; + listAccounts?: InternalAccount[]; }, WithControllerCallback, ]; @@ -3403,6 +3536,7 @@ async function withController( options = {}, mockNetworkClientConfigurationsByNetworkClientId = {}, mocks = {} as WithControllerMockArgs, + listAccounts = [], }, fn, ] = args.length === 2 ? args : [{}, args[0]]; @@ -3427,12 +3561,14 @@ async function withController( 'NetworkController:getNetworkClientById', 'AccountsController:getAccount', 'AccountsController:getSelectedAccount', + 'AccountsController:listAccounts', ], allowedEvents: [ 'NetworkController:networkDidChange', 'NetworkController:stateChange', 'AccountsController:selectedEvmAccountChange', 'TokenListController:stateChange', + 'KeyringController:accountRemoved', ], }); @@ -3452,6 +3588,12 @@ async function withController( ), ); + const mockListAccounts = jest.fn().mockReturnValue(listAccounts); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + const controller = new TokensController({ chainId: ChainId.mainnet, // The tests assume that this is set, but they shouldn't make that @@ -3471,6 +3613,10 @@ async function withController( ); }; + const triggerAccountRemoved = (accountAddress: string) => { + messenger.publish('KeyringController:accountRemoved', accountAddress); + }; + const changeNetwork = ({ selectedNetworkClientId, }: { @@ -3504,6 +3650,7 @@ async function withController( approvalController, triggerSelectedAccountChange, triggerNetworkStateChange, + triggerAccountRemoved, getAccountHandler, getSelectedAccountHandler, }); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 1260f5fd672..d027677b9bd 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -3,6 +3,7 @@ import { Web3Provider } from '@ethersproject/providers'; import type { AccountsControllerGetAccountAction, AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, AccountsControllerSelectedEvmAccountChangeEvent, } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; @@ -24,6 +25,7 @@ import { isValidHexAddress, safelyExecute, } from '@metamask/controller-utils'; +import type { KeyringControllerAccountRemovedEvent } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { abiERC721 } from '@metamask/metamask-eth-abis'; import type { @@ -35,7 +37,7 @@ import type { Provider, } from '@metamask/network-controller'; import { rpcErrors } from '@metamask/rpc-errors'; -import type { Hex } from '@metamask/utils'; +import { isStrictHexString, type Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; @@ -124,7 +126,8 @@ export type AllowedActions = | AddApprovalRequest | NetworkControllerGetNetworkClientByIdAction | AccountsControllerGetAccountAction - | AccountsControllerGetSelectedAccountAction; + | AccountsControllerGetSelectedAccountAction + | AccountsControllerListAccountsAction; export type TokensControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -137,7 +140,8 @@ export type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkDidChangeEvent | TokenListStateChange - | AccountsControllerSelectedEvmAccountChangeEvent; + | AccountsControllerSelectedEvmAccountChangeEvent + | KeyringControllerAccountRemovedEvent; /** * The messenger of the {@link TokensController}. @@ -223,6 +227,11 @@ export class TokensController extends BaseController< this.#onNetworkStateChange.bind(this), ); + this.messagingSystem.subscribe( + 'KeyringController:accountRemoved', + (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), + ); + this.messagingSystem.subscribe( 'TokenListController:stateChange', ({ tokensChainsCache }) => { @@ -260,6 +269,45 @@ export class TokensController extends BaseController< ); } + #handleOnAccountRemoved(accountAddress: string) { + const isEthAddress = + isStrictHexString(accountAddress.toLowerCase()) && + isValidHexAddress(accountAddress); + + if (!isEthAddress) { + return; + } + + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; + const newAllTokens = cloneDeep(allTokens); + const newAllDetectedTokens = cloneDeep(allDetectedTokens); + const newAllIgnoredTokens = cloneDeep(allIgnoredTokens); + + for (const chainId of Object.keys(newAllTokens)) { + if (newAllTokens[chainId as Hex][accountAddress]) { + delete newAllTokens[chainId as Hex][accountAddress]; + } + } + + for (const chainId of Object.keys(newAllDetectedTokens)) { + if (newAllDetectedTokens[chainId as Hex][accountAddress]) { + delete newAllDetectedTokens[chainId as Hex][accountAddress]; + } + } + + for (const chainId of Object.keys(newAllIgnoredTokens)) { + if (newAllIgnoredTokens[chainId as Hex][accountAddress]) { + delete newAllIgnoredTokens[chainId as Hex][accountAddress]; + } + } + + this.update((state) => { + state.allTokens = newAllTokens; + state.allIgnoredTokens = newAllIgnoredTokens; + state.allDetectedTokens = newAllDetectedTokens; + }); + } + /** * Handles the event when the network state changes. * @param _ - The network state. diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index c7c7bc20350..da67830a2a2 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -15,7 +15,8 @@ { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, { "path": "../permission-controller/tsconfig.build.json" }, - { "path": "../transaction-controller/tsconfig.build.json" } + { "path": "../transaction-controller/tsconfig.build.json" }, + { "path": "../phishing-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"], "exclude": ["**/*.test.ts", "**/__fixtures__/"] diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index 578f600e201..b0e7c0374e3 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../keyring-controller" }, { "path": "../network-controller" }, { "path": "../preferences-controller" }, + { "path": "../phishing-controller" }, { "path": "../polling-controller" }, { "path": "../permission-controller" }, { "path": "../transaction-controller" } diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 7bce89cec14..a577fcc6847 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,10 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [28.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^65.0.0` ([#5863](https://github.com/MetaMask/core/pull/5863)) + +## [27.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^64.0.0` ([#5854](https://github.com/MetaMask/core/pull/5854)) + +## [26.0.0] + +### Added + +- **BREAKING:** Added a required `minimumVersion` to feature flag response schema ([#5834](https://github.com/MetaMask/core/pull/5834)) + +### Changed + +- Consume `bridgeConfigV2` in the feature flag response schema for Mobile and export `DEFAULT_FEATURE_FLAG_CONFIG` ([#5837](https://github.com/MetaMask/core/pull/5837)) + +## [25.1.0] + +### Added + +- Added optional `isUnifiedUIEnabled` flag to chain-level feature-flag `ChainConfiguration` type and updated the validation schema to accept the new flag ([#5783](https://github.com/MetaMask/core/pull/5783)) +- Add and export `calcSlippagePercentage`, a utility that calculates the absolute slippage percentage based on the adjusted return and the sent amount ([#5723](https://github.com/MetaMask/core/pull/5723)). +- Error logs for invalid getQuote responses ([#5816](https://github.com/MetaMask/core/pull/5816)) + +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [25.0.1] + +### Fixed + +- Use zero address as solana's default native address instead of assetId ([#5799](https://github.com/MetaMask/core/pull/5799)) + +## [25.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/assets-controllers` peer dependency to `^63.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [24.0.0] + +### Added + +- Sentry traces for `BridgeQuotesFetched` and `SwapQuotesFetched` events ([#5780](https://github.com/MetaMask/core/pull/5780)) +- Export `isCrossChain` utility ([#5780](https://github.com/MetaMask/core/pull/5780)) + +### Changed + +- **BREAKING:** Remove `BridgeToken` export ([#5768](https://github.com/MetaMask/core/pull/5768)) +- `traceFn` added to BridgeController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) + +## [23.0.0] + ### Changed - **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) +### Fixed + +- Handle cancelled bridge quote polling gracefully by skipping state updates ([#5787](https://github.com/MetaMask/core/pull/5787)) + ## [22.0.0] ### Changed @@ -215,7 +281,15 @@ 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@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@28.0.0...HEAD +[28.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@27.0.0...@metamask/bridge-controller@28.0.0 +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@26.0.0...@metamask/bridge-controller@27.0.0 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.1.0...@metamask/bridge-controller@26.0.0 +[25.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.1...@metamask/bridge-controller@25.1.0 +[25.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.0...@metamask/bridge-controller@25.0.1 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@24.0.0...@metamask/bridge-controller@25.0.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@23.0.0...@metamask/bridge-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@22.0.0...@metamask/bridge-controller@23.0.0 [22.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@21.0.0...@metamask/bridge-controller@22.0.0 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@20.0.0...@metamask/bridge-controller@21.0.0 [20.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@19.0.0...@metamask/bridge-controller@20.0.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 8ff4709d9d0..11fd855bfde 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "22.0.0", + "version": "28.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -53,26 +53,26 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/keyring-api": "^17.4.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/multichain-network-controller": "^0.6.0", + "@metamask/multichain-network-controller": "^0.7.0", "@metamask/polling-controller": "^13.0.0", "@metamask/utils": "^11.2.0", "bignumber.js": "^9.1.2", "reselect": "^5.1.1" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/assets-controllers": "^62.0.0", + "@metamask/accounts-controller": "^29.0.0", + "@metamask/assets-controllers": "^65.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^4.1.8", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -85,12 +85,12 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/assets-controllers": "^62.0.0", + "@metamask/accounts-controller": "^29.0.0", + "@metamask/assets-controllers": "^65.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/snaps-controllers": "^11.0.0", - "@metamask/transaction-controller": "^55.0.0" + "@metamask/transaction-controller": "^56.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index 9244db547bd..c2f82bd30c1 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -1,5 +1,53 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` +Object { + "assetExchangeRates": Object { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "quoteFetchError": null, + "quoteRequest": Object { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + "quotesInitialLoadTime": 10000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, +} +`; + +exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` +Object { + "assetExchangeRates": Object { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "quoteFetchError": null, + "quoteRequest": Object { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + "quotesInitialLoadTime": 10000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, +} +`; + exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Completed event 1`] = ` Array [ Array [ @@ -395,6 +443,58 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 1`] = ` +Object { + "assetExchangeRates": Object { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "quoteFetchError": null, + "quoteRequest": Object { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "insufficientBal": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + "quotes": Array [], + "quotesInitialLoadTime": null, + "quotesLastFetched": null, + "quotesLoadingStatus": null, + "quotesRefreshCount": 0, +} +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 2`] = ` +Object { + "assetExchangeRates": Object { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "quoteFetchError": null, + "quoteRequest": Object { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "insufficientBal": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + "quotesInitialLoadTime": 15000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, +} +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 3`] = ` Array [ Array [ "Unified SwapBridge Input Changed", @@ -516,6 +616,26 @@ Array [ "warnings": Array [], }, ], + Array [ + "Unified SwapBridge Quotes Requested", + Object { + "action_type": "crosschain-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "error_message": null, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], ] `; diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index b48243f96b4..c92e40d1bd0 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -14,6 +14,7 @@ import { SWAPS_API_V2_BASE_URL } from './constants/swaps'; import * as selectors from './selectors'; import { ChainId, + RequestStatus, SortOrder, StatusTypes, type BridgeControllerMessenger, @@ -74,76 +75,6 @@ describe('BridgeController', function () { jest.clearAllMocks(); jest.clearAllTimers(); - nock(BRIDGE_PROD_API_BASE_URL) - .get('/getAllFeatureFlags') - .reply(200, { - 'extension-config': { - refreshRate: 3, - maxRefreshCount: 3, - support: true, - chains: { - '10': { - isActiveSrc: true, - isActiveDest: false, - }, - '534352': { - isActiveSrc: true, - isActiveDest: false, - }, - '137': { - isActiveSrc: false, - isActiveDest: true, - }, - '42161': { - isActiveSrc: false, - isActiveDest: true, - }, - [ChainId.SOLANA]: { - isActiveSrc: true, - isActiveDest: true, - }, - }, - }, - 'mobile-config': { - refreshRate: 3, - maxRefreshCount: 3, - support: true, - chains: { - '10': { - isActiveSrc: true, - isActiveDest: false, - }, - '534352': { - isActiveSrc: true, - isActiveDest: false, - }, - '137': { - isActiveSrc: false, - isActiveDest: true, - }, - '42161': { - isActiveSrc: false, - isActiveDest: true, - }, - [ChainId.SOLANA]: { - isActiveSrc: true, - isActiveDest: true, - }, - }, - }, - 'approval-gas-multiplier': { - '137': 1.1, - '42161': 1.2, - '10': 1.3, - '534352': 1.4, - }, - 'bridge-gas-multiplier': { - '137': 2.1, - '42161': 2.2, - '10': 2.3, - '534352': 2.4, - }, - }); nock(BRIDGE_PROD_API_BASE_URL) .get('/getTokens?chainId=10') .reply(200, [ @@ -184,6 +115,7 @@ describe('BridgeController', function () { it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { const bridgeConfig = { + minimumVersion: '0.0.0', maxRefreshCount: 3, refreshRate: 3, support: true, @@ -390,6 +322,17 @@ describe('BridgeController', function () { }); }); + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); + }, 10000); + }); + }); + const quoteParams = { srcChainId: '0x1', destChainId: SolScope.Mainnet, @@ -504,11 +447,46 @@ describe('BridgeController', function () { bridgeController.state.quotesLastFetched, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ).toBeGreaterThan(secondFetchTime!); + const thirdFetchTime = bridgeController.state.quotesLastFetched; + + // Incoming request update aborts current polling + jest.advanceTimersByTime(10000); + await flushPromises(); + await bridgeController.updateBridgeQuoteRequestParams( + { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + }, + ); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + // eslint-disable-next-line jest/no-restricted-matchers + expect(bridgeController.state).toMatchSnapshot(); + + // Next fetch succeeds + jest.advanceTimersByTime(15000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + const { quotesLastFetched, quotes, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(quotes).toStrictEqual([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ]); + expect( + quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(thirdFetchTime!); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); - expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); // eslint-disable-next-line jest/no-restricted-matchers expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); @@ -1011,7 +989,7 @@ describe('BridgeController', function () { }, ); - it('should handle abort signals in fetchBridgeQuotes', async () => { + it('should handle errors from fetchBridgeQuotes', async () => { jest.useFakeTimers(); const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuotes'); messengerMock.call.mockReturnValue({ @@ -1021,11 +999,32 @@ describe('BridgeController', function () { jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); - // Mock fetchBridgeQuotes to throw AbortError - fetchBridgeQuotesSpy.mockImplementation(async () => { - const error = new Error('Aborted'); - error.name = 'AbortError'; - throw error; + // Fetch throws unknown Error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Other error')); + }, 1000); + }); + }); + + // Fetch succeeds + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(mockBridgeQuotesNativeErc20Eth as never); + }, 1000); + }); + }); + + // Fetch throws string error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject('Test error'); + }, 1000); + }); }); const quoteParams = { @@ -1047,25 +1046,46 @@ describe('BridgeController', function () { await flushPromises(); // Verify state wasn't updated due to abort - expect(bridgeController.state.quoteFetchError).toBeNull(); - expect(bridgeController.state.quotesLoadingStatus).toBe(0); + expect(bridgeController.state.quoteFetchError).toBe('Other error'); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.ERROR, + ); expect(bridgeController.state.quotes).toStrictEqual([]); - // Test reset abort - fetchBridgeQuotesSpy.mockRejectedValueOnce('Reset controller state'); + // Verify state wasn't updated due to reset + bridgeController.resetState(); + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(bridgeController.state.quoteFetchError).toBeNull(); + expect(bridgeController.state.quotesLoadingStatus).toBeNull(); + expect(bridgeController.state.quotes).toStrictEqual([]); + // Verify quotes are fetched await bridgeController.updateBridgeQuoteRequestParams( quoteParams, metricsContext, ); + jest.advanceTimersByTime(10000); + await flushPromises(); + const { quotes, quotesLastFetched, ...stateWithoutQuotes } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutQuotes).toMatchSnapshot(); + expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20Eth); + expect(quotesLastFetched).toBeCloseTo(Date.now()); - jest.advanceTimersByTime(1000); + jest.advanceTimersByTime(10000); await flushPromises(); + const { + quotes: quotes2, + quotesLastFetched: quotesLastFetched2, + ...stateWithoutQuotes2 + } = bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutQuotes2).toMatchSnapshot(); + expect(quotes2).toStrictEqual(mockBridgeQuotesNativeErc20Eth); - // Verify state wasn't updated due to reset - expect(bridgeController.state.quoteFetchError).toBeNull(); - expect(bridgeController.state.quotesLoadingStatus).toBe(0); - expect(bridgeController.state.quotes).toStrictEqual([]); + expect(quotesLastFetched2).toBe(quotesLastFetched); }); const getFeeSnapCalls = mockBridgeQuotesSolErc20.map(({ trade }) => [ diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index f38c030bce1..f3e5bf6678b 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -2,7 +2,7 @@ import type { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { StateMetadata } from '@metamask/base-controller'; -import type { ChainId } from '@metamask/controller-utils'; +import type { ChainId, TraceCallback } from '@metamask/controller-utils'; import { SolScope } from '@metamask/keyring-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { NetworkClientId } from '@metamask/network-controller'; @@ -20,6 +20,7 @@ import { REFRESH_INTERVAL_MS, } from './constants/bridge'; import { CHAIN_IDS } from './constants/chains'; +import { TraceName } from './constants/traces'; import { selectIsAssetExchangeRateInState } from './selectors'; import type { QuoteRequest } from './types'; import { @@ -37,6 +38,7 @@ import { getAssetIdsForToken, toExchangeRates } from './utils/assets'; import { hasSufficientBalance } from './utils/balance'; import { getDefaultBridgeControllerState, + isCrossChain, isSolanaChainId, sumHexes, } from './utils/bridge'; @@ -151,6 +153,8 @@ export class BridgeController extends StaticIntervalPollingController, ) => void; + readonly #trace: TraceCallback; + readonly #config: { customBridgeApiBaseUrl?: string; }; @@ -163,6 +167,7 @@ export class BridgeController extends StaticIntervalPollingController; @@ -182,6 +187,7 @@ export class BridgeController extends StaticIntervalPollingController, ) => void; + traceFn?: TraceCallback; }) { super({ name: BRIDGE_CONTROLLER_NAME, @@ -201,6 +207,7 @@ export class BridgeController extends StaticIntervalPollingController fn?.()) as TraceCallback); // Register action handlers this.messagingSystem.registerActionHandler( @@ -439,7 +446,6 @@ export class BridgeController extends StaticIntervalPollingController { - const { quotesInitialLoadTime, quotesRefreshCount } = this.state; this.#abortController?.abort('New quote request'); this.#abortController = new AbortController(); @@ -453,7 +459,7 @@ export class BridgeController extends StaticIntervalPollingController { const quotes = await fetchBridgeQuotes( updatedQuoteRequest, // AbortController is always defined by this line, because we assign it a few lines above, @@ -473,10 +479,29 @@ export class BridgeController extends StaticIntervalPollingController= maxRefreshCount) - ) { - this.stopAllPolling(); - } + } + const bridgeFeatureFlags = getBridgeFeatureFlags(this.messagingSystem); + const { maxRefreshCount } = bridgeFeatureFlags; - // Update quote fetching stats - const quotesLastFetched = Date.now(); - this.update((state) => { - state.quotesInitialLoadTime = - updatedQuotesRefreshCount === 1 && this.#quotesFirstFetched - ? quotesLastFetched - this.#quotesFirstFetched - : quotesInitialLoadTime; - state.quotesLastFetched = quotesLastFetched; - state.quotesRefreshCount = updatedQuotesRefreshCount; - }); + // Stop polling if the maximum number of refreshes has been reached + if ( + updatedQuoteRequest.insufficientBal || + (!updatedQuoteRequest.insufficientBal && + this.state.quotesRefreshCount >= maxRefreshCount) + ) { + this.stopAllPolling(); } + + // Update quote fetching stats + const quotesLastFetched = Date.now(); + this.update((state) => { + state.quotesInitialLoadTime = + state.quotesRefreshCount === 0 && this.#quotesFirstFetched + ? quotesLastFetched - this.#quotesFirstFetched + : this.state.quotesInitialLoadTime; + state.quotesLastFetched = quotesLastFetched; + state.quotesRefreshCount += 1; + }); }; readonly #appendL1GasFees = async ( diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index 642f87006e3..6fcfb4878b8 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -44,6 +44,7 @@ export const DEFAULT_MAX_REFRESH_COUNT = 5; export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; export const DEFAULT_FEATURE_FLAG_CONFIG = { + minimumVersion: '0.0.0', refreshRate: REFRESH_INTERVAL_MS, maxRefreshCount: DEFAULT_MAX_REFRESH_COUNT, support: false, diff --git a/packages/bridge-controller/src/constants/tokens.ts b/packages/bridge-controller/src/constants/tokens.ts index 2e65e12dee1..c17cd278f8f 100644 --- a/packages/bridge-controller/src/constants/tokens.ts +++ b/packages/bridge-controller/src/constants/tokens.ts @@ -27,7 +27,6 @@ export type SwapsTokenObject = { }; const DEFAULT_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; -const DEFAULT_SOLANA_TOKEN_ADDRESS = `${SolScope.Mainnet}/slip44:501`; const CURRENCY_SYMBOLS = { ARBITRUM: 'ETH', @@ -134,7 +133,7 @@ const BASE_SWAPS_TOKEN_OBJECT = { const SOLANA_SWAPS_TOKEN_OBJECT = { symbol: CURRENCY_SYMBOLS.SOL, name: 'Solana', - address: DEFAULT_SOLANA_TOKEN_ADDRESS, + address: DEFAULT_TOKEN_ADDRESS, decimals: 9, iconUrl: '', } as const; diff --git a/packages/bridge-controller/src/constants/traces.ts b/packages/bridge-controller/src/constants/traces.ts new file mode 100644 index 00000000000..6be388e9425 --- /dev/null +++ b/packages/bridge-controller/src/constants/traces.ts @@ -0,0 +1,4 @@ +export enum TraceName { + BridgeQuotesFetched = 'Bridge Quotes Fetched', + SwapQuotesFetched = 'Swap Quotes Fetched', +} diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index b71bf73377b..f61ca7014ee 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -28,7 +28,6 @@ export type { L1GasFees, SolanaFees, QuoteMetadata, - BridgeToken, GasMultiplierByChainId, FeatureFlagResponse, BridgeAsset, @@ -101,9 +100,14 @@ export { isSolanaChainId, getNativeAssetForChainId, getDefaultBridgeControllerState, + isCrossChain, } from './utils/bridge'; -export { isValidQuoteRequest, formatEtaInMinutes } from './utils/quote'; +export { + isValidQuoteRequest, + formatEtaInMinutes, + calcSlippagePercentage, +} from './utils/quote'; export { calcLatestSrcBalance } from './utils/balance'; @@ -129,3 +133,5 @@ export { selectIsQuoteExpired, selectBridgeFeatureFlags, } from './selectors'; + +export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 076f3f61a8a..24801143567 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -187,6 +187,7 @@ describe('Bridge Selectors', () => { refreshRate: 30000, chains: {}, support: true, + minimumVersion: '0.0.0', }, }, assetExchangeRates: {}, @@ -347,6 +348,7 @@ describe('Bridge Selectors', () => { quotesInitialLoadTime: Date.now(), remoteFeatureFlags: { bridgeConfig: { + minimumVersion: '0.0.0', maxRefreshCount: 5, refreshRate: 30000, chains: {}, @@ -488,6 +490,7 @@ describe('Bridge Selectors', () => { describe('selectBridgeFeatureFlags', () => { const mockValidBridgeConfig = { + minimumVersion: '0.0.0', refreshRate: 3, maxRefreshCount: 1, support: true, @@ -524,6 +527,7 @@ describe('Bridge Selectors', () => { }; const mockInvalidBridgeConfig = { + minimumVersion: 1, // Should be a string maxRefreshCount: 'invalid', // Should be a number refreshRate: 'invalid', // Should be a number chains: 'invalid', // Should be an object @@ -537,6 +541,7 @@ describe('Bridge Selectors', () => { }); expect(result).toStrictEqual({ + minimumVersion: '0.0.0', refreshRate: 3, maxRefreshCount: 1, support: true, @@ -581,6 +586,7 @@ describe('Bridge Selectors', () => { }); expect(result).toStrictEqual({ + minimumVersion: '0.0.0', maxRefreshCount: 5, refreshRate: 30000, chains: {}, @@ -595,6 +601,7 @@ describe('Bridge Selectors', () => { }); expect(result).toStrictEqual({ + minimumVersion: '0.0.0', maxRefreshCount: 5, refreshRate: 30000, chains: {}, @@ -610,6 +617,7 @@ describe('Bridge Selectors', () => { }); expect(result).toStrictEqual({ + minimumVersion: '0.0.0', maxRefreshCount: 5, refreshRate: 30000, chains: {}, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 50a7fb46da4..88e390c8d94 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -64,6 +64,7 @@ export type ChainConfiguration = { isActiveDest: boolean; refreshRate?: number; topAssets?: string[]; + isUnifiedUIEnabled?: boolean; }; export type L1GasFees = { @@ -325,6 +326,7 @@ export type TxData = { }; export type FeatureFlagsPlatformConfig = { + minimumVersion: string; refreshRate: number; maxRefreshCount: number; support: boolean; diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts index 72730cc8492..f9e187d0dcb 100644 --- a/packages/bridge-controller/src/utils/bridge.test.ts +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -6,6 +6,7 @@ import type { Hex } from '@metamask/utils'; import { getEthUsdtResetData, getNativeAssetForChainId, + isCrossChain, isEthUsdt, isSolanaChainId, isSwapsDefaultTokenAddress, @@ -202,4 +203,21 @@ describe('Bridge utils', () => { expect(decimalResult).toStrictEqual(stringifiedDecimalResult); }); }); + + describe('isCrossChain', () => { + it('should return false when there is no destChainId', () => { + const result = isCrossChain('0x1'); + expect(result).toBe(false); + }); + + it('should return false when srcChainId is invalid', () => { + const result = isCrossChain('a', '0x1'); + expect(result).toBe(false); + }); + + it('should return false when destChainId is invalid', () => { + const result = isCrossChain('0x1', 'a'); + expect(result).toBe(false); + }); + }); }); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index aaeea071ac6..6a56de32035 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -21,7 +21,11 @@ import { SYMBOL_TO_SLIP44_MAP, type SupportedSwapsNativeCurrencySymbols, } from '../constants/tokens'; -import type { BridgeAsset, BridgeControllerState } from '../types'; +import type { + BridgeAsset, + BridgeControllerState, + GenericQuoteRequest, +} from '../types'; import { ChainId } from '../types'; export const getDefaultBridgeControllerState = (): BridgeControllerState => { @@ -175,3 +179,24 @@ export const isSolanaChainId = ( } return chainId.toString() === ChainId.SOLANA.toString(); }; + +/** + * Checks whether the transaction is a cross-chain transaction by comparing the source and destination chainIds + * + * @param srcChainId - The source chainId + * @param destChainId - The destination chainId + * @returns Whether the transaction is a cross-chain transaction + */ +export const isCrossChain = ( + srcChainId: GenericQuoteRequest['srcChainId'], + destChainId?: GenericQuoteRequest['destChainId'], +) => { + try { + if (!destChainId) { + return false; + } + return formatChainIdToCaip(srcChainId) !== formatChainIdToCaip(destChainId); + } catch { + return false; + } +}; diff --git a/packages/bridge-controller/src/utils/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts index 7d42ff76699..febd6c0bf49 100644 --- a/packages/bridge-controller/src/utils/feature-flags.test.ts +++ b/packages/bridge-controller/src/utils/feature-flags.test.ts @@ -11,6 +11,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: { '1': { isActiveSrc: true, @@ -49,6 +50,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: { 'eip155:1': { isActiveSrc: true, @@ -87,6 +89,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: {}, }; @@ -96,6 +99,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: {}, }); }); @@ -105,6 +109,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: { 'eip155:invalid': { isActiveSrc: true, @@ -123,6 +128,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: { 'eip155:invalid': { isActiveSrc: true, @@ -149,6 +155,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: true, + minimumVersion: '0.0.0', chains: { '1': { isActiveSrc: true, @@ -236,6 +243,7 @@ describe('feature-flags', () => { maxRefreshCount: 1, refreshRate: 3, support: true, + minimumVersion: '0.0.0', chains: { 'eip155:1': { isActiveDest: true, @@ -276,6 +284,7 @@ describe('feature-flags', () => { refreshRate: 3, maxRefreshCount: 1, support: 25, + minimumVersion: '0.0.0', chains: { a: { isActiveSrc: 1, @@ -342,9 +351,112 @@ describe('feature-flags', () => { maxRefreshCount: 5, refreshRate: 30000, support: false, + minimumVersion: '0.0.0', chains: {}, }; expect(result).toStrictEqual(expectedBridgeConfig); }); + + it('should prioritize bridgeConfigV2 over bridgeConfig', async () => { + const bridgeConfigV2 = { + refreshRate: 5, + maxRefreshCount: 2, + support: true, + minimumVersion: '1.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfigV2, + bridgeConfig, + assetsNotificationsEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + refreshRate: 5, + maxRefreshCount: 2, + support: true, + minimumVersion: '1.0.0', + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); + + it('should fallback to bridgeConfig when bridgeConfigV2 is not available', async () => { + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); }); }); diff --git a/packages/bridge-controller/src/utils/feature-flags.ts b/packages/bridge-controller/src/utils/feature-flags.ts index 45e138d0c6f..8ee38ca3d87 100644 --- a/packages/bridge-controller/src/utils/feature-flags.ts +++ b/packages/bridge-controller/src/utils/feature-flags.ts @@ -47,8 +47,18 @@ export function getBridgeFeatureFlags( const remoteFeatureFlagControllerState = messenger.call( 'RemoteFeatureFlagController:getState', ); + + // bridgeConfigV2 is the feature flag for the mobile app + // bridgeConfig for Mobile has been deprecated since release of bridge and Solana in 7.46.0 was pushed back + // and there's no way to turn on bridgeConfig for 7.47.0 without affecting 7.46.0 as well. + // You will still get bridgeConfig returned from remoteFeatureFlagControllerState but you should use bridgeConfigV2 instead + // Mobile's bridgeConfig will be permanently serving the disabled variation, so falling back to it in Mobile will be ok + const rawMobileFlags = + remoteFeatureFlagControllerState?.remoteFeatureFlags?.bridgeConfigV2; + + // Extension LaunchDarkly will not have the bridgeConfigV2 field, so we'll continue to use bridgeConfig const rawBridgeConfig = remoteFeatureFlagControllerState?.remoteFeatureFlags?.bridgeConfig; - return processFeatureFlags(rawBridgeConfig); + return processFeatureFlags(rawMobileFlags || rawBridgeConfig); } diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index c481a67e434..6c24468de5a 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -104,7 +104,12 @@ export async function fetchBridgeQuotes( }); const filteredQuotes = quotes.filter((quoteResponse: unknown) => { - return validateQuoteResponse(quoteResponse); + try { + return validateQuoteResponse(quoteResponse); + } catch (error) { + console.error(error); + return false; + } }); return filteredQuotes as QuoteResponse[]; } diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index be78b5d293c..84e0ada1dd9 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -6,7 +6,7 @@ import type { AccountsControllerState } from '../../../../accounts-controller/sr import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge'; import type { BridgeControllerState, QuoteResponse, TxData } from '../../types'; import { type GenericQuoteRequest, type QuoteRequest } from '../../types'; -import { getNativeAssetForChainId } from '../bridge'; +import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { formatAddressToAssetId, formatChainIdToCaip, @@ -49,11 +49,7 @@ export const getActionType = ( srcChainId?: GenericQuoteRequest['srcChainId'], destChainId?: GenericQuoteRequest['destChainId'], ) => { - if ( - srcChainId && - formatChainIdToCaip(srcChainId) === - formatChainIdToCaip(destChainId ?? srcChainId) - ) { + if (srcChainId && !isCrossChain(srcChainId, destChainId ?? srcChainId)) { return MetricsActionType.SWAPBRIDGE_V1; } return MetricsActionType.CROSSCHAIN_V1; @@ -69,11 +65,7 @@ export const getSwapType = ( srcChainId?: GenericQuoteRequest['srcChainId'], destChainId?: GenericQuoteRequest['destChainId'], ) => { - if ( - srcChainId && - formatChainIdToCaip(srcChainId) === - formatChainIdToCaip(destChainId ?? srcChainId) - ) { + if (srcChainId && !isCrossChain(srcChainId, destChainId ?? srcChainId)) { return MetricsSwapType.SINGLE; } return MetricsSwapType.CROSSCHAIN; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 0796c7596eb..4f018b8618a 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -14,6 +14,7 @@ import { calcSwapRate, calcCost, formatEtaInMinutes, + calcSlippagePercentage, } from './quote'; import type { GenericQuoteRequest, @@ -160,27 +161,32 @@ describe('Quote Metadata Utils', () => { }); describe('calcSentAmount', () => { - const mockQuote: Quote = { - srcTokenAmount: '1000000000', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '100000000' }, - }, - } as Quote; - it('should calculate sent amount correctly with exchange rates', () => { + const mockQuote: Quote = { + srcTokenAmount: '12555423', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, + } as Quote; const result = calcSentAmount(mockQuote, { - exchangeRate: '2', + exchangeRate: '2.14', usdExchangeRate: '1.5', }); - // 1000000000 + 100000000 = 1100000000, then divided by 10^6 - expect(result.amount).toBe('1100'); - expect(result.valueInCurrency).toBe('2200'); - expect(result.usd).toBe('1650'); + expect(result.amount).toBe('112.555423'); + expect(result.valueInCurrency).toBe('240.86860522'); + expect(result.usd).toBe('168.8331345'); }); it('should handle missing exchange rates', () => { + const mockQuote: Quote = { + srcTokenAmount: '1000000000', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, + } as Quote; const result = calcSentAmount(mockQuote, {}); expect(result.amount).toBe('1100'); @@ -189,6 +195,13 @@ describe('Quote Metadata Utils', () => { }); it('should handle zero values', () => { + const mockQuote: Quote = { + srcTokenAmount: '0', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '0' }, + }, + } as Quote; const zeroQuote = { ...mockQuote, srcTokenAmount: '0', @@ -522,4 +535,37 @@ describe('Quote Metadata Utils', () => { expect(result.usd).toBeNull(); }); }); + + describe('calcSlippagePercentage', () => { + it.each([ + ['100', null, '100', null, '0'], + ['95', '95', '100', '100', '5'], + ['98.3', '98.3', '100', '100', '1.7'], + [null, '100', null, '100', '0'], + [null, null, null, '100', null], + ['105', '105', '100', '100', '5'], + ])( + 'calcSlippagePercentage: calculate slippage absolute value for received amount %p, usd %p, sent amount %p, usd %p to expected slippage %p', + ( + returnValueInCurrency: string | null, + returnUsd: string | null, + sentValueInCurrency: string | null, + sentUsd: string | null, + expectedSlippage: string | null, + ) => { + const result = calcSlippagePercentage( + { + valueInCurrency: returnValueInCurrency, + usd: returnUsd, + }, + { + amount: '1000', + valueInCurrency: sentValueInCurrency, + usd: sentUsd, + }, + ); + expect(result).toBe(expectedSlippage); + }, + ); + }); }); diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 638c2e4a55d..07c5c6d01d7 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -304,6 +304,38 @@ export const calcCost = ( : null, }); +/** + * Calculates the slippage absolute value percentage based on the adjusted return and sent amount. + * + * @param adjustedReturn - Adjusted return value + * @param sentAmount - Sent amount value + * @returns the slippage in percentage + */ +export const calcSlippagePercentage = ( + adjustedReturn: ReturnType, + sentAmount: ReturnType, +): string | null => { + const cost = calcCost(adjustedReturn, sentAmount); + + if (cost.valueInCurrency && sentAmount.valueInCurrency) { + return new BigNumber(cost.valueInCurrency) + .div(sentAmount.valueInCurrency) + .times(100) + .abs() + .toString(); + } + + if (cost.usd && sentAmount.usd) { + return new BigNumber(cost.usd) + .div(sentAmount.usd) + .times(100) + .abs() + .toString(); + } + + return null; +}; + export const formatEtaInMinutes = ( estimatedProcessingTimeInSeconds: number, ) => { diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 12428474708..76538f6238e 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -20,6 +20,7 @@ describe('validators', () => { maxRefreshCount: 5, refreshRate: 30000, support: true, + minimumVersion: '0.0.0', }, type: 'all evm chains active', expected: true, @@ -30,6 +31,7 @@ describe('validators', () => { maxRefreshCount: 1, refreshRate: 3000000, support: false, + minimumVersion: '0.0.0', }, type: 'bridge disabled', expected: true, @@ -93,6 +95,7 @@ describe('validators', () => { maxRefreshCount: 5, refreshRate: 30000, support: true, + minimumVersion: '0.0.0', }, type: 'evm and solana chain config', expected: true, @@ -102,6 +105,28 @@ describe('validators', () => { type: 'no response', expected: false, }, + { + response: { + chains: { + '1': { isActiveDest: true, isActiveSrc: true }, + '10': { isActiveDest: true, isActiveSrc: true }, + '137': { isActiveDest: true, isActiveSrc: true }, + '324': { isActiveDest: true, isActiveSrc: true }, + '42161': { isActiveDest: true, isActiveSrc: true }, + '43114': { isActiveDest: true, isActiveSrc: true }, + '56': { isActiveDest: true, isActiveSrc: true }, + '59144': { isActiveDest: true, isActiveSrc: true }, + '8453': { isActiveDest: true, isActiveSrc: true }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + extraField: 'foo', + }, + type: 'all evm chains active + an extra field not specified in the schema', + expected: true, + }, ])( 'should return $expected if the response is valid: $type', ({ diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index d1d8dfe1d80..b1d4d1e0aeb 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -12,6 +12,7 @@ import { enums, define, union, + assert, } from '@metamask/superstruct'; import { isStrictHexString } from '@metamask/utils'; @@ -58,9 +59,11 @@ export const validateFeatureFlagsResponse = ( isActiveDest: boolean(), refreshRate: optional(number()), topAssets: optional(array(string())), + isUnifiedUIEnabled: optional(boolean()), }); const PlatformConfigSchema = type({ + minimumVersion: string(), refreshRate: number(), maxRefreshCount: number(), support: boolean(), @@ -133,5 +136,6 @@ export const validateQuoteResponse = (data: unknown): data is QuoteResponse => { estimatedProcessingTimeInSeconds: number(), }); - return is(data, QuoteResponseSchema); + assert(data, QuoteResponseSchema); + return true; }; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 53cfc79a02b..e685b9016ee 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,8 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [25.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^28.0.0` ([#5863](https://github.com/MetaMask/core/pull/5863)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^27.0.0` ([#5845](https://github.com/MetaMask/core/pull/5845)) + +## [23.0.0] + +### Added + +- Subscribe to TransactionController and MultichainTransactionsController tx confirmed and failed events for swaps ([#5829](https://github.com/MetaMask/core/pull/5829)) + +### Changed + +- **BREAKING:** bump `@metamask/bridge-controller` peer dependency to `^26.0.0` ([#5842](https://github.com/MetaMask/core/pull/5842)) +- **BREAKING:** Remove the published bridgeTransactionComplete and bridgeTransactionFailed events ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Modify events to use `swap` and `swapApproval` TransactionTypes when src and dest chain are the same ([#5829](https://github.com/MetaMask/core/pull/5829)) + +## [22.0.0] + +### Added + +- Subscribe to TransactionController and MultichainTransactionsController tx confirmed and failed events for swaps ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Error logs for invalid getTxStatus responses ([#5816](https://github.com/MetaMask/core/pull/5816)) + +### Changed + +- **BREAKING:** Remove the published bridgeTransactionComplete and bridgeTransactionFailed events ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Modify events to use `swap` and `swapApproval` TransactionTypes when src and dest chain are the same ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Bump `@metamask/bridge-controller` dev dependency to `^25.0.1` ([#5811](https://github.com/MetaMask/core/pull/5811)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Don't start or restart getTxStatus polling if transaction is a swap ([#5831](https://github.com/MetaMask/core/pull/5831)) + +## [21.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/bridge-controller` peer dependency to `^25.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [20.1.0] + +### Added + +- Sentry traces for Swap and Bridge `TransactionApprovalCompleted` and `TransactionCompleted` events ([#5780](https://github.com/MetaMask/core/pull/5780)) + +### Changed + +- `traceFn` added to BridgeStatusController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) + +## [20.0.0] + ### Changed +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^23.0.0` ([#5795](https://github.com/MetaMask/core/pull/5795)) - Replace `bridgePriceData` with `priceData` from QuoteResponse object ([#5784](https://github.com/MetaMask/core/pull/5784)) ## [19.0.0] @@ -205,7 +268,14 @@ 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@19.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@25.0.0...HEAD +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@24.0.0...@metamask/bridge-status-controller@25.0.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@23.0.0...@metamask/bridge-status-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@22.0.0...@metamask/bridge-status-controller@23.0.0 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@21.0.0...@metamask/bridge-status-controller@22.0.0 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.1.0...@metamask/bridge-status-controller@21.0.0 +[20.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.0.0...@metamask/bridge-status-controller@20.1.0 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@19.0.0...@metamask/bridge-status-controller@20.0.0 [19.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@18.0.0...@metamask/bridge-status-controller@19.0.0 [18.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@17.0.1...@metamask/bridge-status-controller@18.0.0 [17.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@17.0.0...@metamask/bridge-status-controller@17.0.1 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index a66ab2314fe..1729bd3b5bf 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": "19.0.0", + "version": "25.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -48,23 +48,24 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/keyring-api": "^17.4.0", "@metamask/polling-controller": "^13.0.0", "@metamask/superstruct": "^3.1.0", - "@metamask/user-operation-controller": "^34.0.0", + "@metamask/user-operation-controller": "^35.0.0", "@metamask/utils": "^11.2.0", "bignumber.js": "^9.1.2", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^22.0.0", + "@metamask/bridge-controller": "^28.0.0", "@metamask/gas-fee-controller": "^23.0.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/multichain-transactions-controller": "^1.0.0", + "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -77,12 +78,13 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/bridge-controller": "^22.0.0", + "@metamask/accounts-controller": "^29.0.0", + "@metamask/bridge-controller": "^28.0.0", "@metamask/gas-fee-controller": "^23.0.0", + "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/snaps-controllers": "^11.0.0", - "@metamask/transaction-controller": "^55.0.0" + "@metamask/transaction-controller": "^56.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index ee6919ee327..e291a32e8ca 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -120,6 +120,23 @@ Object { } `; +exports[`BridgeStatusController constructor should setup correctly 1`] = ` +Array [ + Array [ + "TransactionController:transactionFailed", + [Function], + ], + Array [ + "TransactionController:transactionConfirmed", + [Function], + ], + Array [ + "MultichainTransactionsController:transactionConfirmed", + [Function], + ], +] +`; + exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` Array [ Array [ @@ -336,7 +353,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should delay after submitting linea approval 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -355,7 +372,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should delay after submitting linea approval 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -456,7 +473,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should delay after submitting linea approval 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 3`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -532,7 +549,32 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 4`] = ` +Array [ + Array [ + Object { + "data": Object { + "srcChainId": "eip155:59144", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], + Array [ + Object { + "data": Object { + "srcChainId": "eip155:59144", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -551,7 +593,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -652,7 +694,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 3`] = ` Array [ Array [ Object { @@ -672,7 +714,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 4`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -734,7 +776,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 5`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 5`] = ` Array [ Array [ Object { @@ -759,7 +801,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart transactions 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions 1`] = ` Object { "approvalTxId": undefined, "chainId": "0xa4b1", @@ -789,7 +831,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should handle smart transactions 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -890,7 +932,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should handle smart transactions 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions 3`] = ` Array [ Array [ Object { @@ -910,7 +952,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart transactions 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions 4`] = ` Array [ Array [ Object { @@ -935,7 +977,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart transactions 5`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions 5`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -994,7 +1036,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should reset USDT allowance 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -1013,7 +1055,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should reset USDT allowance 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -1114,7 +1156,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should reset USDT allowance 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 3`] = ` Array [ Array [ Object { @@ -1164,7 +1206,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should reset USDT allowance 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 4`] = ` Array [ Array [ Object { @@ -1229,7 +1271,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should reset USDT allowance 5`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 5`] = ` Array [ Array [ "BridgeController:getBridgeERC20Allowance", @@ -1324,7 +1366,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with approval 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -1343,7 +1385,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with approval 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -1444,7 +1486,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with approval 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 3`] = ` Array [ Array [ Object { @@ -1489,7 +1531,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with approval 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 4`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -1565,7 +1607,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with no approval 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -1584,7 +1626,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with no approval 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 2`] = ` Object { "bridge": "across", "bridgeId": "lifi", @@ -1685,7 +1727,7 @@ Object { } `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with no approval 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 3`] = ` Array [ Array [ Object { @@ -1705,7 +1747,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with no approval 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 4`] = ` Array [ Array [ Object { @@ -1730,7 +1772,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should successfully submit an EVM bridge transaction with no approval 5`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 5`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -1792,7 +1834,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should throw an error if approval tx fails 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx fails 1`] = ` Array [ Array [ Object { @@ -1817,7 +1859,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should throw an error if approval tx fails 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx fails 2`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -1833,7 +1875,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should throw an error if approval tx meta is undefined 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx meta is undefined 1`] = ` Array [ Array [ Object { @@ -1858,7 +1900,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should throw an error if approval tx meta is undefined 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx meta is undefined 2`] = ` Array [ Array [ "AccountsController:getAccountByAddress", @@ -1877,102 +1919,1032 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: Solana should successfully submit a Solana transaction 1`] = ` +exports[`BridgeStatusController submitTx: EVM swap should handle smart accounts (4337) 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart accounts (4337) 2`] = ` +Object { + "bridge": "across", + "bridgeId": "lifi", + "destChainId": 42161, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "refuel": false, + "srcChainId": 42161, + "srcTxHash": "0xevmTxHash", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart accounts (4337) 3`] = ` Array [ Array [ - "AccountsController:getSelectedMultichainAccount", - ], - Array [ - "SnapController:handleRequest", Object { - "handler": "onKeyringRequest", - "origin": "metamask", - "request": Object { - "id": "test-uuid-1234", - "jsonrpc": "2.0", - "method": "keyring_submitRequest", - "params": Object { - "account": undefined, - "id": "test-uuid-1234", - "request": Object { - "method": "signAndSendTransaction", - "params": Object { - "account": Object { - "address": "0x123...", - }, - "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", - }, - }, - "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - }, + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "21000", + "gasLimit": "21000", + "to": "0xbridgeContract", + "value": "0x0", }, - "snapId": "test-snap", }, ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart accounts (4337) 4`] = ` +Array [ Array [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Snap Confirmation Page Viewed", - Object {}, + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + Array [ + "GasFeeController:getState", + ], + Array [ + "TransactionController:getState", ], Array [ "AccountsController:getSelectedMultichainAccount", ], Array [ "AccountsController:getAccountByAddress", - "0x123...", + "", ], Array [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", Object { - "action_type": "crosschain-v1", + "action_type": "swapbridge-v1", "actual_time_minutes": 0, "allowance_reset_transaction": undefined, "approval_transaction": undefined, - "chain_id_destination": "eip155:1", - "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", "custom_slippage": true, "destination_transaction": "PENDING", "error_message": "error_message", "gas_included": false, "is_hardware_wallet": false, "price_impact": 0, - "provider": "test-bridge_test-bridge", + "provider": "lifi_across", "quote_vs_execution_ratio": 1, - "quoted_time_minutes": 5, + "quoted_time_minutes": 0, "quoted_vs_used_gas_ratio": 1, "security_warnings": Array [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, - "swap_type": "crosschain", - "token_address_destination": "eip155:1/slip44:60", - "token_address_source": "eip155:1399811149/slip44:501", + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", "token_symbol_destination": "ETH", - "token_symbol_source": "SOL", - "usd_actual_gas": 5, - "usd_actual_return": 1000, - "usd_amount_source": 100, - "usd_quoted_gas": 5, - "usd_quoted_return": 1000, + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, }, ], ] `; -exports[`BridgeStatusController submitTx: Solana should successfully submit a Solana transaction 2`] = ` +exports[`BridgeStatusController submitTx: EVM swap should handle smart accounts (4337) 5`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "gasLimit": "21000", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + Object { + "actionId": "1234567890.456", + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 1`] = ` Object { "approvalTxId": undefined, - "chainId": "0x416edef1601be", - "destinationChainId": "0x1", - "destinationTokenAddress": "0x...", - "destinationTokenAmount": "0.5", + "chainId": "0xa4b1", + "destinationChainId": "0xa4b1", + "destinationTokenAddress": "0x0000000000000000000000000000000000000000", + "destinationTokenAmount": "990654755978612", "destinationTokenDecimals": 18, "destinationTokenSymbol": "ETH", - "hash": "signature", - "id": "test-uuid-1234", - "isBridgeTx": true, + "hash": "0xevmTxHash", + "id": "test-tx-id", + "sourceTokenAddress": "0x0000000000000000000000000000000000000000", + "sourceTokenAmount": "991250000000000", + "sourceTokenDecimals": 18, + "sourceTokenSymbol": "ETH", + "status": "unapproved", + "swapTokenValue": "1.234", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 2`] = ` +Object { + "bridge": "across", + "bridgeId": "lifi", + "destChainId": 42161, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "refuel": false, + "srcChainId": 42161, + "srcTxHash": "0xevmTxHash", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 3`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "21000", + "gasLimit": "21000", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 4`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "gasLimit": "21000", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + Object { + "actionId": "1234567890.456", + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "swap", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 5`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + Array [ + "GasFeeController:getState", + ], + Array [ + "AccountsController:getSelectedMultichainAccount", + ], + Array [ + "AccountsController:getAccountByAddress", + "", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": true, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 2`] = ` +Object { + "bridge": "across", + "bridgeId": "lifi", + "destChainId": 42161, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "refuel": false, + "srcChainId": 42161, + "srcTxHash": "0xevmTxHash", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 3`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "gasLimit": "21000", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + Object { + "actionId": "1234567890.456", + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "swapApproval", + }, + ], + Array [ + Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "gasLimit": "21000", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + Object { + "actionId": "1234567890.456", + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 4`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + Array [ + "GasFeeController:getState", + ], + Array [ + "TransactionController:getState", + ], + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + Array [ + "GasFeeController:getState", + ], + Array [ + "TransactionController:getState", + ], + Array [ + "AccountsController:getSelectedMultichainAccount", + ], + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 2`] = ` +Object { + "bridge": "across", + "bridgeId": "lifi", + "destChainId": 42161, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000032", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "WETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "WETH", + "priceUSD": "2478.63", + "symbol": "WETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "refuel": false, + "srcChainId": 42161, + "srcTxHash": "0xevmTxHash", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 3`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "21000", + "gasLimit": "21000", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 4`] = ` +Array [ + Array [ + Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "gasLimit": "21000", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + Object { + "actionId": "1234567890.456", + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 5`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + Array [ + "GasFeeController:getState", + ], + Array [ + "TransactionController:getState", + ], + Array [ + "AccountsController:getSelectedMultichainAccount", + ], + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "WETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana should successfully submit a Solana transaction 1`] = ` +Array [ + Array [ + "AccountsController:getSelectedMultichainAccount", + ], + Array [ + "SnapController:handleRequest", + Object { + "handler": "onKeyringRequest", + "origin": "metamask", + "request": Object { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "keyring_submitRequest", + "params": Object { + "account": undefined, + "id": "test-uuid-1234", + "request": Object { + "method": "signAndSendTransaction", + "params": Object { + "account": Object { + "address": "0x123...", + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", + }, + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "test-snap", + }, + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Snap Confirmation Page Viewed", + Object {}, + ], + Array [ + "AccountsController:getSelectedMultichainAccount", + ], + Array [ + "AccountsController:getAccountByAddress", + "0x123...", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + Object { + "action_type": "crosschain-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 5, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_actual_gas": 5, + "usd_actual_return": 1000, + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 1000, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana should successfully submit a Solana transaction 2`] = ` +Object { + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": "0x...", + "destinationTokenAmount": "0.5", + "destinationTokenDecimals": 18, + "destinationTokenSymbol": "ETH", + "hash": "signature", + "id": "test-uuid-1234", + "isBridgeTx": true, "isSolana": true, "networkClientId": "test-snap", "origin": "test-snap", @@ -2213,6 +3185,196 @@ Object { } `; +exports[`BridgeStatusController subscription handlers MultichainTransactionsController:transactionConfirmed should track completed event for other transaction types 1`] = `Array []`; + +exports[`BridgeStatusController subscription handlers MultichainTransactionsController:transactionConfirmed should track completed event for swap transaction 1`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not track completed event for other transaction types 1`] = `Array []`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should track completed event for swap transaction 1`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for approved status 1`] = `Array []`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for other transaction types 1`] = `Array []`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for signed status 1`] = `Array []`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction 1`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + Object { + "action_type": "crosschain-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for swap transaction 1`] = ` +Array [ + Array [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + Array [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + Object { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "gas_included": false, + "is_hardware_wallet": false, + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 1, + "security_warnings": Array [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the given address 1`] = ` Array [ Array [ diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 2723795ebec..97c2e46703b 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -1,20 +1,43 @@ /* eslint-disable jest/no-conditional-in-test */ /* eslint-disable jest/no-restricted-matchers */ +import type { AccountsControllerActions } from '@metamask/accounts-controller'; +import { Messenger } from '@metamask/base-controller'; +import type { + BridgeControllerActions, + BridgeControllerEvents, +} from '@metamask/bridge-controller'; import { type QuoteResponse, type QuoteMetadata, StatusTypes, + BridgeController, + getNativeAssetForChainId, } from '@metamask/bridge-controller'; import { ChainId } from '@metamask/bridge-controller'; import { ActionTypes, FeeType } from '@metamask/bridge-controller'; -import { EthAccountType } from '@metamask/keyring-api'; -import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import { EthAccountType, SolScope } from '@metamask/keyring-api'; +import { + TransactionType, + TransactionStatus, +} from '@metamask/transaction-controller'; +import type { + TransactionControllerActions, + TransactionControllerEvents, + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; import type { CaipAssetType } from '@metamask/utils'; import { numberToHex } from '@metamask/utils'; import { BridgeStatusController } from './bridge-status-controller'; -import { DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE } from './constants'; +import { + BRIDGE_STATUS_CONTROLLER_NAME, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, +} from './constants'; +import type { + BridgeStatusControllerActions, + BridgeStatusControllerEvents, +} from './types'; import { type BridgeId, type StartPollingForBridgeTxStatusArgsSerialized, @@ -26,6 +49,8 @@ import { import * as bridgeStatusUtils from './utils/bridge-status'; import * as transactionUtils from './utils/transaction'; import { flushPromises } from '../../../tests/helpers'; +import { CHAIN_IDS } from '../../bridge-controller/src/constants/chains'; +import type { MultichainTransactionsControllerEvents } from '../../multichain-transactions-controller/src/MultichainTransactionsController'; jest.mock('uuid', () => ({ v4: () => 'test-uuid-1234', @@ -41,6 +66,7 @@ const EMPTY_INIT_STATE: BridgeStatusControllerState = { ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, }; +const mockMessengerSubscribe = jest.fn(); const MockStatusResponse = { getPending: ({ srcTxHash = '0xsrcTxHash1', @@ -265,7 +291,7 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', from: account, value: '0x038d7ea4c68000', - data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', gasLimit: 282915, }, approval: null, @@ -398,6 +424,38 @@ const MockTxHistory = { completionTime: undefined, }, }), + getPendingSwap: ({ + txMetaId = 'swapTxMetaId1', + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 42161, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: undefined, + }, + approvalTxId: undefined, + isStxEnabled: false, + hasApprovalTx: false, + completionTime: undefined, + }, + }), getComplete: ({ txMetaId = 'bridgeTxMetaId1', srcTxHash = '0xsrcTxHash1', @@ -465,6 +523,7 @@ const getMessengerMock = ({ } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -523,10 +582,11 @@ const addTransactionFn = jest.fn(); const estimateGasFeeFn = jest.fn(); const addUserOperationFromTransactionFn = jest.fn(); -const getController = (call: jest.Mock) => { +const getController = (call: jest.Mock, traceFn?: jest.Mock) => { const controller = new BridgeStatusController({ messenger: { call, + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -536,6 +596,7 @@ const getController = (call: jest.Mock) => { addTransactionFn, estimateGasFeeFn, addUserOperationFromTransactionFn, + traceFn, }); jest.spyOn(controller, 'startPolling').mockImplementation(jest.fn()); @@ -566,6 +627,7 @@ describe('BridgeStatusController', () => { addUserOperationFromTransactionFn: jest.fn(), }); expect(bridgeStatusController.state).toStrictEqual(EMPTY_INIT_STATE); + expect(mockMessengerSubscribe.mock.calls).toMatchSnapshot(); }); it('rehydrates the tx history state', async () => { // Setup @@ -605,6 +667,7 @@ describe('BridgeStatusController', () => { txHistory: { ...MockTxHistory.getPending(), ...MockTxHistory.getUnknown(), + ...MockTxHistory.getPendingSwap(), }, }, clientId: BridgeClientId.EXTENSION, @@ -736,6 +799,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -808,17 +872,6 @@ describe('BridgeStatusController', () => { // Assertions expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect(messengerMock.publish).toHaveBeenCalledWith( - 'BridgeStatusController:bridgeTransactionComplete', - { - bridgeHistoryItem: expect.objectContaining({ - txMetaId: 'bridgeTxMetaId1', - status: expect.objectContaining({ - status: 'COMPLETE', - }), - }), - }, - ); // Cleanup jest.restoreAllMocks(); @@ -854,17 +907,6 @@ describe('BridgeStatusController', () => { // Assertions expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); - expect(messengerMock.publish).toHaveBeenCalledWith( - 'BridgeStatusController:bridgeTransactionFailed', - { - bridgeHistoryItem: expect.objectContaining({ - txMetaId: 'bridgeTxMetaId1', - status: expect.objectContaining({ - status: 'FAILED', - }), - }), - }, - ); expect(messengerMock.call.mock.calls).toMatchSnapshot(); // Cleanup @@ -904,6 +946,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -992,6 +1035,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1078,6 +1122,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1178,6 +1223,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1471,7 +1517,7 @@ describe('BridgeStatusController', () => { }); }); - describe('submitTx: EVM', () => { + describe('submitTx: EVM bridge', () => { const mockEvmQuoteResponse = { ...getMockQuote(), quote: { @@ -1837,12 +1883,17 @@ describe('BridgeStatusController', () => { const handleLineaDelaySpy = jest .spyOn(transactionUtils, 'handleLineaDelay') .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); setupApprovalMocks(); setupBridgeMocks(); - const { controller, startPollingForBridgeTxStatusSpy } = - getController(mockMessengerCall); + const { controller, startPollingForBridgeTxStatusSpy } = getController( + mockMessengerCall, + mockTraceFn, + ); const lineaQuoteResponse = { ...mockEvmQuoteResponse, @@ -1853,6 +1904,7 @@ describe('BridgeStatusController', () => { const result = await controller.submitTx(lineaQuoteResponse, false); controller.stopAllPolling(); + expect(mockTraceFn).toHaveBeenCalledTimes(2); expect(handleLineaDelaySpy).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); @@ -1866,6 +1918,556 @@ describe('BridgeStatusController', () => { 1234567890, ); expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }); + }); + + describe('submitTx: EVM swap', () => { + const mockEvmQuoteResponse = { + ...getMockQuote(), + quote: { + ...getMockQuote(), + srcChainId: 42161, + destChainId: 42161, + }, + estimatedProcessingTimeInSeconds: 0, + sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + gasFee: { amount: '1.234', valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: null, usd: null }, + swapRate: '1.234', + cost: { valueInCurrency: null, usd: null }, + trade: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: 42161, + gasLimit: 21000, + }, + approval: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: 42161, + gasLimit: 21000, + }, + } as QuoteResponse & QuoteMetadata; + + const mockEvmTxMeta = { + id: 'test-tx-id', + hash: '0xevmTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swap, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + const mockApprovalTxMeta = { + id: 'test-approval-tx-id', + hash: '0xapprovalTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swapApproval, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + const mockEstimateGasFeeResult = { + estimates: { + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + jest.spyOn(Math, 'random').mockReturnValue(0.456); + }); + + const setupApprovalMocks = () => { + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockReturnValueOnce({ + gasFeeEstimates: { estimatedBaseFee: '0x1234' }, + }); + estimateGasFeeFn.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionFn.mockResolvedValueOnce({ + transactionMeta: mockApprovalTxMeta, + result: Promise.resolve('0xapprovalTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockApprovalTxMeta], + }); + }; + + const setupBridgeMocks = () => { + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + mockMessengerCall.mockReturnValueOnce({ + gasFeeEstimates: { estimatedBaseFee: '0x1234' }, + }); + estimateGasFeeFn.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionFn.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + }; + + it('should successfully submit an EVM swap transaction with approval', async () => { + setupApprovalMocks(); + setupBridgeMocks(); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const result = await controller.submitTx(mockEvmQuoteResponse, false); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].statusRequest, + ).toMatchSnapshot(); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].bridgeTxMeta, + ).toStrictEqual(result); + expect(startPollingForBridgeTxStatusSpy.mock.lastCall[0].startTime).toBe( + 1234567890, + ); + expect(addTransactionFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(addUserOperationFromTransactionFn).not.toHaveBeenCalled(); + }); + + it('should successfully submit an EVM swap transaction with no approval', async () => { + setupBridgeMocks(); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const erc20Token = { + address: '0x0000000000000000000000000000000000000032', + assetId: `eip155:10/slip44:60` as CaipAssetType, + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'WETH', + coinKey: 'WETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }; + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + { + ...quoteWithoutApproval, + quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + }, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].statusRequest, + ).toMatchSnapshot(); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].bridgeTxMeta, + ).toStrictEqual(result); + expect(startPollingForBridgeTxStatusSpy.mock.lastCall[0].startTime).toBe( + 1234567890, + ); + expect(estimateGasFeeFn.mock.calls).toMatchSnapshot(); + expect(addTransactionFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(addUserOperationFromTransactionFn).not.toHaveBeenCalled(); + }); + + it('should handle smart transactions', async () => { + setupBridgeMocks(); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx(quoteWithoutApproval, true); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].statusRequest, + ).toMatchSnapshot(); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].bridgeTxMeta, + ).toStrictEqual(result); + expect(startPollingForBridgeTxStatusSpy.mock.lastCall[0].startTime).toBe( + 1234567890, + ); + expect(estimateGasFeeFn.mock.calls).toMatchSnapshot(); + expect(addTransactionFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(addUserOperationFromTransactionFn).not.toHaveBeenCalled(); + }); + + it('should handle smart accounts (4337)', async () => { + mockMessengerCall.mockReturnValueOnce({ + ...mockSelectedAccount, + type: EthAccountType.Erc4337, + }); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + estimateGasFeeFn.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockReturnValueOnce({ + gasFeeEstimates: { estimatedBaseFee: '0x1234' }, + }); + addUserOperationFromTransactionFn.mockResolvedValueOnce({ + id: 'user-op-id', + transactionHash: Promise.resolve('0xevmTxHash'), + hash: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + estimateGasFeeFn.mockResolvedValueOnce(mockEstimateGasFeeResult); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx(quoteWithoutApproval, false); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].statusRequest, + ).toMatchSnapshot(); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0].bridgeTxMeta, + ).toStrictEqual(result); + expect(startPollingForBridgeTxStatusSpy.mock.lastCall[0].startTime).toBe( + 1234567890, + ); + expect(estimateGasFeeFn.mock.calls).toMatchSnapshot(); + expect(addTransactionFn).not.toHaveBeenCalled(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(addUserOperationFromTransactionFn.mock.calls).toMatchSnapshot(); + }); + }); + + describe('subscription handlers', () => { + let mockBridgeStatusMessenger: jest.Mocked; + let mockTrackEventFn: jest.Mock; + + let mockMessenger: Messenger< + | BridgeStatusControllerActions + | TransactionControllerActions + | BridgeControllerActions + | AccountsControllerActions, + | BridgeStatusControllerEvents + | TransactionControllerEvents + | BridgeControllerEvents + | MultichainTransactionsControllerEvents + >; + + beforeEach(() => { + mockMessenger = new Messenger< + | BridgeStatusControllerActions + | TransactionControllerActions + | BridgeControllerActions + | AccountsControllerActions, + | BridgeStatusControllerEvents + | TransactionControllerEvents + | BridgeControllerEvents + | MultichainTransactionsControllerEvents + >(); + + jest.spyOn(mockMessenger, 'call').mockImplementation((...args) => { + console.log('call', args); + return Promise.resolve(); + }); + + mockBridgeStatusMessenger = mockMessenger.getRestricted({ + name: BRIDGE_STATUS_CONTROLLER_NAME, + allowedActions: [ + 'TransactionController:getState', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'AccountsController:getAccountByAddress', + ], + allowedEvents: [ + 'TransactionController:transactionFailed', + 'TransactionController:transactionConfirmed', + 'MultichainTransactionsController:transactionConfirmed', + ], + }) as never; + + const mockBridgeMessenger = mockMessenger.getRestricted({ + name: 'BridgeController', + allowedActions: [], + allowedEvents: [], + }); + mockTrackEventFn = jest.fn(); + new BridgeController({ + messenger: mockBridgeMessenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + trackMetaMetricsFn: mockTrackEventFn, + getLayer1GasFee: jest.fn(), + }); + + new BridgeStatusController({ + messenger: mockBridgeStatusMessenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionFn: jest.fn(), + estimateGasFeeFn: jest.fn(), + addUserOperationFromTransactionFn: jest.fn(), + state: { + txHistory: { + ...MockTxHistory.getPending(), + ...MockTxHistory.getPendingSwap(), + }, + }, + }); + }); + + describe('TransactionController:transactionFailed', () => { + it('should track failed event for bridge transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionFailed', { + error: 'tx-error', + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }, + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should track failed event for swap transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionFailed', { + error: 'tx-error', + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.failed, + id: 'swapTxMetaId1', + }, + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track failed event for signed status', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionFailed', { + error: 'tx-error', + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.signed, + id: 'swapTxMetaId1', + }, + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track failed event for approved status', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionFailed', { + error: 'tx-error', + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.approved, + id: 'swapTxMetaId1', + }, + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track failed event for other transaction types', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionFailed', { + error: 'tx-error', + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.simpleSend, + status: TransactionStatus.failed, + id: 'simpleSendTxMetaId1', + }, + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + }); + + describe('TransactionController:transactionConfirmed', () => { + it('should track completed event for swap transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionConfirmed', { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'swapTxMetaId1', + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track completed event for other transaction types', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish('TransactionController:transactionConfirmed', { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1', + }); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + }); + + describe('MultichainTransactionsController:transactionConfirmed', () => { + it('should track completed event for swap transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'MultichainTransactionsController:transactionConfirmed', + { + from: { + address: 'address-id', + asset: { + type: getNativeAssetForChainId(SolScope.Mainnet).assetId, + fungible: true, + unit: 'SOL', + amount: '1000', + }, + } as never, + chain: SolScope.Mainnet, + type: 'swap', + status: TransactionStatus.confirmed, + id: 'swapTxMetaId1', + account: 'test-account-id', + timestamp: Date.now(), + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { + type: getNativeAssetForChainId(SolScope.Mainnet).assetId, + fungible: true, + unit: 'SOL', + amount: '1000', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: Date.now(), + }, + ], + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should track completed event for other transaction types', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'MultichainTransactionsController:transactionConfirmed', + { + from: { + address: 'address-id', + asset: { + type: getNativeAssetForChainId(SolScope.Mainnet).assetId, + fungible: true, + unit: 'SOL', + amount: '1000', + }, + } as never, + chain: SolScope.Mainnet, + type: 'bridge:send', + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId100', + account: 'test-account-id', + timestamp: Date.now(), + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { + type: getNativeAssetForChainId(SolScope.Mainnet).assetId, + fungible: true, + unit: 'SOL', + amount: '1000', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: Date.now(), + }, + ], + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); }); }); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index cf8ccc6a80b..0e731717485 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -13,7 +13,10 @@ import { StatusTypes, UnifiedSwapBridgeEventName, getActionType, + formatChainIdToCaip, + isCrossChain, } from '@metamask/bridge-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; import { toHex } from '@metamask/controller-utils'; import { EthAccountType } from '@metamask/keyring-api'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; @@ -35,6 +38,7 @@ import { BRIDGE_STATUS_CONTROLLER_NAME, DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, REFRESH_INTERVAL_MS, + TraceName, } from './constants'; import { type BridgeStatusControllerMessenger } from './types'; import type { @@ -103,6 +107,8 @@ export class BridgeStatusController extends StaticIntervalPollingController; @@ -123,6 +130,7 @@ export class BridgeStatusController extends StaticIntervalPollingController fn?.()) as TraceCallback); // Register action handlers this.messagingSystem.registerActionHandler( @@ -166,6 +175,51 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const { type, status, id } = transactionMeta; + if ( + type && + [TransactionType.bridge, TransactionType.swap].includes(type) && + ![TransactionStatus.signed, TransactionStatus.approved].includes( + status, + ) + ) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + id, + ); + } + }, + ); + + this.messagingSystem.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta) => { + const { type, id } = transactionMeta; + if (type === TransactionType.swap) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + id, + ); + } + }, + ); + + this.messagingSystem.subscribe( + 'MultichainTransactionsController:transactionConfirmed', + (transactionMeta) => { + const { type, id } = transactionMeta; + if (type === TransactionType.swap) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + id, + ); + } + }, + ); + // If you close the extension, but keep the browser open, the polling continues // If you close the browser, the polling stops // Check for historyItems that do not have a status of complete and restart polling @@ -219,6 +273,14 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const isBridgeTx = isCrossChain( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ); + return isBridgeTx; }); incompleteHistoryItems.forEach((historyItem) => { @@ -232,12 +294,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { const { @@ -287,10 +344,29 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const { quoteResponse, bridgeTxMeta } = txHistoryMeta; + + this.#addTxToHistory(txHistoryMeta); + + const isBridgeTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + if (isBridgeTx) { + this.#pollingTokensByTxMetaId[bridgeTxMeta.id] = this.startPolling({ + bridgeTxMetaId: bridgeTxMeta.id, + }); + } }; // This will be called after you call this.startPolling() @@ -364,21 +440,12 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, ): Promise => { - if (quoteResponse.approval) { - await this.#handleUSDTAllowanceReset(quoteResponse); - const approvalTxMeta = await this.#handleEvmTransaction( - TransactionType.bridgeApproval, - quoteResponse.approval, - quoteResponse, - ); - if (!approvalTxMeta) { - throw new Error( - 'Failed to submit bridge tx: approval txMeta is undefined', + const { approval } = quoteResponse; + + if (approval) { + const approveTx = async () => { + await this.#handleUSDTAllowanceReset(quoteResponse); + + const approvalTxMeta = await this.#handleEvmTransaction( + isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval, + approval, + quoteResponse, ); - } + if (!approvalTxMeta) { + throw new Error( + 'Failed to submit bridge tx: approval txMeta is undefined', + ); + } + + await handleLineaDelay(quoteResponse); + return approvalTxMeta; + }; - await handleLineaDelay(quoteResponse); - return approvalTxMeta; + return await this.#trace( + { + name: isBridgeTx + ? TraceName.BridgeTransactionApprovalCompleted + : TraceName.SwapTransactionApprovalCompleted, + data: { + srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), + stxEnabled: false, + }, + }, + approveTx, + ); } + return undefined; }; readonly #handleEvmSmartTransaction = async ( + isBridgeTx: boolean, trade: TxData, quoteResponse: Omit & QuoteMetadata, approvalTxId?: string, ) => { return await this.#handleEvmTransaction( - TransactionType.bridge, + isBridgeTx ? TransactionType.bridge : TransactionType.swap, trade, quoteResponse, approvalTxId, @@ -731,13 +821,31 @@ export class BridgeStatusController extends StaticIntervalPollingController { let txMeta: (TransactionMeta & Partial) | undefined; + + const isBridgeTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + // Submit SOLANA tx if ( isSolanaChainId(quoteResponse.quote.srcChainId) && typeof quoteResponse.trade === 'string' ) { - txMeta = await this.#handleSolanaTx( - quoteResponse as QuoteResponse & QuoteMetadata, + txMeta = await this.#trace( + { + name: isBridgeTx + ? TraceName.BridgeTransactionCompleted + : TraceName.SwapTransactionCompleted, + data: { + srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), + stxEnabled: false, + }, + }, + async () => + await this.#handleSolanaTx( + quoteResponse as QuoteResponse & QuoteMetadata, + ), ); this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.SnapConfirmationViewed, @@ -751,22 +859,50 @@ export class BridgeStatusController extends StaticIntervalPollingController + await this.#handleEvmSmartTransaction( + isBridgeTx, + quoteResponse.trade as TxData, + quoteResponse, + approvalTxId, + ), ); } else { - txMeta = await this.#handleEvmTransaction( - TransactionType.bridge, - quoteResponse.trade, - quoteResponse, - approvalTxId, + txMeta = await this.#trace( + { + name: isBridgeTx + ? TraceName.BridgeTransactionCompleted + : TraceName.SwapTransactionCompleted, + data: { + srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), + stxEnabled: false, + }, + }, + async () => + await this.#handleEvmTransaction( + TransactionType.bridge, + quoteResponse.trade as TxData, + quoteResponse, + approvalTxId, + ), ); } } diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts index b3a09375045..564363d3729 100644 --- a/packages/bridge-status-controller/src/constants.ts +++ b/packages/bridge-status-controller/src/constants.ts @@ -12,3 +12,10 @@ export const DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE: BridgeStatusControllerState export const BRIDGE_PROD_API_BASE_URL = 'https://bridge.api.cx.metamask.io'; export const LINEA_DELAY_MS = 5000; + +export enum TraceName { + BridgeTransactionApprovalCompleted = 'Bridge Transaction Approval Completed', + BridgeTransactionCompleted = 'Bridge Transaction Completed', + SwapTransactionApprovalCompleted = 'Swap Transaction Approval Completed', + SwapTransactionCompleted = 'Swap Transaction Completed', +} diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index db3d2fa82cf..44324582049 100644 --- a/packages/bridge-status-controller/src/index.ts +++ b/packages/bridge-status-controller/src/index.ts @@ -26,8 +26,6 @@ export type { BridgeStatusControllerResetStateAction, BridgeStatusControllerEvents, BridgeStatusControllerStateChangeEvent, - BridgeStatusControllerBridgeTransactionCompleteEvent, - BridgeStatusControllerBridgeTransactionFailedEvent, StartPollingForBridgeTxStatusArgs, StartPollingForBridgeTxStatusArgsSerialized, TokenAmountValuesSerialized, diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 2b94314c253..ed2ff8e6e90 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -18,6 +18,7 @@ import type { TxData, } from '@metamask/bridge-controller'; import type { GetGasFeeState } from '@metamask/gas-fee-controller'; +import type { MultichainTransactionsControllerTransactionConfirmedEvent } from '@metamask/multichain-transactions-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, @@ -26,6 +27,8 @@ import type { import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { TransactionControllerGetStateAction, + TransactionControllerTransactionConfirmedEvent, + TransactionControllerTransactionFailedEvent, TransactionMeta, } from '@metamask/transaction-controller'; @@ -316,20 +319,8 @@ export type BridgeStatusControllerStateChangeEvent = ControllerStateChangeEvent< BridgeStatusControllerState >; -export type BridgeStatusControllerBridgeTransactionCompleteEvent = { - type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionComplete`; - payload: [{ bridgeHistoryItem: BridgeHistoryItem }]; -}; - -export type BridgeStatusControllerBridgeTransactionFailedEvent = { - type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionFailed`; - payload: [{ bridgeHistoryItem: BridgeHistoryItem }]; -}; - export type BridgeStatusControllerEvents = - | BridgeStatusControllerStateChangeEvent - | BridgeStatusControllerBridgeTransactionCompleteEvent - | BridgeStatusControllerBridgeTransactionFailedEvent; + BridgeStatusControllerStateChangeEvent; /** * The external actions available to the BridgeStatusController. @@ -349,7 +340,10 @@ type AllowedActions = /** * The external events available to the BridgeStatusController. */ -type AllowedEvents = never; +type AllowedEvents = + | MultichainTransactionsControllerTransactionConfirmedEvent + | TransactionControllerTransactionFailedEvent + | TransactionControllerTransactionConfirmedEvent; /** * The messenger for the BridgeStatusController. diff --git a/packages/bridge-status-controller/src/utils/__snapshots__/validators.test.ts.snap b/packages/bridge-status-controller/src/utils/__snapshots__/validators.test.ts.snap new file mode 100644 index 00000000000..5d629ff951b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/__snapshots__/validators.test.ts.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validators bridgeStatusValidator should throw for invalid response for complete bridge status with missing fields 1`] = ` +Array [ + Array [ + [StructError: At path: srcChain -- Expected an object, but received: undefined], + ], +] +`; + +exports[`validators bridgeStatusValidator should throw for invalid response for empty object 1`] = ` +Array [ + Array [ + [StructError: At path: status -- Expected one of \`"UNKNOWN","FAILED","PENDING","COMPLETE"\`, but received: undefined], + ], +] +`; + +exports[`validators bridgeStatusValidator should throw for invalid response for null 1`] = ` +Array [ + Array [ + [StructError: Expected an object, but received: null], + ], +] +`; + +exports[`validators bridgeStatusValidator should throw for invalid response for pending bridge status with missing fields 1`] = ` +Array [ + Array [ + [StructError: At path: destChain.chainId -- Expected the value to satisfy a union of \`number | string\`, but received: undefined], + ], +] +`; + +exports[`validators bridgeStatusValidator should throw for invalid response for undefined 1`] = ` +Array [ + Array [ + [StructError: Expected an object, but received: undefined], + ], +] +`; diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 5befa6c38b4..dc968d0b33a 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -3,6 +3,7 @@ import type { TxData } from '@metamask/bridge-controller'; import { ChainId, formatChainIdToHex, + isCrossChain, type QuoteMetadata, type QuoteResponse, } from '@metamask/bridge-controller'; @@ -84,6 +85,11 @@ export const handleSolanaTxResponse = ( } const hexChainId = formatChainIdToHex(quoteResponse.quote.srcChainId); + const isBridgeTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + // Create a transaction meta object with bridge-specific fields return { ...getTxMetaFields(quoteResponse), @@ -92,13 +98,13 @@ export const handleSolanaTxResponse = ( chainId: hexChainId, networkClientId: snapId ?? hexChainId, txParams: { from: selectedAccountAddress, data: quoteResponse.trade }, - type: TransactionType.bridge, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, status: TransactionStatus.submitted, hash, // Add the transaction signature as hash origin: snapId, // Add an explicit bridge flag to mark this as a Solana transaction isSolana: true, // TODO deprecate this and use chainId - isBridgeTx: true, // TODO deprecate this and use type + isBridgeTx, }; }; diff --git a/packages/bridge-status-controller/src/utils/validators.test.ts b/packages/bridge-status-controller/src/utils/validators.test.ts index b6cd2c97816..80d23811d0c 100644 --- a/packages/bridge-status-controller/src/utils/validators.test.ts +++ b/packages/bridge-status-controller/src/utils/validators.test.ts @@ -246,6 +246,17 @@ describe('validators', () => { input: BridgeTxStatusResponses.STATUS_FAILED_VALID, description: 'valid failed bridge status', }, + { + input: { + status: 'COMPLETE', + srcChain: { + chainId: 1151111081099710, + txHash: + '33LfknAQsrLC1WzmNybkZWUtuGANRFHNupsQ1YLCnjXGXxbBE93BbVTeKLLdE7Sz3WUdxnFW5HQhPuUayrXyqWky', + }, + }, + description: 'placeholder complete swap status', + }, ])( 'should not throw for valid response for $description', ({ input }: { input: unknown }) => { @@ -271,14 +282,20 @@ describe('validators', () => { description: 'null', }, { - input: {}, description: 'empty object', + input: {}, }, ])( 'should throw for invalid response for $description', ({ input }: { input: unknown }) => { + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation((_message: string) => jest.fn()); + // eslint-disable-next-line jest/require-to-throw-message expect(() => validateBridgeStatusResponse(input)).toThrow(); + // eslint-disable-next-line jest/no-restricted-matchers + expect(mockConsoleError.mock.calls).toMatchSnapshot(); }, ); }); diff --git a/packages/bridge-status-controller/src/utils/validators.ts b/packages/bridge-status-controller/src/utils/validators.ts index b6a20cf8e4d..8117f4cc8a8 100644 --- a/packages/bridge-status-controller/src/utils/validators.ts +++ b/packages/bridge-status-controller/src/utils/validators.ts @@ -52,5 +52,10 @@ export const validateBridgeStatusResponse = (data: unknown) => { refuel: optional(RefuelStatusResponseSchema), }); - assert(data, StatusResponseSchema); + try { + assert(data, StatusResponseSchema); + } catch (error) { + console.error(error); + throw error; + } }; diff --git a/packages/bridge-status-controller/tsconfig.build.json b/packages/bridge-status-controller/tsconfig.build.json index 1ec93edefdf..bc350d54388 100644 --- a/packages/bridge-status-controller/tsconfig.build.json +++ b/packages/bridge-status-controller/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "../bridge-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../multichain-transactions-controller/tsconfig.build.json" }, { "path": "../gas-fee-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" }, diff --git a/packages/bridge-status-controller/tsconfig.json b/packages/bridge-status-controller/tsconfig.json index 7935a0447f7..2b313a0a49f 100644 --- a/packages/bridge-status-controller/tsconfig.json +++ b/packages/bridge-status-controller/tsconfig.json @@ -13,7 +13,8 @@ { "path": "../polling-controller" }, { "path": "../transaction-controller" }, { "path": "../gas-fee-controller" }, - { "path": "../user-operation-controller" } + { "path": "../user-operation-controller" }, + { "path": "../multichain-transactions-controller" } ], "include": ["../../types", "./src"] } diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index f025aa5a44a..f61ac70288e 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] + ### Changed -- Bump `@metamask/network-controller` to `^23.4.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) +- Bump `@metamask/network-controller` to `^23.5.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [0.6.0] @@ -81,7 +84,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.7.0...HEAD +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.6.0...@metamask/chain-agnostic-permission@0.7.0 [0.6.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.5.0...@metamask/chain-agnostic-permission@0.6.0 [0.5.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.4.0...@metamask/chain-agnostic-permission@0.5.0 [0.4.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.3.0...@metamask/chain-agnostic-permission@0.4.0 diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index a4a25bcb631..47690ee031a 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/chain-agnostic-permission", - "version": "0.6.0", + "version": "0.7.0", "description": "Defines a CAIP-25 based endowment permission and helpers for interfacing with it", "keywords": [ "MetaMask", @@ -47,9 +47,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", - "@metamask/controller-utils": "^11.8.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/api-specs": "^0.14.0", + "@metamask/controller-utils": "^11.9.0", + "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.2.0", diff --git a/packages/chain-agnostic-permission/src/scope/constants.test.ts b/packages/chain-agnostic-permission/src/scope/constants.test.ts index 2ea29c799ee..0b223103c3d 100644 --- a/packages/chain-agnostic-permission/src/scope/constants.test.ts +++ b/packages/chain-agnostic-permission/src/scope/constants.test.ts @@ -13,6 +13,9 @@ describe('KnownRpcMethods', () => { "personal_sign", "eth_signTypedData_v4", "wallet_watchAsset", + "wallet_sendCalls", + "wallet_getCallsStatus", + "wallet_getCapabilities", "eth_sendTransaction", "eth_decrypt", "eth_getEncryptionPublicKey", diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index c23b9b5a022..7dc45bf52b5 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `TransactionBatch` in approval types enum ([#5793](https://github.com/MetaMask/core/pull/5793)) + +## [11.9.0] + +### Added + +- Add `HttpError` class for errors representing non-200 HTTP responses ([#5809](https://github.com/MetaMask/core/pull/5809)) + +### Changed + +- Improved circuit breaker behavior to no longer consider HTTP 4XX responses as service failures ([#5798](https://github.com/MetaMask/core/pull/5798), [#5809](https://github.com/MetaMask/core/pull/5809)) + - Changed from using `handleAll` to `handleWhen(isServiceFailure)` in circuit breaker policy + - This ensures that expected error responses (like 405 Method Not Allowed and 429 Rate Limited) don't trigger the circuit breaker + ## [11.8.0] ### Added @@ -503,7 +519,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.8.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.9.0...HEAD +[11.9.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.8.0...@metamask/controller-utils@11.9.0 [11.8.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.7.0...@metamask/controller-utils@11.8.0 [11.7.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.6.0...@metamask/controller-utils@11.7.0 [11.6.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.5.0...@metamask/controller-utils@11.6.0 diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json index bf40f365998..d9ec15336df 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/controller-utils", - "version": "11.8.0", + "version": "11.9.0", "description": "Data and convenience functions shared by multiple packages", "keywords": [ "MetaMask", diff --git a/packages/controller-utils/src/constants.ts b/packages/controller-utils/src/constants.ts index 977c15d7b0f..581860f1d5f 100644 --- a/packages/controller-utils/src/constants.ts +++ b/packages/controller-utils/src/constants.ts @@ -165,6 +165,7 @@ export enum ApprovalType { SnapDialogDefault = 'snap_dialog', SwitchEthereumChain = 'wallet_switchEthereumChain', Transaction = 'transaction', + TransactionBatch = 'transaction_batch', Unlock = 'unlock', WalletConnect = 'wallet_connect', WalletRequestPermissions = 'wallet_requestPermissions', diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index b49758f1254..e2028b42dce 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -130,6 +130,21 @@ export const DEFAULT_CIRCUIT_BREAK_DURATION = 30 * 60 * 1000; */ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; +const isServiceFailure = (error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'httpStatus' in error && + typeof error.httpStatus === 'number' + ) { + return error.httpStatus >= 500; + } + + // If the error is not an object, or doesn't have a numeric code property, + // consider it a service failure (e.g., network errors, timeouts, etc.) + return true; +}; + /** * Constructs an object exposing an `execute` method which, given a function — * hereafter called the "service" — will retry that service with ever increasing @@ -202,7 +217,7 @@ export function createServicePolicy( }); const onRetry = retryPolicy.onRetry.bind(retryPolicy); - const circuitBreakerPolicy = circuitBreaker(handleAll, { + const circuitBreakerPolicy = circuitBreaker(handleWhen(isServiceFailure), { // While the circuit is open, any additional invocations of the service // passed to the policy (either via automatic retries or by manually // executing the policy again) will result in a BrokenCircuitError. This diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index dbcad3f253a..543c33f46fe 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -25,6 +25,7 @@ describe('@metamask/controller-utils', () => { "handleFetch", "hexToBN", "hexToText", + "HttpError", "isNonEmptyArray", "isPlainObject", "isSafeChainId", diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index a3f5c992283..e849b4f97a9 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -29,6 +29,7 @@ export { handleFetch, hexToBN, hexToText, + HttpError, isNonEmptyArray, isPlainObject, isSafeChainId, diff --git a/packages/controller-utils/src/util.test.ts b/packages/controller-utils/src/util.test.ts index 4398cb8a20a..14595fd4f3e 100644 --- a/packages/controller-utils/src/util.test.ts +++ b/packages/controller-utils/src/util.test.ts @@ -354,6 +354,26 @@ describe('util', () => { expect(toSmartContract4).toBe(true); }); + describe('HttpError', () => { + it('stores the status as an instance variable', () => { + const httpError = new util.HttpError(500); + + expect(httpError.httpStatus).toBe(500); + }); + + it('has the expected default message', () => { + const httpError = new util.HttpError(500); + + expect(httpError.message).toBe(`Fetch failed with status '500'`); + }); + + it('allows setting a custom message', () => { + const httpError = new util.HttpError(500, 'custom message'); + + expect(httpError.message).toBe('custom message'); + }); + }); + describe('successfulFetch', () => { beforeEach(() => { nock(SOME_API).get(/.+/u).reply(200, { foo: 'bar' }).persist(); @@ -371,6 +391,12 @@ describe('util', () => { `Fetch failed with status '500' for request '${SOME_FAILING_API}'`, ); }); + + it('throws an HttpError', async () => { + await expect(util.successfulFetch(SOME_FAILING_API)).rejects.toThrow( + util.HttpError, + ); + }); }); describe('timeoutFetch', () => { diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 7f3358b64c9..7e80c6ace65 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -364,6 +364,24 @@ export function isSmartContractCode(code: string) { return smartContractCode; } +/** + * An error representing a non-200 HTTP response. + */ +export class HttpError extends Error { + public httpStatus: number; + + /** + * Construct an HTTP error. + * + * @param status - The HTTP response status. + * @param message - The error message. + */ + constructor(status: number, message?: string) { + super(message || `Fetch failed with status '${status}'`); + this.httpStatus = status; + } +} + /** * Execute fetch and verify that the response was successful. * @@ -377,10 +395,9 @@ export async function successfulFetch( ) { const response = await fetch(request, options); if (!response.ok) { - throw new Error( - `Fetch failed with status '${response.status}' for request '${String( - request, - )}'`, + throw new HttpError( + response.status, + `Fetch failed with status '${response.status}' for request '${String(request)}'`, ); } return response; diff --git a/packages/delegation-controller/CHANGELOG.md b/packages/delegation-controller/CHANGELOG.md index e8c21dabc84..6e8bf67dc76 100644 --- a/packages/delegation-controller/CHANGELOG.md +++ b/packages/delegation-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + ## [0.2.0] ### Changed @@ -20,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5592](https://github.com/MetaMask/core/pull/5592)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.3.0...HEAD +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.2.0...@metamask/delegation-controller@0.3.0 [0.2.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.1.0...@metamask/delegation-controller@0.2.0 [0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/delegation-controller@0.1.0 diff --git a/packages/delegation-controller/package.json b/packages/delegation-controller/package.json index fd4b4a9a2b9..6ea9e66bfa4 100644 --- a/packages/delegation-controller/package.json +++ b/packages/delegation-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/delegation-controller", - "version": "0.2.0", + "version": "0.3.0", "description": "Manages delegations for MetaMask", "keywords": [ "MetaMask", @@ -51,9 +51,9 @@ "@metamask/utils": "^11.2.0" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@ts-bridge/cli": "^0.6.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -64,8 +64,8 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/keyring-controller": "^21.0.2" + "@metamask/accounts-controller": "^29.0.0", + "@metamask/keyring-controller": "^22.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 8bd6985ba4f..b59c71a2d1d 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.14.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) ## [0.13.0] @@ -102,7 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5271](https://github.com/MetaMask/core/pull/5271)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.13.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.14.0...HEAD +[0.14.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.13.0...@metamask/earn-controller@0.14.0 [0.13.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.12.0...@metamask/earn-controller@0.13.0 [0.12.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.11.0...@metamask/earn-controller@0.12.0 [0.11.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.10.0...@metamask/earn-controller@0.11.0 diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index 047dea9fd83..f1f3227f52b 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/earn-controller", - "version": "0.13.0", + "version": "0.14.0", "description": "Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities", "keywords": [ "MetaMask", @@ -49,14 +49,14 @@ "dependencies": { "@ethersproject/providers": "^5.7.0", "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/stake-sdk": "^1.0.0" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/network-controller": "^23.5.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -66,7 +66,7 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/network-controller": "^23.0.0" }, "engines": { diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index 2779bcab1f7..f444e92ba3c 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -9,9 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/chain-agnostic-permission` to `^0.4.0` ([#5674](https://github.com/MetaMask/core/pull/5674)) -- Bump `@metamask/chain-agnostic-permission` to `^0.2.0` ([#5518](https://github.com/MetaMask/core/pull/5518)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/chain-agnostic-permission` to `^0.7.0` ([#5518](https://github.com/MetaMask/core/pull/5518), [#5674](https://github.com/MetaMask/core/pull/5674), [#5818](https://github.com/MetaMask/core/pull/5818)[#5583](https://github.com/MetaMask/core/pull/5583)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [0.1.0] diff --git a/packages/eip1193-permission-middleware/package.json b/packages/eip1193-permission-middleware/package.json index 1208b629da2..ffc6228b5a1 100644 --- a/packages/eip1193-permission-middleware/package.json +++ b/packages/eip1193-permission-middleware/package.json @@ -47,8 +47,8 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/chain-agnostic-permission": "^0.6.0", - "@metamask/controller-utils": "^11.8.0", + "@metamask/chain-agnostic-permission": "^0.7.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/json-rpc-engine": "^10.0.3", "@metamask/permission-controller": "^11.0.6", "@metamask/utils": "^11.2.0", diff --git a/packages/ens-controller/CHANGELOG.md b/packages/ens-controller/CHANGELOG.md index 02705e00023..14e51bfba5a 100644 --- a/packages/ens-controller/CHANGELOG.md +++ b/packages/ens-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [16.0.0] diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index 1274fb80f3b..b6764d445b1 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -49,13 +49,13 @@ "dependencies": { "@ethersproject/providers": "^5.7.0", "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0", "punycode": "^2.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/error-reporting-service/CHANGELOG.md b/packages/error-reporting-service/CHANGELOG.md new file mode 100644 index 00000000000..7567e4e9350 --- /dev/null +++ b/packages/error-reporting-service/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release ([#5849](https://github.com/MetaMask/core/pull/5849)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/error-reporting-service/LICENSE b/packages/error-reporting-service/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/error-reporting-service/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/error-reporting-service/README.md b/packages/error-reporting-service/README.md new file mode 100644 index 00000000000..4e21c415969 --- /dev/null +++ b/packages/error-reporting-service/README.md @@ -0,0 +1,204 @@ +# `@metamask/error-reporting-service` + +Reports errors to an external app such as Sentry but in an agnostic fashion. + +## Installation + +`yarn add @metamask/error-reporting-service` + +or + +`npm install @metamask/error-reporting-service` + +## Usage + +This package is designed to be used in another module via a messenger, but can also be used on its own if needed. + +### Using the service via a messenger + +In most cases, you will want to use the error reporting service in your module via a messenger object. + +In this example, we have a controller, and something bad happens, but we want to report an error instead of throwing it. + +#### 1. Controller file + +```typescript +// We need to get the type for the `ErrorReportingService:captureException` +// action. +import type { ErrorReportingServiceCaptureExceptionAction } from '@metamask/error-reporting-service'; + +// Now let's set up our controller, starting with the messenger. +// Note that we grant the `ErrorReportingService:captureException` action to the +// messenger. +type AllowedActions = ErrorReportingServiceCaptureExceptionAction; +type ExampleControllerMessenger = RestrictedMessenger< + 'ExampleController', + AllowedActions, + never, + AllowedActions['type'], + never +>; + +// Finally, we define our controller. +class ExampleController extends BaseController< + 'ExampleController', + ExampleControllerState, + ExampleControllerMessenger +> { + doSomething() { + // Now imagine that we do something that produces an error and we want to + // report the error. + this.messagingSystem.call( + 'ErrorReportingService:captureException', + new Error('Something went wrong'), + ); + } +} +``` + +#### 2A. Initialization file (browser) + +```typescript +// We need a version of `captureException` from somewhere. Here, we are getting +// it from `@sentry/browser`. +import { captureException } from '@sentry/browser'; + +// We also need to get the ErrorReportingService. +import { ErrorReportingService } from '@metamask/error-reporting-service'; + +// And we need our controller. +import { ExampleController } from './example-controller'; + +// We need to have a global messenger. +const globalMessenger = new Messenger(); + +// We need to create a restricted messenger for the ErrorReportingService, and +// then we can create the service itself. +const errorReportingServiceMessenger = globalMessenger.getRestricted({ + allowedActions: [], + allowedEvents: [], +}); +const errorReportingService = new ErrorReportingService({ + messenger: errorReportingServiceMessenger, + captureException, +}); + +// Now we can create a restricted messenger for our controller, and then +// we can create the controller too. +// Note that we grant the `ErrorReportingService:captureException` action to the +// messenger. +const exampleControllerMessenger = globalMessenger.getRestricted({ + allowedActions: ['ErrorReportingService:captureException'], + allowedEvents: [], +}); +const exampleController = new ExampleController({ + messenger: exampleControllerMessenger, +}); +``` + +#### 2B. Initialization file (React Native) + +```typescript +// We need a version of `captureException` from somewhere. Here, we are getting +// it from `@sentry/react-native`. +import { captureException } from '@sentry/react-native'; + +// We also need to get the ErrorReportingService. +import { ErrorReportingService } from '@metamask/error-reporting-service'; + +// And we need our controller. +import { ExampleController } from './example-controller'; + +// We need to have a global messenger. +const globalMessenger = new Messenger(); + +// We need to create a restricted messenger for the ErrorReportingService, and +// then we can create the service itself. +const errorReportingServiceMessenger = globalMessenger.getRestricted({ + allowedActions: [], + allowedEvents: [], +}); +const errorReportingService = new ErrorReportingService({ + messenger: errorReportingServiceMessenger, + captureException, +}); + +// Now we can create a restricted messenger for our controller, and then +// we can create the controller too. +// Note that we grant the `ErrorReportingService:captureException` action to the +// messenger. +const exampleControllerMessenger = globalMessenger.getRestricted({ + allowedActions: ['ErrorReportingService:captureException'], + allowedEvents: [], +}); +const exampleController = new ExampleController({ + messenger: exampleControllerMessenger, +}); +``` + +#### 3. Using the controller + +```typescript +// Now this will report an error without throwing it. +exampleController.doSomething(); +``` + +### Using the service directly + +You probably don't need to use the service directly, but if you do, here's how. + +In this example, we have a function, and we use the error reporting service there. + +#### 1. Function file + +```typescript +export function doSomething( + errorReportingService: AbstractErrorReportingService, +) { + errorReportingService.captureException(new Error('Something went wrong')); +} +``` + +#### 2A. Calling file (browser) + +```typescript +// We need a version of `captureException` from somewhere. Here, we are getting +it from `@sentry/browser`. +import { captureException } from '@sentry/browser'; + +// We also need to get the ErrorReportingService. +import { ErrorReportingService } from '@metamask/error-reporting-service'; + +// We also bring in our function. +import { doSomething } from './do-something'; + +// We create a new instance of the ErrorReportingService. +const errorReportingService = new ErrorReportingService({ captureException }); + +// Now we call our function, and it will report the error in Sentry. +doSomething(errorReportingService); +``` + +#### 2A. Calling file (React Native) + +```typescript +// We need a version of `captureException` from somewhere. Here, we are getting +it from `@sentry/react-native`. +import { captureException } from '@sentry/react-native'; + +// We also need to get the ErrorReportingService. +import { ErrorReportingService } from '@metamask/error-reporting-service'; + +// We also bring in our function. +import { doSomething } from './do-something'; + +// We create a new instance of the ErrorReportingService. +const errorReportingService = new ErrorReportingService({ captureException }); + +// Now we call our function, and it will report the error in Sentry. +doSomething(errorReportingService); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/error-reporting-service/jest.config.js b/packages/error-reporting-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/error-reporting-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/error-reporting-service/package.json b/packages/error-reporting-service/package.json new file mode 100644 index 00000000000..d34328b18ff --- /dev/null +++ b/packages/error-reporting-service/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/error-reporting-service", + "version": "0.0.0", + "description": "Logs errors to an error reporting service such as Sentry", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/error-reporting-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/error-reporting-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/error-reporting-service", + "publish:preview": "yarn npm publish --tag preview", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "since-latest-release": "../../scripts/since-latest-release.sh" + }, + "dependencies": { + "@metamask/base-controller": "^8.0.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@sentry/core": "^9.22.0", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/error-reporting-service/src/error-reporting-service.test.ts b/packages/error-reporting-service/src/error-reporting-service.test.ts new file mode 100644 index 00000000000..43330af5fb9 --- /dev/null +++ b/packages/error-reporting-service/src/error-reporting-service.test.ts @@ -0,0 +1,65 @@ +import { Messenger } from '@metamask/base-controller'; +import { captureException as sentryCaptureException } from '@sentry/core'; + +import type { ErrorReportingServiceMessenger } from './error-reporting-service'; +import { ErrorReportingService } from './error-reporting-service'; + +describe('ErrorReportingService', () => { + describe('constructor', () => { + it('allows the Sentry captureException function to be passed', () => { + const messenger = buildMessenger(); + const errorReportingService = new ErrorReportingService({ + messenger, + captureException: sentryCaptureException, + }); + + // This assertion is just here to appease the ESLint Jest rules + expect(errorReportingService).toBeInstanceOf(ErrorReportingService); + }); + }); + + describe('captureException', () => { + it('calls the captureException function supplied to the constructor with the given arguments', () => { + const messenger = buildMessenger(); + const captureExceptionMock = jest.fn(); + const errorReportingService = new ErrorReportingService({ + messenger, + captureException: captureExceptionMock, + }); + const error = new Error('some error'); + + errorReportingService.captureException(error); + + expect(captureExceptionMock).toHaveBeenCalledWith(error); + }); + }); + + describe('ErrorReportingService:captureException', () => { + it('calls the captureException function supplied to the constructor with the given arguments', () => { + const messenger = buildMessenger(); + const captureExceptionMock = jest.fn(); + new ErrorReportingService({ + messenger, + captureException: captureExceptionMock, + }); + const error = new Error('some error'); + + messenger.call('ErrorReportingService:captureException', error); + + expect(captureExceptionMock).toHaveBeenCalledWith(error); + }); + }); +}); + +/** + * Builds a messenger suited to the ErrorReportingService. + * + * @returns The messenger. + */ +function buildMessenger(): ErrorReportingServiceMessenger { + return new Messenger().getRestricted({ + name: 'ErrorReportingService', + allowedActions: [], + allowedEvents: [], + }); +} diff --git a/packages/error-reporting-service/src/error-reporting-service.ts b/packages/error-reporting-service/src/error-reporting-service.ts new file mode 100644 index 00000000000..9460dff2a59 --- /dev/null +++ b/packages/error-reporting-service/src/error-reporting-service.ts @@ -0,0 +1,162 @@ +import type { RestrictedMessenger } from '@metamask/base-controller'; + +/** + * The action which can be used to report an error. + */ +export type ErrorReportingServiceCaptureExceptionAction = { + type: 'ErrorReportingService:captureException'; + handler: ErrorReportingService['captureException']; +}; + +/** + * All actions that {@link ErrorReportingService} registers so that other + * modules can call them. + */ +export type ErrorReportingServiceActions = + ErrorReportingServiceCaptureExceptionAction; + +/** + * All events that {@link ErrorReportingService} publishes so that other modules + * can subscribe to them. + */ +export type ErrorReportingServiceEvents = never; + +/** + * All actions registered by other modules that {@link ErrorReportingService} + * calls. + */ +type AllowedActions = never; + +/** + * All events published by other modules that {@link ErrorReportingService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events that + * {@link ErrorReportingService} needs to access. + */ +export type ErrorReportingServiceMessenger = RestrictedMessenger< + 'ErrorReportingService', + ErrorReportingServiceActions | AllowedActions, + ErrorReportingServiceEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * The options that {@link ErrorReportingService} takes. + */ +type ErrorReportingServiceOptions = { + captureException: (error: unknown) => string; + messenger: ErrorReportingServiceMessenger; +}; + +/** + * `ErrorReportingService` is designed to log an error to an error reporting app + * such as Sentry, but in an agnostic fashion. + * + * @example + * + * In this example, we have a controller, and something bad happens, but we want + * to report an error instead of throwing it. + * + * ``` ts + * // === Controller file === + * + * import type { ErrorReportingServiceCaptureExceptionAction } from '@metamask/error-reporting-service'; + * + * // Define the messenger type for the controller. + * type AllowedActions = ErrorReportingServiceCaptureExceptionAction; + * type ExampleControllerMessenger = RestrictedMessenger< + * 'ExampleController', + * AllowedActions, + * never, + * AllowedActions['type'], + * never + * >; + * + * // Define the controller. + * class ExampleController extends BaseController< + * 'ExampleController', + * ExampleControllerState, + * ExampleControllerMessenger + * > { + * doSomething() { + * // Imagine that we do something that produces an error and we want to + * // report the error. + * this.messagingSystem.call( + * 'ErrorReportingService:captureException', + * new Error('Something went wrong'), + * ); + * } + * } + * + * // === Initialization file === + * + * import { captureException } from '@sentry/browser'; + * import { ErrorReportingService } from '@metamask/error-reporting-service'; + * import { ExampleController } from './example-controller'; + * + * // Create a global messenger. + * const globalMessenger = new Messenger(); + * + * // Register handler for the `ErrorReportingService:captureException` + * // action in the global messenger. + * const errorReportingServiceMessenger = globalMessenger.getRestricted({ + * allowedActions: [], + * allowedEvents: [], + * }); + * const errorReportingService = new ErrorReportingService({ + * messenger: errorReportingServiceMessenger, + * captureException, + * }); + * + * const exampleControllerMessenger = globalMessenger.getRestricted({ + * allowedActions: ['ErrorReportingService:captureException'], + * allowedEvents: [], + * }); + * const exampleController = new ExampleController({ + * messenger: exampleControllerMessenger, + * }); + * + * // === Somewhere else === + * + * // Now this will report an error without throwing it. + * exampleController.doSomething(); + * ``` + */ +export class ErrorReportingService { + readonly #captureException: ErrorReportingServiceOptions['captureException']; + + readonly #messenger: ErrorReportingServiceMessenger; + + /** + * Constructs a new ErrorReportingService. + * + * @param options - The options. + * @param options.messenger - The messenger suited to this + * ErrorReportingService. + * @param options.captureException - A function that stores the given error in + * the error reporting service. + */ + constructor({ messenger, captureException }: ErrorReportingServiceOptions) { + this.#messenger = messenger; + this.#captureException = captureException; + + this.#messenger.registerActionHandler( + 'ErrorReportingService:captureException', + this.#captureException.bind(this), + ); + } + + /** + * Reports the given error to an external location. + * + * @param error - The error to report. + */ + captureException(error: Error): void { + this.#captureException(error); + } +} diff --git a/packages/error-reporting-service/src/index.ts b/packages/error-reporting-service/src/index.ts new file mode 100644 index 00000000000..e77fdb259ef --- /dev/null +++ b/packages/error-reporting-service/src/index.ts @@ -0,0 +1,7 @@ +export { ErrorReportingService } from './error-reporting-service'; +export type { + ErrorReportingServiceActions, + ErrorReportingServiceCaptureExceptionAction, + ErrorReportingServiceEvents, + ErrorReportingServiceMessenger, +} from './error-reporting-service'; diff --git a/packages/error-reporting-service/tsconfig.build.json b/packages/error-reporting-service/tsconfig.build.json new file mode 100644 index 00000000000..e5fd7422b9a --- /dev/null +++ b/packages/error-reporting-service/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../base-controller/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/error-reporting-service/tsconfig.json b/packages/error-reporting-service/tsconfig.json new file mode 100644 index 00000000000..34354c4b09d --- /dev/null +++ b/packages/error-reporting-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../base-controller" }], + "include": ["../../types", "./src"] +} diff --git a/packages/error-reporting-service/typedoc.json b/packages/error-reporting-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/error-reporting-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/gas-fee-controller/CHANGELOG.md b/packages/gas-fee-controller/CHANGELOG.md index ceff931c55e..2472ec9b710 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [23.0.0] diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 5cd4d6cb20e..2e8fad1c7af 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", "@metamask/polling-controller": "^13.0.0", @@ -61,7 +61,7 @@ "devDependencies": { "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "@types/jest-when": "^2.7.3", "deepmerge": "^4.2.2", diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index d6def954e41..c810d01dc26 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.0.0] + +### Changed + +- **BREAKING** `keyringsMetadata` has been removed from the controller state ([#5725](https://github.com/MetaMask/core/pull/5725)) + - The metadata is now stored in each keyring object in the `state.keyrings` array. + - When updating to this version, we recommend removing the `keyringsMetadata` state and all state referencing a keyring ID with a migration. New metadata will be generated for each keyring automatically after the update. + +### Fixed + +- Keyrings with duplicate accounts are skipped as unsupported on unlock ([#5775](https://github.com/MetaMask/core/pull/5775)) + ## [21.0.6] ### Changed @@ -770,7 +782,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.6...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.0.0...HEAD +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.6...@metamask/keyring-controller@22.0.0 [21.0.6]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.5...@metamask/keyring-controller@21.0.6 [21.0.5]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.4...@metamask/keyring-controller@21.0.5 [21.0.4]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.3...@metamask/keyring-controller@21.0.4 diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index d8355e87e91..568a60b2b46 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 93.64, + branches: 94.31, functions: 100, - lines: 98.92, - statements: 98.93, + lines: 98.79, + statements: 98.8, }, }, diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index bb1909226fd..378b0056995 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/keyring-controller", - "version": "21.0.6", + "version": "22.0.0", "description": "Stores identities seen in the wallet and manages interactions such as signing", "keywords": [ "MetaMask", diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 26f09defe41..90bdcfddc66 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -127,6 +127,95 @@ describe('KeyringController', () => { }, ); }); + + it('allows removing a keyring builder without bricking the wallet when metadata was already generated', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: 'my vault', + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: '', + metadata: { id: 'hd', name: '' }, + }, + { + type: 'Unsupported', + data: '', + metadata: { id: 'unsupported', name: '' }, + }, + { + type: KeyringTypes.hd, + data: '', + metadata: { id: 'hd2', name: '' }, + }, + ]); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[0].metadata).toStrictEqual({ + id: 'hd', + name: '', + }); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].metadata).toStrictEqual({ + id: 'hd2', + name: '', + }); + }, + ); + }); + + it('allows removing a keyring builder without bricking the wallet when metadata was not yet generated', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: 'my vault', + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: 'HD Key Tree', + data: '', + metadata: { id: 'hd', name: '' }, + }, + { + type: 'HD Key Tree', + data: '', + metadata: { id: 'hd2', name: '' }, + }, + // This keyring was already unsupported + // (no metadata, and is at the end of the array) + { + type: MockKeyring.type, + data: 'unsupported', + }, + ]); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[0].metadata).toStrictEqual({ + id: 'hd', + name: '', + }); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].metadata).toStrictEqual({ + id: 'hd2', + name: '', + }); + }, + ); + }); }); describe('addNewAccount', () => { @@ -467,7 +556,7 @@ describe('KeyringController', () => { { cacheEncryptionKey }, async ({ controller, initialState }) => { const initialVault = controller.state.vault; - const initialKeyringsMetadata = controller.state.keyringsMetadata; + const initialKeyrings = controller.state.keyrings; await controller.createNewVaultAndRestore( password, uint8ArraySeed, @@ -475,12 +564,12 @@ describe('KeyringController', () => { expect(controller.state).not.toBe(initialState); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).toStrictEqual(initialVault); - expect(controller.state.keyringsMetadata).toHaveLength( - initialKeyringsMetadata.length, + expect(controller.state.keyrings).toHaveLength( + initialKeyrings.length, ); // new keyring metadata should be generated - expect(controller.state.keyringsMetadata).not.toStrictEqual( - initialKeyringsMetadata, + expect(controller.state.keyrings).not.toStrictEqual( + initialKeyrings, ); }, ); @@ -507,6 +596,10 @@ describe('KeyringController', () => { { data: serializedKeyring, type: 'HD Key Tree', + metadata: { + id: expect.any(String), + name: '', + }, }, ]); }, @@ -769,7 +862,7 @@ describe('KeyringController', () => { it('should export seed phrase with valid keyringId', async () => { await withController(async ({ controller, initialState }) => { - const keyringId = initialState.keyringsMetadata[0].id; + const keyringId = initialState.keyrings[0].metadata.id; const seed = await controller.exportSeedPhrase(password, keyringId); expect(seed).not.toBe(''); }); @@ -799,7 +892,7 @@ describe('KeyringController', () => { it('should throw invalid password error with valid keyringId', async () => { await withController( async ({ controller, encryptor, initialState }) => { - const keyringId = initialState.keyringsMetadata[0].id; + const keyringId = initialState.keyrings[0].metadata.id; jest .spyOn(encryptor, 'decrypt') .mockRejectedValueOnce(new Error('Invalid password')); @@ -1203,10 +1296,12 @@ describe('KeyringController', () => { ); const modifiedState = { ...initialState, - keyrings: [initialState.keyrings[0], newKeyring], - keyringsMetadata: [ - initialState.keyringsMetadata[0], - controller.state.keyringsMetadata[1], + keyrings: [ + initialState.keyrings[0], + { + ...newKeyring, + metadata: controller.state.keyrings[1].metadata, + }, ], }; expect(controller.state).toStrictEqual(modifiedState); @@ -1280,10 +1375,12 @@ describe('KeyringController', () => { }; const modifiedState = { ...initialState, - keyrings: [initialState.keyrings[0], newKeyring], - keyringsMetadata: [ - initialState.keyringsMetadata[0], - controller.state.keyringsMetadata[1], + keyrings: [ + initialState.keyrings[0], + { + ...newKeyring, + metadata: controller.state.keyrings[1].metadata, + }, ], }; expect(controller.state).toStrictEqual(modifiedState); @@ -1480,12 +1577,10 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { await controller.addNewKeyring(KeyringTypes.hd); expect(controller.state.keyrings).toHaveLength(2); - expect(controller.state.keyringsMetadata).toHaveLength(2); await controller.removeAccount( controller.state.keyrings[1].accounts[0], ); expect(controller.state.keyrings).toHaveLength(1); - expect(controller.state.keyringsMetadata).toHaveLength(1); }); }); }); @@ -2635,10 +2730,12 @@ describe('KeyringController', () => { }); it('should unlock succesfully when the controller is instantiated with an existing `keyringsMetadata`', async () => { + // @ts-expect-error HdKeyring is not yet compatible with Keyring type. + stubKeyringClassWithAccount(HdKeyring, '0x123'); await withController( { cacheEncryptionKey, - state: { keyringsMetadata: [], vault: 'my vault' }, + state: { vault: 'my vault' }, skipVaultCreation: true, }, async ({ controller, encryptor }) => { @@ -2648,30 +2745,194 @@ describe('KeyringController', () => { data: { accounts: ['0x123'], }, + metadata: { + id: '123', + name: '', + }, }, ]); await controller.submitPassword(password); - expect(controller.state.keyringsMetadata).toHaveLength(1); + expect(controller.state.keyrings).toStrictEqual([ + { + type: KeyringTypes.hd, + accounts: ['0x123'], + metadata: { + id: '123', + name: '', + }, + }, + ]); }, ); }); - it('should throw an error when the controller is instantiated with an existing `keyringsMetadata` with too many objects', async () => { + cacheEncryptionKey && + it('should generate new metadata when there is no metadata in the vault and cacheEncryptionKey is enabled', async () => { + const hdKeyringSerializeSpy = jest.spyOn( + HdKeyring.prototype, + 'serialize', + ); + await withController( + { + cacheEncryptionKey: true, + state: { + vault: 'my vault', + }, + skipVaultCreation: true, + }, + async ({ controller, encryptor }) => { + const encryptWithKeySpy = jest.spyOn( + encryptor, + 'encryptWithKey', + ); + jest + .spyOn(encryptor, 'importKey') + // @ts-expect-error we are assigning a mock value + .mockResolvedValue('imported key'); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]); + hdKeyringSerializeSpy.mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toStrictEqual([ + { + type: KeyringTypes.hd, + accounts: expect.any(Array), + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + expect(encryptWithKeySpy).toHaveBeenCalledWith('imported key', [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + }, + ); + }); + + !cacheEncryptionKey && + it('should generate new metadata when there is no metadata in the vault and cacheEncryptionKey is disabled', async () => { + const hdKeyringSerializeSpy = jest.spyOn( + HdKeyring.prototype, + 'serialize', + ); + await withController( + { + cacheEncryptionKey: false, + state: { + vault: 'my vault', + }, + skipVaultCreation: true, + }, + async ({ controller, encryptor }) => { + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]); + hdKeyringSerializeSpy.mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toStrictEqual([ + { + type: KeyringTypes.hd, + accounts: expect.any(Array), + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + expect(encryptSpy).toHaveBeenCalledWith(password, [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + }, + ); + }); + + it('should unlock the wallet if the state has a duplicate account and the encryption parameters are outdated', async () => { + stubKeyringClassWithAccount(MockKeyring, '0x123'); + // @ts-expect-error HdKeyring is not yet compatible with Keyring type. + stubKeyringClassWithAccount(HdKeyring, '0x123'); await withController( { + skipVaultCreation: true, cacheEncryptionKey, - state: { - keyringsMetadata: [ - { id: '123', name: '' }, - { id: '456', name: '' }, - ], - vault: 'my vault', - }, + state: { vault: 'my vault' }, + keyringBuilders: [keyringBuilderFactory(MockKeyring)], + }, + async ({ controller, encryptor, messenger }) => { + const unlockListener = jest.fn(); + messenger.subscribe('KeyringController:unlock', unlockListener); + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: {}, + }, + { + type: MockKeyring.type, + data: {}, + }, + ]); + + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); + expect(unlockListener).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should unlock the wallet also if encryption parameters are outdated and the vault upgrade fails', async () => { + await withController( + { skipVaultCreation: true, + cacheEncryptionKey, + state: { vault: 'my vault' }, }, async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + jest.spyOn(encryptor, 'encrypt').mockRejectedValue(new Error()); jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ { type: KeyringTypes.hd, @@ -2681,14 +2942,14 @@ describe('KeyringController', () => { }, ]); - await expect(controller.submitPassword(password)).rejects.toThrow( - KeyringControllerError.KeyringMetadataLengthMismatch, - ); + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); }, ); }); - it('should unlock the wallet if the state has a duplicate account and the encryption parameters are outdated', async () => { + it('should unlock the wallet discarding existing duplicate accounts', async () => { stubKeyringClassWithAccount(MockKeyring, '0x123'); // @ts-expect-error HdKeyring is not yet compatible with Keyring type. stubKeyringClassWithAccount(HdKeyring, '0x123'); @@ -2702,7 +2963,6 @@ describe('KeyringController', () => { async ({ controller, encryptor, messenger }) => { const unlockListener = jest.fn(); messenger.subscribe('KeyringController:unlock', unlockListener); - jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ { type: KeyringTypes.hd, @@ -2716,8 +2976,7 @@ describe('KeyringController', () => { await controller.submitPassword(password); - expect(controller.state.keyrings).toHaveLength(2); - expect(controller.state.isUnlocked).toBe(true); + expect(controller.state.keyrings).toHaveLength(1); // Second keyring will be skipped as "unsupported". expect(unlockListener).toHaveBeenCalledTimes(1); }, ); @@ -2750,6 +3009,33 @@ describe('KeyringController', () => { ); }); + cacheEncryptionKey && + it('should not upgrade the vault encryption if the key encryptor has the same parameters', async () => { + await withController( + { + skipVaultCreation: true, + cacheEncryptionKey, + state: { vault: 'my vault' }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(true); + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]); + + await controller.submitPassword(password); + + expect(encryptSpy).not.toHaveBeenCalled(); + }, + ); + }); + !cacheEncryptionKey && it('should upgrade the vault encryption if the generic encryptor has different parameters', async () => { await withController( @@ -2777,6 +3063,36 @@ describe('KeyringController', () => { ); }); + it('should not upgrade the vault encryption if the encryptor has the same parameters and the keyring has metadata', async () => { + await withController( + { + skipVaultCreation: true, + cacheEncryptionKey, + state: { vault: 'my vault' }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(true); + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: '123', + name: '', + }, + }, + ]); + + await controller.submitPassword(password); + + expect(encryptSpy).not.toHaveBeenCalled(); + }, + ); + }); + !cacheEncryptionKey && it('should throw error if password is of wrong type', async () => { await withController( @@ -2864,6 +3180,57 @@ describe('KeyringController', () => { ); }); + it('should update the vault if new metadata is created while unlocking', async () => { + jest.spyOn(HdKeyring.prototype, 'serialize').mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + await withController( + { + cacheEncryptionKey: true, + skipVaultCreation: true, + state: { + vault: JSON.stringify({ data: '0x123', salt: 'my salt' }), + // @ts-expect-error we want to force the controller to have an + // encryption salt equal to the one in the vault + encryptionSalt: 'my salt', + }, + }, + async ({ controller, initialState, encryptor }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + jest + .spyOn(encryptor, 'importKey') + // @ts-expect-error we are assigning a mock value + .mockResolvedValue('imported key'); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: '0x123', + }, + ]); + + await controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ); + + expect(controller.state.isUnlocked).toBe(true); + expect(encryptWithKeySpy).toHaveBeenCalledWith('imported key', [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + }, + ); + }); + it('should throw error if vault unlocked has an unexpected shape', async () => { await withController( { cacheEncryptionKey: true }, @@ -2930,7 +3297,7 @@ describe('KeyringController', () => { it('should return seedphrase for a specific keyring', async () => { await withController(async ({ controller }) => { const seedPhrase = await controller.verifySeedPhrase( - controller.state.keyringsMetadata[0].id, + controller.state.keyrings[0].metadata.id, ); expect(seedPhrase).toBeDefined(); }); @@ -2965,7 +3332,7 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { await controller.addNewKeyring(KeyringTypes.simple, [privateKey]); - const keyringId = controller.state.keyringsMetadata[1].id; + const keyringId = controller.state.keyrings[1].metadata.id; await expect(controller.verifySeedPhrase(keyringId)).rejects.toThrow( KeyringControllerError.UnsupportedVerifySeedPhrase, ); @@ -3073,7 +3440,7 @@ describe('KeyringController', () => { const fn = jest.fn(); const selector = { type: KeyringTypes.hd }; const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; - const metadata = controller.state.keyringsMetadata[0]; + const { metadata } = controller.state.keyrings[0]; await controller.withKeyring(selector, fn); @@ -3211,7 +3578,7 @@ describe('KeyringController', () => { address: initialState.keyrings[0].accounts[0] as Hex, }; const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; - const metadata = controller.state.keyringsMetadata[0]; + const { metadata } = controller.state.keyrings[0]; await controller.withKeyring(selector, fn); @@ -3253,11 +3620,11 @@ describe('KeyringController', () => { describe('when the keyring is selected by id', () => { it('should call the given function with the selected keyring', async () => { - await withController(async ({ controller, initialState }) => { + await withController(async ({ controller }) => { const fn = jest.fn(); const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; - const selector = { id: initialState.keyringsMetadata[0].id }; - const metadata = controller.state.keyringsMetadata[0]; + const { metadata } = controller.state.keyrings[0]; + const selector = { id: metadata.id }; await controller.withKeyring(selector, fn); @@ -3268,7 +3635,7 @@ describe('KeyringController', () => { it('should return the result of the function', async () => { await withController(async ({ controller, initialState }) => { const fn = async () => Promise.resolve('hello'); - const selector = { id: initialState.keyringsMetadata[0].id }; + const selector = { id: initialState.keyrings[0].metadata.id }; expect(await controller.withKeyring(selector, fn)).toBe('hello'); }); @@ -3276,7 +3643,7 @@ describe('KeyringController', () => { it('should throw an error if the callback returns the selected keyring', async () => { await withController(async ({ controller, initialState }) => { - const selector = { id: initialState.keyringsMetadata[0].id }; + const selector = { id: initialState.keyrings[0].metadata.id }; await expect( controller.withKeyring(selector, async ({ keyring }) => { diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 97f95b90b09..e62791a9da7 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -22,7 +22,6 @@ import type { KeyringClass } from '@metamask/keyring-utils'; import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; import { add0x, - assert, assertIsStrictHexString, bytesToHex, hasProperty, @@ -92,10 +91,6 @@ export type KeyringControllerState = { * Representations of managed keyrings. */ keyrings: KeyringObject[]; - /** - * Metadata for each keyring. - */ - keyringsMetadata: KeyringMetadata[]; /** * The encryption key derived from the password and used to encrypt * the vault. This is only stored if the `cacheEncryptionKey` option @@ -278,6 +273,10 @@ export type KeyringObject = { * Keyring type. */ type: string; + /** + * Additional data associated with the keyring. + */ + metadata: KeyringMetadata; }; /** @@ -319,6 +318,7 @@ export enum SignTypedDataVersion { export type SerializedKeyring = { type: string; data: Json; + metadata?: KeyringMetadata; }; /** @@ -326,7 +326,6 @@ export type SerializedKeyring = { */ type SessionState = { keyrings: SerializedKeyring[]; - keyringsMetadata: KeyringMetadata[]; password?: string; }; @@ -482,7 +481,6 @@ export const getDefaultKeyringState = (): KeyringControllerState => { return { isUnlocked: false, keyrings: [], - keyringsMetadata: [], }; }; @@ -566,12 +564,18 @@ function isSerializedKeyringsArray( * * Is used for adding the current keyrings to the state object. * - * @param keyring - The keyring to display. + * @param keyringWithMetadata - The keyring and its metadata. + * @param keyringWithMetadata.keyring - The keyring to display. + * @param keyringWithMetadata.metadata - The metadata of the keyring. * @returns A keyring display object, with type and accounts properties. */ -async function displayForKeyring( - keyring: EthKeyring, -): Promise<{ type: string; accounts: string[] }> { +async function displayForKeyring({ + keyring, + metadata, +}: { + keyring: EthKeyring; + metadata: KeyringMetadata; +}): Promise { const accounts = await keyring.getAccounts(); return { @@ -579,6 +583,7 @@ async function displayForKeyring( // Cast to `string[]` here is safe here because `accounts` has no nullish // values, and `normalize` returns `string` unless given a nullish value accounts: accounts.map(normalize) as string[], + metadata, }; } @@ -638,12 +643,10 @@ export class KeyringController extends BaseController< readonly #cacheEncryptionKey: boolean; - #keyrings: EthKeyring[]; + #keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; #unsupportedKeyrings: SerializedKeyring[]; - #keyringsMetadata: KeyringMetadata[]; - #password?: string; #qrKeyringStateListener?: ( @@ -674,7 +677,6 @@ export class KeyringController extends BaseController< vault: { persist: true, anonymous: false }, isUnlocked: { persist: false, anonymous: true }, keyrings: { persist: false, anonymous: false }, - keyringsMetadata: { persist: true, anonymous: false }, encryptionKey: { persist: false, anonymous: false }, encryptionSalt: { persist: false, anonymous: false }, }, @@ -691,7 +693,6 @@ export class KeyringController extends BaseController< this.#encryptor = encryptor; this.#keyrings = []; - this.#keyringsMetadata = state?.keyringsMetadata?.slice() ?? []; this.#unsupportedKeyrings = []; // This option allows the controller to cache an exported key @@ -989,7 +990,7 @@ export class KeyringController extends BaseController< const address = normalize(account); const candidates = await Promise.all( - this.#keyrings.map(async (keyring) => { + this.#keyrings.map(async ({ keyring }) => { return Promise.all([keyring, keyring.getAccounts()]); }), ); @@ -1026,7 +1027,9 @@ export class KeyringController extends BaseController< */ getKeyringsByType(type: KeyringTypes | string): unknown[] { this.#assertIsUnlocked(); - return this.#keyrings.filter((keyring) => keyring.type === type); + return this.#keyrings + .filter(({ keyring }) => keyring.type === type) + .map(({ keyring }) => keyring); } /** @@ -1442,14 +1445,29 @@ export class KeyringController extends BaseController< encryptionKey: string, encryptionSalt: string, ): Promise { - return this.#withRollback(async () => { - this.#keyrings = await this.#unlockKeyrings( + const { newMetadata } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings( undefined, encryptionKey, encryptionSalt, ); this.#setUnlocked(); + return result; }); + + try { + // if new metadata has been generated during login, we + // can attempt to upgrade the vault. + await this.#withRollback(async () => { + if (newMetadata) { + await this.#updateVault(); + } + }); + } catch (error) { + // We don't want to throw an error if the upgrade fails + // since the controller is already unlocked. + console.error('Failed to update vault during login:', error); + } } /** @@ -1460,21 +1478,25 @@ export class KeyringController extends BaseController< * @returns Promise resolving when the operation completes. */ async submitPassword(password: string): Promise { - await this.#withRollback(async () => { - this.#keyrings = await this.#unlockKeyrings(password); + const { newMetadata } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings(password); this.#setUnlocked(); + return result; }); try { - // If there are stronger encryption params available, we + // If there are stronger encryption params available, or + // if new metadata has been generated during login, we // can attempt to upgrade the vault. - await this.#withRollback(async () => - this.#upgradeVaultEncryptionParams(), - ); + await this.#withRollback(async () => { + if (newMetadata || this.#isNewEncryptionAvailable()) { + await this.#updateVault(); + } + }); } catch (error) { // We don't want to throw an error if the upgrade fails // since the controller is already unlocked. - console.error('Failed to upgrade vault encryption params:', error); + console.error('Failed to update vault during login:', error); } } @@ -1949,10 +1971,8 @@ export class KeyringController extends BaseController< * @returns The keyring. */ #getKeyringById(keyringId: string): EthKeyring | undefined { - const index = this.state.keyringsMetadata.findIndex( - (metadata) => metadata.id === keyringId, - ); - return this.#keyrings[index]; + return this.#keyrings.find(({ metadata }) => metadata.id === keyringId) + ?.keyring; } /** @@ -1963,7 +1983,7 @@ export class KeyringController extends BaseController< */ #getKeyringByIdOrDefault(keyringId?: string): EthKeyring | undefined { if (!keyringId) { - return this.#keyrings[0] as EthKeyring; + return this.#keyrings[0]?.keyring; } return this.#getKeyringById(keyringId); @@ -1976,11 +1996,13 @@ export class KeyringController extends BaseController< * @returns The keyring metadata. */ #getKeyringMetadata(keyring: unknown): KeyringMetadata { - const index = this.#keyrings.findIndex( - (keyringCandidate) => keyringCandidate === keyring, + const keyringWithMetadata = this.#keyrings.find( + (candidate) => candidate.keyring === keyring, ); - assert(index !== -1, KeyringControllerError.KeyringNotFound); - return this.#keyringsMetadata[index]; + if (!keyringWithMetadata) { + throw new Error(KeyringControllerError.KeyringNotFound); + } + return keyringWithMetadata.metadata; } /** @@ -2072,7 +2094,6 @@ export class KeyringController extends BaseController< this.#password = password; await this.#clearKeyrings(); - this.#keyringsMetadata = []; await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts); this.#setUnlocked(); } @@ -2156,13 +2177,13 @@ export class KeyringController extends BaseController< includeUnsupported: true, }, ): Promise { - const serializedKeyrings = await Promise.all( - this.#keyrings.map(async (keyring) => { - const [type, data] = await Promise.all([ - keyring.type, - keyring.serialize(), - ]); - return { type, data }; + const serializedKeyrings: SerializedKeyring[] = await Promise.all( + this.#keyrings.map(async ({ keyring, metadata }) => { + return { + type: keyring.type, + data: await keyring.serialize(), + metadata, + }; }), ); @@ -2182,7 +2203,6 @@ export class KeyringController extends BaseController< async #getSessionState(): Promise { return { keyrings: await this.#getSerializedKeyrings(), - keyringsMetadata: this.#keyringsMetadata.slice(), // Force copy. password: this.#password, }; } @@ -2191,15 +2211,30 @@ export class KeyringController extends BaseController< * Restore a serialized keyrings array. * * @param serializedKeyrings - The serialized keyrings array. + * @returns The restored keyrings. */ async #restoreSerializedKeyrings( serializedKeyrings: SerializedKeyring[], - ): Promise { + ): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + newMetadata: boolean; + }> { await this.#clearKeyrings(); + const keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[] = []; + let newMetadata = false; for (const serializedKeyring of serializedKeyrings) { - await this.#restoreKeyring(serializedKeyring); + const result = await this.#restoreKeyring(serializedKeyring); + if (result) { + const { keyring, metadata } = result; + keyrings.push({ keyring, metadata }); + if (result.newMetadata) { + newMetadata = true; + } + } } + + return { keyrings, newMetadata }; } /** @@ -2215,7 +2250,10 @@ export class KeyringController extends BaseController< password: string | undefined, encryptionKey?: string, encryptionSalt?: string, - ): Promise { + ): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + newMetadata: boolean; + }> { return this.#withVaultLock(async () => { const encryptedVault = this.state.vault; if (!encryptedVault) { @@ -2276,13 +2314,8 @@ export class KeyringController extends BaseController< throw new Error(KeyringControllerError.VaultDataError); } - await this.#restoreSerializedKeyrings(vault); - - // The keyrings array and the keyringsMetadata array should - // always have the same length while the controller is unlocked. - if (this.#keyrings.length !== this.#keyringsMetadata.length) { - throw new Error(KeyringControllerError.KeyringMetadataLengthMismatch); - } + const { keyrings, newMetadata } = + await this.#restoreSerializedKeyrings(vault); const updatedKeyrings = await this.#getUpdatedKeyrings(); @@ -2294,7 +2327,7 @@ export class KeyringController extends BaseController< } }); - return this.#keyrings; + return { keyrings, newMetadata }; }); } @@ -2366,14 +2399,10 @@ export class KeyringController extends BaseController< } const updatedKeyrings = await this.#getUpdatedKeyrings(); - if (updatedKeyrings.length !== this.#keyringsMetadata.length) { - throw new Error(KeyringControllerError.KeyringMetadataLengthMismatch); - } this.update((state) => { state.vault = updatedState.vault; state.keyrings = updatedKeyrings; - state.keyringsMetadata = this.#keyringsMetadata.slice(); if (updatedState.encryptionKey) { state.encryptionKey = updatedState.encryptionKey; state.encryptionSalt = JSON.parse(updatedState.vault as string).salt; @@ -2385,35 +2414,36 @@ export class KeyringController extends BaseController< } /** - * Upgrade the vault encryption parameters if needed. + * Check if there are new encryption parameters available. * * @returns A promise resolving to `void`. */ - async #upgradeVaultEncryptionParams(): Promise { - this.#assertControllerMutexIsLocked(); + #isNewEncryptionAvailable(): boolean { const { vault } = this.state; - if ( - vault && - this.#password && - this.#encryptor.isVaultUpdated && - !this.#encryptor.isVaultUpdated(vault) - ) { - await this.#updateVault(); + if (!vault || !this.#password || !this.#encryptor.isVaultUpdated) { + return false; } + + return !this.#encryptor.isVaultUpdated(vault); } /** * Retrieves all the accounts from keyrings instances * that are currently in memory. * + * @param additionalKeyrings - Additional keyrings to include in the search. * @returns A promise resolving to an array of accounts. */ - async #getAccountsFromKeyrings(): Promise { - const keyrings = this.#keyrings; + async #getAccountsFromKeyrings( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const keyrings = this.#keyrings.map(({ keyring }) => keyring); const keyringArrays = await Promise.all( - keyrings.map(async (keyring) => keyring.getAccounts()), + [...keyrings, ...additionalKeyrings].map(async (keyring) => + keyring.getAccounts(), + ), ); const addresses = keyringArrays.reduce((res, arr) => { return res.concat(arr); @@ -2460,11 +2490,7 @@ export class KeyringController extends BaseController< async #newKeyring(type: string, data?: unknown): Promise { const keyring = await this.#createKeyring(type, data); - if (this.#keyrings.length !== this.#keyringsMetadata.length) { - throw new Error('Keyring metadata missing'); - } - this.#keyrings.push(keyring); - this.#keyringsMetadata.push(getDefaultKeyringMetadata()); + this.#keyrings.push({ keyring, metadata: getDefaultKeyringMetadata() }); return keyring; } @@ -2536,7 +2562,7 @@ export class KeyringController extends BaseController< */ async #clearKeyrings() { this.#assertControllerMutexIsLocked(); - for (const keyring of this.#keyrings) { + for (const { keyring } of this.#keyrings) { await this.#destroyKeyring(keyring); } this.#keyrings = []; @@ -2552,22 +2578,31 @@ export class KeyringController extends BaseController< */ async #restoreKeyring( serialized: SerializedKeyring, - ): Promise { + ): Promise< + | { keyring: EthKeyring; metadata: KeyringMetadata; newMetadata: boolean } + | undefined + > { this.#assertControllerMutexIsLocked(); try { - const { type, data } = serialized; + const { type, data, metadata: serializedMetadata } = serialized; + let newMetadata = false; + let metadata = serializedMetadata; const keyring = await this.#createKeyring(type, data); + await this.#assertNoDuplicateAccounts([keyring]); // If metadata is missing, assume the data is from an installation before // we had keyring metadata. - if (this.#keyringsMetadata.length <= this.#keyrings.length) { - console.log(`Adding missing metadata for '${type}' keyring`); - this.#keyringsMetadata.push(getDefaultKeyringMetadata()); + if (!metadata) { + newMetadata = true; + metadata = getDefaultKeyringMetadata(); } // The keyring is added to the keyrings array only if it's successfully restored // and the metadata is successfully added to the controller - this.#keyrings.push(keyring); - return keyring; + this.#keyrings.push({ + keyring, + metadata, + }); + return { keyring, metadata, newMetadata }; } catch (error) { console.error(error); this.#unsupportedKeyrings.push(serialized); @@ -2596,35 +2631,36 @@ export class KeyringController extends BaseController< */ async #removeEmptyKeyrings(): Promise { this.#assertControllerMutexIsLocked(); - const validKeyrings: EthKeyring[] = []; - const validKeyringMetadata: KeyringMetadata[] = []; + const validKeyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[] = + []; // Since getAccounts returns a Promise // We need to wait to hear back form each keyring // in order to decide which ones are now valid (accounts.length > 0) await Promise.all( - this.#keyrings.map(async (keyring: EthKeyring, index: number) => { + this.#keyrings.map(async ({ keyring, metadata }) => { const accounts = await keyring.getAccounts(); if (accounts.length > 0) { - validKeyrings.push(keyring); - validKeyringMetadata.push(this.#keyringsMetadata[index]); + validKeyrings.push({ keyring, metadata }); } else { await this.#destroyKeyring(keyring); } }), ); this.#keyrings = validKeyrings; - this.#keyringsMetadata = validKeyringMetadata; } /** * Assert that there are no duplicate accounts in the keyrings. * + * @param additionalKeyrings - Additional keyrings to include in the check. * @throws If there are duplicate accounts. */ - async #assertNoDuplicateAccounts(): Promise { - const accounts = await this.#getAccountsFromKeyrings(); + async #assertNoDuplicateAccounts( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings); if (new Set(accounts).size !== accounts.length) { throw new Error(KeyringControllerError.DuplicatedAccount); @@ -2642,11 +2678,6 @@ export class KeyringController extends BaseController< this.update((state) => { state.isUnlocked = true; - // If new keyringsMetadata was generated during the unlock operation, - // we'll have to update the state with the new array - if (this.#keyringsMetadata.length > state.keyringsMetadata.length) { - state.keyringsMetadata = this.#keyringsMetadata.slice(); - } }); this.messagingSystem.publish(`${name}:unlock`); } @@ -2700,13 +2731,11 @@ export class KeyringController extends BaseController< return this.#withControllerLock(async ({ releaseLock }) => { const currentSerializedKeyrings = await this.#getSerializedKeyrings(); const currentPassword = this.#password; - const currentKeyringsMetadata = this.#keyringsMetadata.slice(); try { return await callback({ releaseLock }); } catch (e) { // Keyrings and password are restored to their previous state - this.#keyringsMetadata = currentKeyringsMetadata; this.#password = currentPassword; await this.#restoreSerializedKeyrings(currentSerializedKeyrings); diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts index abf89373050..d914a3d6f74 100644 --- a/packages/keyring-controller/src/constants.ts +++ b/packages/keyring-controller/src/constants.ts @@ -35,6 +35,5 @@ export enum KeyringControllerError { DataType = 'KeyringController - Incorrect data type provided', NoHdKeyring = 'KeyringController - No HD Keyring found', ControllerLockRequired = 'KeyringController - attempt to update vault during a non mutually exclusive operation', - KeyringMetadataLengthMismatch = 'KeyringController - keyring metadata length mismatch', LastAccountInPrimaryKeyring = 'KeyringController - Last account in primary keyring cannot be removed', } diff --git a/packages/logging-controller/CHANGELOG.md b/packages/logging-controller/CHANGELOG.md index 9759104928d..f0d15c4ede0 100644 --- a/packages/logging-controller/CHANGELOG.md +++ b/packages/logging-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [6.0.4] diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index ceb93f10b90..3048a61b8e1 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/message-manager/CHANGELOG.md b/packages/message-manager/CHANGELOG.md index a89383c06fd..404a60026ff 100644 --- a/packages/message-manager/CHANGELOG.md +++ b/packages/message-manager/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [12.0.1] diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index 55386d72244..4a7177c9cd6 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-sig-util": "^8.2.0", "@metamask/utils": "^11.2.0", "@types/uuid": "^8.3.0", diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 3d0cd0e1813..9b5e1bf294d 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,10 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] + +### Added + +- When `wallet_createSession` handler is called with `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` (solana mainnet) as a requested scope, but there are not currently any accounts in the wallet supporting this scope, we now add a `promptToCreateSolanaAccount` to a metadata object on the `requestPermissions` call forwarded to the `PermissionsController`. + +## [0.3.0] + +### Added + +- Add more chain-agnostic-permission utility functions from sip-26 usage ([#5609](https://github.com/MetaMask/core/pull/5609)) + ### Changed -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) -- Bump `@metamask/network-controller` to `^23.4.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/chain-agnostic-permission` to `^0.7.0` ([#5715](https://github.com/MetaMask/core/pull/5715),[#5760](https://github.com/MetaMask/core/pull/5760), [#5818](https://github.com/MetaMask/core/pull/5818)) +- Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/network-controller` to `^23.5.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [0.2.0] @@ -41,7 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.4.0...HEAD +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.3.0...@metamask/multichain-api-middleware@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.2.0...@metamask/multichain-api-middleware@0.3.0 [0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.1.1...@metamask/multichain-api-middleware@0.2.0 [0.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.1.0...@metamask/multichain-api-middleware@0.1.1 [0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-api-middleware@0.1.0 diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 7a095c9e66b..77664e68a19 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-api-middleware", - "version": "0.2.0", + "version": "0.4.0", "description": "JSON-RPC methods and middleware to support the MetaMask Multichain API", "keywords": [ "MetaMask", @@ -47,11 +47,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", - "@metamask/chain-agnostic-permission": "^0.6.0", - "@metamask/controller-utils": "^11.8.0", + "@metamask/api-specs": "^0.14.0", + "@metamask/chain-agnostic-permission": "^0.7.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/json-rpc-engine": "^10.0.3", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.2.0", @@ -62,7 +62,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-filters": "^9.0.0", - "@metamask/multichain-transactions-controller": "^0.10.0", + "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/safe-event-emitter": "^3.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts index 46c604309b9..fa1c705843a 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts @@ -673,34 +673,37 @@ describe('wallet_createSession', () => { await handler(baseRequest); - expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: { - requiredScopes: { - 'eip155:1337': { - accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], - }, - }, - optionalScopes: { - 'eip155:100': { - accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + }, }, - [MultichainNetwork.Solana]: { - accounts: [ - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', - ], + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + }, + [MultichainNetwork.Solana]: { + accounts: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + ], + }, }, + isMultichainOrigin: true, + sessionProperties: {}, }, - isMultichainOrigin: true, - sessionProperties: {}, }, - }, - ], + ], + }, }, - }); + { metadata: { promptToCreateSolanaAccount: false } }, + ); }); it('throws an error when requesting account permission approval fails', async () => { @@ -799,29 +802,32 @@ describe('wallet_createSession', () => { unsupportableScopes: {}, }); await handler(baseRequest); - expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: { - requiredScopes: { - 'eip155:1337': { - accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + }, }, - }, - optionalScopes: { - 'eip155:100': { - accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + }, }, + isMultichainOrigin: true, + sessionProperties: {}, }, - isMultichainOrigin: true, - sessionProperties: {}, }, - }, - ], + ], + }, }, - }); + { metadata: { promptToCreateSolanaAccount: false } }, + ); }); it('preserves known session properties', async () => { @@ -993,32 +999,299 @@ describe('wallet_createSession', () => { }, }); - expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: { - requiredScopes: { - 'eip155:1': { - accounts: ['eip155:1:0xABC123'], // Requested EVM address included + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xABC123'], // Requested EVM address included + }, }, + optionalScopes: { + [MultichainNetwork.Solana]: { + accounts: [], // Solana address excluded due to case mismatch + }, + [MultichainNetwork.Bitcoin]: { + accounts: [], // Bitcoin address excluded due to case mismatch + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, }, - optionalScopes: { - [MultichainNetwork.Solana]: { - accounts: [], // Solana address excluded due to case mismatch + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + }); + + describe('promptToCreateSolanaAccount', () => { + const baseRequestWithSolanaScope = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + optionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }; + + it('prompts to create a solana account if a solana scope is requested and no solana accounts are currently available', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + getNonEvmAccountAddresses.mockReturnValue([]); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: {}, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1337': { + methods: [], + notifications: [], + accounts: [], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [], + }, }, - [MultichainNetwork.Bitcoin]: { - accounts: [], // Bitcoin address excluded due to case mismatch + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: + true, }, }, - isMultichainOrigin: true, - sessionProperties: {}, }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: true } }, + ); + }); + + it('does not prompt to create a solana account if a solana scope is requested and solana accounts are currently available', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + getNonEvmAccountAddresses.mockReturnValue([ + 'solana:101:0x1', + 'solana:101:0x2', + ]); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], }, - ], + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [MultichainNetwork.Solana]: { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: + true, + }, + }, + }, + ], + }, }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + + it('adds a wallet scope when solana is requested with no accounts and no other valid scopes exist', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + + getNonEvmAccountAddresses.mockReturnValue([]); + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: + true, + }, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: true } }, + ); + }); + + it('returns error when no scopes are supported and solana is not requested', async () => { + const { handler, end } = createMockedHandler(); + + // Request with no valid scopes + const requestWithNoValidScopes = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + requiredScopes: { + 'unsupported:chain': { + methods: ['someMethod'], + notifications: [], + }, + }, + }, + }; + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'unsupported:chain': { + methods: ['someMethod'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: {}, }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(requestWithNoValidScopes); + + expect(end).toHaveBeenCalledWith( + new JsonRpcError(5100, 'Requested scopes are not supported'), + ); }); }); }); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index 8ce60f861a0..bad56056336 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -13,6 +13,7 @@ import { getCaipAccountIdsFromScopesObjects, getAllScopesFromScopesObjects, setNonSCACaipAccountIdsInCaip25CaveatValue, + isNamespaceInScopesObject, } from '@metamask/chain-agnostic-permission'; import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { @@ -39,6 +40,8 @@ import { import type { GrantedPermissions } from './types'; +const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + /** * Handler for the `wallet_createSession` RPC method which is responsible * for prompting for approval and granting a CAIP-25 permission. @@ -124,6 +127,24 @@ async function walletCreateSessionHandler( } }; + // if solana is a requested scope but not supported, we add a promptToCreateSolanaAccount flag to request + const isSolanaRequested = + isNamespaceInScopesObject( + requiredScopesWithSupportedMethodsAndNotifications, + KnownCaipNamespace.Solana, + ) || + isNamespaceInScopesObject( + optionalScopesWithSupportedMethodsAndNotifications, + KnownCaipNamespace.Solana, + ); + + let promptToCreateSolanaAccount = false; + if (isSolanaRequested) { + const supportedSolanaAccounts = + hooks.getNonEvmAccountAddresses(SOLANA_CAIP_CHAIN_ID); + promptToCreateSolanaAccount = supportedSolanaAccounts.length === 0; + } + const { supportedScopes: supportedRequiredScopes } = bucketScopes( requiredScopesWithSupportedMethodsAndNotifications, { @@ -154,10 +175,6 @@ async function walletCreateSessionHandler( supportedOptionalScopes, ]); - if (allSupportedRequestedCaipChainIds.length === 0) { - return end(new JsonRpcError(5100, 'Requested scopes are not supported')); - } - const existingEvmAddresses = hooks .listAccounts() .map((account) => account.address); @@ -198,16 +215,40 @@ async function walletCreateSessionHandler( supportedRequestedAccountAddresses, ); - const [grantedPermissions] = await hooks.requestPermissionsForOrigin({ - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: requestedCaip25CaveatValueWithSupportedAccounts, - }, - ], + // if `promptToCreateSolanaAccount` is true and there are no other valid scopes requested, + // we add a `wallet` scope to the request in order to get passed the CAIP-25 caveat validator. + // This is very hacky but is necessary because the solana opt-in flow breaks key assumptions + // of the CAIP-25 permission specification - namely that we can have valid requests with no scopes. + if (allSupportedRequestedCaipChainIds.length === 0) { + if (promptToCreateSolanaAccount) { + requestedCaip25CaveatValueWithSupportedAccounts.optionalScopes[ + KnownCaipNamespace.Wallet + ] = { + accounts: [], + }; + } else { + // if solana is not requested and there are no supported scopes, we return an error + return end( + new JsonRpcError(5100, 'Requested scopes are not supported'), + ); + } + } + + const [grantedPermissions] = await hooks.requestPermissionsForOrigin( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: requestedCaip25CaveatValueWithSupportedAccounts, + }, + ], + }, }, - }); + { + metadata: { promptToCreateSolanaAccount }, + }, + ); const approvedCaip25Permission = grantedPermissions[Caip25EndowmentPermissionName]; diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index a393b6c230b..4928c1039f4 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.7.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) ## [0.6.0] @@ -89,7 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle both EVM and non-EVM network and account switching for the associated network. - Act as a proxy for the `NetworkController` (for EVM network changes). -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.7.0...HEAD +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.6.0...@metamask/multichain-network-controller@0.7.0 [0.6.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.5.1...@metamask/multichain-network-controller@0.6.0 [0.5.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.5.0...@metamask/multichain-network-controller@0.5.1 [0.5.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.4.0...@metamask/multichain-network-controller@0.5.0 diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json index 98b400cc988..f8d9b496d05 100644 --- a/packages/multichain-network-controller/package.json +++ b/packages/multichain-network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-network-controller", - "version": "0.6.0", + "version": "0.7.0", "description": "Multichain network controller", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/keyring-api": "^17.4.0", "@metamask/keyring-internal-api": "^6.0.1", "@metamask/superstruct": "^3.1.0", @@ -57,10 +57,10 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", - "@metamask/network-controller": "^23.4.0", + "@metamask/keyring-controller": "^22.0.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/uuid": "^8.3.0", @@ -74,7 +74,7 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/network-controller": "^23.0.0" }, "engines": { diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index e0d22f6d46b..6f50eb0e889 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] + +### Changed + +- **BREAKING:** Store transactions by chain IDs ([#5756](https://github.com/MetaMask/core/pull/5756)) +- Remove Solana mainnet filtering to support other Solana networks (devnet, testnet) ([#5756](https://github.com/MetaMask/core/pull/5756)) + +## [0.11.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + ## [0.10.0] ### Changed @@ -118,7 +131,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5133](https://github.com/MetaMask/core/pull/5133)), ([#5177](https://github.com/MetaMask/core/pull/5177)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.10.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.11.0...@metamask/multichain-transactions-controller@1.0.0 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.10.0...@metamask/multichain-transactions-controller@0.11.0 [0.10.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.9.0...@metamask/multichain-transactions-controller@0.10.0 [0.9.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.8.0...@metamask/multichain-transactions-controller@0.9.0 [0.8.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.7.2...@metamask/multichain-transactions-controller@0.8.0 diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index 8baff503eb3..46221a24561 100644 --- a/packages/multichain-transactions-controller/package.json +++ b/packages/multichain-transactions-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-transactions-controller", - "version": "0.10.0", + "version": "1.0.0", "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", "keywords": [ "MetaMask", @@ -60,9 +60,9 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@metamask/snaps-controllers": "^11.2.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -73,7 +73,7 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/snaps-controllers": "^11.0.0" }, "engines": { diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts index 6aafbdc680e..326f7d6a6a7 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -228,14 +228,13 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); - expect(controller.state).toStrictEqual({ - nonEvmTransactions: { - [mockBtcAccount.id]: { - transactions: mockTransactionResult.data, - next: null, - lastUpdated: expect.any(Number), - }, - }, + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), }); }); @@ -244,22 +243,20 @@ describe('MultichainTransactionsController', () => { setupController(); await controller.updateTransactionsForAccount(mockBtcAccount.id); - expect(controller.state).toStrictEqual({ - nonEvmTransactions: { - [mockBtcAccount.id]: { - transactions: mockTransactionResult.data, - next: null, - lastUpdated: expect.any(Number), - }, - }, + + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), }); messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); mockListMultichainAccounts.mockReturnValue([]); - expect(controller.state).toStrictEqual({ - nonEvmTransactions: {}, - }); + expect(controller.state.nonEvmTransactions).toStrictEqual({}); }); it('does not track balances for EVM accounts', async () => { @@ -282,8 +279,9 @@ describe('MultichainTransactionsController', () => { const { controller } = setupController(); await controller.updateTransactionsForAccount(mockBtcAccount.id); + const { chain } = mockTransactionResult.data[0]; expect( - controller.state.nonEvmTransactions[mockBtcAccount.id], + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], ).toStrictEqual({ transactions: mockTransactionResult.data, next: null, @@ -291,7 +289,7 @@ describe('MultichainTransactionsController', () => { }); }); - it('filters out non-mainnet Solana transactions', async () => { + it('stores transactions by chain for accounts', async () => { const mockSolTransaction = { account: mockSolAccount.id, type: 'send' as const, @@ -327,10 +325,6 @@ describe('MultichainTransactionsController', () => { ], next: null, }; - // First transaction must be the mainnet one (for the test), so we assert this. - expect(mockSolTransactions.data[0].chain).toStrictEqual( - MultichainNetwork.Solana, - ); const { controller, mockSnapHandleRequest } = setupController({ mocks: { @@ -341,10 +335,42 @@ describe('MultichainTransactionsController', () => { await controller.updateTransactionsForAccount(mockSolAccount.id); - const { transactions } = - controller.state.nonEvmTransactions[mockSolAccount.id]; - expect(transactions).toHaveLength(1); - expect(transactions[0]).toStrictEqual(mockSolTransactions.data[0]); // First transaction is the mainnet one. + expect( + Object.keys(controller.state.nonEvmTransactions[mockSolAccount.id]), + ).toHaveLength(4); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.Solana + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.Solana + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[0]); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaTestnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaTestnet + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[1]); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaDevnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaDevnet + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[2]); }); it('handles pagination when fetching transactions', async () => { @@ -455,31 +481,37 @@ describe('MultichainTransactionsController', () => { id: TEST_ACCOUNT_ID, }; + const { chain } = mockTransactionResult.data[0]; const existingTransaction = { ...mockTransactionResult.data[0], id: '123', status: 'confirmed' as const, + chain, }; const newTransaction = { ...mockTransactionResult.data[0], id: '456', status: 'submitted' as const, + chain, }; const updatedExistingTransaction = { ...mockTransactionResult.data[0], id: '123', status: 'failed' as const, + chain, }; const { controller, messenger } = setupController({ state: { nonEvmTransactions: { [mockSolAccountWithId.id]: { - transactions: [existingTransaction], - next: null, - lastUpdated: Date.now(), + [chain]: { + transactions: [existingTransaction], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -494,7 +526,8 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); const finalTransactions = - controller.state.nonEvmTransactions[mockSolAccountWithId.id].transactions; + controller.state.nonEvmTransactions[mockSolAccountWithId.id][chain] + .transactions; expect(finalTransactions).toStrictEqual([ updatedExistingTransaction, newTransaction, @@ -502,13 +535,16 @@ describe('MultichainTransactionsController', () => { }); it('handles empty transaction updates gracefully', async () => { + const { chain } = mockTransactionResult.data[0]; const { controller, messenger } = setupController({ state: { nonEvmTransactions: { [TEST_ACCOUNT_ID]: { - transactions: [], - next: null, - lastUpdated: Date.now(), + [chain]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -520,7 +556,9 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); - expect(controller.state.nonEvmTransactions[TEST_ACCOUNT_ID]).toStrictEqual({ + expect( + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain], + ).toStrictEqual({ transactions: [], next: null, lastUpdated: expect.any(Number), @@ -528,6 +566,8 @@ describe('MultichainTransactionsController', () => { }); it('initializes new accounts with empty transactions array when receiving updates', async () => { + const { chain } = mockTransactionResult.data[0]; + const { controller, messenger } = setupController({ state: { nonEvmTransactions: {}, @@ -541,21 +581,26 @@ describe('MultichainTransactionsController', () => { }); await waitForAllPromises(); - - expect(controller.state.nonEvmTransactions[NEW_ACCOUNT_ID]).toStrictEqual({ + expect( + controller.state.nonEvmTransactions[NEW_ACCOUNT_ID][chain], + ).toStrictEqual({ transactions: mockTransactionResult.data, + next: null, lastUpdated: expect.any(Number), }); }); it('handles undefined transactions in update payload', async () => { + const { chain } = mockTransactionResult.data[0]; const { controller, messenger } = setupController({ state: { nonEvmTransactions: { [TEST_ACCOUNT_ID]: { - transactions: [], - next: null, - lastUpdated: Date.now(), + [chain]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -570,8 +615,10 @@ describe('MultichainTransactionsController', () => { const initialStateSnapshot = { [TEST_ACCOUNT_ID]: { - ...controller.state.nonEvmTransactions[TEST_ACCOUNT_ID], - lastUpdated: expect.any(Number), + [chain]: { + ...controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain], + lastUpdated: expect.any(Number), + }, }, }; @@ -587,6 +634,7 @@ describe('MultichainTransactionsController', () => { }); it('sorts transactions by timestamp (newest first)', async () => { + const { chain } = mockTransactionResult.data[0]; const olderTransaction = { ...mockTransactionResult.data[0], id: '123', @@ -602,9 +650,11 @@ describe('MultichainTransactionsController', () => { state: { nonEvmTransactions: { [TEST_ACCOUNT_ID]: { - transactions: [olderTransaction], - next: null, - lastUpdated: Date.now(), + [chain]: { + transactions: [olderTransaction], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -619,7 +669,7 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); const finalTransactions = - controller.state.nonEvmTransactions[TEST_ACCOUNT_ID].transactions; + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain].transactions; expect(finalTransactions).toStrictEqual([ newerTransaction, olderTransaction, @@ -627,6 +677,7 @@ describe('MultichainTransactionsController', () => { }); it('sorts transactions by timestamp and handles null timestamps', async () => { + const { chain } = mockTransactionResult.data[0]; const nullTimestampTx1 = { ...mockTransactionResult.data[0], id: '123', @@ -647,9 +698,11 @@ describe('MultichainTransactionsController', () => { state: { nonEvmTransactions: { [TEST_ACCOUNT_ID]: { - transactions: [nullTimestampTx1], - next: null, - lastUpdated: Date.now(), + [chain]: { + transactions: [nullTimestampTx1], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -664,7 +717,7 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); const finalTransactions = - controller.state.nonEvmTransactions[TEST_ACCOUNT_ID].transactions; + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain].transactions; expect(finalTransactions).toStrictEqual([ withTimestampTx, nullTimestampTx1, @@ -685,8 +738,10 @@ describe('MultichainTransactionsController', () => { mockGetKeyringState.mockReturnValue({ isUnlocked: true }); await controller.updateTransactionsForAccount(mockBtcAccount.id); + + const { chain } = mockTransactionResult.data[0]; expect( - controller.state.nonEvmTransactions[mockBtcAccount.id], + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], ).toStrictEqual({ transactions: mockTransactionResult.data, next: null, @@ -694,7 +749,7 @@ describe('MultichainTransactionsController', () => { }); }); - it('filters out non-mainnet Solana transactions in transaction updates', async () => { + it('updates transactions by chain when receiving transaction updates', async () => { const mockSolAccountWithId = { ...mockSolAccount, id: TEST_ACCOUNT_ID, @@ -732,9 +787,11 @@ describe('MultichainTransactionsController', () => { state: { nonEvmTransactions: { [mockSolAccountWithId.id]: { - transactions: [], - next: null, - lastUpdated: Date.now(), + [MultichainNetwork.Solana]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, }, }, }, @@ -748,11 +805,31 @@ describe('MultichainTransactionsController', () => { await waitForAllPromises(); - const finalTransactions = - controller.state.nonEvmTransactions[mockSolAccountWithId.id].transactions; + expect( + Object.keys(controller.state.nonEvmTransactions[mockSolAccountWithId.id]), + ).toHaveLength(2); - expect(finalTransactions).toHaveLength(1); - expect(finalTransactions[0]).toBe(mainnetTransaction); + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.Solana + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.Solana + ].transactions[0], + ).toBe(mainnetTransaction); + + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.SolanaDevnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.SolanaDevnet + ].transactions[0], + ).toBe(devnetTransaction); }); it('publishes transactionConfirmed event when transaction is confirmed', async () => { diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts index fc8b09b239a..b36b3c667e3 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -23,15 +23,12 @@ import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; import { - KnownCaipNamespace, - parseCaipChainId, + type CaipChainId, type Json, type JsonRpcRequest, } from '@metamask/utils'; import type { Draft } from 'immer'; -import { MultichainNetwork } from './constants'; - const controllerName = 'MultichainTransactionsController'; /** @@ -51,7 +48,9 @@ export type PaginationOptions = { */ export type MultichainTransactionsControllerState = { nonEvmTransactions: { - [accountId: string]: TransactionStateEntry; + [accountId: string]: { + [chain: CaipChainId]: TransactionStateEntry; + }; }; }; @@ -156,7 +155,7 @@ const multichainTransactionsControllerMetadata = { }; /** - * The state of transactions for a specific account. + * The state of transactions for a specific chain. */ export type TransactionStateEntry = { transactions: Transaction[]; @@ -285,16 +284,36 @@ export class MultichainTransactionsController extends BaseController< { limit: 10 }, ); - const transactions = this.#filterTransactions(response.data); + const transactionsByChain: Record = {}; + + response.data.forEach((transaction) => { + const { chain } = transaction; + + if (!transactionsByChain[chain]) { + transactionsByChain[chain] = []; + } + transactionsByChain[chain].push(transaction); + }); + + const chainUpdates = Object.entries(transactionsByChain).map( + ([chain, transactions]) => ({ + chain, + entry: { + transactions, + next: response.next, + lastUpdated: Date.now(), + }, + }), + ); this.update((state: Draft) => { - const entry: TransactionStateEntry = { - transactions, - next: response.next, - lastUpdated: Date.now(), - }; + if (!state.nonEvmTransactions[account.id]) { + state.nonEvmTransactions[account.id] = {}; + } - Object.assign(state.nonEvmTransactions, { [account.id]: entry }); + chainUpdates.forEach(({ chain, entry }) => { + state.nonEvmTransactions[account.id][chain as CaipChainId] = entry; + }); }); } } catch (error) { @@ -305,27 +324,6 @@ export class MultichainTransactionsController extends BaseController< } } - /** - * Filters transactions to only include mainnet Solana transactions for Solana chains. - * Non-Solana chain transactions are kept as is. - * - * @param transactions - Array of transactions to filter - * @returns Filtered transactions array - */ - #filterTransactions(transactions: Transaction[]): Transaction[] { - return transactions.filter((tx) => { - const chain = tx.chain as MultichainNetwork; - const { namespace } = parseCaipChainId(chain); - - // Enum comparison is safe here as we control both enum values - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (namespace === KnownCaipNamespace.Solana) { - return chain === MultichainNetwork.Solana; - } - return true; - }); - } - /** * Checks for non-EVM accounts. * @@ -395,7 +393,10 @@ export class MultichainTransactionsController extends BaseController< #handleOnAccountTransactionsUpdated( transactionsUpdate: AccountTransactionsUpdatedEventPayload, ): void { - const updatedTransactions: Record = {}; + const updatedTransactions: Record< + string, + Record + > = {}; const transactionsToPublish: Transaction[] = []; if (!transactionsUpdate?.transactions) { @@ -404,45 +405,63 @@ export class MultichainTransactionsController extends BaseController< Object.entries(transactionsUpdate.transactions).forEach( ([accountId, newTransactions]) => { - // Account might not have any transactions yet, so use `[]` in that case. - const oldTransactions = - this.state.nonEvmTransactions[accountId]?.transactions ?? []; + updatedTransactions[accountId] = {}; - const filteredNewTransactions = - this.#filterTransactions(newTransactions); + newTransactions.forEach((tx) => { + const { chain } = tx; - // Uses a `Map` to deduplicate transactions by ID, ensuring we keep the latest version - // of each transaction while preserving older transactions and transactions from other accounts. - // Transactions are sorted by timestamp (newest first). - const transactions = new Map(); + if (!updatedTransactions[accountId][chain]) { + updatedTransactions[accountId][chain] = []; + } - oldTransactions.forEach((tx) => { - transactions.set(tx.id, tx); - }); - - filteredNewTransactions.forEach((tx) => { - transactions.set(tx.id, tx); + updatedTransactions[accountId][chain].push(tx); transactionsToPublish.push(tx); }); - // Sorted by timestamp (newest first). If the timestamp is not provided, those - // transactions will be put in the end of this list. - updatedTransactions[accountId] = Array.from(transactions.values()).sort( - (a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0), + Object.entries(updatedTransactions[accountId]).forEach( + ([chain, chainTransactions]) => { + // Account might not have any transactions yet, so use `[]` in that case. + const oldTransactions = + this.state.nonEvmTransactions[accountId]?.[chain as CaipChainId] + ?.transactions ?? []; + + // Uses a `Map` to deduplicate transactions by ID, ensuring we keep the latest version + // of each transaction while preserving older transactions and transactions from other accounts. + // Transactions are sorted by timestamp (newest first). + const transactions = new Map(); + + oldTransactions.forEach((tx) => { + transactions.set(tx.id, tx); + }); + + chainTransactions.forEach((tx) => { + transactions.set(tx.id, tx); + }); + + // Sorted by timestamp (newest first). If the timestamp is not provided, those + // transactions will be put in the end of this list. + updatedTransactions[accountId][chain as CaipChainId] = Array.from( + transactions.values(), + ).sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + }, ); }, ); this.update((state) => { - Object.entries(updatedTransactions).forEach( - ([accountId, transactions]) => { - state.nonEvmTransactions[accountId] = { - ...state.nonEvmTransactions[accountId], + Object.entries(updatedTransactions).forEach(([accountId, chainsData]) => { + if (!state.nonEvmTransactions[accountId]) { + state.nonEvmTransactions[accountId] = {}; + } + + Object.entries(chainsData).forEach(([chain, transactions]) => { + state.nonEvmTransactions[accountId][chain as CaipChainId] = { transactions, + next: null, lastUpdated: Date.now(), }; - }, - ); + }); + }); }); // After we update the state, publish the events for new/updated transactions diff --git a/packages/multichain/CHANGELOG.md b/packages/multichain/CHANGELOG.md index 4302d172a53..b6ccc341a71 100644 --- a/packages/multichain/CHANGELOG.md +++ b/packages/multichain/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.0] + ### Changed -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [4.0.0] @@ -185,7 +188,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#4962](https://github.com/MetaMask/core/pull/4962)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain@4.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain@4.1.0...HEAD +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@4.0.0...@metamask/multichain@4.1.0 [4.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@3.0.0...@metamask/multichain@4.0.0 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.2.0...@metamask/multichain@3.0.0 [2.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain@2.1.1...@metamask/multichain@2.2.0 diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 5fe568deef9..49b2f12a02b 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain", - "version": "4.0.0", + "version": "4.1.0", "description": "Provides types, helpers, adapters, and wrappers for facilitating CAIP Multichain sessions", "keywords": [ "MetaMask", @@ -47,8 +47,8 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", - "@metamask/controller-utils": "^11.8.0", + "@metamask/api-specs": "^0.14.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-json-rpc-filters": "^9.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/json-rpc-engine": "^10.0.3", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", "@open-rpc/meta-schema": "^1.14.6", "@types/jest": "^27.4.1", diff --git a/packages/multichain/src/scope/constants.test.ts b/packages/multichain/src/scope/constants.test.ts index a01691f2bf5..82915fbe7d0 100644 --- a/packages/multichain/src/scope/constants.test.ts +++ b/packages/multichain/src/scope/constants.test.ts @@ -9,6 +9,9 @@ describe('KnownRpcMethods', () => { "personal_sign", "eth_signTypedData_v4", "wallet_watchAsset", + "wallet_sendCalls", + "wallet_getCallsStatus", + "wallet_getCapabilities", "eth_sendTransaction", "eth_decrypt", "eth_getEncryptionPublicKey", diff --git a/packages/name-controller/CHANGELOG.md b/packages/name-controller/CHANGELOG.md index d12df9de739..82a79f599bd 100644 --- a/packages/name-controller/CHANGELOG.md +++ b/packages/name-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [8.0.3] diff --git a/packages/name-controller/package.json b/packages/name-controller/package.json index d1824229ea1..8d84cc3ffb6 100644 --- a/packages/name-controller/package.json +++ b/packages/name-controller/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0", "async-mutex": "^0.5.0" }, diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index e8ee19a6104..d953ad392a6 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/eth-json-rpc-infura` to `^10.2.0` ([#5867](https://github.com/MetaMask/core/pull/5867)) + +### Fixed + +- Improved error handling in RPC service with more specific error types ([#5843](https://github.com/MetaMask/core/pull/5843)): + - 401 responses now throw an "Unauthorized" error + - 402/404/5xx responses now throw a "Resource Unavailable" error + - 429 responses now throw a "Rate Limiting" error + - Other 4xx responses now throw a generic HTTP client error + - Invalid JSON responses now throw a "Parse" error +- Rather than throwing an error, NetworkController now corrects an invalid initial `selectedNetworkClientId` to point to the default RPC endpoint of the first network sorted by chain ID ([#5851](https://github.com/MetaMask/core/pull/5851)) + +## [23.5.0] + +### Changed + +- Remove obsolete `eth_getBlockByNumber` error handling for load balancer errors ([#5808](https://github.com/MetaMask/core/pull/5808)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5798](https://github.com/MetaMask/core/pull/5798), [#5809](https://github.com/MetaMask/core/pull/5809)) + - HTTP 4XX responses (e.g. rate limit errors) will no longer trigger the circuit breaker policy. + ## [23.4.0] ### Added @@ -839,7 +865,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.4.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.5.0...HEAD +[23.5.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.4.0...@metamask/network-controller@23.5.0 [23.4.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.3.0...@metamask/network-controller@23.4.0 [23.3.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.2.0...@metamask/network-controller@23.3.0 [23.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.1.0...@metamask/network-controller@23.2.0 diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 10734de052c..ee32b99aa5d 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-controller", - "version": "23.4.0", + "version": "23.5.0", "description": "Provides an interface to the currently selected network via a MetaMask-compatible provider object", "keywords": [ "MetaMask", @@ -48,9 +48,10 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", + "@metamask/error-reporting-service": "^0.0.0", "@metamask/eth-block-tracker": "^11.0.3", - "@metamask/eth-json-rpc-infura": "^10.1.1", + "@metamask/eth-json-rpc-infura": "^10.2.0", "@metamask/eth-json-rpc-middleware": "^16.0.1", "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/eth-query": "^4.0.0", diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index b7ddd36f0ee..d23f6d55aae 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -17,6 +17,7 @@ import { BUILT_IN_CUSTOM_NETWORKS_RPC, BUILT_IN_NETWORKS, } from '@metamask/controller-utils'; +import type { ErrorReportingServiceCaptureExceptionAction } from '@metamask/error-reporting-service'; import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; import EthQuery from '@metamask/eth-query'; import { errorCodes } from '@metamask/rpc-errors'; @@ -26,6 +27,7 @@ import type { Hex } from '@metamask/utils'; import { hasProperty, isPlainObject, isStrictHexString } from '@metamask/utils'; import deepEqual from 'fast-deep-equal'; import type { Draft } from 'immer'; +import { produce } from 'immer'; import { cloneDeep } from 'lodash'; import type { Logger } from 'loglevel'; import { createSelector } from 'reselect'; @@ -498,6 +500,11 @@ export type NetworkControllerEvents = | NetworkControllerRpcEndpointDegradedEvent | NetworkControllerRpcEndpointRequestRetriedEvent; +/** + * All events that {@link NetworkController} calls internally. + */ +type AllowedEvents = never; + export type NetworkControllerGetStateAction = ControllerGetStateAction< typeof controllerName, NetworkState @@ -590,12 +597,17 @@ export type NetworkControllerActions = | NetworkControllerRemoveNetworkAction | NetworkControllerUpdateNetworkAction; +/** + * All actions that {@link NetworkController} calls internally. + */ +type AllowedActions = ErrorReportingServiceCaptureExceptionAction; + export type NetworkControllerMessenger = RestrictedMessenger< typeof controllerName, - NetworkControllerActions, - NetworkControllerEvents, - never, - never + NetworkControllerActions | AllowedActions, + NetworkControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] >; /** @@ -1003,7 +1015,7 @@ function deriveInfuraNetworkNameFromRpcEndpointUrl( * @param state - The NetworkController state to verify. * @throws if the state is invalid in some way. */ -function validateNetworkControllerState(state: NetworkState) { +function validateInitialState(state: NetworkState) { const networkConfigurationEntries = Object.entries( state.networkConfigurationsByChainId, ); @@ -1054,14 +1066,44 @@ function validateNetworkControllerState(state: NetworkState) { 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', ); } +} - if (!networkClientIds.includes(state.selectedNetworkClientId)) { - throw new Error( - // This ESLint rule mistakenly produces an error. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `NetworkController state is invalid: \`selectedNetworkClientId\` '${state.selectedNetworkClientId}' does not refer to an RPC endpoint within a network configuration`, - ); - } +/** + * Checks that the given initial NetworkController state is internally + * consistent similar to `validateInitialState`, but if an anomaly is detected, + * it does its best to correct the state and logs an error to Sentry. + * + * @param state - The NetworkController state to verify. + * @param messenger - The NetworkController messenger. + * @returns The corrected state. + */ +function correctInitialState( + state: NetworkState, + messenger: NetworkControllerMessenger, +): NetworkState { + const networkConfigurationsSortedByChainId = getNetworkConfigurations( + state, + ).sort((a, b) => a.chainId.localeCompare(b.chainId)); + const networkClientIds = getAvailableNetworkClientIds( + networkConfigurationsSortedByChainId, + ); + + return produce(state, (newState) => { + if (!networkClientIds.includes(state.selectedNetworkClientId)) { + const firstNetworkConfiguration = networkConfigurationsSortedByChainId[0]; + const newSelectedNetworkClientId = + firstNetworkConfiguration.rpcEndpoints[ + firstNetworkConfiguration.defaultRpcEndpointIndex + ].networkClientId; + messenger.call( + 'ErrorReportingService:captureException', + new Error( + `\`selectedNetworkClientId\` '${state.selectedNetworkClientId}' does not refer to an RPC endpoint within a network configuration; correcting to '${newSelectedNetworkClientId}'`, + ), + ); + newState.selectedNetworkClientId = newSelectedNetworkClientId; + } + }); } /** @@ -1146,7 +1188,9 @@ export class NetworkController extends BaseController< ...getDefaultNetworkControllerState(additionalDefaultNetworks), ...state, }; - validateNetworkControllerState(initialState); + validateInitialState(initialState); + const correctedInitialState = correctInitialState(initialState, messenger); + if (!infuraProjectId || typeof infuraProjectId !== 'string') { throw new Error('Invalid Infura project ID'); } @@ -1168,7 +1212,7 @@ export class NetworkController extends BaseController< }, }, messenger, - state: initialState, + state: correctedInitialState, }); this.#infuraProjectId = infuraProjectId; diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index c4edfd921a7..afef3de8e6e 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -193,22 +193,46 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. @@ -315,22 +339,54 @@ describe('RpcServiceChain', () => { // Retry the first endpoint until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow('Gateway timeout'); + ).rejects.toThrow( + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), + ); // Retry the first endpoint again, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow('Gateway timeout'); + ).rejects.toThrow( + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), + ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow('Gateway timeout'); + ).rejects.toThrow( + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), + ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect( rpcServiceChain.request(jsonRpcRequest, fetchOptions), - ).rejects.toThrow('Gateway timeout'); + ).rejects.toThrow( + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), + ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. // The circuit will break on the last time, and the third endpoint will @@ -415,22 +471,46 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. @@ -530,22 +610,46 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. @@ -645,22 +749,46 @@ describe('RpcServiceChain', () => { }; // Retry the first endpoint until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint again, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Retry the first endpoint for a third time, until max retries is hit. // The circuit will break on the last time, and the second endpoint will // be retried, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( - 'Gateway timeout', + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: 503, + }, + }), ); // Try the first endpoint, see that the circuit is broken, and retry the // second endpoint, until max retries is hit. diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.ts index 65921f27695..1a1204f64cb 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.ts @@ -99,11 +99,11 @@ export class RpcServiceChain implements RpcServiceRequestable { * @param fetchOptions - An options bag for {@link fetch} which further * specifies the request. * @returns The decoded JSON-RPC response from the endpoint. - * @throws A "method not found" error if the response status is 405. - * @throws A rate limiting error if the response HTTP status is 429. - * @throws A timeout error if the response HTTP status is 503 or 504. - * @throws A generic error if the response HTTP status is not 2xx but also not - * 405, 429, 503, or 504. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. */ async request( jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, @@ -122,11 +122,11 @@ export class RpcServiceChain implements RpcServiceRequestable { * @param fetchOptions - An options bag for {@link fetch} which further * specifies the request. * @returns The decoded JSON-RPC response from the endpoint. - * @throws A "method not found" error if the response status is 405. - * @throws A rate limiting error if the response HTTP status is 429. - * @throws A timeout error if the response HTTP status is 503 or 504. - * @throws A generic error if the response HTTP status is not 2xx but also not - * 405, 429, 503, or 504. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. */ async request( jsonRpcRequest: JsonRpcRequest, diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 2bc9c93ae03..ea3473147d7 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -1,6 +1,7 @@ // We use conditions exclusively in this file. /* eslint-disable jest/no-conditional-in-test */ +import { HttpError } from '@metamask/controller-utils'; import { rpcErrors } from '@metamask/rpc-errors'; import nock from 'nock'; import { FetchError } from 'node-fetch'; @@ -83,15 +84,6 @@ describe('RpcService', () => { }, ); - describe('if making the request throws a "Gateway timeout" error', () => { - const error = new Error('Gateway timeout'); - testsForRetriableFetchErrors({ - getClock: () => clock, - producedError: error, - expectedError: error, - }); - }); - describe.each(['ETIMEDOUT', 'ECONNRESET'])( 'if making the request throws a %s error', (errorCode) => { @@ -316,31 +308,31 @@ describe('RpcService', () => { }); }); - describe.each([503, 504])( + describe.each([502, 503, 504])( 'if the endpoint has a %d response', (httpStatus) => { testsForRetriableResponses({ getClock: () => clock, httpStatus, expectedError: rpcErrors.internal({ - message: - 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', + message: 'RPC endpoint not found or unavailable.', }), + expectedOnBreakError: new HttpError(httpStatus), }); }, ); - describe('if the endpoint has a 405 response', () => { - it('throws a non-existent method error without retrying the request', async () => { + describe('if the endpoint has a 401 response', () => { + it('throws a 401 error without retrying the request', async () => { const endpointUrl = 'https://rpc.example.chain'; nock(endpointUrl) .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }) - .reply(405); + .reply(401); const service = new RpcService({ fetch, btoa, @@ -350,11 +342,17 @@ describe('RpcService', () => { const promise = service.request({ id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }); await expect(promise).rejects.toThrow( - 'The method does not exist / is not available.', + expect.objectContaining({ + code: -33100, + message: 'Unauthorized.', + data: { + httpStatus: 401, + }, + }), ); }); @@ -364,10 +362,10 @@ describe('RpcService', () => { .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }) - .reply(405); + .reply(401); const failoverService = buildMockRpcService(); const service = new RpcService({ fetch, @@ -379,7 +377,7 @@ describe('RpcService', () => { const jsonRpcRequest = { id: 1, jsonrpc: '2.0' as const, - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }; await ignoreRejection(service.request(jsonRpcRequest)); @@ -392,10 +390,10 @@ describe('RpcService', () => { .post('/', { id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }) - .reply(405); + .reply(429); const onBreakListener = jest.fn(); const service = new RpcService({ fetch, @@ -407,7 +405,7 @@ describe('RpcService', () => { const promise = service.request({ id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }); await ignoreRejection(promise); @@ -415,6 +413,100 @@ describe('RpcService', () => { }); }); + describe.each([402, 404, 500, 501, 505, 506, 507, 508, 510, 511])( + 'if the endpoint has a %d response', + (httpStatus) => { + it('throws a resource unavailable error without retrying the request', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(httpStatus); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }); + await expect(promise).rejects.toThrow( + expect.objectContaining({ + code: -32002, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }), + ); + }); + + it('does not forward the request to a failover service if given one', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(httpStatus); + const failoverService = buildMockRpcService(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + failoverService, + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_unknownMethod', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + expect(failoverService.request).not.toHaveBeenCalled(); + }); + + it('does not call onBreak', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(httpStatus); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + }); + service.onBreak(onBreakListener); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }); + await ignoreRejection(promise); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }, + ); + describe('if the endpoint has a 429 response', () => { it('throws a rate-limiting error without retrying the request', async () => { const endpointUrl = 'https://rpc.example.chain'; @@ -438,7 +530,15 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }); - await expect(promise).rejects.toThrow('Request is being rate limited.'); + await expect(promise).rejects.toThrow( + expect.objectContaining({ + code: -32005, + message: 'Request is being rate limited.', + data: { + httpStatus: 429, + }, + }), + ); }); it('does not forward the request to a failover service if given one', async () => { @@ -498,8 +598,8 @@ describe('RpcService', () => { }); }); - describe('when the endpoint has a response that is neither 2xx, nor 405, 429, 503, or 504', () => { - it('throws a generic error without retrying the request', async () => { + describe('when the endpoint has a 4xx response that is not 401, 402, 404, or 429', () => { + it('throws an invalid request error without retrying the request', async () => { const endpointUrl = 'https://rpc.example.chain'; nock(endpointUrl) .post('/', { @@ -508,7 +608,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', @@ -527,11 +627,10 @@ describe('RpcService', () => { }); await expect(promise).rejects.toThrow( expect.objectContaining({ - message: "Non-200 status code: '500'", + code: -32100, + message: 'HTTP client error.', data: { - id: 1, - jsonrpc: '2.0', - error: 'oops', + httpStatus: 403, }, }), ); @@ -546,7 +645,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', @@ -578,7 +677,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', @@ -861,36 +960,6 @@ describe('RpcService', () => { }); }); - it('interprets a "Not Found" response for eth_getBlockByNumber as an empty result', async () => { - const endpointUrl = 'https://rpc.example.chain'; - nock(endpointUrl) - .post('/', { - id: 1, - jsonrpc: '2.0', - method: 'eth_getBlockByNumber', - params: ['0x999999999', false], - }) - .reply(200, 'Not Found'); - const service = new RpcService({ - fetch, - btoa, - endpointUrl, - }); - - const response = await service.request({ - id: 1, - jsonrpc: '2.0', - method: 'eth_getBlockByNumber', - params: ['0x999999999', false], - }); - - expect(response).toStrictEqual({ - id: 1, - jsonrpc: '2.0', - result: null, - }); - }); - it('calls the onDegraded callback if the endpoint takes more than 5 seconds to respond', async () => { const endpointUrl = 'https://rpc.example.chain'; nock(endpointUrl) @@ -1273,17 +1342,21 @@ function testsForRetriableFetchErrors({ * @param args.responseBody - The body that the response will have. * @param args.expectedError - The error that a call to the service's `request` * method is expected to produce. + * @param args.expectedOnBreakError - The error expected by the `onBreak` handler when there is a + * circuit break. Defaults to `expectedError` if not provided. */ function testsForRetriableResponses({ getClock, httpStatus, responseBody = '', expectedError, + expectedOnBreakError = expectedError, }: { getClock: () => SinonFakeTimers; httpStatus: number; responseBody?: string; expectedError: string | jest.Constructable | RegExp | Error; + expectedOnBreakError?: string | jest.Constructable | RegExp | Error; }) { // This function is designed to be used inside of a describe, so this won't be // a problem in practice. @@ -1401,7 +1474,7 @@ function testsForRetriableResponses({ expect(onBreakListener).toHaveBeenCalledTimes(1); expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedError, + error: expectedOnBreakError, endpointUrl: `${endpointUrl}/`, }); }); @@ -1619,7 +1692,7 @@ function testsForRetriableResponses({ expect(onBreakListener).toHaveBeenCalledTimes(2); expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedError, + error: expectedOnBreakError, endpointUrl: `${endpointUrl}/`, failoverEndpointUrl: `${failoverEndpointUrl}/`, }); diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index e2766fd2a08..653913b85e2 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -4,10 +4,11 @@ import type { } from '@metamask/controller-utils'; import { CircuitState, + HttpError, createServicePolicy, handleWhen, } from '@metamask/controller-utils'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest } from '@metamask/utils'; import { hasProperty, @@ -250,7 +251,10 @@ export class RpcService implements AbstractRpcService { // Ignore server sent HTML error pages or truncated JSON responses error.message.includes('not valid JSON') || // Ignore server overload errors - error.message.includes('Gateway timeout') || + ('httpStatus' in error && + (error.httpStatus === 502 || + error.httpStatus === 503 || + error.httpStatus === 504)) || (hasProperty(error, 'code') && (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) ); @@ -334,11 +338,11 @@ export class RpcService implements AbstractRpcService { * @param fetchOptions - An options bag for {@link fetch} which further * specifies the request. * @returns The decoded JSON-RPC response from the endpoint. - * @throws A "method not found" error if the response status is 405. - * @throws A rate limiting error if the response HTTP status is 429. - * @throws A timeout error if the response HTTP status is 503 or 504. - * @throws A generic error if the response HTTP status is not 2xx but also not - * 405, 429, 503, or 504. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. */ async request( jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, @@ -358,11 +362,11 @@ export class RpcService implements AbstractRpcService { * @param fetchOptions - An options bag for {@link fetch} which further * specifies the request. * @returns The decoded JSON-RPC response from the endpoint. - * @throws A "method not found" error if the response status is 405. - * @throws A rate limiting error if the response HTTP status is 429. - * @throws A timeout error if the response HTTP status is 503 or 504. - * @throws A generic error if the response HTTP status is not 2xx but also not - * 405, 429, 503, or 504. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. */ async request( jsonRpcRequest: JsonRpcRequest, @@ -379,10 +383,7 @@ export class RpcService implements AbstractRpcService { ); try { - return await this.#executePolicy( - jsonRpcRequest, - completeFetchOptions, - ); + return await this.#processRequest(completeFetchOptions); } catch (error) { if ( this.#policy.circuitBreakerPolicy.state === CircuitState.Open && @@ -462,79 +463,62 @@ export class RpcService implements AbstractRpcService { /** * Makes the request using the Cockatiel policy that this service creates. * - * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. * @param fetchOptions - The options for `fetch`; will be combined with the * fetch options passed to the constructor * @returns The decoded JSON-RPC response from the endpoint. - * @throws A "method not found" error if the response status is 405. - * @throws A rate limiting error if the response HTTP status is 429. - * @throws A timeout error if the response HTTP status is 503 or 504. - * @throws A generic error if the response HTTP status is not 2xx but also not - * 405, 429, 503, or 504. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. */ - async #executePolicy< - Params extends JsonRpcParams, - Result extends Json, - Request extends JsonRpcRequest = JsonRpcRequest, - >( - jsonRpcRequest: Request, + async #processRequest( fetchOptions: FetchOptions, ): Promise | JsonRpcResponse> { - return await this.#policy.execute(async () => { - const response = await this.#fetch(this.endpointUrl, fetchOptions); - - if (response.status === 405) { - throw rpcErrors.methodNotFound(); - } - - if (response.status === 429) { - throw rpcErrors.internal({ message: 'Request is being rate limited.' }); - } - - if (response.status === 503 || response.status === 504) { - throw rpcErrors.internal({ - message: - 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', - }); - } - - const text = await response.text(); - - if ( - jsonRpcRequest.method === 'eth_getBlockByNumber' && - text === 'Not Found' - ) { - return { - id: jsonRpcRequest.id, - jsonrpc: jsonRpcRequest.jsonrpc, - result: null, - }; - } - - // Type annotation: We assume that if this response is valid JSON, it's a - // valid JSON-RPC response. - let json: JsonRpcResponse; - try { - json = JSON.parse(text); - } catch (error) { - if (error instanceof SyntaxError) { - throw rpcErrors.internal({ - message: 'Could not parse response as it is not valid JSON', - data: text, + let response: Response | undefined; + try { + return await this.#policy.execute(async () => { + response = await this.#fetch(this.endpointUrl, fetchOptions); + if (!response.ok) { + throw new HttpError(response.status); + } + return await response.json(); + }); + } catch (error) { + if (error instanceof HttpError) { + const status = error.httpStatus; + if (status === 401) { + throw new JsonRpcError(-33100, 'Unauthorized.', { + httpStatus: status, + }); + } + if (status === 429) { + throw rpcErrors.limitExceeded({ + message: 'Request is being rate limited.', + data: { + httpStatus: status, + }, + }); + } + if (status >= 500 || status === 402 || status === 404) { + throw rpcErrors.resourceUnavailable({ + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: status, + }, }); - } else { - throw error; } - } - if (!response.ok) { - throw rpcErrors.internal({ - message: `Non-200 status code: '${response.status}'`, - data: json, + // Handle all other 4xx errors as generic HTTP client errors + throw new JsonRpcError(-32100, 'HTTP client error.', { + httpStatus: status, + }); + } else if (error instanceof SyntaxError) { + throw rpcErrors.parse({ + message: 'Could not parse response as it is not valid JSON.', }); } - - return json; - }); + throw error; + } } } diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index a0eb50e84a9..20e8bdf1a17 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1,7 +1,6 @@ // A lot of the tests in this file have conditionals. /* eslint-disable jest/no-conditional-in-test */ -import type { Messenger } from '@metamask/base-controller'; import { BuiltInNetworkName, ChainId, @@ -37,6 +36,7 @@ import { INFURA_NETWORKS, TESTNET, } from './helpers'; +import type { RootMessenger } from './helpers'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import type { FakeProviderStub } from '../../../tests/fake-provider'; import { FakeProvider } from '../../../tests/fake-provider'; @@ -51,7 +51,6 @@ import type { InfuraRpcEndpoint, NetworkClientId, NetworkConfiguration, - NetworkControllerActions, NetworkControllerEvents, NetworkControllerMessenger, NetworkControllerOptions, @@ -350,30 +349,90 @@ describe('NetworkController', () => { ); }); - it('throws if selectedNetworkClientId does not match the networkClientId of an RPC endpoint in networkConfigurationsByChainId', () => { - const messenger = buildRootMessenger(); - const restrictedMessenger = buildNetworkControllerMessenger(messenger); - expect( - () => - new NetworkController({ - messenger: restrictedMessenger, - state: { - selectedNetworkClientId: 'nonexistent', - networkConfigurationsByChainId: { - '0x1337': buildCustomNetworkConfiguration({ - chainId: '0x1337', - }), - }, + describe('if selectedNetworkClientId does not match the networkClientId of an RPC endpoint in networkConfigurationsByChainId', () => { + it('corrects selectedNetworkClientId to the default RPC endpoint of the first chain', () => { + const messenger = buildRootMessenger(); + messenger.registerActionHandler( + 'ErrorReportingService:captureException', + jest.fn(), + ); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + const controller = new NetworkController({ + messenger: restrictedMessenger, + state: { + selectedNetworkClientId: 'nonexistent', + networkConfigurationsByChainId: { + '0x1': buildCustomNetworkConfiguration({ + chainId: '0x1', + defaultRpcEndpointIndex: 1, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), + '0x2': buildCustomNetworkConfiguration({ chainId: '0x2' }), + '0x3': buildCustomNetworkConfiguration({ chainId: '0x3' }), }, - infuraProjectId: 'infura-project-id', - getRpcServiceOptions: () => ({ - fetch, - btoa, - }), + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: () => ({ + fetch, + btoa, }), - ).toThrow( - "NetworkController state is invalid: `selectedNetworkClientId` 'nonexistent' does not refer to an RPC endpoint within a network configuration", - ); + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + }); + + it('logs a Sentry error', () => { + const messenger = buildRootMessenger(); + const captureExceptionMock = jest.fn(); + messenger.registerActionHandler( + 'ErrorReportingService:captureException', + captureExceptionMock, + ); + const restrictedMessenger = buildNetworkControllerMessenger(messenger); + + new NetworkController({ + messenger: restrictedMessenger, + state: { + selectedNetworkClientId: 'nonexistent', + networkConfigurationsByChainId: { + '0x1': buildCustomNetworkConfiguration({ + chainId: '0x1', + defaultRpcEndpointIndex: 1, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), + '0x2': buildCustomNetworkConfiguration({ chainId: '0x2' }), + '0x3': buildCustomNetworkConfiguration({ chainId: '0x3' }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: () => ({ + fetch, + btoa, + }), + }); + + expect(captureExceptionMock).toHaveBeenCalledWith( + new Error( + "`selectedNetworkClientId` 'nonexistent' does not refer to an RPC endpoint within a network configuration; correcting to 'BBBB-BBBB-BBBB-BBBB'", + ), + ); + }); }); const invalidInfuraProjectIds = [undefined, null, {}, 1]; @@ -1326,10 +1385,7 @@ describe('NetworkController', () => { { messenger, }: { - messenger: Messenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: RootMessenger; }, args: Parameters, ): ReturnType => @@ -3278,13 +3334,7 @@ describe('NetworkController', () => { ], [ 'NetworkController:getNetworkConfigurationByChainId', - ({ - messenger, - chainId, - }: { - messenger: Messenger; - chainId: Hex; - }) => + ({ messenger, chainId }: { messenger: RootMessenger; chainId: Hex }) => messenger.call( 'NetworkController:getNetworkConfigurationByChainId', chainId, @@ -3397,7 +3447,7 @@ describe('NetworkController', () => { messenger, networkClientId, }: { - messenger: Messenger; + messenger: RootMessenger; networkClientId: NetworkClientId; }) => messenger.call( @@ -15171,7 +15221,7 @@ type WithControllerCallback = ({ controller, }: { controller: NetworkController; - messenger: Messenger; + messenger: RootMessenger; networkControllerMessenger: NetworkControllerMessenger; }) => Promise | ReturnValue; @@ -15350,7 +15400,7 @@ async function waitForPublishedEvents({ // do nothing }, }: { - messenger: Messenger; + messenger: RootMessenger; eventType: E['type']; count?: number; filter?: (payload: E['payload']) => boolean; @@ -15481,7 +15531,7 @@ async function waitForStateChanges({ operation, beforeResolving, }: { - messenger: Messenger; + messenger: RootMessenger; propertyPath?: string[]; count?: number; wait?: number; diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 30def58c9ef..f60ed344994 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -12,6 +12,10 @@ import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import type { FakeProviderStub } from '../../../tests/fake-provider'; import { buildTestObject } from '../../../tests/helpers'; +import type { + ExtractAvailableAction, + ExtractAvailableEvent, +} from '../../base-controller/tests/helpers'; import { type BuiltInNetworkClientId, type CustomNetworkClientId, @@ -27,8 +31,6 @@ import type { AddNetworkFields, CustomRpcEndpoint, InfuraRpcEndpoint, - NetworkControllerActions, - NetworkControllerEvents, NetworkControllerMessenger, UpdateNetworkCustomRpcEndpointFields, } from '../src/NetworkController'; @@ -40,8 +42,8 @@ import type { import { NetworkClientType } from '../src/types'; export type RootMessenger = Messenger< - NetworkControllerActions, - NetworkControllerEvents + ExtractAvailableAction, + ExtractAvailableEvent >; /** @@ -73,7 +75,7 @@ export const TESTNET = { * @returns The messenger. */ export function buildRootMessenger(): RootMessenger { - return new Messenger(); + return new Messenger(); } /** @@ -87,7 +89,7 @@ export function buildNetworkControllerMessenger( ): NetworkControllerMessenger { return messenger.getRestricted({ name: 'NetworkController', - allowedActions: [], + allowedActions: ['ErrorReportingService:captureException'], allowedEvents: [], }); } diff --git a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts b/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts index 95fc8c1f68b..2cab1dbcde0 100644 --- a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts +++ b/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts @@ -337,7 +337,7 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( }); describe.each([ - [405, 'The method does not exist / is not available.'], + [405, 'HTTP client error.'], [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', @@ -364,6 +364,36 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const errorMessage = 'RPC endpoint not found or unavailable.'; + + it('throws a generic, undescriptive error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(errorMessage); + }); + }); testsForRpcFailoverBehavior({ providerType, @@ -383,62 +413,18 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), }); }, ); - describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { - const httpStatus = 500; - const errorMessage = `Non-200 status code: '${httpStatus}'`; - - it('throws a generic, undescriptive error', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow(errorMessage); - }); - }); - - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - }); - }); - - describe.each([503, 504])( + describe.each([502, 503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { - const errorMessage = 'Gateway timeout'; + const errorMessage = 'RPC endpoint not found or unavailable.'; it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -527,6 +513,12 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( expect.objectContaining({ message: expect.stringContaining(errorMessage), }), + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), + }), }); }, ); diff --git a/packages/network-controller/tests/provider-api-tests/block-param.ts b/packages/network-controller/tests/provider-api-tests/block-param.ts index 28ecc9e8fe0..f7f3c82dd48 100644 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/block-param.ts @@ -414,7 +414,7 @@ export function testsForRpcMethodSupportingBlockParam( }); describe.each([ - [405, 'The method does not exist / is not available.'], + [405, 'HTTP client error.'], [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', @@ -454,6 +454,49 @@ export function testsForRpcMethodSupportingBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const errorMessage = 'RPC endpoint not found or unavailable.'; + + it('throws a generic, undescriptive error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(errorMessage); + }); + }); testsForRpcFailoverBehavior({ providerType, @@ -476,78 +519,18 @@ export function testsForRpcMethodSupportingBlockParam( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), }); }, ); - describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { - const httpStatus = 500; - const errorMessage = `Non-200 status code: '${httpStatus}'`; - - it('throws a generic, undescriptive error', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow(errorMessage); - }); - }); - - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }, - getRequestToMock: (request: MockRequest, blockNumber: Hex) => { - return buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - blockNumber, - ); - }, - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - }); - }); - - describe.each([503, 504])( + describe.each([502, 503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { - const errorMessage = 'Gateway timeout'; + const errorMessage = 'RPC endpoint not found or unavailable.'; it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -647,7 +630,6 @@ export function testsForRpcMethodSupportingBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); - testsForRpcFailoverBehavior({ providerType, requestToCall: { @@ -667,7 +649,15 @@ export function testsForRpcMethodSupportingBlockParam( isRetriableFailure: true, getExpectedError: () => expect.objectContaining({ - message: expect.stringContaining(errorMessage), + message: expect.stringContaining( + 'RPC endpoint not found or unavailable.', + ), + }), + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), }), }); }, diff --git a/packages/network-controller/tests/provider-api-tests/no-block-param.ts b/packages/network-controller/tests/provider-api-tests/no-block-param.ts index ef8dd12d54e..0171e16f8fd 100644 --- a/packages/network-controller/tests/provider-api-tests/no-block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/no-block-param.ts @@ -293,7 +293,7 @@ export function testsForRpcMethodAssumingNoBlockParam( }); describe.each([ - [405, 'The method does not exist / is not available.'], + [405, 'HTTP client error.'], [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', @@ -320,6 +320,36 @@ export function testsForRpcMethodAssumingNoBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const errorMessage = 'RPC endpoint not found or unavailable.'; + + it('throws a generic, undescriptive error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(errorMessage); + }); + }); testsForRpcFailoverBehavior({ providerType, @@ -339,62 +369,18 @@ export function testsForRpcMethodAssumingNoBlockParam( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), }); }, ); - describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { - const httpStatus = 500; - const errorMessage = `Non-200 status code: '${httpStatus}'`; - - it('throws a generic, undescriptive error', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow(errorMessage); - }); - }); - - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - }); - }); - - describe.each([503, 504])( + describe.each([502, 503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { - const errorMessage = 'Gateway timeout'; + const errorMessage = 'RPC endpoint not found or unavailable.'; it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -483,6 +469,12 @@ export function testsForRpcMethodAssumingNoBlockParam( expect.objectContaining({ message: expect.stringContaining(errorMessage), }), + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), + }), }); }, ); diff --git a/packages/network-controller/tests/provider-api-tests/rpc-failover.ts b/packages/network-controller/tests/provider-api-tests/rpc-failover.ts index 920345a9a28..7ddb1120828 100644 --- a/packages/network-controller/tests/provider-api-tests/rpc-failover.ts +++ b/packages/network-controller/tests/provider-api-tests/rpc-failover.ts @@ -16,6 +16,8 @@ import { buildRootMessenger } from '../helpers'; * @param args.failure - The failure mock response to use. * @param args.isRetriableFailure - Whether the failure gets retried. * @param args.getExpectedError - Factory returning the expected error. + * @param args.getExpectedBreakError - Factory returning the expected error + * upon circuit break. Defaults to using `getExpectedError`. */ export function testsForRpcFailoverBehavior({ providerType, @@ -24,6 +26,7 @@ export function testsForRpcFailoverBehavior({ failure, isRetriableFailure, getExpectedError, + getExpectedBreakError = getExpectedError, }: { providerType: ProviderType; requestToCall: MockRequest; @@ -31,6 +34,7 @@ export function testsForRpcFailoverBehavior({ failure: MockResponse | Error | string; isRetriableFailure: boolean; getExpectedError: (url: string) => Error | jest.Constructable; + getExpectedBreakError?: (url: string) => Error | jest.Constructable; }) { const blockNumber = '0x100'; const backoffDuration = 100; @@ -199,7 +203,7 @@ export function testsForRpcFailoverBehavior({ chainId, endpointUrl: rpcUrl, failoverEndpointUrl, - error: getExpectedError(rpcUrl), + error: getExpectedBreakError(rpcUrl), }, ); }, @@ -295,7 +299,7 @@ export function testsForRpcFailoverBehavior({ ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: failoverEndpointUrl, - error: getExpectedError(failoverEndpointUrl), + error: getExpectedBreakError(failoverEndpointUrl), }); }, ); diff --git a/packages/network-controller/tsconfig.build.json b/packages/network-controller/tsconfig.build.json index c054df5ef38..fb5b1cb08e5 100644 --- a/packages/network-controller/tsconfig.build.json +++ b/packages/network-controller/tsconfig.build.json @@ -9,7 +9,8 @@ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../eth-json-rpc-provider/tsconfig.build.json" }, - { "path": "../json-rpc-engine/tsconfig.build.json" } + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../error-reporting-service/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/network-controller/tsconfig.json b/packages/network-controller/tsconfig.json index c6a988886f9..cc0926fbd0c 100644 --- a/packages/network-controller/tsconfig.json +++ b/packages/network-controller/tsconfig.json @@ -5,18 +5,11 @@ "rootDir": "../.." }, "references": [ - { - "path": "../base-controller" - }, - { - "path": "../controller-utils" - }, - { - "path": "../eth-json-rpc-provider" - }, - { - "path": "../json-rpc-engine" - } + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../eth-json-rpc-provider" }, + { "path": "../json-rpc-engine" }, + { "path": "../error-reporting-service" } ], "include": ["../../types", "../../tests", "./src", "./tests"] } diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 05fa1da69c5..05f17aa7834 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [8.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/profile-sync-controller` peer dependency to `^15.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Bump peer dependency `@metamask/profile-sync-controller` to `^14.0.0` ([#5789](https://github.com/MetaMask/core/pull/5789)) + - While `@metamask/profile-sync-controller@14.0.0` contains breaking changes for clients, they are not breaking as a peer dependency here as the changes do not impact `@metamask/notification-services-controller` - replaced `KeyringController:withKeyring` with `KeyringController:getState` to get the first HD keyring for notifications ([#5764](https://github.com/MetaMask/core/pull/5764)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) @@ -414,7 +424,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@7.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@8.0.0...HEAD +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@7.0.0...@metamask/notification-services-controller@8.0.0 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@6.0.1...@metamask/notification-services-controller@7.0.0 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@6.0.0...@metamask/notification-services-controller@6.0.1 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@5.0.1...@metamask/notification-services-controller@6.0.0 diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index fe24169af4c..c657e405f4b 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/notification-services-controller", - "version": "7.0.0", + "version": "8.0.0", "description": "Manages New MetaMask decentralized Notification system", "keywords": [ "MetaMask", @@ -111,7 +111,7 @@ "dependencies": { "@contentful/rich-text-html-renderer": "^16.5.2", "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0", "bignumber.js": "^9.1.2", "firebase": "^11.2.0", @@ -122,8 +122,8 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", - "@metamask/profile-sync-controller": "^13.0.0", + "@metamask/keyring-controller": "^22.0.0", + "@metamask/profile-sync-controller": "^15.0.0", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", "contentful": "^10.15.0", @@ -137,8 +137,8 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/keyring-controller": "^21.0.0", - "@metamask/profile-sync-controller": "^13.0.0" + "@metamask/keyring-controller": "^22.0.0", + "@metamask/profile-sync-controller": "^15.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 7445390462b..66e037c08a1 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -141,8 +141,16 @@ describe('metamask-notifications - init()', () => { const act = async (addresses: string[], assertion: () => void) => { mockKeyringControllerGetState.mockReturnValue({ isUnlocked: true, - keyrings: [{ accounts: addresses, type: KeyringTypes.hd }], - keyringsMetadata: [], + keyrings: [ + { + accounts: addresses, + type: KeyringTypes.hd, + metadata: { + id: '123', + name: '', + }, + }, + ], }); await actPublishKeyringStateChange(globalMessenger, addresses); @@ -270,7 +278,6 @@ describe('metamask-notifications - init()', () => { mocks.mockKeyringControllerGetState.mockReturnValue({ isUnlocked: false, // Wallet Locked keyrings: [], - keyringsMetadata: [], }); }); @@ -357,7 +364,6 @@ describe('metamask-notifications - init()', () => { mocks.mockKeyringControllerGetState.mockReturnValue({ isUnlocked: false, keyrings: [], - keyringsMetadata: [], }); }); expect(mockKeyringControllerGetState).toHaveBeenCalledTimes(1); @@ -952,8 +958,16 @@ describe('metamask-notifications - enableMetamaskNotifications()', () => { messengerMocks.mockKeyringControllerGetState.mockReturnValue({ isUnlocked: true, - keyrings: [{ accounts: [ADDRESS_1], type: KeyringTypes.hd }], - keyringsMetadata: [], + keyrings: [ + { + accounts: [ADDRESS_1], + type: KeyringTypes.hd, + metadata: { + id: '123', + name: '', + }, + }, + ], }); return { ...messengerMocks, mockCreateOnChainTriggers }; diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index 99543c82cfa..a8d5b4bcf58 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [11.0.6] diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index 068580bceee..7ee13a56985 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.2.0", diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index a00643d2a55..27d7ee2fc21 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [12.5.0] diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index aca3b7a8db3..933b0f02af9 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@noble/hashes": "^1.4.0", "@types/punycode": "^2.1.0", "ethereum-cryptography": "^2.1.2", diff --git a/packages/polling-controller/CHANGELOG.md b/packages/polling-controller/CHANGELOG.md index c65e4e61161..bdfdba311d3 100644 --- a/packages/polling-controller/CHANGELOG.md +++ b/packages/polling-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [13.0.0] diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index 6612199c402..71b3d96e410 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0", "@types/uuid": "^8.3.0", "fast-json-stable-stringify": "^2.1.0", @@ -56,7 +56,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index e186f9aed1d..f8af262f03b 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [18.1.0] + +### Added + +- Add `dismissSmartAccountSuggestionEnabled` preference ([#5866](https://github.com/MetaMask/core/pull/5866)) + +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [18.0.0] + ### Changed +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) @@ -357,7 +370,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@17.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@18.1.0...HEAD +[18.1.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@18.0.0...@metamask/preferences-controller@18.1.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@17.0.0...@metamask/preferences-controller@18.0.0 [17.0.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@16.0.0...@metamask/preferences-controller@17.0.0 [16.0.0]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@15.0.2...@metamask/preferences-controller@16.0.0 [15.0.2]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@15.0.1...@metamask/preferences-controller@15.0.2 diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index 218743a93f0..8cb289f9683 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/preferences-controller", - "version": "17.0.0", + "version": "18.1.0", "description": "Manages user-configurable settings for MetaMask", "keywords": [ "MetaMask", @@ -48,11 +48,11 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0" + "@metamask/controller-utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -63,7 +63,7 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/keyring-controller": "^21.0.0" + "@metamask/keyring-controller": "^22.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 30ff3ea64e8..579fada570b 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -31,10 +31,13 @@ describe('PreferencesController', () => { useMultiRpcMigration: true, showIncomingTransactions: Object.values( ETHERSCAN_SUPPORTED_CHAIN_IDS, - ).reduce((acc, curr) => { - acc[curr] = true; - return acc; - }, {} as { [chainId in EtherscanSupportedHexChainId]: boolean }), + ).reduce( + (acc, curr) => { + acc[curr] = true; + return acc; + }, + {} as { [chainId in EtherscanSupportedHexChainId]: boolean }, + ), smartTransactionsOptInStatus: true, useSafeChainsListValidation: true, tokenSortConfig: { @@ -43,6 +46,7 @@ describe('PreferencesController', () => { sortCallback: 'stringNumeric', }, privacyMode: false, + dismissSmartAccountSuggestionEnabled: false, }); }); @@ -69,6 +73,10 @@ describe('PreferencesController', () => { { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, ], }, @@ -111,7 +119,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -141,7 +158,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -170,7 +196,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: [], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: [], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -203,6 +238,10 @@ describe('PreferencesController', () => { { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, ], }, @@ -237,10 +276,18 @@ describe('PreferencesController', () => { { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, ], }, @@ -271,7 +318,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00', '0x01'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00', '0x01'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -487,6 +543,13 @@ describe('PreferencesController', () => { controller.setPrivacyMode(true); expect(controller.state.privacyMode).toBe(true); }); + + it('should set dismissSmartAccountSuggestionEnabled', () => { + const controller = setupPreferencesController(); + expect(controller.state.dismissSmartAccountSuggestionEnabled).toBe(false); + controller.setDismissSmartAccountSuggestionEnabled(true); + expect(controller.state.dismissSmartAccountSuggestionEnabled).toBe(true); + }); }); /** diff --git a/packages/preferences-controller/src/PreferencesController.ts b/packages/preferences-controller/src/PreferencesController.ts index c61800c052a..a065fb06212 100644 --- a/packages/preferences-controller/src/PreferencesController.ts +++ b/packages/preferences-controller/src/PreferencesController.ts @@ -132,6 +132,10 @@ export type PreferencesState = { * Controls whether balance and assets are hidden or not */ privacyMode: boolean; + /** + * Allow user to stop being prompted for smart account upgrade + */ + dismissSmartAccountSuggestionEnabled: boolean; }; const metadata = { @@ -154,6 +158,7 @@ const metadata = { useSafeChainsListValidation: { persist: true, anonymous: true }, tokenSortConfig: { persist: true, anonymous: true }, privacyMode: { persist: true, anonymous: true }, + dismissSmartAccountSuggestionEnabled: { persist: true, anonymous: true }, }; const name = 'PreferencesController'; @@ -233,6 +238,7 @@ export function getDefaultPreferencesState(): PreferencesState { sortCallback: 'stringNumeric', }, privacyMode: false, + dismissSmartAccountSuggestionEnabled: false, }; } @@ -585,6 +591,20 @@ export class PreferencesController extends BaseController< state.privacyMode = privacyMode; }); } + + /** + * A setter for the user preferences dismiss smart account upgrade prompt. + * + * @param dismissSmartAccountSuggestionEnabled - true to dismiss smart account upgrade prompt, false to enable it. + */ + setDismissSmartAccountSuggestionEnabled( + dismissSmartAccountSuggestionEnabled: boolean, + ) { + this.update((state) => { + state.dismissSmartAccountSuggestionEnabled = + dismissSmartAccountSuggestionEnabled; + }); + } } export default PreferencesController; diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index bf6e1148b01..fbd330a19e3 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,12 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [13.0.0] +## [15.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [14.0.0] ### Changed - **BREAKING:** Replace all "Profile Syncing" mentions to "Backup & Sync" ([#5686](https://github.com/MetaMask/core/pull/5686)) - Replaces state properties `isProfileSyncingEnabled` to `isBackupAndSyncEnabled`, and `isProfileSyncingUpdateLoading` to `isBackupAndSyncUpdateLoading` + +### Fixed + +- Remove metadata for unsupported keyrings ([#5725](https://github.com/MetaMask/core/pull/5725)) + +## [13.0.0] + +### Changed + - **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^27.0.0` to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) - **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^9.19.0` to `^11.0.0` ([#5639](https://github.com/MetaMask/core/pull/5639)) - **BREAKING:** Bump `@metamask/providers` peer dependency from `^18.1.1` to `^21.0.0` ([#5639](https://github.com/MetaMask/core/pull/5639)) @@ -577,7 +593,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@13.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@15.0.0...HEAD +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@14.0.0...@metamask/profile-sync-controller@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@13.0.0...@metamask/profile-sync-controller@14.0.0 [13.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@12.0.0...@metamask/profile-sync-controller@13.0.0 [12.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@11.0.1...@metamask/profile-sync-controller@12.0.0 [11.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@11.0.0...@metamask/profile-sync-controller@11.0.1 diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 5ab32c29695..76455d6b49a 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/profile-sync-controller", - "version": "13.0.0", + "version": "15.0.0", "description": "The profile sync helps developers synchronize data across multiple clients and devices in a privacy-preserving way. All data saved in the user storage database is encrypted client-side to preserve privacy. The user storage provides a modular design, giving developers the flexibility to construct and manage their storage spaces in a way that best suits their needs", "keywords": [ "MetaMask", @@ -113,11 +113,11 @@ "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@metamask/keyring-internal-api": "^6.0.1", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@types/jest": "^27.4.1", @@ -133,8 +133,8 @@ "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/keyring-controller": "^21.0.0", + "@metamask/accounts-controller": "^29.0.0", + "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.0.0", diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index eb01a865423..c8b7aefeaf9 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -995,7 +995,6 @@ describe('user-storage/user-storage-controller - snap handling', () => { messengerMocks.mockKeyringGetState.mockReturnValue({ isUnlocked: false, keyrings: [], - keyringsMetadata: [], }); const controller = new UserStorageController({ messenger: messengerMocks.messenger, @@ -1012,7 +1011,6 @@ describe('user-storage/user-storage-controller - snap handling', () => { messengerMocks.mockKeyringGetState.mockReturnValue({ isUnlocked: true, keyrings: [], - keyringsMetadata: [], }); const controller = new UserStorageController({ diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index 4a1a3a606b6..eeb9f4861f6 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -132,7 +132,6 @@ export function mockUserStorageMessenger( ).mockReturnValue({ isUnlocked: true, keyrings: [], - keyringsMetadata: [], }); const mockAccountsListAccounts = jest.fn(); diff --git a/packages/queued-request-controller/CHANGELOG.md b/packages/queued-request-controller/CHANGELOG.md index 14fbb949d97..4100ca5104f 100644 --- a/packages/queued-request-controller/CHANGELOG.md +++ b/packages/queued-request-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [10.0.0] diff --git a/packages/queued-request-controller/package.json b/packages/queued-request-controller/package.json index a5a7112cbf4..a2290cba617 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/json-rpc-engine": "^10.0.3", "@metamask/rpc-errors": "^7.0.2", "@metamask/swappable-obj-proxy": "^2.3.0", @@ -56,8 +56,8 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", - "@metamask/selected-network-controller": "^22.0.0", + "@metamask/network-controller": "^23.5.0", + "@metamask/selected-network-controller": "^22.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 779232c9daf..d2067f8b60a 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) -- Bump `@metamask/controller-utils` to `^11.8.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) ## [1.6.0] diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json index 88265158667..d1375a09593 100644 --- a/packages/remote-feature-flag-controller/package.json +++ b/packages/remote-feature-flag-controller/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/utils": "^11.2.0", "uuid": "^8.3.2" }, diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index f523b732d6a..43bcb8cecca 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -130,9 +130,9 @@ describe('user-segmentation-utils', () => { distribution[Math.min(distributionIndex, 9)] += 1; }); - // Each range should have roughly 10% of the values and 30% deviation + // Each range should have roughly 10% of the values and 40% deviation const expectedPerRange = samples / ranges.length; - const allowedDeviation = expectedPerRange * 0.3; + const allowedDeviation = expectedPerRange * 0.4; // Check distribution distribution.forEach((count) => { diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index faaf0a3dbb0..265f36ba09b 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -52,8 +52,8 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/controller-utils": "^11.8.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/controller-utils": "^11.9.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 35723196073..1f5875b27c0 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,24 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial implementation of the seedless onboarding controller. ([#5671](https://github.com/MetaMask/core/pull/5671)) +- Initial implementation of the seedless onboarding controller. ([#5874](https://github.com/MetaMask/core/pull/5874)) - Authenticate OAuth user using the seedless onboarding flow and determine if the user is already registered or not - Create a new Toprf key and backup seed phrase - Add a new seed phrase backup to the metadata store - Add array of new seed phrase backups to the metadata store in batch (useful in multi-srp flow) - Fetch seed phrase metadata from the metadata store - Update the password of the seedless onboarding flow -- Support multi SRP sync using social login. ([#5](https://github.com/Web3Auth/core/pull/5)) +- Support multi SRP sync using social login. ([#5875](https://github.com/MetaMask/core/pull/5875)) - Update Metadata to support multiple types of secrets (SRP, PrivateKey). - Add `Controller Lock` which will sync with `Keyring Lock`. -- Password sync features implementation. ([#6](https://github.com/Web3Auth/core/pull/6)) + - Updated `VaultEncryptor` type in constructor args and is compulsory to provided relevant encryptor to constructor. + - Added new non-persisted states, `encryptionKey` and `encryptionSalt` to decrypt the vault when password is not available. + - Update `password` param in `fetchAllSeedPhrases` method to optional. If password is not provided, `cached EncryptionKey` will be used. +- Password sync features implementation. ([#5877](https://github.com/MetaMask/core/pull/5877)) - checkIsPasswordOutdated to check current password is outdated compare to global password - Add password outdated check to add SRPs / change password - recover old password using latest global password - sync latest global password to reset vault to use latest password and persist latest auth pubkey -- Updated `toprf-secure-backup` to `0.2.0`. ([#13](https://github.com/Web3Auth/core/pull/13)) +- Updated `toprf-secure-backup` to `0.3.0`. ([#5880](https://github.com/MetaMask/core/pull/5880)) - added an optional constructor param, `topfKeyDeriver` to assist the `Key derivation` in `toprf-seucre-backup` sdk and adds an additinal security -- Added new state value, `recoveryRatelimitCache` to the controller. ([#17](https://github.com/Web3Auth/core/pull/17)) - - The new state can be used to parse the `RecoveryError` correctly and synchroize the error data (numberOfAttempts) across multiple devices. + - added new state value, `recoveryRatelimitCache` to the controller to parse the `RecoveryError` correctly and synchroize the error data (numberOfAttempts) across multiple devices. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 6e60255f8dd..8d4d04ee3f4 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -58,7 +58,7 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/browser-passworder": "^4.3.0", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@noble/ciphers": "^0.5.2", "@noble/curves": "^1.2.0", "@noble/hashes": "^1.4.0", @@ -74,7 +74,7 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/keyring-controller": "^21.0.0" + "@metamask/keyring-controller": "^22.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 3ddd9f12334..90b8d86ad0d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -25,6 +25,7 @@ import { bytesToBase64, stringToBytes, bigIntToHex, + remove0x, } from '@metamask/utils'; import type { webcrypto } from 'node:crypto'; @@ -70,6 +71,7 @@ const authConnectionId = 'seedless-onboarding'; const groupedAuthConnectionId = 'auth-server'; const userId = 'user-test@gmail.com'; const idTokens = ['idToken']; +const refreshToken = 'refreshToken'; const MOCK_NODE_AUTH_TOKENS = [ { @@ -89,6 +91,21 @@ const MOCK_NODE_AUTH_TOKENS = [ }, ]; +/** + * Mock getNewRefreshToken function for tests. + * + * @returns A promise that resolves to new idTokens and refreshToken. + */ +const mockGetNewRefreshToken = jest.fn().mockResolvedValue({ + idTokens: ['newIdToken'], + refreshToken: 'newRefreshToken', +}); + +const mockRevokeRefreshToken = jest.fn().mockResolvedValue({ + newRevokeToken: 'newRevokeToken', + newRefreshToken: 'newRefreshToken', +}); + const MOCK_KEYRING_ID = 'mock-keyring-id'; const MOCK_SEED_PHRASE = stringToBytes( 'horror pink muffin canal young photo magnet runway start elder patch until', @@ -171,6 +188,8 @@ async function withController( encryptor, messenger, network: Web3AuthNetwork.Devnet, + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, ...rest, }); const { toprfClient } = controller; @@ -339,6 +358,7 @@ async function mockCreateToprfKeyAndBackupSeedPhrase( * @param authKeyPair - The authentication key pair. * @param MOCK_PASSWORD - The mock password. * @param authTokens - The authentication tokens. + * @param mockRefreshToken - The refresh token. * * @returns The mock vault data. */ @@ -347,6 +367,7 @@ async function createMockVault( authKeyPair: KeyPair, MOCK_PASSWORD: string, authTokens: NodeAuthTokens, + mockRefreshToken: string = refreshToken, ) { const encryptor = createMockVaultEncryptor(); @@ -357,6 +378,7 @@ async function createMockVault( sk: `0x${authKeyPair.sk.toString(16)}`, pk: bytesToBase64(authKeyPair.pk), }), + refreshToken: mockRefreshToken, }); const { vault: encryptedMockVault, exportedKeyString } = @@ -436,10 +458,12 @@ function getMockInitialControllerState(options?: { } if (options?.withMockAuthenticatedUser) { + state.authConnection = authConnection; state.nodeAuthTokens = MOCK_NODE_AUTH_TOKENS; state.authConnectionId = authConnectionId; state.groupedAuthConnectionId = groupedAuthConnectionId; state.userId = userId; + state.refreshToken = refreshToken; } if (options?.withMockAuthPubKey || options?.authPubKey) { @@ -460,6 +484,8 @@ describe('SeedlessOnboardingController', () => { const controller = new SeedlessOnboardingController({ messenger, encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -476,6 +502,8 @@ describe('SeedlessOnboardingController', () => { new SeedlessOnboardingController({ messenger, encryptor, + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, }), ).not.toThrow(); }); @@ -531,6 +559,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -561,6 +590,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -593,6 +623,7 @@ describe('SeedlessOnboardingController', () => { groupedAuthConnectionId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -637,6 +668,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.AuthenticationError, @@ -830,6 +862,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); const { encKey, authKeyPair } = mockcreateLocalKey( @@ -1580,6 +1613,7 @@ describe('SeedlessOnboardingController', () => { nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, userId, authConnectionId, + refreshToken, }, }, async ({ controller, toprfClient, initialState, encryptor }) => { @@ -1629,6 +1663,54 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should be able to fetch seed phrases with cached encryption key without providing password', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + const MOCK_VAULT = mockResult.encryptedMockVault; + const MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + const MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [MOCK_SEED_PHRASE], + MOCK_PASSWORD, + ), + }); + + const secretData = await controller.fetchAllSeedPhrases(); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toBeDefined(); + expect(secretData).toStrictEqual([MOCK_SEED_PHRASE]); + }, + ); + }); + it('should throw an error if the key recovery failed', async () => { await withController( { @@ -2928,6 +3010,7 @@ describe('SeedlessOnboardingController', () => { sk: bigIntToHex(newAuthKeyPair.sk), pk: bytesToBase64(newAuthKeyPair.pk), }), + refreshToken: controller.state.refreshToken, }); expect(encryptorSpy).toHaveBeenCalledWith( GLOBAL_PASSWORD, @@ -3076,4 +3159,689 @@ describe('SeedlessOnboardingController', () => { ); }); }); + + describe('token refresh functionality', () => { + const MOCK_PASSWORD = 'mock-password'; + const NEW_MOCK_PASSWORD = 'new-mock-password'; + + beforeEach(() => { + // This resets both call history AND implementation + mockGetNewRefreshToken.mockReset(); + mockGetNewRefreshToken.mockResolvedValue({ + idTokens: ['newIdToken'], + refreshToken: 'newRefreshToken', + }); + }); + + describe('changePassword with token refresh', () => { + it('should retry changePassword after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to fail first with token expired error, then succeed + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = + mockToprfEncryptor.deriveEncKey(NEW_MOCK_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(NEW_MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'changeEncKey') + .mockImplementationOnce(() => { + // Mock the recover enc key for second time + mockRecoverEncKey(toprfClient, NEW_MOCK_PASSWORD); + + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD); + + // Verify that getNewRefreshToken was called + expect(mockGetNewRefreshToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken, + }); + + // Verify that changeEncKey was called twice (once failed, once succeeded) + expect(toprfClient.changeEncKey).toHaveBeenCalledTimes(2); + + // Verify that authenticate was called during token refresh + expect(toprfClient.authenticate).toHaveBeenCalled(); + }, + ); + }); + + it('should fail if token refresh fails during changePassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to always fail with token expired error + jest + .spyOn(toprfClient, 'changeEncKey') + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }); + + // Mock getNewRefreshToken to fail + mockGetNewRefreshToken.mockRejectedValueOnce( + new Error('Failed to get new refresh token'), + ); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + + // Verify that getNewRefreshToken was called + expect(mockGetNewRefreshToken).toHaveBeenCalled(); + }, + ); + }); + + it('should not retry on non-token-related errors during changePassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + // Mock the recover enc key + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Mock changeEncKey to fail with a non-token error + jest + .spyOn(toprfClient, 'changeEncKey') + .mockRejectedValue(new Error('Some other error')); + + await expect( + controller.changePassword(NEW_MOCK_PASSWORD, MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + ); + + // Verify that getNewRefreshToken was NOT called + expect(mockGetNewRefreshToken).not.toHaveBeenCalled(); + + // Verify that changeEncKey was only called once (no retry) + expect(toprfClient.changeEncKey).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('syncLatestGlobalPassword with token refresh', () => { + const OLD_PASSWORD = 'old-mock-password'; + const GLOBAL_PASSWORD = 'new-global-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + let INITIAL_AUTH_PUB_KEY: string; + let initialAuthKeyPair: KeyPair; // Store initial keypair for vault creation + let initialEncKey: Uint8Array; // Store initial encKey for vault creation + + // Generate initial keys and vault state before tests run + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); + initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); + INITIAL_AUTH_PUB_KEY = bytesToBase64(initialAuthKeyPair.pk); + + const mockResult = await createMockVault( + initialEncKey, + initialAuthKeyPair, + OLD_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should retry syncLatestGlobalPassword after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient, encryptor }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + const verifyPasswordSpy = jest.spyOn( + controller, + 'verifyVaultPassword', + ); + const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); + const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); + + // Mock recoverEncKey for the new global password + const mockToprfEncryptor = createMockToprfEncryptor(); + const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const newAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + + // Mock recoverEncKey to fail first with token expired error, then succeed + recoverEncKeySpy + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce({ + encKey: newEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }); + + // Verify that getNewRefreshToken was called + expect(mockGetNewRefreshToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken, + }); + + // Verify that recoverEncKey was called twice (once failed, once succeeded) + expect(recoverEncKeySpy).toHaveBeenCalledTimes(2); + + // Verify that authenticate was called during token refresh + expect(toprfClient.authenticate).toHaveBeenCalled(); + + // Verify that verifyPassword was called + expect(verifyPasswordSpy).toHaveBeenCalledWith(OLD_PASSWORD, { + skipLock: true, + }); + + // Check if vault was re-encrypted with the new password and keys + const expectedSerializedVaultData = JSON.stringify({ + authTokens: controller.state.nodeAuthTokens, + toprfEncryptionKey: bytesToBase64(newEncKey), + toprfAuthKeyPair: JSON.stringify({ + sk: bigIntToHex(newAuthKeyPair.sk), + pk: bytesToBase64(newAuthKeyPair.pk), + }), + refreshToken: controller.state.refreshToken, + }); + expect(encryptorSpy).toHaveBeenCalledWith( + GLOBAL_PASSWORD, + expectedSerializedVaultData, + ); + + // Check if authPubKey was updated in state + expect(controller.state.authPubKey).toBe( + bytesToBase64(newAuthKeyPair.pk), + ); + }, + ); + }); + + it('should fail if token refresh fails during syncLatestGlobalPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + // Mock recoverEncKey to fail with token expired error + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockImplementationOnce(() => { + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }); + + // Mock getNewRefreshToken to fail + mockGetNewRefreshToken.mockRejectedValueOnce( + new Error('Failed to get new refresh token'), + ); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + // Verify that getNewRefreshToken was called + expect(mockGetNewRefreshToken).toHaveBeenCalled(); + }, + ); + }); + + it('should not retry on non-token-related errors during syncLatestGlobalPassword', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + // Unlock controller first + await controller.submitPassword(OLD_PASSWORD); + + // Mock recoverEncKey to fail with a non-token error + jest + .spyOn(toprfClient, 'recoverEncKey') + .mockRejectedValue(new Error('Some other error')); + + await expect( + controller.syncLatestGlobalPassword({ + oldPassword: OLD_PASSWORD, + globalPassword: GLOBAL_PASSWORD, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.LoginFailedError, + ); + + // Verify that getNewRefreshToken was NOT called + expect(mockGetNewRefreshToken).not.toHaveBeenCalled(); + + // Verify that recoverEncKey was only called once (no retry) + expect(toprfClient.recoverEncKey).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('addNewSeedPhraseBackup with token refresh', () => { + const NEW_KEY_RING = { + id: 'new-keyring-1', + seedPhrase: stringToBytes('new mock seed phrase 1'), + }; + + it('should retry addNewSeedPhraseBackup after refreshing expired tokens', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = + await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient }) => { + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + await controller.submitPassword(MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'addSecretDataItem') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.addNewSeedPhraseBackup( + NEW_KEY_RING.seedPhrase, + NEW_KEY_RING.id, + ); + + // Verify that getNewRefreshToken was called + expect(mockGetNewRefreshToken).toHaveBeenCalled(); + + // Verify that addSecretDataItem was called twice + expect(toprfClient.addSecretDataItem).toHaveBeenCalledTimes(2); + }, + ); + }); + }); + + describe('fetchAllSeedPhrases with token refresh', () => { + it('should retry fetchAllSeedPhrases after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + // Mock recoverEncKey + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockImplementationOnce(() => { + // Mock the recover enc key for second time + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce([]); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.fetchAllSeedPhrases(MOCK_PASSWORD); + + expect(result).toStrictEqual([]); + expect(mockGetNewRefreshToken).toHaveBeenCalled(); + expect(toprfClient.fetchAllSecretDataItems).toHaveBeenCalledTimes( + 2, + ); + }, + ); + }); + }); + + describe('createToprfKeyAndBackupSeedPhrase with token refresh', () => { + it('should retry createToprfKeyAndBackupSeedPhrase after refreshing expired tokens', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + await controller.submitPassword(MOCK_PASSWORD); + // Mock createLocalKey + mockcreateLocalKey(toprfClient, MOCK_PASSWORD); + + // Mock addSecretDataItem + jest + .spyOn(toprfClient, 'addSecretDataItem') + .mockImplementationOnce(() => { + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }) + .mockResolvedValueOnce(); + + // persist the local enc key + jest.spyOn(toprfClient, 'persistLocalKey').mockResolvedValueOnce(); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.createToprfKeyAndBackupSeedPhrase( + MOCK_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + expect(mockGetNewRefreshToken).toHaveBeenCalled(); + expect(toprfClient.persistLocalKey).toHaveBeenCalledTimes(2); + }, + ); + }); + }); + + describe('refreshNodeAuthTokens', () => { + it('should successfully refresh node auth tokens', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = + await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient }) => { + await controller.submitPassword(MOCK_PASSWORD); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: [ + { + authToken: 'newAuthToken1', + nodeIndex: 1, + nodePubKey: 'newNodePubKey1', + }, + { + authToken: 'newAuthToken2', + nodeIndex: 2, + nodePubKey: 'newNodePubKey2', + }, + { + authToken: 'newAuthToken3', + nodeIndex: 3, + nodePubKey: 'newNodePubKey3', + }, + ], + isNewUser: false, + }); + + await controller.refreshNodeAuthTokens(); + + expect(mockGetNewRefreshToken).toHaveBeenCalledWith({ + connection: controller.state.authConnection, + refreshToken, + }); + + expect(toprfClient.authenticate).toHaveBeenCalledWith({ + authConnectionId: + // eslint-disable-next-line jest/no-conditional-in-test + controller.state.groupedAuthConnectionId || + controller.state.authConnectionId, + userId: controller.state.userId, + idTokens: ['newIdToken'].map((idToken) => { + return remove0x(keccak256AndHexify(stringToBytes(idToken))); + }), + groupedAuthConnectionParams: { + authConnectionId: controller.state.authConnectionId, + idTokens: ['newIdToken'], + }, + }); + }, + ); + }); + + it('should throw error if controller is locked', async () => { + await withController(async ({ controller }) => { + await expect(controller.refreshNodeAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }); + }); + + it('should throw error if no refresh token is available', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = + await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + MOCK_NODE_AUTH_TOKENS, + ); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: false, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + await expect(controller.refreshNodeAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + }, + ); + }); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 28d943df34d..232de85a1d2 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -7,7 +7,11 @@ import type { RecoverEncryptionKeyResult, SEC1EncodedPublicKey, } from '@metamask/toprf-secure-backup'; -import { ToprfSecureBackup } from '@metamask/toprf-secure-backup'; +import { + ToprfSecureBackup, + TOPRFErrorCode, + TOPRFError, +} from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; @@ -33,6 +37,8 @@ import type { SocialBackupsMetadata, SRPBackedUpUserDetails, VaultEncryptor, + RefreshJWTToken, + RevokeRefreshToken, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -109,6 +115,14 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< @@ -124,6 +138,10 @@ export class SeedlessOnboardingController extends BaseController< readonly toprfClient: ToprfSecureBackup; + readonly #refreshJWTToken: RefreshJWTToken; + + readonly #revokeRefreshToken: RevokeRefreshToken; + /** * Controller lock state. * @@ -140,6 +158,8 @@ export class SeedlessOnboardingController extends BaseController< * @param options.encryptor - An optional encryptor to use for encrypting and decrypting seedless onboarding vault. * @param options.toprfKeyDeriver - An optional key derivation interface for the TOPRF client. * @param options.network - The network to be used for the Seedless Onboarding flow. + * @param options.refreshJWTToken - A function to get a new jwt token using refresh token. + * @param options.revokeRefreshToken - A function to revoke the refresh token. */ constructor({ messenger, @@ -147,6 +167,8 @@ export class SeedlessOnboardingController extends BaseController< encryptor, toprfKeyDeriver, network = Web3AuthNetwork.Mainnet, + refreshJWTToken, + revokeRefreshToken, }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, @@ -163,6 +185,8 @@ export class SeedlessOnboardingController extends BaseController< network, keyDeriver: toprfKeyDeriver, }); + this.#refreshJWTToken = refreshJWTToken; + this.#revokeRefreshToken = revokeRefreshToken; // setup subscriptions to the keyring lock event // when the keyring is locked (wallet is locked), the controller will be cleared of its credentials @@ -185,6 +209,9 @@ export class SeedlessOnboardingController extends BaseController< * @param params.userId - user email or id from Social login * @param params.groupedAuthConnectionId - Optional grouped authConnectionId to be used for the authenticate request. * @param params.socialLoginEmail - The user email from Social login. + * @param params.refreshToken - refresh token for refreshing expired nodeAuthTokens. + * @param params.revokeToken - revoke token for revoking refresh token and get new refresh token and new revoke token. + * @param params.skipLock - Optional flag to skip acquiring the controller lock. * You can pass this to use aggregate multiple OAuth connections. Useful when you want user to have same account while using different OAuth connections. * @returns A promise that resolves to the authentication result. */ @@ -195,8 +222,11 @@ export class SeedlessOnboardingController extends BaseController< userId: string; groupedAuthConnectionId?: string; socialLoginEmail?: string; + refreshToken?: string; + revokeToken?: string; + skipLock?: boolean; }) { - return await this.#withControllerLock(async () => { + const doAuthenticate = async () => { try { const { idTokens, @@ -205,6 +235,8 @@ export class SeedlessOnboardingController extends BaseController< userId, authConnection, socialLoginEmail, + refreshToken, + revokeToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -221,7 +253,15 @@ export class SeedlessOnboardingController extends BaseController< state.userId = userId; state.authConnection = authConnection; state.socialLoginEmail = socialLoginEmail; + if (refreshToken) { + state.refreshToken = refreshToken; + } + if (revokeToken) { + // Temporarily store revoke token in state for later vault creation + state.revokeToken = revokeToken; + } }); + return authenticationResult; } catch (error) { log('Error authenticating user', error); @@ -229,7 +269,10 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.AuthenticationError, ); } - }); + }; + return params.skipLock + ? await doAuthenticate() + : await this.#withControllerLock(doAuthenticate); } /** @@ -255,29 +298,35 @@ export class SeedlessOnboardingController extends BaseController< await this.toprfClient.createLocalKey({ password, }); + const performKeyCreationAndBackup = async (): Promise => { + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, + seedPhrase, + encKey, + authKeyPair, + }); - // encrypt and store the seed phrase backup - await this.#encryptAndStoreSeedPhraseBackup({ - keyringId, - seedPhrase, - encKey, - authKeyPair, - }); + // store/persist the encryption key shares + // We store the seed phrase metadata in the metadata store first. If this operation fails, + // we avoid persisting the encryption key shares to prevent a situation where a user appears + // to have an account but with no associated data. + await this.#persistOprfKey(oprfKey, authKeyPair.pk); + // create a new vault with the resulting authentication data + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + }; - // store/persist the encryption key shares - // We store the seed phrase metadata in the metadata store first. If this operation fails, - // we avoid persisting the encryption key shares to prevent a situation where a user appears - // to have an account but with no associated data. - await this.#persistOprfKey(oprfKey, authKeyPair.pk); - // create a new vault with the resulting authentication data - await this.#createNewVaultWithAuthData({ - password, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, - }); - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, - }); + await this.#executeWithTokenRefresh( + performKeyCreationAndBackup, + 'createToprfKeyAndBackupSeedPhrase', + ); }); } @@ -298,17 +347,25 @@ export class SeedlessOnboardingController extends BaseController< skipCache: true, skipLock: true, // skip lock since we already have the lock }); - // verify the password and unlock the vault - const { toprfEncryptionKey, toprfAuthKeyPair } = - await this.#unlockVaultAndGetBackupEncKey(); - - // encrypt and store the seed phrase backup - await this.#encryptAndStoreSeedPhraseBackup({ - keyringId, - seedPhrase, - encKey: toprfEncryptionKey, - authKeyPair: toprfAuthKeyPair, - }); + + const performBackup = async (): Promise => { + // verify the password and unlock the vault + const { toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + // encrypt and store the seed phrase backup + await this.#encryptAndStoreSeedPhraseBackup({ + keyringId, + seedPhrase, + encKey: toprfEncryptionKey, + authKeyPair: toprfAuthKeyPair, + }); + }; + + await this.#executeWithTokenRefresh( + performBackup, + 'addNewSeedPhraseBackup', + ); }); } @@ -317,39 +374,60 @@ export class SeedlessOnboardingController extends BaseController< * * Decrypts the seed phrases and returns the decrypted seed phrases using the recovered encryption key from the password. * - * @param password - The password used to create new wallet and seedphrase + * @param password - The optional password used to create new wallet and seedphrase. If not provided, `cached Encryption Key` will be used. * @returns A promise that resolves to the seed phrase metadata. */ - async fetchAllSeedPhrases(password: string): Promise { + async fetchAllSeedPhrases(password?: string): Promise { // assert that the user is authenticated before fetching the seed phrases this.#assertIsAuthenticatedUser(this.state); return await this.#withControllerLock(async () => { - const { encKey, authKeyPair } = await this.#recoverEncKey(password); + let encKey: Uint8Array; + let authKeyPair: KeyPair; - try { - const secretData = await this.toprfClient.fetchAllSecretDataItems({ - decKey: encKey, - authKeyPair, - }); + if (password) { + const recoverEncKeyResult = await this.#recoverEncKey(password); + encKey = recoverEncKeyResult.encKey; + authKeyPair = recoverEncKeyResult.authKeyPair; + } else { + this.#assertIsUnlocked(); + // verify the password and unlock the vault + const keysFromVault = await this.#unlockVaultAndGetBackupEncKey(); + encKey = keysFromVault.toprfEncryptionKey; + authKeyPair = keysFromVault.toprfAuthKeyPair; + } - if (secretData?.length > 0) { - await this.#createNewVaultWithAuthData({ - password, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, + try { + const performFetch = async (): Promise => { + const secretData = await this.toprfClient.fetchAllSecretDataItems({ + decKey: encKey, + authKeyPair, }); - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, - }); - } + if (secretData?.length > 0 && password) { + // if password is provided, we need to create a new vault with the auth data. (supposedly the user is trying to rehydrate the wallet) + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + } + + const secrets = SecretMetadata.parseSecretsFromMetadataStore( + secretData, + SecretType.Mnemonic, + ); + return secrets.map((secret) => secret.data); + }; - const secrets = SecretMetadata.parseSecretsFromMetadataStore( - secretData, - SecretType.Mnemonic, + return await this.#executeWithTokenRefresh( + performFetch, + 'fetchAllSeedPhrases', ); - return secrets.map((secret) => secret.data); } catch (error) { log('Error fetching seed phrase metadata', error); throw new Error( @@ -380,7 +458,7 @@ export class SeedlessOnboardingController extends BaseController< skipLock: true, // skip lock since we already have the lock }); - try { + const attemptChangePassword = async (): Promise => { // update the encryption key with new password and update the Metadata Store const { encKey: newEncKey, authKeyPair: newAuthKeyPair } = await this.#changeEncryptionKey(newPassword, oldPassword); @@ -396,6 +474,13 @@ export class SeedlessOnboardingController extends BaseController< authPubKey: newAuthKeyPair.pk, }); this.#resetPasswordOutdatedCache(); + }; + + try { + await this.#executeWithTokenRefresh( + attemptChangePassword, + 'changePassword', + ); } catch (error) { log('Error changing password', error); throw new Error( @@ -486,6 +571,8 @@ export class SeedlessOnboardingController extends BaseController< return await this.#withControllerLock(async () => { await this.#unlockVaultAndGetBackupEncKey(password); this.#setUnlocked(); + // revoke and recyle refresh token after unlock to keep refresh token fresh, avoid malicious use of leaked refresh token + await this.revokeRefreshToken(password); }); } @@ -498,6 +585,7 @@ export class SeedlessOnboardingController extends BaseController< this.update((state) => { delete state.vaultEncryptionKey; delete state.vaultEncryptionSalt; + delete state.revokeToken; }); this.#isUnlocked = false; @@ -521,23 +609,30 @@ export class SeedlessOnboardingController extends BaseController< globalPassword: string; }) { return await this.#withControllerLock(async () => { - // verify correct old password - await this.verifyVaultPassword(oldPassword, { - skipLock: true, // skip lock since we already have the lock - }); - // update vault with latest globalPassword - const { encKey, authKeyPair } = await this.#recoverEncKey(globalPassword); - // update and encrypt the vault with new password - await this.#createNewVaultWithAuthData({ - password: globalPassword, - rawToprfEncryptionKey: encKey, - rawToprfAuthKeyPair: authKeyPair, - }); - // persist the latest global password authPubKey - this.#persistAuthPubKey({ - authPubKey: authKeyPair.pk, - }); - this.#resetPasswordOutdatedCache(); + const doSyncPassword = async () => { + // verify correct old password + await this.verifyVaultPassword(oldPassword, { + skipLock: true, // skip lock since we already have the lock + }); + // update vault with latest globalPassword + const { encKey, authKeyPair } = + await this.#recoverEncKey(globalPassword); + // update and encrypt the vault with new password + await this.#createNewVaultWithAuthData({ + password: globalPassword, + rawToprfEncryptionKey: encKey, + rawToprfAuthKeyPair: authKeyPair, + }); + // persist the latest global password authPubKey + this.#persistAuthPubKey({ + authPubKey: authKeyPair.pk, + }); + this.#resetPasswordOutdatedCache(); + }; + return await this.#executeWithTokenRefresh( + doSyncPassword, + 'syncLatestGlobalPassword', + ); }); } @@ -636,7 +731,8 @@ export class SeedlessOnboardingController extends BaseController< const { authPubKey: globalAuthPubKey } = await this.toprfClient.fetchAuthPubKey({ nodeAuthTokens, - authConnectionId: groupedAuthConnectionId || authConnectionId, + authConnectionId, + groupedAuthConnectionId, userId, }); @@ -651,9 +747,13 @@ export class SeedlessOnboardingController extends BaseController< return isExpiredPwd; }; - return options?.skipLock - ? await doCheck() - : await this.#withControllerLock(doCheck); + return await this.#executeWithTokenRefresh( + async () => + options?.skipLock + ? await doCheck() + : await this.#withControllerLock(doCheck), + 'checkIsPasswordOutdated', + ); } #setUnlocked(): void { @@ -679,18 +779,21 @@ export class SeedlessOnboardingController extends BaseController< */ async #persistOprfKey(oprfKey: bigint, authPubKey: SEC1EncodedPublicKey) { this.#assertIsAuthenticatedUser(this.state); - const authConnectionId = - this.state.groupedAuthConnectionId || this.state.authConnectionId; + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; try { await this.toprfClient.persistLocalKey({ nodeAuthTokens: this.state.nodeAuthTokens, authConnectionId, - userId: this.state.userId, + groupedAuthConnectionId, + userId, oprfKey, authPubKey, }); } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } log('Error persisting local encryption key', error); throw new Error( SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, @@ -735,14 +838,14 @@ export class SeedlessOnboardingController extends BaseController< return this.#withRecoveryErrorHandler(async () => { this.#assertIsAuthenticatedUser(this.state); - const authConnectionId = - this.state.groupedAuthConnectionId || this.state.authConnectionId; + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; const recoverEncKeyResult = await this.toprfClient.recoverEncKey({ nodeAuthTokens: this.state.nodeAuthTokens, password, authConnectionId, - userId: this.state.userId, + groupedAuthConnectionId, + userId, }); return recoverEncKeyResult; }); @@ -757,8 +860,7 @@ export class SeedlessOnboardingController extends BaseController< */ async #changeEncryptionKey(newPassword: string, oldPassword: string) { this.#assertIsAuthenticatedUser(this.state); - const authConnectionId = - this.state.groupedAuthConnectionId || this.state.authConnectionId; + const { authConnectionId, groupedAuthConnectionId, userId } = this.state; const { encKey, @@ -769,7 +871,8 @@ export class SeedlessOnboardingController extends BaseController< return await this.toprfClient.changeEncKey({ nodeAuthTokens: this.state.nodeAuthTokens, authConnectionId, - userId: this.state.userId, + groupedAuthConnectionId, + userId, oldEncKey: encKey, oldAuthKeyPair: authKeyPair, newKeyShareIndex, @@ -812,6 +915,9 @@ export class SeedlessOnboardingController extends BaseController< }; }); } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } log('Error encrypting and storing seed phrase backup', error); throw new Error( SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSeedPhraseBackup, @@ -838,6 +944,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + revokeToken: string; }> { return this.#withVaultLock(async () => { const { @@ -895,17 +1002,27 @@ export class SeedlessOnboardingController extends BaseController< updatedState.vaultEncryptionSalt = vaultEncryptionSalt; } - const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = - this.#parseVaultData(decryptedVaultData); + const { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + revokeToken, + } = this.#parseVaultData(decryptedVaultData); // update the state with the restored nodeAuthTokens this.update((state) => { state.nodeAuthTokens = nodeAuthTokens; state.vaultEncryptionKey = updatedState.vaultEncryptionKey; state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.revokeToken = revokeToken; }); - return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair }; + return { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + revokeToken, + }; }); } @@ -1019,6 +1136,13 @@ export class SeedlessOnboardingController extends BaseController< rawToprfAuthKeyPair: KeyPair; }): Promise { this.#assertIsAuthenticatedUser(this.state); + + if (!this.state.revokeToken) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + } + this.#setUnlocked(); const { toprfEncryptionKey, toprfAuthKeyPair } = this.#serializeKeyData( @@ -1030,6 +1154,7 @@ export class SeedlessOnboardingController extends BaseController< authTokens: this.state.nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, + revokeToken: this.state.revokeToken, }); await this.#updateVault({ @@ -1145,6 +1270,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + revokeToken: string; } { if (typeof data !== 'string') { throw new Error( @@ -1176,6 +1302,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: parsedVaultData.authTokens, toprfEncryptionKey: rawToprfEncryptionKey, toprfAuthKeyPair: rawToprfAuthKeyPair, + revokeToken: parsedVaultData.revokeToken, }; } @@ -1225,6 +1352,12 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, ); } + + if (!('refreshToken' in value) || typeof value.refreshToken !== 'string') { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + } } #assertIsSRPBackedUpUser( @@ -1261,6 +1394,11 @@ export class SeedlessOnboardingController extends BaseController< return result; } catch (error) { + // throw token expired error for token refresh handler + if (this.#isTokenExpiredError(error)) { + throw error; + } + const recoveryError = RecoveryError.getInstance(error, { numberOfAttempts: updatedRecoveryAttempts, remainingTime: updatedRemainingTime, @@ -1327,11 +1465,145 @@ export class SeedlessOnboardingController extends BaseController< !('toprfEncryptionKey' in value) || // toprfEncryptionKey is not defined typeof value.toprfEncryptionKey !== 'string' || // toprfEncryptionKey is not a string !('toprfAuthKeyPair' in value) || // toprfAuthKeyPair is not defined - typeof value.toprfAuthKeyPair !== 'string' // toprfAuthKeyPair is not a string + typeof value.toprfAuthKeyPair !== 'string' || // toprfAuthKeyPair is not a string + !('revokeToken' in value) || // revokeToken is not defined + typeof value.revokeToken !== 'string' // revokeToken is not a string ) { throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); } } + + /** + * Refresh expired nodeAuthTokens using the stored refresh token. + * + * This method retrieves the refresh token from the vault and uses it to obtain + * new nodeAuthTokens when the current ones have expired. + * + * @returns A promise that resolves to the new nodeAuthTokens. + */ + async refreshNodeAuthTokens(): Promise { + this.#assertIsUnlocked(); + const { refreshToken } = this.state; + if (!refreshToken) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + } + + this.#assertIsAuthenticatedUser(this.state); + + try { + const res = await this.#refreshJWTToken({ + connection: this.state.authConnection, + refreshToken, + }); + const { idTokens } = res; + // re-authenticate with the new id tokens to set new node auth tokens + await this.authenticate({ + idTokens, + authConnection: this.state.authConnection, + authConnectionId: this.state.authConnectionId, + groupedAuthConnectionId: this.state.groupedAuthConnectionId, + userId: this.state.userId, + skipLock: true, + }); + } catch (error) { + log('Error refreshing node auth tokens', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + } + } + + /** + * Revoke the refresh token and get new refresh token and new revoke token. + * This method is to be called after unlock + * + * @param password - The password to re-encrypt new token in the vault. + */ + async revokeRefreshToken(password: string) { + this.#assertIsUnlocked(); + this.#assertIsAuthenticatedUser(this.state); + // get revoke token and backup encryption key from vault (should be unlocked already) + const { revokeToken, toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + + const { newRevokeToken, newRefreshToken } = await this.#revokeRefreshToken({ + connection: this.state.authConnection, + revokeToken, + }); + + this.update((state) => { + // set new revoke token in state temporarily for persisting in vault + state.revokeToken = newRevokeToken; + // set new refresh token to persist in state + state.refreshToken = newRefreshToken; + }); + + await this.#createNewVaultWithAuthData({ + password, + rawToprfEncryptionKey: toprfEncryptionKey, + rawToprfAuthKeyPair: toprfAuthKeyPair, + }); + } + + /** + * Check if the provided error is a token expiration error. + * + * This method checks if the error is a TOPRF error with AuthTokenExpired code. + * + * @param error - The error to check. + * @returns True if the error indicates token expiration, false otherwise. + */ + #isTokenExpiredError(error: unknown): boolean { + if (error instanceof TOPRFError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + return error.code === TOPRFErrorCode.AuthTokenExpired; + } + + return false; + } + + /** + * Executes an operation with automatic token refresh on expiration. + * + * This wrapper method automatically handles token expiration by refreshing tokens + * and retrying the operation. It can be used by any method that might encounter + * token expiration errors. + * + * @param operation - The operation to execute that might require valid tokens. + * @param operationName - A descriptive name for the operation (used in error messages). + * @returns A promise that resolves to the result of the operation. + * @throws The original error if it's not token-related, or refresh error if token refresh fails. + */ + async #executeWithTokenRefresh( + operation: () => Promise, + operationName: string, + ): Promise { + try { + return await operation(); + } catch (error) { + // Check if this is a token expiration error + if (this.#isTokenExpiredError(error)) { + log( + `Token expired during ${operationName}, attempting to refresh tokens`, + error, + ); + try { + // Refresh the tokens + await this.refreshNodeAuthTokens(); + // Retry the operation with fresh tokens + return await operation(); + } catch (refreshError) { + log(`Error refreshing tokens during ${operationName}`, refreshError); + throw refreshError; + } + } else { + // Re-throw non-token-related errors + throw error; + } + } + } } /** diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 2eb5a84c3f5..5f20829e8bc 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -25,6 +25,8 @@ function getErrorMessageFromTOPRFErrorCode( return SeedlessOnboardingControllerErrorMessage.IncorrectPassword; case TOPRFErrorCode.CouldNotFetchPassword: return SeedlessOnboardingControllerErrorMessage.CouldNotRecoverPassword; + case TOPRFErrorCode.AuthTokenExpired: + return SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken; default: return defaultMessage; } diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 5bff5bc7f91..a6b5b684338 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -119,6 +119,18 @@ export type SeedlessOnboardingControllerState = * And it also helps to synchronize the recovery error data across multiple devices. */ recoveryRatelimitCache?: RecoveryErrorData; + + /** + * The refresh token used to refresh expired nodeAuthTokens. + * This is temporarily stored in state during authentication and then persisted in the vault. + */ + refreshToken?: string; + + /** + * The revoke token used to revoke refresh token and get new refresh token and new revoke token. + * This is temporarily stored in state during authentication and then persisted in the vault. + */ + revokeToken?: string; }; // Actions @@ -182,6 +194,16 @@ export type ToprfKeyDeriver = { deriveKey: (seed: Uint8Array, salt: Uint8Array) => Promise; }; +export type RefreshJWTToken = (params: { + connection: AuthConnection; + refreshToken: string; +}) => Promise<{ idTokens: string[] }>; + +export type RevokeRefreshToken = (params: { + connection: AuthConnection; + revokeToken: string; +}) => Promise<{ newRevokeToken: string; newRefreshToken: string }>; + /** * Seedless Onboarding Controller Options. * @@ -204,6 +226,17 @@ export type SeedlessOnboardingControllerOptions = { */ encryptor: VaultEncryptor; + /** + * A function to get a new jwt token using refresh token. + */ + refreshJWTToken: RefreshJWTToken; + + /** + * A function to revoke the refresh token. + * And get new refresh token and revoke token. + */ + revokeRefreshToken: RevokeRefreshToken; + /** * Optional key derivation interface for the TOPRF client. * @@ -252,6 +285,10 @@ export type VaultData = { * The authentication key pair to authenticate the TOPRF. */ toprfAuthKeyPair: string; + /** + * The revoke token to revoke refresh token and get new refresh token and new revoke token. + */ + revokeToken: string; }; export type SecretDataType = Uint8Array | string | number; diff --git a/packages/selected-network-controller/CHANGELOG.md b/packages/selected-network-controller/CHANGELOG.md index 293547632aa..b960c7d4c5b 100644 --- a/packages/selected-network-controller/CHANGELOG.md +++ b/packages/selected-network-controller/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.1.0] + +### Added + +- Add support for Snaps ([#4602](https://github.com/MetaMask/core/pull/4602)) + ### Changed -- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) ## [22.0.0] @@ -356,7 +362,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#1643](https://github.com/MetaMask/core/pull/1643)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@22.1.0...HEAD +[22.1.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@22.0.0...@metamask/selected-network-controller@22.1.0 [22.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@21.0.1...@metamask/selected-network-controller@22.0.0 [21.0.1]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@21.0.0...@metamask/selected-network-controller@21.0.1 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@20.0.2...@metamask/selected-network-controller@21.0.0 diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 4820d05b8ca..886b0dcfa33 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/selected-network-controller", - "version": "22.0.0", + "version": "22.1.0", "description": "Provides an interface to the currently selected networkClientId for a given domain", "keywords": [ "MetaMask", @@ -54,7 +54,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index a70a55ab2bd..21837a55fcb 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -26,12 +26,6 @@ const stateMetadata = { const getDefaultState = () => ({ domains: {} }); -// npm and local are currently the only valid prefixes for snap domains -// TODO: eventually we maybe want to pull this in from snaps-utils to ensure it stays in sync -// For now it seems like overkill to add a dependency for this one constant -// https://github.com/MetaMask/snaps/blob/2beee7803bfe9e540788a3558b546b9f55dc3cb4/packages/snaps-utils/src/types.ts#L120 -const snapsPrefixes = ['npm:', 'local:'] as const; - export type Domain = string; export const METAMASK_DOMAIN = 'metamask' as const; @@ -357,10 +351,6 @@ export class SelectedNetworkController extends BaseController< ); } - if (snapsPrefixes.some((prefix) => domain.startsWith(prefix))) { - return; - } - if (!this.#domainHasPermissions(domain)) { throw new Error( 'NetworkClientId for domain cannot be called with a domain that has not yet been granted permissions', @@ -386,11 +376,8 @@ export class SelectedNetworkController extends BaseController< * @returns The proxy and block tracker proxies. */ getProviderAndBlockTracker(domain: Domain): NetworkProxy { - // If the domain is 'metamask' or a snap, return the NetworkController's globally selected network client proxy - if ( - domain === METAMASK_DOMAIN || - snapsPrefixes.some((prefix) => domain.startsWith(prefix)) - ) { + // If the domain is 'metamask', return the NetworkController's globally selected network client proxy + if (domain === METAMASK_DOMAIN) { const networkClient = this.messagingSystem.call( 'NetworkController:getSelectedNetworkClient', ); diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 97ad38c658e..586f65c256c 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -527,33 +527,46 @@ describe('SelectedNetworkController', () => { }); describe('when the requesting domain is a snap (starts with "npm:" or "local:"', () => { - it('skips setting the networkClientId for the passed in domain', () => { + it('sets the networkClientId for the passed in snap ID', () => { const { controller, mockHasPermissions } = setup({ state: { domains: {} }, useRequestQueuePreference: true, }); mockHasPermissions.mockReturnValue(true); - const snapDomainOne = 'npm:@metamask/bip32-example-snap'; - const snapDomainTwo = 'local:@metamask/bip32-example-snap'; - const nonSnapDomain = 'example.com'; + const domain = 'npm:foo-snap'; const networkClientId = 'network1'; + controller.setNetworkClientIdForDomain(domain, networkClientId); + expect(controller.state.domains[domain]).toBe(networkClientId); + }); + it('updates the provider and block tracker proxy when they already exist for the snap ID', () => { + const { controller, mockProviderProxy, mockHasPermissions } = setup({ + state: { domains: {} }, + useRequestQueuePreference: true, + }); + mockHasPermissions.mockReturnValue(true); + const initialNetworkClientId = '123'; + + // creates the proxy for the new domain controller.setNetworkClientIdForDomain( - nonSnapDomain, - networkClientId, - ); - controller.setNetworkClientIdForDomain( - snapDomainOne, - networkClientId, + 'npm:foo-snap', + initialNetworkClientId, ); + const newNetworkClientId = 'abc'; + + expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(1); + + // calls setTarget on the proxy controller.setNetworkClientIdForDomain( - snapDomainTwo, - networkClientId, + 'npm:foo-snap', + newNetworkClientId, ); - expect(controller.state.domains).toStrictEqual({ - [nonSnapDomain]: networkClientId, - }); + expect(mockProviderProxy.setTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ request: expect.any(Function) }), + ); + expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(2); }); }); @@ -776,23 +789,22 @@ describe('SelectedNetworkController', () => { // TODO - improve these tests by using a full NetworkController and doing more robust behavioral testing describe('when the domain is a snap (starts with "npm:" or "local:")', () => { - it('returns a proxied globally selected networkClient and does not create a new proxy in the domainProxyMap', () => { - const { controller, domainProxyMap, messenger } = setup({ + it('calls to NetworkController:getSelectedNetworkClient and creates a new proxy provider and block tracker with the proxied globally selected network client', () => { + const { controller, messenger } = setup({ state: { domains: {}, }, - useRequestQueuePreference: true, + useRequestQueuePreference: false, }); jest.spyOn(messenger, 'call'); - const snapDomain = 'npm:@metamask/bip32-example-snap'; - - const result = controller.getProviderAndBlockTracker(snapDomain); - expect(domainProxyMap.get(snapDomain)).toBeUndefined(); + const result = controller.getProviderAndBlockTracker('npm:foo-snap'); + expect(result).toBeDefined(); + // unfortunately checking which networkController method is called is the best + // proxy (no pun intended) for checking that the correct instance of the networkClient is used expect(messenger.call).toHaveBeenCalledWith( 'NetworkController:getSelectedNetworkClient', ); - expect(result).toBeDefined(); }); it('throws an error if the globally selected network client is not initialized', () => { diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index 6b9109cbbc3..63531f27e71 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [29.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) ## [28.0.0] @@ -515,7 +523,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#1214](https://github.com/MetaMask/core/pull/1214)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@28.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@29.0.0...HEAD +[29.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@28.0.0...@metamask/signature-controller@29.0.0 [28.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@27.1.0...@metamask/signature-controller@28.0.0 [27.1.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@27.0.0...@metamask/signature-controller@27.1.0 [27.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@26.0.0...@metamask/signature-controller@27.0.0 diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 696a63d7b36..f38b79aae4d 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/signature-controller", - "version": "28.0.0", + "version": "29.0.0", "description": "Processes signing requests in order to sign arbitrary and typed data", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-sig-util": "^8.2.0", "@metamask/utils": "^11.2.0", "jsonschema": "^1.4.1", @@ -56,12 +56,12 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@metamask/logging-controller": "^6.0.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -71,9 +71,9 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.0.0", - "@metamask/keyring-controller": "^21.0.0", + "@metamask/keyring-controller": "^22.0.0", "@metamask/logging-controller": "^6.0.0", "@metamask/network-controller": "^23.0.0" }, diff --git a/packages/token-search-discovery-controller/CHANGELOG.md b/packages/token-search-discovery-controller/CHANGELOG.md index 80597eed0cc..c4eed62b8fb 100644 --- a/packages/token-search-discovery-controller/CHANGELOG.md +++ b/packages/token-search-discovery-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.2.0] + ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Add `swappable` param to token discovery controller and API service ([#5819](https://github.com/MetaMask/core/pull/5819)) ## [3.1.0] @@ -69,7 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This service is responsible for making search related requests to the Portfolio API - Specifically, it handles the `tokens-search` endpoint which returns a list of tokens based on the provided query parameters -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@3.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@3.2.0...HEAD +[3.2.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@3.1.0...@metamask/token-search-discovery-controller@3.2.0 [3.1.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@3.0.0...@metamask/token-search-discovery-controller@3.1.0 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@2.1.0...@metamask/token-search-discovery-controller@3.0.0 [2.1.0]: https://github.com/MetaMask/core/compare/@metamask/token-search-discovery-controller@2.0.0...@metamask/token-search-discovery-controller@2.1.0 diff --git a/packages/token-search-discovery-controller/package.json b/packages/token-search-discovery-controller/package.json index 3ed759579b9..a3f67c40f83 100644 --- a/packages/token-search-discovery-controller/package.json +++ b/packages/token-search-discovery-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/token-search-discovery-controller", - "version": "3.1.0", + "version": "3.2.0", "description": "Manages token search and discovery through the Portfolio API", "keywords": [ "MetaMask", diff --git a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts index 812a1875418..f62bb6c91b5 100644 --- a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts +++ b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.test.ts @@ -89,6 +89,10 @@ describe('TokenDiscoveryApiService', () => { params: { limit: '10' }, expectedPath: '/tokens-search/trending?limit=10', }, + { + params: { swappable: true }, + expectedPath: '/tokens-search/trending?swappable=true', + }, { params: {}, expectedPath: '/tokens-search/trending', @@ -154,6 +158,10 @@ describe('TokenDiscoveryApiService', () => { params: { chains: ['1', '137'] }, expectedPath: '/tokens-search/top-gainers?chains=1,137', }, + { + params: { swappable: true }, + expectedPath: '/tokens-search/top-gainers?swappable=true', + }, ])( 'should construct correct URL for params: $params', async ({ params, expectedPath }) => { @@ -196,6 +204,10 @@ describe('TokenDiscoveryApiService', () => { params: { chains: ['1', '137'] }, expectedPath: '/tokens-search/top-losers?chains=1,137', }, + { + params: { swappable: true }, + expectedPath: '/tokens-search/top-losers?swappable=true', + }, ])( 'should construct correct URL for params: $params', async ({ params, expectedPath }) => { @@ -238,6 +250,10 @@ describe('TokenDiscoveryApiService', () => { params: { chains: ['1', '137'] }, expectedPath: '/tokens-search/blue-chip?chains=1,137', }, + { + params: { swappable: true }, + expectedPath: '/tokens-search/blue-chip?swappable=true', + }, ])( 'should construct correct URL for params: $params', async ({ params, expectedPath }) => { diff --git a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts index 7af4f5faff3..8514917bf77 100644 --- a/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts +++ b/packages/token-search-discovery-controller/src/token-discovery-api-service/token-discovery-api-service.ts @@ -5,6 +5,7 @@ import type { TopLosersParams, TrendingTokensParams, BlueChipParams, + ParamsBase, } from '../types'; export class TokenDiscoveryApiService extends AbstractTokenDiscoveryApiService { @@ -18,19 +19,19 @@ export class TokenDiscoveryApiService extends AbstractTokenDiscoveryApiService { this.#baseUrl = baseUrl; } - async getTrendingTokensByChains( - trendingTokensParams?: TrendingTokensParams, - ): Promise { - const url = new URL('/tokens-search/trending', this.#baseUrl); + async #fetch(subPath: string, params?: ParamsBase) { + const url = new URL(`/tokens-search/${subPath}`, this.#baseUrl); - if ( - trendingTokensParams?.chains && - trendingTokensParams.chains.length > 0 - ) { - url.searchParams.append('chains', trendingTokensParams.chains.join()); + if (params?.chains && params.chains.length > 0) { + url.searchParams.append('chains', params.chains.join()); } - if (trendingTokensParams?.limit) { - url.searchParams.append('limit', trendingTokensParams.limit); + + if (params?.limit) { + url.searchParams.append('limit', params.limit); + } + + if (params?.swappable) { + url.searchParams.append('swappable', 'true'); } const response = await fetch(url, { @@ -49,87 +50,27 @@ export class TokenDiscoveryApiService extends AbstractTokenDiscoveryApiService { return response.json(); } + async getTrendingTokensByChains( + trendingTokensParams?: TrendingTokensParams, + ): Promise { + return this.#fetch('trending', trendingTokensParams); + } + async getTopLosersByChains( topLosersParams?: TopLosersParams, ): Promise { - const url = new URL('/tokens-search/top-losers', this.#baseUrl); - - if (topLosersParams?.chains && topLosersParams.chains.length > 0) { - url.searchParams.append('chains', topLosersParams.chains.join()); - } - if (topLosersParams?.limit) { - url.searchParams.append('limit', topLosersParams.limit); - } - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error( - `Portfolio API request failed with status: ${response.status}`, - ); - } - - return response.json(); + return this.#fetch('top-losers', topLosersParams); } async getTopGainersByChains( topGainersParams?: TopGainersParams, ): Promise { - const url = new URL('/tokens-search/top-gainers', this.#baseUrl); - - if (topGainersParams?.chains && topGainersParams.chains.length > 0) { - url.searchParams.append('chains', topGainersParams.chains.join()); - } - if (topGainersParams?.limit) { - url.searchParams.append('limit', topGainersParams.limit); - } - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error( - `Portfolio API request failed with status: ${response.status}`, - ); - } - - return response.json(); + return this.#fetch('top-gainers', topGainersParams); } async getBlueChipTokensByChains( blueChipParams?: BlueChipParams, ): Promise { - const url = new URL('/tokens-search/blue-chip', this.#baseUrl); - - if (blueChipParams?.chains && blueChipParams.chains.length > 0) { - url.searchParams.append('chains', blueChipParams.chains.join()); - } - if (blueChipParams?.limit) { - url.searchParams.append('limit', blueChipParams.limit); - } - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error( - `Portfolio API request failed with status: ${response.status}`, - ); - } - - return response.json(); + return this.#fetch('blue-chip', blueChipParams); } } diff --git a/packages/token-search-discovery-controller/src/types.ts b/packages/token-search-discovery-controller/src/types.ts index 84f7ea31547..c079131fa3f 100644 --- a/packages/token-search-discovery-controller/src/types.ts +++ b/packages/token-search-discovery-controller/src/types.ts @@ -1,11 +1,12 @@ // Function params -type ParamsBase = { +export type ParamsBase = { chains?: string[]; limit?: string; + swappable?: boolean; }; -export type TokenSearchParams = ParamsBase & { +export type TokenSearchParams = Omit & { query?: string; }; diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index d3851c628d3..f5b302707c2 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional approval request when calling `addTransactionBatch` ([#5793](https://github.com/MetaMask/core/pull/5793)) + - Add `transactionBatches` array to state. + - Add `TransactionBatchMeta` type. + +### Fixed + +- Support leading zeroes in `authorizationList` properties ([#5830](https://github.com/MetaMask/core/pull/5830)) + +## [56.2.0] + +### Added + +- Add sequential batch support when `publishBatchHook` is not defined ([#5762](https://github.com/MetaMask/core/pull/5762)) + +### Fixed + +- Fix `userFeeLevel` as `dappSuggested` initially when dApp suggested gas values for legacy transactions ([#5821](https://github.com/MetaMask/core/pull/5821)) +- Fix `addTransaction` function to correctly identify a transaction as a `simpleSend` type when the recipient is a smart account ([#5822](https://github.com/MetaMask/core/pull/5822)) +- Fix gas fee randomisation with many decimal places ([#5839](https://github.com/MetaMask/core/pull/5839)) + +## [56.1.0] + +### Added + +- Automatically update gas fee properties in `txParams` when `updateTransactionGasFees` method is called with `userFeeLevel` ([#5800](https://github.com/MetaMask/core/pull/5800)) +- Support additional debug of incoming transaction requests ([#5803](https://github.com/MetaMask/core/pull/5803)) + - Add optional `incomingTransactions.client` constructor property. + - Add optional `tags` property to `updateIncomingTransactions` method. + +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Throw correct error code if upgrade rejected ([#5814](https://github.com/MetaMask/core/pull/5814)) + +## [56.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Configure incoming transaction polling interval using feature flag ([#5792](https://github.com/MetaMask/core/pull/5792)) + +## [55.0.2] + +### Fixed + +- Fix type-4 gas estimation ([#5790](https://github.com/MetaMask/core/pull/5790)) + ## [55.0.1] ### Changed @@ -1571,7 +1623,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@55.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@56.2.0...HEAD +[56.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@56.1.0...@metamask/transaction-controller@56.2.0 +[56.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@56.0.0...@metamask/transaction-controller@56.1.0 +[56.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@55.0.2...@metamask/transaction-controller@56.0.0 +[55.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@55.0.1...@metamask/transaction-controller@55.0.2 [55.0.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@55.0.0...@metamask/transaction-controller@55.0.1 [55.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@54.4.0...@metamask/transaction-controller@55.0.0 [54.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@54.3.0...@metamask/transaction-controller@54.4.0 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 2f0881bc5a5..6ee27087719 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "55.0.1", + "version": "56.2.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -55,7 +55,7 @@ "@ethersproject/providers": "^5.7.0", "@ethersproject/wallet": "^5.7.0", "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-query": "^4.0.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/nonce-tracker": "^6.0.0", @@ -70,14 +70,14 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.1.3", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^11.0.3", "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/ethjs-provider-http": "^0.3.0", "@metamask/gas-fee-controller": "^23.0.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/remote-feature-flag-controller": "^1.6.0", "@types/bn.js": "^5.1.5", "@types/jest": "^27.4.1", @@ -94,7 +94,7 @@ }, "peerDependencies": { "@babel/runtime": "^7.0.0", - "@metamask/accounts-controller": "^28.0.0", + "@metamask/accounts-controller": "^29.0.0", "@metamask/approval-controller": "^7.0.0", "@metamask/eth-block-tracker": ">=9", "@metamask/gas-fee-controller": "^23.0.0", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 6706a237a50..2e0357fc286 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -40,7 +40,10 @@ import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow'; import { TestGasFeeFlow } from './gas-flows/TestGasFeeFlow'; -import { GasFeePoller } from './helpers/GasFeePoller'; +import { + updateTransactionGasEstimates, + GasFeePoller, +} from './helpers/GasFeePoller'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; @@ -68,8 +71,10 @@ import type { InternalAccount, PublishHook, GasFeeToken, + GasFeeEstimates, } from './types'; import { + GasFeeEstimateLevel, GasFeeEstimateType, SimulationErrorCode, SimulationTokenStandard, @@ -98,6 +103,7 @@ import { updatePostTransactionBalance, updateSwapsTransaction, } from './utils/swaps'; +import { ErrorCode } from './utils/validation'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import { flushPromises } from '../../../tests/helpers'; @@ -530,6 +536,9 @@ describe('TransactionController', () => { ); const testGasFeeFlowClassMock = jest.mocked(TestGasFeeFlow); const gasFeePollerClassMock = jest.mocked(GasFeePoller); + const updateTransactionGasEstimatesMock = jest.mocked( + updateTransactionGasEstimates, + ); const getSimulationDataMock = jest.mocked(getSimulationData); const getTransactionLayer1GasFeeMock = jest.mocked( getTransactionLayer1GasFee, @@ -993,6 +1002,7 @@ describe('TransactionController', () => { expect(controller.state).toStrictEqual({ methodData: {}, transactions: [], + transactionBatches: [], lastFetchedBlockNumbers: {}, submitHistory: [], }); @@ -2933,9 +2943,9 @@ describe('TransactionController', () => { ); }); - it('publishes TransactionController:transactionRejected if error is method not supported', async () => { + it('publishes TransactionController:transactionRejected if error is rejected upgrade', async () => { const error = { - code: errorCodes.rpc.methodNotSupported, + code: ErrorCode.RejectedUpgrade, }; const { controller, messenger } = setupController({ @@ -2983,6 +2993,36 @@ describe('TransactionController', () => { }), ); }); + + it('throws with correct error code if approval request is rejected due to upgrade', async () => { + const error = { + code: ErrorCode.RejectedUpgrade, + }; + + const { controller } = setupController({ + messengerOptions: { + addTransactionApprovalRequest: { + state: 'rejected', + error, + }, + }, + }); + + const { result } = await controller.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }, + { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }, + ); + + await expect(result).rejects.toHaveProperty( + 'code', + ErrorCode.RejectedUpgrade, + ); + }); }); describe('checks from address origin', () => { @@ -4994,6 +5034,232 @@ describe('TransactionController', () => { maxFeePerGas, ); }); + + describe('when called with userFeeLevel', () => { + it('does not call updateTransactionGasEstimates when gasFeeEstimates is undefined', async () => { + const transactionId = '123'; + const { controller } = setupController({ + options: { + state: { + transactions: [ + { + id: transactionId, + chainId: '0x1', + networkClientId: NETWORK_CLIENT_ID_MOCK, + time: 123456789, + status: TransactionStatus.unapproved as const, + gasFeeEstimates: undefined, + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + controller.updateTransactionGasFees(transactionId, { + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(updateTransactionGasEstimatesMock).not.toHaveBeenCalled(); + }); + + it('calls updateTransactionGasEstimates with correct parameters when gasFeeEstimates exists', async () => { + const transactionId = '123'; + const gasFeeEstimates = { + type: GasFeeEstimateType.FeeMarket, + low: { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x2' }, + medium: { maxFeePerGas: '0x3', maxPriorityFeePerGas: '0x4' }, + high: { maxFeePerGas: '0x5', maxPriorityFeePerGas: '0x6' }, + } as GasFeeEstimates; + + const { controller } = setupController({ + options: { + isAutomaticGasFeeUpdateEnabled: () => true, + state: { + transactions: [ + { + id: transactionId, + chainId: '0x1', + networkClientId: NETWORK_CLIENT_ID_MOCK, + time: 123456789, + status: TransactionStatus.unapproved as const, + gasFeeEstimates, + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + controller.updateTransactionGasFees(transactionId, { + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(updateTransactionGasEstimatesMock).toHaveBeenCalledWith({ + txMeta: expect.objectContaining({ + id: transactionId, + gasFeeEstimates, + }), + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + }); + + it('preserves existing gas values when gasFeeEstimates type is unknown', async () => { + const transactionId = '123'; + const unknownGasFeeEstimates = { + type: 'unknown' as unknown as GasFeeEstimateType, + low: '0x123', + medium: '0x1234', + high: '0x12345', + } as GasFeeEstimates; + + const existingGasPrice = '0x777777'; + + const { controller } = setupController({ + options: { + isAutomaticGasFeeUpdateEnabled: () => true, + state: { + transactions: [ + { + id: transactionId, + chainId: '0x1', + networkClientId: NETWORK_CLIENT_ID_MOCK, + time: 123456789, + status: TransactionStatus.unapproved as const, + gasFeeEstimates: unknownGasFeeEstimates, + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + gasPrice: existingGasPrice, + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + updateTransactionGasEstimatesMock.mockImplementation(({ txMeta }) => { + expect(txMeta.txParams.gasPrice).toBe(existingGasPrice); + }); + + controller.updateTransactionGasFees(transactionId, { + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(updateTransactionGasEstimatesMock).toHaveBeenCalled(); + + const updatedTransaction = controller.state.transactions.find( + ({ id }) => id === transactionId, + ); + + // Gas price should remain unchanged + expect(updatedTransaction?.txParams.gasPrice).toBe(existingGasPrice); + }); + + it('preserves existing EIP-1559 gas values when gasFeeEstimates is undefined', async () => { + const transactionId = '123'; + const existingMaxFeePerGas = '0x999999'; + const existingMaxPriorityFeePerGas = '0x888888'; + + const { controller } = setupController({ + options: { + state: { + transactions: [ + { + id: transactionId, + chainId: '0x1', + networkClientId: NETWORK_CLIENT_ID_MOCK, + time: 123456789, + status: TransactionStatus.unapproved as const, + gasFeeEstimates: undefined, + txParams: { + type: TransactionEnvelopeType.feeMarket, + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + maxFeePerGas: existingMaxFeePerGas, + maxPriorityFeePerGas: existingMaxPriorityFeePerGas, + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + controller.updateTransactionGasFees(transactionId, { + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(updateTransactionGasEstimatesMock).not.toHaveBeenCalled(); + + const updatedTransaction = controller.state.transactions.find( + ({ id }) => id === transactionId, + ); + + // Values should remain unchanged + expect(updatedTransaction?.txParams.maxFeePerGas).toBe( + existingMaxFeePerGas, + ); + expect(updatedTransaction?.txParams.maxPriorityFeePerGas).toBe( + existingMaxPriorityFeePerGas, + ); + }); + + it('does not update transaction gas estimates when userFeeLevel is custom', () => { + const transactionId = '1'; + + const { controller } = setupController({ + options: { + isAutomaticGasFeeUpdateEnabled: () => true, + state: { + transactions: [ + { + id: transactionId, + chainId: '0x1', + networkClientId: NETWORK_CLIENT_ID_MOCK, + time: 123456789, + status: TransactionStatus.unapproved as const, + gasFeeEstimates: { + type: GasFeeEstimateType.Legacy, + low: '0x1', + medium: '0x2', + high: '0x3', + }, + txParams: { + type: TransactionEnvelopeType.legacy, + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + gasPrice: '0x1234', + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + // Update with custom userFeeLevel and new gasPrice + controller.updateTransactionGasFees(transactionId, { + userFeeLevel: 'custom', + gasPrice: '0x5678', + }); + + const updatedTransaction = controller.state.transactions.find( + ({ id }) => id === transactionId, + ); + expect(updatedTransaction?.txParams.gasPrice).toBe('0x5678'); + expect(updatedTransaction?.userFeeLevel).toBe('custom'); + }); + }); }); describe('updatePreviousGasParams', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index d1ecfaf1912..a41b16899e5 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -69,7 +69,11 @@ import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimatio import { ScrollLayer1GasFeeFlow } from './gas-flows/ScrollLayer1GasFeeFlow'; import { TestGasFeeFlow } from './gas-flows/TestGasFeeFlow'; import { AccountsApiRemoteTransactionSource } from './helpers/AccountsApiRemoteTransactionSource'; -import { GasFeePoller, updateTransactionGasFees } from './helpers/GasFeePoller'; +import { + GasFeePoller, + updateTransactionGasProperties, + updateTransactionGasEstimates, +} from './helpers/GasFeePoller'; import type { IncomingTransactionOptions } from './helpers/IncomingTransactionHelper'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; @@ -111,8 +115,11 @@ import type { IsAtomicBatchSupportedResult, IsAtomicBatchSupportedRequest, AfterAddHook, + GasFeeEstimateLevel as GasFeeEstimateLevelType, + TransactionBatchMeta, } from './types'; import { + GasFeeEstimateLevel, TransactionEnvelopeType, TransactionType, TransactionStatus, @@ -182,6 +189,10 @@ const metadata = { persist: true, anonymous: false, }, + transactionBatches: { + persist: true, + anonymous: false, + }, methodData: { persist: true, anonymous: false, @@ -245,13 +256,16 @@ export type TransactionControllerState = { /** A list of TransactionMeta objects. */ transactions: TransactionMeta[]; + /** A list of TransactionBatchMeta objects. */ + transactionBatches: TransactionBatchMeta[]; + /** Object containing all known method data information. */ methodData: Record; /** Cache to optimise incoming transaction queries. */ lastFetchedBlockNumbers: { [key: string]: number | string }; - /** History of all tranasactions submitted from the wallet. */ + /** History of all transactions submitted from the wallet. */ submitHistory: SubmitHistoryEntry[]; }; @@ -666,6 +680,7 @@ function getDefaultTransactionControllerState(): TransactionControllerState { return { methodData: {}, transactions: [], + transactionBatches: [], lastFetchedBlockNumbers: {}, submitHistory: [], }; @@ -945,12 +960,14 @@ export class TransactionController extends BaseController< }; this.#incomingTransactionHelper = new IncomingTransactionHelper({ + client: this.#incomingTransactionOptions.client, getCache: () => this.state.lastFetchedBlockNumbers, getCurrentAccount: () => this.#getSelectedAccount(), getLocalTransactions: () => this.state.transactions, includeTokenTransfers: this.#incomingTransactionOptions.includeTokenTransfers, isEnabled: this.#incomingTransactionOptions.isEnabled, + messenger: this.messagingSystem, queryEntireHistory: this.#incomingTransactionOptions.queryEntireHistory, remoteTransactionSource: new AccountsApiRemoteTransactionSource(), trimTransactions: this.#trimTransactionsForState.bind(this), @@ -1016,6 +1033,11 @@ export class TransactionController extends BaseController< async addTransactionBatch( request: TransactionBatchRequest, ): Promise { + const { blockTracker } = this.messagingSystem.call( + `NetworkController:getNetworkClientById`, + request.networkClientId, + ); + return await addTransactionBatch({ addTransaction: this.addTransaction.bind(this), getChainId: this.#getChainId.bind(this), @@ -1028,6 +1050,18 @@ export class TransactionController extends BaseController< publicKeyEIP7702: this.#publicKeyEIP7702, request, updateTransaction: this.#updateTransactionInternal.bind(this), + publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => this.#publishTransaction(ethQuery, transactionMeta) as Promise, + getPendingTransactionTracker: (networkClientId: NetworkClientId) => + this.#createPendingTransactionTracker({ + provider: this.#getProvider({ networkClientId }), + blockTracker, + chainId: this.#getChainId(networkClientId), + networkClientId, + }), + update: this.update.bind(this), }); } @@ -1314,8 +1348,14 @@ export class TransactionController extends BaseController< this.#incomingTransactionHelper.stop(); } - async updateIncomingTransactions() { - await this.#incomingTransactionHelper.update(); + /** + * Update the incoming transactions by polling the remote transaction source. + * + * @param request - Request object. + * @param request.tags - Additional tags to identify the source of the request. + */ + async updateIncomingTransactions({ tags }: { tags?: string[] } = {}) { + await this.#incomingTransactionHelper.update({ tags }); } /** @@ -1796,7 +1836,7 @@ export class TransactionController extends BaseController< maxFeePerGas, originalGasEstimate, userEditedGasLimit, - userFeeLevel, + userFeeLevel: userFeeLevelParam, }: { defaultGasEstimates?: string; estimateUsed?: string; @@ -1824,34 +1864,71 @@ export class TransactionController extends BaseController< 'updateTransactionGasFees', ); - let transactionGasFees = { - txParams: { - gas, - gasLimit, + const clonedTransactionMeta = cloneDeep(transactionMeta); + const isTransactionGasFeeEstimatesExists = transactionMeta.gasFeeEstimates; + const isAutomaticGasFeeUpdateEnabled = + this.#isAutomaticGasFeeUpdateEnabled(transactionMeta); + const userFeeLevel = userFeeLevelParam as GasFeeEstimateLevelType; + const isOneOfFeeLevelSelected = + Object.values(GasFeeEstimateLevel).includes(userFeeLevel); + const shouldUpdateTxParamsGasFees = + isTransactionGasFeeEstimatesExists && + isAutomaticGasFeeUpdateEnabled && + isOneOfFeeLevelSelected; + + if (shouldUpdateTxParamsGasFees) { + updateTransactionGasEstimates({ + txMeta: clonedTransactionMeta, + userFeeLevel, + }); + } + + const txParamsUpdate = { + gas, + gasLimit, + }; + + if (shouldUpdateTxParamsGasFees) { + // Get updated values from clonedTransactionMeta if we're using automated fee updates + Object.assign(txParamsUpdate, { + gasPrice: clonedTransactionMeta.txParams.gasPrice, + maxPriorityFeePerGas: + clonedTransactionMeta.txParams.maxPriorityFeePerGas, + maxFeePerGas: clonedTransactionMeta.txParams.maxFeePerGas, + }); + } else { + Object.assign(txParamsUpdate, { gasPrice, maxPriorityFeePerGas, maxFeePerGas, - }, + }); + } + + const transactionGasFees = { + txParams: pickBy(txParamsUpdate), defaultGasEstimates, estimateUsed, estimateSuggested, originalGasEstimate, userEditedGasLimit, userFeeLevel, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - // only update what is defined - transactionGasFees.txParams = pickBy(transactionGasFees.txParams); - transactionGasFees = pickBy(transactionGasFees); + }; - // merge updated gas values with existing transaction meta - const updatedMeta = merge({}, transactionMeta, transactionGasFees); + const filteredTransactionGasFees = pickBy(transactionGasFees); - this.updateTransaction( - updatedMeta, - `${controllerName}:updateTransactionGasFees - gas values updated`, + this.#updateTransactionInternal( + { + transactionId, + note: `${controllerName}:updateTransactionGasFees - gas values updated`, + skipResimulateCheck: true, + }, + (draftTxMeta) => { + const { txParams, ...otherProps } = filteredTransactionGasFees; + Object.assign(draftTxMeta, otherProps); + if (txParams) { + Object.assign(draftTxMeta.txParams, txParams); + } + }, ); return this.#getTransaction(transactionId) as TransactionMeta; @@ -4057,7 +4134,7 @@ export class TransactionController extends BaseController< this.#updateTransactionInternal( { transactionId, skipHistory: true }, (txMeta) => { - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates, gasFeeEstimatesLoaded, @@ -4157,7 +4234,7 @@ export class TransactionController extends BaseController< #isRejectError(error: Error & { code?: number }) { return [ errorCodes.provider.userRejectedRequest, - errorCodes.rpc.methodNotSupported, + ErrorCode.RejectedUpgrade, ].includes(error.code as number); } diff --git a/packages/transaction-controller/src/api/accounts-api.test.ts b/packages/transaction-controller/src/api/accounts-api.test.ts index c508afedd36..7174de355d1 100644 --- a/packages/transaction-controller/src/api/accounts-api.test.ts +++ b/packages/transaction-controller/src/api/accounts-api.test.ts @@ -25,6 +25,8 @@ const CHAIN_ID_SUPPORTED = 1; const CHAIN_ID_UNSUPPORTED = 999; const FROM_ADDRESS = '0xSender'; const TO_ADDRESS = '0xRecipient'; +const TAG_MOCK = 'test1'; +const TAG_2_MOCK = 'test2'; const ACCOUNT_RESPONSE_MOCK = { data: [{}], @@ -143,5 +145,27 @@ describe('Accounts API', () => { expect.any(Object), ); }); + + it('includes the client header', async () => { + mockFetch(ACCOUNT_RESPONSE_MOCK); + + await getAccountTransactions({ + address: ADDRESS_MOCK, + chainIds: CHAIN_IDS_MOCK, + cursor: CURSOR_MOCK, + endTimestamp: END_TIMESTAMP_MOCK, + startTimestamp: START_TIMESTAMP_MOCK, + tags: [TAG_MOCK, TAG_2_MOCK], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { + 'x-metamask-clientproduct': `metamask-transaction-controller__${TAG_MOCK}__${TAG_2_MOCK}`, + }, + }), + ); + }); }); }); diff --git a/packages/transaction-controller/src/api/accounts-api.ts b/packages/transaction-controller/src/api/accounts-api.ts index 39231916c55..85ad7e93bb3 100644 --- a/packages/transaction-controller/src/api/accounts-api.ts +++ b/packages/transaction-controller/src/api/accounts-api.ts @@ -81,6 +81,7 @@ export type GetAccountTransactionsRequest = { endTimestamp?: number; sortDirection?: 'ASC' | 'DESC'; startTimestamp?: number; + tags?: string[]; }; export type GetAccountTransactionsResponse = { @@ -170,6 +171,7 @@ export async function getAccountTransactions( endTimestamp, sortDirection, startTimestamp, + tags, } = request; let url = `${BASE_URL_ACCOUNTS}${address}/transactions`; @@ -202,8 +204,10 @@ export async function getAccountTransactions( log('Getting account transactions', { request, url }); + const clientId = [CLIENT_ID, ...(tags || [])].join('__'); + const headers = { - [CLIENT_HEADER]: CLIENT_ID, + [CLIENT_HEADER]: clientId, }; const response = await successfulFetch(url, { headers }); diff --git a/packages/transaction-controller/src/api/simulation-api.ts b/packages/transaction-controller/src/api/simulation-api.ts index 2f8ca98d048..334936db562 100644 --- a/packages/transaction-controller/src/api/simulation-api.ts +++ b/packages/transaction-controller/src/api/simulation-api.ts @@ -188,7 +188,10 @@ export type SimulationResponseTransaction = { tokenFees: SimulationResponseTokenFee[]; }[]; - /** The total gas used by the transaction. */ + /** Required `gasLimit` for the transaction. */ + gasLimit?: Hex; + + /** Total gas used by the transaction. */ gasUsed?: Hex; /** Return value of the transaction, such as the balance if calling balanceOf. */ diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts index 2f6faf29f81..88a22e56460 100644 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts @@ -114,7 +114,7 @@ export class AccountsApiRemoteTransactionSource cursor?: string, timestamp?: number, ): Promise { - const { address, queryEntireHistory } = request; + const { address, queryEntireHistory, tags } = request; const transactions: TransactionResponse[] = []; let hasNextPage = true; @@ -135,6 +135,7 @@ export class AccountsApiRemoteTransactionSource cursor: currentCursor, sortDirection: 'ASC', startTimestamp, + tags, }); pageCount += 1; diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts index 62cf67ce67b..c71fe8bdd73 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -1,7 +1,11 @@ import type { Provider } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; -import { GasFeePoller, updateTransactionGasFees } from './GasFeePoller'; +import { + GasFeePoller, + updateTransactionGasProperties, + updateTransactionGasEstimates, +} from './GasFeePoller'; import { flushPromises } from '../../../../tests/helpers'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { GasFeeFlowResponse, Layer1GasFeeFlow } from '../types'; @@ -40,21 +44,38 @@ const TRANSACTION_META_MOCK: TransactionMeta = { }, }; -const GAS_FEE_FLOW_RESPONSE_MOCK: GasFeeFlowResponse = { - estimates: { - type: GasFeeEstimateType.FeeMarket, - low: { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x2' }, - medium: { - maxFeePerGas: '0x3', - maxPriorityFeePerGas: '0x4', - }, - high: { - maxFeePerGas: '0x5', - maxPriorityFeePerGas: '0x6', - }, +const FEE_MARKET_GAS_FEE_ESTIMATES_MOCK = { + type: GasFeeEstimateType.FeeMarket, + [GasFeeEstimateLevel.Low]: { + maxFeePerGas: '0x123', + maxPriorityFeePerGas: '0x123', + }, + [GasFeeEstimateLevel.Medium]: { + maxFeePerGas: '0x1234', + maxPriorityFeePerGas: '0x1234', + }, + [GasFeeEstimateLevel.High]: { + maxFeePerGas: '0x12345', + maxPriorityFeePerGas: '0x12345', }, }; +const LEGACY_GAS_FEE_ESTIMATES_MOCK = { + type: GasFeeEstimateType.Legacy, + [GasFeeEstimateLevel.Low]: '0x123', + [GasFeeEstimateLevel.Medium]: '0x1234', + [GasFeeEstimateLevel.High]: '0x12345', +}; + +const GAS_PRICE_GAS_FEE_ESTIMATES_MOCK = { + type: GasFeeEstimateType.GasPrice, + gasPrice: '0x12345', +}; + +const GAS_FEE_FLOW_RESPONSE_MOCK = { + estimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK, +} as unknown as GasFeeFlowResponse; + /** * Creates a mock GasFeeFlow. * @@ -336,39 +357,65 @@ describe('GasFeePoller', () => { }); }); -describe('updateTransactionGasFees', () => { - const FEE_MARKET_GAS_FEE_ESTIMATES_MOCK = { - type: GasFeeEstimateType.FeeMarket, - [GasFeeEstimateLevel.Low]: { - maxFeePerGas: '0x123', - maxPriorityFeePerGas: '0x123', - }, - [GasFeeEstimateLevel.Medium]: { - maxFeePerGas: '0x1234', - maxPriorityFeePerGas: '0x1234', - }, - [GasFeeEstimateLevel.High]: { - maxFeePerGas: '0x12345', - maxPriorityFeePerGas: '0x12345', - }, - }; - const LEGACY_GAS_FEE_ESTIMATES_MOCK = { - type: GasFeeEstimateType.Legacy, - [GasFeeEstimateLevel.Low]: '0x123', - [GasFeeEstimateLevel.Medium]: '0x1234', - [GasFeeEstimateLevel.High]: '0x12345', - }; - const GAS_PRICE_GAS_FEE_ESTIMATES_MOCK = { - type: GasFeeEstimateType.GasPrice, - gasPrice: '0x12345', - }; +const sharedEIP1559GasTests = [ + { + name: 'with fee market gas fee estimates', + estimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Low, + expectedMaxFeePerGas: + FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low].maxFeePerGas, + expectedMaxPriorityFeePerGas: + FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low] + .maxPriorityFeePerGas, + }, + { + name: 'with gas price gas fee estimates', + estimates: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Low, + expectedMaxFeePerGas: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, + expectedMaxPriorityFeePerGas: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, + }, + { + name: 'with legacy gas fee estimates', + estimates: LEGACY_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Low, + expectedMaxFeePerGas: + LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], + expectedMaxPriorityFeePerGas: + LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], + }, +]; + +const sharedLegacyGasTests = [ + { + name: 'with fee market gas fee estimates', + estimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Medium, + expectedGasPrice: + FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Medium] + .maxFeePerGas, + }, + { + name: 'with gas price gas fee estimates', + estimates: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Low, + expectedGasPrice: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, + }, + { + name: 'with legacy gas fee estimates', + estimates: LEGACY_GAS_FEE_ESTIMATES_MOCK, + userFeeLevel: GasFeeEstimateLevel.Low, + expectedGasPrice: LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], + }, +]; +describe('updateTransactionGasProperties', () => { it('updates gas fee estimates', () => { const txMeta = { ...TRANSACTION_META_MOCK, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -382,7 +429,7 @@ describe('updateTransactionGasFees', () => { ...TRANSACTION_META_MOCK, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimatesLoaded: true, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -390,7 +437,7 @@ describe('updateTransactionGasFees', () => { expect(txMeta.gasFeeEstimatesLoaded).toBe(true); - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimatesLoaded: false, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -405,7 +452,7 @@ describe('updateTransactionGasFees', () => { ...TRANSACTION_META_MOCK, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, layer1GasFee: layer1GasFeeMock, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -429,7 +476,7 @@ describe('updateTransactionGasFees', () => { userFeeLevel, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => false, @@ -464,7 +511,7 @@ describe('updateTransactionGasFees', () => { }, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -496,7 +543,7 @@ describe('updateTransactionGasFees', () => { userFeeLevel, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -517,7 +564,7 @@ describe('updateTransactionGasFees', () => { userFeeLevel: GasFeeEstimateLevel.Low, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: mockCallback, @@ -526,141 +573,52 @@ describe('updateTransactionGasFees', () => { expect(mockCallback).toHaveBeenCalledWith(txMeta); }); - describe('EIP-1559 compatible chains', () => { - it('with fee market gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - userFeeLevel: GasFeeEstimateLevel.Low, - }; - - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, - }); - - expect(txMeta.txParams.maxFeePerGas).toBe( - FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low] - .maxFeePerGas, - ); - expect(txMeta.txParams.maxPriorityFeePerGas).toBe( - FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low] - .maxPriorityFeePerGas, - ); - }); - - it('with gas price gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - userFeeLevel: GasFeeEstimateLevel.Low, - }; - - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, - }); - - expect(txMeta.txParams.maxFeePerGas).toBe( - GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, - ); - - expect(txMeta.txParams.maxPriorityFeePerGas).toBe( - GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, - ); - }); + describe('EIP-1559 compatible transaction', () => { + sharedEIP1559GasTests.forEach((testCase) => { + it(`${testCase.name}`, () => { + const txMeta = { + ...TRANSACTION_META_MOCK, + userFeeLevel: testCase.userFeeLevel, + }; - it('with legacy gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - userFeeLevel: GasFeeEstimateLevel.Low, - }; + updateTransactionGasProperties({ + txMeta, + gasFeeEstimates: testCase.estimates as GasFeeEstimates, + isTxParamsGasFeeUpdatesEnabled: () => true, + }); - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: LEGACY_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, + expect(txMeta.txParams.maxFeePerGas).toBe( + testCase.expectedMaxFeePerGas, + ); + expect(txMeta.txParams.maxPriorityFeePerGas).toBe( + testCase.expectedMaxPriorityFeePerGas, + ); }); - - expect(txMeta.txParams.maxFeePerGas).toBe( - LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], - ); - expect(txMeta.txParams.maxPriorityFeePerGas).toBe( - LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], - ); - expect(txMeta.txParams.gasPrice).toBeUndefined(); }); }); - describe('on non-EIP-1559 compatible chains', () => { - it('with fee market gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - txParams: { - ...TRANSACTION_META_MOCK.txParams, - type: TransactionEnvelopeType.legacy, - }, - userFeeLevel: GasFeeEstimateLevel.Medium, - }; - - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, - }); - - expect(txMeta.txParams.gasPrice).toBe( - FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Medium] - .maxFeePerGas, - ); - expect(txMeta.txParams.maxFeePerGas).toBeUndefined(); - expect(txMeta.txParams.maxPriorityFeePerGas).toBeUndefined(); - }); - - it('with gas price gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - txParams: { - ...TRANSACTION_META_MOCK.txParams, - type: TransactionEnvelopeType.legacy, - }, - userFeeLevel: GasFeeEstimateLevel.Low, - }; - - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: GAS_PRICE_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, - }); - - expect(txMeta.txParams.gasPrice).toBe( - GAS_PRICE_GAS_FEE_ESTIMATES_MOCK.gasPrice, - ); - expect(txMeta.txParams.maxFeePerGas).toBeUndefined(); - expect(txMeta.txParams.maxPriorityFeePerGas).toBeUndefined(); - }); + describe('on non-EIP-1559 compatible transaction', () => { + sharedLegacyGasTests.forEach((testCase) => { + it(`${testCase.name}`, () => { + const txMeta = { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + type: TransactionEnvelopeType.legacy, + }, + userFeeLevel: testCase.userFeeLevel, + }; - it('with legacy gas fee estimates', () => { - const txMeta = { - ...TRANSACTION_META_MOCK, - txParams: { - ...TRANSACTION_META_MOCK.txParams, - type: TransactionEnvelopeType.legacy, - }, - userFeeLevel: GasFeeEstimateLevel.Low, - }; + updateTransactionGasProperties({ + txMeta, + gasFeeEstimates: testCase.estimates as GasFeeEstimates, + isTxParamsGasFeeUpdatesEnabled: () => true, + }); - updateTransactionGasFees({ - txMeta, - gasFeeEstimates: LEGACY_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, - isTxParamsGasFeeUpdatesEnabled: () => true, + expect(txMeta.txParams.gasPrice).toBe(testCase.expectedGasPrice); + expect(txMeta.txParams.maxFeePerGas).toBeUndefined(); + expect(txMeta.txParams.maxPriorityFeePerGas).toBeUndefined(); }); - - expect(txMeta.txParams.gasPrice).toBe( - LEGACY_GAS_FEE_ESTIMATES_MOCK[GasFeeEstimateLevel.Low], - ); - expect(txMeta.txParams.maxFeePerGas).toBeUndefined(); - expect(txMeta.txParams.maxPriorityFeePerGas).toBeUndefined(); }); }); }); @@ -676,7 +634,7 @@ describe('updateTransactionGasFees', () => { }, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -705,7 +663,7 @@ describe('updateTransactionGasFees', () => { }, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: LEGACY_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -731,7 +689,7 @@ describe('updateTransactionGasFees', () => { }, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: undefined, isTxParamsGasFeeUpdatesEnabled: () => true, @@ -746,7 +704,7 @@ describe('updateTransactionGasFees', () => { ...TRANSACTION_META_MOCK, }; - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates: undefined, gasFeeEstimatesLoaded: true, @@ -758,3 +716,130 @@ describe('updateTransactionGasFees', () => { }); }); }); + +describe('updateTransactionGasEstimates', () => { + describe('EIP-1559 compatible transaction', () => { + sharedEIP1559GasTests.forEach((testCase) => { + it(`${testCase.name}`, () => { + const txMeta = { + ...TRANSACTION_META_MOCK, + gasFeeEstimates: testCase.estimates as GasFeeEstimates, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + type: TransactionEnvelopeType.feeMarket, + }, + }; + + updateTransactionGasEstimates({ + txMeta, + userFeeLevel: testCase.userFeeLevel, + }); + + expect(txMeta.txParams.maxFeePerGas).toBe( + testCase.expectedMaxFeePerGas, + ); + expect(txMeta.txParams.maxPriorityFeePerGas).toBe( + testCase.expectedMaxPriorityFeePerGas, + ); + }); + }); + }); + + describe('non-EIP-1559 compatible transaction', () => { + sharedLegacyGasTests.forEach((testCase) => { + it(`${testCase.name}`, () => { + const txMeta = { + ...TRANSACTION_META_MOCK, + gasFeeEstimates: testCase.estimates as GasFeeEstimates, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + type: TransactionEnvelopeType.legacy, + }, + }; + + updateTransactionGasEstimates({ + txMeta, + userFeeLevel: testCase.userFeeLevel, + }); + + expect(txMeta.txParams.gasPrice).toBe(testCase.expectedGasPrice); + }); + }); + }); + + describe('handles missing gas fee estimates', () => { + it('when gas fee estimates are undefined', () => { + const txMeta = { + ...TRANSACTION_META_MOCK, + gasFeeEstimates: undefined, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + type: TransactionEnvelopeType.feeMarket, + maxFeePerGas: '0x999999', + maxPriorityFeePerGas: '0x888888', + }, + }; + + updateTransactionGasEstimates({ + txMeta, + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(txMeta.txParams.maxFeePerGas).toBe('0x999999'); + expect(txMeta.txParams.maxPriorityFeePerGas).toBe('0x888888'); + }); + + it('when gas fee estimates type is unknown', () => { + const unknownGasFeeEstimates = { + ...LEGACY_GAS_FEE_ESTIMATES_MOCK, + type: 'unknown' as unknown as GasFeeEstimateType, + }; + + const txMeta = { + ...TRANSACTION_META_MOCK, + gasFeeEstimates: unknownGasFeeEstimates as GasFeeEstimates, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + gasPrice: '0x777777', + type: TransactionEnvelopeType.legacy, + }, + }; + + updateTransactionGasEstimates({ + txMeta, + userFeeLevel: GasFeeEstimateLevel.Medium, + }); + + expect(txMeta.txParams.gasPrice).toBe('0x777777'); + }); + }); + + describe('handles different fee levels', () => { + it.each([ + GasFeeEstimateLevel.Low, + GasFeeEstimateLevel.Medium, + GasFeeEstimateLevel.High, + ])('applies correct fee level %s', (feeLevel) => { + const txMeta = { + ...TRANSACTION_META_MOCK, + gasFeeEstimates: FEE_MARKET_GAS_FEE_ESTIMATES_MOCK as GasFeeEstimates, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + type: TransactionEnvelopeType.feeMarket, + }, + }; + + updateTransactionGasEstimates({ + txMeta, + userFeeLevel: feeLevel, + }); + + expect(txMeta.txParams.maxFeePerGas).toBe( + FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[feeLevel].maxFeePerGas, + ); + expect(txMeta.txParams.maxPriorityFeePerGas).toBe( + FEE_MARKET_GAS_FEE_ESTIMATES_MOCK[feeLevel].maxPriorityFeePerGas, + ); + }); + }); +}); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.ts b/packages/transaction-controller/src/helpers/GasFeePoller.ts index a60df403fac..cc7a4ae75ad 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -21,6 +21,7 @@ import type { Layer1GasFeeFlow, LegacyGasFeeEstimates, TransactionMeta, + TransactionParams, } from '../types'; import { GasFeeEstimateLevel, @@ -320,7 +321,7 @@ export class GasFeePoller { } /** - * Update the gas fees for a transaction. + * Updates gas properties for transaction. * * @param args - Argument bag. * @param args.txMeta - The transaction meta. @@ -329,94 +330,148 @@ export class GasFeePoller { * @param args.isTxParamsGasFeeUpdatesEnabled - Whether to update the gas fee properties in `txParams`. * @param args.layer1GasFee - The layer 1 gas fee. */ -export function updateTransactionGasFees({ - txMeta, +export function updateTransactionGasProperties({ gasFeeEstimates, gasFeeEstimatesLoaded, isTxParamsGasFeeUpdatesEnabled, layer1GasFee, + txMeta, }: { - txMeta: TransactionMeta; gasFeeEstimates?: GasFeeEstimates; gasFeeEstimatesLoaded?: boolean; isTxParamsGasFeeUpdatesEnabled: (transactionMeta: TransactionMeta) => boolean; layer1GasFee?: Hex; + txMeta: TransactionMeta; }): void { const userFeeLevel = txMeta.userFeeLevel as GasFeeEstimateLevel; const isUsingGasFeeEstimateLevel = Object.values(GasFeeEstimateLevel).includes(userFeeLevel); - const { type: gasEstimateType } = gasFeeEstimates ?? {}; const shouldUpdateTxParamsGasFees = isTxParamsGasFeeUpdatesEnabled(txMeta); - if (shouldUpdateTxParamsGasFees && isUsingGasFeeEstimateLevel) { + if ( + shouldUpdateTxParamsGasFees && + isUsingGasFeeEstimateLevel && + gasFeeEstimates + ) { const isEIP1559Compatible = txMeta.txParams.type !== TransactionEnvelopeType.legacy; - if (isEIP1559Compatible) { - // Handle EIP-1559 compatible transactions - if (gasEstimateType === GasFeeEstimateType.FeeMarket) { - const feeMarketGasFeeEstimates = - gasFeeEstimates as FeeMarketGasFeeEstimates; + updateGasFeeParameters( + txMeta.txParams, + gasFeeEstimates, + userFeeLevel, + isEIP1559Compatible, + ); + } - txMeta.txParams.maxFeePerGas = - feeMarketGasFeeEstimates[userFeeLevel].maxFeePerGas; - txMeta.txParams.maxPriorityFeePerGas = - feeMarketGasFeeEstimates[userFeeLevel].maxPriorityFeePerGas; - } + if (gasFeeEstimates) { + txMeta.gasFeeEstimates = gasFeeEstimates; + } - if (gasEstimateType === GasFeeEstimateType.GasPrice) { - const gasPriceGasFeeEstimates = - gasFeeEstimates as GasPriceGasFeeEstimates; + if (gasFeeEstimatesLoaded !== undefined) { + txMeta.gasFeeEstimatesLoaded = gasFeeEstimatesLoaded; + } - txMeta.txParams.maxFeePerGas = gasPriceGasFeeEstimates.gasPrice; - txMeta.txParams.maxPriorityFeePerGas = gasPriceGasFeeEstimates.gasPrice; - } + if (layer1GasFee) { + txMeta.layer1GasFee = layer1GasFee; + } +} + +/** + * Updates `txParams` gas values accordingly with given `userFeeLevel` from `txMeta.gasFeeEstimates`. + * + * @param args - Argument bag. + * @param args.txMeta - The transaction meta. + * @param args.userFeeLevel - The user fee level. + */ +export function updateTransactionGasEstimates({ + txMeta, + userFeeLevel, +}: { + txMeta: TransactionMeta; + userFeeLevel: GasFeeEstimateLevel; +}): void { + const { txParams, gasFeeEstimates } = txMeta; - if (gasEstimateType === GasFeeEstimateType.Legacy) { - const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; - const gasPrice = legacyGasFeeEstimates[userFeeLevel]; + if (!gasFeeEstimates) { + return; + } - txMeta.txParams.maxFeePerGas = gasPrice; - txMeta.txParams.maxPriorityFeePerGas = gasPrice; - } + const isEIP1559Compatible = + txMeta.txParams.type !== TransactionEnvelopeType.legacy; - // Remove gasPrice for EIP-1559 transactions - delete txMeta.txParams.gasPrice; - } else { - // Handle non-EIP-1559 transactions - if (gasEstimateType === GasFeeEstimateType.FeeMarket) { - const feeMarketGasFeeEstimates = - gasFeeEstimates as FeeMarketGasFeeEstimates; - txMeta.txParams.gasPrice = - feeMarketGasFeeEstimates[userFeeLevel].maxFeePerGas; - } + updateGasFeeParameters( + txParams, + gasFeeEstimates, + userFeeLevel, + isEIP1559Compatible, + ); +} - if (gasEstimateType === GasFeeEstimateType.GasPrice) { - const gasPriceGasFeeEstimates = - gasFeeEstimates as GasPriceGasFeeEstimates; - txMeta.txParams.gasPrice = gasPriceGasFeeEstimates.gasPrice; - } +/** + * Updates gas fee parameters based on transaction type and gas estimate type + * + * @param txParams - The transaction parameters to update + * @param gasFeeEstimates - The gas fee estimates + * @param userFeeLevel - The user fee level + * @param isEIP1559Compatible - Whether the transaction is EIP-1559 compatible + */ +function updateGasFeeParameters( + txParams: TransactionParams, + gasFeeEstimates: GasFeeEstimates, + userFeeLevel: GasFeeEstimateLevel, + isEIP1559Compatible: boolean, +): void { + const { type: gasEstimateType } = gasFeeEstimates; + + if (isEIP1559Compatible) { + // Handle EIP-1559 compatible transactions + if (gasEstimateType === GasFeeEstimateType.FeeMarket) { + const feeMarketGasFeeEstimates = + gasFeeEstimates as FeeMarketGasFeeEstimates; + txParams.maxFeePerGas = + feeMarketGasFeeEstimates[userFeeLevel]?.maxFeePerGas; + txParams.maxPriorityFeePerGas = + feeMarketGasFeeEstimates[userFeeLevel]?.maxPriorityFeePerGas; + } - if (gasEstimateType === GasFeeEstimateType.Legacy) { - const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; - txMeta.txParams.gasPrice = legacyGasFeeEstimates[userFeeLevel]; - } + if (gasEstimateType === GasFeeEstimateType.GasPrice) { + const gasPriceGasFeeEstimates = + gasFeeEstimates as GasPriceGasFeeEstimates; + txParams.maxFeePerGas = gasPriceGasFeeEstimates.gasPrice; + txParams.maxPriorityFeePerGas = gasPriceGasFeeEstimates.gasPrice; + } - // Remove EIP-1559 specific parameters for legacy transactions - delete txMeta.txParams.maxFeePerGas; - delete txMeta.txParams.maxPriorityFeePerGas; + if (gasEstimateType === GasFeeEstimateType.Legacy) { + const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; + const gasPrice = legacyGasFeeEstimates[userFeeLevel]; + txParams.maxFeePerGas = gasPrice; + txParams.maxPriorityFeePerGas = gasPrice; } - } - if (gasFeeEstimates) { - txMeta.gasFeeEstimates = gasFeeEstimates; - } + // Remove gasPrice for EIP-1559 transactions + delete txParams.gasPrice; + } else { + // Handle non-EIP-1559 transactions + if (gasEstimateType === GasFeeEstimateType.FeeMarket) { + const feeMarketGasFeeEstimates = + gasFeeEstimates as FeeMarketGasFeeEstimates; + txParams.gasPrice = feeMarketGasFeeEstimates[userFeeLevel]?.maxFeePerGas; + } - if (gasFeeEstimatesLoaded !== undefined) { - txMeta.gasFeeEstimatesLoaded = gasFeeEstimatesLoaded; - } + if (gasEstimateType === GasFeeEstimateType.GasPrice) { + const gasPriceGasFeeEstimates = + gasFeeEstimates as GasPriceGasFeeEstimates; + txParams.gasPrice = gasPriceGasFeeEstimates.gasPrice; + } - if (layer1GasFee) { - txMeta.layer1GasFee = layer1GasFee; + if (gasEstimateType === GasFeeEstimateType.Legacy) { + const legacyGasFeeEstimates = gasFeeEstimates as LegacyGasFeeEstimates; + txParams.gasPrice = legacyGasFeeEstimates[userFeeLevel]; + } + + // Remove EIP-1559 specific parameters for legacy transactions + delete txParams.maxFeePerGas; + delete txParams.maxPriorityFeePerGas; } } diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 701d48e5cc7..a408f1abc4a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -1,6 +1,7 @@ import type { Hex } from '@metamask/utils'; import { IncomingTransactionHelper } from './IncomingTransactionHelper'; +import type { TransactionControllerMessenger } from '..'; import { flushPromises } from '../../../../tests/helpers'; import { TransactionStatus, @@ -8,9 +9,12 @@ import { type RemoteTransactionSource, type TransactionMeta, } from '../types'; +import { getIncomingTransactionsPollingInterval } from '../utils/feature-flags'; jest.useFakeTimers(); +jest.mock('../utils/feature-flags'); + // eslint-disable-next-line jest/prefer-spy-on console.error = jest.fn(); @@ -18,6 +22,10 @@ const CHAIN_ID_MOCK = '0x1' as const; const ADDRESS_MOCK = '0x1'; const SYSTEM_TIME_MOCK = 1000 * 60 * 60 * 24 * 2; const CACHE_MOCK = {}; +const MESSENGER_MOCK = {} as unknown as TransactionControllerMessenger; +const TAG_MOCK = 'test1'; +const TAG_2_MOCK = 'test2'; +const CLIENT_MOCK = 'test-client'; const CONTROLLER_ARGS_MOCK: ConstructorParameters< typeof IncomingTransactionHelper @@ -40,6 +48,7 @@ const CONTROLLER_ARGS_MOCK: ConstructorParameters< }, getCache: () => CACHE_MOCK, getLocalTransactions: () => [], + messenger: MESSENGER_MOCK, remoteTransactionSource: {} as RemoteTransactionSource, trimTransactions: (transactions) => transactions, updateCache: jest.fn(), @@ -122,6 +131,10 @@ describe('IncomingTransactionHelper', () => { jest.resetAllMocks(); jest.clearAllTimers(); jest.setSystemTime(SYSTEM_TIME_MOCK); + + jest + .mocked(getIncomingTransactionsPollingInterval) + .mockReturnValue(1000 * 30); }); describe('on interval', () => { @@ -156,6 +169,7 @@ describe('IncomingTransactionHelper', () => { cache: CACHE_MOCK, includeTokenTransfers: true, queryEntireHistory: true, + tags: ['automatic-polling'], updateCache: expect.any(Function), updateTransactions: false, }); @@ -451,5 +465,23 @@ describe('IncomingTransactionHelper', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('includes correct tags in remote transaction source request', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + client: CLIENT_MOCK, + remoteTransactionSource, + }); + + await helper.update({ isInterval: false, tags: [TAG_MOCK, TAG_2_MOCK] }); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith( + expect.objectContaining({ + tags: [CLIENT_MOCK, TAG_MOCK, TAG_2_MOCK], + }), + ); + }); }); }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 94b065e3b1c..703b58a8cde 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -4,21 +4,26 @@ import type { Hex } from '@metamask/utils'; // eslint-disable-next-line import-x/no-nodejs-modules import EventEmitter from 'events'; +import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; +import { getIncomingTransactionsPollingInterval } from '../utils/feature-flags'; export type IncomingTransactionOptions = { + client?: string; includeTokenTransfers?: boolean; isEnabled?: () => boolean; queryEntireHistory?: boolean; updateTransactions?: boolean; }; -const INTERVAL = 1000 * 30; // 30 Seconds +const TAG_POLLING = 'automatic-polling'; export class IncomingTransactionHelper { hub: EventEmitter; + readonly #client?: string; + readonly #getCache: () => Record; readonly #getCurrentAccount: () => ReturnType< @@ -33,6 +38,8 @@ export class IncomingTransactionHelper { #isRunning: boolean; + readonly #messenger: TransactionControllerMessenger; + readonly #queryEntireHistory?: boolean; readonly #remoteTransactionSource: RemoteTransactionSource; @@ -48,17 +55,20 @@ export class IncomingTransactionHelper { readonly #updateTransactions?: boolean; constructor({ + client, getCache, getCurrentAccount, getLocalTransactions, includeTokenTransfers, isEnabled, + messenger, queryEntireHistory, remoteTransactionSource, trimTransactions, updateCache, updateTransactions, }: { + client?: string; getCache: () => Record; getCurrentAccount: () => ReturnType< AccountsController['getSelectedAccount'] @@ -66,6 +76,7 @@ export class IncomingTransactionHelper { getLocalTransactions: () => TransactionMeta[]; includeTokenTransfers?: boolean; isEnabled?: () => boolean; + messenger: TransactionControllerMessenger; queryEntireHistory?: boolean; remoteTransactionSource: RemoteTransactionSource; trimTransactions: (transactions: TransactionMeta[]) => TransactionMeta[]; @@ -74,12 +85,14 @@ export class IncomingTransactionHelper { }) { this.hub = new EventEmitter(); + this.#client = client; this.#getCache = getCache; this.#getCurrentAccount = getCurrentAccount; this.#getLocalTransactions = getLocalTransactions; this.#includeTokenTransfers = includeTokenTransfers; this.#isEnabled = isEnabled ?? (() => true); this.#isRunning = false; + this.#messenger = messenger; this.#queryEntireHistory = queryEntireHistory; this.#remoteTransactionSource = remoteTransactionSource; this.#trimTransactions = trimTransactions; @@ -96,10 +109,12 @@ export class IncomingTransactionHelper { return; } - log('Starting polling'); + const interval = this.#getInterval(); + + log('Starting polling', { interval }); // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.#timeoutId = setTimeout(() => this.#onInterval(), INTERVAL); + this.#timeoutId = setTimeout(() => this.#onInterval(), interval); this.#isRunning = true; log('Started polling'); @@ -127,14 +142,23 @@ export class IncomingTransactionHelper { } if (this.#isRunning) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.#timeoutId = setTimeout(() => this.#onInterval(), INTERVAL); + this.#timeoutId = setTimeout( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + () => this.#onInterval(), + this.#getInterval(), + ); } } - async update({ isInterval }: { isInterval?: boolean } = {}): Promise { + async update({ + isInterval, + tags, + }: { isInterval?: boolean; tags?: string[] } = {}): Promise { + const finalTags = this.#getTags(tags, isInterval); + log('Checking for incoming transactions', { isInterval: Boolean(isInterval), + tags: finalTags, }); if (!this.#canStart()) { @@ -156,6 +180,7 @@ export class IncomingTransactionHelper { cache, includeTokenTransfers, queryEntireHistory, + tags: finalTags, updateCache: this.#updateCache, updateTransactions, }); @@ -228,4 +253,27 @@ export class IncomingTransactionHelper { #canStart(): boolean { return this.#isEnabled(); } + + #getInterval(): number { + return getIncomingTransactionsPollingInterval(this.#messenger); + } + + #getTags( + requestTags: string[] | undefined, + isInterval: boolean | undefined, + ): string[] | undefined { + const tags = []; + + if (this.#client) { + tags.push(this.#client); + } + + if (requestTags?.length) { + tags.push(...requestTags); + } else if (isInterval) { + tags.push(TAG_POLLING); + } + + return tags?.length ? tags : undefined; + } } diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index acab9f2c03d..baba496811a 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -1151,4 +1151,53 @@ describe('PendingTransactionTracker', () => { expect(transactionMeta.txReceipt).toBeUndefined(); }); }); + + describe('addTransactionToPoll', () => { + it('adds a transaction to poll and sets #transactionToForcePoll', () => { + pendingTransactionTracker = new PendingTransactionTracker(options); + + pendingTransactionTracker.addTransactionToPoll( + TRANSACTION_SUBMITTED_MOCK, + ); + + expect(transactionPoller.setPendingTransactions).toHaveBeenCalledWith([ + TRANSACTION_SUBMITTED_MOCK, + ]); + expect(transactionPoller.start).toHaveBeenCalledTimes(1); + }); + + describe('emits confirm event and clean transactionToForcePoll', () => { + it('if receipt has success status', async () => { + const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; + const getTransactions = jest + .fn() + .mockReturnValue(freeze([transaction], true)); + + pendingTransactionTracker = new PendingTransactionTracker({ + ...options, + getTransactions, + }); + + pendingTransactionTracker.addTransactionToPoll( + TRANSACTION_SUBMITTED_MOCK, + ); + + const listener = jest.fn(); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + + queryMock.mockResolvedValueOnce(RECEIPT_MOCK); + queryMock.mockResolvedValueOnce(BLOCK_MOCK); + + await onPoll(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining(TRANSACTION_SUBMITTED_MOCK), + ); + }); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index dec9b29d651..316866f56c6 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -93,6 +93,8 @@ export class PendingTransactionTracker { readonly #transactionPoller: TransactionPoller; + #transactionToForcePoll: TransactionMeta | undefined; + readonly #beforeCheckPendingTransaction: ( transactionMeta: TransactionMeta, ) => Promise; @@ -139,6 +141,7 @@ export class PendingTransactionTracker { this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; this.#running = false; + this.#transactionToForcePoll = undefined; this.#transactionPoller = new TransactionPoller({ blockTracker, @@ -167,6 +170,22 @@ export class PendingTransactionTracker { } }; + /** + * Adds a transaction to the polling mechanism for monitoring its status. + * + * This method forcefully adds a single transaction to the list of transactions + * being polled, ensuring that its status is checked, event emitted but no update is performed. + * It overrides the default behavior by prioritizing the given transaction for polling. + * + * @param transactionMeta - The transaction metadata to be added for polling. + * + * The transaction will now be monitored for updates, such as confirmation or failure. + */ + addTransactionToPoll(transactionMeta: TransactionMeta): void { + this.#start([transactionMeta]); + this.#transactionToForcePoll = transactionMeta; + } + /** * Force checks the network if the given transaction is confirmed and updates it's status. * @@ -232,7 +251,10 @@ export class PendingTransactionTracker { async #checkTransactions() { this.#log('Checking transactions'); - const pendingTransactions = this.#getPendingTransactions(); + const pendingTransactions: TransactionMeta[] = [ + ...this.#getPendingTransactions(), + ...(this.#transactionToForcePoll ? [this.#transactionToForcePoll] : []), + ]; if (!pendingTransactions.length) { this.#log('No pending transactions to check'); @@ -353,6 +375,12 @@ export class PendingTransactionTracker { return blocksSinceFirstRetry >= requiredBlocksSinceFirstRetry; } + #cleanTransactionToForcePoll(transactionId: string) { + if (this.#transactionToForcePoll?.id === transactionId) { + this.#transactionToForcePoll = undefined; + } + } + async #checkTransaction(txMeta: TransactionMeta) { const { hash, id } = txMeta; @@ -429,6 +457,12 @@ export class PendingTransactionTracker { this.#log('Transaction confirmed', id); + if (this.#transactionToForcePoll) { + this.#cleanTransactionToForcePoll(txMeta.id); + this.hub.emit('transaction-confirmed', txMeta); + return; + } + const { baseFeePerGas, timestamp: blockTimestamp } = await this.#getBlockByHash(blockHash, false); @@ -525,11 +559,13 @@ export class PendingTransactionTracker { #failTransaction(txMeta: TransactionMeta, error: Error) { this.#log('Transaction failed', txMeta.id, error); + this.#cleanTransactionToForcePoll(txMeta.id); this.hub.emit('transaction-failed', txMeta, error); } #dropTransaction(txMeta: TransactionMeta) { this.#log('Transaction dropped', txMeta.id); + this.#cleanTransactionToForcePoll(txMeta.id); this.hub.emit('transaction-dropped', txMeta); } diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts new file mode 100644 index 00000000000..94aa9247134 --- /dev/null +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts @@ -0,0 +1,445 @@ +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; + +import { SequentialPublishBatchHook } from './SequentialPublishBatchHook'; +import { flushPromises } from '../../../../tests/helpers'; +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; +import type { PublishBatchHookTransaction, TransactionMeta } from '../types'; + +jest.mock('@metamask/controller-utils', () => ({ + query: jest.fn(), +})); + +const TRANSACTION_HASH_MOCK = '0x123'; +const TRANSACTION_HASH_2_MOCK = '0x456'; +const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; +const TRANSACTION_ID_MOCK = 'testTransactionId'; +const TRANSACTION_ID_2_MOCK = 'testTransactionId2'; +const TRANSACTION_SIGNED_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; +const TRANSACTION_SIGNED_2_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567891'; +const TRANSACTION_PARAMS_MOCK = { + from: '0x1234567890abcdef1234567890abcdef12345678' as Hex, + to: '0xabcdef1234567890abcdef1234567890abcdef12' as Hex, + value: '0x1' as Hex, +}; +const TRANSACTION_1_MOCK = { + id: TRANSACTION_ID_MOCK, + signedTx: TRANSACTION_SIGNED_MOCK, + params: TRANSACTION_PARAMS_MOCK, +} as PublishBatchHookTransaction; +const TRANSACTION_2_MOCK = { + id: TRANSACTION_ID_2_MOCK, + signedTx: TRANSACTION_SIGNED_2_MOCK, + params: TRANSACTION_PARAMS_MOCK, +} as PublishBatchHookTransaction; + +const TRANSACTION_META_MOCK = { + id: TRANSACTION_ID_MOCK, + rawTx: '0xabcdef', +} as TransactionMeta; + +const TRANSACTION_META_2_MOCK = { + id: TRANSACTION_ID_2_MOCK, + rawTx: '0x123456', +} as TransactionMeta; + +describe('SequentialPublishBatchHook', () => { + const eventListeners: Record = {}; + let publishTransactionMock: jest.MockedFn< + (ethQuery: EthQuery, transactionMeta: TransactionMeta) => Promise + >; + let getTransactionMock: jest.MockedFn<(id: string) => TransactionMeta>; + let getEthQueryMock: jest.MockedFn<(networkClientId: string) => EthQuery>; + let ethQueryInstanceMock: EthQuery; + let pendingTransactionTrackerMock: jest.Mocked; + + /** + * Simulate an event from the pending transaction tracker. + * + * @param eventName - The name of the event to fire. + * @param args - Additional arguments to pass to the event handler. + */ + function firePendingTransactionTrackerEvent( + eventName: string, + ...args: unknown[] + ) { + eventListeners[eventName]?.forEach((callback) => callback(...args)); + } + + beforeEach(() => { + jest.resetAllMocks(); + + publishTransactionMock = jest.fn(); + getTransactionMock = jest.fn(); + getEthQueryMock = jest.fn(); + + ethQueryInstanceMock = {} as EthQuery; + getEthQueryMock.mockReturnValue(ethQueryInstanceMock); + + getTransactionMock.mockImplementation((id) => { + if (id === TRANSACTION_ID_MOCK) { + return TRANSACTION_META_MOCK; + } + if (id === TRANSACTION_ID_2_MOCK) { + return TRANSACTION_META_2_MOCK; + } + throw new Error(`Transaction with ID ${id} not found`); + }); + + pendingTransactionTrackerMock = { + hub: { + on: jest.fn((eventName, callback) => { + if (!eventListeners[eventName]) { + eventListeners[eventName] = []; + } + eventListeners[eventName].push(callback); + }), + off: jest.fn((eventName) => { + if (eventName) { + eventListeners[eventName] = []; + } else { + Object.keys(eventListeners).forEach((key) => { + eventListeners[key] = []; + }); + } + }), + }, + addTransactionToPoll: jest.fn(), + stop: jest.fn(), + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('publishes multiple transactions sequentially', async () => { + const transactions: PublishBatchHookTransaction[] = [ + TRANSACTION_1_MOCK, + TRANSACTION_2_MOCK, + ]; + + publishTransactionMock + .mockResolvedValueOnce(TRANSACTION_HASH_MOCK) + .mockResolvedValueOnce(TRANSACTION_HASH_2_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const resultPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + // Simulate confirmation for the first transaction + await flushPromises(); + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_MOCK, + ); + + // Simulate confirmation for the second transaction + await flushPromises(); + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_2_MOCK, + ); + + const result = await resultPromise; + + expect(result).toStrictEqual({ + results: [ + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, + ], + }); + + expect(publishTransactionMock).toHaveBeenCalledTimes(2); + expect(publishTransactionMock).toHaveBeenNthCalledWith( + 1, + ethQueryInstanceMock, + TRANSACTION_META_MOCK, + ); + expect(publishTransactionMock).toHaveBeenNthCalledWith( + 2, + ethQueryInstanceMock, + TRANSACTION_META_2_MOCK, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(2); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: TRANSACTION_ID_2_MOCK, + hash: TRANSACTION_HASH_2_MOCK, + }), + ); + + expect(pendingTransactionTrackerMock.hub.on).toHaveBeenCalledTimes(6); + expect(pendingTransactionTrackerMock.hub.off).toHaveBeenCalledTimes(6); + }); + + it('throws an error when publishTransaction fails', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockRejectedValueOnce( + new Error('Failed to publish transaction'), + ); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + await expect( + hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }), + ).rejects.toThrow('Failed to publish batch transaction'); + + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).not.toHaveBeenCalled(); + }); + + it('returns an empty result when transactions array is empty', async () => { + const transactions: PublishBatchHookTransaction[] = []; + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const result = await hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + expect(result).toStrictEqual({ results: [] }); + expect(publishTransactionMock).not.toHaveBeenCalled(); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).not.toHaveBeenCalled(); + }); + + it('handles transaction dropped event correctly', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent( + 'transaction-dropped', + TRANSACTION_META_MOCK, + ); + + await expect(hookPromise).rejects.toThrow( + `Failed to publish batch transaction`, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + + expect(pendingTransactionTrackerMock.hub.off).toHaveBeenCalledTimes(3); + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('handles transaction failed event correctly', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent( + 'transaction-failed', + TRANSACTION_META_MOCK, + new Error('Transaction failed'), + ); + + await expect(hookPromise).rejects.toThrow( + `Failed to publish batch transaction`, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + + expect(pendingTransactionTrackerMock.hub.off).toHaveBeenCalledTimes(3); + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('does nothing when #onConfirmed is called with a different transactionId', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent('transaction-confirmed', { + id: 'differentTransactionId', + }); + + expect(pendingTransactionTrackerMock.hub.off).not.toHaveBeenCalled(); + + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_MOCK, + ); + + expect(await hookPromise).toStrictEqual({ + results: [{ transactionHash: TRANSACTION_HASH_MOCK }], + }); + }); + + it('does nothing when #onFailedOrDropped is called with a different transactionId', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTracker: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent( + 'transaction-failed', + { id: 'differentTransactionId' }, + new Error('Transaction failed'), + ); + + expect(pendingTransactionTrackerMock.hub.off).not.toHaveBeenCalled(); + + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_MOCK, + ); + + expect(await hookPromise).toStrictEqual({ + results: [{ transactionHash: TRANSACTION_HASH_MOCK }], + }); + }); +}); diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts new file mode 100644 index 00000000000..bfdbb27d4d5 --- /dev/null +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts @@ -0,0 +1,221 @@ +import type EthQuery from '@metamask/eth-query'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; +import { projectLogger } from '../logger'; +import { + type PublishBatchHook, + type PublishBatchHookRequest, + type PublishBatchHookResult, + type TransactionMeta, +} from '../types'; + +const log = createModuleLogger(projectLogger, 'sequential-publish-batch-hook'); + +type SequentialPublishBatchHookOptions = { + publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + getTransaction: (id: string) => TransactionMeta; + getEthQuery: (networkClientId: string) => EthQuery; + getPendingTransactionTracker: ( + networkClientId: string, + ) => PendingTransactionTracker; +}; + +/** + * Custom publish logic that also publishes additional sequential transactions in a batch. + * Requires the batch to be successful to resolve. + */ +export class SequentialPublishBatchHook { + readonly #publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + + readonly #getTransaction: (id: string) => TransactionMeta; + + readonly #getEthQuery: (networkClientId: string) => EthQuery; + + readonly #getPendingTransactionTracker: ( + networkClientId: string, + ) => PendingTransactionTracker; + + #boundListeners: Record< + string, + { + onConfirmed: (txMeta: TransactionMeta) => void; + onFailedOrDropped: (txMeta: TransactionMeta, error?: Error) => void; + } + > = {}; + + constructor({ + publishTransaction, + getTransaction, + getPendingTransactionTracker, + getEthQuery, + }: SequentialPublishBatchHookOptions) { + this.#publishTransaction = publishTransaction; + this.#getTransaction = getTransaction; + this.#getEthQuery = getEthQuery; + this.#getPendingTransactionTracker = getPendingTransactionTracker; + } + + /** + * @returns The publish batch hook function. + */ + getHook(): PublishBatchHook { + return this.#hook.bind(this); + } + + async #hook({ + from, + networkClientId, + transactions, + }: PublishBatchHookRequest): Promise { + log('Starting sequential publish batch hook', { from, networkClientId }); + + const pendingTransactionTracker = + this.#getPendingTransactionTracker(networkClientId); + const results = []; + + for (const transaction of transactions) { + try { + const transactionMeta = this.#getTransaction(String(transaction.id)); + + const transactionHash = await this.#publishTransaction( + this.#getEthQuery(networkClientId), + transactionMeta, + ); + log('Transaction published', { transactionHash }); + + const transactionUpdated = { + ...transactionMeta, + hash: transactionHash, + }; + + const confirmationPromise = this.#waitForTransactionEvent( + pendingTransactionTracker, + transactionUpdated.id, + transactionUpdated.hash, + ); + + pendingTransactionTracker.addTransactionToPoll(transactionUpdated); + + await confirmationPromise; + results.push({ transactionHash }); + } catch (error) { + log('Batch transaction failed', { transaction, error }); + pendingTransactionTracker.stop(); + throw rpcErrors.internal(`Failed to publish batch transaction`); + } + } + + log('Sequential publish batch hook completed', { results }); + pendingTransactionTracker.stop(); + + return { results }; + } + + /** + * Waits for a transaction event (confirmed, failed, or dropped) and resolves/rejects accordingly. + * + * @param pendingTransactionTracker - The tracker instance to subscribe to events. + * @param transactionId - The transaction ID. + * @param transactionHash - The hash of the transaction. + * @returns A promise that resolves when the transaction is confirmed or rejects if it fails or is dropped. + */ + async #waitForTransactionEvent( + pendingTransactionTracker: PendingTransactionTracker, + transactionId: string, + transactionHash: string, + ): Promise { + return new Promise((resolve, reject) => { + const onConfirmed = this.#onConfirmed.bind( + this, + transactionId, + transactionHash, + resolve, + pendingTransactionTracker, + ); + + const onFailedOrDropped = this.#onFailedOrDropped.bind( + this, + transactionId, + transactionHash, + reject, + pendingTransactionTracker, + ); + + this.#boundListeners[transactionId] = { + onConfirmed, + onFailedOrDropped, + }; + + pendingTransactionTracker.hub.on('transaction-confirmed', onConfirmed); + pendingTransactionTracker.hub.on('transaction-failed', onFailedOrDropped); + pendingTransactionTracker.hub.on( + 'transaction-dropped', + onFailedOrDropped, + ); + }); + } + + #onConfirmed( + transactionId: string, + transactionHash: string, + resolve: (txMeta: TransactionMeta) => void, + pendingTransactionTracker: PendingTransactionTracker, + txMeta: TransactionMeta, + ): void { + if (txMeta.id !== transactionId) { + return; + } + + log('Transaction confirmed', { transactionHash }); + this.#removeListeners(pendingTransactionTracker, transactionId); + resolve(txMeta); + } + + #onFailedOrDropped( + transactionId: string, + transactionHash: string, + reject: (error: Error) => void, + pendingTransactionTracker: PendingTransactionTracker, + txMeta: TransactionMeta, + error?: Error, + ): void { + if (txMeta.id !== transactionId) { + return; + } + + log('Transaction failed or dropped', { transactionHash, error }); + this.#removeListeners(pendingTransactionTracker, transactionId); + reject(new Error(`Transaction ${transactionHash} failed or dropped.`)); + } + + #removeListeners( + pendingTransactionTracker: PendingTransactionTracker, + transactionId: string, + ): void { + const listeners = this.#boundListeners[transactionId]; + + pendingTransactionTracker.hub.off( + 'transaction-confirmed', + listeners.onConfirmed, + ); + pendingTransactionTracker.hub.off( + 'transaction-failed', + listeners.onFailedOrDropped, + ); + pendingTransactionTracker.hub.off( + 'transaction-dropped', + listeners.onFailedOrDropped, + ); + + delete this.#boundListeners[transactionId]; + } +} diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index e9e287fe8d7..cfd775ffa83 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -66,6 +66,7 @@ export type { SimulationError, SimulationToken, SimulationTokenBalanceChange, + TransactionBatchMeta, TransactionBatchRequest, TransactionBatchResult, TransactionError, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 62253b96113..0f305db63b5 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -490,6 +490,36 @@ export type TransactionMeta = { }; }; +/** + * Information about a batch transaction. + */ +export type TransactionBatchMeta = { + /** + * Network code as per EIP-155 for this transaction. + */ + chainId: Hex; + + /** + * ID of the associated transaction batch. + */ + id: string; + + /** + * Data for any EIP-7702 transactions. + */ + transactions?: NestedTransactionMetadata[]; + + /** + * The ID of the network client used by the transaction. + */ + networkClientId: NetworkClientId; + + /** + * Origin this transaction was sent from. + */ + origin?: string; +}; + export type SendFlowHistoryEntry = { /** * String to indicate user interaction information. @@ -946,6 +976,11 @@ export interface RemoteTransactionSourceRequest { */ queryEntireHistory: boolean; + /** + * Additional tags to identify the source of the request. + */ + tags?: string[]; + /** * Callback to update the cache. */ @@ -1509,7 +1544,7 @@ export type BatchTransactionParams = { /** Metadata for a nested transaction within a standard transaction. */ export type NestedTransactionMetadata = BatchTransactionParams & { - /** Type of the neted transaction. */ + /** Type of the nested transaction. */ type?: TransactionType; }; diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index a32aaedb478..a0e4b21ff18 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -1,4 +1,5 @@ -import { rpcErrors } from '@metamask/rpc-errors'; +import { ORIGIN_METAMASK, type AddResult } from '@metamask/approval-controller'; +import { rpcErrors, errorCodes } from '@metamask/rpc-errors'; import { ERROR_MESSAGE_NO_UPGRADE_CONTRACT, @@ -16,6 +17,7 @@ import { getEIP7702UpgradeContractAddress, } from './feature-flags'; import { validateBatchRequest } from './validation'; +import type { TransactionControllerState } from '..'; import { TransactionEnvelopeType, type TransactionControllerMessenger, @@ -24,6 +26,7 @@ import { TransactionType, } from '..'; import { flushPromises } from '../../../../tests/helpers'; +import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import type { PublishBatchHook } from '../types'; jest.mock('./eip7702'); @@ -35,6 +38,8 @@ jest.mock('./validation', () => ({ validateBatchRequest: jest.fn(), })); +jest.mock('../hooks/SequentialPublishBatchHook'); + type AddBatchTransactionOptions = Parameters[0]; const CHAIN_ID_MOCK = '0x123'; @@ -44,7 +49,9 @@ const CONTRACT_ADDRESS_MOCK = '0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'; const TO_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef'; const DATA_MOCK = '0xabcdef'; const VALUE_MOCK = '0x1234'; -const MESSENGER_MOCK = {} as TransactionControllerMessenger; +const MESSENGER_MOCK = { + call: jest.fn().mockResolvedValue({}), +} as unknown as TransactionControllerMessenger; const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; const PUBLIC_KEY_MOCK = '0x112233'; const BATCH_ID_CUSTOM_MOCK = '0x123456'; @@ -72,11 +79,94 @@ const TRANSACTION_META_MOCK = { }, } as unknown as TransactionMeta; +/** + * Mocks the `ApprovalController:addRequest` action for the `requestApproval` function in `batch.ts`. + * + * @param messenger - The mocked messenger instance. + * @param options - An options bag which will be used to create an action + * handler that places the approval request in a certain state. + * @returns An object which contains the mocked promise, functions to + * manually approve or reject the approval (and therefore the promise), and + * finally the mocked version of the action handler itself. + */ +function mockRequestApproval( + messenger: TransactionControllerMessenger, + options: + | { + state: 'approved'; + result?: Partial; + } + | { + state: 'rejected'; + error?: unknown; + } + | { + state: 'pending'; + }, +): { + promise: Promise; + approve: (approvalResult?: Partial) => void; + reject: (rejectionError: unknown) => void; + actionHandlerMock: jest.Mock< + ReturnType, + Parameters + >; +} { + let resolvePromise: (value: AddResult) => void; + let rejectPromise: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const approveTransaction = (approvalResult?: Partial) => { + resolvePromise({ + resultCallbacks: { + success() { + // Mock success callback + }, + error() { + // Mock error callback + }, + }, + ...approvalResult, + }); + }; + + const rejectTransaction = ( + rejectionError: unknown = { + code: errorCodes.provider.userRejectedRequest, + }, + ) => { + rejectPromise(rejectionError); + }; + + const actionHandlerMock = jest.fn().mockReturnValue(promise); + + if (options.state === 'approved') { + approveTransaction(options.result); + } else if (options.state === 'rejected') { + rejectTransaction(options.error); + } + + messenger.call = actionHandlerMock; + + return { + promise, + approve: approveTransaction, + reject: rejectTransaction, + actionHandlerMock, + }; +} + describe('Batch Utils', () => { const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702); const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); const validateBatchRequestMock = jest.mocked(validateBatchRequest); const determineTransactionTypeMock = jest.mocked(determineTransactionType); + const sequentialPublishBatchHookMock = jest.mocked( + SequentialPublishBatchHook, + ); const isAccountUpgradedToEIP7702Mock = jest.mocked( isAccountUpgradedToEIP7702, @@ -103,6 +193,16 @@ describe('Batch Utils', () => { AddBatchTransactionOptions['updateTransaction'] >; + let publishTransactionMock: jest.MockedFn< + AddBatchTransactionOptions['publishTransaction'] + >; + + let getPendingTransactionTrackerMock: jest.MockedFn< + AddBatchTransactionOptions['getPendingTransactionTracker'] + >; + + let updateMock: jest.MockedFn; + let request: AddBatchTransactionOptions; beforeEach(() => { @@ -110,6 +210,9 @@ describe('Batch Utils', () => { addTransactionMock = jest.fn(); getChainIdMock = jest.fn(); updateTransactionMock = jest.fn(); + publishTransactionMock = jest.fn(); + getPendingTransactionTrackerMock = jest.fn(); + updateMock = jest.fn(); determineTransactionTypeMock.mockResolvedValue({ type: TransactionType.simpleSend, @@ -148,6 +251,9 @@ describe('Batch Utils', () => { ], }, updateTransaction: updateTransactionMock, + publishTransaction: publishTransactionMock, + getPendingTransactionTracker: getPendingTransactionTrackerMock, + update: updateMock, }; }); @@ -552,6 +658,12 @@ describe('Batch Utils', () => { }); describe('with publish batch hook', () => { + beforeEach(() => { + mockRequestApproval(MESSENGER_MOCK, { + state: 'approved', + }); + }); + it('adds each nested transaction', async () => { const publishBatchHook = jest.fn(); @@ -588,6 +700,59 @@ describe('Batch Utils', () => { ); }); + it.each([ + { + origin: ORIGIN_MOCK, + description: 'with defined origin', + expectedOrigin: ORIGIN_MOCK, + }, + { + origin: undefined, + description: 'with undefined origin', + expectedOrigin: ORIGIN_METAMASK, + }, + ])( + 'requests approval for batch transactions $description', + async ({ origin, expectedOrigin }) => { + const publishBatchHook = jest.fn(); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce({ + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }); + + request.messenger = MESSENGER_MOCK; + + addTransactionBatch({ + ...request, + publishBatchHook, + request: { ...request.request, useHook: true, origin }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + + expect(MESSENGER_MOCK.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: expectedOrigin, + requestData: { txBatchId: expect.any(String) }, + expectsResult: true, + type: 'transaction_batch', + }), + true, + ); + }, + ); + it('calls publish batch hook', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); @@ -967,15 +1132,6 @@ describe('Batch Utils', () => { ); }); - it('throws if no publish batch hook', async () => { - await expect( - addTransactionBatch({ - ...request, - request: { ...request.request, useHook: true }, - }), - ).rejects.toThrow(rpcErrors.internal('No publish batch hook provided')); - }); - it('rejects individual publish hooks if batch hook throws', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); @@ -1002,7 +1158,11 @@ describe('Batch Utils', () => { addTransactionBatch({ ...request, publishBatchHook, - request: { ...request.request, useHook: true }, + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, }).catch(() => { // Intentionally empty }); @@ -1055,7 +1215,11 @@ describe('Batch Utils', () => { addTransactionBatch({ ...request, publishBatchHook, - request: { ...request.request, useHook: true }, + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, }).catch(() => { // Intentionally empty }); @@ -1078,6 +1242,251 @@ describe('Batch Utils', () => { await expect(publishHookPromise1).rejects.toThrow(ERROR_MESSAGE_MOCK); }); }); + + describe('with sequential publish batch hook', () => { + let sequentialPublishBatchHook: jest.MockedFn; + + beforeEach(() => { + sequentialPublishBatchHook = jest.fn(); + + addTransactionMock + .mockResolvedValueOnce({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_MOCK, + }, + result: Promise.resolve(''), + }) + .mockResolvedValueOnce({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_2_MOCK, + }, + result: Promise.resolve(''), + }); + }); + + const setupSequentialPublishBatchHookMock = ( + hookImplementation: () => PublishBatchHook | undefined, + ) => { + sequentialPublishBatchHookMock.mockReturnValue({ + getHook: hookImplementation, + } as unknown as SequentialPublishBatchHook); + }; + + const executePublishHooks = async () => { + const publishHooks = addTransactionMock.mock.calls.map( + ([, options]) => options.publishHook, + ); + + for (const [index, publishHook] of publishHooks.entries()) { + publishHook?.( + TRANSACTION_META_MOCK, + index === 0 + ? TRANSACTION_SIGNATURE_MOCK + : TRANSACTION_SIGNATURE_2_MOCK, + ).catch(() => { + // Intentionally empty + }); + } + + await flushPromises(); + }; + + const mockSequentialPublishBatchHookResults = () => { + sequentialPublishBatchHook.mockResolvedValueOnce({ + results: [ + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, + ], + }); + }; + + const assertSequentialPublishBatchHookCalled = () => { + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + expect(sequentialPublishBatchHook).toHaveBeenCalledTimes(1); + expect(sequentialPublishBatchHook).toHaveBeenCalledWith({ + from: FROM_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions: [ + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + params: { data: DATA_MOCK, to: TO_MOCK, value: VALUE_MOCK }, + signedTx: TRANSACTION_SIGNATURE_MOCK, + }), + expect.objectContaining({ + id: TRANSACTION_ID_2_MOCK, + params: { data: DATA_MOCK, to: TO_MOCK, value: VALUE_MOCK }, + signedTx: TRANSACTION_SIGNATURE_2_MOCK, + }), + ], + }); + }; + + it('invokes sequentialPublishBatchHook when publishBatchHook is undefined', async () => { + mockSequentialPublishBatchHookResults(); + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); + + const resultPromise = addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + await executePublishHooks(); + + assertSequentialPublishBatchHookCalled(); + + const result = await resultPromise; + expect(result?.batchId).toMatch(/^0x[0-9a-f]{32}$/u); + }); + + it('throws an error when sequentialPublishBatchHook fails', async () => { + setupSequentialPublishBatchHookMock(() => { + throw new Error('Test error'); + }); + + await expect( + addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }), + ).rejects.toThrow('Test error'); + + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + }); + + it('creates an approval request for sequential publish batch hook', async () => { + const { approve } = mockRequestApproval(MESSENGER_MOCK, { + state: 'approved', + }); + mockSequentialPublishBatchHookResults(); + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); + + const resultPromise = addTransactionBatch({ + ...request, + publishBatchHook: undefined, + messenger: MESSENGER_MOCK, + request: { ...request.request, useHook: true, origin: ORIGIN_MOCK }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + approve(); + await executePublishHooks(); + + expect(MESSENGER_MOCK.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: ORIGIN_MOCK, + requestData: { txBatchId: expect.any(String) }, + expectsResult: true, + type: 'transaction_batch', + }), + true, + ); + + assertSequentialPublishBatchHookCalled(); + + const result = await resultPromise; + expect(result?.batchId).toMatch(/^0x[0-9a-f]{32}$/u); + }); + + it('saves a transaction batch and then cleans the specific batch by ID', async () => { + const { approve } = mockRequestApproval(MESSENGER_MOCK, { + state: 'approved', + }); + mockSequentialPublishBatchHookResults(); + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); + + const resultPromise = addTransactionBatch({ + ...request, + publishBatchHook: undefined, + messenger: MESSENGER_MOCK, + request: { + ...request.request, + useHook: true, + origin: ORIGIN_MOCK, + }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + approve(); + await executePublishHooks(); + + expect(MESSENGER_MOCK.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: ORIGIN_MOCK, + requestData: { txBatchId: expect.any(String) }, + expectsResult: true, + type: 'transaction_batch', + }), + true, + ); + + expect(updateMock).toHaveBeenCalledTimes(2); + expect(updateMock).toHaveBeenCalledWith(expect.any(Function)); + + // Simulate the state update for adding the batch + const state = { + transactionBatches: [ + { id: 'batch1', chainId: '0x1', transactions: [] }, + ], + } as unknown as TransactionControllerState; + + // Simulate adding the batch + updateMock.mock.calls[0][0](state); + + expect(state.transactionBatches).toStrictEqual([ + { id: 'batch1', chainId: '0x1', transactions: [] }, + expect.objectContaining({ + id: expect.any(String), + chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions: [ + expect.objectContaining({ + params: { + data: DATA_MOCK, + to: TO_MOCK, + value: VALUE_MOCK, + }, + }), + expect.objectContaining({ + params: { + data: DATA_MOCK, + to: TO_MOCK, + value: VALUE_MOCK, + }, + }), + ], + origin: ORIGIN_MOCK, + }), + ]); + + await resultPromise; + + // Simulate cleaning the specific batch by ID + updateMock.mock.calls[1][0](state); + + expect(state.transactionBatches).toStrictEqual([ + { id: 'batch1', chainId: '0x1', transactions: [] }, + ]); + }); + }); }); describe('isAtomicBatchSupported', () => { diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 84af05b244c..5768781027b 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -1,7 +1,13 @@ +import type { + AcceptResultCallbacks, + AddResult, +} from '@metamask/approval-controller'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Hex } from '@metamask/utils'; import { bytesToHex, createModuleLogger } from '@metamask/utils'; +import type { WritableDraft } from 'immer/dist/internal.js'; import { parse, v4 } from 'uuid'; import { @@ -16,6 +22,7 @@ import { getEIP7702UpgradeContractAddress, } from './feature-flags'; import { validateBatchRequest } from './validation'; +import type { TransactionControllerState } from '..'; import { determineTransactionType, type BatchTransactionParams, @@ -23,7 +30,9 @@ import { type TransactionControllerMessenger, type TransactionMeta, } from '..'; +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; import { CollectPublishHook } from '../hooks/CollectPublishHook'; +import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import { projectLogger } from '../logger'; import type { NestedTransactionMetadata, @@ -36,6 +45,7 @@ import type { ValidateSecurityRequest, IsAtomicBatchSupportedResult, IsAtomicBatchSupportedResultEntry, + TransactionBatchMeta, } from '../types'; import { TransactionEnvelopeType, @@ -44,6 +54,12 @@ import { TransactionType, } from '../types'; +type UpdateStateCallback = ( + callback: ( + state: WritableDraft, + ) => void | TransactionControllerState, +) => void; + type AddTransactionBatchRequest = { addTransaction: TransactionController['addTransaction']; getChainId: (networkClientId: string) => Hex; @@ -58,6 +74,14 @@ type AddTransactionBatchRequest = { options: { transactionId: string }, callback: (transactionMeta: TransactionMeta) => void, ) => void; + publishTransaction: ( + _ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + getPendingTransactionTracker: ( + networkClientId: string, + ) => PendingTransactionTracker; + update: UpdateStateCallback; }; type IsAtomicBatchSupportedRequestInternal = { @@ -173,7 +197,7 @@ export async function isAtomicBatchSupported( } /** - * Generate a tranasction batch ID. + * Generate a transaction batch ID. * * @returns A unique batch ID as a hexadecimal string. */ @@ -349,28 +373,61 @@ async function addTransactionBatchWith7702( async function addTransactionBatchWithHook( request: AddTransactionBatchRequest, ): Promise { - const { publishBatchHook, request: userRequest } = request; + const { + getChainId, + messenger, + publishBatchHook: requestPublishBatchHook, + request: userRequest, + update, + } = request; const { from, networkClientId, + origin, + requireApproval, transactions: nestedTransactions, + useHook, } = userRequest; + let resultCallbacks: AcceptResultCallbacks | undefined; + log('Adding transaction batch using hook', userRequest); - if (!publishBatchHook) { - log('No publish batch hook provided'); - throw new Error('No publish batch hook provided'); - } + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: request.publishTransaction, + getTransaction: request.getTransaction, + getEthQuery: request.getEthQuery, + getPendingTransactionTracker: request.getPendingTransactionTracker, + }); + + const publishBatchHook = + requestPublishBatchHook ?? sequentialPublishBatchHook.getHook(); + const chainId = getChainId(networkClientId); const batchId = generateBatchId(); const transactionCount = nestedTransactions.length; const collectHook = new CollectPublishHook(transactionCount); - const publishHook = collectHook.getHook(); - const hookTransactions: Omit[] = []; - try { + if (requireApproval && useHook) { + const txBatchMeta = newBatchMetadata({ + id: batchId, + chainId, + networkClientId, + transactions: nestedTransactions, + origin, + }); + + addBatchMetadata(txBatchMeta, update); + + resultCallbacks = (await requestApproval(txBatchMeta, messenger)) + .resultCallbacks; + } + + const publishHook = collectHook.getHook(); + const hookTransactions: Omit[] = + []; + for (const nestedTransaction of nestedTransactions) { const hookTransaction = await processTransactionWithHook( batchId, @@ -408,6 +465,7 @@ async function addTransactionBatchWithHook( ); collectHook.success(transactionHashes); + resultCallbacks?.success(); log('Completed batch transaction with hook', transactionHashes); @@ -418,8 +476,12 @@ async function addTransactionBatchWithHook( log('Publish batch hook failed', error); collectHook.error(error); + resultCallbacks?.error(error as Error); throw error; + } finally { + log('Cleaning up publish batch hook', batchId); + wipeTransactionBatchById(update, batchId); } } @@ -512,3 +574,94 @@ async function processTransactionWithHook( params: newParams, }; } + +/** + * Requests approval for a transaction batch by interacting with the ApprovalController. + * + * @param txBatchMeta - Metadata for the transaction batch, including its ID and origin. + * @param messenger - The messenger instance used to communicate with the ApprovalController. + * @returns A promise that resolves to the result of adding the approval request. + */ +async function requestApproval( + txBatchMeta: TransactionBatchMeta, + messenger: TransactionControllerMessenger, +): Promise { + const id = String(txBatchMeta.id); + const { origin } = txBatchMeta; + const type = 'transaction_batch'; + const requestData = { txBatchId: id }; + + return (await messenger.call( + 'ApprovalController:addRequest', + { + id, + origin: origin || ORIGIN_METAMASK, + requestData, + expectsResult: true, + type, + }, + true, + )) as Promise; +} + +/** + * Create a new batch metadata object. + * + * @param options - The options for creating a new batch metadata object. + * @param options.id - The ID of the transaction batch. + * @param options.chainId - The chain ID of the transaction batch. + * @param options.networkClientId - The network client ID of the transaction batch. + * @param options.transactions - The transactions in the batch. + * @param options.origin - The origin of the transaction batch. + * @returns A new TransactionBatchMeta object. + */ +function newBatchMetadata({ + id, + chainId, + networkClientId, + transactions, + origin, +}: TransactionBatchMeta): TransactionBatchMeta { + return { + id, + chainId, + networkClientId, + transactions, + origin, + }; +} + +/** + * Adds batch metadata to the transaction controller state. + * + * @param transactionBatchMeta - The transaction batch metadata to be added. + * @param update - The update function to modify the transaction controller state. + */ +function addBatchMetadata( + transactionBatchMeta: TransactionBatchMeta, + update: UpdateStateCallback, +) { + update((state) => { + state.transactionBatches = [ + ...state.transactionBatches, + transactionBatchMeta, + ]; + }); +} + +/** + * Wipes a specific transaction batch from the transaction controller state by its ID. + * + * @param update - The update function to modify the transaction controller state. + * @param id - The ID of the transaction batch to be wiped. + */ +function wipeTransactionBatchById( + update: UpdateStateCallback, + id: string, +): void { + update((state) => { + state.transactionBatches = state.transactionBatches.filter( + (batch) => batch.id !== id, + ); + }); +} diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 329f5f71eee..cd9e5a26db3 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -173,7 +173,7 @@ describe('EIP-7702 Utils', () => { nonce: AUTHORIZATION_LIST_MOCK[0].nonce, r: '0x82d5b4845dfc808802480749c30b0e02d6d7817061ba141d2d1dcd520f9b65c5', s: '0x9d0b985134dc2958a9981ce3b5d1061176313536e6da35852cfae41404f53ef3', - yParity: '0x', + yParity: '0x0', }, ]); }); @@ -217,16 +217,6 @@ describe('EIP-7702 Utils', () => { expect(result?.[1]?.nonce).toBe('0x125'); expect(result?.[2]?.nonce).toBe('0x126'); }); - - it('normalizes nonce to 0x if zero', async () => { - const result = await signAuthorizationList({ - authorizationList: [{ ...AUTHORIZATION_LIST_MOCK[0], nonce: '0x0' }], - messenger: controllerMessenger, - transactionMeta: TRANSACTION_META_MOCK, - }); - - expect(result?.[0]?.nonce).toBe('0x'); - }); }); describe('doesChainSupportEIP7702', () => { diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 96042e2b4a2..ef970f939f7 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -244,15 +244,14 @@ async function signAuthorization( ); const r = signature.slice(0, 66) as Hex; - const s = `0x${signature.slice(66, 130)}` as Hex; + const s = add0x(signature.slice(66, 130)); const v = parseInt(signature.slice(130, 132), 16); - const yParity = v - 27 === 0 ? '0x' : '0x1'; - const finalNonce = nonceDecimal === 0 ? '0x' : nonce; + const yParity = toHex(v - 27 === 0 ? 0 : 1); const result: Required = { address, chainId, - nonce: finalNonce, + nonce, r, s, yParity, diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 6574d31a67f..84bc2e1922a 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -13,6 +13,7 @@ import { getGasEstimateFallback, getGasEstimateBuffer, FeatureFlag, + getIncomingTransactionsPollingInterval, } from './feature-flags'; import { isValidSignature } from './signature'; import type { TransactionControllerMessenger } from '..'; @@ -680,4 +681,26 @@ describe('Feature Flags Utils', () => { ).toBe(GAS_BUFFER_5_MOCK); }); }); + + describe('getIncomingTransactionsPollingInterval', () => { + it('returns default value if no feature flags set', () => { + mockFeatureFlags({}); + + expect(getIncomingTransactionsPollingInterval(controllerMessenger)).toBe( + 1000 * 60 * 4, + ); + }); + + it('returns value from remote feature flag controller', () => { + mockFeatureFlags({ + [FeatureFlag.IncomingTransactions]: { + pollingIntervalMs: 5000, + }, + }); + + expect(getIncomingTransactionsPollingInterval(controllerMessenger)).toBe( + 5000, + ); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index 4fd9c59542d..f529441c0b4 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -10,6 +10,7 @@ const DEFAULT_ACCELERATED_POLLING_COUNT_MAX = 10; const DEFAULT_ACCELERATED_POLLING_INTERVAL_MS = 3 * 1000; const DEFAULT_GAS_ESTIMATE_FALLBACK_BLOCK_PERCENT = 35; const DEFAULT_GAS_ESTIMATE_BUFFER = 1; +const DEFAULT_INCOMING_TRANSACTIONS_POLLING_INTERVAL_MS = 1000 * 60 * 4; // 4 Minutes /** * Feature flags supporting the transaction controller. @@ -17,6 +18,7 @@ const DEFAULT_GAS_ESTIMATE_BUFFER = 1; export enum FeatureFlag { EIP7702 = 'confirmations_eip_7702', GasBuffer = 'confirmations_gas_buffer', + IncomingTransactions = 'confirmations_incoming_transactions', Transactions = 'confirmations_transactions', } @@ -94,6 +96,12 @@ export type TransactionControllerFeatureFlags = { }; }; + /** Incoming transaction configuration. */ + [FeatureFlag.IncomingTransactions]?: { + /** Interval between requests to accounts API to retrieve incoming transactions. */ + pollingIntervalMs?: number; + }; + /** Miscellaneous feature flags to support the transaction controller. */ [FeatureFlag.Transactions]?: { /** Maximum number of transactions that can be in an external batch. */ @@ -356,6 +364,24 @@ export function getGasEstimateBuffer({ ); } +/** + * Retrieves the incoming transactions polling interval. + * Defaults to 4 minutes if not set. + * + * @param messenger - The controller messenger instance. + * @returns The incoming transactions polling interval in milliseconds. + */ +export function getIncomingTransactionsPollingInterval( + messenger: TransactionControllerMessenger, +): number { + const featureFlags = getFeatureFlags(messenger); + + return ( + featureFlags?.[FeatureFlag.IncomingTransactions]?.pollingIntervalMs ?? + DEFAULT_INCOMING_TRANSACTIONS_POLLING_INTERVAL_MS + ); +} + /** * Retrieves the relevant feature flags from the remote feature flag controller. * diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 9db8e481746..801f629526d 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -487,14 +487,6 @@ describe('gas-fees', () => { }); describe('sets userFeeLevel', () => { - it('to undefined if not eip1559', async () => { - updateGasFeeRequest.eip1559 = false; - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.userFeeLevel).toBeUndefined(); - }); - it('to saved userFeeLevel if saved gas fees defined', async () => { updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ @@ -588,4 +580,37 @@ describe('gweiDecimalToWeiDecimal', () => { expect(gweiDecimalToWeiDecimal('1000000')).toBe('1000000000000000'); expect(gweiDecimalToWeiDecimal(1000000)).toBe('1000000000000000'); }); + + it('handles values with many decimal places', () => { + expect(gweiDecimalToWeiDecimal('1.123456789123')).toBe('1123456789'); + expect(gweiDecimalToWeiDecimal(1.123456789123)).toBe('1123456789'); + }); + + it('handles small decimal values', () => { + expect(gweiDecimalToWeiDecimal('0.000000001')).toBe('1'); + expect(gweiDecimalToWeiDecimal(0.000000001)).toBe('1'); + expect(gweiDecimalToWeiDecimal('0.00000001')).toBe('10'); + }); + + it('handles string values with leading zeros', () => { + expect(gweiDecimalToWeiDecimal('00.1')).toBe('100000000'); + expect(gweiDecimalToWeiDecimal('01.5')).toBe('1500000000'); + }); + + it('handles string values with trailing zeros', () => { + expect(gweiDecimalToWeiDecimal('1.500')).toBe('1500000000'); + expect(gweiDecimalToWeiDecimal('123.450000')).toBe('123450000000'); + }); + + it('handles extremely small values', () => { + expect(gweiDecimalToWeiDecimal('0.000000000001')).toBe('0'); + expect(gweiDecimalToWeiDecimal(0.000000000001)).toBe('0'); + }); + + it('handles scientific notation inputs', () => { + expect(gweiDecimalToWeiDecimal('1e-9')).toBe('1'); + expect(gweiDecimalToWeiDecimal(1e-9)).toBe('1'); + expect(gweiDecimalToWeiDecimal('1e9')).toBe('1000000000000000000'); + expect(gweiDecimalToWeiDecimal(1e9)).toBe('1000000000000000000'); + }); }); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index c78634af4cc..b395abbf72a 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -126,12 +126,9 @@ export function gweiDecimalToWeiHex(value: string) { * // Returns "1500000000" */ export function gweiDecimalToWeiDecimal(gweiDecimal: string | number): string { - const gwei = - typeof gweiDecimal === 'string' ? gweiDecimal : String(gweiDecimal); + const weiValue = Number(gweiDecimal) * 1e9; - const weiDecimal = Number(gwei) * 1e9; - - return weiDecimal.toString(); + return weiValue.toString().split('.')[0]; } /** @@ -283,12 +280,7 @@ function getGasPrice(request: GetGasFeeRequest): string | undefined { * @returns The user fee level. */ function getUserFeeLevel(request: GetGasFeeRequest): UserFeeLevel | undefined { - const { eip1559, initialParams, savedGasFees, suggestedGasFees, txMeta } = - request; - - if (!eip1559) { - return undefined; - } + const { initialParams, savedGasFees, suggestedGasFees, txMeta } = request; if (savedGasFees) { return UserFeeLevel.CUSTOM; diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index eb86f04d26a..b44648abc57 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -617,7 +617,7 @@ describe('gas', () => { simulateTransactionsMock.mockResolvedValueOnce({ transactions: [ { - gasUsed: toHex(SIMULATE_GAS_MOCK) as Hex, + gasLimit: toHex(SIMULATE_GAS_MOCK) as Hex, }, ], } as SimulationResponse); diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index a945e9fa07b..ec3c46b56bd 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -405,13 +405,13 @@ async function simulateGas({ }, }); - const gasUsed = response?.transactions?.[0].gasUsed; + const gasLimit = response?.transactions?.[0].gasLimit; - if (!gasUsed) { + if (!gasLimit) { throw new Error('No simulated gas returned'); } - return gasUsed; + return gasLimit; } /** diff --git a/packages/transaction-controller/src/utils/prepare.test.ts b/packages/transaction-controller/src/utils/prepare.test.ts index 840e847482e..c9ef425d43f 100644 --- a/packages/transaction-controller/src/utils/prepare.test.ts +++ b/packages/transaction-controller/src/utils/prepare.test.ts @@ -1,6 +1,11 @@ -import { FeeMarketEIP1559Transaction, LegacyTransaction } from '@ethereumjs/tx'; +import { + FeeMarketEIP1559Transaction, + LegacyTransaction, + EOACodeEIP7702Transaction, +} from '@ethereumjs/tx'; import { prepareTransaction, serializeTransaction } from './prepare'; +import type { Authorization } from '../types'; import { TransactionEnvelopeType, type TransactionParams } from '../types'; const CHAIN_ID_MOCK = '0x123'; @@ -27,6 +32,22 @@ const TRANSACTION_PARAMS_FEE_MARKET_MOCK: TransactionParams = { maxPriorityFeePerGas: '0x1234567', }; +const TRANSACTION_PARAMS_SET_CODE_MOCK: TransactionParams = { + ...TRANSACTION_PARAMS_MOCK, + type: TransactionEnvelopeType.setCode, + authorizationList: [ + { + address: '0x0034567890123456789012345678901234567890', + chainId: '0x123', + // @ts-expect-error Wrong nonce type in `ethereumjs/tx`. + nonce: ['0x1'], + r: '0x1234567890123456789012345678901234567890123456789012345678901234', + s: '0x1234567890123456789012345678901234567890123456789012345678901235', + yParity: '0x1', + }, + ], +}; + describe('Prepare Utils', () => { describe('prepareTransaction', () => { it('returns legacy transaction object', () => { @@ -41,6 +62,79 @@ describe('Prepare Utils', () => { ); expect(result).toBeInstanceOf(FeeMarketEIP1559Transaction); }); + + it('returns set code transaction object', () => { + const result = prepareTransaction( + CHAIN_ID_MOCK, + TRANSACTION_PARAMS_SET_CODE_MOCK, + ); + expect(result).toBeInstanceOf(EOACodeEIP7702Transaction); + }); + + describe('removes leading zeroes', () => { + it.each(['r', 's'] as const)('from authorization %s', (propertyName) => { + const transaction = prepareTransaction(CHAIN_ID_MOCK, { + ...TRANSACTION_PARAMS_SET_CODE_MOCK, + authorizationList: [ + { + ...TRANSACTION_PARAMS_SET_CODE_MOCK.authorizationList?.[0], + [propertyName]: + '0x0034567890123456789012345678901234567890123456789012345678901234', + } as Authorization, + ], + }) as EOACodeEIP7702Transaction; + + expect(transaction.AuthorizationListJSON[0][propertyName]).toBe( + '0x34567890123456789012345678901234567890123456789012345678901234', + ); + }); + + it('from authorization yParity', () => { + const transaction = prepareTransaction(CHAIN_ID_MOCK, { + ...TRANSACTION_PARAMS_SET_CODE_MOCK, + authorizationList: [ + { + ...TRANSACTION_PARAMS_SET_CODE_MOCK.authorizationList?.[0], + yParity: '0x0', + } as Authorization, + ], + }) as EOACodeEIP7702Transaction; + + expect(transaction.AuthorizationListJSON[0].yParity).toBe('0x'); + }); + + it('including multiple pairs', () => { + const transaction = prepareTransaction(CHAIN_ID_MOCK, { + ...TRANSACTION_PARAMS_SET_CODE_MOCK, + authorizationList: [ + { + ...TRANSACTION_PARAMS_SET_CODE_MOCK.authorizationList?.[0], + r: '0x0000007890123456789012345678901234567890123456789012345678901234', + } as Authorization, + ], + }) as EOACodeEIP7702Transaction; + + expect(transaction.AuthorizationListJSON[0].r).toBe( + '0x7890123456789012345678901234567890123456789012345678901234', + ); + }); + + it('allows zero nibbles', () => { + const transaction = prepareTransaction(CHAIN_ID_MOCK, { + ...TRANSACTION_PARAMS_SET_CODE_MOCK, + authorizationList: [ + { + ...TRANSACTION_PARAMS_SET_CODE_MOCK.authorizationList?.[0], + r: '0x0200567890123456789012345678901234567890123456789012345678901234', + } as Authorization, + ], + }) as EOACodeEIP7702Transaction; + + expect(transaction.AuthorizationListJSON[0].r).toBe( + '0x0200567890123456789012345678901234567890123456789012345678901234', + ); + }); + }); }); describe('serializeTransaction', () => { diff --git a/packages/transaction-controller/src/utils/prepare.ts b/packages/transaction-controller/src/utils/prepare.ts index 4db930d3292..95ae3fb2478 100644 --- a/packages/transaction-controller/src/utils/prepare.ts +++ b/packages/transaction-controller/src/utils/prepare.ts @@ -4,8 +4,9 @@ import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; import { bytesToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; -import type { TransactionParams } from '../types'; +import type { AuthorizationList, TransactionParams } from '../types'; export const HARDFORK = Hardfork.Prague; @@ -20,8 +21,10 @@ export function prepareTransaction( chainId: Hex, txParams: TransactionParams, ): TypedTransaction { + const normalizedData = normalizeParams(txParams); + // Does not allow `gasPrice` on type 4 transactions. - const data = txParams as TypedTxData; + const data = normalizedData as TypedTxData; return TransactionFactory.fromTxData(data, { freeze: false, @@ -55,3 +58,51 @@ function getCommonConfiguration(chainId: Hex): Common { eips: [7702], }); } + +/** + * Normalize the transaction parameters for compatibility with `ethereumjs/tx`. + * + * @param params - The transaction parameters to normalize. + * @returns The normalized transaction parameters. + */ +function normalizeParams(params: TransactionParams): TransactionParams { + const newParams = cloneDeep(params); + normalizeAuthorizationList(newParams.authorizationList); + return newParams; +} + +/** + * Normalize the authorization list for `ethereumjs/tx` compatibility. + * + * @param authorizationList - The list of authorizations to normalize. + */ +function normalizeAuthorizationList(authorizationList?: AuthorizationList) { + if (!authorizationList) { + return; + } + + for (const authorization of authorizationList) { + authorization.nonce = removeLeadingZeroes(authorization.nonce); + authorization.r = removeLeadingZeroes(authorization.r); + authorization.s = removeLeadingZeroes(authorization.s); + authorization.yParity = removeLeadingZeroes(authorization.yParity); + } +} + +/** + * Remove leading zeroes from a hexadecimal string. + * + * @param value - The hexadecimal string to process. + * @returns The processed hexadecimal string. + */ +function removeLeadingZeroes(value: Hex | undefined): Hex | undefined { + if (!value) { + return value; + } + + if (value === '0x0') { + return '0x'; + } + + return (value.replace?.(/^0x(00)+/u, '0x') as Hex) ?? value; +} diff --git a/packages/transaction-controller/src/utils/transaction-type.test.ts b/packages/transaction-controller/src/utils/transaction-type.test.ts index e44a9111755..fe13f27b3ff 100644 --- a/packages/transaction-controller/src/utils/transaction-type.test.ts +++ b/packages/transaction-controller/src/utils/transaction-type.test.ts @@ -1,5 +1,6 @@ import EthQuery from '@metamask/eth-query'; +import { DELEGATION_PREFIX } from './eip7702'; import { determineTransactionType } from './transaction-type'; import { FakeProvider } from '../../../../tests/fake-provider'; import { TransactionType } from '../types'; @@ -103,6 +104,30 @@ describe('determineTransactionType', () => { }); }); + it('does not identify contract codes with DELEGATION_PREFIX as contract addresses', async () => { + class MockEthQuery extends EthQuery { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCode(_to: any, cb: any) { + cb(null, `${DELEGATION_PREFIX}1234567890abcdef`); + } + } + + const result = await determineTransactionType( + { + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', + data: '0xabd', + from: FROM_MOCK, + }, + new MockEthQuery(new FakeProvider()), + ); + + expect(result).toMatchObject({ + type: TransactionType.simpleSend, + getCodeResponse: `${DELEGATION_PREFIX}1234567890abcdef`, + }); + }); + it('returns a token approve type when the recipient is a contract and data is for the respective method call', async () => { class MockEthQuery extends EthQuery { // TODO: Replace `any` with type diff --git a/packages/transaction-controller/src/utils/transaction-type.ts b/packages/transaction-controller/src/utils/transaction-type.ts index 30b9f35c05e..a83e139ddc3 100644 --- a/packages/transaction-controller/src/utils/transaction-type.ts +++ b/packages/transaction-controller/src/utils/transaction-type.ts @@ -9,6 +9,7 @@ import { abiFiatTokenV2, } from '@metamask/metamask-eth-abis'; +import { DELEGATION_PREFIX } from './eip7702'; import type { InferTransactionTypeResult, TransactionParams } from '../types'; import { TransactionType } from '../types'; @@ -146,7 +147,9 @@ async function readAddressAsContract( } const isContractAddress = contractCode - ? contractCode !== '0x' && contractCode !== '0x0' + ? contractCode !== '0x' && + contractCode !== '0x0' && + !contractCode.startsWith(DELEGATION_PREFIX) : false; return { contractCode, isContractAddress }; } diff --git a/packages/transaction-controller/src/utils/validation.test.ts b/packages/transaction-controller/src/utils/validation.test.ts index 45860868c4d..05e1b802160 100644 --- a/packages/transaction-controller/src/utils/validation.test.ts +++ b/packages/transaction-controller/src/utils/validation.test.ts @@ -575,7 +575,7 @@ describe('validation', () => { }, ); - it('throws if yParity is not 0x or 0x1', () => { + it('throws if yParity is not 0x0 or 0x1', () => { expect(() => validateTxParams({ authorizationList: [ @@ -590,7 +590,7 @@ describe('validation', () => { }), ).toThrow( rpcErrors.invalidParams( - `Invalid transaction params: yParity must be '0x' or '0x1'. got: 0x2`, + `Invalid transaction params: yParity must be '0x0' or '0x1'. got: 0x2`, ), ); }); diff --git a/packages/transaction-controller/src/utils/validation.ts b/packages/transaction-controller/src/utils/validation.ts index 86f09a1a300..11a6eaeca56 100644 --- a/packages/transaction-controller/src/utils/validation.ts +++ b/packages/transaction-controller/src/utils/validation.ts @@ -16,6 +16,7 @@ import { export enum ErrorCode { DuplicateBundleId = 5720, BundleTooLarge = 5740, + RejectedUpgrade = 5750, } const TRANSACTION_ENVELOPE_TYPES_FEE_MARKET = [ @@ -534,9 +535,9 @@ function validateAuthorization(authorization: Authorization) { const { yParity } = authorization; - if (yParity && !['0x', '0x1'].includes(yParity)) { + if (yParity && !['0x0', '0x1'].includes(yParity)) { throw rpcErrors.invalidParams( - `Invalid transaction params: yParity must be '0x' or '0x1'. got: ${yParity}`, + `Invalid transaction params: yParity must be '0x0' or '0x1'. got: ${yParity}`, ); } } diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index ba493b25179..f97ba5bade1 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [35.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) - Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) ## [34.0.0] @@ -407,7 +415,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#3749](https://github.com/MetaMask/core/pull/3749)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@34.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@35.0.0...HEAD +[35.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@34.0.0...@metamask/user-operation-controller@35.0.0 [34.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@33.0.0...@metamask/user-operation-controller@34.0.0 [33.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@32.0.0...@metamask/user-operation-controller@33.0.0 [32.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@31.0.0...@metamask/user-operation-controller@32.0.0 diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 891b85a97d9..c41b92a0d36 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/user-operation-controller", - "version": "34.0.0", + "version": "35.0.0", "description": "Creates user operations and manages their life cycle", "keywords": [ "MetaMask", @@ -49,7 +49,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-query": "^4.0.0", "@metamask/polling-controller": "^13.0.0", "@metamask/rpc-errors": "^7.0.2", @@ -65,9 +65,9 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^11.0.3", "@metamask/gas-fee-controller": "^23.0.0", - "@metamask/keyring-controller": "^21.0.6", - "@metamask/network-controller": "^23.4.0", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/keyring-controller": "^22.0.0", + "@metamask/network-controller": "^23.5.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -80,9 +80,9 @@ "@metamask/approval-controller": "^7.0.0", "@metamask/eth-block-tracker": ">=9", "@metamask/gas-fee-controller": "^23.0.0", - "@metamask/keyring-controller": "^21.0.0", + "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.0.0", - "@metamask/transaction-controller": "^55.0.0" + "@metamask/transaction-controller": "^56.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/teams.json b/teams.json index dd451cf25f3..3416e1256cf 100644 --- a/teams.json +++ b/teams.json @@ -46,5 +46,6 @@ "metamask/multichain-transactions-controller": "team-sol,team-accounts", "metamask/token-search-discovery-controller": "team-portfolio", "metamask/earn-controller": "team-earn", + "metamask/error-reporting-service": "team-wallet-framework", "metamask/seedless-onboarding-controller": "team-web3auth" } diff --git a/tsconfig.build.json b/tsconfig.build.json index d81b46ef27c..37362ebeb58 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./packages/earn-controller/tsconfig.build.json" }, { "path": "./packages/eip1193-permission-middleware/tsconfig.build.json" }, { "path": "./packages/ens-controller/tsconfig.build.json" }, + { "path": "./packages/error-reporting-service/tsconfig.build.json" }, { "path": "./packages/eth-json-rpc-provider/tsconfig.build.json" }, { "path": "./packages/gas-fee-controller/tsconfig.build.json" }, { "path": "./packages/json-rpc-engine/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 1742b9e932b..7060f538193 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ { "path": "./packages/earn-controller" }, { "path": "./packages/eip1193-permission-middleware" }, { "path": "./packages/ens-controller" }, + { "path": "./packages/error-reporting-service" }, { "path": "./packages/eth-json-rpc-provider" }, { "path": "./packages/gas-fee-controller" }, { "path": "./packages/json-rpc-engine" }, diff --git a/yarn.lock b/yarn.lock index 2deec67d370..a0039559501 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2427,7 +2427,7 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^28.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^29.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: @@ -2436,10 +2436,10 @@ __metadata: "@metamask/base-controller": "npm:^8.0.1" "@metamask/eth-snap-keyring": "npm:^12.1.1" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/keyring-internal-api": "npm:^6.0.1" "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/providers": "npm:^21.0.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/snaps-sdk": "npm:^6.22.0" @@ -2458,7 +2458,7 @@ __metadata: uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/providers": ^21.0.0 "@metamask/snaps-controllers": ^11.0.0 @@ -2483,7 +2483,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -2511,10 +2511,10 @@ __metadata: languageName: unknown linkType: soft -"@metamask/api-specs@npm:^0.10.12": - version: 0.10.12 - resolution: "@metamask/api-specs@npm:0.10.12" - checksum: 10/e592f27f350994688d3d54a8a8db16de033011ef665efe3283a77431914d8d69d1c3312fad33e4245b4984e1223b04c98da3d0a68c7f9577cf8290ba441c52ee +"@metamask/api-specs@npm:^0.14.0": + version: 0.14.0 + resolution: "@metamask/api-specs@npm:0.14.0" + checksum: 10/6caad5e233c12b87f25313fe1e0fb35af6ad9f0ef49e105b36a1826bd8b611a9335642920ddb6c556343375db4b02138a32598b7185392e50050ae7f390e0e7d languageName: node linkType: hard @@ -2555,7 +2555,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^62.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^65.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2567,29 +2567,30 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/keyring-internal-api": "npm:^6.0.1" "@metamask/keyring-snap-client": "npm:^4.1.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/permission-controller": "npm:^11.0.6" + "@metamask/phishing-controller": "npm:^12.5.0" "@metamask/polling-controller": "npm:^13.0.0" - "@metamask/preferences-controller": "npm:^17.0.0" + "@metamask/preferences-controller": "npm:^18.1.0" "@metamask/providers": "npm:^21.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/snaps-sdk": "npm:^6.22.0" "@metamask/snaps-utils": "npm:^9.2.0" - "@metamask/transaction-controller": "npm:^55.0.1" + "@metamask/transaction-controller": "npm:^56.2.0" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2615,15 +2616,16 @@ __metadata: uuid: "npm:^8.3.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/permission-controller": ^11.0.0 - "@metamask/preferences-controller": ^17.0.0 + "@metamask/phishing-controller": ^12.5.0 + "@metamask/preferences-controller": ^18.0.0 "@metamask/providers": ^21.0.0 "@metamask/snaps-controllers": ^11.0.0 - "@metamask/transaction-controller": ^55.0.0 + "@metamask/transaction-controller": ^56.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 languageName: unknown linkType: soft @@ -2706,7 +2708,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^22.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^28.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2715,22 +2717,22 @@ __metadata: "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^28.0.0" - "@metamask/assets-controllers": "npm:^62.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" + "@metamask/assets-controllers": "npm:^65.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-json-rpc-provider": "npm:^4.1.8" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^0.6.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/multichain-network-controller": "npm:^0.7.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/polling-controller": "npm:^13.0.0" "@metamask/remote-feature-flag-controller": "npm:^1.6.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^55.0.1" + "@metamask/transaction-controller": "npm:^56.2.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -2745,12 +2747,12 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 - "@metamask/assets-controllers": ^62.0.0 + "@metamask/accounts-controller": ^29.0.0 + "@metamask/assets-controllers": ^65.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/remote-feature-flag-controller": ^1.6.0 "@metamask/snaps-controllers": ^11.0.0 - "@metamask/transaction-controller": ^55.0.0 + "@metamask/transaction-controller": ^56.0.0 languageName: unknown linkType: soft @@ -2758,19 +2760,20 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/bridge-controller": "npm:^22.0.0" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/bridge-controller": "npm:^28.0.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/multichain-transactions-controller": "npm:^1.0.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/polling-controller": "npm:^13.0.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^55.0.1" - "@metamask/user-operation-controller": "npm:^34.0.0" + "@metamask/transaction-controller": "npm:^56.2.0" + "@metamask/user-operation-controller": "npm:^35.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -2785,12 +2788,13 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 - "@metamask/bridge-controller": ^22.0.0 + "@metamask/accounts-controller": ^29.0.0 + "@metamask/bridge-controller": ^28.0.0 "@metamask/gas-fee-controller": ^23.0.0 + "@metamask/multichain-transactions-controller": ^1.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/snaps-controllers": ^11.0.0 - "@metamask/transaction-controller": ^55.0.0 + "@metamask/transaction-controller": ^56.0.0 languageName: unknown linkType: soft @@ -2820,15 +2824,15 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chain-agnostic-permission@npm:^0.6.0, @metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission": +"@metamask/chain-agnostic-permission@npm:^0.7.0, @metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission": version: 0.0.0-use.local resolution: "@metamask/chain-agnostic-permission@workspace:packages/chain-agnostic-permission" dependencies: - "@metamask/api-specs": "npm:^0.10.12" + "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/keyring-internal-api": "npm:^6.0.1" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.2.0" @@ -2869,7 +2873,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.8.0, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^11.9.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -2990,10 +2994,10 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" dependencies: - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/utils": "npm:^11.2.0" "@ts-bridge/cli": "npm:^0.6.1" "@types/jest": "npm:^27.4.1" @@ -3004,8 +3008,8 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 - "@metamask/keyring-controller": ^21.0.2 + "@metamask/accounts-controller": ^29.0.0 + "@metamask/keyring-controller": ^22.0.0 languageName: unknown linkType: soft @@ -3014,13 +3018,13 @@ __metadata: resolution: "@metamask/earn-controller@workspace:packages/earn-controller" dependencies: "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/stake-sdk": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^55.0.1" + "@metamask/transaction-controller": "npm:^56.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3029,7 +3033,7 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/network-controller": ^23.0.0 languageName: unknown linkType: soft @@ -3039,8 +3043,8 @@ __metadata: resolution: "@metamask/eip1193-permission-middleware@workspace:packages/eip1193-permission-middleware" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/chain-agnostic-permission": "npm:^0.6.0" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/chain-agnostic-permission": "npm:^0.7.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" @@ -3063,8 +3067,8 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -3079,6 +3083,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/error-reporting-service@npm:^0.0.0, @metamask/error-reporting-service@workspace:packages/error-reporting-service": + version: 0.0.0-use.local + resolution: "@metamask/error-reporting-service@workspace:packages/error-reporting-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.1" + "@sentry/core": "npm:^9.22.0" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/eslint-config-jest@npm:^14.0.0": version: 14.0.0 resolution: "@metamask/eslint-config-jest@npm:14.0.0" @@ -3182,15 +3203,15 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-infura@npm:^10.1.1": - version: 10.1.1 - resolution: "@metamask/eth-json-rpc-infura@npm:10.1.1" +"@metamask/eth-json-rpc-infura@npm:^10.2.0": + version: 10.2.0 + resolution: "@metamask/eth-json-rpc-infura@npm:10.2.0" dependencies: "@metamask/eth-json-rpc-provider": "npm:^4.1.7" "@metamask/json-rpc-engine": "npm:^10.0.2" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.0.1" - checksum: 10/24296fd6d2dca4b9bda2692590eafcecc7318c2d2acf0b5a2e3f3670ffbe7ff0c6338779b8b31a69060cc7963e98d9bf354e3c0f43683371f1f2e9c7642dc763 + checksum: 10/f3e2ac8f8657259978923bdb08cee660ae8e1f6a3f2a67c9e8b93a55030c42b0a8ba45e9321dd6d52f7a4309d1c4241745c2c292d6be0596dd4954ac38d586f6 languageName: node linkType: hard @@ -3448,10 +3469,10 @@ __metadata: "@babel/runtime": "npm:^7.23.9" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/polling-controller": "npm:^13.0.0" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" @@ -3543,7 +3564,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^21.0.6, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^22.0.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -3640,7 +3661,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3658,7 +3679,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" @@ -3685,14 +3706,14 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware" dependencies: - "@metamask/api-specs": "npm:^0.10.12" + "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/chain-agnostic-permission": "npm:^0.6.0" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/chain-agnostic-permission": "npm:^0.7.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/json-rpc-engine": "npm:^10.0.3" - "@metamask/multichain-transactions-controller": "npm:^0.10.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/multichain-transactions-controller": "npm:^1.0.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -3710,18 +3731,18 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-network-controller@npm:^0.6.0, @metamask/multichain-network-controller@workspace:packages/multichain-network-controller": +"@metamask/multichain-network-controller@npm:^0.7.0, @metamask/multichain-network-controller@workspace:packages/multichain-network-controller": version: 0.0.0-use.local resolution: "@metamask/multichain-network-controller@workspace:packages/multichain-network-controller" dependencies: - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/keyring-internal-api": "npm:^6.0.1" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.2.0" "@solana/addresses": "npm:^2.0.0" @@ -3738,20 +3759,20 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/network-controller": ^23.0.0 languageName: unknown linkType: soft -"@metamask/multichain-transactions-controller@npm:^0.10.0, @metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller": +"@metamask/multichain-transactions-controller@npm:^1.0.0, @metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller": version: 0.0.0-use.local resolution: "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller" dependencies: - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/keyring-internal-api": "npm:^6.0.1" "@metamask/keyring-snap-client": "npm:^4.1.0" "@metamask/polling-controller": "npm:^13.0.0" @@ -3770,7 +3791,7 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/snaps-controllers": ^11.0.0 languageName: unknown linkType: soft @@ -3779,12 +3800,12 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain@workspace:packages/multichain" dependencies: - "@metamask/api-specs": "npm:^0.10.12" + "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/json-rpc-engine": "npm:^10.0.3" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -3812,7 +3833,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" async-mutex: "npm:^0.5.0" @@ -3825,16 +3846,17 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^23.4.0, @metamask/network-controller@workspace:packages/network-controller": +"@metamask/network-controller@npm:^23.5.0, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: "@json-rpc-specification/meta-schema": "npm:^1.0.6" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/error-reporting-service": "npm:^0.0.0" "@metamask/eth-block-tracker": "npm:^11.0.3" - "@metamask/eth-json-rpc-infura": "npm:^10.1.1" + "@metamask/eth-json-rpc-infura": "npm:^10.2.0" "@metamask/eth-json-rpc-middleware": "npm:^16.0.1" "@metamask/eth-json-rpc-provider": "npm:^4.1.8" "@metamask/eth-query": "npm:^4.0.0" @@ -3888,9 +3910,9 @@ __metadata: "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/keyring-controller": "npm:^21.0.6" - "@metamask/profile-sync-controller": "npm:^13.0.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/keyring-controller": "npm:^22.0.0" + "@metamask/profile-sync-controller": "npm:^15.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" "@types/readable-stream": "npm:^2.3.0" @@ -3908,8 +3930,8 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/keyring-controller": ^21.0.0 - "@metamask/profile-sync-controller": ^13.0.0 + "@metamask/keyring-controller": ^22.0.0 + "@metamask/profile-sync-controller": ^15.0.0 languageName: unknown linkType: soft @@ -3950,7 +3972,7 @@ __metadata: "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.2.0" @@ -3991,13 +4013,13 @@ __metadata: languageName: unknown linkType: soft -"@metamask/phishing-controller@npm:^12.4.1, @metamask/phishing-controller@workspace:packages/phishing-controller": +"@metamask/phishing-controller@npm:^12.4.1, @metamask/phishing-controller@npm:^12.5.0, @metamask/phishing-controller@workspace:packages/phishing-controller": version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@noble/hashes": "npm:^1.4.0" "@types/jest": "npm:^27.4.1" "@types/punycode": "npm:^2.1.0" @@ -4021,8 +4043,8 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" "@types/uuid": "npm:^8.3.0" @@ -4050,14 +4072,14 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^17.0.0, @metamask/preferences-controller@workspace:packages/preferences-controller": +"@metamask/preferences-controller@npm:^18.1.0, @metamask/preferences-controller@workspace:packages/preferences-controller": version: 0.0.0-use.local resolution: "@metamask/preferences-controller@workspace:packages/preferences-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/keyring-controller": "npm:^22.0.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -4067,23 +4089,23 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^13.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": +"@metamask/profile-sync-controller@npm:^15.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/keyring-internal-api": "npm:^6.0.1" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/providers": "npm:^21.0.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/snaps-sdk": "npm:^6.22.0" @@ -4105,8 +4127,8 @@ __metadata: typescript: "npm:~5.2.2" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 - "@metamask/keyring-controller": ^21.0.0 + "@metamask/accounts-controller": ^29.0.0 + "@metamask/keyring-controller": ^22.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/providers": ^21.0.0 "@metamask/snaps-controllers": ^11.0.0 @@ -4141,11 +4163,11 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/json-rpc-engine": "npm:^10.0.3" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/selected-network-controller": "npm:^22.0.0" + "@metamask/selected-network-controller": "npm:^22.1.0" "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" @@ -4190,7 +4212,7 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -4227,8 +4249,8 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/controller-utils": "npm:^11.9.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -4263,7 +4285,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/browser-passworder": "npm:^4.3.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/toprf-secure-backup": "npm:^0.3.0" "@metamask/utils": "npm:^11.2.0" "@noble/ciphers": "npm:^0.5.2" @@ -4281,18 +4303,18 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" peerDependencies: - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 languageName: unknown linkType: soft -"@metamask/selected-network-controller@npm:^22.0.0, @metamask/selected-network-controller@workspace:packages/selected-network-controller": +"@metamask/selected-network-controller@npm:^22.1.0, @metamask/selected-network-controller@workspace:packages/selected-network-controller": version: 0.0.0-use.local resolution: "@metamask/selected-network-controller@workspace:packages/selected-network-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/json-rpc-engine": "npm:^10.0.3" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.2.0" @@ -4317,15 +4339,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-controller": "npm:^21.0.6" + "@metamask/keyring-controller": "npm:^22.0.0" "@metamask/logging-controller": "npm:^6.0.4" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -4338,9 +4360,9 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 "@metamask/logging-controller": ^6.0.0 "@metamask/network-controller": ^23.0.0 languageName: unknown @@ -4525,7 +4547,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^55.0.1, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^56.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -4537,18 +4559,18 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^28.0.0" + "@metamask/accounts-controller": "npm:^29.0.0" "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-block-tracker": "npm:^11.0.3" "@metamask/eth-json-rpc-provider": "npm:^4.1.8" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/nonce-tracker": "npm:^6.0.0" "@metamask/remote-feature-flag-controller": "npm:^1.6.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -4573,7 +4595,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - "@metamask/accounts-controller": ^28.0.0 + "@metamask/accounts-controller": ^29.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/eth-block-tracker": ">=9" "@metamask/gas-fee-controller": ^23.0.0 @@ -4582,23 +4604,23 @@ __metadata: languageName: unknown linkType: soft -"@metamask/user-operation-controller@npm:^34.0.0, @metamask/user-operation-controller@workspace:packages/user-operation-controller": +"@metamask/user-operation-controller@npm:^35.0.0, @metamask/user-operation-controller@workspace:packages/user-operation-controller": version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller" dependencies: "@metamask/approval-controller": "npm:^7.1.3" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.8.0" + "@metamask/controller-utils": "npm:^11.9.0" "@metamask/eth-block-tracker": "npm:^11.0.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/gas-fee-controller": "npm:^23.0.0" - "@metamask/keyring-controller": "npm:^21.0.6" - "@metamask/network-controller": "npm:^23.4.0" + "@metamask/keyring-controller": "npm:^22.0.0" + "@metamask/network-controller": "npm:^23.5.0" "@metamask/polling-controller": "npm:^13.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^55.0.1" + "@metamask/transaction-controller": "npm:^56.2.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bn.js: "npm:^5.2.1" @@ -4615,9 +4637,9 @@ __metadata: "@metamask/approval-controller": ^7.0.0 "@metamask/eth-block-tracker": ">=9" "@metamask/gas-fee-controller": ^23.0.0 - "@metamask/keyring-controller": ^21.0.0 + "@metamask/keyring-controller": ^22.0.0 "@metamask/network-controller": ^23.0.0 - "@metamask/transaction-controller": ^55.0.0 + "@metamask/transaction-controller": ^56.0.0 languageName: unknown linkType: soft @@ -5021,10 +5043,10 @@ __metadata: languageName: node linkType: hard -"@sentry/core@npm:^9.10.0": - version: 9.14.0 - resolution: "@sentry/core@npm:9.14.0" - checksum: 10/ade3f5248ac7d823d44fa632d1387dfca7818db0bc55281df177728f5d6920135657b3a0bbe2d62877c6a779bbbc2bb1084950fb9f020176171f64659b9840ca +"@sentry/core@npm:^9.10.0, @sentry/core@npm:^9.22.0": + version: 9.23.0 + resolution: "@sentry/core@npm:9.23.0" + checksum: 10/4ee771098d4ce4f4d2f7bd62cacb41ee2993780f4cab0eea600e73de3a3803cb953ac47ac015c23bcd7a9919e2220fd6cdc5a9a22a3663440296336d8df959b7 languageName: node linkType: hard