From 85f17831a2210c2eefb3118d25669dab90b327d9 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Tue, 22 Jun 2021 12:39:44 -0500 Subject: [PATCH] add erc-721 token detection and flag to disable sending (#11210) * add erc-721 token detection and flag to disable sending * addressing feedback * remove redundant provider instantiation * fix issue caused by unprotected destructuring * add tests and documentation * move add isERC721 flag to useTokenTracker hook * Update and unit tests * use memoizedTokens in useTokenTracker Co-authored-by: Dan Miller --- app/_locales/en/messages.json | 4 + app/scripts/controllers/detect-tokens.test.js | 15 +- app/scripts/controllers/preferences.js | 93 +++++++++--- app/scripts/controllers/preferences.test.js | 143 +++++++++++++++++- app/scripts/metamask-controller.js | 15 +- package.json | 1 + .../app/asset-list-item/asset-list-item.js | 12 +- ui/components/app/token-cell/token-cell.js | 3 + .../app/wallet-overview/token-overview.js | 2 + ui/helpers/constants/error-keys.js | 1 + ui/hooks/useTokenTracker.js | 12 +- .../send-asset-row.component.js | 45 +++++- .../send-asset-row.container.js | 10 +- .../send-content/send-content.component.js | 21 ++- ui/selectors/confirm-transaction.js | 8 +- ui/store/actions.js | 15 ++ 16 files changed, 348 insertions(+), 52 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 40f8bd8ac..c64f882d1 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2369,6 +2369,10 @@ "message": "verify the network details", "description": "Serves as link text for the 'unrecognizedChain' key. This text will be embedded inside the translation for that key." }, + "unsendableAsset": { + "message": "Sending collectible (ERC-721) tokens is not currently supported", + "description": "This is an error message we show the user if they attempt to send a collectible asset type, for which currently don't support sending" + }, "updatedWithDate": { "message": "Updated $1" }, diff --git a/app/scripts/controllers/detect-tokens.test.js b/app/scripts/controllers/detect-tokens.test.js index 3b5eddd24..4d4578124 100644 --- a/app/scripts/controllers/detect-tokens.test.js +++ b/app/scripts/controllers/detect-tokens.test.js @@ -11,7 +11,7 @@ import PreferencesController from './preferences'; describe('DetectTokensController', function () { const sandbox = sinon.createSandbox(); - let keyringMemStore, network, preferences; + let keyringMemStore, network, preferences, provider; const noop = () => undefined; @@ -23,12 +23,16 @@ describe('DetectTokensController', function () { keyringMemStore = new ObservableStore({ isUnlocked: false }); network = new NetworkController(); network.setInfuraProjectId('foo'); - preferences = new PreferencesController({ network }); + network.initializeProvider(networkControllerProviderConfig); + provider = network.getProviderAndBlockTracker().provider; + preferences = new PreferencesController({ network, provider }); preferences.setAddresses([ '0x7e57e2', '0xbc86727e770de68b1060c91f6bb6945c73e10388', ]); - network.initializeProvider(networkControllerProviderConfig); + sandbox + .stub(preferences, '_detectIsERC721') + .returns(Promise.resolve(false)); }); after(function () { @@ -125,6 +129,7 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, ]); }); @@ -177,11 +182,13 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, { address: tokenAddressToAdd.toLowerCase(), decimals: tokenToAdd.decimals, symbol: tokenToAdd.symbol, + isERC721: false, }, ]); }); @@ -234,11 +241,13 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, { address: tokenAddressToAdd.toLowerCase(), decimals: tokenToAdd.decimals, symbol: tokenToAdd.symbol, + isERC721: false, }, ]); }); diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 7d7669804..298272968 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -2,14 +2,21 @@ import { strict as assert } from 'assert'; import { ObservableStore } from '@metamask/obs-store'; import { ethErrors } from 'eth-rpc-errors'; import { normalize as normalizeAddress } from 'eth-sig-util'; -import ethers from 'ethers'; +import { ethers } from 'ethers'; import log from 'loglevel'; +import abiERC721 from 'human-standard-collectible-abi'; +import contractsMap from '@metamask/contract-metadata'; 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 { isValidHexAddress } from '../../../shared/modules/hexstring-utils'; +import { + isValidHexAddress, + toChecksumHexAddress, +} from '../../../shared/modules/hexstring-utils'; import { NETWORK_EVENTS } from './network'; +const ERC721METADATA_INTERFACE_ID = '0x5b5e139f'; + export default class PreferencesController { /** * @@ -73,11 +80,18 @@ export default class PreferencesController { }; this.network = opts.network; + this.ethersProvider = new ethers.providers.Web3Provider(opts.provider); this.store = new ObservableStore(initState); this.store.setMaxListeners(12); this.openPopup = opts.openPopup; this.migrateAddressBookState = opts.migrateAddressBookState; - this._subscribeToNetworkDidChange(); + + this.network.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { + const { tokens, hiddenTokens } = this._getTokenRelatedStates(); + this.ethersProvider = new ethers.providers.Web3Provider(opts.provider); + this._updateAccountTokens(tokens, this.getAssetImages(), hiddenTokens); + }); + this._subscribeToInfuraAvailability(); global.setPreference = (key, value) => { @@ -393,6 +407,8 @@ export default class PreferencesController { }); const previousIndex = tokens.indexOf(previousEntry); + newEntry.isERC721 = await this._detectIsERC721(newEntry.address); + if (previousEntry) { tokens[previousIndex] = newEntry; } else { @@ -403,6 +419,24 @@ export default class PreferencesController { return Promise.resolve(tokens); } + /** + * Adds isERC721 field to token object + * (Called when a user attempts to add tokens that were previously added which do not yet had isERC721 field) + * + * @param {string} tokenAddress - The contract address of the token requiring the isERC721 field added. + * @returns {Promise} The new token object with the added isERC721 field. + * + */ + async updateTokenType(tokenAddress) { + const { tokens } = this.store.getState(); + const tokenIndex = tokens.findIndex((token) => { + return token.address === tokenAddress; + }); + tokens[tokenIndex].isERC721 = await this._detectIsERC721(tokenAddress); + this.store.updateState({ tokens }); + return Promise.resolve(tokens[tokenIndex]); + } + /** * Removes a specified token from the tokens array and adds it to hiddenTokens array * @@ -480,11 +514,8 @@ export default class PreferencesController { let addressBookKey = rpcDetail.chainId; if (!addressBookKey) { // We need to find the networkId to determine what these addresses were keyed by - const provider = new ethers.providers.JsonRpcProvider( - rpcDetail.rpcUrl, - ); try { - addressBookKey = await provider.send('net_version'); + addressBookKey = await this.ethersProvider.send('net_version'); assert(typeof addressBookKey === 'string'); } catch (error) { log.debug(error); @@ -701,17 +732,6 @@ export default class PreferencesController { // PRIVATE METHODS // - /** - * Handle updating token list to reflect current network by listening for the - * NETWORK_DID_CHANGE event. - */ - _subscribeToNetworkDidChange() { - this.network.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { - const { tokens, hiddenTokens } = this._getTokenRelatedStates(); - this._updateAccountTokens(tokens, this.getAssetImages(), hiddenTokens); - }); - } - _subscribeToInfuraAvailability() { this.network.on(NETWORK_EVENTS.INFURA_IS_BLOCKED, () => { this._setInfuraBlocked(true); @@ -763,6 +783,43 @@ export default class PreferencesController { }); } + /** + * Detects whether or not a token is ERC-721 compatible. + * + * @param {string} tokensAddress - the token contract address. + * + */ + async _detectIsERC721(tokenAddress) { + const checksumAddress = toChecksumHexAddress(tokenAddress); + // if this token is already in our contract metadata map we don't need + // to check against the contract + if (contractsMap[checksumAddress]?.erc721 === true) { + return Promise.resolve(true); + } + const tokenContract = await this._createEthersContract( + tokenAddress, + abiERC721, + this.ethersProvider, + ); + + return await tokenContract + .supportsInterface(ERC721METADATA_INTERFACE_ID) + .catch((error) => { + console.log('error', error); + log.debug(error); + return false; + }); + } + + async _createEthersContract(tokenAddress, abi, ethersProvider) { + const tokenContract = await new ethers.Contract( + tokenAddress, + abi, + ethersProvider, + ); + return tokenContract; + } + /** * Updates `tokens` and `hiddenTokens` of current account and network. * diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index 4141f0f5f..d5b993c8e 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -1,10 +1,13 @@ import { strict as assert } from 'assert'; import sinon from 'sinon'; +import contractMaps from '@metamask/contract-metadata'; +import abiERC721 from 'human-standard-collectible-abi'; import { MAINNET_CHAIN_ID, RINKEBY_CHAIN_ID, } from '../../../shared/constants/network'; import PreferencesController from './preferences'; +import NetworkController from './network'; describe('preferences controller', function () { let preferencesController; @@ -13,19 +16,32 @@ describe('preferences controller', function () { let triggerNetworkChange; let switchToMainnet; let switchToRinkeby; + let provider; const migrateAddressBookState = sinon.stub(); beforeEach(function () { + const sandbox = sinon.createSandbox(); currentChainId = MAINNET_CHAIN_ID; - network = { - getCurrentChainId: () => currentChainId, - on: sinon.spy(), + const networkControllerProviderConfig = { + getAccounts: () => undefined, }; + network = new NetworkController(); + network.setInfuraProjectId('foo'); + network.initializeProvider(networkControllerProviderConfig); + provider = network.getProviderAndBlockTracker().provider; + + sandbox.stub(network, 'getCurrentChainId').callsFake(() => currentChainId); + sandbox + .stub(network, 'getProviderConfig') + .callsFake(() => ({ type: 'mainnet' })); + const spy = sandbox.spy(network, 'on'); + preferencesController = new PreferencesController({ migrateAddressBookState, network, + provider, }); - triggerNetworkChange = network.on.firstCall.args[1]; + triggerNetworkChange = spy.firstCall.args[1]; switchToMainnet = () => { currentChainId = MAINNET_CHAIN_ID; triggerNetworkChange(); @@ -86,6 +102,104 @@ describe('preferences controller', function () { }); }); + describe('updateTokenType', function () { + it('should add isERC721 = true to token object in state when token is collectible and in our contract-metadata repo', async function () { + const contractAddresses = Object.keys(contractMaps); + const erc721ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc721 === true, + ); + const address = erc721ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + preferencesController.store.updateState({ + tokens: [{ address, symbol, decimals }], + }); + const result = await preferencesController.updateTokenType(address); + assert.equal(result.isERC721, true); + }); + + it('should add isERC721 = true to token object in state when token is collectible and not in our contract-metadata repo', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + preferencesController.store.updateState({ + tokens: [ + { + address: tokenAddress, + symbol: 'TESTNFT', + decimals: '0', + }, + ], + }); + sinon + .stub(preferencesController, '_detectIsERC721') + .callsFake(() => true); + + const result = await preferencesController.updateTokenType(tokenAddress); + assert.equal( + preferencesController._detectIsERC721.getCall(0).args[0], + tokenAddress, + ); + assert.equal(result.isERC721, true); + }); + }); + + describe('_detectIsERC721', function () { + it('should return true when token is in our contract-metadata repo', async function () { + const tokenAddress = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal(result, true); + }); + + it('should return true when the token is not in our contract-metadata repo but tokenContract.supportsInterface returns true', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + const supportsInterfaceStub = sinon.stub().returns(Promise.resolve(true)); + sinon + .stub(preferencesController, '_createEthersContract') + .callsFake(() => ({ supportsInterface: supportsInterfaceStub })); + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[0], + tokenAddress, + ); + assert.deepEqual( + preferencesController._createEthersContract.getCall(0).args[1], + abiERC721, + ); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[2], + preferencesController.ethersProvider, + ); + assert.equal(result, true); + }); + + it('should return false when the token is not in our contract-metadata repo and tokenContract.supportsInterface returns false', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + const supportsInterfaceStub = sinon + .stub() + .returns(Promise.resolve(false)); + sinon + .stub(preferencesController, '_createEthersContract') + .callsFake(() => ({ supportsInterface: supportsInterfaceStub })); + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[0], + tokenAddress, + ); + assert.deepEqual( + preferencesController._createEthersContract.getCall(0).args[1], + abiERC721, + ); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[2], + preferencesController.ethersProvider, + ); + assert.equal(result, false); + }); + }); + describe('removeAddress', function () { it('should remove an address from state', function () { preferencesController.setAddresses(['0xda22le', '0x7e57e2']); @@ -291,7 +405,12 @@ describe('preferences controller', function () { assert.equal(tokens.length, 1, 'one token removed'); const [token1] = tokens; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); }); it('should remove a token from its state on corresponding address', async function () { @@ -310,7 +429,12 @@ describe('preferences controller', function () { assert.equal(tokensFirst.length, 1, 'one token removed in account'); const [token1] = tokensFirst; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); await preferencesController.setSelectedAddress('0x7e57e3'); const tokensSecond = preferencesController.getTokens(); @@ -335,7 +459,12 @@ describe('preferences controller', function () { assert.equal(tokensFirst.length, 1, 'one token removed in network'); const [token1] = tokensFirst; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); switchToRinkeby(); const tokensSecond = preferencesController.getTokens(); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 31186aaa3..e0296ca32 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -132,11 +132,17 @@ export default class MetamaskController extends EventEmitter { this.networkController = new NetworkController(initState.NetworkController); this.networkController.setInfuraProjectId(opts.infuraProjectId); + // now we can initialize the RPC provider, which other controllers require + this.initializeProvider(); + this.provider = this.networkController.getProviderAndBlockTracker().provider; + this.blockTracker = this.networkController.getProviderAndBlockTracker().blockTracker; + this.preferencesController = new PreferencesController({ initState: initState.PreferencesController, initLangCode: opts.initLangCode, openPopup: opts.openPopup, network: this.networkController, + provider: this.provider, migrateAddressBookState: this.migrateAddressBookState.bind(this), }); @@ -183,11 +189,6 @@ export default class MetamaskController extends EventEmitter { initState.NotificationController, ); - // now we can initialize the RPC provider, which other controllers require - this.initializeProvider(); - this.provider = this.networkController.getProviderAndBlockTracker().provider; - this.blockTracker = this.networkController.getProviderAndBlockTracker().blockTracker; - // token exchange rate tracker this.tokenRatesController = new TokenRatesController({ preferences: this.preferencesController.store, @@ -727,6 +728,10 @@ export default class MetamaskController extends EventEmitter { preferencesController, ), addToken: nodeify(preferencesController.addToken, preferencesController), + updateTokenType: nodeify( + preferencesController.updateTokenType, + preferencesController, + ), removeToken: nodeify( preferencesController.removeToken, preferencesController, diff --git a/package.json b/package.json index 06bed064d..09c16a206 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "fast-safe-stringify": "^2.0.7", "fuse.js": "^3.2.0", "globalthis": "^1.0.1", + "human-standard-collectible-abi": "^1.0.2", "human-standard-token-abi": "^2.0.0", "immer": "^8.0.1", "json-rpc-engine": "^6.1.0", diff --git a/ui/components/app/asset-list-item/asset-list-item.js b/ui/components/app/asset-list-item/asset-list-item.js index c147297b5..8d7f67965 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -27,6 +27,7 @@ const AssetListItem = ({ primary, secondary, identiconBorder, + isERC721, }) => { const t = useI18nContext(); const dispatch = useDispatch(); @@ -121,10 +122,12 @@ const AssetListItem = ({ } midContent={midContent} rightContent={ - <> - - {sendTokenButton} - + !isERC721 && ( + <> + + {sendTokenButton} + + ) } /> ); @@ -143,6 +146,7 @@ AssetListItem.propTypes = { 'primary': PropTypes.string, 'secondary': PropTypes.string, 'identiconBorder': PropTypes.bool, + 'isERC721': PropTypes.bool, }; AssetListItem.defaultProps = { diff --git a/ui/components/app/token-cell/token-cell.js b/ui/components/app/token-cell/token-cell.js index 777e9b560..18a7cd698 100644 --- a/ui/components/app/token-cell/token-cell.js +++ b/ui/components/app/token-cell/token-cell.js @@ -15,6 +15,7 @@ export default function TokenCell({ string, image, onClick, + isERC721, }) { const userAddress = useSelector(getSelectedAddress); const t = useI18nContext(); @@ -50,6 +51,7 @@ export default function TokenCell({ warning={warning} primary={`${string || 0}`} secondary={formattedFiat} + isERC721={isERC721} /> ); } @@ -62,6 +64,7 @@ TokenCell.propTypes = { string: PropTypes.string, image: PropTypes.string, onClick: PropTypes.func.isRequired, + isERC721: PropTypes.bool, }; TokenCell.defaultProps = { diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index 9bdb87533..f34cc3900 100644 --- a/ui/components/app/wallet-overview/token-overview.js +++ b/ui/components/app/wallet-overview/token-overview.js @@ -91,6 +91,7 @@ const TokenOverview = ({ className, token }) => { Icon={SendIcon} label={t('send')} data-testid="eth-overview-send" + disabled={token.isERC721} /> Number(token.balance) > 0) : tokenWithBalances; - setTokensWithBalances(matchingTokens); + // TODO: improve this pattern for adding this field when we improve support for + // EIP721 tokens. + const matchingTokensWithIsERC721Flag = matchingTokens.map((token) => { + const additionalTokenData = memoizedTokens.find( + (t) => t.address === token.address, + ); + return { ...token, isERC721: additionalTokenData?.isERC721 }; + }); + setTokensWithBalances(matchingTokensWithIsERC721Flag); setLoading(false); setError(null); }, - [hideZeroBalanceTokens], + [hideZeroBalanceTokens, memoizedTokens], ); const showError = useCallback((err) => { diff --git a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js index 35c89a041..785b711b0 100644 --- a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js +++ b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js @@ -22,6 +22,9 @@ export default class SendAssetRow extends Component { setSendToken: PropTypes.func.isRequired, nativeCurrency: PropTypes.string, nativeCurrencyImage: PropTypes.string, + setUnsendableAssetError: PropTypes.func.isRequired, + updateSendErrors: PropTypes.func.isRequired, + updateTokenType: PropTypes.func.isRequired, }; static contextTypes = { @@ -31,13 +34,41 @@ export default class SendAssetRow extends Component { state = { isShowingDropdown: false, + sendableTokens: [], }; + async componentDidMount() { + const sendableTokens = this.props.tokens.filter((token) => !token.isERC721); + this.setState({ sendableTokens }); + } + openDropdown = () => this.setState({ isShowingDropdown: true }); closeDropdown = () => this.setState({ isShowingDropdown: false }); - selectToken = (token) => { + clearUnsendableAssetError = () => { + this.props.setUnsendableAssetError(false); + this.props.updateSendErrors({ + unsendableAssetError: null, + gasLoadingError: null, + }); + }; + + selectToken = async (token) => { + if (token && token.isERC721 === undefined) { + const updatedToken = await this.props.updateTokenType(token.address); + if (updatedToken.isERC721) { + this.props.setUnsendableAssetError(true); + this.props.updateSendErrors({ + unsendableAssetError: 'unsendableAssetError', + }); + } + } + + if ((token && token.isERC721 === false) || token === undefined) { + this.clearUnsendableAssetError(); + } + this.setState( { isShowingDropdown: false, @@ -65,7 +96,9 @@ export default class SendAssetRow extends Component {
{this.renderSendToken()} - {this.props.tokens.length > 0 ? this.renderAssetDropdown() : null} + {this.state.sendableTokens.length > 0 + ? this.renderAssetDropdown() + : null}
); @@ -96,7 +129,9 @@ export default class SendAssetRow extends Component { />
{this.renderNativeCurrency(true)} - {this.props.tokens.map((token) => this.renderAsset(token, true))} + {this.state.sendableTokens.map((token) => + this.renderAsset(token, true), + )}
) @@ -119,7 +154,7 @@ export default class SendAssetRow extends Component { return (
0 + this.state.sendableTokens.length > 0 ? 'send-v2__asset-dropdown__asset' : 'send-v2__asset-dropdown__single-asset' } @@ -146,7 +181,7 @@ export default class SendAssetRow extends Component { />
- {!insideDropdown && this.props.tokens.length > 0 && ( + {!insideDropdown && this.state.sendableTokens.length > 0 && ( )} diff --git a/ui/pages/send/send-content/send-asset-row/send-asset-row.container.js b/ui/pages/send/send-content/send-asset-row/send-asset-row.container.js index 61c659434..eeeabba28 100644 --- a/ui/pages/send/send-content/send-asset-row/send-asset-row.container.js +++ b/ui/pages/send/send-content/send-asset-row/send-asset-row.container.js @@ -6,7 +6,11 @@ import { getSendTokenAddress, getAssetImages, } from '../../../../selectors'; -import { updateSendToken } from '../../../../ducks/send/send.duck'; +import { updateTokenType } from '../../../../store/actions'; +import { + updateSendErrors, + updateSendToken, +} from '../../../../ducks/send/send.duck'; import SendAssetRow from './send-asset-row.component'; function mapStateToProps(state) { @@ -24,6 +28,10 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { setSendToken: (token) => dispatch(updateSendToken(token)), + updateTokenType: (tokenAddress) => dispatch(updateTokenType(tokenAddress)), + updateSendErrors: (error) => { + dispatch(updateSendErrors(error)); + }, }; } diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 6f1a82b92..96d728024 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -6,6 +6,7 @@ import { ETH_GAS_PRICE_FETCH_WARNING_KEY, GAS_PRICE_FETCH_FAILURE_ERROR_KEY, GAS_PRICE_EXCESSIVE_ERROR_KEY, + UNSENDABLE_ASSET_ERROR_KEY, } from '../../../helpers/constants/error-keys'; import SendAmountRow from './send-amount-row'; import SendGasRow from './send-gas-row'; @@ -17,6 +18,10 @@ export default class SendContent extends Component { t: PropTypes.func, }; + state = { + unsendableAssetError: false, + }; + static propTypes = { updateGas: PropTypes.func, showAddToAddressBookModal: PropTypes.func, @@ -32,6 +37,9 @@ export default class SendContent extends Component { updateGas = (updateData) => this.props.updateGas(updateData); + setUnsendableAssetError = (unsendableAssetError) => + this.setState({ unsendableAssetError }); + render() { const { warning, @@ -41,6 +49,7 @@ export default class SendContent extends Component { noGasPrice, } = this.props; + const { unsendableAssetError } = this.state; let gasError; if (gasIsExcessive) gasError = GAS_PRICE_EXCESSIVE_ERROR_KEY; else if (noGasPrice) gasError = GAS_PRICE_FETCH_FAILURE_ERROR_KEY; @@ -50,10 +59,13 @@ export default class SendContent extends Component {
{gasError && this.renderError(gasError)} {isEthGasPrice && this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)} - {error && this.renderError()} + {unsendableAssetError && this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} + {error && this.renderError(error)} {warning && this.renderWarning()} {this.maybeRenderAddContact()} - + {this.props.showHexData && ( @@ -97,12 +109,11 @@ export default class SendContent extends Component { ); } - renderError(gasError = '') { + renderError(error) { const { t } = this.context; - const { error } = this.props; return ( - {gasError === '' ? t(error) : t(gasError)} + {t(error)} ); } diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index 70982d1b1..cd0770c99 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -218,8 +218,12 @@ export const transactionFeeSelector = function (state, txData) { const conversionRate = conversionRateSelector(state); const nativeCurrency = getNativeCurrency(state); - const { - txParams: { value = '0x0', gas: gasLimit = '0x0', gasPrice = '0x0' } = {}, + const { txParams: { value = '0x0', gas: gasLimit = '0x0' } = {} } = txData; + + // if the gas price from our infura endpoint is null or undefined + // use the metaswap average price estimation as a fallback + let { + txParams: { gasPrice }, } = txData; const fiatTransactionAmount = getValueFromWeiHex({ diff --git a/ui/store/actions.js b/ui/store/actions.js index 8dc7e9ee9..9f680fabb 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -1234,6 +1234,21 @@ export function addToken( }; } +export function updateTokenType(tokenAddress) { + return async (dispatch) => { + let token = {}; + dispatch(showLoadingIndication()); + try { + token = await promisifiedBackground.updateTokenType(tokenAddress); + } catch (error) { + log.error(error); + } finally { + dispatch(hideLoadingIndication()); + } + return token; + }; +} + export function removeToken(address) { return (dispatch) => { dispatch(showLoadingIndication());