diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index d0e3fd8588f9..470652fc8f9a 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1,4 +1,10 @@ { + "migrateSai": { + "message": "A message from Maker: The new Multi-Collateral Dai token has been released. Your old tokens are now called Sai. Please upgrade your Sai tokens to the new Dai." + }, + "migrate": { + "message": "Migrate" + }, "showIncomingTransactions": { "message": "Show Incoming Transactions" }, diff --git a/app/scripts/migrations/039.js b/app/scripts/migrations/039.js new file mode 100644 index 000000000000..60c013c58950 --- /dev/null +++ b/app/scripts/migrations/039.js @@ -0,0 +1,67 @@ +const version = 39 +const clone = require('clone') +const ethUtil = require('ethereumjs-util') + +const DAI_V1_CONTRACT_ADDRESS = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359' +const DAI_V1_TOKEN_SYMBOL = 'DAI' +const SAI_TOKEN_SYMBOL = 'SAI' + +function isOldDai (token = {}) { + return token && typeof token === 'object' && + token.symbol === DAI_V1_TOKEN_SYMBOL && + ethUtil.toChecksumAddress(token.address) === DAI_V1_CONTRACT_ADDRESS +} + +/** + * This migration renames the Dai token to Sai. + * + * As of 2019-11-18 Dai is now called Sai (refs https://git.io/JeooP) to facilitate + * Maker's upgrade to Multi-Collateral Dai and this migration renames the token + * at the old address. + */ +module.exports = { + version, + migrate: async function (originalVersionedData) { + const versionedData = clone(originalVersionedData) + versionedData.meta.version = version + const state = versionedData.data + versionedData.data = transformState(state) + return versionedData + }, +} + +function transformState (state) { + const { PreferencesController } = state + + if (PreferencesController) { + const tokens = PreferencesController.tokens || [] + if (Array.isArray(tokens)) { + for (const token of tokens) { + if (isOldDai(token)) { + token.symbol = SAI_TOKEN_SYMBOL + } + } + } + + const accountTokens = PreferencesController.accountTokens || {} + if (accountTokens && typeof accountTokens === 'object') { + for (const address of Object.keys(accountTokens)) { + const networkTokens = accountTokens[address] + if (networkTokens && typeof networkTokens === 'object') { + for (const network of Object.keys(networkTokens)) { + const tokensOnNetwork = networkTokens[network] + if (Array.isArray(tokensOnNetwork)) { + for (const token of tokensOnNetwork) { + if (isOldDai(token)) { + token.symbol = SAI_TOKEN_SYMBOL + } + } + } + } + } + } + } + } + + return state +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 9fb24fe70aee..c2a9d8fe7f84 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -49,4 +49,5 @@ module.exports = [ require('./036'), require('./037'), require('./038'), + require('./039'), ] diff --git a/package.json b/package.json index fad7c4226bef..047911146e87 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "dnode": "^1.2.2", "end-of-stream": "^1.1.0", "eth-block-tracker": "^4.4.2", - "eth-contract-metadata": "1.9.3", + "eth-contract-metadata": "^1.11.0", "eth-ens-namehash": "^2.0.8", "eth-json-rpc-errors": "^1.1.0", "eth-json-rpc-filters": "^4.1.1", @@ -114,7 +114,7 @@ "extensionizer": "^1.0.1", "fast-json-patch": "^2.0.4", "fuse.js": "^3.2.0", - "gaba": "^1.8.0", + "gaba": "^1.9.0", "human-standard-token-abi": "^2.0.0", "jazzicon": "^1.2.0", "json-rpc-engine": "^5.1.5", diff --git a/test/unit/migrations/039-test.js b/test/unit/migrations/039-test.js new file mode 100644 index 000000000000..231d8fdeea3f --- /dev/null +++ b/test/unit/migrations/039-test.js @@ -0,0 +1,419 @@ +const assert = require('assert') +const migration39 = require('../../../app/scripts/migrations/039') + +describe('migration #39', () => { + it('should update the version metadata', (done) => { + const oldStorage = { + 'meta': { + 'version': 38, + }, + 'data': {}, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.meta, { + 'version': 39, + }) + done() + }) + .catch(done) + }) + + it('should update old DAI token symbol to SAI in tokens', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': [{ + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'DAI', + }, { + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'symbol': 'BAT', + 'decimals': 18, + }, { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'symbol': 'META', + 'decimals': 18, + }], + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data.PreferencesController, { + 'tokens': [{ + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'SAI', + }, { + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'symbol': 'BAT', + 'decimals': 18, + }, { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'symbol': 'META', + 'decimals': 18, + }], + }) + done() + }) + .catch(done) + }) + + it('should update old DAI token symbol to SAI in accountTokens', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'accountTokens': { + '0x7250739de134d33ec7ab1ee592711e15098c9d2d': { + 'mainnet': [ + { + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'DAI', + }, + ], + }, + '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5': { + 'mainnet': [], + 'rinkeby': [], + }, + '0x8e5d75d60224ea0c33d1041e75de68b1c3cb6dd5': {}, + '0xb3958fb96c8201486ae20be1d5c9f58083df343a': { + 'mainnet': [ + { + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'DAI', + }, + { + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'decimals': 18, + 'symbol': 'BAT', + }, + { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'decimals': 18, + 'symbol': 'META', + }, + ], + }, + }, + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data.PreferencesController, { + 'accountTokens': { + '0x7250739de134d33ec7ab1ee592711e15098c9d2d': { + 'mainnet': [ + { + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'SAI', + }, + ], + }, + '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5': { + 'mainnet': [], + 'rinkeby': [], + }, + '0x8e5d75d60224ea0c33d1041e75de68b1c3cb6dd5': {}, + '0xb3958fb96c8201486ae20be1d5c9f58083df343a': { + 'mainnet': [ + { + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'SAI', + }, + { + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'decimals': 18, + 'symbol': 'BAT', + }, + { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'decimals': 18, + 'symbol': 'META', + }, + ], + }, + }, + }) + done() + }) + .catch(done) + }) + + it('should NOT change any state if accountTokens is not an object', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'accountTokens': [], + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if accountTokens is an object with invalid values', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'accountTokens': { + '0x7250739de134d33ec7ab1ee592711e15098c9d2d': [ + { + 'address': '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', + 'decimals': 18, + 'symbol': 'DAI', + }, + ], + '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359': null, + '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5': { + 'mainnet': [ + null, + undefined, + [], + 42, + ], + 'rinkeby': null, + }, + }, + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if accountTokens includes the new DAI token', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'accountTokens': { + '0x7250739de134d33ec7ab1ee592711e15098c9d2d': { + 'mainnet': [ + { + 'address': '0x6B175474E89094C44Da98b954EedeAC495271d0F', + 'decimals': 18, + 'symbol': 'DAI', + }, + ], + }, + '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5': { + 'mainnet': [], + 'rinkeby': [], + }, + '0x8e5d75d60224ea0c33d1041e75de68b1c3cb6dd5': {}, + '0xb3958fb96c8201486ae20be1d5c9f58083df343a': { + 'mainnet': [ + { + 'address': '0x6B175474E89094C44Da98b954EedeAC495271d0F', + 'decimals': 18, + 'symbol': 'DAI', + }, + { + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'decimals': 18, + 'symbol': 'BAT', + }, + { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'decimals': 18, + 'symbol': 'META', + }, + ], + }, + }, + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if tokens includes the new DAI token', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': [{ + 'address': '0x6B175474E89094C44Da98b954EedeAC495271d0F', + 'symbol': 'DAI', + 'decimals': 18, + }, { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'symbol': 'META', + 'decimals': 18, + }], + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if tokens does not include DAI', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': [{ + 'address': '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + 'symbol': 'BAT', + 'decimals': 18, + }, { + 'address': '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'symbol': 'META', + 'decimals': 18, + }], + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if a tokens property has invalid entries', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': [ + null, + [], + undefined, + 42, + ], + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if a tokens property is not an array', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': {}, + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if a tokens property is null', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + 'tokens': null, + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if a tokens property is missing', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if a accountTokens property is missing', (done) => { + const oldStorage = { + 'meta': {}, + 'data': { + 'PreferencesController': { + }, + }, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) + + it('should NOT change any state if PreferencesController is missing', (done) => { + const oldStorage = { + 'meta': {}, + 'data': {}, + } + + migration39.migrate(oldStorage) + .then((newStorage) => { + assert.deepEqual(newStorage.data, oldStorage.data) + done() + }) + .catch(done) + }) +}) diff --git a/ui/app/components/app/dai-migration-component/dai-migration-notification.component.js b/ui/app/components/app/dai-migration-component/dai-migration-notification.component.js new file mode 100644 index 000000000000..eebe4296a6ec --- /dev/null +++ b/ui/app/components/app/dai-migration-component/dai-migration-notification.component.js @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import HomeNotification from '../home-notification' + +export default class DaiV1MigrationNotification extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + string: '', + symbol: '', + } + + static propTypes = { + string: PropTypes.string, + symbol: PropTypes.string, + } + + render () { + const { t } = this.context + const { string: balanceString, symbol } = this.props + + if (!balanceString || !symbol) { + return null + } + + if (balanceString === '0') { + return null + } + + return ( + { + window.open('https://migrate.makerdao.com', '_blank', 'noopener') + }} + ignoreText={t('learnMore')} + onIgnore={() => { + window.open('https://blog.makerdao.com/multi-collateral-dai-is-live/', '_blank', 'noopener') + }} + /> + ) + } +} diff --git a/ui/app/components/app/dai-migration-component/dai-migration-notification.container.js b/ui/app/components/app/dai-migration-component/dai-migration-notification.container.js new file mode 100644 index 000000000000..1ea1d2fe41c9 --- /dev/null +++ b/ui/app/components/app/dai-migration-component/dai-migration-notification.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import DaiMigrationNotification from './dai-migration-notification.component' +import withTokenTracker from '../../../helpers/higher-order-components/with-token-tracker' +import { getSelectedAddress, getDaiV1Token } from '../../../selectors/selectors' + +const mapStateToProps = (state) => { + const userAddress = getSelectedAddress(state) + const oldDai = getDaiV1Token(state) + + return { + userAddress, + token: oldDai, + } +} + +export default compose( + connect(mapStateToProps), + withTokenTracker, +)(DaiMigrationNotification) diff --git a/ui/app/components/app/dai-migration-component/index.js b/ui/app/components/app/dai-migration-component/index.js new file mode 100644 index 000000000000..e3c7cec2bab3 --- /dev/null +++ b/ui/app/components/app/dai-migration-component/index.js @@ -0,0 +1 @@ +export { default } from './dai-migration-notification.container' diff --git a/ui/app/pages/home/home.component.js b/ui/app/pages/home/home.component.js index f08a0bb47fda..e51c8217798a 100644 --- a/ui/app/pages/home/home.component.js +++ b/ui/app/pages/home/home.component.js @@ -4,6 +4,7 @@ import Media from 'react-media' import { Redirect } from 'react-router-dom' import { formatDate } from '../../helpers/utils/util' import HomeNotification from '../../components/app/home-notification' +import DaiMigrationNotification from '../../components/app/dai-migration-component' import MultipleNotifications from '../../components/app/multiple-notifications' import WalletView from '../../components/app/wallet-view' import TransactionView from '../../components/app/transaction-view' @@ -23,6 +24,7 @@ export default class Home extends PureComponent { static defaultProps = { unsetMigratedPrivacyMode: null, + hasDaiV1Token: false, } static propTypes = { @@ -43,6 +45,7 @@ export default class Home extends PureComponent { restoreFromThreeBox: PropTypes.func, setShowRestorePromptToFalse: PropTypes.func, threeBoxLastUpdated: PropTypes.number, + hasDaiV1Token: PropTypes.bool, } componentWillMount () { @@ -86,6 +89,7 @@ export default class Home extends PureComponent { forgottenPassword, providerRequests, history, + hasDaiV1Token, showPrivacyModeNotification, unsetMigratedPrivacyMode, shouldShowSeedPhraseReminder, @@ -172,6 +176,11 @@ export default class Home extends PureComponent { /> : null } + { + hasDaiV1Token + ? + : null + } ) diff --git a/ui/app/pages/home/home.container.js b/ui/app/pages/home/home.container.js index 1bac780afeb8..4a2106a55b91 100644 --- a/ui/app/pages/home/home.container.js +++ b/ui/app/pages/home/home.container.js @@ -3,7 +3,7 @@ import { compose } from 'recompose' import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' -import { getCurrentEthBalance } from '../../selectors/selectors' +import { getCurrentEthBalance, getDaiV1Token } from '../../selectors/selectors' import { unsetMigratedPrivacyMode, restoreFromThreeBox, @@ -44,6 +44,7 @@ const mapStateToProps = state => { showRestorePrompt, selectedAddress, threeBoxLastUpdated, + hasDaiV1Token: Boolean(getDaiV1Token(state)), } } diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js index fab5f1dae114..d606b6db8a38 100644 --- a/ui/app/selectors/selectors.js +++ b/ui/app/selectors/selectors.js @@ -49,6 +49,7 @@ const selectors = { getAccountType, getNumberOfAccounts, getNumberOfTokens, + getDaiV1Token, isEthereumNetwork, getMetaMetricState, getRpcPrefsForCurrentProvider, @@ -225,6 +226,12 @@ function getAddressBookEntryName (state, address) { return entry && entry.name !== '' ? entry.name : addressSlicer(address) } +function getDaiV1Token (state) { + const OLD_DAI_CONTRACT_ADDRESS = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359' + const tokens = state.metamask.tokens || [] + return tokens.find(({address}) => checksumAddress(address) === OLD_DAI_CONTRACT_ADDRESS) +} + function accountsWithSendEtherInfoSelector (state) { const accounts = getMetaMaskAccounts(state) const { identities } = state.metamask diff --git a/yarn.lock b/yarn.lock index 0d4e70c87ef0..5e9578a16af7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10038,10 +10038,10 @@ eth-block-tracker@^4.4.2: pify "^3.0.0" safe-event-emitter "^1.0.1" -eth-contract-metadata@1.9.3, eth-contract-metadata@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/eth-contract-metadata/-/eth-contract-metadata-1.9.3.tgz#d627d81cb6dadbe9d9261ec9594617ada38a25f2" - integrity sha512-qDdH9n2yw5GqWW5E6wrh7KZ8WicpEzofrpuJG3FWiJew+Yt6RapnqtXN8ljvxY+UTZPd1QzLXswKfpJyzsH4Tw== +eth-contract-metadata@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/eth-contract-metadata/-/eth-contract-metadata-1.11.0.tgz#4d23a8208d5d53be9d4c0696ed8492b505c6bca1" + integrity sha512-Bbvio71M+lH+qXd8XXddpTc8hhjL9m4fNPOxmZFIX8z0/VooUdwV8YmmDAbkU5WVioZi+Jp1XaoO7VwzXnDboA== eth-ens-namehash@2.0.8, eth-ens-namehash@^2.0.8: version "2.0.8" @@ -12160,13 +12160,13 @@ fuse.js@^3.4.4: resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.5.tgz#8954fb43f9729bd5dbcb8c08f251db552595a7a6" integrity sha512-s9PGTaQIkT69HaeoTVjwGsLfb8V8ScJLx5XGFcKHg0MqLUH/UZ4EKOtqtXX9k7AFqCGxD1aJmYb8Q5VYDibVRQ== -gaba@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/gaba/-/gaba-1.8.0.tgz#5370e5d662de6aa8e4e41de791da0996a7e12dbe" - integrity sha512-M20fZ6yKRefxgxb82l5Of0VutFxvc1Uxg8LSncaiq5kWQZO1UNe5pkxQc4EQT9rGAcBm6ASv7FG0B04syIELRA== +gaba@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/gaba/-/gaba-1.9.0.tgz#ccd9f99c56687b5acd39f9e3ceb435b2a59b6aa1" + integrity sha512-HoVreAdZssL0jNHuzZ7WP+YKZ0riu44jVDWxhQ9hsgPuzxbVEsz9fO/HDxqAdNZS1Cswayq6+ciZ3HSCFWMKbQ== dependencies: await-semaphore "^0.1.3" - eth-contract-metadata "^1.9.1" + eth-contract-metadata "^1.11.0" eth-ens-namehash "^2.0.8" eth-json-rpc-infura "^4.0.1" eth-keyring-controller "^5.3.0"