From cce452ffdbd2950642fb18c4370bfff809ddc277 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Tue, 13 May 2025 11:09:55 +0200 Subject: [PATCH 01/82] fix: remove metadata for unsupported keyrings (#5725) ## Explanation When the user vault is decrypted and there is an attempt to restore an unsupported/deprecated/faulty keyring there's no mechanism to remove related metadata, which leads to a situation where no further action can be made on the controller, because checks for keyrings and metadata length will fail. We could remove the related metadata object when the keyring restore fails, but then we would lose the original ID generated for the keyring. We can, instead, change the place where the metadata is stored from a state property to the encrypted vault: by placing the metadata along with its serialised keyring in the vault we can guarantee a 1:1 link between them while being able to keep metadata for unsupported keyrings. Given that we don't need to use the KeyringController state to persist metadata anymore (as it is persisted along with the vault), we can also remove `keyringsMetadata` completely, and add a `metadata` attribute to each keyring in `state.keyrings` instead - which won't be persisted, as it will be recreated at runtime every time the vault is decrypted and the keyrings are deserialised. ## References * Fixes https://github.com/MetaMask/core/issues/5701 ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Mark Stacey --- eslint-warning-thresholds.json | 3 - .../src/AccountsController.test.ts | 335 +++++++------- .../src/AccountsController.ts | 6 +- packages/keyring-controller/CHANGELOG.md | 6 + packages/keyring-controller/jest.config.js | 6 +- .../src/KeyringController.test.ts | 415 +++++++++++++++--- .../src/KeyringController.ts | 214 +++++---- packages/keyring-controller/src/constants.ts | 1 - .../NotificationServicesController.test.ts | 26 +- .../src/PreferencesController.test.ts | 71 ++- .../UserStorageController.test.ts | 2 - .../__fixtures__/mockMessenger.ts | 1 - 12 files changed, 730 insertions(+), 356 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index e7eb5143ae5..feceaddb4ed 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -437,9 +437,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/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 8a5fd21cbca..d0241c9be5e 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -501,12 +501,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 +532,7 @@ describe('AccountsController', () => { messenger.publish( 'KeyringController:stateChange', - { isUnlocked: true, keyrings: [], keyringsMetadata: [] }, + { isUnlocked: true, keyrings: [] }, [], ); @@ -553,12 +551,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 +591,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', + }, }, ], }; @@ -654,20 +648,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 +723,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 +782,10 @@ describe('AccountsController', () => { { type: KeyringTypes.hd, accounts: [mockAccount.address], + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, { type: KeyringTypes.snap, @@ -799,16 +793,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 +839,10 @@ describe('AccountsController', () => { mockAccount2.address, mockAccount3.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -919,12 +905,10 @@ describe('AccountsController', () => { mockAccount2.address, mockAccount3.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -982,20 +966,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 +1014,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', + }, }, ], }; @@ -1093,12 +1073,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', + }, }, ], }; @@ -1132,12 +1110,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 +1155,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 +1219,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 +1296,10 @@ describe('AccountsController', () => { mockAccountWithoutLastSelected.address, mockAccount2.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1400,12 +1370,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', + }, }, ], }; @@ -1452,12 +1420,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 +1520,10 @@ describe('AccountsController', () => { mockExistingAccount1.address, mockExistingAccount2.address, ], - }, - ], - keyringsMetadata: [ - { - id: 'mock-id', - name: 'mock-name', + metadata: { + id: 'mock-id', + name: 'mock-name', + }, }, ], }; @@ -1799,12 +1763,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 +1828,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 +1925,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 +1999,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 +2071,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 +2140,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 +2287,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 +3055,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..7bf59e80e65 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. diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index d6def954e41..fdd56be0a67 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [21.0.6] ### Changed diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index d8355e87e91..0d930e38202 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.25, functions: 100, - lines: 98.92, - statements: 98.93, + lines: 98.79, + statements: 98.8, }, }, diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 26f09defe41..97be3333362 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,46 +2745,150 @@ describe('KeyringController', () => { data: { accounts: ['0x123'], }, + metadata: { + id: '123', + name: '', + }, }, ]); await controller.submitPassword(password); - expect(controller.state.keyringsMetadata).toHaveLength(1); - }, - ); - }); - - it('should throw an error when the controller is instantiated with an existing `keyringsMetadata` with too many objects', async () => { - await withController( - { - cacheEncryptionKey, - state: { - keyringsMetadata: [ - { id: '123', name: '' }, - { id: '456', name: '' }, - ], - vault: 'my vault', - }, - skipVaultCreation: true, - }, - async ({ controller, encryptor }) => { - jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + expect(controller.state.keyrings).toStrictEqual([ { type: KeyringTypes.hd, - data: { - accounts: ['0x123'], + accounts: ['0x123'], + metadata: { + id: '123', + name: '', }, }, ]); - - await expect(controller.submitPassword(password)).rejects.toThrow( - KeyringControllerError.KeyringMetadataLengthMismatch, - ); }, ); }); + 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. @@ -2750,6 +2951,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 +3005,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 +3122,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 +3239,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 +3274,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 +3382,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 +3520,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 +3562,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 +3577,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 +3585,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..b4147ae26b2 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,22 +2414,18 @@ 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); } /** @@ -2413,7 +2438,7 @@ export class KeyringController extends BaseController< const keyrings = this.#keyrings; const keyringArrays = await Promise.all( - keyrings.map(async (keyring) => keyring.getAccounts()), + keyrings.map(async ({ keyring }) => keyring.getAccounts()), ); const addresses = keyringArrays.reduce((res, arr) => { return res.concat(arr); @@ -2460,11 +2485,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 +2557,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 +2573,30 @@ 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); // 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,26 +2625,24 @@ 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; } /** @@ -2642,11 +2669,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 +2722,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/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/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 30ff3ea64e8..002d5adefbe 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: { @@ -69,6 +72,10 @@ describe('PreferencesController', () => { { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, ], }, @@ -111,7 +118,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -141,7 +157,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -170,7 +195,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: [], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: [], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); @@ -203,6 +237,10 @@ describe('PreferencesController', () => { { accounts: ['0x00', '0x01', '0x02'], type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, }, ], }, @@ -237,10 +275,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 +317,16 @@ describe('PreferencesController', () => { 'KeyringController:stateChange', { ...getDefaultKeyringState(), - keyrings: [{ accounts: ['0x00', '0x01'], type: 'CustomKeyring' }], + keyrings: [ + { + accounts: ['0x00', '0x01'], + type: 'CustomKeyring', + metadata: { + id: 'mock-id', + name: '', + }, + }, + ], }, [], ); 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(); From 42e8f5cccc3a763af066fd89e361684907294569 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 13 May 2025 11:48:05 +0200 Subject: [PATCH 02/82] fix: misplaced changelog entry for `@metamask/profile-sync-controller` (#5788) ## Explanation This PR moves a changelog entry from **13.0.0** to **Unreleased** for `@metamask/profile-sync-controller`. This entry was mistakenly placed in an already released version's changelog. ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/profile-sync-controller/CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index bf6e1148b01..b110058a6a0 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [13.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` + +## [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)) From 2136c32e0a381c2f8a96cf8bff125346bc584bb6 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 13 May 2025 13:03:37 +0200 Subject: [PATCH 03/82] Release 393.0.0 (#5789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This is a RC for v393.0.0. See changelog for more details - `@metamask/profile-sync-controller@14.0.0` ## References Instructions for client migration are in these test drive PRs: - ✅ Extension test drive PR: https://github.com/MetaMask/metamask-extension/pull/32572 - ✅ Mobile test drive PR: https://github.com/MetaMask/metamask-mobile/pull/15211 ## Changelog ```ms ### Changed - Bump `@metamask/profile-sync-controller` from `^13.0.0` to `^14.0.0` ([#5789](https://github.com/MetaMask/core/pull/5789)) ``` ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/notification-services-controller/CHANGELOG.md | 2 ++ packages/notification-services-controller/package.json | 4 ++-- packages/profile-sync-controller/CHANGELOG.md | 9 ++++++++- packages/profile-sync-controller/package.json | 2 +- yarn.lock | 6 +++--- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index b9f75c1df6f..405e47a1f32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "392.0.0", + "version": "393.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 05fa1da69c5..a022d672ae0 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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)) diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index fe24169af4c..8265b598a22 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -123,7 +123,7 @@ "@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/profile-sync-controller": "^14.0.0", "@types/jest": "^27.4.1", "@types/readable-stream": "^2.3.0", "contentful": "^10.15.0", @@ -138,7 +138,7 @@ }, "peerDependencies": { "@metamask/keyring-controller": "^21.0.0", - "@metamask/profile-sync-controller": "^13.0.0" + "@metamask/profile-sync-controller": "^14.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index b110058a6a0..276d196210b 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -580,7 +586,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/profile-sync-controller@13.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-sync-controller@14.0.0...HEAD +[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..cc85a94cb4f 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": "14.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", diff --git a/yarn.lock b/yarn.lock index a8f68979192..ca8679c48bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3873,7 +3873,7 @@ __metadata: "@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/profile-sync-controller": "npm:^14.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" "@types/readable-stream": "npm:^2.3.0" @@ -3892,7 +3892,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/keyring-controller": ^21.0.0 - "@metamask/profile-sync-controller": ^13.0.0 + "@metamask/profile-sync-controller": ^14.0.0 languageName: unknown linkType: soft @@ -4054,7 +4054,7 @@ __metadata: 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:^14.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: From a0478cbce73f05bf3178129c467421807374c3e2 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 13 May 2025 14:00:42 +0100 Subject: [PATCH 04/82] fix: use gas limit from simulation response (#5790) ## Explanation When simulating gas for type-4 transactions, use `gasLimit` rather than `gasUsed` from simulation response. ## References ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 ++++ packages/transaction-controller/src/api/simulation-api.ts | 5 ++++- packages/transaction-controller/src/utils/gas.test.ts | 2 +- packages/transaction-controller/src/utils/gas.ts | 6 +++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index d3851c628d3..2ad5dd3a76f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix type-4 gas estimation ([#5790](https://github.com/MetaMask/core/pull/5790)) + ## [55.0.1] ### Changed 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/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; } /** From 3cfff65a9eb2caf0fb994ccd26c7be1401960c75 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 13 May 2025 14:45:11 +0100 Subject: [PATCH 05/82] Release 394.0.0 (#5791) Patch release of `@metamask/transaction-controller`. --- package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/package.json | 2 +- packages/earn-controller/package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 5 ++++- packages/transaction-controller/package.json | 2 +- packages/user-operation-controller/package.json | 2 +- yarn.lock | 12 ++++++------ 9 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 405e47a1f32..49e689f06ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "393.0.0", + "version": "394.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 66ee77859e9..e1aeae54acd 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -90,7 +90,7 @@ "@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": "^55.0.2", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 8ff4709d9d0..7a816995332 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -72,7 +72,7 @@ "@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": "^55.0.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index a66ab2314fe..8986fbfd5f3 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -64,7 +64,7 @@ "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/snaps-controllers": "^11.2.1", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/transaction-controller": "^55.0.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index 047dea9fd83..27cc9690c0c 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -56,7 +56,7 @@ "@metamask/accounts-controller": "^28.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/network-controller": "^23.4.0", - "@metamask/transaction-controller": "^55.0.1", + "@metamask/transaction-controller": "^55.0.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2ad5dd3a76f..280d870faee 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [55.0.2] + ### Fixed - Fix type-4 gas estimation ([#5790](https://github.com/MetaMask/core/pull/5790)) @@ -1575,7 +1577,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/transaction-controller@55.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@55.0.2...HEAD +[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..2afe4029fa0 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": "55.0.2", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 891b85a97d9..6975c4cd5fe 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -67,7 +67,7 @@ "@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/transaction-controller": "^55.0.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/yarn.lock b/yarn.lock index ca8679c48bc..4ea532c3111 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,7 +2589,7 @@ __metadata: "@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:^55.0.2" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2713,7 +2713,7 @@ __metadata: "@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:^55.0.2" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -2752,7 +2752,7 @@ __metadata: "@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/transaction-controller": "npm:^55.0.2" "@metamask/user-operation-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" @@ -3003,7 +3003,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.8.0" "@metamask/network-controller": "npm:^23.4.0" "@metamask/stake-sdk": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^55.0.1" + "@metamask/transaction-controller": "npm:^55.0.2" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -4458,7 +4458,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/transaction-controller@npm:^55.0.1, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^55.0.2, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -4531,7 +4531,7 @@ __metadata: "@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:^55.0.2" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bn.js: "npm:^5.2.1" From 9e466284b186a5fbf7c1fd640ca2586cc8826722 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 13 May 2025 10:01:10 -0700 Subject: [PATCH 06/82] Release/395.0.0 (#5795) ## Explanation Releasing new versions of @metamask/bridge-controller and @metamask/bridge-status-controller to rename `bridgePriceData` to `priceData` ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 5 ++++- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/CHANGELOG.md | 6 +++++- packages/bridge-status-controller/package.json | 6 +++--- yarn.lock | 6 +++--- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 49e689f06ba..980184c796f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "394.0.0", + "version": "395.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 7bce89cec14..7fb4789f46b 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [23.0.0] + ### Changed - **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) @@ -215,7 +217,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@23.0.0...HEAD +[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 7a816995332..a9ef0fd99f4 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": "23.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 53cfc79a02b..ff5cab99e65 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +208,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@19.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.0.0...HEAD +[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 8986fbfd5f3..80bd55722b5 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": "20.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^28.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^22.0.0", + "@metamask/bridge-controller": "^23.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/snaps-controllers": "^11.2.1", @@ -78,7 +78,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^28.0.0", - "@metamask/bridge-controller": "^22.0.0", + "@metamask/bridge-controller": "^23.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/snaps-controllers": "^11.0.0", diff --git a/yarn.lock b/yarn.lock index 4ea532c3111..6849984972a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^22.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^23.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2744,7 +2744,7 @@ __metadata: "@metamask/accounts-controller": "npm:^28.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/bridge-controller": "npm:^22.0.0" + "@metamask/bridge-controller": "npm:^23.0.0" "@metamask/controller-utils": "npm:^11.8.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" @@ -2769,7 +2769,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^28.0.0 - "@metamask/bridge-controller": ^22.0.0 + "@metamask/bridge-controller": ^23.0.0 "@metamask/gas-fee-controller": ^23.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/snaps-controllers": ^11.0.0 From 5107e96d4e4298dce81703a53d1d6a8cf01b1913 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 13 May 2025 12:28:23 -0700 Subject: [PATCH 07/82] feat: trace Bridge transactions and quote fetching (#5768) ## Explanation Draft integration for extension: https://github.com/MetaMask/metamask-extension/pull/32722 Sentry Dashboard: https://metamask.sentry.io/dashboard/131851/?statsPeriod=1d ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 7 + .../src/bridge-controller.ts | 29 +++- .../bridge-controller/src/constants/traces.ts | 4 + packages/bridge-controller/src/index.ts | 2 +- .../src/utils/bridge.test.ts | 18 +++ .../bridge-controller/src/utils/bridge.ts | 27 +++- .../src/utils/metrics/properties.ts | 14 +- .../bridge-status-controller/CHANGELOG.md | 8 ++ .../bridge-status-controller.test.ts.snap | 25 ++++ .../src/bridge-status-controller.test.ts | 14 +- .../src/bridge-status-controller.ts | 124 ++++++++++++++---- .../bridge-status-controller/src/constants.ts | 7 + 12 files changed, 236 insertions(+), 43 deletions(-) create mode 100644 packages/bridge-controller/src/constants/traces.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 7fb4789f46b..1e2c62fbaa7 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,11 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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)) + ## [23.0.0] ### Changed +- **BREAKING:** Remove `BridgeToken` export ([#5768](https://github.com/MetaMask/core/pull/5768)) - **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) +- `traceFn` added to BridgeController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) ## [22.0.0] diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index f38c030bce1..c3caf0f1c5a 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( @@ -453,7 +460,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,6 +480,24 @@ export class BridgeController extends StaticIntervalPollingController { 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/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-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index ff5cab99e65..9c79b00adda 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 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..df881b505fc 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 @@ -532,6 +532,31 @@ Array [ ] `; +exports[`BridgeStatusController submitTx: EVM 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 should handle smart accounts (4337) 1`] = ` Object { "chainId": "0xa4b1", 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..a93f593c5a0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -523,7 +523,7 @@ 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, @@ -536,6 +536,7 @@ const getController = (call: jest.Mock) => { addTransactionFn, estimateGasFeeFn, addUserOperationFromTransactionFn, + traceFn, }); jest.spyOn(controller, 'startPolling').mockImplementation(jest.fn()); @@ -1837,12 +1838,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 +1859,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 +1873,7 @@ describe('BridgeStatusController', () => { 1234567890, ); expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.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..28586cf788d 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( @@ -528,24 +537,44 @@ 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( + TransactionType.bridgeApproval, + 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; }; @@ -731,13 +760,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 +798,49 @@ export class BridgeStatusController extends StaticIntervalPollingController + await this.#handleEvmSmartTransaction( + 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', +} From 8734724802b77c671a76ebd7091daa17c9d3bac1 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 13 May 2025 12:58:53 -0700 Subject: [PATCH 08/82] fix: cancelled bridge quote request handling (#5787) ## Explanation When the quote request polling is cancelled, the quote request metadata fields in state don't get reset, which can cause polling to stop prematurely on clients. ## References Fixes https://github.com/MetaMask/metamask-extension/issues/32800 Related to https://consensyssoftware.atlassian.net/browse/MMS-2435 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 4 + .../bridge-controller.test.ts.snap | 120 +++++++++++ .../src/bridge-controller.test.ts | 191 ++++++++++-------- .../src/bridge-controller.ts | 46 ++--- .../bridge-controller/src/utils/quote.test.ts | 38 ++-- 5 files changed, 276 insertions(+), 123 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 1e2c62fbaa7..2a9cb50051f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) - `traceFn` added to BridgeController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) +### Fixed + +- Handle cancelled bridge quote polling gracefully by skipping state updates ([#5787](https://github.com/MetaMask/core/pull/5787)) + ## [22.0.0] ### Changed 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..d832b88b49b 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, [ @@ -390,6 +321,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 +446,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 +988,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 +998,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 +1045,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 c3caf0f1c5a..f3e5bf6678b 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -446,7 +446,6 @@ export class BridgeController extends StaticIntervalPollingController { - const { quotesInitialLoadTime, quotesRefreshCount } = this.state; this.#abortController?.abort('New quote request'); this.#abortController = new AbortController(); @@ -502,6 +501,7 @@ 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/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 0796c7596eb..d08dd1bfc7b 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -160,27 +160,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 +194,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', From 316b3593cff12810b9fefdbf03a85f4d09256a02 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 13 May 2025 13:28:42 -0700 Subject: [PATCH 09/82] Release/396.0.0 (#5797) ## Explanation Releasing these package versions to enable performance tracing functionality - @metamask/bridge-controller @ 24.0.0 - @metamask/bridge-status-controller @ 20.1.0 Draft PR for extension: https://github.com/MetaMask/metamask-extension/pull/32722 ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 12 +++++++++--- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/CHANGELOG.md | 5 ++++- packages/bridge-status-controller/package.json | 6 +++--- yarn.lock | 6 +++--- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 980184c796f..0999f1250c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "395.0.0", + "version": "396.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 2a9cb50051f..d218070d3e0 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,18 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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:** Remove `BridgeToken` export ([#5768](https://github.com/MetaMask/core/pull/5768)) - **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) -- `traceFn` added to BridgeController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) ### Fixed @@ -228,7 +233,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@23.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@24.0.0...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index a9ef0fd99f4..cfea8ac303d 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "23.0.0", + "version": "24.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 9c79b00adda..9d21a2697a1 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [20.1.0] + ### Added - Sentry traces for Swap and Bridge `TransactionApprovalCompleted` and `TransactionCompleted` events ([#5780](https://github.com/MetaMask/core/pull/5780)) @@ -216,7 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.1.0...HEAD +[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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 80bd55722b5..9a77c352699 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": "20.0.0", + "version": "20.1.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^28.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^23.0.0", + "@metamask/bridge-controller": "^24.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/snaps-controllers": "^11.2.1", @@ -78,7 +78,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^28.0.0", - "@metamask/bridge-controller": "^23.0.0", + "@metamask/bridge-controller": "^24.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/snaps-controllers": "^11.0.0", diff --git a/yarn.lock b/yarn.lock index 6849984972a..058b837c566 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^23.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^24.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2744,7 +2744,7 @@ __metadata: "@metamask/accounts-controller": "npm:^28.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" - "@metamask/bridge-controller": "npm:^23.0.0" + "@metamask/bridge-controller": "npm:^24.0.0" "@metamask/controller-utils": "npm:^11.8.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" @@ -2769,7 +2769,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^28.0.0 - "@metamask/bridge-controller": ^23.0.0 + "@metamask/bridge-controller": ^24.0.0 "@metamask/gas-fee-controller": ^23.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/snaps-controllers": ^11.0.0 From b1c2e7468f95fc34ff79420a4f58f4236d576f41 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Wed, 14 May 2025 11:00:23 +0200 Subject: [PATCH 10/82] fix: discard duplicate accounts on unlock (#5775) Dependent on: - https://github.com/MetaMask/core/pull/5725 ## Explanation It is no longer possible to persist duplicates in the vault, though users that already have duplicates will see them in the accounts list, and won't be able to do any action with their vault. These changes aim to discard duplicates, moving the keyring including a duplicate account to the unsupported array. Can be tested on extension with https://github.com/MetaMask/metamask-extension/pull/32621 ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Mark Stacey Co-authored-by: Charly Chevalier --- packages/keyring-controller/CHANGELOG.md | 4 ++ packages/keyring-controller/jest.config.js | 2 +- .../src/KeyringController.test.ts | 60 ++++++++++++++++++- .../src/KeyringController.ts | 19 ++++-- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index fdd56be0a67..455e221a893 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 0d930e38202..568a60b2b46 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,7 +17,7 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 94.25, + branches: 94.31, functions: 100, lines: 98.79, statements: 98.8, diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 97be3333362..90bdcfddc66 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -2917,13 +2917,71 @@ describe('KeyringController', () => { await controller.submitPassword(password); - expect(controller.state.keyrings).toHaveLength(2); 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, + data: { + accounts: ['0x123'], + }, + }, + ]); + + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); + }, + ); + }); + + 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'); + await withController( + { + skipVaultCreation: true, + cacheEncryptionKey, + state: { vault: 'my vault' }, + keyringBuilders: [keyringBuilderFactory(MockKeyring)], + }, + async ({ controller, encryptor, messenger }) => { + const unlockListener = jest.fn(); + messenger.subscribe('KeyringController:unlock', unlockListener); + jest.spyOn(encryptor, 'decrypt').mockResolvedValueOnce([ + { + type: KeyringTypes.hd, + data: {}, + }, + { + type: MockKeyring.type, + data: {}, + }, + ]); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(1); // Second keyring will be skipped as "unsupported". + expect(unlockListener).toHaveBeenCalledTimes(1); + }, + ); + }); + cacheEncryptionKey && it('should upgrade the vault encryption if the key encryptor has different parameters', async () => { await withController( diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index b4147ae26b2..e62791a9da7 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -2432,13 +2432,18 @@ export class KeyringController extends BaseController< * 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); @@ -2584,6 +2589,7 @@ export class KeyringController extends BaseController< 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 (!metadata) { @@ -2648,10 +2654,13 @@ export class KeyringController extends BaseController< /** * 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); From 7bf44c898291c65334f384433d899decc2548fb8 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 14 May 2025 13:00:16 +0100 Subject: [PATCH 11/82] feat: add feature flag for incoming transactions polling interval (#5792) ## Explanation Add feature flag to configure incoming transactions polling interval remotely. ## References ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 +++ .../src/TransactionController.ts | 1 + .../helpers/IncomingTransactionHelper.test.ts | 10 +++++++ .../src/helpers/IncomingTransactionHelper.ts | 26 ++++++++++++++----- .../src/utils/feature-flags.test.ts | 23 ++++++++++++++++ .../src/utils/feature-flags.ts | 26 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 280d870faee..0bbab852a3e 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Configure incoming transaction polling interval using feature flag ([#5792](https://github.com/MetaMask/core/pull/5792)) + ## [55.0.2] ### Fixed diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index d1ecfaf1912..f01bcda572b 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -951,6 +951,7 @@ export class TransactionController extends BaseController< includeTokenTransfers: this.#incomingTransactionOptions.includeTokenTransfers, isEnabled: this.#incomingTransactionOptions.isEnabled, + messenger: this.messagingSystem, queryEntireHistory: this.#incomingTransactionOptions.queryEntireHistory, remoteTransactionSource: new AccountsApiRemoteTransactionSource(), trimTransactions: this.#trimTransactionsForState.bind(this), diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 701d48e5cc7..12ffc5147aa 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,7 @@ 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 CONTROLLER_ARGS_MOCK: ConstructorParameters< typeof IncomingTransactionHelper @@ -40,6 +45,7 @@ const CONTROLLER_ARGS_MOCK: ConstructorParameters< }, getCache: () => CACHE_MOCK, getLocalTransactions: () => [], + messenger: MESSENGER_MOCK, remoteTransactionSource: {} as RemoteTransactionSource, trimTransactions: (transactions) => transactions, updateCache: jest.fn(), @@ -122,6 +128,10 @@ describe('IncomingTransactionHelper', () => { jest.resetAllMocks(); jest.clearAllTimers(); jest.setSystemTime(SYSTEM_TIME_MOCK); + + jest + .mocked(getIncomingTransactionsPollingInterval) + .mockReturnValue(1000 * 30); }); describe('on interval', () => { diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 94b065e3b1c..a221744f6db 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -4,8 +4,10 @@ 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 = { includeTokenTransfers?: boolean; @@ -14,8 +16,6 @@ export type IncomingTransactionOptions = { updateTransactions?: boolean; }; -const INTERVAL = 1000 * 30; // 30 Seconds - export class IncomingTransactionHelper { hub: EventEmitter; @@ -33,6 +33,8 @@ export class IncomingTransactionHelper { #isRunning: boolean; + readonly #messenger: TransactionControllerMessenger; + readonly #queryEntireHistory?: boolean; readonly #remoteTransactionSource: RemoteTransactionSource; @@ -53,6 +55,7 @@ export class IncomingTransactionHelper { getLocalTransactions, includeTokenTransfers, isEnabled, + messenger, queryEntireHistory, remoteTransactionSource, trimTransactions, @@ -66,6 +69,7 @@ export class IncomingTransactionHelper { getLocalTransactions: () => TransactionMeta[]; includeTokenTransfers?: boolean; isEnabled?: () => boolean; + messenger: TransactionControllerMessenger; queryEntireHistory?: boolean; remoteTransactionSource: RemoteTransactionSource; trimTransactions: (transactions: TransactionMeta[]) => TransactionMeta[]; @@ -80,6 +84,7 @@ export class IncomingTransactionHelper { this.#includeTokenTransfers = includeTokenTransfers; this.#isEnabled = isEnabled ?? (() => true); this.#isRunning = false; + this.#messenger = messenger; this.#queryEntireHistory = queryEntireHistory; this.#remoteTransactionSource = remoteTransactionSource; this.#trimTransactions = trimTransactions; @@ -96,10 +101,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,8 +134,11 @@ 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(), + ); } } @@ -228,4 +238,8 @@ export class IncomingTransactionHelper { #canStart(): boolean { return this.#isEnabled(); } + + #getInterval(): number { + return getIncomingTransactionsPollingInterval(this.#messenger); + } } 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. * From f5dcbb7da197508068018b862b5149e22142567b Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Wed, 14 May 2025 14:19:41 +0200 Subject: [PATCH 12/82] Release 397.0.0 (#5802) Releasing KeyringController Sev1 fixes. As they are breaking changes, the release inflated across multiple interdependent packages. See changelogs for more info. --- package.json | 2 +- packages/accounts-controller/CHANGELOG.md | 9 +- packages/accounts-controller/package.json | 6 +- packages/assets-controllers/CHANGELOG.md | 12 +- packages/assets-controllers/package.json | 18 +-- packages/bridge-controller/CHANGELOG.md | 11 +- packages/bridge-controller/package.json | 16 +-- .../bridge-status-controller/CHANGELOG.md | 11 +- .../bridge-status-controller/package.json | 16 +-- packages/delegation-controller/CHANGELOG.md | 10 +- packages/delegation-controller/package.json | 10 +- packages/earn-controller/CHANGELOG.md | 6 +- packages/earn-controller/package.json | 8 +- packages/keyring-controller/CHANGELOG.md | 5 +- packages/keyring-controller/package.json | 2 +- .../multichain-api-middleware/package.json | 2 +- .../CHANGELOG.md | 6 +- .../package.json | 8 +- .../CHANGELOG.md | 9 +- .../package.json | 8 +- .../CHANGELOG.md | 7 +- .../package.json | 10 +- packages/preferences-controller/CHANGELOG.md | 6 +- packages/preferences-controller/package.json | 6 +- packages/profile-sync-controller/CHANGELOG.md | 10 +- packages/profile-sync-controller/package.json | 10 +- packages/signature-controller/CHANGELOG.md | 7 +- packages/signature-controller/package.json | 10 +- packages/transaction-controller/CHANGELOG.md | 6 +- packages/transaction-controller/package.json | 6 +- .../user-operation-controller/CHANGELOG.md | 7 +- .../user-operation-controller/package.json | 10 +- yarn.lock | 136 +++++++++--------- 33 files changed, 249 insertions(+), 157 deletions(-) diff --git a/package.json b/package.json index 0999f1250c2..f65cd010125 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "396.0.0", + "version": "397.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..bbcd21227ed 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +534,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..0e9fa51a274 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,7 +63,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-controller": "^21.0.6", + "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.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/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f88d9b503a7..5df8d3530e4 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +1629,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@62.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.0.0...HEAD +[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 e1aeae54acd..5c30935d8b8 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": "63.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", @@ -77,20 +77,20 @@ }, "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/permission-controller": "^11.0.6", - "@metamask/preferences-controller": "^17.0.0", + "@metamask/preferences-controller": "^18.0.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/snaps-sdk": "^6.22.0", - "@metamask/transaction-controller": "^55.0.2", + "@metamask/transaction-controller": "^56.0.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", @@ -106,15 +106,15 @@ "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/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/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d218070d3e0..93908c93b5f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -233,7 +241,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@24.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.0...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index cfea8ac303d..05e6cc7d784 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "24.0.0", + "version": "25.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -57,22 +57,22 @@ "@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": "^63.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/network-controller": "^23.4.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.2", + "@metamask/transaction-controller": "^56.0.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": "^63.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-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 9d21a2697a1..e0e940ba621 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -218,7 +226,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@21.0.0...HEAD +[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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 9a77c352699..0d46f34ff0e 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": "20.1.0", + "version": "21.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -52,19 +52,19 @@ "@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": "^24.0.0", + "@metamask/bridge-controller": "^25.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/snaps-controllers": "^11.2.1", - "@metamask/transaction-controller": "^55.0.2", + "@metamask/transaction-controller": "^56.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -77,12 +77,12 @@ "typescript": "~5.2.2" }, "peerDependencies": { - "@metamask/accounts-controller": "^28.0.0", - "@metamask/bridge-controller": "^24.0.0", + "@metamask/accounts-controller": "^29.0.0", + "@metamask/bridge-controller": "^25.0.0", "@metamask/gas-fee-controller": "^23.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/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..07ca2d704af 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +105,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 27cc9690c0c..62b51571fef 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", @@ -53,10 +53,10 @@ "@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.2", + "@metamask/transaction-controller": "^56.0.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/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 455e221a893..c810d01dc26 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,8 @@ 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)) @@ -780,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/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/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 7a095c9e66b..ea280244b95 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -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": "^0.11.0", "@metamask/safe-event-emitter": "^3.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index a393b6c230b..02fa65e2607 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +92,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..a584b389c11 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", @@ -57,9 +57,9 @@ "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/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.4.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", @@ -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..d2ed4fdcd05 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +124,8 @@ 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@0.11.0...HEAD +[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..b203ea9e980 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": "0.11.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/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index a022d672ae0..ed68710f523 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -416,7 +420,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 8265b598a22..9c31b125ec1 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", @@ -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": "^14.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": "^14.0.0" + "@metamask/keyring-controller": "^22.0.0", + "@metamask/profile-sync-controller": "^15.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index e186f9aed1d..bfd111655a3 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +360,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/preferences-controller@17.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/preferences-controller@18.0.0...HEAD +[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..f5b5529e36e 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.0.0", "description": "Manages user-configurable settings for MetaMask", "keywords": [ "MetaMask", @@ -52,7 +52,7 @@ }, "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/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 276d196210b..fbd330a19e3 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -586,7 +593,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/profile-sync-controller@14.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 diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index cc85a94cb4f..6d0e3f55173 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": "14.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,9 +113,9 @@ "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/providers": "^21.0.0", @@ -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/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index 6b9109cbbc3..87c20b4c883 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +519,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..4a74cae7819 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", @@ -56,10 +56,10 @@ "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", "@types/jest": "^27.4.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/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 0bbab852a3e..b85361d54b0 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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] @@ -1581,7 +1584,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/transaction-controller@55.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@56.0.0...HEAD +[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 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 2afe4029fa0..1481424ea1c 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "55.0.2", + "version": "56.0.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -70,7 +70,7 @@ }, "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", @@ -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/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index ba493b25179..4a649dddff4 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +411,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 6975c4cd5fe..442e6552710 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", @@ -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/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.4.0", - "@metamask/transaction-controller": "^55.0.2", + "@metamask/transaction-controller": "^56.0.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/yarn.lock b/yarn.lock index 058b837c566..36f2c19fe4d 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,7 +2436,7 @@ __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" @@ -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 @@ -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:^63.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,7 +2567,7 @@ __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" @@ -2576,20 +2576,20 @@ __metadata: "@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/permission-controller": "npm:^11.0.6" "@metamask/polling-controller": "npm:^13.0.0" - "@metamask/preferences-controller": "npm:^17.0.0" + "@metamask/preferences-controller": "npm:^18.0.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.2" + "@metamask/transaction-controller": "npm:^56.0.0" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2615,15 +2615,15 @@ __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/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 @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^24.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^25.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2698,8 +2698,8 @@ __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:^63.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/controller-utils": "npm:^11.8.0" @@ -2707,13 +2707,13 @@ __metadata: "@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/multichain-network-controller": "npm:^0.7.0" "@metamask/network-controller": "npm:^23.4.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.2" + "@metamask/transaction-controller": "npm:^56.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -2728,12 +2728,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": ^63.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 @@ -2741,10 +2741,10 @@ __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:^24.0.0" + "@metamask/bridge-controller": "npm:^25.0.0" "@metamask/controller-utils": "npm:^11.8.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" @@ -2752,8 +2752,8 @@ __metadata: "@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.2" - "@metamask/user-operation-controller": "npm:^34.0.0" + "@metamask/transaction-controller": "npm:^56.0.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" @@ -2768,12 +2768,12 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^28.0.0 - "@metamask/bridge-controller": ^24.0.0 + "@metamask/accounts-controller": ^29.0.0 + "@metamask/bridge-controller": ^25.0.0 "@metamask/gas-fee-controller": ^23.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 @@ -2973,10 +2973,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" @@ -2987,8 +2987,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 @@ -2997,13 +2997,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/stake-sdk": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^55.0.2" + "@metamask/transaction-controller": "npm:^56.0.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -3012,7 +3012,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 @@ -3526,7 +3526,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: @@ -3674,7 +3674,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.8.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/multichain-transactions-controller": "npm:^0.11.0" "@metamask/network-controller": "npm:^23.4.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/rpc-errors": "npm:^7.0.2" @@ -3693,16 +3693,16 @@ __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/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/superstruct": "npm:^3.1.0" @@ -3721,20 +3721,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:^0.11.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" @@ -3753,7 +3753,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 @@ -3872,8 +3872,8 @@ __metadata: "@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:^14.0.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" @@ -3891,8 +3891,8 @@ __metadata: typescript: "npm:~5.2.2" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/keyring-controller": ^21.0.0 - "@metamask/profile-sync-controller": ^14.0.0 + "@metamask/keyring-controller": ^22.0.0 + "@metamask/profile-sync-controller": ^15.0.0 languageName: unknown linkType: soft @@ -4033,14 +4033,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.0.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/keyring-controller": "npm:^22.0.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -4050,21 +4050,21 @@ __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:^14.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/providers": "npm:^21.0.0" @@ -4088,8 +4088,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 @@ -4268,13 +4268,13 @@ __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/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/utils": "npm:^11.2.0" @@ -4289,9 +4289,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 @@ -4458,7 +4458,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/transaction-controller@npm:^55.0.2, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^56.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -4470,7 +4470,7 @@ __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" @@ -4506,7 +4506,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 @@ -4515,7 +4515,7 @@ __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: @@ -4526,12 +4526,12 @@ __metadata: "@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/keyring-controller": "npm:^22.0.0" "@metamask/network-controller": "npm:^23.4.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.2" + "@metamask/transaction-controller": "npm:^56.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bn.js: "npm:^5.2.1" @@ -4548,9 +4548,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 From 29c912fd3d826021364246df536c833814ec9ee9 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Wed, 14 May 2025 16:50:28 +0100 Subject: [PATCH 13/82] perf: improve token-list-controller state updates and caching (#5804) ## Explanation This improves how we perform state updates in the TokenListController. It reduces the mobile commits/renders from 27-30 commits down to 10-15. Here is a test-drive mobile PR: https://github.com/MetaMask/metamask-mobile/pull/15330 | Before | After | |--------|--------| | ![Screenshot 2025-05-14 at 14 27 19](https://github.com/user-attachments/assets/506cee83-144e-4c34-b9e4-335002b821b6) | ![Screenshot 2025-05-14 at 14 52 07](https://github.com/user-attachments/assets/ee4d666a-c1c0-4a95-9edb-949231bcc099) | ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- eslint-warning-thresholds.json | 4 - packages/assets-controllers/CHANGELOG.md | 5 + .../src/TokenListController.ts | 114 ++++++++---------- 3 files changed, 56 insertions(+), 67 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index feceaddb4ed..18526b5dd5b 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -96,10 +96,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 }, diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 5df8d3530e4..4e91cc35b39 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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 + ## [63.0.0] ### Changed 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 + ); } /** From 2bd95368c46feaeb08191d818048b2e1ccbfe0b2 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Wed, 14 May 2025 18:04:34 +0200 Subject: [PATCH 14/82] fix(rpc-service): handle 405 and 429 status codes without triggering circuit breaker (#5798) ## Explanation This PR improves the handling of HTTP status codes in the RPC service by properly handling 405 (Method Not Allowed) and 429 (Too Many Requests) responses without triggering the circuit breaker. ### Changes - Added handling for 405 status code, RPC Error code -32601 (Method not found) - Added handling for 429 status code, RPC Error code-32005 (Request rate limit exceeded) ### Why Previously, these status codes would trigger the circuit breaker, which could lead to unnecessary failover to backup endpoints. These status codes represent expected error conditions that should be handled gracefully without triggering the circuit breaker. ### Testing - [ ] Test with 405 response to verify proper error handling - [ ] Test with 429 response to verify proper error handling and retry delay information - [ ] Verify circuit breaker is not triggered for these status codes ## References * Fixes https://github.com/MetaMask/core/issues/5766 ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/controller-utils/CHANGELOG.md | 10 +++++ .../src/create-service-policy.ts | 19 +++++++- packages/network-controller/CHANGELOG.md | 7 +++ .../src/rpc-service/rpc-service.ts | 4 +- .../block-hash-in-response.ts | 39 ++++++++-------- .../tests/provider-api-tests/block-param.ts | 45 ++++++++++--------- .../provider-api-tests/no-block-param.ts | 39 ++++++++-------- 7 files changed, 101 insertions(+), 62 deletions(-) diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index c23b9b5a022..3188527a3f1 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Improved circuit breaker behavior to only consider specific error codes as service failures ([#5798](https://github.com/MetaMask/core/pull/5798)) + - 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 + - Only considers as service failures: + - Errors that have a numeric code property with value -32603 (Internal error) + - Errors that don't meet the criteria for having a numeric code property + - With more precise type checking for the error object structure + ## [11.8.0] ### Added diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index b49758f1254..ee1888a1963 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -130,6 +130,23 @@ 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 && + 'code' in error && + typeof error.code === 'number' + ) { + const { code } = error; + // Only consider errors with code -32603 (internal error) as service failures + return code === -32603; + } + + // 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 +219,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/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index e8ee19a6104..51c9df60861 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5798](https://github.com/MetaMask/core/pull/5798)) + - 405 (Method Not Allowed) continues to throw JSON-RPC error with code -32601 (Method not found) + - 429 (Too Many Requests) now throws JSON-RPC error with code -32005 (Request rate limit exceeded) instead of a generic internal error + - These errors are filtered by the circuit breaker's `handleWhen` policy to prevent unnecessary failover to backup endpoints + ## [23.4.0] ### Added diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index e2766fd2a08..cbff910027d 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -488,7 +488,9 @@ export class RpcService implements AbstractRpcService { } if (response.status === 429) { - throw rpcErrors.internal({ message: 'Request is being rate limited.' }); + throw rpcErrors.limitExceeded({ + message: 'Request is being rate limited.', + }); } if (response.status === 503 || response.status === 504) { 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..fd985a7617f 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 @@ -365,25 +365,26 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( }); }); - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - }); + // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker + // testsForRpcFailoverBehavior({ + // providerType, + // requestToCall: { + // method, + // params: [], + // }, + // getRequestToMock: () => ({ + // method, + // params: [], + // }), + // failure: { + // httpStatus, + // }, + // isRetriableFailure: false, + // getExpectedError: () => + // expect.objectContaining({ + // message: errorMessage, + // }), + // }); }, ); 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..612853f7f12 100644 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/block-param.ts @@ -455,28 +455,29 @@ export function testsForRpcMethodSupportingBlockParam( }); }); - 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, - }), - }); + // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker + // 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, + // }), + // }); }, ); 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..136f31cba25 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 @@ -321,25 +321,26 @@ export function testsForRpcMethodAssumingNoBlockParam( }); }); - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - }); + // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker + // testsForRpcFailoverBehavior({ + // providerType, + // requestToCall: { + // method, + // params: [], + // }, + // getRequestToMock: () => ({ + // method, + // params: [], + // }), + // failure: { + // httpStatus, + // }, + // isRetriableFailure: false, + // getExpectedError: () => + // expect.objectContaining({ + // message: errorMessage, + // }), + // }); }, ); From 9bac89d8d48db86a2cd9b615f37cd664240c8a57 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 14 May 2025 18:07:57 +0100 Subject: [PATCH 15/82] feat: incoming transaction request tags (#5803) ## Explanation Support additional debug data in `x-metamask-clientproduct` header in incoming transaction requests to accounts API. Provided via optional `tags` in calls to `updateIncomingTransactions`, and optional `client` in constructor. ## References Fixes [#4902](https://github.com/MetaMask/MetaMask-planning/issues/4902) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 6 ++++ .../src/TransactionController.ts | 11 ++++-- .../src/api/accounts-api.test.ts | 24 +++++++++++++ .../src/api/accounts-api.ts | 6 +++- .../AccountsApiRemoteTransactionSource.ts | 3 +- .../helpers/IncomingTransactionHelper.test.ts | 22 ++++++++++++ .../src/helpers/IncomingTransactionHelper.ts | 36 ++++++++++++++++++- packages/transaction-controller/src/types.ts | 5 +++ 8 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index b85361d54b0..adc93d75ed7 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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. + ## [56.0.0] ### Changed diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index f01bcda572b..cba2a1bcfa7 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -945,6 +945,7 @@ export class TransactionController extends BaseController< }; this.#incomingTransactionHelper = new IncomingTransactionHelper({ + client: this.#incomingTransactionOptions.client, getCache: () => this.state.lastFetchedBlockNumbers, getCurrentAccount: () => this.#getSelectedAccount(), getLocalTransactions: () => this.state.transactions, @@ -1315,8 +1316,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 }); } /** 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/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/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 12ffc5147aa..a408f1abc4a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -23,6 +23,9 @@ 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 @@ -166,6 +169,7 @@ describe('IncomingTransactionHelper', () => { cache: CACHE_MOCK, includeTokenTransfers: true, queryEntireHistory: true, + tags: ['automatic-polling'], updateCache: expect.any(Function), updateTransactions: false, }); @@ -461,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 a221744f6db..703b58a8cde 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -10,15 +10,20 @@ 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 TAG_POLLING = 'automatic-polling'; + export class IncomingTransactionHelper { hub: EventEmitter; + readonly #client?: string; + readonly #getCache: () => Record; readonly #getCurrentAccount: () => ReturnType< @@ -50,6 +55,7 @@ export class IncomingTransactionHelper { readonly #updateTransactions?: boolean; constructor({ + client, getCache, getCurrentAccount, getLocalTransactions, @@ -62,6 +68,7 @@ export class IncomingTransactionHelper { updateCache, updateTransactions, }: { + client?: string; getCache: () => Record; getCurrentAccount: () => ReturnType< AccountsController['getSelectedAccount'] @@ -78,6 +85,7 @@ export class IncomingTransactionHelper { }) { this.hub = new EventEmitter(); + this.#client = client; this.#getCache = getCache; this.#getCurrentAccount = getCurrentAccount; this.#getLocalTransactions = getLocalTransactions; @@ -142,9 +150,15 @@ export class IncomingTransactionHelper { } } - 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()) { @@ -166,6 +180,7 @@ export class IncomingTransactionHelper { cache, includeTokenTransfers, queryEntireHistory, + tags: finalTags, updateCache: this.#updateCache, updateTransactions, }); @@ -242,4 +257,23 @@ export class IncomingTransactionHelper { #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/types.ts b/packages/transaction-controller/src/types.ts index 62253b96113..7b19fbb9439 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -946,6 +946,11 @@ export interface RemoteTransactionSourceRequest { */ queryEntireHistory: boolean; + /** + * Additional tags to identify the source of the request. + */ + tags?: string[]; + /** * Callback to update the cache. */ From b1d2647032cf74ccc05d50232a670ef983d65a62 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 14 May 2025 18:12:46 -0230 Subject: [PATCH 16/82] chore: Remove obsolete workaround (#5808) ## Explanation An error case was added to our network middleware long ago to workaround load balancer errors that we encountered with Infura at the time. These errors were fixed long ago, so this workaround is no longer needed. I checked with the Infura team, and they confirmed that this case should no longer be possible for Infura RPC endpoints. Removing this check allowed me to update how we're parsing the response body as well. We're now using `response.json()` rather than parsing the raw body as text. As a consequence of this, we no longer have the raw text to attach to parsing errors, but this seems OK to remove given that we don't reference it anywhere, and the full response can be seen in devtools in a development environment. ## References This workaround was originally introduced here: https://github.com/MetaMask/eth-json-rpc-infura/blame/7871c8ee5acf6c738b6bfa43dfaadc02d7f00509/src/index.js#L13C52-L13C59 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/network-controller/CHANGELOG.md | 4 +++ .../src/rpc-service/rpc-service.test.ts | 30 ------------------- .../src/rpc-service/rpc-service.ts | 29 ++---------------- 3 files changed, 7 insertions(+), 56 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 51c9df60861..49aeb622504 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Remove obsolete `eth_getBlockByNumber` error handling for load balancer errors ([#5808](https://github.com/MetaMask/core/pull/5808)) + ### Fixed - Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5798](https://github.com/MetaMask/core/pull/5798)) 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..be0b4e70771 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -861,36 +861,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) diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index cbff910027d..c737d0ae8ea 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -379,10 +379,7 @@ export class RpcService implements AbstractRpcService { ); try { - return await this.#executePolicy( - jsonRpcRequest, - completeFetchOptions, - ); + return await this.#executePolicy(completeFetchOptions); } catch (error) { if ( this.#policy.circuitBreakerPolicy.state === CircuitState.Open && @@ -462,7 +459,6 @@ 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. @@ -472,12 +468,7 @@ export class RpcService implements AbstractRpcService { * @throws A generic error if the response HTTP status is not 2xx but also not * 405, 429, 503, or 504. */ - async #executePolicy< - Params extends JsonRpcParams, - Result extends Json, - Request extends JsonRpcRequest = JsonRpcRequest, - >( - jsonRpcRequest: Request, + async #executePolicy( fetchOptions: FetchOptions, ): Promise | JsonRpcResponse> { return await this.#policy.execute(async () => { @@ -500,29 +491,15 @@ export class RpcService implements AbstractRpcService { }); } - 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); + json = await response.json(); } catch (error) { if (error instanceof SyntaxError) { throw rpcErrors.internal({ message: 'Could not parse response as it is not valid JSON', - data: text, }); } else { throw error; From 22e116193cc9616c2b5e1a6fc09f633c39fc07bf Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Wed, 14 May 2025 15:13:07 -0700 Subject: [PATCH 17/82] fix: use zero address as native address instead of assetId (#5799) ## Explanation `getNativeAssetForChainId` returns the assetId for SOL instead of a recognized native token address. This can cause duplicate SOL tokens to appear in the clients. This updates the address to the ZeroAddress, which clients use for native assets ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 4 ++++ packages/bridge-controller/src/constants/tokens.ts | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 93908c93b5f..fcc27027708 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **BREAKING:** Use zero address as solana's default native address instead of assetId ([#5799](https://github.com/MetaMask/core/pull/5799)) + ## [25.0.0] ### Changed 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; From 9a26e72e77f0e94ce6653c20f5c2ee0fb84a0940 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Wed, 14 May 2025 15:42:33 -0700 Subject: [PATCH 18/82] Release/398.0.0 (#5811) ## Explanation Bump @metamask/bridge-controller to 25.0.1 to release https://github.com/MetaMask/core/pull/5799 ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 7 +++++-- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/CHANGELOG.md | 4 ++++ packages/bridge-status-controller/package.json | 2 +- yarn.lock | 4 ++-- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f65cd010125..46ee5f2d190 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "397.0.0", + "version": "398.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index fcc27027708..451c782d7c9 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [25.0.1] + ### Fixed -- **BREAKING:** Use zero address as solana's default native address instead of assetId ([#5799](https://github.com/MetaMask/core/pull/5799)) +- Use zero address as solana's default native address instead of assetId ([#5799](https://github.com/MetaMask/core/pull/5799)) ## [25.0.0] @@ -245,7 +247,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.1...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 05e6cc7d784..3a65854574e 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "25.0.0", + "version": "25.0.1", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index e0e940ba621..9dce50e120b 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/bridge-controller` peer dependency to `^25.0.1` ([#5811](https://github.com/MetaMask/core/pull/5811)) + ## [21.0.0] ### Changed diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 0d46f34ff0e..b2544a6c0d0 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^25.0.0", + "@metamask/bridge-controller": "^25.0.1", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.4.0", "@metamask/snaps-controllers": "^11.2.1", diff --git a/yarn.lock b/yarn.lock index 36f2c19fe4d..b130fa6cb60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^25.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^25.0.1, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2744,7 +2744,7 @@ __metadata: "@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:^25.0.0" + "@metamask/bridge-controller": "npm:^25.0.1" "@metamask/controller-utils": "npm:^11.8.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" From e4c1ffaebc1b2016534d879aea50eb5d4c70808d Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 14 May 2025 21:16:05 -0230 Subject: [PATCH 19/82] fix: Prevent circuit break for HTTP 4XX errors (#5809) ## Explanation The "create-service-policy" utility (specifically the circuit breaker) has been updated to handle fetch errors rather than RPC errors. This utility was recently updated to handle the JSON-RPC "Internal error" response, but this is only expected for one specific place where this utility is used (the RPC service). Additionally, there remained some cases that would still inappropriately trigger the circuit break policy (i.e. there were some "internal errors" that don't indicate service failure). The utility will now consider all network errors and HTTP 5XX errors as indicative of service failure. HTTP 4XX errors will no longer trigger the circuit breaker. To accomodate these changes, the RPC service now only handles the fetch request and response parsing inside the policy execution phase. The step where errors are parsed and converted to JSON-RPC errors has been moved to _outside_ the execute step. Effectively this has the same functional result for users of the service, but it makes the policy much simpler. ## References Related: * https://github.com/MetaMask/core/pull/5798 * https://github.com/MetaMask/core/issues/5766 ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/controller-utils/CHANGELOG.md | 10 +-- .../src/create-service-policy.ts | 8 +- packages/controller-utils/src/index.test.ts | 1 + packages/controller-utils/src/index.ts | 1 + packages/controller-utils/src/util.test.ts | 26 +++++++ packages/controller-utils/src/util.ts | 25 ++++++- packages/network-controller/CHANGELOG.md | 6 +- .../src/rpc-service/rpc-service.test.ts | 24 ++---- .../src/rpc-service/rpc-service.ts | 75 +++++++++---------- .../block-hash-in-response.ts | 31 +++----- .../tests/provider-api-tests/block-param.ts | 36 +++------ .../provider-api-tests/no-block-param.ts | 31 +++----- .../tests/provider-api-tests/rpc-failover.ts | 8 +- 13 files changed, 139 insertions(+), 143 deletions(-) diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 3188527a3f1..270a03cfdff 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,15 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 only consider specific error codes as service failures ([#5798](https://github.com/MetaMask/core/pull/5798)) +- 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 - - Only considers as service failures: - - Errors that have a numeric code property with value -32603 (Internal error) - - Errors that don't meet the criteria for having a numeric code property - - With more precise type checking for the error object structure ## [11.8.0] diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index ee1888a1963..e2028b42dce 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -134,12 +134,10 @@ const isServiceFailure = (error: unknown) => { if ( typeof error === 'object' && error !== null && - 'code' in error && - typeof error.code === 'number' + 'httpStatus' in error && + typeof error.httpStatus === 'number' ) { - const { code } = error; - // Only consider errors with code -32603 (internal error) as service failures - return code === -32603; + return error.httpStatus >= 500; } // If the error is not an object, or doesn't have a numeric code property, 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/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 49aeb622504..55b4314aaef 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -13,10 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5798](https://github.com/MetaMask/core/pull/5798)) - - 405 (Method Not Allowed) continues to throw JSON-RPC error with code -32601 (Method not found) - - 429 (Too Many Requests) now throws JSON-RPC error with code -32005 (Request rate limit exceeded) instead of a generic internal error - - These errors are filtered by the circuit breaker's `handleWhen` policy to prevent unnecessary failover to backup endpoints +- 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] 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 be0b4e70771..e161b807eb3 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) => { @@ -326,6 +318,7 @@ describe('RpcService', () => { message: 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', }), + expectedOnBreakError: new HttpError(httpStatus), }); }, ); @@ -528,11 +521,6 @@ describe('RpcService', () => { await expect(promise).rejects.toThrow( expect.objectContaining({ message: "Non-200 status code: '500'", - data: { - id: 1, - jsonrpc: '2.0', - error: 'oops', - }, }), ); }); @@ -1243,17 +1231,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. @@ -1371,7 +1363,7 @@ function testsForRetriableResponses({ expect(onBreakListener).toHaveBeenCalledTimes(1); expect(onBreakListener).toHaveBeenCalledWith({ - error: expectedError, + error: expectedOnBreakError, endpointUrl: `${endpointUrl}/`, }); }); @@ -1589,7 +1581,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 c737d0ae8ea..7a1a9d6c96d 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -4,6 +4,7 @@ import type { } from '@metamask/controller-utils'; import { CircuitState, + HttpError, createServicePolicy, handleWhen, } from '@metamask/controller-utils'; @@ -250,7 +251,8 @@ 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 === 503 || error.httpStatus === 504)) || (hasProperty(error, 'code') && (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) ); @@ -379,7 +381,7 @@ export class RpcService implements AbstractRpcService { ); try { - return await this.#executePolicy(completeFetchOptions); + return await this.#processRequest(completeFetchOptions); } catch (error) { if ( this.#policy.circuitBreakerPolicy.state === CircuitState.Open && @@ -468,52 +470,45 @@ export class RpcService implements AbstractRpcService { * @throws A generic error if the response HTTP status is not 2xx but also not * 405, 429, 503, or 504. */ - async #executePolicy( + 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.limitExceeded({ - 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.', - }); - } - - // Type annotation: We assume that if this response is valid JSON, it's a - // valid JSON-RPC response. - let json: JsonRpcResponse; - try { - json = await response.json(); - } catch (error) { - if (error instanceof SyntaxError) { + 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 === 405) { + throw rpcErrors.methodNotFound(); + } + if (status === 429) { + throw rpcErrors.limitExceeded({ + message: 'Request is being rate limited.', + }); + } + if (status === 503 || status === 504) { throw rpcErrors.internal({ - message: 'Could not parse response as it is not valid JSON', + message: + 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', }); - } else { - throw error; } - } - if (!response.ok) { throw rpcErrors.internal({ - message: `Non-200 status code: '${response.status}'`, - data: json, + message: `Non-200 status code: '${status}'`, + }); + } else if (error instanceof SyntaxError) { + throw rpcErrors.internal({ + message: 'Could not parse response as it is not valid JSON', }); } - - return json; - }); + throw error; + } } } 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 fd985a7617f..4a5fa6bff81 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 @@ -364,27 +364,6 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); - - // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker - // testsForRpcFailoverBehavior({ - // providerType, - // requestToCall: { - // method, - // params: [], - // }, - // getRequestToMock: () => ({ - // method, - // params: [], - // }), - // failure: { - // httpStatus, - // }, - // isRetriableFailure: false, - // getExpectedError: () => - // expect.objectContaining({ - // message: errorMessage, - // }), - // }); }, ); @@ -433,6 +412,10 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '500'`, + }), }); }); @@ -528,6 +511,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 612853f7f12..27793c6009a 100644 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/block-param.ts @@ -454,30 +454,6 @@ export function testsForRpcMethodSupportingBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); - - // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker - // 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, - // }), - // }); }, ); @@ -542,6 +518,10 @@ export function testsForRpcMethodSupportingBlockParam( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '500'`, + }), }); }); @@ -668,7 +648,13 @@ export function testsForRpcMethodSupportingBlockParam( isRetriableFailure: true, getExpectedError: () => expect.objectContaining({ - message: expect.stringContaining(errorMessage), + message: expect.stringContaining(`Gateway timeout`), + }), + 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 136f31cba25..9b50ffdbddb 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 @@ -320,27 +320,6 @@ export function testsForRpcMethodAssumingNoBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); - - // TODO: Add tests for failover behavior when the RPC endpoint returns a 405 or 429 response without opening a circuit breaker - // testsForRpcFailoverBehavior({ - // providerType, - // requestToCall: { - // method, - // params: [], - // }, - // getRequestToMock: () => ({ - // method, - // params: [], - // }), - // failure: { - // httpStatus, - // }, - // isRetriableFailure: false, - // getExpectedError: () => - // expect.objectContaining({ - // message: errorMessage, - // }), - // }); }, ); @@ -389,6 +368,10 @@ export function testsForRpcMethodAssumingNoBlockParam( expect.objectContaining({ message: errorMessage, }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '500'`, + }), }); }); @@ -484,6 +467,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), }); }, ); From 855db28137a2787d85d6eed84fcdd1152b40db35 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 15 May 2025 06:49:29 -0230 Subject: [PATCH 20/82] Release 399.0.0 (#5812) ## Explanation Minor release of `network-controller` and `controller-utils` ## References See diff ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Salah-Eddine Saakoun --- package.json | 2 +- packages/accounts-controller/package.json | 2 +- packages/address-book-controller/CHANGELOG.md | 2 +- packages/address-book-controller/package.json | 2 +- packages/assets-controllers/CHANGELOG.md | 1 + packages/assets-controllers/package.json | 4 +- packages/bridge-controller/CHANGELOG.md | 4 + packages/bridge-controller/package.json | 4 +- .../bridge-status-controller/CHANGELOG.md | 1 + .../bridge-status-controller/package.json | 4 +- .../chain-agnostic-permission/CHANGELOG.md | 4 +- .../chain-agnostic-permission/package.json | 4 +- packages/controller-utils/CHANGELOG.md | 5 +- packages/controller-utils/package.json | 2 +- packages/earn-controller/CHANGELOG.md | 4 + packages/earn-controller/package.json | 4 +- .../CHANGELOG.md | 2 +- .../package.json | 2 +- packages/ens-controller/CHANGELOG.md | 2 +- packages/ens-controller/package.json | 4 +- packages/gas-fee-controller/CHANGELOG.md | 2 +- packages/gas-fee-controller/package.json | 4 +- packages/logging-controller/CHANGELOG.md | 2 +- packages/logging-controller/package.json | 2 +- packages/message-manager/CHANGELOG.md | 2 +- packages/message-manager/package.json | 2 +- .../multichain-api-middleware/CHANGELOG.md | 4 +- .../multichain-api-middleware/package.json | 4 +- .../CHANGELOG.md | 4 + .../package.json | 4 +- packages/multichain/CHANGELOG.md | 2 +- packages/multichain/package.json | 4 +- packages/name-controller/CHANGELOG.md | 2 +- packages/name-controller/package.json | 2 +- packages/network-controller/CHANGELOG.md | 6 +- packages/network-controller/package.json | 4 +- .../CHANGELOG.md | 4 + .../package.json | 2 +- packages/permission-controller/CHANGELOG.md | 2 +- packages/permission-controller/package.json | 2 +- packages/phishing-controller/CHANGELOG.md | 2 +- packages/phishing-controller/package.json | 2 +- packages/polling-controller/CHANGELOG.md | 2 +- packages/polling-controller/package.json | 4 +- packages/preferences-controller/CHANGELOG.md | 4 + packages/preferences-controller/package.json | 2 +- packages/profile-sync-controller/package.json | 2 +- .../queued-request-controller/CHANGELOG.md | 2 +- .../queued-request-controller/package.json | 4 +- .../CHANGELOG.md | 2 +- .../package.json | 2 +- packages/sample-controllers/package.json | 4 +- .../selected-network-controller/package.json | 2 +- packages/signature-controller/CHANGELOG.md | 4 + packages/signature-controller/package.json | 4 +- packages/transaction-controller/CHANGELOG.md | 4 + packages/transaction-controller/package.json | 4 +- .../user-operation-controller/CHANGELOG.md | 4 + .../user-operation-controller/package.json | 4 +- yarn.lock | 96 +++++++++---------- 60 files changed, 157 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index 46ee5f2d190..7139e09bad4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "398.0.0", + "version": "399.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index 0e9fa51a274..dadc4f1b3f6 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^22.0.0", - "@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", diff --git a/packages/address-book-controller/CHANGELOG.md b/packages/address-book-controller/CHANGELOG.md index 9fc7ccc604c..225053c2054 100644 --- a/packages/address-book-controller/CHANGELOG.md +++ b/packages/address-book-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.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/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 4e91cc35b39..938840f74e2 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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] diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 5c30935d8b8..ad5061ca969 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -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", @@ -84,7 +84,7 @@ "@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": "^18.0.0", "@metamask/providers": "^21.0.0", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 451c782d7c9..123c112b678 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [25.0.1] ### Fixed diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 3a65854574e..d4ca6d26ae1 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -53,7 +53,7 @@ "@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", @@ -68,7 +68,7 @@ "@metamask/assets-controllers": "^63.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", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 9dce50e120b..8ef78e97db5 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/bridge-controller` peer 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)) ## [21.0.0] diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index b2544a6c0d0..4ca4d0e4777 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-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/keyring-api": "^17.4.0", "@metamask/polling-controller": "^13.0.0", "@metamask/superstruct": "^3.1.0", @@ -62,7 +62,7 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/bridge-controller": "^25.0.1", "@metamask/gas-fee-controller": "^23.0.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/transaction-controller": "^56.0.0", "@types/jest": "^27.4.1", diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index f025aa5a44a..ea2a0b04640 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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/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] diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index a4a25bcb631..2b9595250ea 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -48,8 +48,8 @@ }, "dependencies": { "@metamask/api-specs": "^0.10.12", - "@metamask/controller-utils": "^11.8.0", - "@metamask/network-controller": "^23.4.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/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 270a03cfdff..095b86aa337 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [11.9.0] + ### Added - Add `HttpError` class for errors representing non-200 HTTP responses ([#5809](https://github.com/MetaMask/core/pull/5809)) @@ -513,7 +515,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/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 07ca2d704af..b59c71a2d1d 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [0.14.0] ### Changed diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index 62b51571fef..2bef723b79a 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-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/stake-sdk": "^1.0.0" }, "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/transaction-controller": "^56.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index 2779bcab1f7..7a895849af9 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/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..3239411476f 100644 --- a/packages/eip1193-permission-middleware/package.json +++ b/packages/eip1193-permission-middleware/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/chain-agnostic-permission": "^0.6.0", - "@metamask/controller-utils": "^11.8.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/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/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..e3d127a2710 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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/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] diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index ea280244b95..323cef227ad 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -49,9 +49,9 @@ "dependencies": { "@metamask/api-specs": "^0.10.12", "@metamask/chain-agnostic-permission": "^0.6.0", - "@metamask/controller-utils": "^11.8.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", diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index 02fa65e2607..4928c1039f4 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [0.7.0] ### Changed diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json index a584b389c11..f8d9b496d05 100644 --- a/packages/multichain-network-controller/package.json +++ b/packages/multichain-network-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/keyring-api": "^17.4.0", "@metamask/keyring-internal-api": "^6.0.1", "@metamask/superstruct": "^3.1.0", @@ -60,7 +60,7 @@ "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^22.0.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/uuid": "^8.3.0", diff --git a/packages/multichain/CHANGELOG.md b/packages/multichain/CHANGELOG.md index 4302d172a53..7a79367b90b 100644 --- a/packages/multichain/CHANGELOG.md +++ b/packages/multichain/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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/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] diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 5fe568deef9..53bfa8aaed9 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/api-specs": "^0.10.12", - "@metamask/controller-utils": "^11.8.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/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 55b4314aaef..a2bb4bd7f5e 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -848,7 +851,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..3982a04823a 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,7 +48,7 @@ }, "dependencies": { "@metamask/base-controller": "^8.0.1", - "@metamask/controller-utils": "^11.8.0", + "@metamask/controller-utils": "^11.9.0", "@metamask/eth-block-tracker": "^11.0.3", "@metamask/eth-json-rpc-infura": "^10.1.1", "@metamask/eth-json-rpc-middleware": "^16.0.1", diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index ed68710f523..05f17aa7834 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [8.0.0] ### Changed diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index 9c31b125ec1..c657e405f4b 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -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", 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 bfd111655a3..9be4dd815b9 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [18.0.0] ### Changed diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index f5b5529e36e..380b60d8689 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-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" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 6d0e3f55173..76455d6b49a 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -117,7 +117,7 @@ "@metamask/auto-changelog": "^3.4.4", "@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", 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..2f16a58ba39 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,7 +56,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/selected-network-controller": "^22.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", 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/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/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 4820d05b8ca..8a509dd3bb0 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -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/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index 87c20b4c883..63531f27e71 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [29.0.0] ### Changed diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 4a74cae7819..f38b79aae4d 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-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-sig-util": "^8.2.0", "@metamask/utils": "^11.2.0", "jsonschema": "^1.4.1", @@ -61,7 +61,7 @@ "@metamask/auto-changelog": "^3.4.4", "@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", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index adc93d75ed7..65c6b360539 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) + ## [56.0.0] ### Changed diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 1481424ea1c..73ea8af665c 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -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", @@ -77,7 +77,7 @@ "@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", diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index 4a649dddff4..f97ba5bade1 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + ## [35.0.0] ### Changed diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 442e6552710..20701c79082 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-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/eth-query": "^4.0.0", "@metamask/polling-controller": "^13.0.0", "@metamask/rpc-errors": "^7.0.2", @@ -66,7 +66,7 @@ "@metamask/eth-block-tracker": "^11.0.3", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/keyring-controller": "^22.0.0", - "@metamask/network-controller": "^23.4.0", + "@metamask/network-controller": "^23.5.0", "@metamask/transaction-controller": "^56.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index b130fa6cb60..c76fff36368 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2439,7 +2439,7 @@ __metadata: "@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" @@ -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" @@ -2572,7 +2572,7 @@ __metadata: "@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" @@ -2580,7 +2580,7 @@ __metadata: "@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/polling-controller": "npm:^13.0.0" "@metamask/preferences-controller": "npm:^18.0.0" @@ -2702,13 +2702,13 @@ __metadata: "@metamask/assets-controllers": "npm:^63.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.7.0" - "@metamask/network-controller": "npm:^23.4.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" @@ -2745,10 +2745,10 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/bridge-controller": "npm:^25.0.1" - "@metamask/controller-utils": "npm:^11.8.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/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" @@ -2809,9 +2809,9 @@ __metadata: dependencies: "@metamask/api-specs": "npm:^0.10.12" "@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" @@ -2852,7 +2852,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: @@ -3000,8 +3000,8 @@ __metadata: "@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:^56.0.0" "@types/jest": "npm:^27.4.1" @@ -3023,7 +3023,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/chain-agnostic-permission": "npm:^0.6.0" - "@metamask/controller-utils": "npm:^11.8.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" @@ -3046,8 +3046,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" @@ -3431,10 +3431,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" @@ -3623,7 +3623,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" @@ -3641,7 +3641,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" @@ -3671,11 +3671,11 @@ __metadata: "@metamask/api-specs": "npm:^0.10.12" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/chain-agnostic-permission": "npm:^0.6.0" - "@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/multichain-transactions-controller": "npm:^0.11.0" - "@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" @@ -3700,11 +3700,11 @@ __metadata: "@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:^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" @@ -3764,10 +3764,10 @@ __metadata: dependencies: "@metamask/api-specs": "npm:^0.10.12" "@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" @@ -3795,7 +3795,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" @@ -3808,14 +3808,14 @@ __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/eth-block-tracker": "npm:^11.0.3" "@metamask/eth-json-rpc-infura": "npm:^10.1.1" "@metamask/eth-json-rpc-middleware": "npm:^16.0.1" @@ -3871,7 +3871,7 @@ __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/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" @@ -3933,7 +3933,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" @@ -3980,7 +3980,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" "@noble/hashes": "npm:^1.4.0" "@types/jest": "npm:^27.4.1" "@types/punycode": "npm:^2.1.0" @@ -4004,8 +4004,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" @@ -4039,7 +4039,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/keyring-controller": "npm:^22.0.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -4066,7 +4066,7 @@ __metadata: "@metamask/keyring-api": "npm:^17.4.0" "@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" @@ -4124,9 +4124,9 @@ __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/swappable-obj-proxy": "npm:^2.3.0" @@ -4173,7 +4173,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" @@ -4210,8 +4210,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" @@ -4243,7 +4243,7 @@ __metadata: "@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" @@ -4272,11 +4272,11 @@ __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/eth-sig-util": "npm:^8.2.0" "@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" @@ -4474,14 +4474,14 @@ __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/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" @@ -4522,12 +4522,12 @@ __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/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:^22.0.0" - "@metamask/network-controller": "npm:^23.4.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" From ebe42fb6cdf44e90d23b15c433420b1f7e78b179 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 15 May 2025 11:01:57 +0100 Subject: [PATCH 21/82] fix: cancel upgrade error code (#5814) ## Explanation Throw the correct error code from `addTransaction` if an EIP-7702 upgrade is rejected. ## References Relates to [#32956](https://github.com/MetaMask/metamask-extension/issues/32956) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 +++ .../src/TransactionController.test.ts | 35 +++++++++++++++++-- .../src/TransactionController.ts | 2 +- .../src/utils/validation.ts | 1 + 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 65c6b360539..0f2e5b44bfe 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 6706a237a50..7dd00982dc1 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -98,6 +98,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'; @@ -2933,9 +2934,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 +2984,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', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index cba2a1bcfa7..d00ffe4adcd 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -4165,7 +4165,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/utils/validation.ts b/packages/transaction-controller/src/utils/validation.ts index 86f09a1a300..12a4ca15cac 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 = [ From e91553513b04bb9fb693283a71f4ca34d584091e Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Thu, 15 May 2025 13:07:24 +0200 Subject: [PATCH 22/82] feat: Update `txParams` gas properties in when controller `updateTransactionGasFees` method is called with `userFeeLevel` (#5800) ## Explanation This PR aims to add automatic update `txParams` gas values when controller `updateTransactionGasFees` method is called with `userFeeLevel`. Making this change will give us cleaner logic in the clients since controller does that update. Fix in action: https://github.com/user-attachments/assets/a0ffcee9-e105-406c-a454-0d31907b73ff ## References * Related to : https://github.com/MetaMask/metamask-mobile/pull/15234/files#r2086343114 * Fixes: https://github.com/MetaMask/MetaMask-planning/issues/4897 ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Matthew Walsh --- packages/transaction-controller/CHANGELOG.md | 1 + .../src/TransactionController.test.ts | 236 +++++++++- .../src/TransactionController.ts | 83 +++- .../src/helpers/GasFeePoller.test.ts | 439 +++++++++++------- .../src/helpers/GasFeePoller.ts | 173 ++++--- 5 files changed, 675 insertions(+), 257 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 0f2e5b44bfe..2cd2a247def 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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. diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 7dd00982dc1..35d9e4f91d7 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, @@ -531,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, @@ -5025,6 +5033,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 d00ffe4adcd..2adc50a9282 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,12 +115,14 @@ import type { IsAtomicBatchSupportedResult, IsAtomicBatchSupportedRequest, AfterAddHook, + GasFeeEstimateLevel as GasFeeEstimateLevelType, } from './types'; import { TransactionEnvelopeType, TransactionType, TransactionStatus, SimulationErrorCode, + GasFeeEstimateLevel, } from './types'; import { addTransactionBatch, @@ -1804,7 +1810,7 @@ export class TransactionController extends BaseController< maxFeePerGas, originalGasEstimate, userEditedGasLimit, - userFeeLevel, + userFeeLevel: userFeeLevelParam, }: { defaultGasEstimates?: string; estimateUsed?: string; @@ -1832,34 +1838,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; @@ -4065,7 +4108,7 @@ export class TransactionController extends BaseController< this.#updateTransactionInternal( { transactionId, skipHistory: true }, (txMeta) => { - updateTransactionGasFees({ + updateTransactionGasProperties({ txMeta, gasFeeEstimates, gasFeeEstimatesLoaded, 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; } } From 27b327646740c2a3780dfbb5ad834d3bfaa2365c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 15 May 2025 12:23:31 +0100 Subject: [PATCH 23/82] Release 400.0.0 (#5815) Minor release of `@metamask/transaction-controller`. --- package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/package.json | 2 +- packages/earn-controller/package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 5 ++++- packages/transaction-controller/package.json | 2 +- packages/user-operation-controller/package.json | 2 +- yarn.lock | 12 ++++++------ 9 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 7139e09bad4..f4132599a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "399.0.0", + "version": "400.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index ad5061ca969..09ab9238c94 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -90,7 +90,7 @@ "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/snaps-sdk": "^6.22.0", - "@metamask/transaction-controller": "^56.0.0", + "@metamask/transaction-controller": "^56.1.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index d4ca6d26ae1..788c4622c88 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -72,7 +72,7 @@ "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^56.0.0", + "@metamask/transaction-controller": "^56.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 4ca4d0e4777..8122eac5fd4 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -64,7 +64,7 @@ "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", - "@metamask/transaction-controller": "^56.0.0", + "@metamask/transaction-controller": "^56.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index 2bef723b79a..b303ddb5170 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -56,7 +56,7 @@ "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/network-controller": "^23.5.0", - "@metamask/transaction-controller": "^56.0.0", + "@metamask/transaction-controller": "^56.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2cd2a247def..c73820aab26 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -1599,7 +1601,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/transaction-controller@56.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@56.1.0...HEAD +[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 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 73ea8af665c..d505bcf5efd 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "56.0.0", + "version": "56.1.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 20701c79082..99abe2dd5ba 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -67,7 +67,7 @@ "@metamask/gas-fee-controller": "^23.0.0", "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.5.0", - "@metamask/transaction-controller": "^56.0.0", + "@metamask/transaction-controller": "^56.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/yarn.lock b/yarn.lock index c76fff36368..79d3fb7c8ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,7 +2589,7 @@ __metadata: "@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:^56.0.0" + "@metamask/transaction-controller": "npm:^56.1.0" "@metamask/utils": "npm:^11.2.0" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2713,7 +2713,7 @@ __metadata: "@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:^56.0.0" + "@metamask/transaction-controller": "npm:^56.1.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -2752,7 +2752,7 @@ __metadata: "@metamask/polling-controller": "npm:^13.0.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^56.0.0" + "@metamask/transaction-controller": "npm:^56.1.0" "@metamask/user-operation-controller": "npm:^35.0.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" @@ -3003,7 +3003,7 @@ __metadata: "@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:^56.0.0" + "@metamask/transaction-controller": "npm:^56.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -4458,7 +4458,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/transaction-controller@npm:^56.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^56.1.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -4531,7 +4531,7 @@ __metadata: "@metamask/polling-controller": "npm:^13.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^56.0.0" + "@metamask/transaction-controller": "npm:^56.1.0" "@metamask/utils": "npm:^11.2.0" "@types/jest": "npm:^27.4.1" bn.js: "npm:^5.2.1" From 4a42d7a85bac40b79adf9fb2dc978b19b9adae69 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 15 May 2025 16:09:24 +0200 Subject: [PATCH 24/82] Use selected network controller for Snaps (#4602) ## Explanation This removes some checks in the `SelectedNetworkController` which disallow a Snap from using their own network, and default to the globally selected network. After this change, Snaps will be able to select their own network just like websites. ## References Related to MetaMask/MetaMask-planning#2938. ## Changelog ### `@metamask/selected-network-controller` - **CHANGED**: Allow Snaps to change own network ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/SelectedNetworkController.ts | 17 +----- .../tests/SelectedNetworkController.test.ts | 58 +++++++++++-------- 2 files changed, 37 insertions(+), 38 deletions(-) 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', () => { From be5a6ffa396de8e16fc0108ebfa40bcf29017fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Thu, 15 May 2025 17:18:47 +0100 Subject: [PATCH 25/82] feat(multichain-transactions-controller)!: store transactions by chain ID (support for devnet chains) (#5756) ## Explanation 1. Removes the Solana mainnet filtering 2. Reorganizes data structure to support an account[] -> chain[] -> transactions ``` nonEvmTransactions: { [accountId: string]: { [chain: string]: TransactionStateEntry; }; }; ``` 3. Updates logic to reflect these changes ## References Extension PR with this package preview and working solution: - https://github.com/MetaMask/metamask-extension/pull/32858 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Charly Chevalier --- .../CHANGELOG.md | 5 + .../MultichainTransactionsController.test.ts | 199 ++++++++++++------ .../src/MultichainTransactionsController.ts | 139 ++++++------ 3 files changed, 222 insertions(+), 121 deletions(-) diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index d2ed4fdcd05..d3e24486460 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 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 From 45b511a76fb357987961f2f486bc2dff9bf6106f Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 15 May 2025 13:34:05 -0500 Subject: [PATCH 26/82] update @metamask/api-specs version to v0.14.0 in @metamask/chain-agnostic-permission + @metamask/multichain-api-middleware (#5817) ## Explanation Update @metamask/api-specs version to v0.14.0 in: `@metamask/chain-agnostic-permission` `@metamask/multichain-api-middleware` `@metamask/multichain` - to be deprecated soon ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/chain-agnostic-permission/CHANGELOG.md | 1 + packages/chain-agnostic-permission/package.json | 2 +- .../src/scope/constants.test.ts | 3 +++ packages/multichain-api-middleware/CHANGELOG.md | 1 + packages/multichain-api-middleware/package.json | 2 +- packages/multichain/CHANGELOG.md | 1 + packages/multichain/package.json | 2 +- packages/multichain/src/scope/constants.test.ts | 3 +++ yarn.lock | 14 +++++++------- 9 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index ea2a0b04640..7a59232903d 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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)) diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index 2b9595250ea..facfa3c1a9a 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", + "@metamask/api-specs": "^0.14.0", "@metamask/controller-utils": "^11.9.0", "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", 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/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index e3d127a2710..912601b08a7 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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)) diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 323cef227ad..4c6ba316fad 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", + "@metamask/api-specs": "^0.14.0", "@metamask/chain-agnostic-permission": "^0.6.0", "@metamask/controller-utils": "^11.9.0", "@metamask/json-rpc-engine": "^10.0.3", diff --git a/packages/multichain/CHANGELOG.md b/packages/multichain/CHANGELOG.md index 7a79367b90b..b1c3f50b783 100644 --- a/packages/multichain/CHANGELOG.md +++ b/packages/multichain/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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] diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 53bfa8aaed9..ed04c18eb30 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/api-specs": "^0.10.12", + "@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", 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/yarn.lock b/yarn.lock index 79d3fb7c8ca..0d9e678e1de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -2807,7 +2807,7 @@ __metadata: 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.9.0" "@metamask/keyring-internal-api": "npm:^6.0.1" @@ -3668,7 +3668,7 @@ __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.9.0" @@ -3762,7 +3762,7 @@ __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.9.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" From e3372e3f1b005b8406699b3a613ce959f93a98ff Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 15 May 2025 16:41:19 -0500 Subject: [PATCH 27/82] Release/401.0.0 (#5818) ## @metamask/chain-agnostic-permission ## [0.7.0] ### Changed - 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)) ## @metamask/multichain-api-middleware ## [0.3.0] ### Changed - feat: Add more chain-agnostic-permission utility functions from sip-26 usage ([#5609](https://github.com/MetaMask/core/pull/5609)) - Bump `@metamask/chain-agnostic-permission` to `^0.6.0` ([#5715](https://github.com/MetaMask/core/pull/5715),[#5760](https://github.com/MetaMask/core/pull/5760)) - 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)) ## @metamask/multichain ## [4.1.0] ### Changed - 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)) ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/chain-agnostic-permission/CHANGELOG.md | 5 ++++- packages/chain-agnostic-permission/package.json | 2 +- packages/eip1193-permission-middleware/CHANGELOG.md | 3 +-- packages/eip1193-permission-middleware/package.json | 2 +- packages/multichain-api-middleware/CHANGELOG.md | 10 +++++++++- packages/multichain-api-middleware/package.json | 4 ++-- packages/multichain/CHANGELOG.md | 5 ++++- packages/multichain/package.json | 2 +- yarn.lock | 6 +++--- 10 files changed, 27 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index f4132599a7e..d1b4b5bfb2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "400.0.0", + "version": "401.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index 7a59232903d..f61ac70288e 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] + ### Changed - Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) @@ -82,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 facfa3c1a9a..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", diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index 7a895849af9..f444e92ba3c 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -9,8 +9,7 @@ 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/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 3239411476f..ffc6228b5a1 100644 --- a/packages/eip1193-permission-middleware/package.json +++ b/packages/eip1193-permission-middleware/package.json @@ -47,7 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/chain-agnostic-permission": "^0.6.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", diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 912601b08a7..63322e37e77 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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/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)) @@ -42,7 +49,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/multichain-api-middleware@0.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.3.0...HEAD +[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 4c6ba316fad..1fe36229838 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.3.0", "description": "JSON-RPC methods and middleware to support the MetaMask Multichain API", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ }, "dependencies": { "@metamask/api-specs": "^0.14.0", - "@metamask/chain-agnostic-permission": "^0.6.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.5.0", diff --git a/packages/multichain/CHANGELOG.md b/packages/multichain/CHANGELOG.md index b1c3f50b783..b6ccc341a71 100644 --- a/packages/multichain/CHANGELOG.md +++ b/packages/multichain/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.0] + ### Changed - Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) @@ -186,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 ed04c18eb30..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", diff --git a/yarn.lock b/yarn.lock index 0d9e678e1de..7a0d510f018 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2803,7 +2803,7 @@ __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: @@ -3022,7 +3022,7 @@ __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/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" @@ -3670,7 +3670,7 @@ __metadata: dependencies: "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/chain-agnostic-permission": "npm:^0.6.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" From 636ba433baabb1f5d8cb2f5c65a647a71d4c0a27 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Thu, 15 May 2025 22:53:50 -0700 Subject: [PATCH 28/82] feat: log bridge quote and status validation errors (#5816) ## Explanation bridge-api responses are ignored if they fail schema validation. This can cause issues like - not showing quotes to the user - tx statuses getting stuck due to dropped status updates This PR adds error logging that we can monitor on Sentry. Here's an example trace that includes validation errors: https://metamask.sentry.io/insights/frontend/summary/trace/f058862e687a4946a72377f7fc6b6c1f/?node=txn-1e62b796f286424ea5f1635cd84564b7&project=273496&query=transaction.op%3Acustom&referrer=performance-transaction-summary&source=performance_transaction_summary&statsPeriod=5m×tamp=1747356475&transaction=Bridge%20Quotes%20Fetched&unselectedSeries=p100%28%29&unselectedSeries=avg%28%29 ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 4 ++ packages/bridge-controller/src/utils/fetch.ts | 7 +++- .../bridge-controller/src/utils/validators.ts | 4 +- .../bridge-status-controller/CHANGELOG.md | 4 ++ .../__snapshots__/validators.test.ts.snap | 41 +++++++++++++++++++ .../src/utils/validators.test.ts | 8 +++- .../src/utils/validators.ts | 7 +++- 7 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/bridge-status-controller/src/utils/__snapshots__/validators.test.ts.snap diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 123c112b678..93c8e47bedc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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)) 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/validators.ts b/packages/bridge-controller/src/utils/validators.ts index d1d8dfe1d80..acfbf0f02bc 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'; @@ -133,5 +134,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 8ef78e97db5..57d9dcc3041 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Error logs for invalid getTxStatus responses ([#5816](https://github.com/MetaMask/core/pull/5816)) + ### Changed - Bump `@metamask/bridge-controller` peer dependency to `^25.0.1` ([#5811](https://github.com/MetaMask/core/pull/5811)) 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/validators.test.ts b/packages/bridge-status-controller/src/utils/validators.test.ts index b6cd2c97816..17ec15017ff 100644 --- a/packages/bridge-status-controller/src/utils/validators.test.ts +++ b/packages/bridge-status-controller/src/utils/validators.test.ts @@ -271,14 +271,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; + } }; From 9b029795c977407a802bc7f7a119428475cea474 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 16 May 2025 11:43:01 +0200 Subject: [PATCH 29/82] Release 402.0.0 (#5820) ## Explanation This is the release candidate for version `402.0.0`, it includes the following packages: - `selected-network-controller` - `multichain-transactions-controller` ## References * Related to https://github.com/MetaMask/core/pull/5756 ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Antonio Regadas --- package.json | 2 +- packages/multichain-api-middleware/package.json | 2 +- .../multichain-transactions-controller/CHANGELOG.md | 5 ++++- .../multichain-transactions-controller/package.json | 2 +- packages/queued-request-controller/package.json | 2 +- packages/selected-network-controller/CHANGELOG.md | 11 +++++++++-- packages/selected-network-controller/package.json | 2 +- yarn.lock | 8 ++++---- 8 files changed, 22 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index d1b4b5bfb2d..66b933c9407 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "401.0.0", + "version": "402.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 1fe36229838..0fa6bd42734 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -62,7 +62,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-filters": "^9.0.0", - "@metamask/multichain-transactions-controller": "^0.11.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-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index d3e24486460..6f50eb0e889 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,6 +7,8 @@ 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)) @@ -129,7 +131,8 @@ 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.11.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 diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index b203ea9e980..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.11.0", + "version": "1.0.0", "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", "keywords": [ "MetaMask", diff --git a/packages/queued-request-controller/package.json b/packages/queued-request-controller/package.json index 2f16a58ba39..a2290cba617 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -57,7 +57,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/network-controller": "^23.5.0", - "@metamask/selected-network-controller": "^22.0.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/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 8a509dd3bb0..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", diff --git a/yarn.lock b/yarn.lock index 7a0d510f018..a777d3127da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3674,7 +3674,7 @@ __metadata: "@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.11.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" @@ -3726,7 +3726,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-transactions-controller@npm:^0.11.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: @@ -4128,7 +4128,7 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.0.3" "@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" @@ -4236,7 +4236,7 @@ __metadata: languageName: node linkType: hard -"@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: From 7fe7380b8fa59bc334f7fa62eac278a94f5d3720 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Fri, 16 May 2025 14:54:44 +0200 Subject: [PATCH 30/82] fix: Fix sending native token to smart account (#5822) ## Explanation This PR aims to fix where the `addTransaction` function incorrectly identifies a transaction as a `simpleSend` type when the recipient is a smart account. ## References * Fixes https://github.com/MetaMask/MetaMask-planning/issues/4920 * Extension PR: https://github.com/MetaMask/metamask-extension/pull/33013 ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 +++ .../src/utils/transaction-type.test.ts | 25 +++++++++++++++++++ .../src/utils/transaction-type.ts | 5 +++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c73820aab26..6f0937efa49 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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)) + ## [56.1.0] ### Added 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 }; } From 0c09d1273eca67e7b56b4cd7941762b8f33fc0b5 Mon Sep 17 00:00:00 2001 From: Gustavo Silva Date: Fri, 16 May 2025 15:07:06 +0100 Subject: [PATCH 31/82] chore: calc slippage percentage (#5723) ## Explanation Calculate slippage percentage ## References ## Changelog - calcSlippagePercentage added to Bridge Controller Utils ([#5723](https://github.com/MetaMask/core/pull/5723)) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 1 + packages/bridge-controller/src/index.ts | 6 +++- .../bridge-controller/src/utils/quote.test.ts | 34 +++++++++++++++++++ packages/bridge-controller/src/utils/quote.ts | 32 +++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 93c8e47bedc..8513141d315 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 617506f8d7e..bc70fc9fe41 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -103,7 +103,11 @@ export { isCrossChain, } from './utils/bridge'; -export { isValidQuoteRequest, formatEtaInMinutes } from './utils/quote'; +export { + isValidQuoteRequest, + formatEtaInMinutes, + calcSlippagePercentage, +} from './utils/quote'; export { calcLatestSrcBalance } from './utils/balance'; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index d08dd1bfc7b..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, @@ -534,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, ) => { From f0f869f7d07e435153c64594625ed978ab41f72f Mon Sep 17 00:00:00 2001 From: hunty Date: Fri, 16 May 2025 13:56:14 -0500 Subject: [PATCH 32/82] feat: add types for unified bridge ui (#5783) ## Explanation Adds `isUnifiedUIEnabled` flag to `ChainConfiguration` feature-flag type and update validators accordingly. This is required as we are adding a feature flag to support the development of a unified bridge/swap UI. ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 1 + packages/bridge-controller/src/types.ts | 1 + packages/bridge-controller/src/utils/validators.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 8513141d315..d21b21b626f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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)) diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 50a7fb46da4..325f7891d7e 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 = { diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index acfbf0f02bc..7f681199473 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -59,6 +59,7 @@ export const validateFeatureFlagsResponse = ( isActiveDest: boolean(), refreshRate: optional(number()), topAssets: optional(array(string())), + isUnifiedUIEnabled: optional(boolean()), }); const PlatformConfigSchema = type({ From 5e355fd760e343b7424cf84482575fff73ec4c11 Mon Sep 17 00:00:00 2001 From: hunty Date: Mon, 19 May 2025 14:15:36 -0500 Subject: [PATCH 33/82] Release/403.0.0 (#5826) ## Explanation Releasing these package versions to add the isUnifiedUIEnabled feature flag to use within the bridge and swap experience @metamask/bridge-controller @ 25.1.0 Draft PR for extension: https://github.com/MetaMask/metamask-extension/pull/32699 ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> --- package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 5 ++++- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/package.json | 2 +- yarn.lock | 4 ++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 66b933c9407..b8bb2fec9d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "402.0.0", + "version": "403.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d21b21b626f..68c2a0f0283 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -257,7 +259,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.1.0...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 788c4622c88..df7db2542e1 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "25.0.1", + "version": "25.1.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 8122eac5fd4..f4badea1525 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^25.0.1", + "@metamask/bridge-controller": "^25.1.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", diff --git a/yarn.lock b/yarn.lock index a777d3127da..2c1cd20c0f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^25.0.1, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^25.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2744,7 +2744,7 @@ __metadata: "@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:^25.0.1" + "@metamask/bridge-controller": "npm:^25.1.0" "@metamask/controller-utils": "npm:^11.9.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" From c767a57da0d565a3b8515e44520c2c34d6823d2e Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 20 May 2025 12:04:57 +0200 Subject: [PATCH 34/82] fix: Add `userFeeLevel` as `dappSuggested` initially when `txParams` contains gas values for `legacy` transactions (#5821) ## Explanation This PR aims to add `userFeeLevel` as `dappSuggested` initially when `txParams` contains gas values also for `legacy` transactions. ## References * Issue found while implementing gas modal: https://github.com/MetaMask/metamask-mobile/pull/15234 ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 1 + .../transaction-controller/src/TransactionController.ts | 2 +- .../transaction-controller/src/utils/gas-fees.test.ts | 8 -------- packages/transaction-controller/src/utils/gas-fees.ts | 7 +------ 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 6f0937efa49..c893ee9a3d4 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) ## [56.1.0] diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 2adc50a9282..02233a16c74 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -118,11 +118,11 @@ import type { GasFeeEstimateLevel as GasFeeEstimateLevelType, } from './types'; import { + GasFeeEstimateLevel, TransactionEnvelopeType, TransactionType, TransactionStatus, SimulationErrorCode, - GasFeeEstimateLevel, } from './types'; import { addTransactionBatch, diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 9db8e481746..02672ea29a2 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({ diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index c78634af4cc..25666c44f17 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -283,12 +283,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; From dbe50971a43732e3d697a9f978cc1e99efd0cb0c Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 20 May 2025 13:49:23 +0100 Subject: [PATCH 35/82] feat: add sequential batch support (#5762) ## Explanation The `TransactionController` currently lacks support for sequential batch transactions, which are required for the stablecoin lending feature. Specifically, there is no mechanism to execute multiple transactions (e.g., approval + token deposit) sequentially while ensuring confirmation for each transaction. This limitation prevents efficient batch processing to support new features. **What solution do these changes offer?** The `SequentialPublishBatchHook` introduces a default mechanism for handling batch transactions when no custom `publishBatchHook` is provided. It ensures transactions are published sequentially, waiting for confirmation before proceeding to the next. If any transaction fails to publish or confirm, the batch process halts and throws an error. **Key Features:** - Sequential Execution: Publishes transactions one at a time and waits for confirmation. - Error Handling: Halts the batch if any transaction fails to publish or confirm. - Polling for Confirmation: Retries confirmation checks up to a maximum number of attempts. ## References Fixes https://github.com/MetaMask/MetaMask-planning/issues/4695 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/TransactionController.ts | 16 + .../helpers/PendingTransactionTracker.test.ts | 49 ++ .../src/helpers/PendingTransactionTracker.ts | 38 +- .../hooks/SequentialPublishBatchHook.test.ts | 445 ++++++++++++++++++ .../src/hooks/SequentialPublishBatchHook.ts | 221 +++++++++ .../src/utils/batch.test.ts | 167 ++++++- .../transaction-controller/src/utils/batch.ts | 29 +- 8 files changed, 953 insertions(+), 16 deletions(-) create mode 100644 packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts create mode 100644 packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c893ee9a3d4..aa85eb11397 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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)) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 02233a16c74..beb67f49614 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1024,6 +1024,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), @@ -1036,6 +1041,17 @@ 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, + }), }); } 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/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index a32aaedb478..5fe2e5ed063 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -24,6 +24,7 @@ import { TransactionType, } from '..'; import { flushPromises } from '../../../../tests/helpers'; +import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import type { PublishBatchHook } from '../types'; jest.mock('./eip7702'); @@ -35,6 +36,8 @@ jest.mock('./validation', () => ({ validateBatchRequest: jest.fn(), })); +jest.mock('../hooks/SequentialPublishBatchHook'); + type AddBatchTransactionOptions = Parameters[0]; const CHAIN_ID_MOCK = '0x123'; @@ -77,6 +80,9 @@ describe('Batch Utils', () => { 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 +109,14 @@ describe('Batch Utils', () => { AddBatchTransactionOptions['updateTransaction'] >; + let publishTransactionMock: jest.MockedFn< + AddBatchTransactionOptions['publishTransaction'] + >; + + let getPendingTransactionTrackerMock: jest.MockedFn< + AddBatchTransactionOptions['getPendingTransactionTracker'] + >; + let request: AddBatchTransactionOptions; beforeEach(() => { @@ -110,6 +124,8 @@ describe('Batch Utils', () => { addTransactionMock = jest.fn(); getChainIdMock = jest.fn(); updateTransactionMock = jest.fn(); + publishTransactionMock = jest.fn(); + getPendingTransactionTrackerMock = jest.fn(); determineTransactionTypeMock.mockResolvedValue({ type: TransactionType.simpleSend, @@ -148,6 +164,8 @@ describe('Batch Utils', () => { ], }, updateTransaction: updateTransactionMock, + publishTransaction: publishTransactionMock, + getPendingTransactionTracker: getPendingTransactionTrackerMock, }; }); @@ -967,15 +985,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(); @@ -1078,6 +1087,146 @@ 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, + ); + + publishHooks[0]?.( + TRANSACTION_META_MOCK, + TRANSACTION_SIGNATURE_MOCK, + ).catch(() => { + // Intentionally empty + }); + + publishHooks[1]?.( + TRANSACTION_META_MOCK, + TRANSACTION_SIGNATURE_2_MOCK, + ).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + }; + + it('calls sequentialPublishBatchHook when publishBatchHook is undefined', async () => { + sequentialPublishBatchHook.mockResolvedValueOnce({ + results: [ + { + transactionHash: TRANSACTION_HASH_MOCK, + }, + { + transactionHash: TRANSACTION_HASH_2_MOCK, + }, + ], + }); + + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); + + addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + await executePublishHooks(); + + 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('throws if sequentialPublishBatchHook does not return a result', async () => { + const publishBatchHookMock: jest.MockedFn = jest.fn(); + publishBatchHookMock.mockResolvedValueOnce(undefined); + setupSequentialPublishBatchHookMock(() => publishBatchHookMock); + + const resultPromise = addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }); + + resultPromise.catch(() => { + // Intentionally empty + }); + + await flushPromises(); + await executePublishHooks(); + + await expect(resultPromise).rejects.toThrow( + 'Publish batch hook did not return a result', + ); + await flushPromises(); + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + }); + + it('handles individual transaction failures when using sequentialPublishBatchHook', 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); + }); + }); }); describe('isAtomicBatchSupported', () => { diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 84af05b244c..38845bb84a5 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -23,7 +23,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, @@ -58,6 +60,13 @@ type AddTransactionBatchRequest = { options: { transactionId: string }, callback: (transactionMeta: TransactionMeta) => void, ) => void; + publishTransaction: ( + _ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + getPendingTransactionTracker: ( + networkClientId: string, + ) => PendingTransactionTracker; }; type IsAtomicBatchSupportedRequestInternal = { @@ -173,7 +182,7 @@ export async function isAtomicBatchSupported( } /** - * Generate a tranasction batch ID. + * Generate a transaction batch ID. * * @returns A unique batch ID as a hexadecimal string. */ @@ -309,7 +318,9 @@ async function addTransactionBatchWith7702( log('Security request', securityRequest); + /* istanbul ignore next */ validateSecurity(securityRequest, chainId).catch((error) => { + /* istanbul ignore next */ log('Security validation failed', error); }); } @@ -349,7 +360,8 @@ async function addTransactionBatchWith7702( async function addTransactionBatchWithHook( request: AddTransactionBatchRequest, ): Promise { - const { publishBatchHook, request: userRequest } = request; + const { publishBatchHook: requestPublishBatchHook, request: userRequest } = + request; const { from, @@ -359,10 +371,15 @@ async function addTransactionBatchWithHook( 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 batchId = generateBatchId(); const transactionCount = nestedTransactions.length; From f3c5b99a54dea45b3623a79e8320a959bd39baa0 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 20 May 2025 10:06:45 -0700 Subject: [PATCH 36/82] fix: don't poll for swap status (#5831) ## Explanation After a swap is submitted the status controller keeps calling getTxStatus. This is unnecessary and can cause clients to keep polling until the user clears their activity log ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../bridge-status-controller/CHANGELOG.md | 4 ++ .../src/bridge-status-controller.test.ts | 33 +++++++++++++++ .../src/bridge-status-controller.ts | 40 ++++++++++++++----- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 57d9dcc3041..907747f9c5e 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` peer 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 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 a93f593c5a0..e7304e9cf92 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -398,6 +398,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', @@ -606,6 +638,7 @@ describe('BridgeStatusController', () => { txHistory: { ...MockTxHistory.getPending(), ...MockTxHistory.getUnknown(), + ...MockTxHistory.getPendingSwap(), }, }, clientId: BridgeClientId.EXTENSION, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 28586cf788d..337a214f8a4 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -228,6 +228,14 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const isBridgeTx = isCrossChain( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ); + return isBridgeTx; }); incompleteHistoryItems.forEach((historyItem) => { @@ -241,12 +249,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { const { @@ -296,10 +299,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() From 452c0c3e5028ddb632398bddb63dbe364083a08f Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 20 May 2025 10:34:24 -0700 Subject: [PATCH 37/82] Release/404.0.0 (#5832) ## Explanation bumps @metamask/bridge-status-controller to v22, which removes unnecessary swap getTxStatus calls ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/bridge-status-controller/CHANGELOG.md | 5 ++++- packages/bridge-status-controller/package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b8bb2fec9d3..a95d3edf519 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "403.0.0", + "version": "404.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 907747f9c5e..8422588d797 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.0.0] + ### Added - Error logs for invalid getTxStatus responses ([#5816](https://github.com/MetaMask/core/pull/5816)) @@ -239,7 +241,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@21.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@22.0.0...HEAD +[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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index f4badea1525..daf859391ea 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": "21.0.0", + "version": "22.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", From 4bcb696542bb031864a2cc2d3ecbab15c33991e6 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 20 May 2025 19:48:40 +0200 Subject: [PATCH 38/82] fix: add optional account for fetching historical prices (#5833) ## Explanation PR to add an optional account to fetchHistoricalPricesForAsset to be used instead of accountsController getSelectedMultichainAccount. ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/assets-controllers/CHANGELOG.md | 1 + .../MultichainAssetsRatesController.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 938840f74e2..3fd05c41f4c 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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)) 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, From 802f9905736844757d9bfeef25e6ebe4b91098a2 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 21 May 2025 05:45:20 +0900 Subject: [PATCH 39/82] chore: add minimumVersion field and fix tests (#5834) ## Explanation This PR adds the field `minimumVersion` to the Bridge feature flags. ## References https://consensyssoftware.atlassian.net/browse/MMS-2459 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/bridge-controller/CHANGELOG.md | 4 ++++ .../bridge-controller/src/bridge-controller.test.ts | 1 + packages/bridge-controller/src/constants/bridge.ts | 1 + packages/bridge-controller/src/selectors.test.ts | 8 ++++++++ packages/bridge-controller/src/types.ts | 1 + .../bridge-controller/src/utils/feature-flags.test.ts | 10 ++++++++++ .../bridge-controller/src/utils/validators.test.ts | 3 +++ packages/bridge-controller/src/utils/validators.ts | 1 + 8 files changed, 29 insertions(+) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 68c2a0f0283..762b74d4bbc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Added a required `minimumVersion` to feature flag response schema ([#5834](https://github.com/MetaMask/core/pull/5834)) + ## [25.1.0] ### Added diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index d832b88b49b..c92e40d1bd0 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -115,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, 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/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 325f7891d7e..88e390c8d94 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -326,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/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts index 7d42ff76699..e84cc42955f 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,6 +351,7 @@ describe('feature-flags', () => { maxRefreshCount: 5, refreshRate: 30000, support: false, + minimumVersion: '0.0.0', chains: {}, }; expect(result).toStrictEqual(expectedBridgeConfig); diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 12428474708..9ccc8b74732 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, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 7f681199473..b1d4d1e0aeb 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -63,6 +63,7 @@ export const validateFeatureFlagsResponse = ( }); const PlatformConfigSchema = type({ + minimumVersion: string(), refreshRate: number(), maxRefreshCount: number(), support: boolean(), From b98915950c7a8cad92d5e419cd7b30c762c8919d Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 21 May 2025 06:22:38 +0900 Subject: [PATCH 40/82] chore: add a test for extra fields in validator (#5835) ## Explanation This PR adds a test for extra fields in the Bridge feature flags response. ## References Related to https://github.com/MetaMask/core/pull/5834 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../src/utils/validators.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 9ccc8b74732..76538f6238e 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -105,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', ({ From 1f8c7cc0ca048e3ceaa6e5966ccf5cd444811ff8 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Tue, 20 May 2025 22:08:51 -0700 Subject: [PATCH 41/82] chore: EVM swap tx submission and events (#5829) ## Explanation - marks swap transactions that go through the BridgeController as swaps - subscribes to tx confirmations and emits swap metrics ## References Fixes https://consensyssoftware.atlassian.net/browse/MMS-2448 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../bridge-status-controller/CHANGELOG.md | 14 +- .../bridge-status-controller/package.json | 2 + .../bridge-status-controller.test.ts.snap | 1329 +++++++++++++++-- .../src/bridge-status-controller.test.ts | 617 +++++++- .../src/bridge-status-controller.ts | 62 +- .../bridge-status-controller/src/index.ts | 2 - .../bridge-status-controller/src/types.ts | 22 +- .../src/utils/transaction.ts | 10 +- .../src/utils/validators.test.ts | 11 + .../tsconfig.build.json | 1 + .../bridge-status-controller/tsconfig.json | 3 +- yarn.lock | 2 + 12 files changed, 1920 insertions(+), 155 deletions(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 8422588d797..97eda4b7238 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,15 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Subscribe to TransactionController and MultichainTransactionsController tx confirmed and failed events for swaps ([#5829](https://github.com/MetaMask/core/pull/5829)) + +### 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)) + ## [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 -- Bump `@metamask/bridge-controller` peer dependency to `^25.0.1` ([#5811](https://github.com/MetaMask/core/pull/5811)) +- **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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index daf859391ea..ab8911cb2a2 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -62,6 +62,7 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/bridge-controller": "^25.1.0", "@metamask/gas-fee-controller": "^23.0.0", + "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/transaction-controller": "^56.1.0", @@ -80,6 +81,7 @@ "@metamask/accounts-controller": "^29.0.0", "@metamask/bridge-controller": "^25.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": "^56.0.0" 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 df881b505fc..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,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should delay after submitting linea approval 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 4`] = ` Array [ Array [ Object { @@ -557,7 +574,7 @@ Array [ ] `; -exports[`BridgeStatusController submitTx: EVM should handle smart accounts (4337) 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart accounts (4337) 1`] = ` Object { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -576,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", @@ -677,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 { @@ -697,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", @@ -759,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 { @@ -784,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", @@ -814,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", @@ -915,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 { @@ -935,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 { @@ -960,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", @@ -1019,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", @@ -1038,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", @@ -1139,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 { @@ -1189,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 { @@ -1254,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", @@ -1349,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", @@ -1368,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", @@ -1469,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 { @@ -1514,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", @@ -1590,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", @@ -1609,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", @@ -1710,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 { @@ -1730,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 { @@ -1755,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", @@ -1817,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 { @@ -1842,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", @@ -1858,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 { @@ -1883,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", @@ -1902,113 +1919,1043 @@ 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 [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], Array [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Snap Confirmation Page Viewed", - Object {}, + "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, - "isSolana": true, - "networkClientId": "test-snap", - "origin": "test-snap", - "sourceTokenAddress": "native", - "sourceTokenAmount": "1000000000", - "sourceTokenDecimals": 9, - "sourceTokenSymbol": "SOL", - "status": "submitted", - "swapTokenValue": "1", - "time": 1234567890, - "txParams": Object { + "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", + "sourceTokenAddress": "native", + "sourceTokenAmount": "1000000000", + "sourceTokenDecimals": 9, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1", + "time": 1234567890, + "txParams": Object { "data": "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=", "from": "0x123...", }, @@ -2238,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 e7304e9cf92..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, @@ -497,6 +523,7 @@ const getMessengerMock = ({ } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -559,6 +586,7 @@ 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(), @@ -599,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 @@ -770,6 +799,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -842,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(); @@ -888,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 @@ -938,6 +946,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1026,6 +1035,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1112,6 +1122,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1212,6 +1223,7 @@ describe('BridgeStatusController', () => { } return null; }), + subscribe: mockMessengerSubscribe, publish: jest.fn(), registerActionHandler: jest.fn(), registerInitialEventPayload: jest.fn(), @@ -1505,7 +1517,7 @@ describe('BridgeStatusController', () => { }); }); - describe('submitTx: EVM', () => { + describe('submitTx: EVM bridge', () => { const mockEvmQuoteResponse = { ...getMockQuote(), quote: { @@ -1909,4 +1921,553 @@ describe('BridgeStatusController', () => { 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 337a214f8a4..0e731717485 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -175,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 @@ -395,21 +440,12 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, approvalTxId?: string, ) => { return await this.#handleEvmTransaction( - TransactionType.bridge, + isBridgeTx ? TransactionType.bridge : TransactionType.swap, trade, quoteResponse, approvalTxId, @@ -840,6 +879,7 @@ export class BridgeStatusController extends StaticIntervalPollingController await this.#handleEvmSmartTransaction( + isBridgeTx, quoteResponse.trade as TxData, quoteResponse, approvalTxId, 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/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 17ec15017ff..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 }) => { 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/yarn.lock b/yarn.lock index 2c1cd20c0f1..a54425d674b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2748,6 +2748,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.9.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.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" @@ -2771,6 +2772,7 @@ __metadata: "@metamask/accounts-controller": ^29.0.0 "@metamask/bridge-controller": ^25.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": ^56.0.0 From 5d5ba4829eb92f6cc357cc7f29552fb1cac70da0 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Wed, 21 May 2025 13:08:57 +0200 Subject: [PATCH 42/82] fix: Remove lingering decimal in return of `gweiDecimalToWeiDecimal` (#5839) ## Explanation This PR aims to fix minor issue on `gweiDecimalToWeiDecimal` utility. If given value is more than 9 decimal places then generated WEI value will have decimal part which we don't expect / want in WEI value. ## References Fixes: https://github.com/MetaMask/MetaMask-planning/issues/4973 ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 1 + .../src/utils/gas-fees.test.ts | 33 +++++++++++++++++++ .../src/utils/gas-fees.ts | 7 ++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index aa85eb11397..a83fd2e581e 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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] diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 02672ea29a2..801f629526d 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -580,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 25666c44f17..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]; } /** From 29c0a6a6b96d459412cd608cbb8270d0396c3cb2 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 21 May 2025 23:04:36 +0900 Subject: [PATCH 43/82] chore: update bridge config to v2 (#5837) ## Explanation This PR consumes the new `bridgeConfigV2` field from LaunchDarkly. ## References Related to #5834, #5835 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Co-authored-by: Bryan Fullam --- packages/bridge-controller/CHANGELOG.md | 4 + packages/bridge-controller/src/index.ts | 2 + .../src/utils/feature-flags.test.ts | 102 ++++++++++++++++++ .../src/utils/feature-flags.ts | 12 ++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 762b74d4bbc..7ed31c4910e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index bc70fc9fe41..f61ca7014ee 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -133,3 +133,5 @@ export { selectIsQuoteExpired, selectBridgeFeatureFlags, } from './selectors'; + +export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge'; diff --git a/packages/bridge-controller/src/utils/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts index e84cc42955f..febd6c0bf49 100644 --- a/packages/bridge-controller/src/utils/feature-flags.test.ts +++ b/packages/bridge-controller/src/utils/feature-flags.test.ts @@ -356,5 +356,107 @@ describe('feature-flags', () => { }; 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); } From 7a55ad32f50f625e8726f3b0c82cc4576114a964 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Wed, 21 May 2025 21:00:01 +0300 Subject: [PATCH 44/82] Release/405.0.0 (#5842) ## Explanation Releasing newest `BridgeController` and `BridgeStatusController` ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: IF <139582705+infiniteflower@users.noreply.github.com> --- package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 5 ++++- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/CHANGELOG.md | 6 +++++- packages/bridge-status-controller/package.json | 6 +++--- yarn.lock | 6 +++--- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a95d3edf519..f76cf97750a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "404.0.0", + "version": "405.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 7ed31c4910e..a68f0507601 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [26.0.0] + ### Added - **BREAKING:** Added a required `minimumVersion` to feature flag response schema ([#5834](https://github.com/MetaMask/core/pull/5834)) @@ -267,7 +269,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@26.0.0...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index df7db2542e1..29bec0a1d0d 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "25.1.0", + "version": "26.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 97eda4b7238..8d2da6564c2 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -253,7 +256,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@23.0.0...HEAD +[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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index ab8911cb2a2..76252b8e7ee 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": "22.0.0", + "version": "23.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^25.1.0", + "@metamask/bridge-controller": "^26.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.5.0", @@ -79,7 +79,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/bridge-controller": "^25.0.0", + "@metamask/bridge-controller": "^26.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.0.0", diff --git a/yarn.lock b/yarn.lock index a54425d674b..8858e91fe20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^25.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^26.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2744,7 +2744,7 @@ __metadata: "@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:^25.1.0" + "@metamask/bridge-controller": "npm:^26.0.0" "@metamask/controller-utils": "npm:^11.9.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" @@ -2770,7 +2770,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^29.0.0 - "@metamask/bridge-controller": ^25.0.0 + "@metamask/bridge-controller": ^26.0.0 "@metamask/gas-fee-controller": ^23.0.0 "@metamask/multichain-transactions-controller": ^1.0.0 "@metamask/network-controller": ^23.0.0 From 1e14fed356a270f83680c0d05a6342eab6eef5fe Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Thu, 22 May 2025 10:08:02 +0200 Subject: [PATCH 45/82] Release/406.0.0 (#5845) ## Explanation This PR aims to release latest `transaction-controller` changes. ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/package.json | 2 +- packages/bridge-status-controller/package.json | 2 +- packages/earn-controller/package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 5 ++++- packages/transaction-controller/package.json | 2 +- packages/user-operation-controller/package.json | 2 +- yarn.lock | 12 ++++++------ 9 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index f76cf97750a..6f98ad9a5ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "405.0.0", + "version": "406.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 09ab9238c94..a260dd9f071 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -90,7 +90,7 @@ "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/snaps-sdk": "^6.22.0", - "@metamask/transaction-controller": "^56.1.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 29bec0a1d0d..b6d0f9dc3e6 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -72,7 +72,7 @@ "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^56.1.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 76252b8e7ee..8446478c1c9 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -65,7 +65,7 @@ "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.5.0", "@metamask/snaps-controllers": "^11.2.1", - "@metamask/transaction-controller": "^56.1.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index b303ddb5170..f1f3227f52b 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -56,7 +56,7 @@ "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/network-controller": "^23.5.0", - "@metamask/transaction-controller": "^56.1.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index a83fd2e581e..63d6b3774b4 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [56.2.0] + ### Added - Add sequential batch support when `publishBatchHook` is not defined ([#5762](https://github.com/MetaMask/core/pull/5762)) @@ -1611,7 +1613,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/transaction-controller@56.1.0...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 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index d505bcf5efd..6ee27087719 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "56.1.0", + "version": "56.2.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 99abe2dd5ba..c41b92a0d36 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -67,7 +67,7 @@ "@metamask/gas-fee-controller": "^23.0.0", "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.5.0", - "@metamask/transaction-controller": "^56.1.0", + "@metamask/transaction-controller": "^56.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/yarn.lock b/yarn.lock index 8858e91fe20..c06348c6398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,7 +2589,7 @@ __metadata: "@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:^56.1.0" + "@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" @@ -2713,7 +2713,7 @@ __metadata: "@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:^56.1.0" + "@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" @@ -2753,7 +2753,7 @@ __metadata: "@metamask/polling-controller": "npm:^13.0.0" "@metamask/snaps-controllers": "npm:^11.2.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^56.1.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" @@ -3005,7 +3005,7 @@ __metadata: "@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:^56.1.0" + "@metamask/transaction-controller": "npm:^56.2.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" @@ -4460,7 +4460,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/transaction-controller@npm:^56.1.0, @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: @@ -4533,7 +4533,7 @@ __metadata: "@metamask/polling-controller": "npm:^13.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^56.1.0" + "@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" From e1b135c13217cdbd205b0f33995f82bb0359329e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Thu, 22 May 2025 11:48:10 +0200 Subject: [PATCH 46/82] feat(accounts-controller): Add `entropySource` to new `InternalAccount` (#5841) ## Explanation `AccountsController:accountAdded` events were missing the `.options.entropySource` property, causing account syncing to misbehave, registering new accounts to the primary SRP instead of the actual SRP used to add the account. We now add the `entropySource` to these new `InternalAccount`s based on the `keyring` that was used to create them. ## References Relates to #5618 Relates to #5725 Relates to #5753 --- packages/accounts-controller/CHANGELOG.md | 4 ++ .../src/AccountsController.test.ts | 43 ++++++++++++++++--- .../src/AccountsController.ts | 10 +++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index bbcd21227ed..0ff09f7ad04 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,10 @@ 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 diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index d0241c9be5e..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. * @@ -621,7 +644,7 @@ describe('AccountsController', () => { expect(accounts).toStrictEqual([ mockAccount, - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ]); }); @@ -876,6 +899,9 @@ describe('AccountsController', () => { name: 'Account 3', address: mockAccount3.address, keyringType: KeyringTypes.hd, + options: { + entropySource: 'mock-id', + }, }), ), ]); @@ -941,7 +967,9 @@ describe('AccountsController', () => { name: 'Account 3', address: mockAccount3.address, keyringType: KeyringTypes.hd, - options: {}, + options: { + entropySource: 'mock-id', + }, }), ]); }); @@ -1044,7 +1072,7 @@ describe('AccountsController', () => { expect(accounts).toStrictEqual([ mockAccount, - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ]); expect(accountsController.getSelectedAccount().id).toBe(mockAccount.id); }); @@ -1093,7 +1121,7 @@ describe('AccountsController', () => { // 2. AccountsController:stateChange 3, 'AccountsController:accountAdded', - setLastSelectedAsAny(mockAccount2), + setLastSelectedAsAny(populateEntropySource(mockAccount2, 'mock-id')), ); }); }); @@ -1407,6 +1435,9 @@ describe('AccountsController', () => { name: 'Account 1', address: '0x456', keyringType: KeyringTypes.hd, + options: { + entropySource: 'mock-id', + }, }); mockUUIDWithNormalAccounts([ diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 7bf59e80e65..770fe5509aa 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -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]); From 10309a91e61067806189daf421d09f66dcfa5496 Mon Sep 17 00:00:00 2001 From: Ziad Saab Date: Thu, 22 May 2025 11:35:18 -0500 Subject: [PATCH 47/82] Add swappable param to discovery endpoints (#5819) ## Explanation * What is the current state of things and why does it need to change? token discovery controller doesn't know about the swappable* endpoints of the token discovery API * What is the solution your changes offer and how does it work? add swappable* versions of the discovery endpoints to the controller + api service * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? no * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? N/A * If you had to upgrade a dependency, why did you do so? N/A ## References MMPD-1626 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../CHANGELOG.md | 1 + .../token-discovery-api-service.test.ts | 16 +++ .../token-discovery-api-service.ts | 101 ++++-------------- .../src/types.ts | 5 +- 4 files changed, 41 insertions(+), 82 deletions(-) diff --git a/packages/token-search-discovery-controller/CHANGELOG.md b/packages/token-search-discovery-controller/CHANGELOG.md index 80597eed0cc..e933d500d40 100644 --- a/packages/token-search-discovery-controller/CHANGELOG.md +++ b/packages/token-search-discovery-controller/CHANGELOG.md @@ -10,6 +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)) +- Add `swappable` param to token discovery controller and API service ([#5819](https://github.com/MetaMask/core/pull/5819)) ## [3.1.0] 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; }; From 6369a542f4d0a4eeab5741576652af104921bbb9 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 22 May 2025 14:44:33 -0600 Subject: [PATCH 48/82] remote-feature-flag-controller: Fix flaky test (#5730) The test for `generateDeterministicRandomNumber` sometimes fails because it relies on the behavior of `uuidv4`, which is non-deterministic, and needs to be more lenient in the range of acceptable return values. --- .../src/utils/user-segmentation-utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) => { From a5384b951ca9bfba5c4223bbaef494092bdcf60d Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 23 May 2025 10:05:53 +0100 Subject: [PATCH 49/82] Release/407.0.0 (#5850) ## Explanation Bumps `@metamask/assets-controllers` from `63.0.0` to `63.1.0` ## References This is a non breaking build. Test drive mobile PR. https://github.com/MetaMask/metamask-mobile/pull/15558 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/assets-controllers/CHANGELOG.md | 7 +++++-- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/package.json | 2 +- yarn.lock | 4 ++-- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 6f98ad9a5ca..d5f29390096 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "406.0.0", + "version": "407.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 3fd05c41f4c..f4e784a6d0e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [63.1.0] + ### Changed -- Added optional`account` parameter to `fetchHistoricalPricesForAsset` method in `MultichainAssetsRatesController` ([#5833](https://github.com/MetaMask/core/pull/5833)) +- 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)) @@ -1636,7 +1638,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.1.0...HEAD +[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 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index a260dd9f071..a3aba4277c8 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "63.0.0", + "version": "63.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index b6d0f9dc3e6..04d7d48e03b 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -65,7 +65,7 @@ }, "devDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/assets-controllers": "^63.0.0", + "@metamask/assets-controllers": "^63.1.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/network-controller": "^23.5.0", diff --git a/yarn.lock b/yarn.lock index c06348c6398..0b1ef38698c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2555,7 +2555,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^63.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^63.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2699,7 +2699,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^29.0.0" - "@metamask/assets-controllers": "npm:^63.0.0" + "@metamask/assets-controllers": "npm:^63.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/controller-utils": "npm:^11.9.0" From a7807855cdd35bc50bad89906f57b80f9aa5e08b Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Fri, 23 May 2025 15:57:14 +0200 Subject: [PATCH 50/82] chore: reduce tokenBalances state updates (#5726) ## Explanation When a user removes an account; tokenBalances controller would still fetch balances for the tokens and update state. This was happening because tokensController does not remove tokens from state when a user removes the account. This PR cleans up tokens from tokensController state once a user removes an account. It also cleans up the balances from state when the user removes the account. This PR also adds a check to see if token balances has changed after fetching them; if none of the balances changed; no need to update state. ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- eslint-warning-thresholds.json | 3 - packages/assets-controllers/CHANGELOG.md | 14 + .../src/TokenBalancesController.test.ts | 376 +++++++++++++++++- .../src/TokenBalancesController.ts | 151 ++++++- .../src/TokensController.test.ts | 147 +++++++ .../src/TokensController.ts | 56 ++- 6 files changed, 721 insertions(+), 26 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 18526b5dd5b..1ccfeb8c6ef 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -73,9 +73,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, diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f4e784a6d0e..3b201f85b57 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 9d79d33468d..b96a998e1f4 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', + 'AccountsController: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,94 @@ describe('TokenBalancesController', () => { }); }); }); + + describe('when accountRemoved is published', () => { + it('does not update state if account removed is not in the list of accounts', async () => { + const { controller, messenger, updateSpy } = setupController(); + + messenger.publish( + 'AccountsController:accountRemoved', + '0x0000000000000000000000000000000000000000', + ); + + 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('AccountsController:accountRemoved', account.id); + + 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..62a667f9073 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -1,6 +1,10 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + AccountsControllerAccountRemovedEvent, + AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, +} from '@metamask/accounts-controller'; import type { RestrictedMessenger, ControllerGetStateAction, @@ -80,7 +84,8 @@ export type AllowedActions = | NetworkControllerGetStateAction | TokensControllerGetStateAction | PreferencesControllerGetStateAction - | AccountsControllerGetSelectedAccountAction; + | AccountsControllerGetSelectedAccountAction + | AccountsControllerListAccountsAction; export type TokenBalancesControllerStateChangeEvent = ControllerStateChangeEvent< @@ -94,7 +99,8 @@ export type TokenBalancesControllerEvents = export type AllowedEvents = | TokensControllerStateChangeEvent | PreferencesControllerStateChangeEvent - | NetworkControllerStateChangeEvent; + | NetworkControllerStateChangeEvent + | AccountsControllerAccountRemovedEvent; export type TokenBalancesControllerMessenger = RestrictedMessenger< typeof controllerName, @@ -185,6 +191,13 @@ export class TokenBalancesController extends StaticIntervalPollingController this.#handleOnAccountRemoved(accountId), + ); } /** @@ -242,8 +255,9 @@ export class TokenBalancesController extends StaticIntervalPollingController account.id === accountId, + )?.address; + if (!accountAddress) { + return; + } + + this.update((state) => { + 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 +344,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 +442,10 @@ export class TokenBalancesController extends StaticIntervalPollingController 0) { const provider = new Web3Provider( this.#getNetworkClient(chainId).provider, @@ -357,18 +462,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/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 5a691ccb597..5a241d4b527 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 = '0x123'; + const secondAddress = '0x456'; + 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.id); + + 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 = '0x123'; + const secondAddress = '0x456'; + 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.id); + + 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: (accountId: 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', + 'AccountsController: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 = (accountId: string) => { + messenger.publish('AccountsController:accountRemoved', accountId); + }; + 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..3cac5e0920a 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -1,8 +1,10 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { + AccountsControllerAccountRemovedEvent, AccountsControllerGetAccountAction, AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, AccountsControllerSelectedEvmAccountChangeEvent, } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; @@ -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 + | AccountsControllerAccountRemovedEvent; /** * The messenger of the {@link TokensController}. @@ -223,6 +227,12 @@ export class TokensController extends BaseController< this.#onNetworkStateChange.bind(this), ); + this.messagingSystem.subscribe( + 'AccountsController:accountRemoved', + (accountAddress: string) => + this.#handleOnAccountRemoved(accountAddress as Hex), + ); + this.messagingSystem.subscribe( 'TokenListController:stateChange', ({ tokensChainsCache }) => { @@ -260,6 +270,48 @@ export class TokensController extends BaseController< ); } + #handleOnAccountRemoved(accountId: string) { + // find the account address in allTokens, allDetectedTokens, allIgnoredTokens + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; + const accounts = this.messagingSystem.call( + 'AccountsController:listAccounts', + ); + const accountAddress = accounts.find( + (account) => account.id === accountId, + )?.address; + + if (!accountAddress) { + return; + } + 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. From 1f9c5970b594b2af7b5a103f32f81edbd426cc70 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Fri, 23 May 2025 16:15:10 +0200 Subject: [PATCH 51/82] Release/408.0.0 (#5854) ## Explanation PR to release assets-controller and bring the new token balances performance updates to mobile. ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/assets-controllers/CHANGELOG.md | 5 ++++- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 9 ++++++++- packages/bridge-controller/package.json | 6 +++--- packages/bridge-status-controller/CHANGELOG.md | 9 ++++++++- packages/bridge-status-controller/package.json | 6 +++--- yarn.lock | 12 ++++++------ 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index d5f29390096..945a3c45eed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "407.0.0", + "version": "408.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 3b201f85b57..1da18030c9e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -1652,7 +1654,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@63.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@64.0.0...HEAD +[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 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index a3aba4277c8..b0b25d910fc 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "63.1.0", + "version": "64.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index a68f0507601..8171fc0d933 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -269,7 +275,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@26.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@27.0.0...HEAD +[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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 04d7d48e03b..75e624baf4f 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "26.0.0", + "version": "27.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -65,7 +65,7 @@ }, "devDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/assets-controllers": "^63.1.0", + "@metamask/assets-controllers": "^64.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^4.1.8", "@metamask/network-controller": "^23.5.0", @@ -86,7 +86,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/assets-controllers": "^63.0.0", + "@metamask/assets-controllers": "^64.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/remote-feature-flag-controller": "^1.6.0", "@metamask/snaps-controllers": "^11.0.0", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 8d2da6564c2..0dd343cf97a 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -256,7 +262,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@23.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@24.0.0...HEAD +[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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 8446478c1c9..5c8e039be78 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": "23.0.0", + "version": "24.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^26.0.0", + "@metamask/bridge-controller": "^27.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.5.0", @@ -79,7 +79,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/bridge-controller": "^26.0.0", + "@metamask/bridge-controller": "^27.0.0", "@metamask/gas-fee-controller": "^23.0.0", "@metamask/multichain-transactions-controller": "^1.0.0", "@metamask/network-controller": "^23.0.0", diff --git a/yarn.lock b/yarn.lock index 0b1ef38698c..0cd3da451eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2555,7 +2555,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^63.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^64.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^26.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^27.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2699,7 +2699,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^29.0.0" - "@metamask/assets-controllers": "npm:^63.1.0" + "@metamask/assets-controllers": "npm:^64.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@metamask/controller-utils": "npm:^11.9.0" @@ -2729,7 +2729,7 @@ __metadata: typescript: "npm:~5.2.2" peerDependencies: "@metamask/accounts-controller": ^29.0.0 - "@metamask/assets-controllers": ^63.0.0 + "@metamask/assets-controllers": ^64.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/remote-feature-flag-controller": ^1.6.0 "@metamask/snaps-controllers": ^11.0.0 @@ -2744,7 +2744,7 @@ __metadata: "@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:^26.0.0" + "@metamask/bridge-controller": "npm:^27.0.0" "@metamask/controller-utils": "npm:^11.9.0" "@metamask/gas-fee-controller": "npm:^23.0.0" "@metamask/keyring-api": "npm:^17.4.0" @@ -2770,7 +2770,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^29.0.0 - "@metamask/bridge-controller": ^26.0.0 + "@metamask/bridge-controller": ^27.0.0 "@metamask/gas-fee-controller": ^23.0.0 "@metamask/multichain-transactions-controller": ^1.0.0 "@metamask/network-controller": ^23.0.0 From 0e40a5b5eb8ce1f47bd0f52d4db78c9d6dc2c913 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 23 May 2025 14:08:31 -0600 Subject: [PATCH 52/82] Add `@metamask/error-reporting-service` (#5849) This package makes it possible for any module to report an error to an error reporting app (such as Sentry). The exact mechanism to do so is customizable, and the service object exposes a messenger action so that modules can report the error without needing direct access to the service object itself. --- .github/CODEOWNERS | 3 +- README.md | 21 +- packages/error-reporting-service/CHANGELOG.md | 14 ++ packages/error-reporting-service/LICENSE | 20 ++ packages/error-reporting-service/README.md | 204 ++++++++++++++++++ .../error-reporting-service/jest.config.js | 26 +++ packages/error-reporting-service/package.json | 70 ++++++ .../src/error-reporting-service.test.ts | 65 ++++++ .../src/error-reporting-service.ts | 162 ++++++++++++++ packages/error-reporting-service/src/index.ts | 7 + .../tsconfig.build.json | 10 + .../error-reporting-service/tsconfig.json | 8 + packages/error-reporting-service/typedoc.json | 7 + teams.json | 3 +- tsconfig.build.json | 3 +- tsconfig.json | 1 + yarn.lock | 24 +++ 17 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 packages/error-reporting-service/CHANGELOG.md create mode 100644 packages/error-reporting-service/LICENSE create mode 100644 packages/error-reporting-service/README.md create mode 100644 packages/error-reporting-service/jest.config.js create mode 100644 packages/error-reporting-service/package.json create mode 100644 packages/error-reporting-service/src/error-reporting-service.test.ts create mode 100644 packages/error-reporting-service/src/error-reporting-service.ts create mode 100644 packages/error-reporting-service/src/index.ts create mode 100644 packages/error-reporting-service/tsconfig.build.json create mode 100644 packages/error-reporting-service/tsconfig.json create mode 100644 packages/error-reporting-service/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 647001339f4..79df2ab1298 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 eebd43dd694..9b866ce71d6 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) @@ -87,9 +89,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"]); @@ -136,19 +140,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; @@ -156,10 +168,14 @@ 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; earn_controller --> network_controller; + earn_controller --> transaction_controller; eip1193_permission_middleware --> chain_agnostic_permission; eip1193_permission_middleware --> controller_utils; eip1193_permission_middleware --> json_rpc_engine; @@ -183,10 +199,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/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/teams.json b/teams.json index a451d0366a1..f79b635cd0a 100644 --- a/teams.json +++ b/teams.json @@ -45,5 +45,6 @@ "metamask/user-operation-controller": "team-confirmations", "metamask/multichain-transactions-controller": "team-sol,team-accounts", "metamask/token-search-discovery-controller": "team-portfolio", - "metamask/earn-controller": "team-earn" + "metamask/earn-controller": "team-earn", + "metamask/error-reporting-service": "team-wallet-framework" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3f4ed8fb383..3b4db6fe5ce 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" }, @@ -24,12 +25,12 @@ { "path": "./packages/keyring-controller/tsconfig.build.json" }, { "path": "./packages/logging-controller/tsconfig.build.json" }, { "path": "./packages/message-manager/tsconfig.build.json" }, + { "path": "./packages/multichain-api-middleware/tsconfig.build.json" }, { "path": "./packages/multichain-network-controller/tsconfig.build.json" }, { "path": "./packages/multichain-transactions-controller/tsconfig.build.json" }, { "path": "./packages/multichain/tsconfig.build.json" }, - { "path": "./packages/multichain-api-middleware/tsconfig.build.json" }, { "path": "./packages/name-controller/tsconfig.build.json" }, { "path": "./packages/network-controller/tsconfig.build.json" }, { diff --git a/tsconfig.json b/tsconfig.json index e107fb6e545..ca474bd2a76 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 0cd3da451eb..fff38f7c314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3064,6 +3064,23 @@ __metadata: languageName: unknown linkType: soft +"@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" @@ -4949,6 +4966,13 @@ __metadata: languageName: node linkType: hard +"@sentry/core@npm:^9.22.0": + version: 9.22.0 + resolution: "@sentry/core@npm:9.22.0" + checksum: 10/5bf5d6b5402dca90c6ed1d6e8834c00067806f9710f1cbcd0dff3004c3f3b6ffae8e43d56592d5378fdbddb3d196eb60d8850ea50ca6eca8e31870608109df3d + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" From 3fb3df497b6816c749e207c98fe5d18fa676f6d4 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Fri, 23 May 2025 21:51:52 +0100 Subject: [PATCH 53/82] fix(rpc-service): improve error handling for HTTP status codes (#5843) ## Explanation Improves error handling in the RPC service by making it more specific and consistent. The changes include: - Clarifies error handling for different HTTP status codes: - 401: Unauthorized error - 402/404/5xx: Resource unavailable error - 405/501: Method not found error - 429: Rate limiting error - Other 4xx: Invalid request error - Invalid JSON: Parse error ## References Fixes https://github.com/MetaMask/core/issues/5844 ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/network-controller/CHANGELOG.md | 9 + .../src/rpc-service/rpc-service-chain.test.ts | 168 +++++++++++++++--- .../src/rpc-service/rpc-service-chain.ts | 20 +-- .../src/rpc-service/rpc-service.test.ts | 155 +++++++++++++--- .../src/rpc-service/rpc-service.ts | 64 ++++--- .../block-hash-in-response.ts | 98 +++++----- .../tests/provider-api-tests/block-param.ts | 135 +++++++------- .../provider-api-tests/no-block-param.ts | 98 +++++----- 8 files changed, 506 insertions(+), 241 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index a2bb4bd7f5e..b3cba08107c 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 + ## [23.5.0] ### Changed 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 e161b807eb3..ea3473147d7 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -308,32 +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, @@ -343,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, + }, + }), ); }); @@ -357,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, @@ -372,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)); @@ -385,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, @@ -400,7 +405,7 @@ describe('RpcService', () => { const promise = service.request({ id: 1, jsonrpc: '2.0', - method: 'eth_unknownMethod', + method: 'eth_chainId', params: [], }); await ignoreRejection(promise); @@ -408,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'; @@ -431,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 () => { @@ -491,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('/', { @@ -501,7 +608,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', @@ -520,7 +627,11 @@ describe('RpcService', () => { }); await expect(promise).rejects.toThrow( expect.objectContaining({ - message: "Non-200 status code: '500'", + code: -32100, + message: 'HTTP client error.', + data: { + httpStatus: 403, + }, }), ); }); @@ -534,7 +645,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', @@ -566,7 +677,7 @@ describe('RpcService', () => { method: 'eth_chainId', params: [], }) - .reply(500, { + .reply(403, { id: 1, jsonrpc: '2.0', error: 'oops', diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 7a1a9d6c96d..653913b85e2 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -8,7 +8,7 @@ import { 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, @@ -252,7 +252,9 @@ export class RpcService implements AbstractRpcService { error.message.includes('not valid JSON') || // Ignore server overload errors ('httpStatus' in error && - (error.httpStatus === 503 || error.httpStatus === 504)) || + (error.httpStatus === 502 || + error.httpStatus === 503 || + error.httpStatus === 504)) || (hasProperty(error, 'code') && (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) ); @@ -336,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' }, @@ -360,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, @@ -464,11 +466,11 @@ export class RpcService implements AbstractRpcService { * @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 #processRequest( fetchOptions: FetchOptions, @@ -485,27 +487,35 @@ export class RpcService implements AbstractRpcService { } catch (error) { if (error instanceof HttpError) { const status = error.httpStatus; - if (status === 405) { - throw rpcErrors.methodNotFound(); + 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 === 503 || 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.', + if (status >= 500 || status === 402 || status === 404) { + throw rpcErrors.resourceUnavailable({ + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: status, + }, }); } - throw rpcErrors.internal({ - message: `Non-200 status code: '${status}'`, + // 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.internal({ - message: 'Could not parse response as it is not valid JSON', + throw rpcErrors.parse({ + message: 'Could not parse response as it is not valid JSON.', }); } throw error; 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 4a5fa6bff81..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', @@ -367,62 +367,64 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( }, ); - 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}'`; + 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 }; + 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), - ); + // 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); + await expect(promiseForResult).rejects.toThrow(errorMessage); + }); }); - }); - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - getExpectedBreakError: () => - expect.objectContaining({ - message: `Fetch failed with status '500'`, + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], }), - }); - }); + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => + expect.objectContaining({ + message: errorMessage, + }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), + }); + }, + ); - 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) => { 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 27793c6009a..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', @@ -457,78 +457,80 @@ export function testsForRpcMethodSupportingBlockParam( }, ); - 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}'`; + 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 = { + 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 }), - }; - - // 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( + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( request, blockParamIndex, - '0x100', - ), - response: { - httpStatus, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow(errorMessage); + blockNumber, + ); + }, + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => + expect.objectContaining({ + message: errorMessage, + }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), }); - }); - - 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, - }), - getExpectedBreakError: () => - expect.objectContaining({ - message: `Fetch failed with status '500'`, - }), - }); - }); + }, + ); - 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) => { @@ -628,7 +630,6 @@ export function testsForRpcMethodSupportingBlockParam( await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); - testsForRpcFailoverBehavior({ providerType, requestToCall: { @@ -648,7 +649,9 @@ export function testsForRpcMethodSupportingBlockParam( isRetriableFailure: true, getExpectedError: () => expect.objectContaining({ - message: expect.stringContaining(`Gateway timeout`), + message: expect.stringContaining( + 'RPC endpoint not found or unavailable.', + ), }), getExpectedBreakError: () => expect.objectContaining({ 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 9b50ffdbddb..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', @@ -323,62 +323,64 @@ export function testsForRpcMethodAssumingNoBlockParam( }, ); - 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}'`; + 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 }; + 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), - ); + // 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); + await expect(promiseForResult).rejects.toThrow(errorMessage); + }); }); - }); - testsForRpcFailoverBehavior({ - providerType, - requestToCall: { - method, - params: [], - }, - getRequestToMock: () => ({ - method, - params: [], - }), - failure: { - httpStatus, - }, - isRetriableFailure: false, - getExpectedError: () => - expect.objectContaining({ - message: errorMessage, - }), - getExpectedBreakError: () => - expect.objectContaining({ - message: `Fetch failed with status '500'`, + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], }), - }); - }); + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => + expect.objectContaining({ + message: errorMessage, + }), + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), + }); + }, + ); - 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) => { From 68b03669f980f1d794b55f57e2562866b42a60ec Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 26 May 2025 12:29:34 +0200 Subject: [PATCH 54/82] feat(address-book): messaging updated and deleted events (#5779) ## Explanation - Emit events on contact updates and deletions. - List method to retrieve contacts stored locally. ## References Needed by: https://github.com/MetaMask/core/pull/5776 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Mathieu Artu --- eslint-warning-thresholds.json | 3 - packages/address-book-controller/CHANGELOG.md | 12 + .../src/AddressBookController.test.ts | 385 ++++++++++++++---- .../src/AddressBookController.ts | 169 ++++++-- packages/address-book-controller/src/index.ts | 5 + 5 files changed, 461 insertions(+), 113 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 1ccfeb8c6ef..3bc772d8258 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 diff --git a/packages/address-book-controller/CHANGELOG.md b/packages/address-book-controller/CHANGELOG.md index 225053c2054..016262f78b4 100644 --- a/packages/address-book-controller/CHANGELOG.md +++ b/packages/address-book-controller/CHANGELOG.md @@ -7,11 +7,23 @@ 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.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] ### Changed 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, From 8a945904769c05ff81910083515fb7acc8610644 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 26 May 2025 16:36:25 +0200 Subject: [PATCH 55/82] fix: detectTokens on tx confirmed (#5859) ## Explanation This PR - Adds subscription to `TransactionController:transactionConfirmed` event in `tokenDetectionController` to attempt to detect new tokens and update tokenList as soon as a transaction is confirmed as opposed to waiting for 3mins before updating state. - Adds account address along with accountId to payload when publishing `AccountsController:accountRemoved` event ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/assets-controllers/CHANGELOG.md | 8 ++ .../src/TokenBalancesController.test.ts | 11 +-- .../src/TokenBalancesController.ts | 31 +++--- .../src/TokenDetectionController.test.ts | 97 +++++++++++++++++-- .../src/TokenDetectionController.ts | 13 ++- .../src/TokensController.test.ts | 20 ++-- .../src/TokensController.ts | 28 +++--- 7 files changed, 152 insertions(+), 56 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 1da18030c9e..8510286bb81 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index b96a998e1f4..e8ea249d9f9 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -48,7 +48,7 @@ const setupController = ({ 'NetworkController:stateChange', 'PreferencesController:stateChange', 'TokensController:stateChange', - 'AccountsController:accountRemoved', + 'KeyringController:accountRemoved', ], }); @@ -747,13 +747,10 @@ describe('TokenBalancesController', () => { }); describe('when accountRemoved is published', () => { - it('does not update state if account removed is not in the list of accounts', async () => { + it('does not update state if account removed is EVM account', async () => { const { controller, messenger, updateSpy } = setupController(); - messenger.publish( - 'AccountsController:accountRemoved', - '0x0000000000000000000000000000000000000000', - ); + messenger.publish('KeyringController:accountRemoved', 'toto'); expect(controller.state.tokenBalances).toStrictEqual({}); expect(updateSpy).toHaveBeenCalledTimes(0); @@ -822,7 +819,7 @@ describe('TokenBalancesController', () => { }, }); - messenger.publish('AccountsController:accountRemoved', account.id); + messenger.publish('KeyringController:accountRemoved', account.address); await advanceTime({ clock, duration: 1 }); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 62a667f9073..bb669906618 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -1,7 +1,6 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { - AccountsControllerAccountRemovedEvent, AccountsControllerGetSelectedAccountAction, AccountsControllerListAccountsAction, } from '@metamask/accounts-controller'; @@ -10,7 +9,12 @@ import type { 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, @@ -24,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'; @@ -100,7 +104,7 @@ export type AllowedEvents = | TokensControllerStateChangeEvent | PreferencesControllerStateChangeEvent | NetworkControllerStateChangeEvent - | AccountsControllerAccountRemovedEvent; + | KeyringControllerAccountRemovedEvent; export type TokenBalancesControllerMessenger = RestrictedMessenger< typeof controllerName, @@ -195,8 +199,8 @@ export class TokenBalancesController extends StaticIntervalPollingController this.#handleOnAccountRemoved(accountId), + 'KeyringController:accountRemoved', + (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), ); } @@ -286,16 +290,13 @@ export class TokenBalancesController extends StaticIntervalPollingController account.id === accountId, - )?.address; - if (!accountAddress) { + #handleOnAccountRemoved(accountAddress: string) { + const isEthAddress = + isStrictHexString(accountAddress.toLowerCase()) && + isValidHexAddress(accountAddress); + if (!isEthAddress) { return; } 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/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 5a241d4b527..5dee2972864 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3342,8 +3342,8 @@ describe('TokensController', () => { describe('when accountRemoved is published', () => { it('removes the list of tokens for the removed account', async () => { - const firstAddress = '0x123'; - const secondAddress = '0x456'; + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; const firstAccount = createMockInternalAccount({ address: firstAddress, }); @@ -3393,7 +3393,7 @@ describe('TokensController', () => { ({ controller, triggerAccountRemoved }) => { expect(controller.state).toStrictEqual(initialState); - triggerAccountRemoved(firstAccount.id); + triggerAccountRemoved(firstAccount.address); expect(controller.state).toStrictEqual({ allTokens: { @@ -3422,8 +3422,8 @@ describe('TokensController', () => { }); it('removes an account with no tokens', async () => { - const firstAddress = '0x123'; - const secondAddress = '0x456'; + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; const firstAccount = createMockInternalAccount({ address: firstAddress, }); @@ -3462,7 +3462,7 @@ describe('TokensController', () => { ({ controller, triggerAccountRemoved }) => { expect(controller.state).toStrictEqual(initialState); - triggerAccountRemoved(secondAccount.id); + triggerAccountRemoved(secondAccount.address); expect(controller.state).toStrictEqual(initialState); }, @@ -3486,7 +3486,7 @@ type WithControllerCallback = ({ messenger: UnrestrictedMessenger; approvalController: ApprovalController; triggerSelectedAccountChange: (internalAccount: InternalAccount) => void; - triggerAccountRemoved: (accountId: string) => void; + triggerAccountRemoved: (accountAddress: string) => void; triggerNetworkStateChange: ( networkState: NetworkState, patches: Patch[], @@ -3568,7 +3568,7 @@ async function withController( 'NetworkController:stateChange', 'AccountsController:selectedEvmAccountChange', 'TokenListController:stateChange', - 'AccountsController:accountRemoved', + 'KeyringController:accountRemoved', ], }); @@ -3613,8 +3613,8 @@ async function withController( ); }; - const triggerAccountRemoved = (accountId: string) => { - messenger.publish('AccountsController:accountRemoved', accountId); + const triggerAccountRemoved = (accountAddress: string) => { + messenger.publish('KeyringController:accountRemoved', accountAddress); }; const changeNetwork = ({ diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 3cac5e0920a..d027677b9bd 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -1,7 +1,6 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { - AccountsControllerAccountRemovedEvent, AccountsControllerGetAccountAction, AccountsControllerGetSelectedAccountAction, AccountsControllerListAccountsAction, @@ -26,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 { @@ -37,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'; @@ -141,7 +141,7 @@ export type AllowedEvents = | NetworkControllerNetworkDidChangeEvent | TokenListStateChange | AccountsControllerSelectedEvmAccountChangeEvent - | AccountsControllerAccountRemovedEvent; + | KeyringControllerAccountRemovedEvent; /** * The messenger of the {@link TokensController}. @@ -228,9 +228,8 @@ export class TokensController extends BaseController< ); this.messagingSystem.subscribe( - 'AccountsController:accountRemoved', - (accountAddress: string) => - this.#handleOnAccountRemoved(accountAddress as Hex), + 'KeyringController:accountRemoved', + (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), ); this.messagingSystem.subscribe( @@ -270,19 +269,16 @@ export class TokensController extends BaseController< ); } - #handleOnAccountRemoved(accountId: string) { - // find the account address in allTokens, allDetectedTokens, allIgnoredTokens - const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - const accounts = this.messagingSystem.call( - 'AccountsController:listAccounts', - ); - const accountAddress = accounts.find( - (account) => account.id === accountId, - )?.address; + #handleOnAccountRemoved(accountAddress: string) { + const isEthAddress = + isStrictHexString(accountAddress.toLowerCase()) && + isValidHexAddress(accountAddress); - if (!accountAddress) { + if (!isEthAddress) { return; } + + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; const newAllTokens = cloneDeep(allTokens); const newAllDetectedTokens = cloneDeep(allDetectedTokens); const newAllIgnoredTokens = cloneDeep(allIgnoredTokens); From 86df720aa428a6b5993539e76bce0b2aed6e2384 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 26 May 2025 16:48:45 +0200 Subject: [PATCH 56/82] Release/409.0.0 (#5863) ## Explanation PR to release assets-controller v65. ## References ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/assets-controllers/CHANGELOG.md | 5 ++++- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 9 ++++++++- packages/bridge-controller/package.json | 6 +++--- packages/bridge-status-controller/CHANGELOG.md | 9 ++++++++- packages/bridge-status-controller/package.json | 6 +++--- yarn.lock | 12 ++++++------ 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 945a3c45eed..7821f6670c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "408.0.0", + "version": "409.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 8510286bb81..94f68057347 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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)) @@ -1662,7 +1664,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@64.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 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index b0b25d910fc..b0fb1349484 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "64.0.0", + "version": "65.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 8171fc0d933..a577fcc6847 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,12 @@ 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 @@ -275,7 +281,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@27.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 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 75e624baf4f..11fd855bfde 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "27.0.0", + "version": "28.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -65,7 +65,7 @@ }, "devDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/assets-controllers": "^64.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.5.0", @@ -86,7 +86,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/assets-controllers": "^64.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", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 0dd343cf97a..e685b9016ee 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,12 @@ 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 @@ -262,7 +268,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@24.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 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 5c8e039be78..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": "24.0.0", + "version": "25.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -60,7 +60,7 @@ "devDependencies": { "@metamask/accounts-controller": "^29.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bridge-controller": "^27.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.5.0", @@ -79,7 +79,7 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^29.0.0", - "@metamask/bridge-controller": "^27.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", diff --git a/yarn.lock b/yarn.lock index fff38f7c314..106315fe9f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2555,7 +2555,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^64.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: @@ -2689,7 +2689,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^27.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: @@ -2699,7 +2699,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^29.0.0" - "@metamask/assets-controllers": "npm:^64.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.9.0" @@ -2729,7 +2729,7 @@ __metadata: typescript: "npm:~5.2.2" peerDependencies: "@metamask/accounts-controller": ^29.0.0 - "@metamask/assets-controllers": ^64.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 @@ -2744,7 +2744,7 @@ __metadata: "@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:^27.0.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" @@ -2770,7 +2770,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^29.0.0 - "@metamask/bridge-controller": ^27.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 From 156c92bb757cc0ddf58b3b6adcc8df6e5c9cbcac Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Mon, 26 May 2025 11:03:35 -0500 Subject: [PATCH 57/82] add missing `promptToCreateSolanaAccount` flag (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation If solana is a requested scope but not supported, we add a `promptToCreateSolanaAccount` metadata flag to forward to the connection UI [See designs here for the "opt in" flow this enables](https://www.figma.com/design/ZQKVsVg1yqve25sUJkX12Z/Solana-opt-in-Scenarios?node-id=0-1&p=f&t=4CjFuTjVgcAu1xw3-0) Screenshot 2025-05-23 at 2 17 15 PM ## References [Extension PR ](https://github.com/MetaMask/metamask-extension/pull/31544)- since currently extension has its own `wallet_createSession` handler. We'll consolidate these soon ## Changelog `@metamask/multichain-api-middleware` - Added: when `wallet_createSession` is called with `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` as a requested scope, but there are not currently any accounts in the wallet supporting this scope, we add a `promptToCreateSolanaAccount` to a metadata object on the requestPermissions call. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- .../multichain-api-middleware/CHANGELOG.md | 4 + .../src/handlers/wallet-createSession.test.ts | 389 +++++++++++++++--- .../src/handlers/wallet-createSession.ts | 67 ++- 3 files changed, 389 insertions(+), 71 deletions(-) diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 63322e37e77..82493b252b4 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [0.3.0] ### Added 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]; From fa64c4aac4d99e586429154db0b28cc1377b3d54 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Mon, 26 May 2025 12:06:54 -0500 Subject: [PATCH 58/82] Release/410.0.0 (#5864) ## @metamask/multichain-api-middleware ## [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`. --- package.json | 2 +- packages/multichain-api-middleware/CHANGELOG.md | 7 +++++-- packages/multichain-api-middleware/package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 7821f6670c2..ad5a209757a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "409.0.0", + "version": "410.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 82493b252b4..9b5e1bf294d 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,9 +7,11 @@ 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. +- 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] @@ -53,7 +55,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/multichain-api-middleware@0.3.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 diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 0fa6bd42734..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.3.0", + "version": "0.4.0", "description": "JSON-RPC methods and middleware to support the MetaMask Multichain API", "keywords": [ "MetaMask", From 4e2d3d5561a2069f1e89767f55502220b0e2aa8b Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 27 May 2025 09:25:23 +0100 Subject: [PATCH 59/82] Adds `transactionBatches` into transaction controller state (#5793) ## Explanation This PR introduces support for batch transactions in the Transaction Controller by adding a new `ApprovalType` and extending the state to handle `TransactionBatches`. These changes enable enhanced metadata management for sequential batch flows, including UI updates for gas estimation and future automation capabilities. ## Changes ### Controller Utils - Added a new `ApprovalType` to the enum: `TransactionBatch`, to support batch transactions. ### Transaction Controller - Introduced a new state property: `transactionBatches`, to store metadata for transaction batches. - Added a private method: `addBatchMetadata`, responsible for populating batch-specific metadata. - Created a new type: `TransactionBatchMeta`, to manage metadata for transaction batches. - Add `addBatchMetadata` to store batch metadata and `wipeTransactionBatches` to clean up state after batch hook completion. - Update unit tests ## Rationale The introduction of `TransactionBatchMeta` allows for a clean separation of metadata for batch transactions, which conceptually differ from individual transactions. This ensures: - Accurate metadata at the time of batch creation, as `TransactionMeta` for individual transactions may not yet exist. - Future support for advanced features such as transaction simulations and dynamic gas fee updates for batches, enabling better client-side functionality and automation. These changes lay the groundwork for improved handling of batch transactions and pave the way for future enhancements. ## References * Related to https://github.com/MetaMask/MetaMask-planning/issues/4697 ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Pedro Figueiredo Co-authored-by: Matthew Walsh --- packages/controller-utils/CHANGELOG.md | 4 + packages/controller-utils/src/constants.ts | 1 + packages/transaction-controller/CHANGELOG.md | 6 + .../src/TransactionController.test.ts | 1 + .../src/TransactionController.ts | 12 +- packages/transaction-controller/src/index.ts | 1 + packages/transaction-controller/src/types.ts | 32 +- .../src/utils/batch.test.ts | 364 +++++++++++++++--- .../transaction-controller/src/utils/batch.ts | 150 +++++++- 9 files changed, 510 insertions(+), 61 deletions(-) diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 095b86aa337..7dc45bf52b5 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,10 @@ 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 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/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 63d6b3774b4..19d2aac8c81 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,12 @@ 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. + ## [56.2.0] ### Added diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 35d9e4f91d7..2e0357fc286 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -1002,6 +1002,7 @@ describe('TransactionController', () => { expect(controller.state).toStrictEqual({ methodData: {}, transactions: [], + transactionBatches: [], lastFetchedBlockNumbers: {}, submitHistory: [], }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index beb67f49614..a41b16899e5 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -116,6 +116,7 @@ import type { IsAtomicBatchSupportedRequest, AfterAddHook, GasFeeEstimateLevel as GasFeeEstimateLevelType, + TransactionBatchMeta, } from './types'; import { GasFeeEstimateLevel, @@ -188,6 +189,10 @@ const metadata = { persist: true, anonymous: false, }, + transactionBatches: { + persist: true, + anonymous: false, + }, methodData: { persist: true, anonymous: false, @@ -251,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[]; }; @@ -672,6 +680,7 @@ function getDefaultTransactionControllerState(): TransactionControllerState { return { methodData: {}, transactions: [], + transactionBatches: [], lastFetchedBlockNumbers: {}, submitHistory: [], }; @@ -1052,6 +1061,7 @@ export class TransactionController extends BaseController< chainId: this.#getChainId(networkClientId), networkClientId, }), + update: this.update.bind(this), }); } 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 7b19fbb9439..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. @@ -1514,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 5fe2e5ed063..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, @@ -47,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'; @@ -75,6 +79,86 @@ 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); @@ -117,6 +201,8 @@ describe('Batch Utils', () => { AddBatchTransactionOptions['getPendingTransactionTracker'] >; + let updateMock: jest.MockedFn; + let request: AddBatchTransactionOptions; beforeEach(() => { @@ -126,6 +212,7 @@ describe('Batch Utils', () => { updateTransactionMock = jest.fn(); publishTransactionMock = jest.fn(); getPendingTransactionTrackerMock = jest.fn(); + updateMock = jest.fn(); determineTransactionTypeMock.mockResolvedValue({ type: TransactionType.simpleSend, @@ -166,6 +253,7 @@ describe('Batch Utils', () => { updateTransaction: updateTransactionMock, publishTransaction: publishTransactionMock, getPendingTransactionTracker: getPendingTransactionTrackerMock, + update: updateMock, }; }); @@ -570,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(); @@ -606,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(); @@ -1011,7 +1158,11 @@ describe('Batch Utils', () => { addTransactionBatch({ ...request, publishBatchHook, - request: { ...request.request, useHook: true }, + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, }).catch(() => { // Intentionally empty }); @@ -1064,7 +1215,11 @@ describe('Batch Utils', () => { addTransactionBatch({ ...request, publishBatchHook, - request: { ...request.request, useHook: true }, + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, }).catch(() => { // Intentionally empty }); @@ -1124,48 +1279,30 @@ describe('Batch Utils', () => { ([, options]) => options.publishHook, ); - publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, - ).catch(() => { - // Intentionally empty - }); - - publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); + 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(); }; - it('calls sequentialPublishBatchHook when publishBatchHook is undefined', async () => { + const mockSequentialPublishBatchHookResults = () => { sequentialPublishBatchHook.mockResolvedValueOnce({ results: [ - { - transactionHash: TRANSACTION_HASH_MOCK, - }, - { - transactionHash: TRANSACTION_HASH_2_MOCK, - }, + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, ], }); + }; - setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); - - addTransactionBatch({ - ...request, - publishBatchHook: undefined, - request: { ...request.request, useHook: true }, - }).catch(() => { - // Intentionally empty - }); - - await flushPromises(); - await executePublishHooks(); - + const assertSequentialPublishBatchHookCalled = () => { expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); expect(sequentialPublishBatchHook).toHaveBeenCalledTimes(1); expect(sequentialPublishBatchHook).toHaveBeenCalledWith({ @@ -1184,34 +1321,34 @@ describe('Batch Utils', () => { }), ], }); - }); + }; - it('throws if sequentialPublishBatchHook does not return a result', async () => { - const publishBatchHookMock: jest.MockedFn = jest.fn(); - publishBatchHookMock.mockResolvedValueOnce(undefined); - setupSequentialPublishBatchHookMock(() => publishBatchHookMock); + it('invokes sequentialPublishBatchHook when publishBatchHook is undefined', async () => { + mockSequentialPublishBatchHookResults(); + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); const resultPromise = addTransactionBatch({ ...request, publishBatchHook: undefined, - request: { ...request.request, useHook: true }, - }); - - resultPromise.catch(() => { + request: { + ...request.request, + useHook: true, + requireApproval: false, + }, + }).catch(() => { // Intentionally empty }); await flushPromises(); await executePublishHooks(); - await expect(resultPromise).rejects.toThrow( - 'Publish batch hook did not return a result', - ); - await flushPromises(); - expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + assertSequentialPublishBatchHookCalled(); + + const result = await resultPromise; + expect(result?.batchId).toMatch(/^0x[0-9a-f]{32}$/u); }); - it('handles individual transaction failures when using sequentialPublishBatchHook', async () => { + it('throws an error when sequentialPublishBatchHook fails', async () => { setupSequentialPublishBatchHookMock(() => { throw new Error('Test error'); }); @@ -1226,6 +1363,129 @@ describe('Batch Utils', () => { 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: [] }, + ]); + }); }); }); diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 38845bb84a5..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, @@ -38,6 +45,7 @@ import type { ValidateSecurityRequest, IsAtomicBatchSupportedResult, IsAtomicBatchSupportedResultEntry, + TransactionBatchMeta, } from '../types'; import { TransactionEnvelopeType, @@ -46,6 +54,12 @@ import { TransactionType, } from '../types'; +type UpdateStateCallback = ( + callback: ( + state: WritableDraft, + ) => void | TransactionControllerState, +) => void; + type AddTransactionBatchRequest = { addTransaction: TransactionController['addTransaction']; getChainId: (networkClientId: string) => Hex; @@ -67,6 +81,7 @@ type AddTransactionBatchRequest = { getPendingTransactionTracker: ( networkClientId: string, ) => PendingTransactionTracker; + update: UpdateStateCallback; }; type IsAtomicBatchSupportedRequestInternal = { @@ -318,9 +333,7 @@ async function addTransactionBatchWith7702( log('Security request', securityRequest); - /* istanbul ignore next */ validateSecurity(securityRequest, chainId).catch((error) => { - /* istanbul ignore next */ log('Security validation failed', error); }); } @@ -360,15 +373,25 @@ async function addTransactionBatchWith7702( async function addTransactionBatchWithHook( request: AddTransactionBatchRequest, ): Promise { - const { publishBatchHook: requestPublishBatchHook, 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); const sequentialPublishBatchHook = new SequentialPublishBatchHook({ @@ -381,13 +404,30 @@ async function addTransactionBatchWithHook( 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, @@ -425,6 +465,7 @@ async function addTransactionBatchWithHook( ); collectHook.success(transactionHashes); + resultCallbacks?.success(); log('Completed batch transaction with hook', transactionHashes); @@ -435,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); } } @@ -529,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, + ); + }); +} From a9f5a47092b602114edf593c194c4ed5792a0510 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 27 May 2025 10:22:09 +0100 Subject: [PATCH 60/82] fix: remove leading zeroes in authorization list (#5830) ## Explanation Remove leading zeroes in `authorizationList` properties, specifically `r`, `s`, `yParity` and `nonce`. ## References Relates to [#32928](https://github.com/MetaMask/metamask-extension/issues/32928) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/utils/eip7702.test.ts | 12 +-- .../src/utils/eip7702.ts | 7 +- .../src/utils/prepare.test.ts | 96 ++++++++++++++++++- .../src/utils/prepare.ts | 55 ++++++++++- .../src/utils/validation.test.ts | 4 +- .../src/utils/validation.ts | 4 +- 7 files changed, 160 insertions(+), 22 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 19d2aac8c81..f5b302707c2 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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/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/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 12a4ca15cac..11a6eaeca56 100644 --- a/packages/transaction-controller/src/utils/validation.ts +++ b/packages/transaction-controller/src/utils/validation.ts @@ -535,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}`, ); } } From 2443794567f7a62c0cc0087439ed1fb112166943 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 27 May 2025 18:41:55 +0530 Subject: [PATCH 61/82] feat: Adding option in preference controller for user to be able to dismiss smart account upgrade prompt. (#5866) ## Explanation Adding option in preference controller for user to be able to dismiss smart account upgrade prompt. ## References * Related to [#67890](https://github.com/MetaMask/MetaMask-planning/issues/4807) ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/preferences-controller/CHANGELOG.md | 4 ++++ .../src/PreferencesController.test.ts | 8 ++++++++ .../src/PreferencesController.ts | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index 9be4dd815b9..82e2a38a83a 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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)) diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 002d5adefbe..579fada570b 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -46,6 +46,7 @@ describe('PreferencesController', () => { sortCallback: 'stringNumeric', }, privacyMode: false, + dismissSmartAccountSuggestionEnabled: false, }); }); @@ -542,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; From f2bf003d596261bb582ca5deddcdaf566d54c615 Mon Sep 17 00:00:00 2001 From: Ziad Saab Date: Tue, 27 May 2025 15:03:42 -0500 Subject: [PATCH 62/82] Release/411.0.0 (#5865) Release new minor version of token search controller --- package.json | 2 +- packages/token-search-discovery-controller/CHANGELOG.md | 5 ++++- packages/token-search-discovery-controller/package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ad5a209757a..84fac6b2bb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "410.0.0", + "version": "411.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/token-search-discovery-controller/CHANGELOG.md b/packages/token-search-discovery-controller/CHANGELOG.md index e933d500d40..c4eed62b8fb 100644 --- a/packages/token-search-discovery-controller/CHANGELOG.md +++ b/packages/token-search-discovery-controller/CHANGELOG.md @@ -7,6 +7,8 @@ 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)) @@ -70,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", From 1c129542bcf0bd7bf74f4bc528830b60e5749280 Mon Sep 17 00:00:00 2001 From: AugmentedMode <31675118+AugmentedMode@users.noreply.github.com> Date: Tue, 27 May 2025 16:11:25 -0400 Subject: [PATCH 63/82] feat: integrate phishing protection into NftController (#5598) ## Explanation # NFT Metadata URL Safety: Moving Phishing Detection from UI to Controller ## Overview This PR implements security enhancements by moving NFT metadata URL safety checks from the UI layer to the controller level. It ensures potentially malicious URLs in NFT metadata are detected and filtered before reaching the UI components. ## Changes - Added URL safety scanning to the `NftController` that checks all external links in NFT metadata - Implemented phishing detection using `PhishingController`'s URL scanning capability - Added caching mechanism to reduce redundant URL checks - Implemented concurrent URL processing with controlled batch sizes - Added sanitization of NFT metadata to remove unsafe URLs ## Technical Details - Added a new method `#sanitizeNftMetadata` that checks all URLs in metadata - Added URL safety check implementation with `PhishingController` integration - Modified `_getNftInformation` to sanitize metadata after retrieval - Implemented filtering for various URL types (image, animation, external links) - Added safety configuration with allowed protocols and denied domains ## References This PR addresses removing the check client side during rendering as we no longer use client side detection for EPD in mobile https://github.com/MetaMask/metamask-mobile/pull/15361 ## Changelog ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Elliot Winkler --- packages/assets-controllers/CHANGELOG.md | 10 + packages/assets-controllers/package.json | 2 + .../src/NftController.test.ts | 428 +++++++++++++++++- .../assets-controllers/src/NftController.ts | 174 ++++++- .../assets-controllers/tsconfig.build.json | 3 +- packages/assets-controllers/tsconfig.json | 1 + yarn.lock | 4 +- 7 files changed, 605 insertions(+), 17 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 94f68057347..ce291cda394 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,16 @@ 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 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index b0fb1349484..1f3fc006b39 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -86,6 +86,7 @@ "@metamask/keyring-snap-client": "^4.1.0", "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", + "@metamask/phishing-controller": "^12.5.0", "@metamask/preferences-controller": "^18.0.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", @@ -111,6 +112,7 @@ "@metamask/keyring-controller": "^22.0.0", "@metamask/network-controller": "^23.0.0", "@metamask/permission-controller": "^11.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", 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/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/yarn.lock b/yarn.lock index 106315fe9f2..35a78dd44c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2582,6 +2582,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@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:^18.0.0" "@metamask/providers": "npm:^21.0.0" @@ -2620,6 +2621,7 @@ __metadata: "@metamask/keyring-controller": ^22.0.0 "@metamask/network-controller": ^23.0.0 "@metamask/permission-controller": ^11.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 @@ -3993,7 +3995,7 @@ __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: From 5eeea558716f51a20aff0154e49a243a5592040b Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 28 May 2025 14:29:29 +0200 Subject: [PATCH 64/82] chore: bump eth-json-rpc-infura package to 10.2.0 (#5867) ## Explanation Bump eth-json-rpc-infura package to 10.2.0 that includes infura support for sei-mainnet and sei-testnet. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- packages/network-controller/CHANGELOG.md | 4 ++++ packages/network-controller/package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index b3cba08107c..5c67f943425 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,10 @@ 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)): diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 3982a04823a..80d858ec8a9 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -50,7 +50,7 @@ "@metamask/base-controller": "^8.0.1", "@metamask/controller-utils": "^11.9.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/yarn.lock b/yarn.lock index 35a78dd44c7..bc5802151dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3186,15 +3186,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 @@ -3838,7 +3838,7 @@ __metadata: "@metamask/base-controller": "npm:^8.0.1" "@metamask/controller-utils": "npm:^11.9.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" From be419719968e2bc3794e03fb266ffeaa906bb489 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 May 2025 18:06:58 +0530 Subject: [PATCH 65/82] Release/412.0.0 (#5870) ## Explanation Preference-Controller release to add new preference `dismissSmartAccountSuggestionEnabled`. ## References * Related to [#67890](https://github.com/MetaMask/MetaMask-planning/issues/4807) ## Changelog ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [X] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --- package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/preferences-controller/CHANGELOG.md | 5 ++++- packages/preferences-controller/package.json | 2 +- yarn.lock | 4 ++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 84fac6b2bb9..a3218725be7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "411.0.0", + "version": "412.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 1f3fc006b39..7791feb7462 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -87,7 +87,7 @@ "@metamask/network-controller": "^23.5.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^12.5.0", - "@metamask/preferences-controller": "^18.0.0", + "@metamask/preferences-controller": "^18.1.0", "@metamask/providers": "^21.0.0", "@metamask/snaps-controllers": "^11.2.1", "@metamask/snaps-sdk": "^6.22.0", diff --git a/packages/preferences-controller/CHANGELOG.md b/packages/preferences-controller/CHANGELOG.md index 82e2a38a83a..f8af262f03b 100644 --- a/packages/preferences-controller/CHANGELOG.md +++ b/packages/preferences-controller/CHANGELOG.md @@ -7,6 +7,8 @@ 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)) @@ -368,7 +370,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/preferences-controller@18.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 diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index 380b60d8689..8cb289f9683 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/preferences-controller", - "version": "18.0.0", + "version": "18.1.0", "description": "Manages user-configurable settings for MetaMask", "keywords": [ "MetaMask", diff --git a/yarn.lock b/yarn.lock index bc5802151dc..7816038f8f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2584,7 +2584,7 @@ __metadata: "@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:^18.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" @@ -4054,7 +4054,7 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^18.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: From 8079b0f599155b1c59549dd4fbb00ac97034ec3c Mon Sep 17 00:00:00 2001 From: jpsains <32621022+jpsains@users.noreply.github.com> Date: Wed, 28 May 2025 14:36:59 +0100 Subject: [PATCH 66/82] feat: defi metrics (#5868) ## Explanation The new defi positions feature is missing a way to track the count of defi positions This PR adds the ability to optionally pass metric tracking function to the DeFi position controller ## References ## Changelog ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Bernardo Garces Chapero --- .../DeFiPositionsController.test.ts | 129 ++++++++ .../DeFiPositionsController.ts | 73 ++++- .../__fixtures__/mock-result.ts | 305 +++++++++++++++++ .../calculate-defi-metrics.test.ts | 37 +++ .../calculate-defi-metrics.ts | 64 ++++ .../group-defi-positions.test.ts | 307 +----------------- .../group-defi-positions.ts | 5 +- 7 files changed, 612 insertions(+), 308 deletions(-) create mode 100644 packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts 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]?: { From 0830705e5adf8cf6eb450148ac342411c82f778b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 28 May 2025 08:51:42 -0600 Subject: [PATCH 67/82] Correct invalid initial selectedNetworkClientId (#5851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, when NetworkController is instantiated with pre-existing state that contains an invalid `selectedNetworkClientId` — that is, no RPC endpoint exists which has the same network client ID — then it throws an error. This was intentionally done to bring attention to possible bugs in NetworkController, but this has the unfortunate side effect of bricking users' wallets. To fix this, we now correct an invalid `selectedNetworkClientId` to point to the default RPC endpoint of the first network sorted by chain ID (which in the vast majority of cases will be Mainnet). We still do want to know about this, though, so we log the error in Sentry. --- packages/network-controller/CHANGELOG.md | 1 + packages/network-controller/package.json | 1 + .../src/NetworkController.ts | 72 ++++++++-- .../tests/NetworkController.test.ts | 128 ++++++++++++------ packages/network-controller/tests/helpers.ts | 14 +- .../network-controller/tsconfig.build.json | 3 +- packages/network-controller/tsconfig.json | 17 +-- yarn.lock | 3 +- 8 files changed, 166 insertions(+), 73 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 5c67f943425..d953ad392a6 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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] diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 80d858ec8a9..ee32b99aa5d 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -49,6 +49,7 @@ "dependencies": { "@metamask/base-controller": "^8.0.1", "@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.2.0", "@metamask/eth-json-rpc-middleware": "^16.0.1", 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/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/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/yarn.lock b/yarn.lock index 7816038f8f7..a9ff4f99094 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3066,7 +3066,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/error-reporting-service@workspace:packages/error-reporting-service": +"@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: @@ -3837,6 +3837,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.0.1" "@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.2.0" "@metamask/eth-json-rpc-middleware": "npm:^16.0.1" From 7320a8fc6ec3a347ea18cc73d30eb20ce0b3be9f Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 12:02:46 +0800 Subject: [PATCH 68/82] feat: fetch SeedPhrases backup with cached encryption key --- .../CHANGELOG.md | 3 ++ .../seedless-onboarding-controller.tgz | Bin 51318 -> 56355 bytes .../src/SeedlessOnboardingController.test.ts | 48 ++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 23 +++++++-- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 3e09f1d0a5d..6deee96f3d6 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -19,5 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support multi SRP sync using social login. ([#5](https://github.com/Web3Auth/core/pull/5)) - Update Metadata to support multiple types of secrets (SRP, PrivateKey). - Add `Controller Lock` which will sync with `Keyring Lock`. + - 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. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/seedless-onboarding-controller/seedless-onboarding-controller.tgz b/packages/seedless-onboarding-controller/seedless-onboarding-controller.tgz index 2cc948fd3a6536ac68b99e8efa6361f2e719cef7..195d092ee357b752ccdaa40589227f2a28f66b73 100644 GIT binary patch literal 56355 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 Gk$(VT{8u9Y diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 4fcb09ea100..27cf7d43019 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -1371,6 +1371,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( { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 091e9571985..0863f7c50b1 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -293,14 +293,28 @@ 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); - const { encKey, authKeyPair } = await this.#recoverEncKey(password); + let encKey: Uint8Array; + let authKeyPair: KeyPair; + + 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 { toprfEncryptionKey, toprfAuthKeyPair } = + await this.#unlockVaultAndGetBackupEncKey(); + encKey = toprfEncryptionKey; + authKeyPair = toprfAuthKeyPair; + } try { const secretData = await this.toprfClient.fetchAllSecretDataItems({ @@ -308,7 +322,8 @@ export class SeedlessOnboardingController extends BaseController< authKeyPair, }); - if (secretData?.length > 0) { + 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, From 5f8ef7b86423de698debc0a657235bc4003538fa Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 12:37:18 +0800 Subject: [PATCH 69/82] fix: fixed lint --- .../package.json | 4 +-- yarn.lock | 36 ++----------------- 2 files changed, 5 insertions(+), 35 deletions(-) diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index d91d5a6f26b..25fc7458546 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/yarn.lock b/yarn.lock index d91d13ec408..9541bf0490c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3564,29 +3564,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^21.0.6": - version: 21.0.6 - resolution: "@metamask/keyring-controller@npm:21.0.6" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@keystonehq/metamask-airgapped-keyring": "npm:^0.14.1" - "@metamask/base-controller": "npm:^8.0.1" - "@metamask/browser-passworder": "npm:^4.3.0" - "@metamask/eth-hd-keyring": "npm:^12.0.0" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/eth-simple-keyring": "npm:^10.0.0" - "@metamask/keyring-api": "npm:^17.4.0" - "@metamask/keyring-internal-api": "npm:^6.0.1" - "@metamask/utils": "npm:^11.2.0" - async-mutex: "npm:^0.5.0" - ethereumjs-wallet: "npm:^1.0.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - ulid: "npm:^2.3.0" - checksum: 10/913d2576c121e6171a9184720e87bfa0eba1220bdcf0549e3cbf865020f8eaa5182c9abea562b101dd5155cefb99df13a0552ebdfeb7ca7b0905d3ff9f753e88 - languageName: node - linkType: hard - "@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" @@ -4308,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.1.0" "@metamask/utils": "npm:^11.2.0" "@noble/ciphers": "npm:^0.5.2" @@ -4326,7 +4303,7 @@ __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 @@ -5066,20 +5043,13 @@ __metadata: languageName: node linkType: hard -"@sentry/core@npm:^9.10.0": +"@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 -"@sentry/core@npm:^9.22.0": - version: 9.22.0 - resolution: "@sentry/core@npm:9.22.0" - checksum: 10/5bf5d6b5402dca90c6ed1d6e8834c00067806f9710f1cbcd0dff3004c3f3b6ffae8e43d56592d5378fdbddb3d196eb60d8850ea50ca6eca8e31870608109df3d - languageName: node - linkType: hard - "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" From eb017d934569812e815dfdbc9778a4a81371b338 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 12:55:38 +0800 Subject: [PATCH 70/82] docs: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index d140dc32fd9..4192fe64230 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,7 +9,7 @@ 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 From dbd6206898f0116536daa9908a4f2eae8ac9054a Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 13:18:38 +0800 Subject: [PATCH 71/82] docs: updated change log --- packages/seedless-onboarding-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 7683a98497e..4813ffb77a2 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. - Updated `VaultEncryptor` type in constructor args and is compulsory to provided relevant encryptor to constructor. From 55756f028d2109210ad5b6a58332431d337d3ca9 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 14:20:10 +0800 Subject: [PATCH 72/82] docs: updated change log --- packages/seedless-onboarding-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 2ce003a76c6..91163d106b8 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ([#6](https://github.com/Web3Auth/core/pull/6)) +- 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 From 8d8b458aff0f3f2274a1f4f122bc4784418f7f8a Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 14:52:51 +0800 Subject: [PATCH 73/82] chore: removed tgz package --- .../seedless-onboarding-controller.tgz | Bin 66977 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/seedless-onboarding-controller/seedless-onboarding-controller.tgz diff --git a/packages/seedless-onboarding-controller/seedless-onboarding-controller.tgz b/packages/seedless-onboarding-controller/seedless-onboarding-controller.tgz deleted file mode 100644 index 7b7153c48106323f66d931407100598ce76af02d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66977 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 XP7^${h&X>U6u5$>0?s!RY_r% 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 Lco6ri0O|$+H?M%W From ae462570f46833dc515f9cd97f1c0f3b5f82fb67 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 29 May 2025 14:55:14 +0800 Subject: [PATCH 74/82] chore: removed toprf-secure-backup.tgz --- .../toprf-secure-backup.tgz | Bin 110220 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/seedless-onboarding-controller/toprf-secure-backup.tgz diff --git a/packages/seedless-onboarding-controller/toprf-secure-backup.tgz b/packages/seedless-onboarding-controller/toprf-secure-backup.tgz deleted file mode 100644 index 779c7a6f4428e068082355564770c69e8d9f5065..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110220 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 w_?7s8y Date: Fri, 30 May 2025 01:45:32 +0800 Subject: [PATCH 75/82] chore: updated usage of 'groupedAuthConnectionId' --- .../src/SeedlessOnboardingController.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index a91ae5456b8..7dffc398561 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -650,7 +650,8 @@ export class SeedlessOnboardingController extends BaseController< const { authPubKey: globalAuthPubKey } = await this.toprfClient.fetchAuthPubKey({ nodeAuthTokens, - authConnectionId: groupedAuthConnectionId || authConnectionId, + authConnectionId, + groupedAuthConnectionId, userId, }); @@ -693,14 +694,14 @@ 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, }); @@ -749,14 +750,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; }); @@ -771,8 +772,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, @@ -783,7 +783,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, From 4bd767a948c1f92a03e7955d485736d76f9251b7 Mon Sep 17 00:00:00 2001 From: lwin Date: Fri, 30 May 2025 01:48:53 +0800 Subject: [PATCH 76/82] docs: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 009f8d95d1a..1f5875b27c0 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -27,9 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/ From 8536e06dda7b4355ba7f91aceec5d53ef5b5b010 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 23 May 2025 22:27:51 +0700 Subject: [PATCH 77/82] feat: handle refresh token logic --- .../src/SeedlessOnboardingController.test.ts | 504 ++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 302 ++++++++--- .../src/types.ts | 17 + 3 files changed, 763 insertions(+), 60 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 9d35b3406ee..f65b8557d0a 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,16 @@ 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 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 +183,7 @@ async function withController( encryptor, messenger, network: Web3AuthNetwork.Devnet, + getNewRefreshToken: mockGetNewRefreshToken, ...rest, }); const { toprfClient } = controller; @@ -339,6 +352,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 +361,7 @@ async function createMockVault( authKeyPair: KeyPair, MOCK_PASSWORD: string, authTokens: NodeAuthTokens, + mockRefreshToken: string = refreshToken, ) { const encryptor = createMockVaultEncryptor(); @@ -357,6 +372,7 @@ async function createMockVault( sk: `0x${authKeyPair.sk.toString(16)}`, pk: bytesToBase64(authKeyPair.pk), }), + refreshToken: mockRefreshToken, }); const { vault: encryptedMockVault, exportedKeyString } = @@ -436,10 +452,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 +478,7 @@ describe('SeedlessOnboardingController', () => { const controller = new SeedlessOnboardingController({ messenger, encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), + getNewRefreshToken: mockGetNewRefreshToken, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -476,6 +495,7 @@ describe('SeedlessOnboardingController', () => { new SeedlessOnboardingController({ messenger, encryptor, + getNewRefreshToken: mockGetNewRefreshToken, }), ).not.toThrow(); }); @@ -531,6 +551,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -561,6 +582,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -593,6 +615,7 @@ describe('SeedlessOnboardingController', () => { groupedAuthConnectionId, authConnection, socialLoginEmail, + refreshToken, }); expect(authResult).toBeDefined(); @@ -637,6 +660,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }), ).rejects.toThrow( SeedlessOnboardingControllerErrorMessage.AuthenticationError, @@ -830,6 +854,7 @@ describe('SeedlessOnboardingController', () => { userId, authConnection, socialLoginEmail, + refreshToken, }); const { encKey, authKeyPair } = mockcreateLocalKey( @@ -1580,6 +1605,7 @@ describe('SeedlessOnboardingController', () => { nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, userId, authConnectionId, + refreshToken, }, }, async ({ controller, toprfClient, initialState, encryptor }) => { @@ -2976,6 +3002,7 @@ describe('SeedlessOnboardingController', () => { sk: bigIntToHex(newAuthKeyPair.sk), pk: bytesToBase64(newAuthKeyPair.pk), }), + refreshToken: controller.state.refreshToken, }); expect(encryptorSpy).toHaveBeenCalledWith( GLOBAL_PASSWORD, @@ -3124,4 +3151,481 @@ 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('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: + 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 7dffc398561..cf148cc9e67 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -7,8 +7,18 @@ import type { RecoverEncryptionKeyResult, SEC1EncodedPublicKey, } from '@metamask/toprf-secure-backup'; -import { ToprfSecureBackup } from '@metamask/toprf-secure-backup'; -import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; +import { + ToprfSecureBackup, + TOPRFErrorCode, + TOPRFError, +} from '@metamask/toprf-secure-backup'; +import { + base64ToBytes, + bytesToBase64, + stringToBytes, + remove0x, + bigIntToHex, +} from '@metamask/utils'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; @@ -33,6 +43,7 @@ import type { SocialBackupsMetadata, SRPBackedUpUserDetails, VaultEncryptor, + GetNewRefreshToken, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -109,6 +120,10 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< @@ -124,6 +139,8 @@ export class SeedlessOnboardingController extends BaseController< readonly toprfClient: ToprfSecureBackup; + readonly #getNewRefreshToken: GetNewRefreshToken; + /** * Controller lock state. * @@ -140,6 +157,7 @@ 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.getNewRefreshToken - A function to get a new refresh token. */ constructor({ messenger, @@ -147,6 +165,7 @@ export class SeedlessOnboardingController extends BaseController< encryptor, toprfKeyDeriver, network = Web3AuthNetwork.Mainnet, + getNewRefreshToken, }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, @@ -163,6 +182,7 @@ export class SeedlessOnboardingController extends BaseController< network, keyDeriver: toprfKeyDeriver, }); + this.#getNewRefreshToken = getNewRefreshToken; // 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 +205,8 @@ 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 - Optional refresh token for refreshing expired nodeAuthTokens. + * @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 +217,10 @@ export class SeedlessOnboardingController extends BaseController< userId: string; groupedAuthConnectionId?: string; socialLoginEmail?: string; + refreshToken: string; + skipLock?: boolean; }) { - return await this.#withControllerLock(async () => { + const doAuthenticate = async () => { try { const { idTokens, @@ -205,6 +229,7 @@ export class SeedlessOnboardingController extends BaseController< userId, authConnection, socialLoginEmail, + refreshToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -221,7 +246,10 @@ export class SeedlessOnboardingController extends BaseController< state.userId = userId; state.authConnection = authConnection; state.socialLoginEmail = socialLoginEmail; + // Store refresh token in state for later vault creation + state.refreshToken = refreshToken; }); + return authenticationResult; } catch (error) { log('Error authenticating user', error); @@ -229,7 +257,10 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.AuthenticationError, ); } - }); + }; + return params.skipLock + ? await doAuthenticate() + : await this.#withControllerLock(doAuthenticate); } /** @@ -255,29 +286,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 +335,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', + ); }); } @@ -341,29 +386,36 @@ export class SeedlessOnboardingController extends BaseController< } try { - const secretData = await this.toprfClient.fetchAllSecretDataItems({ - decKey: encKey, - authKeyPair, - }); - - 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, + 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( @@ -394,7 +446,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); @@ -410,6 +462,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( @@ -512,6 +571,7 @@ export class SeedlessOnboardingController extends BaseController< this.update((state) => { delete state.vaultEncryptionKey; delete state.vaultEncryptionSalt; + delete state.refreshToken; }); this.#isUnlocked = false; @@ -706,6 +766,9 @@ export class SeedlessOnboardingController extends BaseController< authPubKey, }); } catch (error) { + if (this.#isTokenExpiredError(error)) { + throw error; + } log('Error persisting local encryption key', error); throw new Error( SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, @@ -827,6 +890,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, @@ -853,6 +919,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + refreshToken?: string; }> { return this.#withVaultLock(async () => { const { @@ -910,17 +977,27 @@ export class SeedlessOnboardingController extends BaseController< updatedState.vaultEncryptionSalt = vaultEncryptionSalt; } - const { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair } = - this.#parseVaultData(decryptedVaultData); + const { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + refreshToken, + } = 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.refreshToken = refreshToken; }); - return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair }; + return { + nodeAuthTokens, + toprfEncryptionKey, + toprfAuthKeyPair, + refreshToken, + }; }); } @@ -1045,6 +1122,7 @@ export class SeedlessOnboardingController extends BaseController< authTokens: this.state.nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, + refreshToken: this.state.refreshToken, }); await this.#updateVault({ @@ -1160,6 +1238,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; + refreshToken: string; } { if (typeof data !== 'string') { throw new Error( @@ -1191,6 +1270,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: parsedVaultData.authTokens, toprfEncryptionKey: rawToprfEncryptionKey, toprfAuthKeyPair: rawToprfAuthKeyPair, + refreshToken: parsedVaultData.refreshToken, }; } @@ -1342,11 +1422,113 @@ 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 + !('refreshToken' in value) || // refreshToken is not defined + typeof value.refreshToken !== 'string' // refreshToken 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.MissingAuthUserInfo, + ); + } + + this.#assertIsAuthenticatedUser(this.state); + + try { + const res = await this.#getNewRefreshToken({ + connection: this.state.authConnection, + refreshToken, + }); + const { idTokens, refreshToken: newRefreshToken } = res; + // re-authenticate with the new refresh token to set new node auth tokens and refresh token + await this.authenticate({ + idTokens, + authConnection: this.state.authConnection, + authConnectionId: this.state.authConnectionId, + groupedAuthConnectionId: this.state.groupedAuthConnectionId, + userId: this.state.userId, + refreshToken: newRefreshToken, + skipLock: true, + }); + } catch (error) { + log('Error refreshing node auth tokens', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + } + } + + /** + * 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) { + 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/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 5bff5bc7f91..8bad1cb84a3 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -119,6 +119,12 @@ 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; }; // Actions @@ -182,6 +188,11 @@ export type ToprfKeyDeriver = { deriveKey: (seed: Uint8Array, salt: Uint8Array) => Promise; }; +export type GetNewRefreshToken = (params: { + connection: AuthConnection; + refreshToken: string; +}) => Promise<{ idTokens: string[]; refreshToken: string }>; + /** * Seedless Onboarding Controller Options. * @@ -204,6 +215,8 @@ export type SeedlessOnboardingControllerOptions = { */ encryptor: VaultEncryptor; + getNewRefreshToken: GetNewRefreshToken; + /** * Optional key derivation interface for the TOPRF client. * @@ -252,6 +265,10 @@ export type VaultData = { * The authentication key pair to authenticate the TOPRF. */ toprfAuthKeyPair: string; + /** + * The refresh token to refresh byoa token and get new node auth tokens after expiration. + */ + refreshToken: string; }; export type SecretDataType = Uint8Array | string | number; From a69bc83bb2c26120cf0402165b5db633ff3bef40 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 23 May 2025 22:31:52 +0700 Subject: [PATCH 78/82] feat: fix eslint --- .../src/SeedlessOnboardingController.test.ts | 1 + .../src/SeedlessOnboardingController.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f65b8557d0a..1fb2b1d8080 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -3571,6 +3571,7 @@ describe('SeedlessOnboardingController', () => { expect(toprfClient.authenticate).toHaveBeenCalledWith({ authConnectionId: + // eslint-disable-next-line jest/no-conditional-in-test controller.state.groupedAuthConnectionId || controller.state.authConnectionId, userId: controller.state.userId, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index cf148cc9e67..229121f8555 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1483,6 +1483,7 @@ export class SeedlessOnboardingController extends BaseController< */ #isTokenExpiredError(error: unknown): boolean { if (error instanceof TOPRFError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison return error.code === TOPRFErrorCode.AuthTokenExpired; } From 9b475a3163afdad9aea0271278af84ae181c4d64 Mon Sep 17 00:00:00 2001 From: Tuna Date: Sun, 25 May 2025 21:45:37 +0700 Subject: [PATCH 79/82] feat: sync latest password execute with refresh token --- .../src/SeedlessOnboardingController.test.ts | 207 ++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 46 ++-- .../src/errors.ts | 2 + 3 files changed, 238 insertions(+), 17 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 1fb2b1d8080..b85f0467bdb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -3336,6 +3336,213 @@ describe('SeedlessOnboardingController', () => { }); }); + 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', diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 229121f8555..44000b6a48a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -595,23 +595,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', + ); }); } @@ -1356,6 +1363,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, 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; } From f545879df5d95e4bdf59d2a64ea836b5bbde0a70 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 30 May 2025 08:31:17 +0700 Subject: [PATCH 80/82] chore: remove redundant import --- .../src/SeedlessOnboardingController.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 44000b6a48a..100ac840ba2 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -12,13 +12,7 @@ import { TOPRFErrorCode, TOPRFError, } from '@metamask/toprf-secure-backup'; -import { - base64ToBytes, - bytesToBase64, - stringToBytes, - remove0x, - bigIntToHex, -} from '@metamask/utils'; +import { base64ToBytes, bytesToBase64, bigIntToHex } from '@metamask/utils'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; From d7a67cbbc48b3c62b1df13d361eeec37d4b87dce Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 30 May 2025 16:16:09 +0700 Subject: [PATCH 81/82] fix: update test --- .../src/SeedlessOnboardingController.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index b85f0467bdb..90b8d86ad0d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -101,6 +101,11 @@ const mockGetNewRefreshToken = jest.fn().mockResolvedValue({ 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', @@ -183,7 +188,8 @@ async function withController( encryptor, messenger, network: Web3AuthNetwork.Devnet, - getNewRefreshToken: mockGetNewRefreshToken, + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, ...rest, }); const { toprfClient } = controller; @@ -478,7 +484,8 @@ describe('SeedlessOnboardingController', () => { const controller = new SeedlessOnboardingController({ messenger, encryptor: getDefaultSeedlessOnboardingVaultEncryptor(), - getNewRefreshToken: mockGetNewRefreshToken, + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, }); expect(controller).toBeDefined(); expect(controller.state).toStrictEqual( @@ -495,7 +502,8 @@ describe('SeedlessOnboardingController', () => { new SeedlessOnboardingController({ messenger, encryptor, - getNewRefreshToken: mockGetNewRefreshToken, + refreshJWTToken: mockGetNewRefreshToken, + revokeRefreshToken: mockRevokeRefreshToken, }), ).not.toThrow(); }); From ebe51296268b508dc36722a10e7b83218db42da3 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 30 May 2025 16:17:39 +0700 Subject: [PATCH 82/82] feat: handle revoke refresh token --- .../src/SeedlessOnboardingController.ts | 122 ++++++++++++++---- .../src/types.ts | 30 ++++- 2 files changed, 120 insertions(+), 32 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 100ac840ba2..232de85a1d2 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -37,7 +37,8 @@ import type { SocialBackupsMetadata, SRPBackedUpUserDetails, VaultEncryptor, - GetNewRefreshToken, + RefreshJWTToken, + RevokeRefreshToken, } from './types'; const log = createModuleLogger(projectLogger, controllerName); @@ -115,6 +116,10 @@ const seedlessOnboardingMetadata: StateMetadata extends BaseController< readonly toprfClient: ToprfSecureBackup; - readonly #getNewRefreshToken: GetNewRefreshToken; + readonly #refreshJWTToken: RefreshJWTToken; + + readonly #revokeRefreshToken: RevokeRefreshToken; /** * Controller lock state. @@ -151,7 +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.getNewRefreshToken - A function to get a new refresh token. + * @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, @@ -159,7 +167,8 @@ export class SeedlessOnboardingController extends BaseController< encryptor, toprfKeyDeriver, network = Web3AuthNetwork.Mainnet, - getNewRefreshToken, + refreshJWTToken, + revokeRefreshToken, }: SeedlessOnboardingControllerOptions) { super({ name: controllerName, @@ -176,7 +185,8 @@ export class SeedlessOnboardingController extends BaseController< network, keyDeriver: toprfKeyDeriver, }); - this.#getNewRefreshToken = getNewRefreshToken; + 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 @@ -199,7 +209,8 @@ 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 - Optional refresh token for refreshing expired nodeAuthTokens. + * @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. @@ -211,7 +222,8 @@ export class SeedlessOnboardingController extends BaseController< userId: string; groupedAuthConnectionId?: string; socialLoginEmail?: string; - refreshToken: string; + refreshToken?: string; + revokeToken?: string; skipLock?: boolean; }) { const doAuthenticate = async () => { @@ -224,6 +236,7 @@ export class SeedlessOnboardingController extends BaseController< authConnection, socialLoginEmail, refreshToken, + revokeToken, } = params; const authenticationResult = await this.toprfClient.authenticate({ @@ -240,8 +253,13 @@ export class SeedlessOnboardingController extends BaseController< state.userId = userId; state.authConnection = authConnection; state.socialLoginEmail = socialLoginEmail; - // Store refresh token in state for later vault creation - state.refreshToken = refreshToken; + if (refreshToken) { + state.refreshToken = refreshToken; + } + if (revokeToken) { + // Temporarily store revoke token in state for later vault creation + state.revokeToken = revokeToken; + } }); return authenticationResult; @@ -553,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); }); } @@ -565,7 +585,7 @@ export class SeedlessOnboardingController extends BaseController< this.update((state) => { delete state.vaultEncryptionKey; delete state.vaultEncryptionSalt; - delete state.refreshToken; + delete state.revokeToken; }); this.#isUnlocked = false; @@ -727,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 { @@ -920,7 +944,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; - refreshToken?: string; + revokeToken: string; }> { return this.#withVaultLock(async () => { const { @@ -982,7 +1006,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, - refreshToken, + revokeToken, } = this.#parseVaultData(decryptedVaultData); // update the state with the restored nodeAuthTokens @@ -990,14 +1014,14 @@ export class SeedlessOnboardingController extends BaseController< state.nodeAuthTokens = nodeAuthTokens; state.vaultEncryptionKey = updatedState.vaultEncryptionKey; state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; - state.refreshToken = refreshToken; + state.revokeToken = revokeToken; }); return { nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, - refreshToken, + revokeToken, }; }); } @@ -1112,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( @@ -1123,7 +1154,7 @@ export class SeedlessOnboardingController extends BaseController< authTokens: this.state.nodeAuthTokens, toprfEncryptionKey, toprfAuthKeyPair, - refreshToken: this.state.refreshToken, + revokeToken: this.state.revokeToken, }); await this.#updateVault({ @@ -1239,7 +1270,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: NodeAuthTokens; toprfEncryptionKey: Uint8Array; toprfAuthKeyPair: KeyPair; - refreshToken: string; + revokeToken: string; } { if (typeof data !== 'string') { throw new Error( @@ -1271,7 +1302,7 @@ export class SeedlessOnboardingController extends BaseController< nodeAuthTokens: parsedVaultData.authTokens, toprfEncryptionKey: rawToprfEncryptionKey, toprfAuthKeyPair: rawToprfAuthKeyPair, - refreshToken: parsedVaultData.refreshToken, + revokeToken: parsedVaultData.revokeToken, }; } @@ -1321,6 +1352,12 @@ export class SeedlessOnboardingController extends BaseController< SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, ); } + + if (!('refreshToken' in value) || typeof value.refreshToken !== 'string') { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, + ); + } } #assertIsSRPBackedUpUser( @@ -1429,8 +1466,8 @@ export class SeedlessOnboardingController extends BaseController< 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 - !('refreshToken' in value) || // refreshToken is not defined - typeof value.refreshToken !== 'string' // refreshToken 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); } @@ -1449,26 +1486,25 @@ export class SeedlessOnboardingController extends BaseController< const { refreshToken } = this.state; if (!refreshToken) { throw new Error( - SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + SeedlessOnboardingControllerErrorMessage.InsufficientAuthToken, ); } this.#assertIsAuthenticatedUser(this.state); try { - const res = await this.#getNewRefreshToken({ + const res = await this.#refreshJWTToken({ connection: this.state.authConnection, refreshToken, }); - const { idTokens, refreshToken: newRefreshToken } = res; - // re-authenticate with the new refresh token to set new node auth tokens and refresh token + 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, - refreshToken: newRefreshToken, skipLock: true, }); } catch (error) { @@ -1479,6 +1515,38 @@ export class SeedlessOnboardingController extends BaseController< } } + /** + * 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. * diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index 8bad1cb84a3..a6b5b684338 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -125,6 +125,12 @@ export type SeedlessOnboardingControllerState = * 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 @@ -188,10 +194,15 @@ export type ToprfKeyDeriver = { deriveKey: (seed: Uint8Array, salt: Uint8Array) => Promise; }; -export type GetNewRefreshToken = (params: { +export type RefreshJWTToken = (params: { connection: AuthConnection; refreshToken: string; -}) => Promise<{ idTokens: string[]; refreshToken: string }>; +}) => Promise<{ idTokens: string[] }>; + +export type RevokeRefreshToken = (params: { + connection: AuthConnection; + revokeToken: string; +}) => Promise<{ newRevokeToken: string; newRefreshToken: string }>; /** * Seedless Onboarding Controller Options. @@ -215,7 +226,16 @@ export type SeedlessOnboardingControllerOptions = { */ encryptor: VaultEncryptor; - getNewRefreshToken: GetNewRefreshToken; + /** + * 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. @@ -266,9 +286,9 @@ export type VaultData = { */ toprfAuthKeyPair: string; /** - * The refresh token to refresh byoa token and get new node auth tokens after expiration. + * The revoke token to revoke refresh token and get new refresh token and new revoke token. */ - refreshToken: string; + revokeToken: string; }; export type SecretDataType = Uint8Array | string | number;