1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 03:12:42 +02:00

Merge remote-tracking branch 'origin/master' into Version-v10.35.0

This commit is contained in:
Dan J Miller 2023-08-14 14:31:37 -02:30
commit b74ab3f26c
78 changed files with 4998 additions and 581 deletions

View File

@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [10.35.0]
### Uncategorized
- temp
## [10.34.4]
### Changed
- Updated snaps execution environment ([#20420](https://github.com/MetaMask/metamask-extension/pull/20420))
## [10.34.3]
### Fixed
- Ensure users phishing warning list is properly updated ([#20381](https://github.com/MetaMask/metamask-extension/pull/20381))
- Fix inaccurate info in swaps flow for zero-balance tokens ([#20388](https://github.com/MetaMask/metamask-extension/pull/20388))
- Fix 'Global Menu Explorer / Account Details' What's New notification display ([#20371](https://github.com/MetaMask/metamask-extension/pull/20371))
## [10.34.2]
### Added
- Add Address Details and View on Explorer to Global Menu ([#20013](https://github.com/MetaMask/metamask-extension/pull/20013))
## Changed
- Increase copy clipboard time ([#20008](https://github.com/MetaMask/metamask-extension/pull/20008))
- Show checksum addresses on account list menu ([#20217](https://github.com/MetaMask/metamask-extension/pull/20217/commits/41bab4a6e14682388f4021f2f51bc74bddcbe80e))
- Scroll to selected account when opening account list menu ([#20166](https://github.com/MetaMask/metamask-extension/pull/20166))
- Remove fallback phishing warning configuration ([#20327](https://github.com/MetaMask/metamask-extension/pull/20327))
- The phishing warning feature will no longer function if the wallet is unable to receive configuration updates. Previously a fallback config was used in this case, but we found that it was too outdated to be helpful and it caused many problems for users.
- Improved UI for downloading state logs on Chromium-based browsers ([#19872](https://github.com/MetaMask/metamask-extension/pull/19872))
- We now use a file picker to let you select the download location, rather than saving the state logs in your downloads folder.
### Fixed
- Fixed bug that could cause loss of network or token data for users upgrading from old versions ([#20276](https://github.com/MetaMask/metamask-extension/pull/20276))
- Fix crash on open of MetaMask for some users that have old network data in state ([#20345](https://github.com/MetaMask/metamask-extension/pull/20345))
## [10.34.1]
### Fixed
- Fix bug that could cause a failure in the persistence of network related data ([#20080](https://github.com/MetaMask/metamask-extension/pull/20080))
- Fix ([#20080](https://github.com/MetaMask/metamask-extension/pull/20080))
## [10.34.0]
### Added
@ -3859,6 +3890,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.35.0...HEAD
[10.35.0]: https://github.com/MetaMask/metamask-extension/compare/v10.34.0...v10.35.0
[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.34.4...HEAD
[10.34.4]: https://github.com/MetaMask/metamask-extension/compare/v10.34.3...v10.34.4
[10.34.3]: https://github.com/MetaMask/metamask-extension/compare/v10.34.2...v10.34.3
[10.34.2]: https://github.com/MetaMask/metamask-extension/compare/v10.34.1...v10.34.2
[10.34.1]: https://github.com/MetaMask/metamask-extension/compare/v10.34.0...v10.34.1
[10.34.0]: https://github.com/MetaMask/metamask-extension/compare/v10.33.1...v10.34.0
[10.33.1]: https://github.com/MetaMask/metamask-extension/compare/v10.33.0...v10.33.1
[10.33.0]: https://github.com/MetaMask/metamask-extension/compare/v10.32.0...v10.33.0

View File

@ -2743,6 +2743,15 @@
"notifications21Title": {
"message": "Introducing new and refreshed Swaps!"
},
"notifications22ActionText": {
"message": "Got it"
},
"notifications22Description": {
"message": "💡 Just click the global menu or account menu to find them!"
},
"notifications22Title": {
"message": "Looking for your account details or the block explorer URL?"
},
"notifications3ActionText": {
"message": "Read more",
"description": "The 'call to action' on the button, or link, of the 'Stay secure' notification. Upon clicking, users will be taken to a page about security on the metamask support website."

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 400 KiB

View File

@ -2,6 +2,10 @@
* @file The entry point for the web extension singleton process.
*/
// This import sets up a global function required for Sentry to function.
// It must be run first in case an error is thrown later during initialization.
import './lib/setup-persisted-state-hook';
import EventEmitter from 'events';
import endOfStream from 'end-of-stream';
import pump from 'pump';

View File

@ -29,7 +29,7 @@ export class FilterEvents implements Integration {
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
*/
private getMetaMetricsEnabled: () => boolean;
private getMetaMetricsEnabled: () => Promise<boolean>;
/**
* @param options - Constructor options.
@ -40,7 +40,7 @@ export class FilterEvents implements Integration {
constructor({
getMetaMetricsEnabled,
}: {
getMetaMetricsEnabled: () => boolean;
getMetaMetricsEnabled: () => Promise<boolean>;
}) {
this.getMetaMetricsEnabled = getMetaMetricsEnabled;
}
@ -56,13 +56,13 @@ export class FilterEvents implements Integration {
addGlobalEventProcessor: (callback: EventProcessor) => void,
getCurrentHub: () => Hub,
): void {
addGlobalEventProcessor((currentEvent: SentryEvent) => {
addGlobalEventProcessor(async (currentEvent: SentryEvent) => {
// Sentry integrations use the Sentry hub to get "this" references, for
// reasons I don't fully understand.
// eslint-disable-next-line consistent-this
const self = getCurrentHub().getIntegration(FilterEvents);
if (self) {
if (!self.getMetaMetricsEnabled()) {
if (!(await self.getMetaMetricsEnabled())) {
logger.warn(`Event dropped due to MetaMetrics setting.`);
return null;
}

View File

@ -0,0 +1,10 @@
import LocalStore from './local-store';
import ReadOnlyNetworkStore from './network-store';
const localStore = process.env.IN_TEST
? new ReadOnlyNetworkStore()
: new LocalStore();
globalThis.stateHooks.getPersistedState = async function () {
return await localStore.get();
};

View File

@ -118,16 +118,20 @@ export default function setupSentry({ release, getState }) {
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
*/
function getMetaMetricsEnabled() {
if (getState) {
async function getMetaMetricsEnabled() {
const appState = getState();
if (!appState?.store?.metamask?.participateInMetaMetrics) {
if (Object.keys(appState) > 0) {
return Boolean(appState?.store?.metamask?.participateInMetaMetrics);
}
try {
const persistedState = await globalThis.stateHooks.getPersistedState();
return Boolean(
persistedState?.data?.MetaMetricsController?.participateInMetaMetrics,
);
} catch (error) {
console.error(error);
return false;
}
} else {
return false;
}
return true;
}
Sentry.init({
@ -352,6 +356,6 @@ function toMetamaskUrl(origUrl) {
if (!filePath) {
return origUrl;
}
const metamaskUrl = `metamask${filePath}`;
const metamaskUrl = `/metamask${filePath}`;
return metamaskUrl;
}

View File

@ -1,7 +1,14 @@
import { strict as assert } from 'assert';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
import {
ListNames,
METAMASK_STALELIST_URL,
METAMASK_HOTLIST_DIFF_URL,
PHISHING_CONFIG_BASE_URL,
METAMASK_STALELIST_FILE,
METAMASK_HOTLIST_DIFF_FILE,
} from '@metamask/phishing-controller';
import { ApprovalRequestNotFoundError } from '@metamask/approval-controller';
import { PermissionsRequestNotFoundError } from '@metamask/permission-controller';
import nock from 'nock';
@ -59,21 +66,28 @@ describe('MetaMaskController', function () {
});
beforeEach(function () {
nock('https://static.metafi.codefi.network')
nock(PHISHING_CONFIG_BASE_URL)
.persist()
.get('/api/v1/lists/stalelist.json')
.get(METAMASK_STALELIST_FILE)
.reply(
200,
JSON.stringify({
version: 2,
tolerance: 2,
lastUpdated: 1,
eth_phishing_detect_config: {
fuzzylist: [],
allowlist: [],
blocklist: ['127.0.0.1'],
lastUpdated: 0,
name: ListNames.MetaMask,
},
phishfort_hotlist: {
blocklist: [],
name: ListNames.Phishfort,
},
}),
)
.get('/api/v1/lists/hotlist.json')
.get(METAMASK_HOTLIST_DIFF_FILE)
.reply(
200,
JSON.stringify([
@ -110,6 +124,20 @@ describe('MetaMaskController', function () {
await ganacheServer.quit();
});
describe('Phishing Detection Mock', function () {
it('should be updated to use v1 of the API', function () {
// Update the fixture above if this test fails
assert.equal(
METAMASK_STALELIST_URL,
'https://phishing-detection.metafi.codefi.network/v1/stalelist',
);
assert.equal(
METAMASK_HOTLIST_DIFF_URL,
'https://phishing-detection.metafi.codefi.network/v1/diffsSince',
);
});
});
describe('#addNewAccount', function () {
it('two parallel calls with same accountCount give same result', async function () {
await metamaskController.createNewVaultAndKeychain('test@123');

View File

@ -7,6 +7,14 @@ import EthQuery from 'eth-query';
import proxyquire from 'proxyquire';
import browser from 'webextension-polyfill';
import { wordlist as englishWordlist } from '@metamask/scure-bip39/dist/wordlists/english';
import {
ListNames,
METAMASK_STALELIST_URL,
METAMASK_HOTLIST_DIFF_URL,
PHISHING_CONFIG_BASE_URL,
METAMASK_STALELIST_FILE,
METAMASK_HOTLIST_DIFF_FILE,
} from '@metamask/phishing-controller';
import { TransactionStatus } from '../../shared/constants/transaction';
import createTxMeta from '../../test/lib/createTxMeta';
import { NETWORK_TYPES } from '../../shared/constants/network';
@ -169,6 +177,18 @@ const firstTimeState = {
},
},
},
PhishingController: {
phishingLists: [
{
allowlist: [],
blocklist: ['test.metamask-phishing.io'],
fuzzylist: [],
tolerance: 0,
version: 0,
name: 'MetaMask',
},
],
},
};
const noop = () => undefined;
@ -185,25 +205,36 @@ describe('MetaMaskController', function () {
.persist()
.get(/.*/u)
.reply(200, '{"JPY":12415.9}');
nock('https://static.metafi.codefi.network')
nock(PHISHING_CONFIG_BASE_URL)
.persist()
.get('/api/v1/lists/stalelist.json')
.get(METAMASK_STALELIST_FILE)
.reply(
200,
JSON.stringify({
version: 2,
tolerance: 2,
lastUpdated: 1,
eth_phishing_detect_config: {
fuzzylist: [],
allowlist: [],
blocklist: ['127.0.0.1'],
lastUpdated: 0,
blocklist: ['test.metamask-phishing.io'],
name: ListNames.MetaMask,
},
phishfort_hotlist: {
blocklist: [],
name: ListNames.Phishfort,
},
}),
)
.get('/api/v1/lists/hotlist.json')
.get(METAMASK_HOTLIST_DIFF_FILE)
.reply(
200,
JSON.stringify([
{ url: '127.0.0.1', targetList: 'blocklist', timestamp: 0 },
{
url: 'test.metamask-phishing.io',
targetList: 'blocklist',
timestamp: 0,
},
]),
);
@ -223,6 +254,20 @@ describe('MetaMaskController', function () {
await ganacheServer.quit();
});
describe('Phishing Detection Mock', function () {
it('should be updated to use v1 of the API', function () {
// Update the fixture above if this test fails
assert.equal(
METAMASK_STALELIST_URL,
'https://phishing-detection.metafi.codefi.network/v1/stalelist',
);
assert.equal(
METAMASK_HOTLIST_DIFF_URL,
'https://phishing-detection.metafi.codefi.network/v1/diffsSince',
);
});
});
describe('MetaMaskController Behaviour', function () {
let metamaskController;
@ -931,7 +976,7 @@ describe('MetaMaskController', function () {
it('sets up phishing stream for untrusted communication', async function () {
const phishingMessageSender = {
url: 'http://myethereumwalletntw.com',
url: 'http://test.metamask-phishing.io',
tab: {},
};

View File

@ -0,0 +1,25 @@
import { hasProperty, isObject } from '@metamask/utils';
/**
* Deletes frequentRpcListDetail if networkConfigurations exists, on the NetworkController state.
* Further explanation in ./077-supplements.md
*
* @param state - The persisted MetaMask state, keyed by controller.
* @returns Updated versioned MetaMask extension state.
*/
export default function transformState077For082(
state: Record<string, unknown>,
) {
if (
hasProperty(state, 'PreferencesController') &&
isObject(state.PreferencesController) &&
hasProperty(state.PreferencesController, 'frequentRpcListDetail') &&
isObject(state.NetworkController) &&
hasProperty(state.NetworkController, 'networkConfigurations')
) {
delete state.PreferencesController.frequentRpcListDetail;
}
return { ...state };
}

View File

@ -0,0 +1,24 @@
import { hasProperty, isObject } from '@metamask/utils';
/**
* Deletes network if networkId exists, on the NetworkController state.
* Further explanation in ./077-supplements.md
*
* @param state - The persisted MetaMask state, keyed by controller.
* @returns Updated versioned MetaMask extension state.
*/
export default function transformState077For084(
state: Record<string, unknown>,
) {
if (
hasProperty(state, 'NetworkController') &&
isObject(state.NetworkController) &&
hasProperty(state.NetworkController, 'network') &&
hasProperty(state.NetworkController, 'networkId')
) {
delete state.NetworkController.network;
}
return { ...state };
}

View File

@ -0,0 +1,23 @@
import { hasProperty, isObject } from '@metamask/utils';
/**
* Prior to token detection v2 the data property in tokensChainsCache was an array,
* in v2 we changes that to an object. In this migration we are converting the data as array to object.
*
* @param state - The persisted MetaMask state, keyed by controller.
* @returns Updated versioned MetaMask extension state.
*/
export default function transformState077For086(
state: Record<string, unknown>,
) {
if (
hasProperty(state, 'NetworkController') &&
isObject(state.NetworkController) &&
hasProperty(state.NetworkController, 'provider') &&
hasProperty(state.NetworkController, 'providerConfig')
) {
delete state.NetworkController.provider;
}
return { ...state };
}

View File

@ -0,0 +1,152 @@
import { hasProperty, isObject, isStrictHexString } from '@metamask/utils';
/**
* Deletes properties of `NftController.allNftContracts`, `NftController.allNfts`,
* `TokenListController.tokensChainsCache`, `TokensController.allTokens`,
* `TokensController.allIgnoredTokens` and `TokensController.allDetectedTokens` if
* their keyed by decimal number chainId and another hexadecimal chainId property
* exists within the same object.
* Further explanation in ./077-supplements.md
*
* @param state - The persisted MetaMask state, keyed by controller.
* @returns Updated versioned MetaMask extension state.
*/
export default function transformState077For086(
state: Record<string, unknown>,
): Record<string, unknown> {
if (hasProperty(state, 'NftController') && isObject(state.NftController)) {
const nftControllerState = state.NftController;
// Migrate NftController.allNftContracts
if (
hasProperty(nftControllerState, 'allNftContracts') &&
isObject(nftControllerState.allNftContracts)
) {
const { allNftContracts } = nftControllerState;
if (
Object.keys(allNftContracts).every((address) =>
isObject(allNftContracts[address]),
)
) {
Object.keys(allNftContracts).forEach((address) => {
const nftContractsByChainId = allNftContracts[address];
if (
isObject(nftContractsByChainId) &&
anyKeysAreHex(nftContractsByChainId)
) {
for (const chainId of Object.keys(nftContractsByChainId)) {
if (!isStrictHexString(chainId)) {
delete nftContractsByChainId[chainId];
}
}
}
});
}
}
// Migrate NftController.allNfts
if (
hasProperty(nftControllerState, 'allNfts') &&
isObject(nftControllerState.allNfts)
) {
const { allNfts } = nftControllerState;
if (Object.keys(allNfts).every((address) => isObject(allNfts[address]))) {
Object.keys(allNfts).forEach((address) => {
const nftsByChainId = allNfts[address];
if (isObject(nftsByChainId) && anyKeysAreHex(nftsByChainId)) {
for (const chainId of Object.keys(nftsByChainId)) {
if (!isStrictHexString(chainId)) {
delete nftsByChainId[chainId];
}
}
}
});
}
}
state.NftController = nftControllerState;
}
if (
hasProperty(state, 'TokenListController') &&
isObject(state.TokenListController)
) {
const tokenListControllerState = state.TokenListController;
// Migrate TokenListController.tokensChainsCache
if (
hasProperty(tokenListControllerState, 'tokensChainsCache') &&
isObject(tokenListControllerState.tokensChainsCache) &&
anyKeysAreHex(tokenListControllerState.tokensChainsCache)
) {
for (const chainId of Object.keys(
tokenListControllerState.tokensChainsCache,
)) {
if (!isStrictHexString(chainId)) {
delete tokenListControllerState.tokensChainsCache[chainId];
}
}
}
}
if (
hasProperty(state, 'TokensController') &&
isObject(state.TokensController)
) {
const tokensControllerState = state.TokensController;
// Migrate TokensController.allTokens
if (
hasProperty(tokensControllerState, 'allTokens') &&
isObject(tokensControllerState.allTokens) &&
anyKeysAreHex(tokensControllerState.allTokens)
) {
const { allTokens } = tokensControllerState;
for (const chainId of Object.keys(allTokens)) {
if (!isStrictHexString(chainId)) {
delete tokensControllerState.allTokens[chainId];
}
}
}
// Migrate TokensController.allIgnoredTokens
if (
hasProperty(tokensControllerState, 'allIgnoredTokens') &&
isObject(tokensControllerState.allIgnoredTokens) &&
anyKeysAreHex(tokensControllerState.allIgnoredTokens)
) {
const { allIgnoredTokens } = tokensControllerState;
for (const chainId of Object.keys(allIgnoredTokens)) {
if (!isStrictHexString(chainId)) {
delete tokensControllerState.allIgnoredTokens[chainId];
}
}
}
// Migrate TokensController.allDetectedTokens
if (
hasProperty(tokensControllerState, 'allDetectedTokens') &&
isObject(tokensControllerState.allDetectedTokens) &&
anyKeysAreHex(tokensControllerState.allDetectedTokens)
) {
const { allDetectedTokens } = tokensControllerState;
for (const chainId of Object.keys(allDetectedTokens)) {
if (!isStrictHexString(chainId)) {
delete tokensControllerState.allDetectedTokens[chainId];
}
}
}
state.TokensController = tokensControllerState;
}
return state;
}
function anyKeysAreHex(obj: Record<string, unknown>) {
return Object.keys(obj).some((chainId) => isStrictHexString(chainId));
}

View File

@ -0,0 +1,100 @@
# 077 Supplements
As of the time this file was first PR'd, we had not yet had to do what was done in this PR, which is to fix an old migration and also supplement it with state transformations
to handle problems that could be introduced by future migrations.
The document explains the need for these new state transformations and the rationale behind each. It also explains why other state transformations were not included.
## Background
As of release 10.34.0, we started having a `No metadata found for 'previousProviderStore'` error thrown from the `deriveStateFromMetadata` function in `BaseControllerV2.js`.
This was occuring when there was data on the NetworkController state for which the NetworkController + BaseController expect metadata, but no metadata exists. In particular,
`previousProviderStore` was on the NetworkController state when it should not have been.
`previousProviderStore` should not have been on the NetworkController state because of migration 85, which explictly deletes it.
We discovered that for some users, that migration had failed to run because of an error in an earlier migration: `TypeError#1: MetaMask Migration Error #77: Cannot convert undefined or null to object`.
This error was thrown from this line https://github.com/MetaMask/metamask-extension/commit/8f18e04b97af02e5a8a72e3e4872aac66595d1d8#diff-9e76a7c60c1e37cd949f729222338b23ab743e44938ccf63a4a6dab7d84ed8bcR38
So the `data` property of the objects within `TokenListController.tokensChainsCache` could be undefined, and migration 77 didn't handle that case. It could be undefined because of the way the assets controller
code was as of the core controller libraries 14.0.2 release https://github.com/MetaMask/core/blame/19f7bf3b9fd8abe6cef9cb1ac1fe831d9f651ae0/src/assets/TokenListController.ts#L270 (the `safelyExecute` call there
will return undefined if the network call failed)
For users who were in that situation, where a `TokenListController.tokensChainsCache[chainId].data` property was undefined, some significant problems would occur after updating to v10.24.0, which is the
release where migration 77 was shipped. In particular, migration 77 would fail, and all subsequent migrations would not run. The most plain case of this would be a user who was on v10.23.0
with `TokenListController.tokensChainsCache[chainId].data === undefined`. Then suppose they didn't update until v10.34.0. None of migrations 77-89 would run. Leaving their state in a form that doesn't match
with assumptions throughout our codebase. Various bugs could be caused, but the sentry error describe above is the worst case, where MetaMask simply could not be opened and users would hit the error screen.
To correct this situation we had to fix migration 77. Once we do that, all users who were in this situation (and then upgraded to the version which included the fixes for migration 77) would have all migrations
from 77 upwards run for the first time. This could be problematic for users who had used the wallet on versions 10.24.0-10.34.0, where our controllers would be writing data to state under the assumption that
the migrations had run.
As such, we had to also add code to migration 77 to avoid new errors being introduced by the migrations running on code that had been modified by controllers on versions 10.24.0 to 10.34.0
## Introducing migration 77 supplements
To correct the aforementioned problems with the data, new state transformation functions were added to this directory, to be run in migration 77 after the pre-existing migration 77 code had run.
Each file in this directory exports a state transformation function which is then imported in the migration 77 file and applied to state in sequence, after the state transformation function in
077.js itself has run and returns state. These have been split into their own files for each of use, and so that they could be grouped with this documentation.
## The chosen supplements
We added supplements for migrations 82, 84, 86 and 88 for the following reasons and with the following effects ->
**Migration 82**
Attempts to convert `frequentRpcListDetail` - an array of objects with network related data - to a `networkConfigurations` object, with the objects that were in the array keyed by a unique id.
If this migration had not run, then (prior to v10.34.0) a user would still have been able to use MetaMask, but the data they had in `frequentRpcListDetail` would now be ignored by application code,
and subsequent network data would between written to and modified in state in the `networkConfigurations` object. If migration 82 was later run (after fixing migration 77), the old `frequentRpcListDetail`
data could overwrite the existing `networkConfigurations` data, and the user could lose `networkConfigurations` data that had been written to their state since migration 82 had first failed to run.
To fix this, the migration 82 supplement deletes `frequentRpcListDetail` if the `networkConfigurations` object exists. Users in such a scenario will have network data in `networkConfigurations` that
they have been using, while the `frequentRpcListDetail` data would not have been seen for some time. So the best thing to do for them is delete their old data and preserve the data they have most recently
used.
**Migration 84**
Replaces the `NetworkController.network` property with a `networkId` and `networkStatus` property. If this migration had not run, the NetworkController would have a `network` property and
`networkId` and `networkStatus` properties. If migration 84 later ran on this state, the old (and forgotten) `network` property could cause the `networkId` and `networkStatus` to be overwritten,
affecting the state the user's experience was currently depending on.
The fix in the migration 84 supplement is to delete the `NetworkController.network` property if the `NetworkId` property already exists.
**Migration 86**
Renamed the `NetworkController.provider` property to `providerConfig`. If this migration had not run, the NetworkController would have a `provider` property and
a `providerConfig` property. If migration 86 later ran on this state, the old (and forgotten) `provider` property could cause the `providerConfig` property to be overwritten,
affecting the state the user's experience was currently depending on.
The fix in the migration 86 supplement is to delete the `NetworkController.provider` property if the `providerConfig` property already exists.
**Migration 88**
Attempted to change the keys of multiple parts of state related to tokens. In particular, `NftController.allNftContracts`, `NftController.allNfts`, `TokenListController.tokensChainsCache`, `TokensController.allTokens`, `TokensController.allIgnoredTokens` and `TokensController.allDetectedTokens`. All of these objects were keyed by chainId in decimal number form. The migration's
purpose was to change those decimal chain ID keys to hexadecimal. If migration 77 failed, and then the user added or modified tokens, they could have duplicates within these parts of state:
some with decimal keys and others with an equivalent hexadecimal key. If the data pointed to by those keys was modified at all, and the migration 88 was later run, the most recent data (under
the hexadecimal key) could be overwritten by the old data under the decimal key.
The migration 88 supplement fixes this by deleting the properties with decimal keys if an equivalent hexadecimal key exists.
## Migrations that were not supplemented
**Migration 78** was not supplemented because it only deletes data; it does not overwrite data. It's failure to run will have left rogue data in state, but that will be removed when it is run after the migration
77 fix.
**Migration 79** was not supplemented because it only deletes data; it does not overwrite data.
**Migration 80** was not supplemented because it only deletes data; it does not overwrite data.
**Migration 81** was not supplemented because it modifies data that could only be in state on a flask build. The bug that caused the undefined data in tokenlistcontroller state was present on v14.0.2 and v14.1.0 of
the controllers, but fixed in v14.2.0 of the controllers. By the time flask was released to prod, controllers was at v25.0
**Migration 83** just builds on migration 82. No additional fix is needed for 83 given that we have the 82 supplement.
**Migration 85** was not supplemented because it only deletes data; it does not overwrite data.
**Migration 87** was not supplemented because it only deletes data; it does not overwrite data.
**Migration 89** just builds on migration 82 and 84. No additional fix is needed for 89 given that we have the 82 and 84 supplement.
**Migration 90** was not supplemented because it only deletes data; it does not overwrite data.

View File

@ -1,4 +1,8 @@
import { cloneDeep } from 'lodash';
import transformState077For082 from './077-supplements/077-supplement-for-082';
import transformState077For084 from './077-supplements/077-supplement-for-084';
import transformState077For086 from './077-supplements/077-supplement-for-086';
import transformState077For088 from './077-supplements/077-supplement-for-088';
const version = 77;
@ -12,7 +16,13 @@ export default {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
const state = versionedData.data;
const newState = transformState(state);
let newState = transformState(state);
newState = transformState077For082(newState);
newState = transformState077For084(newState);
newState = transformState077For086(newState);
newState = transformState077For088(newState);
versionedData.data = newState;
return versionedData;
},
@ -27,7 +37,7 @@ function transformState(state) {
let dataObject;
// eslint-disable-next-line
for (const chainId in tokensChainsCache) {
dataCache = tokensChainsCache[chainId].data;
dataCache = tokensChainsCache[chainId].data || {};
dataObject = {};
// if the data is array conver that to object
if (Array.isArray(dataCache)) {
@ -35,8 +45,8 @@ function transformState(state) {
dataObject[token.address] = token;
}
} else if (
Object.keys(dataCache)[0].toLowerCase() !==
dataCache[Object.keys(dataCache)[0]].address.toLowerCase()
Object.keys(dataCache)[0]?.toLowerCase() !==
dataCache[Object.keys(dataCache)[0]]?.address?.toLowerCase()
) {
// for the users who already updated to the recent version
// and the dataCache is already an object keyed with 0,1,2,3 etc

File diff suppressed because it is too large Load Diff

View File

@ -156,6 +156,65 @@ describe('migration #88', () => {
});
});
it('deletes undefined-keyed properties from state of NftController.allNftContracts', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
NftController: {
allNftContracts: {
'0x111': {
'16': [
{
name: 'Contract 1',
address: '0xaaa',
},
],
undefined: [
{
name: 'Contract 2',
address: '0xbbb',
},
],
},
'0x222': {
'64': [
{
name: 'Contract 3',
address: '0xccc',
},
],
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
NftController: {
allNftContracts: {
'0x111': {
'0x10': [
{
name: 'Contract 1',
address: '0xaaa',
},
],
},
'0x222': {
'0x40': [
{
name: 'Contract 3',
address: '0xccc',
},
],
},
},
},
});
});
it('does not convert chain IDs in NftController.allNftContracts which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },
@ -395,6 +454,85 @@ describe('migration #88', () => {
});
});
it('deletes undefined-keyed properties from state of NftController.allNfts', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
NftController: {
allNfts: {
'0x111': {
'16': [
{
name: 'NFT 1',
description: 'Description for NFT 1',
image: 'nft1.jpg',
standard: 'ERC721',
tokenId: '1',
address: '0xaaa',
},
],
undefined: [
{
name: 'NFT 2',
description: 'Description for NFT 2',
image: 'nft2.jpg',
standard: 'ERC721',
tokenId: '2',
address: '0xbbb',
},
],
},
'0x222': {
'64': [
{
name: 'NFT 3',
description: 'Description for NFT 3',
image: 'nft3.jpg',
standard: 'ERC721',
tokenId: '3',
address: '0xccc',
},
],
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
NftController: {
allNfts: {
'0x111': {
'0x10': [
{
name: 'NFT 1',
description: 'Description for NFT 1',
image: 'nft1.jpg',
standard: 'ERC721',
tokenId: '1',
address: '0xaaa',
},
],
},
'0x222': {
'0x40': [
{
name: 'NFT 3',
description: 'Description for NFT 3',
image: 'nft3.jpg',
standard: 'ERC721',
tokenId: '3',
address: '0xccc',
},
],
},
},
},
});
});
it('does not convert chain IDs in NftController.allNfts which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },
@ -627,6 +765,69 @@ describe('migration #88', () => {
});
});
it('deletes undefined-keyed properties from state of TokenListController.tokensChainsCache', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
TokenListController: {
tokensChainsCache: {
'16': {
timestamp: 111111,
data: {
'0x111': {
address: '0x111',
symbol: 'TEST1',
decimals: 1,
occurrences: 1,
name: 'Token 1',
iconUrl: 'https://url/to/token1.png',
aggregators: [],
},
},
},
undefined: {
timestamp: 222222,
data: {
'0x222': {
address: '0x222',
symbol: 'TEST2',
decimals: 1,
occurrences: 1,
name: 'Token 2',
iconUrl: 'https://url/to/token2.png',
aggregators: [],
},
},
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
TokenListController: {
tokensChainsCache: {
'0x10': {
timestamp: 111111,
data: {
'0x111': {
address: '0x111',
symbol: 'TEST1',
decimals: 1,
occurrences: 1,
name: 'Token 1',
iconUrl: 'https://url/to/token1.png',
aggregators: [],
},
},
},
},
},
});
});
it('does not convert chain IDs in TokenListController.tokensChainsCache which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },
@ -807,6 +1008,72 @@ describe('migration #88', () => {
});
});
it('deletes undefined keyed properties from TokensController.allTokens', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
TokensController: {
allTokens: {
'16': {
'0x111': [
{
address: '0xaaa',
decimals: 1,
symbol: 'TEST1',
},
],
},
'32': {
'0x222': [
{
address: '0xbbb',
decimals: 1,
symbol: 'TEST2',
},
],
},
undefined: {
'0x333': [
{
address: '0xbbb',
decimals: 1,
symbol: 'TEST2',
},
],
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
TokensController: {
allTokens: {
'0x10': {
'0x111': [
{
address: '0xaaa',
decimals: 1,
symbol: 'TEST1',
},
],
},
'0x20': {
'0x222': [
{
address: '0xbbb',
decimals: 1,
symbol: 'TEST2',
},
],
},
},
},
});
});
it('does not convert chain IDs in TokensController.allTokens which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },
@ -937,6 +1204,52 @@ describe('migration #88', () => {
});
});
it('deletes undefined-keyed properties from TokensController.allIgnoredTokens', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
TokensController: {
allIgnoredTokens: {
'16': {
'0x1': {
'0x111': ['0xaaa'],
},
},
'32': {
'0x2': {
'0x222': ['0xbbb'],
},
},
undefined: {
'0x2': {
'0x222': ['0xbbb'],
},
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
TokensController: {
allIgnoredTokens: {
'0x10': {
'0x1': {
'0x111': ['0xaaa'],
},
},
'0x20': {
'0x2': {
'0x222': ['0xbbb'],
},
},
},
},
});
});
it('does not convert chain IDs in TokensController.allIgnoredTokens which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },
@ -1051,6 +1364,42 @@ describe('migration #88', () => {
});
});
it('deletes undefined-keyed properties from TokensController.allDetectedTokens', async () => {
const oldStorage = {
meta: { version: 87 },
data: {
TokensController: {
allDetectedTokens: {
'16': {
'0x1': {
'0x111': ['0xaaa'],
},
},
undefined: {
'0x2': {
'0x222': ['0xbbb'],
},
},
},
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
TokensController: {
allDetectedTokens: {
'0x10': {
'0x1': {
'0x111': ['0xaaa'],
},
},
},
},
});
});
it('does not convert chain IDs in TokensController.allDetectedTokens which are already hex strings', async () => {
const oldStorage = {
meta: { version: 87 },

View File

@ -16,8 +16,11 @@ export const version = 88;
* by a hex chain ID rather than a decimal chain ID.
* - Rebuilds `tokensChainsCache` in TokenListController to be keyed by a hex
* chain ID rather than a decimal chain ID.
* - Rebuilds `allTokens` and `allIgnoredTokens` in TokensController to be keyed
* by a hex chain ID rather than a decimal chain ID.
* - Rebuilds `allTokens`, `allDetectedTokens`, and `allIgnoredTokens` in
* TokensController to be keyed by a hex chain ID rather than a decimal chain ID.
* - removes any entries in `allNftContracts`, `allNfts`, `tokensChainsCache`,
* `allTokens`, `allIgnoredTokens` or `allDetectedTokens` that are keyed by the
* string 'undefined'
*
* @param originalVersionedData - Versioned MetaMask extension state, exactly what we persist to dist.
* @param originalVersionedData.meta - State metadata.
@ -54,6 +57,12 @@ function migrateData(state: Record<string, unknown>): void {
const nftContractsByChainId = allNftContracts[address];
if (isObject(nftContractsByChainId)) {
for (const chainId of Object.keys(nftContractsByChainId)) {
if (chainId === 'undefined' || chainId === undefined) {
delete nftContractsByChainId[chainId];
}
}
allNftContracts[address] = mapKeys(
nftContractsByChainId,
(_, chainId: string) => toHex(chainId),
@ -75,6 +84,12 @@ function migrateData(state: Record<string, unknown>): void {
const nftsByChainId = allNfts[address];
if (isObject(nftsByChainId)) {
for (const chainId of Object.keys(nftsByChainId)) {
if (chainId === 'undefined' || chainId === undefined) {
delete nftsByChainId[chainId];
}
}
allNfts[address] = mapKeys(nftsByChainId, (_, chainId: string) =>
toHex(chainId),
);
@ -97,6 +112,14 @@ function migrateData(state: Record<string, unknown>): void {
hasProperty(tokenListControllerState, 'tokensChainsCache') &&
isObject(tokenListControllerState.tokensChainsCache)
) {
for (const chainId of Object.keys(
tokenListControllerState.tokensChainsCache,
)) {
if (chainId === 'undefined' || chainId === undefined) {
delete tokenListControllerState.tokensChainsCache[chainId];
}
}
tokenListControllerState.tokensChainsCache = mapKeys(
tokenListControllerState.tokensChainsCache,
(_, chainId: string) => toHex(chainId),
@ -117,6 +140,12 @@ function migrateData(state: Record<string, unknown>): void {
) {
const { allTokens } = tokensControllerState;
for (const chainId of Object.keys(allTokens)) {
if (chainId === 'undefined' || chainId === undefined) {
delete allTokens[chainId];
}
}
tokensControllerState.allTokens = mapKeys(
allTokens,
(_, chainId: string) => toHex(chainId),
@ -130,6 +159,12 @@ function migrateData(state: Record<string, unknown>): void {
) {
const { allIgnoredTokens } = tokensControllerState;
for (const chainId of Object.keys(allIgnoredTokens)) {
if (chainId === 'undefined' || chainId === undefined) {
delete allIgnoredTokens[chainId];
}
}
tokensControllerState.allIgnoredTokens = mapKeys(
allIgnoredTokens,
(_, chainId: string) => toHex(chainId),
@ -143,6 +178,12 @@ function migrateData(state: Record<string, unknown>): void {
) {
const { allDetectedTokens } = tokensControllerState;
for (const chainId of Object.keys(allDetectedTokens)) {
if (chainId === 'undefined' || chainId === undefined) {
delete allDetectedTokens[chainId];
}
}
tokensControllerState.allDetectedTokens = mapKeys(
allDetectedTokens,
(_, chainId: string) => toHex(chainId),

View File

@ -0,0 +1,224 @@
import { migrate, version } from './089';
jest.mock('uuid', () => {
const actual = jest.requireActual('uuid');
return {
...actual,
v4: jest.fn(),
};
});
describe('migration #89', () => {
it('should update the version metadata', async () => {
const oldStorage = {
meta: {
version: 88,
},
data: {},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version,
});
});
it('should return state unaltered if there is no network controller state', async () => {
const oldData = {
other: 'data',
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if there is no network controller providerConfig state', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
},
},
},
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if the providerConfig already has an id', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
},
},
providerConfig: {
id: 'test',
},
},
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if there is no network config with the same rpcUrl and the providerConfig', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
rpcUrl: 'http://foo.bar',
},
},
providerConfig: {
rpcUrl: 'http://baz.buzz',
},
},
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldData);
});
it('should update the provider config to have the id of a network config with the same rpcUrl', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
rpcUrl: 'http://foo.bar',
id: 'test',
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
},
},
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
rpcUrl: 'http://foo.bar',
id: 'test',
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
id: 'test',
},
},
});
});
it('should update the provider config to have the id of a network config with the same rpcUrl, even if there are other networks with the same chainId', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
rpcUrl: 'http://fizz.buzz',
id: 'FAILEDtest',
chainId: 1,
},
id2: {
foo: 'bar',
rpcUrl: 'http://foo.bar',
id: 'PASSEDtest',
},
id3: {
foo: 'bar',
rpcUrl: 'http://baz.buzz',
id: 'FAILEDtest',
chainId: 1,
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
chainId: 1,
},
},
};
const oldStorage = {
meta: {
version: 88,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
rpcUrl: 'http://fizz.buzz',
id: 'FAILEDtest',
chainId: 1,
},
id2: {
foo: 'bar',
rpcUrl: 'http://foo.bar',
id: 'PASSEDtest',
},
id3: {
foo: 'bar',
rpcUrl: 'http://baz.buzz',
id: 'FAILEDtest',
chainId: 1,
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
id: 'PASSEDtest',
chainId: 1,
},
},
});
});
});

View File

@ -0,0 +1,71 @@
import { hasProperty, isObject } from '@metamask/utils';
import { cloneDeep } from 'lodash';
export const version = 89;
/**
* Add an `id` to the `providerConfig` object.
*
* @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: {
meta: { version: number };
data: Record<string, unknown>;
}) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
versionedData.data = transformState(versionedData.data);
return versionedData;
}
function transformState(state: Record<string, unknown>) {
if (
hasProperty(state, 'NetworkController') &&
isObject(state.NetworkController) &&
hasProperty(state.NetworkController, 'providerConfig') &&
isObject(state.NetworkController.providerConfig)
) {
const { networkConfigurations, providerConfig } = state.NetworkController;
if (!isObject(networkConfigurations)) {
return state;
}
if (providerConfig.id) {
return state;
}
let newProviderConfigId;
for (const networkConfigurationId of Object.keys(networkConfigurations)) {
const networkConfiguration =
networkConfigurations[networkConfigurationId];
if (!isObject(networkConfiguration)) {
return state;
}
if (networkConfiguration.rpcUrl === providerConfig.rpcUrl) {
newProviderConfigId = networkConfiguration.id;
break;
}
}
if (!newProviderConfigId) {
return state;
}
state.NetworkController.providerConfig = {
...providerConfig,
id: newProviderConfigId,
};
return {
...state,
NetworkController: state.NetworkController,
};
}
return state;
}

View File

@ -0,0 +1,109 @@
import { migrate, version } from './090';
const PREVIOUS_VERSION = version - 1;
describe('migration #90', () => {
it('updates the version metadata', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: {},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version,
});
});
it('does not change the state if the phishing controller state does not exist', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: { test: '123' },
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldStorage.data);
});
it('does not change the state if the phishing controller state is invalid', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: { PhishingController: 'this is not valid' },
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldStorage.data);
});
it('does not change the state if the listState property does not exist', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: {
PhishingController: { test: 123 },
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldStorage.data);
});
it('deletes the "listState" property', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: { PhishingController: { listState: {} } },
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data.PhishingController.listState).toBeUndefined();
});
it('deletes the listState if present', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: { PhishingController: { listState: { test: 123 } } },
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
PhishingController: {},
});
});
it('does not delete the allowlist if present', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: {
PhishingController: {
whitelist: ['foobar.com'],
listState: { test: 123 },
},
},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
PhishingController: { whitelist: ['foobar.com'] },
});
});
});

View File

@ -0,0 +1,37 @@
import { cloneDeep } from 'lodash';
import { hasProperty, isObject } from '@metamask/utils';
export const version = 90;
/**
* Explain the purpose of the migration here.
*
* @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: {
meta: { version: number };
data: Record<string, unknown>;
}) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
versionedData.data = transformState(versionedData.data);
return versionedData;
}
function transformState(state: Record<string, unknown>) {
if (
!hasProperty(state, 'PhishingController') ||
!isObject(state.PhishingController) ||
!hasProperty(state.PhishingController, 'listState')
) {
return state;
}
delete state.PhishingController.listState;
return state;
}

View File

@ -0,0 +1,150 @@
import { cloneDeep } from 'lodash';
import { migrate, version } from './091';
jest.mock('uuid', () => {
const actual = jest.requireActual('uuid');
return {
...actual,
v4: jest.fn(),
};
});
describe('migration #91', () => {
it('should update the version metadata', async () => {
const oldStorage = {
meta: {
version: 90,
},
data: {},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version,
});
});
it('should return state unaltered if there is no network controller state', async () => {
const oldData = {
other: 'data',
};
const oldStorage = {
meta: {
version: 90,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if there is no network controller networkConfigurations state', async () => {
const oldData = {
other: 'data',
NetworkController: {
providerConfig: {
foo: 'bar',
},
},
};
const oldStorage = {
meta: {
version: 90,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if the networkConfigurations all have a chainId', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
chainId: '0x1',
},
id2: {
fizz: 'buzz',
chainId: '0x2',
},
},
providerConfig: {
id: 'test',
},
},
};
const oldStorage = {
meta: {
version: 90,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual(oldData);
});
it('should delete networks that have an undefined or null chainId', async () => {
const oldData = {
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
chainId: '0x1',
},
id2: {
fizz: 'buzz',
chainId: '0x2',
},
id3: {
buzz: 'baz',
chainId: undefined,
},
id4: {
foo: 'bar',
chainId: null,
},
id5: {
fizz: 'buzz',
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
},
},
};
const oldStorage = {
meta: {
version: 90,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual({
other: 'data',
NetworkController: {
networkConfigurations: {
id1: {
foo: 'bar',
chainId: '0x1',
},
id2: {
fizz: 'buzz',
chainId: '0x2',
},
},
providerConfig: {
rpcUrl: 'http://foo.bar',
},
},
});
});
});

View File

@ -0,0 +1,55 @@
import { hasProperty, isObject } from '@metamask/utils';
import { cloneDeep } from 'lodash';
export const version = 91;
/**
* Delete network configurations if they do not have a chain id
*
* @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: {
meta: { version: number };
data: Record<string, unknown>;
}) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
versionedData.data = transformState(versionedData.data);
return versionedData;
}
function transformState(state: Record<string, unknown>) {
if (
hasProperty(state, 'NetworkController') &&
isObject(state.NetworkController) &&
hasProperty(state.NetworkController, 'networkConfigurations') &&
isObject(state.NetworkController.networkConfigurations)
) {
const { networkConfigurations } = state.NetworkController;
for (const [networkConfigurationId, networkConfiguration] of Object.entries(
networkConfigurations,
)) {
if (isObject(networkConfiguration)) {
if (!networkConfiguration.chainId) {
delete networkConfigurations[networkConfigurationId];
}
}
}
state.NetworkController = {
...state.NetworkController,
networkConfigurations,
};
return {
...state,
NetworkController: state.NetworkController,
};
}
return state;
}

View File

@ -0,0 +1,78 @@
import { cloneDeep } from 'lodash';
import { migrate, version } from './092';
const PREVIOUS_VERSION = version - 1;
describe('migration #92', () => {
it('should update the version metadata', async () => {
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: {},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version,
});
});
it('should return state unaltered if there is no phishing controller state', async () => {
const oldData = {
other: 'data',
};
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual(oldData);
});
it('should return state unaltered if there is no phishing controller last fetched state', async () => {
const oldData = {
other: 'data',
PhishingController: {
whitelist: [],
},
};
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: oldData,
};
const newStorage = await migrate(cloneDeep(oldStorage));
expect(newStorage.data).toStrictEqual(oldData);
});
it('should remove both last fetched properties from phishing controller state', async () => {
const oldData = {
other: 'data',
PhishingController: {
whitelist: [],
hotlistLastFetched: 0,
stalelistLastFetched: 0,
},
};
const oldStorage = {
meta: {
version: PREVIOUS_VERSION,
},
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
other: 'data',
PhishingController: {
whitelist: [],
},
});
});
});

View File

@ -0,0 +1,35 @@
import { cloneDeep } from 'lodash';
import { hasProperty, isObject } from '@metamask/utils';
export const version = 92;
/**
* Delete `stalelistLastFetched` and `hotlistLastFetched` to force a phishing configuration refresh
* because the format has changed.
*
* @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: {
meta: { version: number };
data: Record<string, unknown>;
}) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
versionedData.data = transformState(versionedData.data);
return versionedData;
}
function transformState(state: Record<string, unknown>) {
if (
hasProperty(state, 'PhishingController') &&
isObject(state.PhishingController)
) {
delete state.PhishingController.stalelistLastFetched;
delete state.PhishingController.hotlistLastFetched;
}
return state;
}

View File

@ -92,6 +92,10 @@ import * as m085 from './085';
import * as m086 from './086';
import * as m087 from './087';
import * as m088 from './088';
import * as m089 from './089';
import * as m090 from './090';
import * as m091 from './091';
import * as m092 from './092';
const migrations = [
m002,
@ -181,6 +185,10 @@ const migrations = [
m086,
m087,
m088,
m089,
m090,
m091,
m092,
];
export default migrations;

View File

@ -4,6 +4,10 @@ import '@formatjs/intl-relativetimeformat/polyfill';
// dev only, "react-devtools" import is skipped in prod builds
import 'react-devtools';
// This import sets up a global function required for Sentry to function.
// It must be run first in case an error is thrown later during initialization.
import './lib/setup-persisted-state-hook';
import PortStream from 'extension-port-stream';
import browser from 'webextension-polyfill';

View File

@ -52,7 +52,7 @@ buildTypes:
- SEGMENT_FLASK_WRITE_KEY
- ALLOW_LOCAL_SNAPS: true
- REQUIRE_SNAPS_ALLOWLIST: false
- IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/0.36.1-flask.1/index.html
- IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/0.38.1-flask.1/index.html
- SUPPORT_LINK: https://metamask-flask.zendesk.com/hc
- SUPPORT_REQUEST_LINK: https://metamask-flask.zendesk.com/hc/en-us/requests/new
- INFURA_ENV_KEY_REF: INFURA_FLASK_PROJECT_ID
@ -71,7 +71,7 @@ buildTypes:
- SEGMENT_FLASK_WRITE_KEY
- ALLOW_LOCAL_SNAPS: true
- REQUIRE_SNAPS_ALLOWLIST: false
- IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/0.36.1-flask.1/index.html
- IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/0.38.1-flask.1/index.html
- SUPPORT_LINK: https://metamask-flask.zendesk.com/hc
- SUPPORT_REQUEST_LINK: https://metamask-flask.zendesk.com/hc/en-us/requests/new
- INFURA_ENV_KEY_REF: INFURA_FLASK_PROJECT_ID

View File

@ -99,6 +99,8 @@ async function defineAndRunBuildTasks() {
'navigator',
'harden',
'console',
'WeakSet',
'Event',
'Image', // Used by browser to generate notifications
// globals chromedriver needs to function
/cdc_[a-zA-Z0-9]+_[a-zA-Z]+/iu,

View File

@ -31,7 +31,7 @@ function upload_sourcemaps {
local release="${1}"; shift
local dist_directory="${1}"; shift
sentry-cli releases files "${release}" upload-sourcemaps "${dist_directory}"/chrome/*.js "${dist_directory}"/sourcemaps/ --rewrite --url-prefix 'metamask'
sentry-cli releases files "${release}" upload-sourcemaps "${dist_directory}"/chrome/*.js "${dist_directory}"/sourcemaps/ --rewrite --url-prefix '/metamask'
}
function main {

View File

@ -887,8 +887,8 @@
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@metamask/utils": true,
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
@ -896,6 +896,19 @@
"ethjs>ethjs-unit": true
}
},
"@metamask/controller-utils>@metamask/utils": {
"globals": {
"TextDecoder": true,
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
"superstruct": true
}
},
"@metamask/controller-utils>@spruceid/siwe-parser": {
"globals": {
"console.error": true,
@ -1530,6 +1543,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1714,34 +1728,12 @@
"fetch": true
},
"packages": {
"@metamask/phishing-controller>@metamask/base-controller": true,
"@metamask/phishing-controller>@metamask/controller-utils": true,
"@metamask/base-controller": true,
"@metamask/controller-utils": true,
"@metamask/phishing-warning>eth-phishing-detect": true,
"punycode": true
}
},
"@metamask/phishing-controller>@metamask/base-controller": {
"packages": {
"immer": true
}
},
"@metamask/phishing-controller>@metamask/controller-utils": {
"globals": {
"URL": true,
"console.error": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
"ethereumjs-util": true,
"ethjs>ethjs-unit": true
}
},
"@metamask/phishing-warning>eth-phishing-detect": {
"packages": {
"eslint>optionator>fast-levenshtein": true
@ -1772,6 +1764,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1914,6 +1907,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1949,6 +1943,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,

View File

@ -887,8 +887,8 @@
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@metamask/utils": true,
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
@ -896,6 +896,19 @@
"ethjs>ethjs-unit": true
}
},
"@metamask/controller-utils>@metamask/utils": {
"globals": {
"TextDecoder": true,
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
"superstruct": true
}
},
"@metamask/controller-utils>@spruceid/siwe-parser": {
"globals": {
"console.error": true,
@ -1277,6 +1290,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1294,6 +1308,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1656,6 +1671,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1847,34 +1863,12 @@
"fetch": true
},
"packages": {
"@metamask/phishing-controller>@metamask/base-controller": true,
"@metamask/phishing-controller>@metamask/controller-utils": true,
"@metamask/base-controller": true,
"@metamask/controller-utils": true,
"@metamask/phishing-warning>eth-phishing-detect": true,
"punycode": true
}
},
"@metamask/phishing-controller>@metamask/base-controller": {
"packages": {
"immer": true
}
},
"@metamask/phishing-controller>@metamask/controller-utils": {
"globals": {
"URL": true,
"console.error": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
"ethereumjs-util": true,
"ethjs>ethjs-unit": true
}
},
"@metamask/phishing-warning>eth-phishing-detect": {
"packages": {
"eslint>optionator>fast-levenshtein": true
@ -1994,6 +1988,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2199,6 +2194,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2368,6 +2364,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2405,6 +2402,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2438,6 +2436,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,

View File

@ -887,8 +887,8 @@
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@metamask/utils": true,
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
@ -896,6 +896,19 @@
"ethjs>ethjs-unit": true
}
},
"@metamask/controller-utils>@metamask/utils": {
"globals": {
"TextDecoder": true,
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
"superstruct": true
}
},
"@metamask/controller-utils>@spruceid/siwe-parser": {
"globals": {
"console.error": true,
@ -1277,6 +1290,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1294,6 +1308,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1656,6 +1671,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1847,34 +1863,12 @@
"fetch": true
},
"packages": {
"@metamask/phishing-controller>@metamask/base-controller": true,
"@metamask/phishing-controller>@metamask/controller-utils": true,
"@metamask/base-controller": true,
"@metamask/controller-utils": true,
"@metamask/phishing-warning>eth-phishing-detect": true,
"punycode": true
}
},
"@metamask/phishing-controller>@metamask/base-controller": {
"packages": {
"immer": true
}
},
"@metamask/phishing-controller>@metamask/controller-utils": {
"globals": {
"URL": true,
"console.error": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
"ethereumjs-util": true,
"ethjs>ethjs-unit": true
}
},
"@metamask/phishing-warning>eth-phishing-detect": {
"packages": {
"eslint>optionator>fast-levenshtein": true
@ -1994,6 +1988,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2199,6 +2194,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2368,6 +2364,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2405,6 +2402,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2438,6 +2436,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,

View File

@ -887,8 +887,8 @@
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@metamask/utils": true,
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
@ -896,6 +896,19 @@
"ethjs>ethjs-unit": true
}
},
"@metamask/controller-utils>@metamask/utils": {
"globals": {
"TextDecoder": true,
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
"superstruct": true
}
},
"@metamask/controller-utils>@spruceid/siwe-parser": {
"globals": {
"console.error": true,
@ -1530,6 +1543,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1714,34 +1728,12 @@
"fetch": true
},
"packages": {
"@metamask/phishing-controller>@metamask/base-controller": true,
"@metamask/phishing-controller>@metamask/controller-utils": true,
"@metamask/base-controller": true,
"@metamask/controller-utils": true,
"@metamask/phishing-warning>eth-phishing-detect": true,
"punycode": true
}
},
"@metamask/phishing-controller>@metamask/base-controller": {
"packages": {
"immer": true
}
},
"@metamask/phishing-controller>@metamask/controller-utils": {
"globals": {
"URL": true,
"console.error": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
"ethereumjs-util": true,
"ethjs>ethjs-unit": true
}
},
"@metamask/phishing-warning>eth-phishing-detect": {
"packages": {
"eslint>optionator>fast-levenshtein": true
@ -1772,6 +1764,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1914,6 +1907,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1949,6 +1943,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,

View File

@ -1115,8 +1115,8 @@
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@metamask/utils": true,
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
@ -1124,6 +1124,19 @@
"ethjs>ethjs-unit": true
}
},
"@metamask/controller-utils>@metamask/utils": {
"globals": {
"TextDecoder": true,
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
"superstruct": true
}
},
"@metamask/controller-utils>@spruceid/siwe-parser": {
"globals": {
"console.error": true,
@ -1758,6 +1771,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -1942,34 +1956,12 @@
"fetch": true
},
"packages": {
"@metamask/phishing-controller>@metamask/base-controller": true,
"@metamask/phishing-controller>@metamask/controller-utils": true,
"@metamask/base-controller": true,
"@metamask/controller-utils": true,
"@metamask/phishing-warning>eth-phishing-detect": true,
"punycode": true
}
},
"@metamask/phishing-controller>@metamask/base-controller": {
"packages": {
"immer": true
}
},
"@metamask/phishing-controller>@metamask/controller-utils": {
"globals": {
"URL": true,
"console.error": true,
"fetch": true,
"setTimeout": true
},
"packages": {
"@metamask/controller-utils>@spruceid/siwe-parser": true,
"@metamask/utils": true,
"browserify>buffer": true,
"eslint>fast-deep-equal": true,
"eth-ens-namehash": true,
"ethereumjs-util": true,
"ethjs>ethjs-unit": true
}
},
"@metamask/phishing-warning>eth-phishing-detect": {
"packages": {
"eslint>optionator>fast-levenshtein": true
@ -2000,6 +1992,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2142,6 +2135,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,
@ -2177,6 +2171,7 @@
"TextEncoder": true
},
"packages": {
"@metamask/key-tree>@noble/hashes": true,
"browserify>buffer": true,
"nock>debug": true,
"semver": true,

View File

@ -1124,13 +1124,6 @@
"@metamask/jazzicon>color>color-convert>color-name": true
}
},
"@sentry/cli>mkdirp": {
"builtin": {
"fs": true,
"path.dirname": true,
"path.resolve": true
}
},
"@storybook/addon-knobs>qs": {
"packages": {
"string.prototype.matchall>side-channel": true
@ -8158,7 +8151,14 @@
"path.dirname": true
},
"packages": {
"@sentry/cli>mkdirp": true
"stylelint>file-entry-cache>flat-cache>write>mkdirp": true
}
},
"stylelint>file-entry-cache>flat-cache>write>mkdirp": {
"builtin": {
"fs": true,
"path.dirname": true,
"path.resolve": true
}
},
"stylelint>global-modules": {

View File

@ -229,7 +229,7 @@
"@metamask/announcement-controller": "^4.0.0",
"@metamask/approval-controller": "^3.4.0",
"@metamask/assets-controllers": "^9.2.0",
"@metamask/base-controller": "^3.1.0",
"@metamask/base-controller": "^3.2.0",
"@metamask/browser-passworder": "^4.1.0",
"@metamask/contract-metadata": "^2.3.1",
"@metamask/controller-utils": "^4.2.0",
@ -252,7 +252,7 @@
"@metamask/notification-controller": "^3.0.0",
"@metamask/obs-store": "^8.1.0",
"@metamask/permission-controller": "^4.0.0",
"@metamask/phishing-controller": "^3.0.0",
"@metamask/phishing-controller": "^6.0.0",
"@metamask/post-message-stream": "^6.0.0",
"@metamask/ppom-validator": "^0.0.1",
"@metamask/providers": "^11.1.0",
@ -385,7 +385,7 @@
"@metamask/forwarder": "^1.1.0",
"@metamask/phishing-warning": "^2.1.0",
"@metamask/test-dapp": "^7.0.1",
"@sentry/cli": "^1.58.0",
"@sentry/cli": "^2.19.4",
"@storybook/addon-a11y": "^7.0.11",
"@storybook/addon-actions": "^7.0.11",
"@storybook/addon-essentials": "^7.0.11",

View File

@ -114,6 +114,13 @@ export const UI_NOTIFICATIONS = {
width: '100%',
},
},
22: {
id: 22,
date: null,
image: {
src: 'images/global-menu-block-explorer.svg',
},
},
};
export const getTranslatedUINotifications = (t, locale) => {
@ -313,5 +320,16 @@ export const getTranslatedUINotifications = (t, locale) => {
)
: '',
},
22: {
...UI_NOTIFICATIONS[22],
title: t('notifications22Title'),
description: t('notifications22Description'),
actionText: t('notifications22ActionText'),
date: UI_NOTIFICATIONS[22].date
? new Intl.DateTimeFormat(formattedLocale).format(
new Date(UI_NOTIFICATIONS[22].date),
)
: '',
},
};
};

View File

@ -141,6 +141,11 @@ function defaultFixture() {
id: 21,
isShown: true,
},
22: {
date: null,
id: 22,
isShown: true,
},
},
},
AppStateController: {

View File

@ -499,85 +499,6 @@ const openDapp = async (driver, contract = null, dappURL = DAPP_URL) => {
? await driver.openNewPage(`${dappURL}/?contract=${contract}`)
: await driver.openNewPage(dappURL);
};
const STALELIST_URL =
'https://static.metafi.codefi.network/api/v1/lists/stalelist.json';
const emptyHtmlPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>title</title>
</head>
<body>
Empty page
</body>
</html>`;
/**
* Setup fetch mocks for the phishing detection feature.
*
* The mock configuration will show that "127.0.0.1" is blocked. The dynamic lookup on the warning
* page can be customized, so that we can test both the MetaMask and PhishFort block cases.
*
* @param {import('mockttp').Mockttp} mockServer - The mock server.
* @param {object} metamaskPhishingConfigResponse - The response for the dynamic phishing
* configuration lookup performed by the warning page.
*/
async function setupPhishingDetectionMocks(
mockServer,
metamaskPhishingConfigResponse,
) {
await mockServer.forGet(STALELIST_URL).thenCallback(() => {
return {
statusCode: 200,
json: {
version: 2,
tolerance: 2,
fuzzylist: [],
allowlist: [],
blocklist: ['127.0.0.1'],
lastUpdated: 0,
},
};
});
await mockServer
.forGet('https://github.com/MetaMask/eth-phishing-detect/issues/new')
.thenCallback(() => {
return {
statusCode: 200,
body: emptyHtmlPage,
};
});
await mockServer
.forGet('https://github.com/phishfort/phishfort-lists/issues/new')
.thenCallback(() => {
return {
statusCode: 200,
body: emptyHtmlPage,
};
});
await mockServer
.forGet(
'https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/master/src/config.json',
)
.thenCallback(() => metamaskPhishingConfigResponse);
}
function mockPhishingDetection(mockServer) {
setupPhishingDetectionMocks(mockServer, {
statusCode: 200,
json: {
version: 2,
tolerance: 2,
fuzzylist: [],
whitelist: [],
blacklist: ['127.0.0.1'],
lastUpdated: 0,
},
});
}
const PRIVATE_KEY =
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC';
@ -787,8 +708,6 @@ module.exports = {
importWrongSRPOnboardingFlow,
testSRPDropdownIterations,
openDapp,
mockPhishingDetection,
setupPhishingDetectionMocks,
defaultGanacheOptions,
sendTransaction,
findAnotherAccountFromAccountList,

View File

@ -4,21 +4,9 @@ const blacklistedHosts = [
'mainnet.infura.io',
'sepolia.infura.io',
];
const HOTLIST_URL =
'https://static.metafi.codefi.network/api/v1/lists/hotlist.json';
const STALELIST_URL =
'https://static.metafi.codefi.network/api/v1/lists/stalelist.json';
const emptyHotlist = [];
const emptyStalelist = {
version: 2,
tolerance: 2,
fuzzylist: [],
allowlist: [],
blocklist: [],
lastUpdated: 0,
};
const {
mockEmptyStalelistAndHotlist,
} = require('./tests/phishing-controller/mocks');
/**
* Setup E2E network mocks.
@ -385,19 +373,7 @@ async function setupMocking(server, testSpecificMock, { chainId }) {
};
});
await server.forGet(STALELIST_URL).thenCallback(() => {
return {
statusCode: 200,
json: emptyStalelist,
};
});
await server.forGet(HOTLIST_URL).thenCallback(() => {
return {
statusCode: 200,
json: emptyHotlist,
};
});
await mockEmptyStalelistAndHotlist(server);
await server
.forPost('https://customnetwork.com/api/customRPC')

View File

@ -1,7 +1,7 @@
const { strict: assert } = require('assert');
const FixtureBuilder = require('../fixture-builder');
const {
withFixtures,
mockPhishingDetection,
openDapp,
defaultGanacheOptions,
assertAccountBalanceForDOM,
@ -11,7 +11,11 @@ const {
unlockWallet,
terminateServiceWorker,
} = require('../helpers');
const FixtureBuilder = require('../fixture-builder');
const {
setupPhishingDetectionMocks,
BlockProvider,
} = require('../tests/phishing-controller/helpers');
describe('Phishing warning page', function () {
const driverOptions = { openDevToolsForTabs: true };
@ -21,12 +25,17 @@ describe('Phishing warning page', function () {
await withFixtures(
{
dapp: true,
fixtures: new FixtureBuilder().build(),
ganacheOptions: defaultGanacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
driverOptions,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: ['127.0.0.1'],
});
},
dapp: true,
},
async ({ driver, ganacheServer }) => {
await driver.navigate();

View File

@ -6,10 +6,20 @@ const { runInShell } = require('../../development/lib/run-command');
const { exitWithError } = require('../../development/lib/exit-with-error');
const getTestPathsForTestDir = async (testDir) => {
const testFilenames = await fs.readdir(testDir);
const testPaths = testFilenames.map((filename) =>
path.join(testDir, filename),
);
const testFilenames = await fs.readdir(testDir, { withFileTypes: true });
const testPaths = [];
for (const itemInDirectory of testFilenames) {
const fullPath = path.join(testDir, itemInDirectory.name);
if (itemInDirectory.isDirectory()) {
const subDirPaths = await getTestPathsForTestDir(fullPath);
testPaths.push(...subDirPaths);
} else if (fullPath.endsWith('.spec.js')) {
testPaths.push(fullPath);
}
}
return testPaths;
};

View File

@ -111,7 +111,7 @@ describe('Swaps - notifications', function () {
});
await checkNotification(driver, {
title: 'Insufficient balance',
text: 'You need 50 more TESTETH to complete this swap',
text: 'You need 43.4467 more TESTETH to complete this swap',
});
await reviewQuote(driver, {
swapFrom: 'TESTETH',

View File

@ -56,6 +56,10 @@ describe('Backup and Restore', function () {
],
};
it('should backup the account settings', async function () {
if (process.env.SELENIUM_BROWSER === 'chrome') {
// Chrome shows OS level download prompt which can't be dismissed by Selenium
this.skip();
}
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
@ -97,6 +101,10 @@ describe('Backup and Restore', function () {
});
it('should restore the account settings', async function () {
if (process.env.SELENIUM_BROWSER === 'chrome') {
// Chrome shows OS level download prompt which can't be dismissed by Selenium
this.skip();
}
await withFixtures(
{
fixtures: new FixtureBuilder().build(),

View File

@ -1,9 +1,26 @@
const { strict: assert } = require('assert');
const { Browser } = require('selenium-webdriver');
const { convertToHexValue, withFixtures } = require('../helpers');
const FixtureBuilder = require('../fixture-builder');
describe('Sentry errors', function () {
async function mockSentry(mockServer) {
const migrationError =
process.env.SELENIUM_BROWSER === Browser.CHROME
? `Cannot read properties of undefined (reading 'version')`
: 'meta is undefined';
async function mockSentryMigratorError(mockServer) {
return await mockServer
.forPost('https://sentry.io/api/0000000/envelope/')
.withBodyIncluding(migrationError)
.thenCallback(() => {
return {
statusCode: 200,
json: {},
};
});
}
async function mockSentryTestError(mockServer) {
return await mockServer
.forPost('https://sentry.io/api/0000000/envelope/')
.withBodyIncluding('Test Error')
@ -23,6 +40,111 @@ describe('Sentry errors', function () {
},
],
};
describe('before initialization', function () {
it('should NOT send error events when participateInMetaMetrics is false', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
// Intentionally corrupt state to trigger migration error during initialization
meta: undefined,
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryMigratorError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.delay(3000);
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
it('should send error events', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
// Intentionally corrupt state to trigger migration error during initialization
meta: undefined,
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryMigratorError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const { level } = mockJsonBody;
const [{ type, value }] = mockJsonBody.exception.values;
// Verify request
assert.equal(type, 'TypeError');
assert(value.includes(migrationError));
assert.equal(level, 'error');
},
);
});
});
describe('after initialization', function () {
it('should NOT send error events when participateInMetaMetrics is false', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
// Trigger error
driver.executeScript('window.stateHooks.throwTestError()');
driver.delay(3000);
// Wait for Sentry request
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
it('should send error events', async function () {
await withFixtures(
{
@ -35,7 +157,7 @@ describe('Sentry errors', function () {
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentry,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
@ -62,4 +184,5 @@ describe('Sentry errors', function () {
},
);
});
});
});

View File

@ -55,7 +55,7 @@ describe('Permissions', function () {
await driver.clickElement(
'[data-testid="account-options-menu-button"]',
);
await driver.clickElement('.menu-item');
await driver.clickElement('.menu-item:nth-of-type(3)');
await driver.findElement({
text: 'Connected sites',

View File

@ -0,0 +1,25 @@
const {
METAMASK_STALELIST_URL,
METAMASK_HOTLIST_DIFF_URL,
ListNames,
} = require('@metamask/phishing-controller');
/**
* The block provider names.
*
* @enum {BlockProvider}
* @readonly
* @property {string} MetaMask - The name of the MetaMask block provider.
* @property {string} PhishFort - The name of the PhishFort block provider.
*/
const BlockProvider = {
MetaMask: 'metamask',
PhishFort: 'phishfort',
};
module.exports = {
METAMASK_HOTLIST_DIFF_URL,
METAMASK_STALELIST_URL,
BlockProvider,
ListNames,
};

View File

@ -0,0 +1,172 @@
const {
METAMASK_STALELIST_URL,
METAMASK_HOTLIST_DIFF_URL,
ListNames,
BlockProvider,
} = require('./helpers');
// last updated must not be 0
const lastUpdated = 1;
const defaultHotlist = { data: [] };
const defaultStalelist = {
version: 2,
tolerance: 2,
lastUpdated,
eth_phishing_detect_config: {
fuzzylist: [],
allowlist: [],
blocklist: [],
name: ListNames.MetaMask,
},
phishfort_hotlist: {
blocklist: [],
name: ListNames.Phishfort,
},
};
const emptyHtmlPage = (blockProvider) => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>title</title>
</head>
<body>
Empty page by ${blockProvider}
</body>
</html>`;
/**
* Setup fetch mocks for the phishing detection feature.
*
* The mock configuration will show that "127.0.0.1" is blocked. The dynamic lookup on the warning
* page can be customized, so that we can test both the MetaMask and PhishFort block cases.
*
* @param {import('mockttp').Mockttp} mockServer - The mock server.
* @param {object} mockPhishingConfigResponseConfig - The response for the dynamic phishing
* @param {number} mockPhishingConfigResponseConfig.statusCode - The status code for the response.
* @param {string[]} mockPhishingConfigResponseConfig.blocklist - The blocklist for the response.
* @param {BlockProvider} mockPhishingConfigResponseConfig.blockProvider - The name of the provider who blocked the page.
* configuration lookup performed by the warning page.
*/
async function setupPhishingDetectionMocks(
mockServer,
{
statusCode = 200,
blocklist = ['127.0.0.1'],
blockProvider = BlockProvider.MetaMask,
},
) {
const blockProviderConfig = resolveProviderConfigName(blockProvider);
const response =
statusCode >= 400
? { statusCode }
: {
statusCode,
json: {
data: {
...defaultStalelist,
[blockProviderConfig]: {
...defaultStalelist[blockProviderConfig],
blocklist,
},
},
},
};
await mockServer.forGet(METAMASK_STALELIST_URL).thenCallback(() => {
return response;
});
await mockServer
.forGet(`${METAMASK_HOTLIST_DIFF_URL}/${lastUpdated}`)
.thenCallback(() => {
return {
statusCode: 200,
json: defaultHotlist,
};
});
await mockServer
.forGet('https://github.com/MetaMask/eth-phishing-detect/issues/new')
.thenCallback(() => {
return {
statusCode: 200,
body: emptyHtmlPage(blockProvider),
};
});
await mockServer
.forGet('https://github.com/phishfort/phishfort-lists/issues/new')
.thenCallback(() => {
return {
statusCode: 200,
body: emptyHtmlPage(blockProvider),
};
});
}
/**
* Mocks the request made from the phishing warning page to check eth-phishing-detect
*
* @param {*} mockServer
* @param {*} metamaskPhishingConfigResponse
*/
async function mockConfigLookupOnWarningPage(
mockServer,
metamaskPhishingConfigResponse,
) {
await mockServer
.forGet(
'https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/master/src/config.json',
)
.thenCallback(() => metamaskPhishingConfigResponse);
}
/**
* Setup fallback mocks for default behaviour of the phishing detection feature.
*
* This sets up default mocks for a mockttp server when included in test/e2e/mock-e2e.js
*
* @param {import('mockttp').Mockttp} mockServer - The mock server.
*/
async function mockEmptyStalelistAndHotlist(mockServer) {
await mockServer.forGet(METAMASK_STALELIST_URL).thenCallback(() => {
return {
statusCode: 200,
json: { ...defaultStalelist },
};
});
await mockServer
.forGet(`${METAMASK_HOTLIST_DIFF_URL}/${lastUpdated}`)
.thenCallback(() => {
return {
statusCode: 200,
json: defaultHotlist,
};
});
}
/**
*
* @param {BlockProvider} providerName - The name of the provider who issued the block.
* @returns {string} The name of the phishing config in the response.
*/
function resolveProviderConfigName(providerName) {
switch (providerName.toLowerCase()) {
case BlockProvider.MetaMask:
return 'eth_phishing_detect_config';
case BlockProvider.PhishFort:
return 'phishfort_hotlist';
default:
throw new Error('Provider name must either be metamask or phishfort');
}
}
module.exports = {
setupPhishingDetectionMocks,
mockEmptyStalelistAndHotlist,
mockConfigLookupOnWarningPage,
};

View File

@ -1,12 +1,17 @@
const { strict: assert } = require('assert');
const { convertToHexValue, withFixtures, openDapp } = require('../../helpers');
const FixtureBuilder = require('../../fixture-builder');
const {
METAMASK_HOTLIST_DIFF_URL,
METAMASK_STALELIST_URL,
BlockProvider,
} = require('./helpers');
const {
convertToHexValue,
withFixtures,
openDapp,
setupPhishingDetectionMocks,
mockPhishingDetection,
} = require('../helpers');
const FixtureBuilder = require('../fixture-builder');
mockConfigLookupOnWarningPage,
} = require('./mocks');
describe('Phishing Detection', function () {
const ganacheOptions = {
@ -19,13 +24,32 @@ describe('Phishing Detection', function () {
],
};
describe('Phishing Detection Mock', function () {
it('should be updated to use v1 of the API', function () {
// Update the fixture in phishing-controller/mocks.js if this test fails
assert.equal(
METAMASK_STALELIST_URL,
'https://phishing-detection.metafi.codefi.network/v1/stalelist',
);
assert.equal(
METAMASK_HOTLIST_DIFF_URL,
'https://phishing-detection.metafi.codefi.network/v1/diffsSince',
);
});
});
it('should display the MetaMask Phishing Detection page and take the user to the blocked page if they continue', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: ['127.0.0.1'],
});
},
dapp: true,
failOnConsoleError: false,
},
@ -44,12 +68,20 @@ describe('Phishing Detection', function () {
});
it('should display the MetaMask Phishing Detection page in an iframe and take the user to the blocked page if they continue', async function () {
const DAPP_WITH_IFRAMED_PAGE_ON_BLOCKLIST = 'http://localhost:8080/';
const IFRAMED_HOSTNAME = '127.0.0.1';
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: [IFRAMED_HOSTNAME],
});
},
dapp: true,
dappPaths: ['mock-page-with-iframe'],
dappOptions: {
@ -61,7 +93,7 @@ describe('Phishing Detection', function () {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await driver.openNewPage('http://localhost:8080/');
await driver.openNewPage(DAPP_WITH_IFRAMED_PAGE_ON_BLOCKLIST);
const iframe = await driver.findElement('iframe');
@ -85,7 +117,12 @@ describe('Phishing Detection', function () {
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: ['127.0.0.1'],
});
},
dapp: true,
dappPaths: ['mock-page-with-disallowed-iframe'],
dappOptions: {
@ -125,7 +162,11 @@ describe('Phishing Detection', function () {
ganacheOptions,
title: this.test.title,
testSpecificMock: (mockServer) => {
setupPhishingDetectionMocks(mockServer, { statusCode: 500 });
setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: ['127.0.0.1'],
});
mockConfigLookupOnWarningPage(mockServer, { statusCode: 500 });
},
dapp: true,
failOnConsoleError: false,
@ -139,7 +180,9 @@ describe('Phishing Detection', function () {
await driver.clickElement({ text: 'report a detection problem.' });
// wait for page to load before checking URL.
await driver.findElement({ text: 'Empty page' });
await driver.findElement({
text: `Empty page by ${BlockProvider.MetaMask}`,
});
assert.equal(
await driver.getCurrentUrl(),
`https://github.com/MetaMask/eth-phishing-detect/issues/new?title=[Legitimate%20Site%20Blocked]%20127.0.0.1&body=http%3A%2F%2F127.0.0.1%3A8080%2F`,
@ -149,12 +192,20 @@ describe('Phishing Detection', function () {
});
it('should navigate the user to eth-phishing-detect to dispute a block from MetaMask', async function () {
// Must be site on actual eth-phishing-detect blocklist
const phishingSite = new URL('https://test.metamask-phishing.io');
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: [phishingSite.hostname],
});
},
dapp: true,
failOnConsoleError: false,
},
@ -162,37 +213,34 @@ describe('Phishing Detection', function () {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await openDapp(driver);
await driver.openNewPage(phishingSite.href);
await driver.clickElement({ text: 'report a detection problem.' });
// wait for page to load before checking URL.
await driver.findElement({ text: 'Empty page' });
await driver.findElement({
text: `Empty page by ${BlockProvider.MetaMask}`,
});
assert.equal(
await driver.getCurrentUrl(),
`https://github.com/MetaMask/eth-phishing-detect/issues/new?title=[Legitimate%20Site%20Blocked]%20127.0.0.1&body=http%3A%2F%2F127.0.0.1%3A8080%2F`,
`https://github.com/MetaMask/eth-phishing-detect/issues/new?title=[Legitimate%20Site%20Blocked]%20${encodeURIComponent(
phishingSite.hostname,
)}&body=${encodeURIComponent(phishingSite.href)}`,
);
},
);
});
it('should navigate the user to PhishFort to dispute a block from MetaMask', async function () {
it('should navigate the user to PhishFort to dispute a Phishfort Block', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: (mockServer) => {
setupPhishingDetectionMocks(mockServer, {
statusCode: 200,
json: {
version: 2,
tolerance: 2,
fuzzylist: [],
whitelist: [],
blacklist: [],
lastUpdated: 0,
},
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.PhishFort,
blocklist: ['127.0.0.1'],
});
},
dapp: true,
@ -202,12 +250,14 @@ describe('Phishing Detection', function () {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await openDapp(driver);
await driver.openNewPage('http://127.0.0.1:8080');
await driver.clickElement({ text: 'report a detection problem.' });
// wait for page to load before checking URL.
await driver.findElement({ text: 'Empty page' });
await driver.findElement({
text: `Empty page by ${BlockProvider.PhishFort}`,
});
assert.equal(
await driver.getCurrentUrl(),
`https://github.com/phishfort/phishfort-lists/issues/new?title=[Legitimate%20Site%20Blocked]%20127.0.0.1&body=http%3A%2F%2F127.0.0.1%3A8080%2F`,
@ -222,7 +272,12 @@ describe('Phishing Detection', function () {
fixtures: new FixtureBuilder().build(),
ganacheOptions,
title: this.test.title,
testSpecificMock: mockPhishingDetection,
testSpecificMock: async (mockServer) => {
return setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
blocklist: ['127.0.0.1'],
});
},
dapp: true,
dappPaths: ['mock-page-with-disallowed-iframe'],
dappOptions: {

View File

@ -30,7 +30,12 @@ describe('State logs', function () {
},
],
};
it('should download state logs for the account', async function () {
if (process.env.SELENIUM_BROWSER === 'chrome') {
// Chrome shows OS level download prompt which can't be dismissed by Selenium
this.skip();
}
await withFixtures(
{
fixtures: new FixtureBuilder().build(),

View File

@ -100,6 +100,9 @@ function getActionFunctionById(id, history) {
updateViewedNotifications({ 21: true });
history.push(PREPARE_SWAP_ROUTE);
},
22: () => {
updateViewedNotifications({ 22: true });
},
};
return actionFunctions[id];
@ -360,6 +363,7 @@ export default function WhatsNewPopup({
18: renderFirstNotification,
19: renderFirstNotification,
21: renderFirstNotification,
22: renderFirstNotification,
};
return (

View File

@ -1,16 +1,12 @@
import React, { useContext, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getAccountLink } from '@metamask/etherscan-link';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
import { mmiActionsFactory } from '../../../store/institutional/institution-background';
///: END:ONLY_INCLUDE_IN
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getRpcPrefsForCurrentProvider,
getBlockExplorerLinkText,
getCurrentChainId,
getHardwareWalletType,
getAccountTypeForKeyring,
@ -22,7 +18,6 @@ import {
import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils';
///: END:ONLY_INCLUDE_IN
import { findKeyringForAddress } from '../../../ducks/metamask/metamask';
import { NETWORKS_ROUTE } from '../../../helpers/constants/routes';
import { MenuItem } from '../../ui/menu';
import {
IconName,
@ -34,17 +29,17 @@ import {
} from '../../component-library';
import {
MetaMetricsEventCategory,
MetaMetricsEventLinkType,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { getURLHostName } from '../../../helpers/utils/util';
import { setAccountDetailsAddress, showModal } from '../../../store/actions';
import { showModal } from '../../../store/actions';
import { TextVariant } from '../../../helpers/constants/design-system';
import { formatAccountType } from '../../../helpers/utils/metrics';
import { AccountDetailsMenuItem, ViewExplorerMenuItem } from '..';
const METRICS_LOCATION = 'Account Options';
export const AccountListItemMenu = ({
anchorElement,
blockExplorerUrlSubTitle,
onClose,
closeMenu,
isRemovable,
@ -54,11 +49,8 @@ export const AccountListItemMenu = ({
const t = useI18nContext();
const trackEvent = useContext(MetaMetricsContext);
const dispatch = useDispatch();
const history = useHistory();
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const addressLink = getAccountLink(identity.address, chainId, rpcPrefs);
const deviceName = useSelector(getHardwareWalletType);
@ -67,28 +59,6 @@ export const AccountListItemMenu = ({
);
const accountType = formatAccountType(getAccountTypeForKeyring(keyring));
const blockExplorerLinkText = useSelector(getBlockExplorerLinkText);
const openBlockExplorer = () => {
trackEvent({
event: MetaMetricsEventName.ExternalLinkClicked,
category: MetaMetricsEventCategory.Navigation,
properties: {
link_type: MetaMetricsEventLinkType.AccountTracker,
location: 'Account Options',
url_domain: getURLHostName(addressLink),
},
});
global.platform.openTab({
url: addressLink,
});
onClose();
};
const routeToAddBlockExplorerUrl = () => {
history.push(`${NETWORKS_ROUTE}#blockExplorerUrl`);
};
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const isCustodial = keyring?.type ? /Custody/u.test(keyring.type) : false;
const accounts = useSelector(getMetaMaskAccountsOrdered);
@ -158,46 +128,18 @@ export const AccountListItemMenu = ({
>
<ModalFocus restoreFocus initialFocusRef={anchorElement}>
<div onKeyDown={handleKeyDown} ref={popoverDialogRef}>
<MenuItem
onClick={() => {
blockExplorerLinkText.firstPart === 'addBlockExplorer'
? routeToAddBlockExplorerUrl()
: openBlockExplorer();
trackEvent({
event: MetaMetricsEventName.BlockExplorerLinkClicked,
category: MetaMetricsEventCategory.Accounts,
properties: {
location: 'Account Options',
chain_id: chainId,
},
});
}}
subtitle={blockExplorerUrlSubTitle || null}
iconName={IconName.Export}
data-testid="account-list-menu-open-explorer"
>
<Text variant={TextVariant.bodySm}>{t('viewOnExplorer')}</Text>
</MenuItem>
<MenuItem
ref={accountDetailsItemRef}
onClick={() => {
dispatch(setAccountDetailsAddress(identity.address));
trackEvent({
event: MetaMetricsEventName.NavAccountDetailsOpened,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: 'Account Options',
},
});
onClose();
closeMenu?.();
}}
iconName={IconName.ScanBarcode}
data-testid="account-list-menu-details"
>
<Text variant={TextVariant.bodySm}>{t('accountDetails')}</Text>
</MenuItem>
<AccountDetailsMenuItem
metricsLocation={METRICS_LOCATION}
closeMenu={closeMenu}
address={identity.address}
textProps={{ variant: TextVariant.bodySm }}
/>
<ViewExplorerMenuItem
metricsLocation={METRICS_LOCATION}
closeMenu={closeMenu}
textProps={{ variant: TextVariant.bodySm }}
address={identity.address}
/>
{isRemovable ? (
<MenuItem
ref={removeAccountItemRef}
@ -286,10 +228,6 @@ AccountListItemMenu.propTypes = {
* Function that closes the menu
*/
closeMenu: PropTypes.func,
/**
* Domain of the block explorer
*/
blockExplorerUrlSubTitle: PropTypes.string,
/**
* Represents if the account should be removable
*/

View File

@ -14,9 +14,6 @@ export default {
closeMenu: {
action: 'closeMenu',
},
blockExplorerUrlSubTitle: {
control: 'text',
},
isRemovable: {
control: 'boolean',
},
@ -36,7 +33,6 @@ export default {
tokenBalance: '32.09 ETH',
},
isRemovable: true,
blockExplorerUrlSubTitle: 'etherscan.io',
isOpen: true,
},
};

View File

@ -1,6 +1,6 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { renderWithProvider, fireEvent } from '../../../../test/jest';
import { renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import { AccountListItemMenu } from '.';
@ -31,23 +31,6 @@ const render = (props = {}) => {
};
describe('AccountListItem', () => {
it('renders the URL for explorer', () => {
const blockExplorerDomain = 'etherscan.io';
const { getByText, getByTestId } = render({
blockExplorerUrlSubTitle: blockExplorerDomain,
});
expect(getByText(blockExplorerDomain)).toBeInTheDocument();
Object.defineProperty(global, 'platform', {
value: {
openTab: jest.fn(),
},
});
const openExplorerTabSpy = jest.spyOn(global.platform, 'openTab');
fireEvent.click(getByTestId('account-list-menu-open-explorer'));
expect(openExplorerTabSpy).toHaveBeenCalled();
});
it('renders remove icon with isRemovable', () => {
const { getByTestId } = render({ isRemovable: true });
expect(getByTestId('account-list-menu-remove')).toBeInTheDocument();

View File

@ -103,7 +103,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
<p
class="mm-box mm-text mm-text--body-sm mm-box--color-text-alternative"
>
0x0dc...e7bc
0x0DC...E7bc
</p>
</div>
<div

View File

@ -1,11 +1,11 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useSelector } from 'react-redux';
import { toChecksumHexAddress } from '@metamask/controller-utils';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { getRpcPrefsForCurrentProvider } from '../../../selectors';
import { getURLHostName, shortenAddress } from '../../../helpers/utils/util';
import { shortenAddress } from '../../../helpers/utils/util';
import { AccountListItemMenu } from '..';
import {
@ -87,15 +87,20 @@ export const AccountListItem = ({
setAccountListItemMenuElement(ref);
};
// If this is the selected item in the Account menu,
// scroll the item into view
const itemRef = useRef(null);
useEffect(() => {
if (selected) {
itemRef.current?.scrollIntoView?.();
}
}, [itemRef, selected]);
const keyring = useSelector((state) =>
findKeyringForAddress(state, identity.address),
);
const label = getLabel(keyring, t);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const { blockExplorerUrl } = rpcPrefs;
const blockExplorerUrlSubTitle = getURLHostName(blockExplorerUrl);
const trackEvent = useContext(MetaMetricsContext);
return (
@ -107,6 +112,7 @@ export const AccountListItem = ({
'multichain-account-list-item--selected': selected,
'multichain-account-list-item--connected': Boolean(connectedAvatar),
})}
ref={itemRef}
onClick={() => {
// Without this check, the account will be selected after
// the account options menu closes
@ -202,7 +208,7 @@ export const AccountListItem = ({
/>
) : null}
<Text variant={TextVariant.bodySm} color={Color.textAlternative}>
{shortenAddress(identity.address)}
{shortenAddress(toChecksumHexAddress(identity.address))}
</Text>
</Box>
<Text
@ -250,7 +256,6 @@ export const AccountListItem = ({
/>
<AccountListItemMenu
anchorElement={accountListItemMenuElement}
blockExplorerUrlSubTitle={blockExplorerUrlSubTitle}
identity={identity}
onClose={() => setAccountOptionsMenuOpen(false)}
isOpen={accountOptionsMenuOpen}

View File

@ -1,6 +1,7 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { toChecksumHexAddress } from '@metamask/controller-utils';
import { renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
@ -34,7 +35,7 @@ describe('AccountListItem', () => {
const { container } = render();
expect(screen.getByText(identity.name)).toBeInTheDocument();
expect(
screen.getByText(shortenAddress(identity.address)),
screen.getByText(shortenAddress(toChecksumHexAddress(identity.address))),
).toBeInTheDocument();
expect(document.querySelector('[title="0.006 ETH"]')).toBeInTheDocument();

View File

@ -42,6 +42,7 @@ import {
///: END:ONLY_INCLUDE_IN
import {
getMetaMetricsId,
getSelectedAddress,
///: BEGIN:ONLY_INCLUDE_IN(snaps)
getUnreadNotificationsCount,
///: END:ONLY_INCLUDE_IN
@ -57,6 +58,9 @@ import {
TextVariant,
} from '../../../helpers/constants/design-system';
///: END:ONLY_INCLUDE_IN
import { AccountDetailsMenuItem, ViewExplorerMenuItem } from '..';
const METRICS_LOCATION = 'Global Menu';
export const GlobalMenu = ({ closeMenu, anchorElement }) => {
const t = useI18nContext();
@ -64,6 +68,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
const trackEvent = useContext(MetaMetricsContext);
const history = useHistory();
const metaMetricsId = useSelector(getMetaMetricsId);
const address = useSelector(getSelectedAddress);
const hasUnapprovedTransactions = useSelector(
(state) => Object.keys(state.metamask.unapprovedTxs).length > 0,
@ -86,6 +91,16 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
return (
<Menu anchorElement={anchorElement} onHide={closeMenu}>
<AccountDetailsMenuItem
metricsLocation={METRICS_LOCATION}
closeMenu={closeMenu}
address={address}
/>
<ViewExplorerMenuItem
metricsLocation={METRICS_LOCATION}
closeMenu={closeMenu}
address={address}
/>
<MenuItem
iconName={IconName.Connect}
disabled={hasUnapprovedTransactions}
@ -95,7 +110,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
event: MetaMetricsEventName.NavConnectedSitesOpened,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: 'Global Menu',
location: METRICS_LOCATION,
},
});
closeMenu();
@ -141,7 +156,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
event: MetaMetricsEventName.PortfolioLinkClicked,
properties: {
url: portfolioUrl,
location: 'Global Menu',
location: METRICS_LOCATION,
},
},
{
@ -168,7 +183,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
event: MetaMetricsEventName.AppWindowExpanded,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: 'Global Menu',
location: METRICS_LOCATION,
},
});
closeMenu();
@ -226,7 +241,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
event: MetaMetricsEventName.SupportLinkClicked,
properties: {
url: supportLink,
location: 'Global Menu',
location: METRICS_LOCATION,
},
},
{
@ -250,7 +265,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.NavSettingsOpened,
properties: {
location: 'Global Menu',
location: METRICS_LOCATION,
},
});
closeMenu();
@ -268,7 +283,7 @@ export const GlobalMenu = ({ closeMenu, anchorElement }) => {
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AppLocked,
properties: {
location: 'Global Menu',
location: METRICS_LOCATION,
},
});
closeMenu();

View File

@ -18,8 +18,10 @@ const render = (metamaskStateChanges = {}) => {
};
const mockLockMetaMask = jest.fn();
const mockSetAccountDetailsAddress = jest.fn();
jest.mock('../../../store/actions', () => ({
lockMetamask: () => mockLockMetaMask,
setAccountDetailsAddress: () => mockSetAccountDetailsAddress,
}));
describe('AccountListItem', () => {

View File

@ -17,3 +17,4 @@ export { AccountDetails } from './account-details';
export { CreateAccount } from './create-account';
export { ImportAccount } from './import-account';
export { ImportNftsModal } from './import-nfts-modal';
export { AccountDetailsMenuItem, ViewExplorerMenuItem } from './menu-items';

View File

@ -0,0 +1,54 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { setAccountDetailsAddress } from '../../../store/actions';
import { MenuItem } from '../../ui/menu';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { IconName, Text } from '../../component-library';
export const AccountDetailsMenuItem = ({
metricsLocation,
closeMenu,
address,
textProps,
}) => {
const t = useI18nContext();
const dispatch = useDispatch();
const trackEvent = useContext(MetaMetricsContext);
const LABEL = t('accountDetails');
return (
<MenuItem
onClick={() => {
dispatch(setAccountDetailsAddress(address));
trackEvent({
event: MetaMetricsEventName.NavAccountDetailsOpened,
category: MetaMetricsEventCategory.Navigation,
properties: {
location: metricsLocation,
},
});
closeMenu?.();
}}
iconName={IconName.ScanBarcode}
data-testid="account-list-menu-details"
>
{textProps ? <Text {...textProps}>{LABEL}</Text> : LABEL}
</MenuItem>
);
};
AccountDetailsMenuItem.propTypes = {
metricsLocation: PropTypes.string.isRequired,
closeMenu: PropTypes.func,
address: PropTypes.string.isRequired,
textProps: PropTypes.object,
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { renderWithProvider, fireEvent } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import * as actions from '../../../store/actions';
import { AccountDetailsMenuItem } from '.';
const render = () => {
const store = configureStore(mockState);
return renderWithProvider(
<AccountDetailsMenuItem
metricsLocation="Global Menu"
address={mockState.metamask.selectedAddress}
closeMenu={jest.fn()}
/>,
store,
);
};
jest.mock('../../../store/actions', () => ({
...jest.requireActual('../../../store/actions.ts'),
setAccountDetailsAddress: jest.fn().mockReturnValue({ type: 'TYPE' }),
}));
describe('AccountDetailsMenuItem', () => {
it('opens the Account Details modal with the correct address', () => {
global.platform = { openTab: jest.fn() };
const { getByText, getByTestId } = render();
expect(getByText('Account details')).toBeInTheDocument();
fireEvent.click(getByTestId('account-list-menu-details'));
expect(actions.setAccountDetailsAddress).toHaveBeenCalledWith(
mockState.metamask.selectedAddress,
);
});
});

View File

@ -0,0 +1,2 @@
export { AccountDetailsMenuItem } from './account-details-menu-item';
export { ViewExplorerMenuItem } from './view-explorer-menu-item';

View File

@ -0,0 +1,102 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { toChecksumHexAddress } from '@metamask/controller-utils';
import { getAccountLink } from '@metamask/etherscan-link';
import { MenuItem } from '../../ui/menu';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
MetaMetricsEventCategory,
MetaMetricsEventLinkType,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { IconName, Text } from '../../component-library';
import {
getBlockExplorerLinkText,
getCurrentChainId,
getRpcPrefsForCurrentProvider,
} from '../../../selectors';
import { getURLHostName } from '../../../helpers/utils/util';
import { NETWORKS_ROUTE } from '../../../helpers/constants/routes';
export const ViewExplorerMenuItem = ({
metricsLocation,
closeMenu,
textProps,
address,
}) => {
const t = useI18nContext();
const trackEvent = useContext(MetaMetricsContext);
const history = useHistory();
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const addressLink = getAccountLink(
toChecksumHexAddress(address),
chainId,
rpcPrefs,
);
const { blockExplorerUrl } = rpcPrefs;
const blockExplorerUrlSubTitle = getURLHostName(blockExplorerUrl);
const blockExplorerLinkText = useSelector(getBlockExplorerLinkText);
const openBlockExplorer = () => {
trackEvent({
event: MetaMetricsEventName.ExternalLinkClicked,
category: MetaMetricsEventCategory.Navigation,
properties: {
link_type: MetaMetricsEventLinkType.AccountTracker,
location: metricsLocation,
url_domain: getURLHostName(addressLink),
},
});
global.platform.openTab({
url: addressLink,
});
closeMenu();
};
const routeToAddBlockExplorerUrl = () => {
history.push(`${NETWORKS_ROUTE}#blockExplorerUrl`);
};
const LABEL = t('viewOnExplorer');
return (
<MenuItem
onClick={() => {
blockExplorerLinkText.firstPart === 'addBlockExplorer'
? routeToAddBlockExplorerUrl()
: openBlockExplorer();
trackEvent({
event: MetaMetricsEventName.BlockExplorerLinkClicked,
category: MetaMetricsEventCategory.Accounts,
properties: {
location: metricsLocation,
chain_id: chainId,
},
});
closeMenu?.();
}}
subtitle={blockExplorerUrlSubTitle || null}
iconName={IconName.Export}
data-testid="account-list-menu-open-explorer"
>
{textProps ? <Text {...textProps}>{LABEL}</Text> : LABEL}
</MenuItem>
);
};
ViewExplorerMenuItem.propTypes = {
metricsLocation: PropTypes.string.isRequired,
closeMenu: PropTypes.func,
textProps: PropTypes.object,
address: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,30 @@
import React from 'react';
import { renderWithProvider, fireEvent } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import { ViewExplorerMenuItem } from '.';
const render = () => {
const store = configureStore(mockState);
return renderWithProvider(
<ViewExplorerMenuItem
metricsLocation="Global Menu"
closeMenu={jest.fn()}
address="0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc"
/>,
store,
);
};
describe('ViewExplorerMenuItem', () => {
it('renders "View on explorer"', () => {
global.platform = { openTab: jest.fn() };
const { getByText, getByTestId } = render();
expect(getByText('View on explorer')).toBeInTheDocument();
const openExplorerTabSpy = jest.spyOn(global.platform, 'openTab');
fireEvent.click(getByTestId('account-list-menu-open-explorer'));
expect(openExplorerTabSpy).toHaveBeenCalled();
});
});

View File

@ -121,6 +121,7 @@ const initialState = {
currentSmartTransactionsError: '',
swapsSTXLoading: false,
transactionSettingsOpened: false,
latestAddedTokenTo: '',
};
const slice = createSlice({
@ -150,6 +151,9 @@ const slice = createSlice({
setFetchingQuotes: (state, action) => {
state.fetchingQuotes = action.payload;
},
setLatestAddedTokenTo: (state, action) => {
state.latestAddedTokenTo = action.payload;
},
setFromToken: (state, action) => {
state.fromToken = action.payload;
},
@ -245,6 +249,8 @@ export const getToToken = (state) => state.swaps.toToken;
export const getFetchingQuotes = (state) => state.swaps.fetchingQuotes;
export const getLatestAddedTokenTo = (state) => state.swaps.latestAddedTokenTo;
export const getQuotesFetchStartTime = (state) =>
state.swaps.quotesFetchStartTime;
@ -479,6 +485,7 @@ const {
setAggregatorMetadata,
setBalanceError,
setFetchingQuotes,
setLatestAddedTokenTo,
setFromToken,
setFromTokenError,
setFromTokenInputValue,
@ -502,6 +509,7 @@ export {
setAggregatorMetadata,
setBalanceError,
setFetchingQuotes,
setLatestAddedTokenTo,
setFromToken as setSwapsFromToken,
setFromTokenError,
setFromTokenInputValue,
@ -665,7 +673,12 @@ export const fetchQuotesAndSetQuoteState = (
iconUrl: fromTokenIconUrl,
balance: fromTokenBalance,
} = selectedFromToken;
const { address: toTokenAddress, symbol: toTokenSymbol } = selectedToToken;
const {
address: toTokenAddress,
symbol: toTokenSymbol,
decimals: toTokenDecimals,
iconUrl: toTokenIconUrl,
} = selectedToToken;
// pageRedirectionDisabled is true if quotes prefetching is active (a user is on the Build Quote page).
// In that case we just want to silently prefetch quotes without redirecting to the quotes loading page.
if (!pageRedirectionDisabled) {
@ -676,6 +689,30 @@ export const fetchQuotesAndSetQuoteState = (
const contractExchangeRates = getTokenExchangeRates(state);
if (
toTokenAddress &&
toTokenSymbol !== swapsDefaultToken.symbol &&
contractExchangeRates[toTokenAddress] === undefined &&
!isTokenAlreadyAdded(toTokenAddress, getTokens(state))
) {
await dispatch(
addToken(
toTokenAddress,
toTokenSymbol,
toTokenDecimals,
toTokenIconUrl,
true,
),
);
await dispatch(setLatestAddedTokenTo(toTokenAddress));
} else {
const latestAddedTokenTo = getLatestAddedTokenTo(state);
// Only reset the latest added Token To if it's a different token.
if (latestAddedTokenTo !== toTokenAddress) {
await dispatch(setLatestAddedTokenTo(''));
}
}
if (
fromTokenAddress &&
fromTokenSymbol !== swapsDefaultToken.symbol &&
@ -831,36 +868,6 @@ export const fetchQuotesAndSetQuoteState = (
};
};
const addTokenTo = (dispatch, state) => {
const fetchParams = getFetchParams(state);
const swapsDefaultToken = getSwapsDefaultToken(state);
const contractExchangeRates = getTokenExchangeRates(state);
const selectedToToken =
getToToken(state) || fetchParams?.metaData?.destinationTokenInfo || {};
const {
address: toTokenAddress,
symbol: toTokenSymbol,
decimals: toTokenDecimals,
iconUrl: toTokenIconUrl,
} = selectedToToken;
if (
toTokenAddress &&
toTokenSymbol !== swapsDefaultToken.symbol &&
contractExchangeRates[toTokenAddress] === undefined &&
!isTokenAlreadyAdded(toTokenAddress, getTokens(state))
) {
dispatch(
addToken(
toTokenAddress,
toTokenSymbol,
toTokenDecimals,
toTokenIconUrl,
true,
),
);
}
};
export const signAndSendSwapsSmartTransaction = ({
unsignedTransaction,
trackEvent,
@ -960,7 +967,6 @@ export const signAndSendSwapsSmartTransaction = ({
dispatch(setCurrentSmartTransactionsError(StxErrorTypes.unavailable));
return;
}
addTokenTo(dispatch, state);
if (approveTxParams) {
updatedApproveTxParams.gas = `0x${decimalToHex(
fees.approvalTxFees?.gasLimit || 0,
@ -1204,7 +1210,6 @@ export const signAndSendTransactions = (
history.push(AWAITING_SIGNATURES_ROUTE);
}
addTokenTo(dispatch, state);
if (approveTxParams) {
if (networkAndAccountSupports1559) {
approveTxParams.maxFeePerGas = maxFeePerGas;

View File

@ -1,11 +1,93 @@
import { getRandomFileName } from './util';
/**
* @enum { string }
*/
export const ExportableContentType = {
JSON: 'application/json',
TXT: 'text/plain',
};
export function exportAsFile(filename, data, type = 'text/csv') {
/**
* @enum { string }
*/
const ExtensionForContentType = {
[ExportableContentType.JSON]: '.json',
[ExportableContentType.TXT]: '.txt',
};
/**
* Export data as a file.
*
* @param {string} filename - The name of the file to export.
* @param {string} data - The data to export.
* @param {ExportableContentType} contentType - The content type of the file to export.
*/
export async function exportAsFile(filename, data, contentType) {
if (!ExtensionForContentType[contentType]) {
throw new Error(`Unsupported file type: ${contentType}`);
}
if (supportsShowSaveFilePicker()) {
// Preferred method for downloads
await saveFileUsingFilePicker(filename, data, contentType);
} else {
saveFileUsingDataUri(filename, data, contentType);
}
}
/**
* Notes if the browser supports the File System Access API.
*
* @returns {boolean}
*/
function supportsShowSaveFilePicker() {
return (
typeof window !== 'undefined' &&
typeof window.showSaveFilePicker !== 'undefined' &&
typeof window.Blob !== 'undefined'
);
}
/**
* Saves a file using the File System Access API.
*
* @param {string} filename - The name of the file to export.
* @param {string} data - The data to export.
* @param {ExportableContentType} contentType - The content type of the file to export.
* @returns {Promise<void>}
*/
async function saveFileUsingFilePicker(filename, data, contentType) {
const blob = new window.Blob([data], { contentType });
const fileExtension = ExtensionForContentType[contentType];
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: filename,
accept: {
[contentType]: [fileExtension],
},
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
/**
* Saves a file using a data URI.
* This is a fallback for browsers that do not support the File System Access API.
* This method is less preferred because it requires the entire file to be encoded in a data URI.
*
* @param {string} filename - The name of the file to export.
* @param {string} data - The data to export.
* @param {ExportableContentType} contentType - The content type of the file to export.
*/
function saveFileUsingDataUri(filename, data, contentType) {
const b64 = Buffer.from(data, 'utf8').toString('base64');
// eslint-disable-next-line no-param-reassign
filename = filename || getRandomFileName();
const elem = window.document.createElement('a');
elem.href = `data:${type};Base64,${b64}`;
const elem = document.createElement('a');
elem.href = `data:${contentType};Base64,${b64}`;
elem.download = filename;
document.body.appendChild(elem);
elem.click();

View File

@ -0,0 +1,68 @@
import { exportAsFile, ExportableContentType } from './export-utils';
describe('exportAsFile', () => {
let windowSpy;
beforeEach(() => {
windowSpy = jest.spyOn(window, 'window', 'get');
});
afterEach(() => {
windowSpy.mockRestore();
});
describe('when showSaveFilePicker is supported', () => {
it('uses .json file extension when content type is JSON', async () => {
const showSaveFilePicker = mockShowSaveFilePicker();
const filename = 'test.json';
const data = '{file: "content"}';
windowSpy.mockImplementation(() => ({
showSaveFilePicker,
Blob: global.Blob,
}));
await exportAsFile(filename, data, ExportableContentType.JSON);
expect(showSaveFilePicker).toHaveBeenCalledWith({
suggestedName: filename,
types: [
{
description: filename,
accept: { 'application/json': ['.json'] },
},
],
});
});
it('uses .txt file extension when content type is TXT', async () => {
const showSaveFilePicker = mockShowSaveFilePicker();
const filename = 'test.txt';
const data = 'file content';
windowSpy.mockImplementation(() => ({
showSaveFilePicker,
Blob: global.Blob,
}));
await exportAsFile(filename, data, ExportableContentType.TXT);
expect(showSaveFilePicker).toHaveBeenCalledWith({
suggestedName: filename,
types: [
{
description: filename,
accept: { 'text/plain': ['.txt'] },
},
],
});
});
});
});
function mockShowSaveFilePicker() {
return jest.fn().mockResolvedValueOnce({
createWritable: jest
.fn()
.mockResolvedValueOnce({ write: jest.fn(), close: jest.fn() }),
});
}

View File

@ -23,7 +23,10 @@ import {
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../../shared/constants/preferences';
import { exportAsFile } from '../../../helpers/utils/export-utils';
import {
exportAsFile,
ExportableContentType,
} from '../../../helpers/utils/export-utils';
import ActionableMessage from '../../../components/ui/actionable-message';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { BannerAlert } from '../../../components/component-library';
@ -150,7 +153,7 @@ export default class AdvancedTab extends PureComponent {
backupUserData = async () => {
const { fileName, data } = await this.props.backupUserData();
exportAsFile(fileName, data);
exportAsFile(fileName, data, ExportableContentType.JSON);
this.context.trackEvent({
event: 'User Data Exported',
@ -185,7 +188,11 @@ export default class AdvancedTab extends PureComponent {
if (err) {
displayWarning(t('stateLogError'));
} else {
exportAsFile(`${t('stateLogFileName')}.json`, result);
exportAsFile(
`${t('stateLogFileName')}.json`,
result,
ExportableContentType.JSON,
);
}
});
}}

View File

@ -48,6 +48,7 @@ import {
getIsFeatureFlagLoaded,
getCurrentSmartTransactionsError,
getSmartTransactionFees,
getLatestAddedTokenTo,
} from '../../../ducks/swaps/swaps';
import {
getSwapsDefaultToken,
@ -84,6 +85,7 @@ import {
import {
resetSwapsPostFetchState,
ignoreTokens,
setBackgroundSwapRouteState,
clearSwapsQuotes,
stopPollingForQuotes,
@ -144,6 +146,7 @@ export default function BuildQuote({
const tokenList = useSelector(getTokenList, isEqual);
const quotes = useSelector(getQuotes, isEqual);
const areQuotesPresent = Object.keys(quotes).length > 0;
const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual);
const conversionRate = useSelector(getConversionRate);
@ -347,12 +350,21 @@ export default function BuildQuote({
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
const { address: toAddress } = toToken || {};
const onToSelect = useCallback(
(token) => {
if (latestAddedTokenTo && token.address !== toAddress) {
dispatch(
ignoreTokens({
tokensToIgnore: toAddress,
dontShowLoadingIndicator: true,
}),
);
}
dispatch(setSwapToToken(token));
setVerificationClicked(false);
},
[dispatch],
[dispatch, latestAddedTokenTo, toAddress],
);
const hideDropdownItemIf = useCallback(

View File

@ -29,6 +29,7 @@ const createProps = (customProps = {}) => {
setBackgroundConnection({
resetPostFetchState: jest.fn(),
ignoreTokens: jest.fn(),
setBackgroundSwapRouteState: jest.fn(),
clearSwapsQuotes: jest.fn(),
stopPollingForQuotes: jest.fn(),

View File

@ -50,6 +50,7 @@ import {
navigateBackToBuildQuote,
getSwapRedesignEnabled,
setTransactionSettingsOpened,
getLatestAddedTokenTo,
} from '../../ducks/swaps/swaps';
import {
checkNetworkAndAccountSupports1559,
@ -79,6 +80,7 @@ import {
import {
resetBackgroundSwapsState,
setSwapsTokens,
ignoreTokens,
setBackgroundSwapRouteState,
setSwapsErrorKey,
} from '../../store/actions';
@ -134,6 +136,7 @@ export default function Swap() {
const routeState = useSelector(getBackgroundSwapRouteState);
const selectedAccount = useSelector(getSelectedAccount, shallowEqual);
const quotes = useSelector(getQuotes, isEqual);
const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const txList = useSelector(currentNetworkTxListSelector, shallowEqual);
const tradeTxId = useSelector(getTradeTxId);
const approveTxId = useSelector(getApproveTxId);
@ -209,6 +212,32 @@ export default function Swap() {
swapsErrorKey = SWAP_FAILED_ERROR;
}
const clearTemporaryTokenRef = useRef();
useEffect(() => {
clearTemporaryTokenRef.current = () => {
if (latestAddedTokenTo && (!isAwaitingSwapRoute || conversionError)) {
dispatch(
ignoreTokens({
tokensToIgnore: latestAddedTokenTo,
dontShowLoadingIndicator: true,
}),
);
}
};
}, [
conversionError,
dispatch,
latestAddedTokenTo,
destinationTokenInfo,
fetchParams,
isAwaitingSwapRoute,
]);
useEffect(() => {
return () => {
clearTemporaryTokenRef.current();
};
}, []);
// eslint-disable-next-line
useEffect(() => {
if (!isSwapsChain) {
@ -283,6 +312,7 @@ export default function Swap() {
const beforeUnloadEventAddedRef = useRef();
useEffect(() => {
const fn = () => {
clearTemporaryTokenRef.current();
if (isLoadingQuotesRoute) {
dispatch(prepareToLeaveSwaps());
}
@ -349,6 +379,7 @@ export default function Swap() {
}
const redirectToDefaultRoute = async () => {
clearTemporaryTokenRef.current();
dispatch(clearSwapsState());
await dispatch(resetBackgroundSwapsState());
history.push(DEFAULT_ROUTE);
@ -400,6 +431,7 @@ export default function Swap() {
<div
className="swaps__header-cancel"
onClick={async () => {
clearTemporaryTokenRef.current();
dispatch(clearSwapsState());
await dispatch(resetBackgroundSwapsState());
history.push(DEFAULT_ROUTE);

View File

@ -54,6 +54,7 @@ import {
getAggregatorMetadata,
getTransactionSettingsOpened,
setTransactionSettingsOpened,
getLatestAddedTokenTo,
} from '../../../ducks/swaps/swaps';
import {
getSwapsDefaultToken,
@ -92,6 +93,7 @@ import {
} from '../../../../shared/constants/swaps';
import {
resetSwapsPostFetchState,
ignoreTokens,
clearSwapsQuotes,
stopPollingForQuotes,
setSmartTransactionsOptInStatus,
@ -182,6 +184,7 @@ export default function PrepareSwapPage({
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider, shallowEqual);
const tokenList = useSelector(getTokenList, isEqual);
const quotes = useSelector(getQuotes, isEqual);
const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const numberOfQuotes = Object.keys(quotes).length;
const areQuotesPresent = numberOfQuotes > 0;
const swapsErrorKey = useSelector(getSwapsErrorKey);
@ -449,12 +452,21 @@ export default function PrepareSwapPage({
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
const { address: toAddress } = toToken || {};
const onToSelect = useCallback(
(token) => {
if (latestAddedTokenTo && token.address !== toAddress) {
dispatch(
ignoreTokens({
tokensToIgnore: toAddress,
dontShowLoadingIndicator: true,
}),
);
}
dispatch(setSwapToToken(token));
setVerificationClicked(false);
},
[dispatch],
[dispatch, latestAddedTokenTo, toAddress],
);
const tokensWithBalancesFromToken = tokensWithBalances.find((token) =>

View File

@ -27,6 +27,7 @@ const createProps = (customProps = {}) => {
setBackgroundConnection({
resetPostFetchState: jest.fn(),
ignoreTokens: jest.fn(),
setBackgroundSwapRouteState: jest.fn(),
clearSwapsQuotes: jest.fn(),
stopPollingForQuotes: jest.fn(),

View File

@ -1008,6 +1008,7 @@ function getAllowedAnnouncementIds(state) {
19: false,
20: currentKeyringIsLedger && isFirefox,
21: isSwapsChain,
22: true,
};
}

View File

@ -3960,13 +3960,13 @@ __metadata:
languageName: node
linkType: hard
"@metamask/base-controller@npm:^3.0.0, @metamask/base-controller@npm:^3.1.0":
version: 3.1.0
resolution: "@metamask/base-controller@npm:3.1.0"
"@metamask/base-controller@npm:^3.0.0, @metamask/base-controller@npm:^3.1.0, @metamask/base-controller@npm:^3.2.0":
version: 3.2.0
resolution: "@metamask/base-controller@npm:3.2.0"
dependencies:
"@metamask/utils": ^5.0.2
"@metamask/utils": ^6.2.0
immer: ^9.0.6
checksum: fc1597a099e6d28bd089df936ca349d6c38c2e1b0f0737385cba30c34a5239241519eb172d77c70f8db2604f4dc5724f6893affe42bdd104cef98f9cfd6f1db8
checksum: 3be6f2594309c013e07f83c4bb8271e1e99f02b6ff829c18b5e7218fbab4e6a9e03bcb49056704ce47f84ae2f38b1bc1c10284ec538aad56ed7b554ef2d3e189
languageName: node
linkType: hard
@ -4027,20 +4027,19 @@ __metadata:
languageName: node
linkType: hard
"@metamask/controller-utils@npm:^4.0.0, @metamask/controller-utils@npm:^4.0.1, @metamask/controller-utils@npm:^4.1.0, @metamask/controller-utils@npm:^4.2.0":
version: 4.2.0
resolution: "@metamask/controller-utils@npm:4.2.0"
"@metamask/controller-utils@npm:^4.0.0, @metamask/controller-utils@npm:^4.0.1, @metamask/controller-utils@npm:^4.1.0, @metamask/controller-utils@npm:^4.2.0, @metamask/controller-utils@npm:^4.3.0":
version: 4.3.1
resolution: "@metamask/controller-utils@npm:4.3.1"
dependencies:
"@metamask/utils": ^5.0.2
"@metamask/eth-query": ^3.0.1
"@metamask/utils": ^6.2.0
"@spruceid/siwe-parser": 1.1.3
babel-runtime: ^6.26.0
eth-ens-namehash: ^2.0.8
eth-query: ^2.1.2
eth-rpc-errors: ^4.0.2
ethereumjs-util: ^7.0.10
ethjs-unit: ^0.1.6
fast-deep-equal: ^3.1.3
checksum: e71779577c37038e6e605a43ef6b9c1af82e0b3887a72c01f48ae1e4e2005116fc9d09c8b690139478c04dd2929e227642c5fd80cfbc81814d667c415c714228
checksum: 5bb471df560a12fba1b7fa147fe0332e06b527637c04facff1774b1279dd388b4cf1d74340469adb13551c08cc156f204d90e36599ad69b54716b11e5842b348
languageName: node
linkType: hard
@ -4206,6 +4205,16 @@ __metadata:
languageName: node
linkType: hard
"@metamask/eth-query@npm:^3.0.1":
version: 3.0.1
resolution: "@metamask/eth-query@npm:3.0.1"
dependencies:
json-rpc-random-id: ^1.0.0
xtend: ^4.0.1
checksum: b9a323dff67328eace7d54fc8b0bc4dd763bf15760870656cbd5aad5380d1ee4489fb5c59506290d5f77cf55e74e530ee97b52702a329f1090ec03a6158434b7
languageName: node
linkType: hard
"@metamask/eth-sig-util@npm:5.0.2":
version: 5.0.2
resolution: "@metamask/eth-sig-util@npm:5.0.2"
@ -4531,16 +4540,16 @@ __metadata:
languageName: node
linkType: hard
"@metamask/phishing-controller@npm:^3.0.0":
version: 3.0.0
resolution: "@metamask/phishing-controller@npm:3.0.0"
"@metamask/phishing-controller@npm:^6.0.0":
version: 6.0.0
resolution: "@metamask/phishing-controller@npm:6.0.0"
dependencies:
"@metamask/base-controller": ^2.0.0
"@metamask/controller-utils": ^3.0.0
"@metamask/base-controller": ^3.2.0
"@metamask/controller-utils": ^4.3.0
"@types/punycode": ^2.1.0
eth-phishing-detect: ^1.2.0
punycode: ^2.1.1
checksum: b0b9a86cba1928f0fd22a2aed196d75dc19a5e56547efe1b533d7ae06eaaf9432a6ee5004a8fd477f52310b50c2f3635a1e70ac83e3670f4cc6a1f488a674d73
checksum: 13a85865cef1515f6d0ee1cd02da37e5e6b98c493676e3a80195294725b717aa17651a0c24d2e841f790bbd22ae16911cc16bab7846da8266f4ee03007a17f4e
languageName: node
linkType: hard
@ -5144,16 +5153,17 @@ __metadata:
languageName: node
linkType: hard
"@metamask/utils@npm:^6.0.0, @metamask/utils@npm:^6.0.1, @metamask/utils@npm:^6.1.0":
version: 6.1.0
resolution: "@metamask/utils@npm:6.1.0"
"@metamask/utils@npm:^6.0.0, @metamask/utils@npm:^6.0.1, @metamask/utils@npm:^6.1.0, @metamask/utils@npm:^6.2.0":
version: 6.2.0
resolution: "@metamask/utils@npm:6.2.0"
dependencies:
"@ethereumjs/tx": ^4.1.2
"@noble/hashes": ^1.3.1
"@types/debug": ^4.1.7
debug: ^4.3.4
semver: ^7.3.8
superstruct: ^1.0.3
checksum: d4eac3ce3c08674b8e9ef838d1661a5025690c6f266c26ebdb8e8d0da11fce786e54c326b5d9c6d33b262f37e7057e31d6545a3715613bd0a5bfa10e7755643a
checksum: 0bc675358ecc09b3bc04da613d73666295d7afa51ff6b8554801585966900b24b8545bd93b8b2e9a17db867ebe421fe884baf3558ec4ca3199fa65504f677c1b
languageName: node
linkType: hard
@ -5735,18 +5745,18 @@ __metadata:
languageName: node
linkType: hard
"@sentry/cli@npm:^1.58.0":
version: 1.58.0
resolution: "@sentry/cli@npm:1.58.0"
"@sentry/cli@npm:^2.19.4":
version: 2.19.4
resolution: "@sentry/cli@npm:2.19.4"
dependencies:
https-proxy-agent: ^5.0.0
mkdirp: ^0.5.5
node-fetch: ^2.6.0
node-fetch: ^2.6.7
progress: ^2.0.3
proxy-from-env: ^1.1.0
which: ^2.0.2
bin:
sentry-cli: bin/sentry-cli
checksum: fc781bbffcf5cd970bb023168421ad89bca4184c2ddfbfddde92f4f5333c8b9075e9e16a8a4b192ecc3b197ac97062715e7b350c306ccc538fc01b955b06c3bb
checksum: 1f2442857a5eec2bc6f872a633d88fc2f11ed7f434db36627a034d904390f4cbbb4dccc33c571a8815e423cd36b863c72621298d49a1541b28370c7f7308f0dc
languageName: node
linkType: hard
@ -24649,7 +24659,7 @@ __metadata:
"@metamask/approval-controller": ^3.4.0
"@metamask/assets-controllers": ^9.2.0
"@metamask/auto-changelog": ^2.1.0
"@metamask/base-controller": ^3.1.0
"@metamask/base-controller": ^3.2.0
"@metamask/browser-passworder": ^4.1.0
"@metamask/contract-metadata": ^2.3.1
"@metamask/controller-utils": ^4.2.0
@ -24678,7 +24688,7 @@ __metadata:
"@metamask/notification-controller": ^3.0.0
"@metamask/obs-store": ^8.1.0
"@metamask/permission-controller": ^4.0.0
"@metamask/phishing-controller": ^3.0.0
"@metamask/phishing-controller": ^6.0.0
"@metamask/phishing-warning": ^2.1.0
"@metamask/post-message-stream": ^6.0.0
"@metamask/ppom-validator": ^0.0.1
@ -24705,7 +24715,7 @@ __metadata:
"@reduxjs/toolkit": ^1.6.2
"@segment/loosely-validate-event": ^2.0.0
"@sentry/browser": ^7.53.0
"@sentry/cli": ^1.58.0
"@sentry/cli": ^2.19.4
"@sentry/integrations": ^7.53.0
"@sentry/types": ^7.53.0
"@sentry/utils": ^7.53.0
@ -26294,8 +26304,8 @@ __metadata:
linkType: hard
"node-fetch@npm:^2, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.11, node-fetch@npm:^2.6.7, node-fetch@npm:~2.6.1":
version: 2.6.11
resolution: "node-fetch@npm:2.6.11"
version: 2.6.12
resolution: "node-fetch@npm:2.6.12"
dependencies:
whatwg-url: ^5.0.0
peerDependencies:
@ -26303,7 +26313,7 @@ __metadata:
peerDependenciesMeta:
encoding:
optional: true
checksum: 249d0666a9497553384d46b5ab296ba223521ac88fed4d8a17d6ee6c2efb0fc890f3e8091cafe7f9fba8151a5b8d925db2671543b3409a56c3cd522b468b47b3
checksum: 3bc1655203d47ee8e313c0d96664b9673a3d4dd8002740318e9d27d14ef306693a4b2ef8d6525775056fd912a19e23f3ac0d7111ad8925877b7567b29a625592
languageName: node
linkType: hard