diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 67548988d..539978bca 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -8,6 +8,7 @@ import log from 'loglevel'; import { LISTED_CONTRACT_ADDRESSES } from '../../../shared/constants/tokens'; import { NETWORK_TYPE_TO_ID_MAP } from '../../../shared/constants/network'; import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils'; +import { NETWORK_EVENTS } from './network'; export default class PreferencesController { /** @@ -72,7 +73,7 @@ export default class PreferencesController { this.store.setMaxListeners(12); this.openPopup = opts.openPopup; this.migrateAddressBookState = opts.migrateAddressBookState; - this._subscribeProviderType(); + this._subscribeToNetworkDidChange(); global.setPreference = (key, value) => { return this.setFeatureFlag(key, value); @@ -667,12 +668,11 @@ export default class PreferencesController { // /** - * Subscription to network provider type. - * - * + * Handle updating token list to reflect current network by listening for the + * NETWORK_DID_CHANGE event. */ - _subscribeProviderType() { - this.network.providerStore.subscribe(() => { + _subscribeToNetworkDidChange() { + this.network.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { const { tokens, hiddenTokens } = this._getTokenRelatedStates(); this._updateAccountTokens(tokens, this.getAssetImages(), hiddenTokens); }); @@ -689,12 +689,12 @@ export default class PreferencesController { _updateAccountTokens(tokens, assetImages, hiddenTokens) { const { accountTokens, - providerType, + chainId, selectedAddress, accountHiddenTokens, } = this._getTokenRelatedStates(); - accountTokens[selectedAddress][providerType] = tokens; - accountHiddenTokens[selectedAddress][providerType] = hiddenTokens; + accountTokens[selectedAddress][chainId] = tokens; + accountHiddenTokens[selectedAddress][chainId] = hiddenTokens; this.store.updateState({ accountTokens, tokens, @@ -730,27 +730,27 @@ export default class PreferencesController { // eslint-disable-next-line no-param-reassign selectedAddress = this.store.getState().selectedAddress; } - const providerType = this.network.providerStore.getState().type; + const chainId = this.network.getCurrentChainId(); if (!(selectedAddress in accountTokens)) { accountTokens[selectedAddress] = {}; } if (!(selectedAddress in accountHiddenTokens)) { accountHiddenTokens[selectedAddress] = {}; } - if (!(providerType in accountTokens[selectedAddress])) { - accountTokens[selectedAddress][providerType] = []; + if (!(chainId in accountTokens[selectedAddress])) { + accountTokens[selectedAddress][chainId] = []; } - if (!(providerType in accountHiddenTokens[selectedAddress])) { - accountHiddenTokens[selectedAddress][providerType] = []; + if (!(chainId in accountHiddenTokens[selectedAddress])) { + accountHiddenTokens[selectedAddress][chainId] = []; } - const tokens = accountTokens[selectedAddress][providerType]; - const hiddenTokens = accountHiddenTokens[selectedAddress][providerType]; + const tokens = accountTokens[selectedAddress][chainId]; + const hiddenTokens = accountHiddenTokens[selectedAddress][chainId]; return { tokens, accountTokens, hiddenTokens, accountHiddenTokens, - providerType, + chainId, selectedAddress, }; } diff --git a/app/scripts/migrations/052.js b/app/scripts/migrations/052.js new file mode 100644 index 000000000..4b5de7d22 --- /dev/null +++ b/app/scripts/migrations/052.js @@ -0,0 +1,125 @@ +import { cloneDeep } from 'lodash'; +import { + GOERLI, + GOERLI_CHAIN_ID, + KOVAN, + KOVAN_CHAIN_ID, + MAINNET, + MAINNET_CHAIN_ID, + NETWORK_TYPE_RPC, + RINKEBY, + RINKEBY_CHAIN_ID, + ROPSTEN, + ROPSTEN_CHAIN_ID, +} from '../../../shared/constants/network'; + +const version = 52; + +/** + * Migrate tokens in Preferences to be keyed by chainId instead of + * providerType. To prevent breaking user's MetaMask and selected + * tokens, this migration copies the RPC entry into *every* custom RPC + * chainId. + */ +export default { + version, + async migrate(originalVersionedData) { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + const state = versionedData.data; + versionedData.data = transformState(state); + return versionedData; + }, +}; + +function transformState(state = {}) { + if (state.PreferencesController) { + const { + accountTokens, + accountHiddenTokens, + frequentRpcListDetail, + } = state.PreferencesController; + + const newAccountTokens = {}; + const newAccountHiddenTokens = {}; + + if (accountTokens && Object.keys(accountTokens).length > 0) { + for (const address of Object.keys(accountTokens)) { + newAccountTokens[address] = {}; + if (accountTokens[address][NETWORK_TYPE_RPC]) { + frequentRpcListDetail.forEach((detail) => { + newAccountTokens[address][detail.chainId] = + accountTokens[address][NETWORK_TYPE_RPC]; + }); + } + for (const providerType of Object.keys(accountTokens[address])) { + switch (providerType) { + case MAINNET: + newAccountTokens[address][MAINNET_CHAIN_ID] = + accountTokens[address][MAINNET]; + break; + case ROPSTEN: + newAccountTokens[address][ROPSTEN_CHAIN_ID] = + accountTokens[address][ROPSTEN]; + break; + case RINKEBY: + newAccountTokens[address][RINKEBY_CHAIN_ID] = + accountTokens[address][RINKEBY]; + break; + case GOERLI: + newAccountTokens[address][GOERLI_CHAIN_ID] = + accountTokens[address][GOERLI]; + break; + case KOVAN: + newAccountTokens[address][KOVAN_CHAIN_ID] = + accountTokens[address][KOVAN]; + break; + default: + break; + } + } + } + state.PreferencesController.accountTokens = newAccountTokens; + } + + if (accountHiddenTokens && Object.keys(accountHiddenTokens).length > 0) { + for (const address of Object.keys(accountHiddenTokens)) { + newAccountHiddenTokens[address] = {}; + if (accountHiddenTokens[address][NETWORK_TYPE_RPC]) { + frequentRpcListDetail.forEach((detail) => { + newAccountHiddenTokens[address][detail.chainId] = + accountHiddenTokens[address][NETWORK_TYPE_RPC]; + }); + } + for (const providerType of Object.keys(accountHiddenTokens[address])) { + switch (providerType) { + case MAINNET: + newAccountHiddenTokens[address][MAINNET_CHAIN_ID] = + accountHiddenTokens[address][MAINNET]; + break; + case ROPSTEN: + newAccountHiddenTokens[address][ROPSTEN_CHAIN_ID] = + accountHiddenTokens[address][ROPSTEN]; + break; + case RINKEBY: + newAccountHiddenTokens[address][RINKEBY_CHAIN_ID] = + accountHiddenTokens[address][RINKEBY]; + break; + case GOERLI: + newAccountHiddenTokens[address][GOERLI_CHAIN_ID] = + accountHiddenTokens[address][GOERLI]; + break; + case KOVAN: + newAccountHiddenTokens[address][KOVAN_CHAIN_ID] = + accountHiddenTokens[address][KOVAN]; + break; + default: + break; + } + } + } + state.PreferencesController.accountHiddenTokens = newAccountHiddenTokens; + } + } + return state; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 5fe85ae74..b4368c1b5 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -56,6 +56,7 @@ const migrations = [ require('./049').default, require('./050').default, require('./051').default, + require('./052').default, ]; export default migrations; diff --git a/test/unit/app/controllers/preferences-controller-test.js b/test/unit/app/controllers/preferences-controller-test.js index d2a85d12c..b56a6d65c 100644 --- a/test/unit/app/controllers/preferences-controller-test.js +++ b/test/unit/app/controllers/preferences-controller-test.js @@ -1,19 +1,39 @@ import assert from 'assert'; -import { ObservableStore } from '@metamask/obs-store'; import sinon from 'sinon'; import PreferencesController from '../../../../app/scripts/controllers/preferences'; +import { + MAINNET_CHAIN_ID, + RINKEBY_CHAIN_ID, +} from '../../../../shared/constants/network'; describe('preferences controller', function () { let preferencesController; let network; + let currentChainId; + let triggerNetworkChange; + let switchToMainnet; + let switchToRinkeby; const migrateAddressBookState = sinon.stub(); beforeEach(function () { - network = { providerStore: new ObservableStore({ type: 'mainnet' }) }; + currentChainId = MAINNET_CHAIN_ID; + network = { + getCurrentChainId: () => currentChainId, + on: sinon.spy(), + }; preferencesController = new PreferencesController({ migrateAddressBookState, network, }); + triggerNetworkChange = network.on.firstCall.args[1]; + switchToMainnet = () => { + currentChainId = MAINNET_CHAIN_ID; + triggerNetworkChange(); + }; + switchToRinkeby = () => { + currentChainId = RINKEBY_CHAIN_ID; + triggerNetworkChange(); + }; }); afterEach(function () { @@ -230,12 +250,10 @@ describe('preferences controller', function () { const symbolFirst = 'ABBR'; const symbolSecond = 'ABBB'; const decimals = 5; - - network.providerStore.updateState({ type: 'mainnet' }); await preferencesController.addToken(addressFirst, symbolFirst, decimals); const tokensFirstAddress = preferencesController.getTokens(); - network.providerStore.updateState({ type: 'rinkeby' }); + switchToRinkeby(); await preferencesController.addToken( addressSecond, symbolSecond, @@ -304,14 +322,13 @@ describe('preferences controller', function () { }); it('should remove a token from its state on corresponding network', async function () { - network.providerStore.updateState({ type: 'mainnet' }); await preferencesController.addToken('0xa', 'A', 4); await preferencesController.addToken('0xb', 'B', 5); - network.providerStore.updateState({ type: 'rinkeby' }); + switchToRinkeby(); await preferencesController.addToken('0xa', 'A', 4); await preferencesController.addToken('0xb', 'B', 5); const initialTokensSecond = preferencesController.getTokens(); - network.providerStore.updateState({ type: 'mainnet' }); + switchToMainnet(); await preferencesController.removeToken('0xa'); const tokensFirst = preferencesController.getTokens(); @@ -320,7 +337,7 @@ describe('preferences controller', function () { const [token1] = tokensFirst; assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); - network.providerStore.updateState({ type: 'rinkeby' }); + switchToRinkeby(); const tokensSecond = preferencesController.getTokens(); assert.deepEqual( tokensSecond, @@ -371,11 +388,10 @@ describe('preferences controller', function () { describe('on updateStateNetworkType', function () { it('should remove a token from its state on corresponding network', async function () { - network.providerStore.updateState({ type: 'mainnet' }); await preferencesController.addToken('0xa', 'A', 4); await preferencesController.addToken('0xb', 'B', 5); const initialTokensFirst = preferencesController.getTokens(); - network.providerStore.updateState({ type: 'rinkeby' }); + switchToRinkeby(); await preferencesController.addToken('0xa', 'C', 4); await preferencesController.addToken('0xb', 'D', 5); const initialTokensSecond = preferencesController.getTokens(); @@ -386,9 +402,9 @@ describe('preferences controller', function () { 'tokens not equal for different networks and tokens', ); - network.providerStore.updateState({ type: 'mainnet' }); + switchToMainnet(); const tokensFirst = preferencesController.getTokens(); - network.providerStore.updateState({ type: 'rinkeby' }); + switchToRinkeby(); const tokensSecond = preferencesController.getTokens(); assert.deepEqual( tokensFirst, diff --git a/test/unit/migrations/052-test.js b/test/unit/migrations/052-test.js new file mode 100644 index 000000000..23dc7c603 --- /dev/null +++ b/test/unit/migrations/052-test.js @@ -0,0 +1,424 @@ +import assert from 'assert'; +import migration52 from '../../../app/scripts/migrations/052'; +import { + GOERLI, + GOERLI_CHAIN_ID, + KOVAN, + KOVAN_CHAIN_ID, + MAINNET, + MAINNET_CHAIN_ID, + NETWORK_TYPE_RPC, + RINKEBY, + RINKEBY_CHAIN_ID, + ROPSTEN, + ROPSTEN_CHAIN_ID, +} from '../../../shared/constants/network'; + +const TOKEN1 = { symbol: 'TST', address: '0x10', decimals: 18 }; +const TOKEN2 = { symbol: 'TXT', address: '0x11', decimals: 18 }; +const TOKEN3 = { symbol: 'TVT', address: '0x12', decimals: 18 }; +const TOKEN4 = { symbol: 'TAT', address: '0x13', decimals: 18 }; + +describe('migration #52', function () { + it('should update the version metadata', async function () { + const oldStorage = { + meta: { + version: 52, + }, + data: {}, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.meta, { + version: 52, + }); + }); + + it(`should move ${MAINNET} tokens and hidden tokens to be keyed by ${MAINNET_CHAIN_ID} for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [MAINNET]: [TOKEN1], + }, + '0x1112': { + [MAINNET]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [MAINNET]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [MAINNET]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [MAINNET_CHAIN_ID]: [TOKEN1], + }, + '0x1112': { + [MAINNET_CHAIN_ID]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [MAINNET_CHAIN_ID]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [MAINNET_CHAIN_ID]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should move ${RINKEBY} tokens and hidden tokens to be keyed by ${RINKEBY_CHAIN_ID} for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [RINKEBY]: [TOKEN1], + }, + '0x1112': { + [RINKEBY]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [RINKEBY]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [RINKEBY]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [RINKEBY_CHAIN_ID]: [TOKEN1], + }, + '0x1112': { + [RINKEBY_CHAIN_ID]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [RINKEBY_CHAIN_ID]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [RINKEBY_CHAIN_ID]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should move ${KOVAN} tokens and hidden tokens to be keyed by ${KOVAN_CHAIN_ID} for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [KOVAN]: [TOKEN1], + }, + '0x1112': { + [KOVAN]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [KOVAN]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [KOVAN]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [KOVAN_CHAIN_ID]: [TOKEN1], + }, + '0x1112': { + [KOVAN_CHAIN_ID]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [KOVAN_CHAIN_ID]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [KOVAN_CHAIN_ID]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should move ${GOERLI} tokens and hidden tokens to be keyed by ${GOERLI_CHAIN_ID} for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [GOERLI]: [TOKEN1], + }, + '0x1112': { + [GOERLI]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [GOERLI]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [GOERLI]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [GOERLI_CHAIN_ID]: [TOKEN1], + }, + '0x1112': { + [GOERLI_CHAIN_ID]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [GOERLI_CHAIN_ID]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [GOERLI_CHAIN_ID]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should move ${ROPSTEN} tokens and hidden tokens to be keyed by ${ROPSTEN_CHAIN_ID} for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [ROPSTEN]: [TOKEN1], + }, + '0x1112': { + [ROPSTEN]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [ROPSTEN]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [ROPSTEN]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + accountHiddenTokens: { + '0x1111': { + [ROPSTEN_CHAIN_ID]: [TOKEN1], + }, + '0x1112': { + [ROPSTEN_CHAIN_ID]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [ROPSTEN_CHAIN_ID]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [ROPSTEN_CHAIN_ID]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should duplicate ${NETWORK_TYPE_RPC} tokens and hidden tokens to all custom networks for each address`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + frequentRpcListDetail: [ + { chainId: '0xab' }, + { chainId: '0x12' }, + { chainId: '0xfa' }, + ], + accountHiddenTokens: { + '0x1111': { + [NETWORK_TYPE_RPC]: [TOKEN1], + }, + '0x1112': { + [NETWORK_TYPE_RPC]: [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + [NETWORK_TYPE_RPC]: [TOKEN1, TOKEN2], + }, + '0x1112': { + [NETWORK_TYPE_RPC]: [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + frequentRpcListDetail: [ + { chainId: '0xab' }, + { chainId: '0x12' }, + { chainId: '0xfa' }, + ], + accountHiddenTokens: { + '0x1111': { + '0xab': [TOKEN1], + '0x12': [TOKEN1], + '0xfa': [TOKEN1], + }, + '0x1112': { + '0xab': [TOKEN3], + '0x12': [TOKEN3], + '0xfa': [TOKEN3], + }, + }, + accountTokens: { + '0x1111': { + '0xab': [TOKEN1, TOKEN2], + '0x12': [TOKEN1, TOKEN2], + '0xfa': [TOKEN1, TOKEN2], + }, + '0x1112': { + '0xab': [TOKEN1, TOKEN3], + '0x12': [TOKEN1, TOKEN3], + '0xfa': [TOKEN1, TOKEN3], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it(`should overwrite ${NETWORK_TYPE_RPC} tokens with built in networks if chainIds match`, async function () { + const oldStorage = { + meta: {}, + data: { + PreferencesController: { + frequentRpcListDetail: [{ chainId: '0x1' }], + accountHiddenTokens: { + '0x1111': { + [NETWORK_TYPE_RPC]: [TOKEN3], + [MAINNET]: [TOKEN1], + }, + }, + accountTokens: { + '0x1111': { + [NETWORK_TYPE_RPC]: [TOKEN1, TOKEN2], + [MAINNET]: [TOKEN3, TOKEN4], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + PreferencesController: { + frequentRpcListDetail: [{ chainId: '0x1' }], + accountHiddenTokens: { + '0x1111': { + '0x1': [TOKEN1], + }, + }, + accountTokens: { + '0x1111': { + '0x1': [TOKEN3, TOKEN4], + }, + }, + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it('should do nothing if no PreferencesController key', async function () { + const oldStorage = { + meta: {}, + data: { + foo: 'bar', + }, + }; + + const newStorage = await migration52.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + foo: 'bar', + }); + }); +});