diff --git a/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch b/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch new file mode 100644 index 000000000000..eb2684b678b7 Binary files /dev/null and b/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch differ diff --git a/app/scripts/migrations/120.2.test.ts b/app/scripts/migrations/120.2.test.ts new file mode 100644 index 000000000000..903c706ff665 --- /dev/null +++ b/app/scripts/migrations/120.2.test.ts @@ -0,0 +1,253 @@ +import { cloneDeep } from 'lodash'; +import { migrate, version } from './120.2'; + +const sentryCaptureExceptionMock = jest.fn(); + +global.sentry = { + captureException: sentryCaptureExceptionMock, +}; + +const oldVersion = 120.1; + +describe('migration #120.2', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: {}, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.meta).toStrictEqual({ version }); + }); + + it('returns state unchanged if TransactionController state is missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('reports error and returns state unchanged if TransactionController state is invalid', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: 'invalid', + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + `Migration ${version}: Invalid TransactionController state of type 'string'`, + ); + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('returns state unchanged if transactions are missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: {}, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('reports error and returns state unchanged if transactions property is invalid', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: 'invalid', + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + `Migration ${version}: Invalid TransactionController transactions state of type 'string'`, + ); + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('reports error and returns state unchanged if there is an invalid transaction', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [ + {}, // empty object is valid for the purposes of this migration + 'invalid', + {}, + ], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + `Migration ${version}: Invalid transaction of type 'string'`, + ); + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('reports error and returns state unchanged if there is a transaction with an invalid history property', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [ + {}, // empty object is valid for the purposes of this migration + { + history: 'invalid', + }, + {}, + ], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + `Migration ${version}: Invalid transaction history of type 'string'`, + ); + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('returns state unchanged if there are no transactions', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('returns state unchanged if there are no transactions with history', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [{}, {}, {}], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('returns state unchanged if there are no transactions with history exceeding max size', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [ + { + history: [...Array(99).keys()], + }, + { + history: [...Array(100).keys()], + }, + { + history: [], + }, + ], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorageDataClone); + }); + + it('trims histories exceeding max size', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: {}, + TransactionController: { + transactions: [ + { + history: [...Array(99).keys()], + }, + { + history: [...Array(100).keys()], + }, + { + history: [...Array(101).keys()], + }, + { + history: [...Array(1000).keys()], + }, + ], + }, + }, + }; + const oldStorageDataClone = cloneDeep(oldStorage.data); + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual({ + ...oldStorageDataClone, + TransactionController: { + transactions: [ + { + history: [...Array(99).keys()], + }, + { + history: [...Array(100).keys()], + }, + { + history: [...Array(100).keys()], + }, + { + history: [...Array(100).keys()], + }, + ], + }, + }); + }); +}); diff --git a/app/scripts/migrations/120.2.ts b/app/scripts/migrations/120.2.ts new file mode 100644 index 000000000000..62846e37db85 --- /dev/null +++ b/app/scripts/migrations/120.2.ts @@ -0,0 +1,110 @@ +import { RuntimeObject, hasProperty, isObject } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; +import log from 'loglevel'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 120.2; + +/** + * This migration trims the size of any large transaction histories. This will + * result in some loss of information, but the impact is minor. The lost data + * is only used in the "Activity log" on the transaction details page. + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly + * what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by + * controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + transformState(versionedData.data); + return versionedData; +} + +function transformState(state: Record) { + if (!hasProperty(state, 'TransactionController')) { + log.warn(`Migration ${version}: Missing TransactionController state`); + return state; + } else if (!isObject(state.TransactionController)) { + global.sentry?.captureException( + `Migration ${version}: Invalid TransactionController state of type '${typeof state.TransactionController}'`, + ); + return state; + } + + const transactionControllerState = state.TransactionController; + + if (!hasProperty(transactionControllerState, 'transactions')) { + log.warn( + `Migration ${version}: Missing TransactionController transactions`, + ); + return state; + } else if (!Array.isArray(transactionControllerState.transactions)) { + global.sentry?.captureException( + `Migration ${version}: Invalid TransactionController transactions state of type '${typeof transactionControllerState.transactions}'`, + ); + return state; + } + + const validTransactions = + transactionControllerState.transactions.filter(isObject); + if ( + transactionControllerState.transactions.length !== validTransactions.length + ) { + const invalidTransaction = transactionControllerState.transactions.find( + (transaction) => !isObject(transaction), + ); + global.sentry?.captureException( + `Migration ${version}: Invalid transaction of type '${typeof invalidTransaction}'`, + ); + return state; + } + + const validHistoryTransactions = validTransactions.filter( + hasValidTransactionHistory, + ); + if (validHistoryTransactions.length !== validTransactions.length) { + const invalidTransaction = validTransactions.find( + (transaction) => !hasValidTransactionHistory(transaction), + ); + global.sentry?.captureException( + `Migration ${version}: Invalid transaction history of type '${typeof invalidTransaction?.history}'`, + ); + return state; + } + + for (const transaction of validHistoryTransactions) { + if (transaction.history && transaction.history.length > 100) { + transaction.history = transaction.history.slice(0, 100); + } + } + + return state; +} + +/** + * Check whether the given object has a valid `history` property, or no `history` + * property. We just check that it's an array, we don't validate the contents. + * + * @param transaction - The object to validate. + * @returns True if the given object was valid, false otherwise. + */ +function hasValidTransactionHistory( + transaction: RuntimeObject, +): transaction is RuntimeObject & { + history: undefined | unknown[]; +} { + return ( + !hasProperty(transaction, 'history') || Array.isArray(transaction.history) + ); +} diff --git a/app/scripts/migrations/120.test.ts b/app/scripts/migrations/120.test.ts index 3c7fd5b6b74a..cc6b149ddd96 100644 --- a/app/scripts/migrations/120.test.ts +++ b/app/scripts/migrations/120.test.ts @@ -1,6 +1,6 @@ import { migrate, version } from './120'; -const oldVersion = 119; +const oldVersion = 120.2; describe('migration #120', () => { afterEach(() => jest.resetAllMocks()); diff --git a/package.json b/package.json index 6209651f8d45..9b8b6d1b62cf 100644 --- a/package.json +++ b/package.json @@ -224,7 +224,7 @@ "@trezor/schema-utils@npm:1.0.2": "patch:@trezor/schema-utils@npm%3A1.0.2#~/.yarn/patches/@trezor-schema-utils-npm-1.0.2-7dd48689b2.patch", "lavamoat-core@npm:^15.1.1": "patch:lavamoat-core@npm%3A15.1.1#~/.yarn/patches/lavamoat-core-npm-15.1.1-51fbe39988.patch", "@metamask/snaps-sdk": "^5.0.0", - "@metamask/transaction-controller": "^32.0.0", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A32.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch", "@babel/runtime@npm:^7.7.6": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", "@babel/runtime@npm:^7.9.2": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", "@babel/runtime@npm:^7.12.5": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", @@ -350,7 +350,7 @@ "@metamask/snaps-rpc-methods": "^9.1.3", "@metamask/snaps-sdk": "^5.0.0", "@metamask/snaps-utils": "patch:@metamask/snaps-utils@npm%3A7.6.0#~/.yarn/patches/@metamask-snaps-utils-npm-7.6.0-5569b09766.patch", - "@metamask/transaction-controller": "^32.0.0", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A32.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch", "@metamask/user-operation-controller": "^10.0.0", "@metamask/utils": "^8.2.1", "@ngraveio/bc-ur": "^1.1.12", diff --git a/yarn.lock b/yarn.lock index 524fc37f06aa..8eb6ff293d65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6434,7 +6434,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^32.0.0": +"@metamask/transaction-controller@npm:32.0.0": version: 32.0.0 resolution: "@metamask/transaction-controller@npm:32.0.0" dependencies: @@ -6469,6 +6469,41 @@ __metadata: languageName: node linkType: hard +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A32.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch": + version: 32.0.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A32.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch::version=32.0.0&hash=27b27a" + dependencies: + "@ethereumjs/common": "npm:^3.2.0" + "@ethereumjs/tx": "npm:^4.2.0" + "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/approval-controller": "npm:^7.0.0" + "@metamask/base-controller": "npm:^6.0.0" + "@metamask/controller-utils": "npm:^11.0.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/gas-fee-controller": "npm:^17.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^19.0.0" + "@metamask/nonce-tracker": "npm:^5.0.0" + "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/utils": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bn.js: "npm:^5.2.1" + eth-method-registry: "npm:^4.0.0" + fast-json-patch: "npm:^3.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + peerDependencies: + "@babel/runtime": ^7.23.9 + "@metamask/approval-controller": ^7.0.0 + "@metamask/gas-fee-controller": ^17.0.0 + "@metamask/network-controller": ^19.0.0 + checksum: 10/f8e1907ed697406fc1af7dca3627e5d28f213a3b4875258ec58abdaa0ae48c4714ad79face61519ab1f3475e6aa57e8c80c9622e2eb18f65b1cd5b123962e5ea + languageName: node + linkType: hard + "@metamask/user-operation-controller@npm:^10.0.0": version: 10.0.0 resolution: "@metamask/user-operation-controller@npm:10.0.0" @@ -24959,7 +24994,7 @@ __metadata: "@metamask/snaps-utils": "patch:@metamask/snaps-utils@npm%3A7.6.0#~/.yarn/patches/@metamask-snaps-utils-npm-7.6.0-5569b09766.patch" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:^8.4.0" - "@metamask/transaction-controller": "npm:^32.0.0" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A32.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-32.0.0-e23c2c3443.patch" "@metamask/user-operation-controller": "npm:^10.0.0" "@metamask/utils": "npm:^8.2.1" "@ngraveio/bc-ur": "npm:^1.1.12"