From 85f17831a2210c2eefb3118d25669dab90b327d9 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Tue, 22 Jun 2021 12:39:44 -0500 Subject: [PATCH 01/12] 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()); From e17325c38a536f57280da77c16568edaea8fbe22 Mon Sep 17 00:00:00 2001 From: Brad Decker Date: Wed, 23 Jun 2021 16:35:25 -0500 Subject: [PATCH 02/12] Refactor send page state management (#10965) --- app/_locales/am/messages.json | 3 - app/_locales/ar/messages.json | 3 - app/_locales/bg/messages.json | 3 - app/_locales/bn/messages.json | 3 - app/_locales/ca/messages.json | 3 - app/_locales/cs/messages.json | 3 - app/_locales/da/messages.json | 3 - app/_locales/de/messages.json | 3 - app/_locales/el/messages.json | 3 - app/_locales/en/messages.json | 12 +- app/_locales/es/messages.json | 3 - app/_locales/es_419/messages.json | 3 - app/_locales/et/messages.json | 3 - app/_locales/fa/messages.json | 3 - app/_locales/fi/messages.json | 3 - app/_locales/fil/messages.json | 3 - app/_locales/fr/messages.json | 3 - app/_locales/he/messages.json | 3 - app/_locales/hi/messages.json | 3 - app/_locales/hn/messages.json | 3 - app/_locales/hr/messages.json | 3 - app/_locales/ht/messages.json | 3 - app/_locales/hu/messages.json | 3 - app/_locales/id/messages.json | 3 - app/_locales/it/messages.json | 3 - app/_locales/ja/messages.json | 3 - app/_locales/kn/messages.json | 3 - app/_locales/ko/messages.json | 3 - app/_locales/lt/messages.json | 3 - app/_locales/lv/messages.json | 3 - app/_locales/ms/messages.json | 3 - app/_locales/nl/messages.json | 3 - app/_locales/no/messages.json | 3 - app/_locales/ph/messages.json | 3 - app/_locales/pl/messages.json | 3 - app/_locales/pt/messages.json | 3 - app/_locales/pt_BR/messages.json | 3 - app/_locales/ro/messages.json | 3 - app/_locales/ru/messages.json | 3 - app/_locales/sk/messages.json | 3 - app/_locales/sl/messages.json | 3 - app/_locales/sr/messages.json | 3 - app/_locales/sv/messages.json | 3 - app/_locales/sw/messages.json | 3 - app/_locales/ta/messages.json | 3 - app/_locales/th/messages.json | 3 - app/_locales/tl/messages.json | 3 - app/_locales/tr/messages.json | 3 - app/_locales/uk/messages.json | 3 - app/_locales/vi/messages.json | 3 - app/_locales/zh_CN/messages.json | 3 - app/_locales/zh_TW/messages.json | 3 - test/e2e/tests/send-eth.spec.js | 219 ++ .../app/asset-list-item/asset-list-item.js | 18 +- ...gas-modal-page-container-container.test.js | 54 +- .../gas-modal-page-container.container.js | 73 +- .../app/wallet-overview/token-overview.js | 12 +- .../ui/unit-input/unit-input.component.js | 5 +- ui/contexts/metametrics.js | 14 +- ui/ducks/ens.js | 197 ++ ui/ducks/gas/gas-action-constants.js | 14 + ui/ducks/gas/gas-duck.test.js | 15 +- ui/ducks/gas/gas.duck.js | 16 +- ui/ducks/index.js | 4 +- ui/ducks/send/index.js | 1 + ui/ducks/send/send-duck.test.js | 142 -- ui/ducks/send/send.duck.js | 382 ---- ui/ducks/send/send.js | 1472 ++++++++++++++ ui/ducks/send/send.test.js | 1808 +++++++++++++++++ .../confirm-send-ether.container.js | 20 +- .../confirm-send-token.container.js | 43 +- ui/pages/confirm-transaction/conf-tx.js | 15 +- .../confirm-transaction.component.js | 6 +- .../confirm-transaction.container.js | 5 +- ui/pages/send/index.js | 2 +- .../add-recipient/add-recipient.component.js | 120 +- .../add-recipient.component.test.js | 68 +- .../add-recipient/add-recipient.container.js | 36 +- .../add-recipient.container.test.js | 60 +- .../add-recipient/add-recipient.js | 56 - .../add-recipient/add-recipient.utils.test.js | 115 -- .../add-recipient/ens-input.component.js | 330 +-- .../add-recipient/ens-input.container.js | 26 +- .../amount-max-button.component.test.js | 93 - .../amount-max-button.container.js | 42 - .../amount-max-button.container.test.js | 83 - .../amount-max-button/amount-max-button.js | 49 + .../amount-max-button.test.js | 61 + .../amount-max-button.utils.js | 22 - .../amount-max-button.utils.test.js | 26 - .../amount-max-button/index.js | 2 +- .../send-amount-row.component.js | 92 +- .../send-amount-row.component.test.js | 129 +- .../send-amount-row.container.js | 32 +- .../send-amount-row.container.test.js | 58 +- .../send-asset-row.component.js | 45 +- .../send-asset-row.container.js | 16 +- .../send-content/send-content.component.js | 26 +- .../send-content/send-content.container.js | 6 +- .../send-gas-row/send-gas-row.component.js | 106 +- .../send-gas-row.component.test.js | 11 +- .../send-gas-row/send-gas-row.container.js | 98 +- .../send-gas-row.container.test.js | 67 +- .../send-hex-data-row.component.js | 4 +- .../send-hex-data-row.container.js | 4 +- .../send-row-error-message.container.js | 2 +- .../send-row-error-message.container.test.js | 2 +- .../send/send-footer/send-footer.component.js | 86 +- .../send-footer/send-footer.component.test.js | 125 +- .../send/send-footer/send-footer.container.js | 105 +- .../send-footer/send-footer.container.test.js | 135 +- .../send/send-footer/send-footer.utils.js | 96 - .../send-footer/send-footer.utils.test.js | 215 -- ui/pages/send/send-header/index.js | 2 +- .../send/send-header/send-header.component.js | 64 +- .../send-header/send-header.component.test.js | 155 +- .../send/send-header/send-header.container.js | 20 - ui/pages/send/send.component.js | 403 ---- ui/pages/send/send.component.test.js | 467 ----- ui/pages/send/send.constants.js | 16 +- ui/pages/send/send.container.js | 138 -- ui/pages/send/send.container.test.js | 128 -- ui/pages/send/send.js | 112 + ui/pages/send/send.test.js | 173 ++ ui/pages/send/send.utils.js | 206 +- ui/pages/send/send.utils.test.js | 329 --- .../add-contact/add-contact.component.js | 54 +- .../add-contact/add-contact.container.js | 10 +- ui/selectors/confirm-transaction.js | 8 +- ui/selectors/custom-gas.js | 10 +- ui/selectors/custom-gas.test.js | 44 +- ui/selectors/index.js | 1 - ui/selectors/send-selectors-test-data.js | 214 -- ui/selectors/send.js | 135 -- ui/selectors/send.test.js | 417 ---- ui/store/actionConstants.js | 4 + ui/store/actionConstants.test.js | 9 +- ui/store/actions.js | 124 +- ui/store/actions.test.js | 134 +- 139 files changed, 5029 insertions(+), 5699 deletions(-) create mode 100644 test/e2e/tests/send-eth.spec.js create mode 100644 ui/ducks/ens.js create mode 100644 ui/ducks/gas/gas-action-constants.js create mode 100644 ui/ducks/send/index.js delete mode 100644 ui/ducks/send/send-duck.test.js delete mode 100644 ui/ducks/send/send.duck.js create mode 100644 ui/ducks/send/send.js create mode 100644 ui/ducks/send/send.test.js delete mode 100644 ui/pages/send/send-content/add-recipient/add-recipient.js delete mode 100644 ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js create mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js create mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js delete mode 100644 ui/pages/send/send-footer/send-footer.utils.js delete mode 100644 ui/pages/send/send-footer/send-footer.utils.test.js delete mode 100644 ui/pages/send/send-header/send-header.container.js delete mode 100644 ui/pages/send/send.component.js delete mode 100644 ui/pages/send/send.component.test.js delete mode 100644 ui/pages/send/send.container.js delete mode 100644 ui/pages/send/send.container.test.js create mode 100644 ui/pages/send/send.js create mode 100644 ui/pages/send/send.test.js delete mode 100644 ui/selectors/send-selectors-test-data.js delete mode 100644 ui/selectors/send.js delete mode 100644 ui/selectors/send.test.js diff --git a/app/_locales/am/messages.json b/app/_locales/am/messages.json index 91c2eb214..5516f4465 100644 --- a/app/_locales/am/messages.json +++ b/app/_locales/am/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "የቅርብ ጊዜያት" }, - "recipientAddress": { - "message": "የተቀባይ አድራሻ" - }, "recipientAddressPlaceholder": { "message": "ፍለጋ፣ ለሕዝብ ክፍት የሆነ አድራሻ (0x), ወይም ENS" }, diff --git a/app/_locales/ar/messages.json b/app/_locales/ar/messages.json index ffe065f16..14ecf04ed 100644 --- a/app/_locales/ar/messages.json +++ b/app/_locales/ar/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "الحديث" }, - "recipientAddress": { - "message": "عنوان المستلم" - }, "recipientAddressPlaceholder": { "message": "البحث، العنوان العام (0x)، أو ENS" }, diff --git a/app/_locales/bg/messages.json b/app/_locales/bg/messages.json index dde63701f..9903c065a 100644 --- a/app/_locales/bg/messages.json +++ b/app/_locales/bg/messages.json @@ -750,9 +750,6 @@ "recents": { "message": "Скорошни" }, - "recipientAddress": { - "message": "Адрес на получателя" - }, "recipientAddressPlaceholder": { "message": "Търсене, публичен адрес (0x) или ENS" }, diff --git a/app/_locales/bn/messages.json b/app/_locales/bn/messages.json index d2c08ab7b..c35429f22 100644 --- a/app/_locales/bn/messages.json +++ b/app/_locales/bn/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "সাম্প্রতিকগুলি" }, - "recipientAddress": { - "message": "প্রাপকের ঠিকানা" - }, "recipientAddressPlaceholder": { "message": "অনুসন্ধান, সার্বজনীন ঠিকানা (0x), বা ENS" }, diff --git a/app/_locales/ca/messages.json b/app/_locales/ca/messages.json index ab0e91b84..1608d3fbf 100644 --- a/app/_locales/ca/messages.json +++ b/app/_locales/ca/messages.json @@ -732,9 +732,6 @@ "readdToken": { "message": "Pots tornar a afegir aquesta fitxa en el futur anant a \"Afegir fitxa\" al menu d'opcions dels teus comptes." }, - "recipientAddress": { - "message": "Adreça del destinatari" - }, "recipientAddressPlaceholder": { "message": "Cerca, adreça pública (0x), o ENS" }, diff --git a/app/_locales/cs/messages.json b/app/_locales/cs/messages.json index 442b91544..cc75f6444 100644 --- a/app/_locales/cs/messages.json +++ b/app/_locales/cs/messages.json @@ -308,9 +308,6 @@ "readdToken": { "message": "Tento token můžete v budoucnu přidat zpět s „Přidat token“ v nastavení účtu." }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "reject": { "message": "Odmítnout" }, diff --git a/app/_locales/da/messages.json b/app/_locales/da/messages.json index 24fd9a480..05daa9b45 100644 --- a/app/_locales/da/messages.json +++ b/app/_locales/da/messages.json @@ -735,9 +735,6 @@ "recents": { "message": "Seneste" }, - "recipientAddress": { - "message": "Modtagerens adresse" - }, "recipientAddressPlaceholder": { "message": "Søg, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index f04fba67c..e38061843 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -723,9 +723,6 @@ "recents": { "message": "Letzte" }, - "recipientAddress": { - "message": "Empfängeradresse" - }, "recipientAddressPlaceholder": { "message": "Suchen, öffentliche Adresse (0x) oder ENS" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index d7902743a..d18a8ef43 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Πρόσφατα" }, - "recipientAddress": { - "message": "Διεύθυνση Παραλήπτη" - }, "recipientAddressPlaceholder": { "message": "Αναζήτηση, δημόσια διεύθυνση (0x) ή ENS" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index c64f882d1..45a08b5a9 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -650,12 +650,21 @@ "message": "The endpoint returned a different chain ID: $1", "description": "$1 is the return value of eth_chainId from an RPC endpoint" }, + "ensIllegalCharacter": { + "message": "Illegal Character for ENS." + }, "ensNotFoundOnCurrentNetwork": { "message": "ENS name not found on the current network. Try switching to Ethereum Mainnet." }, + "ensNotSupportedOnNetwork": { + "message": "Network does not support ENS" + }, "ensRegistrationError": { "message": "Error in ENS name registration" }, + "ensUnknownError": { + "message": "ENS Lookup failed." + }, "enterAnAlias": { "message": "Enter an alias" }, @@ -1451,9 +1460,6 @@ "recents": { "message": "Recents" }, - "recipientAddress": { - "message": "Recipient Address" - }, "recipientAddressPlaceholder": { "message": "Search, public address (0x), or ENS" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index ea6826817..8f41652bf 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 413624d20..af16138e3 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -1419,9 +1419,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, diff --git a/app/_locales/et/messages.json b/app/_locales/et/messages.json index 4139c4a92..36bac4a2f 100644 --- a/app/_locales/et/messages.json +++ b/app/_locales/et/messages.json @@ -744,9 +744,6 @@ "recents": { "message": "Hiljutised" }, - "recipientAddress": { - "message": "Saaja aadress" - }, "recipientAddressPlaceholder": { "message": "Otsing, avalik aadress (0x) või ENS" }, diff --git a/app/_locales/fa/messages.json b/app/_locales/fa/messages.json index a60b658cf..4d871ca55 100644 --- a/app/_locales/fa/messages.json +++ b/app/_locales/fa/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "واپسین" }, - "recipientAddress": { - "message": "آدرس دریافت کننده" - }, "recipientAddressPlaceholder": { "message": "جستجو، آدرس عمومی (0x)، یا ENS" }, diff --git a/app/_locales/fi/messages.json b/app/_locales/fi/messages.json index 79b779fbb..a2bc0c34d 100644 --- a/app/_locales/fi/messages.json +++ b/app/_locales/fi/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Viimeaikaiset" }, - "recipientAddress": { - "message": "Vastaanottajan osoite" - }, "recipientAddressPlaceholder": { "message": "Haku, julkinen osoite (0x) tai ENS" }, diff --git a/app/_locales/fil/messages.json b/app/_locales/fil/messages.json index be0fef29c..43f7b79d7 100644 --- a/app/_locales/fil/messages.json +++ b/app/_locales/fil/messages.json @@ -678,9 +678,6 @@ "recents": { "message": "Kamakailan" }, - "recipientAddress": { - "message": "Address ng Recipient" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 77207d133..2e5bb49f9 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -736,9 +736,6 @@ "recents": { "message": "Récents" }, - "recipientAddress": { - "message": "Adresse du destinataire" - }, "recipientAddressPlaceholder": { "message": "Recherche, adresse publique (0x) ou ENS" }, diff --git a/app/_locales/he/messages.json b/app/_locales/he/messages.json index 72123870b..f8ca504c0 100644 --- a/app/_locales/he/messages.json +++ b/app/_locales/he/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "אחרונים" }, - "recipientAddress": { - "message": "כתובת הנמען" - }, "recipientAddressPlaceholder": { "message": "חיפוש, כתובת ציבורית (0x), או ENS" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 7d57f515b..6bc518358 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "हाल ही के" }, - "recipientAddress": { - "message": "प्राप्तकर्ता का पता" - }, "recipientAddressPlaceholder": { "message": "खोज, सार्वजनिक पता (0x) या ENS" }, diff --git a/app/_locales/hn/messages.json b/app/_locales/hn/messages.json index f440c3ff9..2dfdfd179 100644 --- a/app/_locales/hn/messages.json +++ b/app/_locales/hn/messages.json @@ -285,9 +285,6 @@ "readdToken": { "message": "आप अपने खाता विकल्प मेनू में .टोकन जोड़ें. पर जाकर भविष्य में इस टोकन को वापस जोड़ सकते हैं।" }, - "recipientAddress": { - "message": "प्राप्तकर्ता पता" - }, "reject": { "message": "अस्वीकार" }, diff --git a/app/_locales/hr/messages.json b/app/_locales/hr/messages.json index bf02b3939..760043918 100644 --- a/app/_locales/hr/messages.json +++ b/app/_locales/hr/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Adresa primatelja" - }, "recipientAddressPlaceholder": { "message": "Pretraži, javne adrese (0x) ili ENS" }, diff --git a/app/_locales/ht/messages.json b/app/_locales/ht/messages.json index 2f750cd7d..61d5c1a96 100644 --- a/app/_locales/ht/messages.json +++ b/app/_locales/ht/messages.json @@ -450,9 +450,6 @@ "readdToken": { "message": "Ou ka ajoute token sa aprè sa ankò ou prale nan \"Ajoute token\" nan opsyon meni kont ou an." }, - "recipientAddress": { - "message": "Adrès pou resevwa" - }, "reject": { "message": "Rejte" }, diff --git a/app/_locales/hu/messages.json b/app/_locales/hu/messages.json index cb397cd69..b124316cd 100644 --- a/app/_locales/hu/messages.json +++ b/app/_locales/hu/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Legutóbbiak" }, - "recipientAddress": { - "message": "Címzett címe" - }, "recipientAddressPlaceholder": { "message": "Keresés, nyilvános cím (0x) vagy ENS" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index b019c25b5..07e913856 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "Terkini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat publik (0x), atau ENS" }, diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index 161086c29..82c92fff4 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -1201,9 +1201,6 @@ "recents": { "message": "Recenti" }, - "recipientAddress": { - "message": "Indirizzo Destinatario" - }, "recipientAddressPlaceholder": { "message": "Ricerca, indirizzo pubblico (0x) o ENS" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 7ac44b231..c8b8d0a7c 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "受信者のアドレス" - }, "recipientAddressPlaceholder": { "message": "検索、パブリック アドレス (0x)、または ENS" }, diff --git a/app/_locales/kn/messages.json b/app/_locales/kn/messages.json index cbe33d70e..dff966fe2 100644 --- a/app/_locales/kn/messages.json +++ b/app/_locales/kn/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "ಇತ್ತೀಚಿನವುಗಳು" }, - "recipientAddress": { - "message": "ಸ್ವೀಕರಿಸುವವರ ವಿಳಾಸ" - }, "recipientAddressPlaceholder": { "message": "ಸಾರ್ವಜನಿಕ ವಿಳಾಸ (0x) ಅಥವಾ ENS ಹುಡುಕಿ" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index e81d77b82..a14259717 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -1415,9 +1415,6 @@ "recents": { "message": "최근" }, - "recipientAddress": { - "message": "수신인 주소" - }, "recipientAddressPlaceholder": { "message": "검색, 공개 주소(0x) 또는 ENS" }, diff --git a/app/_locales/lt/messages.json b/app/_locales/lt/messages.json index f18067844..833ccc493 100644 --- a/app/_locales/lt/messages.json +++ b/app/_locales/lt/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "Naujausi" }, - "recipientAddress": { - "message": "Gavėjo adresas" - }, "recipientAddressPlaceholder": { "message": "Ieška, viešieji adresai (0x) arba ENS" }, diff --git a/app/_locales/lv/messages.json b/app/_locales/lv/messages.json index 85848338a..0ad89609f 100644 --- a/app/_locales/lv/messages.json +++ b/app/_locales/lv/messages.json @@ -750,9 +750,6 @@ "recents": { "message": "Nesenie" }, - "recipientAddress": { - "message": "Saņēmēja adrese" - }, "recipientAddressPlaceholder": { "message": "Meklēšana, publiskā adrese (0x) vai ENS" }, diff --git a/app/_locales/ms/messages.json b/app/_locales/ms/messages.json index 483925f9c..1f5438c72 100644 --- a/app/_locales/ms/messages.json +++ b/app/_locales/ms/messages.json @@ -731,9 +731,6 @@ "recents": { "message": "Baru-baru ini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat awam (0x), atau ENS" }, diff --git a/app/_locales/nl/messages.json b/app/_locales/nl/messages.json index 2366f8283..aaaac5ffc 100644 --- a/app/_locales/nl/messages.json +++ b/app/_locales/nl/messages.json @@ -272,9 +272,6 @@ "readdToken": { "message": "U kunt dit token in de toekomst weer toevoegen door naar \"Token toevoegen\" te gaan in het menu met accountopties." }, - "recipientAddress": { - "message": "Geadresseerde adres" - }, "reject": { "message": "Afwijzen" }, diff --git a/app/_locales/no/messages.json b/app/_locales/no/messages.json index a6bba8253..ff6fd6bfd 100644 --- a/app/_locales/no/messages.json +++ b/app/_locales/no/messages.json @@ -741,9 +741,6 @@ "recents": { "message": "Nylige" }, - "recipientAddress": { - "message": "Mottakeradresse" - }, "recipientAddressPlaceholder": { "message": "Søk, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index 3a9141757..f67d67d4e 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -1419,9 +1419,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/pl/messages.json b/app/_locales/pl/messages.json index e2f4db68c..075940604 100644 --- a/app/_locales/pl/messages.json +++ b/app/_locales/pl/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "Ostatnie" }, - "recipientAddress": { - "message": "Adres odbiorcy" - }, "recipientAddressPlaceholder": { "message": "Szukaj, adres publiczny (0x) lub ENS" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index c9f598cba..e72e564df 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -282,9 +282,6 @@ "readdToken": { "message": "Pode adicionar este token de novo clicando na opção “Adicionar token” no menu de opções da sua conta." }, - "recipientAddress": { - "message": "Endereço do Destinatário" - }, "reject": { "message": "Rejeitar" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index f6d41ac24..369298586 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -1405,9 +1405,6 @@ "recents": { "message": "Recentes" }, - "recipientAddress": { - "message": "Endereço do destinatário" - }, "recipientAddressPlaceholder": { "message": "Busca, endereço público (0x) ou ENS" }, diff --git a/app/_locales/ro/messages.json b/app/_locales/ro/messages.json index 444ba049f..d18b032df 100644 --- a/app/_locales/ro/messages.json +++ b/app/_locales/ro/messages.json @@ -741,9 +741,6 @@ "recents": { "message": "Recente" }, - "recipientAddress": { - "message": "Adresă destinatar" - }, "recipientAddressPlaceholder": { "message": "Căutare, adresa publică (0x) sau ENS" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 02dfbd8bb..8c2870a49 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "Недавние" }, - "recipientAddress": { - "message": "Адрес получателя" - }, "recipientAddressPlaceholder": { "message": "Поиск, публичный адрес (0x) или ENS" }, diff --git a/app/_locales/sk/messages.json b/app/_locales/sk/messages.json index e23dfb73e..c8be9c255 100644 --- a/app/_locales/sk/messages.json +++ b/app/_locales/sk/messages.json @@ -723,9 +723,6 @@ "recents": { "message": "Posledné" }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "recipientAddressPlaceholder": { "message": "Vyhľadávať verejnú adresu (0x) alebo ENS" }, diff --git a/app/_locales/sl/messages.json b/app/_locales/sl/messages.json index 47a5a1737..d1779de49 100644 --- a/app/_locales/sl/messages.json +++ b/app/_locales/sl/messages.json @@ -742,9 +742,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Prejemnikov naslov" - }, "recipientAddressPlaceholder": { "message": "Iskanje, javni naslov (0x) ali ENS" }, diff --git a/app/_locales/sr/messages.json b/app/_locales/sr/messages.json index 7cbcdf175..50780aa35 100644 --- a/app/_locales/sr/messages.json +++ b/app/_locales/sr/messages.json @@ -745,9 +745,6 @@ "recents": { "message": "Skorašnje" }, - "recipientAddress": { - "message": "Adresa primaoca" - }, "recipientAddressPlaceholder": { "message": "Pretraga, javna adresa (0x) ili ENS" }, diff --git a/app/_locales/sv/messages.json b/app/_locales/sv/messages.json index bfa2e92f6..4ce66b4e6 100644 --- a/app/_locales/sv/messages.json +++ b/app/_locales/sv/messages.json @@ -738,9 +738,6 @@ "recents": { "message": "Senaste" }, - "recipientAddress": { - "message": "Mottagaradress" - }, "recipientAddressPlaceholder": { "message": "Sök, allmän adress (0x) eller ENS" }, diff --git a/app/_locales/sw/messages.json b/app/_locales/sw/messages.json index f4d9acc5b..541a39f90 100644 --- a/app/_locales/sw/messages.json +++ b/app/_locales/sw/messages.json @@ -732,9 +732,6 @@ "recents": { "message": "Za hivi karibuni" }, - "recipientAddress": { - "message": "Anwani ya Mpokeaji" - }, "recipientAddressPlaceholder": { "message": "Tafuta, anwani za umma (0x), au ENS" }, diff --git a/app/_locales/ta/messages.json b/app/_locales/ta/messages.json index 29a239a41..4f6ab5c14 100644 --- a/app/_locales/ta/messages.json +++ b/app/_locales/ta/messages.json @@ -372,9 +372,6 @@ "readdToken": { "message": "உங்கள் கணக்கு விருப்பங்கள் மெனுவில் \"டோக்கனைச் சேர்\" என்பதன் மூலம் நீங்கள் எதிர்காலத்தில் இந்த டோக்கனை மீண்டும் சேர்க்கலாம்." }, - "recipientAddress": { - "message": "பெறுநர் முகவரி" - }, "reject": { "message": "நிராகரி" }, diff --git a/app/_locales/th/messages.json b/app/_locales/th/messages.json index 324722400..0f8e7cdeb 100644 --- a/app/_locales/th/messages.json +++ b/app/_locales/th/messages.json @@ -375,9 +375,6 @@ "readdToken": { "message": "คุณสามารถเพิ่มโทเค็นนี้ในอนาคตได้โดยไปที่ “เพิ่มโทเค็น” ในเมนูตัวเลือกบัญชีของคุณ" }, - "recipientAddress": { - "message": "แอดแดรสผู้รับ" - }, "reject": { "message": "ปฏิเสธ" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index caf6059fd..cffc323a2 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -1192,9 +1192,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index d47b570ad..1bb278b72 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -324,9 +324,6 @@ "readdToken": { "message": "Gelecekte Bu jetonu hesap seçenekleri menüsünde “Jeton ekle”'ye giderek geri ekleyebilirsiniz." }, - "recipientAddress": { - "message": "Alıcı adresi" - }, "reject": { "message": "Reddetmek" }, diff --git a/app/_locales/uk/messages.json b/app/_locales/uk/messages.json index 27766159a..5b6fe762e 100644 --- a/app/_locales/uk/messages.json +++ b/app/_locales/uk/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "Останні" }, - "recipientAddress": { - "message": "Адреса отримувача" - }, "recipientAddressPlaceholder": { "message": "Пошук, публічна адреса (0x), або ENS" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index c2955653b..dd91e2af6 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -1411,9 +1411,6 @@ "recents": { "message": "Gần đây" }, - "recipientAddress": { - "message": "Địa chỉ người nhận" - }, "recipientAddressPlaceholder": { "message": "Tìm kiếm, địa chỉ công khai (0x) hoặc ENS" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index cb1950b2c..969150137 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "最近记录" }, - "recipientAddress": { - "message": "接收地址" - }, "recipientAddressPlaceholder": { "message": "查找、公用地址 (0x) 或 ENS" }, diff --git a/app/_locales/zh_TW/messages.json b/app/_locales/zh_TW/messages.json index bd95f859e..5756be928 100644 --- a/app/_locales/zh_TW/messages.json +++ b/app/_locales/zh_TW/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "接收位址" - }, "recipientAddressPlaceholder": { "message": "搜尋,公開地址 (0x),或 ENS" }, diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js new file mode 100644 index 000000000..2c1c1c97e --- /dev/null +++ b/test/e2e/tests/send-eth.spec.js @@ -0,0 +1,219 @@ +const { strict: assert } = require('assert'); +const { withFixtures, regularDelayMs } = require('../helpers'); + +describe('Send ETH from inside MetaMask using default gas', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1000'); + + const errorAmount = await driver.findElement('.send-v2__error-amount'); + assert.equal( + await errorAmount.getText(), + 'Insufficient funds.', + 'send screen should render an insufficient fund error message', + ); + + await inputAmount.press(driver.Key.BACK_SPACE); + await inputAmount.press(driver.Key.BACK_SPACE); + await inputAmount.press(driver.Key.BACK_SPACE); + await driver.delay(regularDelayMs); + + await driver.assertElementNotPresent('.send-v2__error-amount'); + + const amountMax = await driver.findClickableElement( + '.send-v2__amount-max', + ); + await amountMax.click(); + + let inputValue = await inputAmount.getAttribute('value'); + + assert(Number(inputValue) > 24); + + await amountMax.click(); + + assert.equal(await inputAmount.isEnabled(), true); + + await inputAmount.fill('1'); + + inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .transaction-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + + await driver.waitForSelector({ + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }); + }, + ); + }); +}); + +describe('Send ETH from inside MetaMask using fast gas option', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1'); + + const inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Set the gas price + await driver.clickElement({ text: 'Fast', tag: 'button/div/div' }); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item', + ); + await driver.waitForSelector({ + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }); + }, + ); + }); +}); + +describe('Send ETH from inside MetaMask using advanced gas modal', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1'); + + const inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Set the gas limit + await driver.clickElement('.advanced-gas-options-btn'); + + // wait for gas modal to be visible + const gasModal = await driver.findVisibleElement('span .modal'); + + await driver.clickElement({ text: 'Save', tag: 'button' }); + + // Wait for gas modal to be removed from DOM + await gasModal.waitForElementState('hidden'); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + const transactionAmounts = await driver.findElements( + '.currency-display-component__text', + ); + const transactionAmount = transactionAmounts[0]; + assert.equal(await transactionAmount.getText(), '1'); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .transaction-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + + await driver.waitForSelector( + { + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }, + { timeout: 10000 }, + ); + }, + ); + }); +}); 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 8d7f67965..3c2325d48 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -10,7 +10,7 @@ import InfoIcon from '../../ui/icon/info-icon.component'; import Button from '../../ui/button'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useMetricEvent } from '../../../hooks/useMetricEvent'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; @@ -69,13 +69,17 @@ const AssetListItem = ({ e.stopPropagation(); sendTokenEvent(); dispatch( - updateSendToken({ - address: tokenAddress, - decimals: tokenDecimals, - symbol: tokenSymbol, + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { + address: tokenAddress, + decimals: tokenDecimals, + symbol: tokenSymbol, + }, }), - ); - history.push(SEND_ROUTE); + ).then(() => { + history.push(SEND_ROUTE); + }); }} > {t('sendSpecifiedTokens', [tokenSymbol])} diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js index 6b36af651..d82a8fb0a 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js @@ -9,10 +9,10 @@ import { } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, -} from '../../../../ducks/send/send.duck'; + useCustomGas, + updateGasLimit, + updateGasPrice, +} from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -32,8 +32,6 @@ jest.mock('../../../../selectors', () => ({ `mockRenderableBasicEstimateData:${Object.keys(s).length}`, getDefaultActiveButtonIndex: (a, b) => a + b, getCurrentEthBalance: (state) => state.metamask.balance || '0x0', - getSendToken: () => null, - getTokenBalance: (state) => state.send.tokenBalance || '0x0', getCustomGasPrice: (state) => state.gas.customData.price || '0x0', getCustomGasLimit: (state) => state.gas.customData.limit || '0x0', getCurrentCurrency: jest.fn().mockReturnValue('usd'), @@ -57,11 +55,15 @@ jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - hideGasButtonGroup: jest.fn(), - setGasLimit: jest.fn(), - setGasPrice: jest.fn(), -})); +jest.mock('../../../../ducks/send', () => { + const { ASSET_TYPES } = jest.requireActual('../../../../ducks/send'); + return { + useCustomGas: jest.fn(), + updateGasLimit: jest.fn(), + updateGasPrice: jest.fn(), + getSendAsset: jest.fn(() => ({ type: ASSET_TYPES.NATIVE })), + }; +}); require('./gas-modal-page-container.container'); @@ -79,11 +81,11 @@ describe('gas-modal-page-container container', () => { dispatchSpy.resetHistory(); }); - describe('hideGasButtonGroup()', () => { - it('should dispatch a hideGasButtonGroup action', () => { - mapDispatchToPropsObject.hideGasButtonGroup(); + describe('useCustomGas()', () => { + it('should dispatch a useCustomGas action', () => { + mapDispatchToPropsObject.useCustomGas(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(hideGasButtonGroup).toHaveBeenCalled(); + expect(useCustomGas).toHaveBeenCalled(); }); }); @@ -126,13 +128,13 @@ describe('gas-modal-page-container container', () => { }); describe('setGasData()', () => { - it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => { + it('should dispatch a updateGasPrice and updateGasLimit action with the correct props', () => { mapDispatchToPropsObject.setGasData('ffff', 'aaaa'); expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalledWith('ffff'); - expect(setGasPrice).toHaveBeenCalledWith('aaaa'); + expect(updateGasPrice).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalledWith('ffff'); + expect(updateGasPrice).toHaveBeenCalledWith('aaaa'); }); }); @@ -165,7 +167,7 @@ describe('gas-modal-page-container container', () => { }; dispatchProps = { updateCustomGasPrice: sinon.spy(), - hideGasButtonGroup: sinon.spy(), + useCustomGas: sinon.spy(), setGasData: sinon.spy(), updateConfirmTxGasAndCalculate: sinon.spy(), someOtherDispatchProp: sinon.spy(), @@ -194,7 +196,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(0); result.onSubmit(); @@ -203,7 +205,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(1); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -238,7 +240,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(0); result.onSubmit('mockNewLimit', 'mockNewPrice'); @@ -251,7 +253,7 @@ describe('gas-modal-page-container container', () => { 'mockNewLimit', 'mockNewPrice', ]); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(1); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(1); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -278,7 +280,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.createSpeedUpTransaction.callCount).toStrictEqual(1); diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js index fc94176a0..95660b8b4 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -14,19 +14,21 @@ import { fetchBasicGasEstimates, } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, - setGasTotal, - updateSendAmount, - updateSendErrors, -} from '../../../../ducks/send/send.duck'; + getSendMaxModeState, + getGasLimit, + getGasPrice, + getSendAmount, + updateGasLimit, + updateGasPrice, + useCustomGas, + getSendAsset, + ASSET_TYPES, +} from '../../../../ducks/send'; import { conversionRateSelector as getConversionRate, getCurrentCurrency, getCurrentEthBalance, getIsMainnet, - getSendToken, getPreferences, getIsTestnet, getBasicGasEstimateLoadingStatus, @@ -35,8 +37,6 @@ import { getDefaultActiveButtonIndex, getRenderableBasicEstimateData, isCustomPriceSafe, - getTokenBalance, - getSendMaxModeState, isCustomPriceSafeForCustomNetwork, getAveragePriceEstimateInHexWEI, isCustomPriceExcessive, @@ -57,16 +57,15 @@ import { isBalanceSufficient, } from '../../../../pages/send/send.utils'; import { MIN_GAS_LIMIT_DEC } from '../../../../pages/send/send.constants'; -import { calcMaxAmount } from '../../../../pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils'; import { TRANSACTION_STATUSES } from '../../../../../shared/constants/transaction'; import { GAS_LIMITS } from '../../../../../shared/constants/gas'; import GasModalPageContainer from './gas-modal-page-container.component'; const mapStateToProps = (state, ownProps) => { - const { - metamask: { currentNetworkTxList }, - send, - } = state; + const gasLimit = getGasLimit(state); + const gasPrice = getGasPrice(state); + const amount = getSendAmount(state); + const { currentNetworkTxList } = state.metamask; const { modalState: { props: modalProps } = {} } = state.appState.modal || {}; const { txData = {} } = modalProps || {}; const { transaction = {}, onSubmit } = ownProps; @@ -74,15 +73,15 @@ const mapStateToProps = (state, ownProps) => { ({ id }) => id === (transaction.id || txData.id), ); const buttonDataLoading = getBasicGasEstimateLoadingStatus(state); - const sendToken = getSendToken(state); + const asset = getSendAsset(state); // a "default" txParams is used during the send flow, since the transaction doesn't exist yet in that case const txParams = selectedTransaction?.txParams ? selectedTransaction.txParams : { - gas: send.gasLimit || GAS_LIMITS.SIMPLE, - gasPrice: send.gasPrice || getAveragePriceEstimateInHexWEI(state, true), - value: sendToken ? '0x0' : send.amount, + gas: gasLimit || GAS_LIMITS.SIMPLE, + gasPrice: gasPrice || getAveragePriceEstimateInHexWEI(state, true), + value: asset.type === ASSET_TYPES.TOKEN ? '0x0' : amount, }; const { gasPrice: currentGasPrice, gas: currentGasLimit } = txParams; @@ -120,16 +119,15 @@ const mapStateToProps = (state, ownProps) => { const isMainnet = getIsMainnet(state); const showFiat = Boolean(isMainnet || showFiatInTestnets); - const isSendTokenSet = Boolean(sendToken); const isTestnet = getIsTestnet(state); const newTotalEth = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? sumHexWEIsToRenderableEth([balance, '0x0']) : sumHexWEIsToRenderableEth([value, customGasTotal]); const sendAmount = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) : sumHexWEIsToRenderableEth([value, '0x0']); @@ -194,9 +192,7 @@ const mapStateToProps = (state, ownProps) => { txId: transaction.id, insufficientBalance, isMainnet, - sendToken, balance, - tokenBalance: getTokenBalance(state), conversionRate, value, onSubmit, @@ -213,12 +209,13 @@ const mapDispatchToProps = (dispatch) => { dispatch(hideModal()); }, hideModal: () => dispatch(hideModal()), + useCustomGas: () => dispatch(useCustomGas()), updateCustomGasPrice, updateCustomGasLimit: (newLimit) => dispatch(setCustomGasLimit(addHexPrefix(newLimit))), setGasData: (newLimit, newPrice) => { - dispatch(setGasLimit(newLimit)); - dispatch(setGasPrice(newPrice)); + dispatch(updateGasLimit(newLimit)); + dispatch(updateGasPrice(newPrice)); }, updateConfirmTxGasAndCalculate: (gasLimit, gasPrice, updatedTx) => { updateCustomGasPrice(gasPrice); @@ -231,14 +228,8 @@ const mapDispatchToProps = (dispatch) => { createSpeedUpTransaction: (txId, gasPrice, gasLimit) => { return dispatch(createSpeedUpTransaction(txId, gasPrice, gasLimit)); }, - hideGasButtonGroup: () => dispatch(hideGasButtonGroup()), hideSidebar: () => dispatch(hideSidebar()), fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()), - setGasTotal: (total) => dispatch(setGasTotal(total)), - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, }; }; @@ -251,17 +242,12 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { isSpeedUp, isRetry, insufficientBalance, - maxModeOn, customGasPrice, - customGasTotal, - balance, - sendToken, - tokenBalance, customGasLimit, transaction, } = stateProps; const { - hideGasButtonGroup: dispatchHideGasButtonGroup, + useCustomGas: dispatchUseCustomGas, setGasData: dispatchSetGasData, updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate, createSpeedUpTransaction: dispatchCreateSpeedUpTransaction, @@ -269,7 +255,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { hideSidebar: dispatchHideSidebar, cancelAndClose: dispatchCancelAndClose, hideModal: dispatchHideModal, - setAmountToMax: dispatchSetAmountToMax, ...otherDispatchProps } = dispatchProps; @@ -305,17 +290,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { dispatchCancelAndClose(); } else { dispatchSetGasData(gasLimit, gasPrice); - dispatchHideGasButtonGroup(); + dispatchUseCustomGas(); dispatchCancelAndClose(); } - if (maxModeOn) { - dispatchSetAmountToMax({ - balance, - gasTotal: customGasTotal, - sendToken, - tokenBalance, - }); - } }, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index f34cc3900..e40003067 100644 --- a/ui/components/app/wallet-overview/token-overview.js +++ b/ui/components/app/wallet-overview/token-overview.js @@ -17,7 +17,7 @@ import { } from '../../../hooks/useMetricEvent'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; import { getAssetImages, @@ -85,8 +85,14 @@ const TokenOverview = ({ className, token }) => { className="token-overview__button" onClick={() => { sendTokenEvent(); - dispatch(updateSendToken(token)); - history.push(SEND_ROUTE); + dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: token, + }), + ).then(() => { + history.push(SEND_ROUTE); + }); }} Icon={SendIcon} label={t('send')} diff --git a/ui/components/ui/unit-input/unit-input.component.js b/ui/components/ui/unit-input/unit-input.component.js index 8eeb39e1b..78458cab6 100644 --- a/ui/components/ui/unit-input/unit-input.component.js +++ b/ui/components/ui/unit-input/unit-input.component.js @@ -1,7 +1,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { removeLeadingZeroes } from '../../../pages/send/send.utils'; + +function removeLeadingZeroes(str) { + return str.replace(/^0*(?=\d)/u, ''); +} /** * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index 7a1cafd80..bb23afef8 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -15,10 +15,11 @@ import { getNumberOfAccounts, getNumberOfTokens, } from '../selectors/selectors'; -import { getSendToken } from '../selectors/send'; +import { getSendAsset, ASSET_TYPES } from '../ducks/send'; import { txDataSelector } from '../selectors/confirm-transaction'; import { getEnvironmentType } from '../../app/scripts/lib/util'; import { trackMetaMetricsEvent } from '../store/actions'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; export const MetaMetricsContext = createContext(() => { captureException( @@ -31,7 +32,8 @@ export const MetaMetricsContext = createContext(() => { export function MetaMetricsProvider({ children }) { const txData = useSelector(txDataSelector) || {}; const environmentType = getEnvironmentType(); - const activeCurrency = useSelector(getSendToken)?.symbol; + const activeAsset = useSelector(getSendAsset); + const nativeAssetSymbol = useSelector(getNativeCurrency); const accountType = useSelector(getAccountType); const confirmTransactionOrigin = txData.origin; const numberOfTokens = useSelector(getNumberOfTokens); @@ -72,7 +74,10 @@ export function MetaMetricsProvider({ children }) { action: eventOpts.action, number_of_tokens: numberOfTokens, number_of_accounts: numberOfAccounts, - active_currency: activeCurrency, + active_currency: + activeAsset.type === ASSET_TYPES.NATIVE + ? nativeAssetSymbol + : activeAsset?.details?.symbol, account_type: accountType, is_new_visit: config.is_new_visit, // the properties coming from this key will not match our standards for @@ -102,7 +107,8 @@ export function MetaMetricsProvider({ children }) { accountType, currentPath, confirmTransactionOrigin, - activeCurrency, + activeAsset, + nativeAssetSymbol, numberOfTokens, numberOfAccounts, environmentType, diff --git a/ui/ducks/ens.js b/ui/ducks/ens.js new file mode 100644 index 000000000..de72739e6 --- /dev/null +++ b/ui/ducks/ens.js @@ -0,0 +1,197 @@ +import { createSlice } from '@reduxjs/toolkit'; +import ENS from 'ethjs-ens'; +import log from 'loglevel'; +import networkMap from 'ethereum-ens-network-map'; +import { isConfusing } from 'unicode-confusables'; +import { isHexString } from 'ethereumjs-util'; + +import { getCurrentChainId } from '../selectors'; +import { + CHAIN_ID_TO_NETWORK_ID_MAP, + MAINNET_NETWORK_ID, +} from '../../shared/constants/network'; +import { + CONFUSING_ENS_ERROR, + ENS_ILLEGAL_CHARACTER, + ENS_NOT_FOUND_ON_NETWORK, + ENS_NOT_SUPPORTED_ON_NETWORK, + ENS_NO_ADDRESS_FOR_NAME, + ENS_REGISTRATION_ERROR, + ENS_UNKNOWN_ERROR, +} from '../pages/send/send.constants'; +import { isValidDomainName } from '../helpers/utils/util'; +import { CHAIN_CHANGED } from '../store/actionConstants'; +import { + BURN_ADDRESS, + isBurnAddress, + isValidHexAddress, +} from '../../shared/modules/hexstring-utils'; + +// Local Constants +const ZERO_X_ERROR_ADDRESS = '0x'; + +const initialState = { + stage: 'UNINITIALIZED', + resolution: null, + error: null, + warning: null, + network: null, +}; + +export const ensInitialState = initialState; + +const name = 'ENS'; + +let ens = null; + +const slice = createSlice({ + name, + initialState, + reducers: { + ensLookup: (state, action) => { + // first clear out the previous state + state.resolution = null; + state.error = null; + state.warning = null; + const { address, ensName, error, network } = action.payload; + + if (error) { + if ( + isValidDomainName(ensName) && + error.message === 'ENS name not defined.' + ) { + state.error = + network === MAINNET_NETWORK_ID + ? ENS_NO_ADDRESS_FOR_NAME + : ENS_NOT_FOUND_ON_NETWORK; + } else if (error.message === 'Illegal Character for ENS.') { + state.error = ENS_ILLEGAL_CHARACTER; + } else { + log.error(error); + state.error = ENS_UNKNOWN_ERROR; + } + } else if (address) { + if (address === BURN_ADDRESS) { + state.error = ENS_NO_ADDRESS_FOR_NAME; + } else if (address === ZERO_X_ERROR_ADDRESS) { + state.error = ENS_REGISTRATION_ERROR; + } else { + state.resolution = address; + } + if (isValidDomainName(address) && isConfusing(address)) { + state.warning = CONFUSING_ENS_ERROR; + } + } + }, + enableEnsLookup: (state, action) => { + state.stage = 'INITIALIZED'; + state.error = null; + state.resolution = null; + state.warning = null; + state.network = action.payload; + }, + disableEnsLookup: (state) => { + state.stage = 'NO_NETWORK_SUPPORT'; + state.error = ENS_NOT_SUPPORTED_ON_NETWORK; + state.warning = null; + state.resolution = null; + state.network = null; + }, + resetResolution: (state) => { + state.resolution = null; + state.warning = null; + state.error = + state.stage === 'NO_NETWORK_SUPPORT' + ? ENS_NOT_SUPPORTED_ON_NETWORK + : null; + }, + }, + extraReducers: (builder) => { + builder.addCase(CHAIN_CHANGED, (state, action) => { + if (action.payload !== state.currentChainId) { + state.stage = 'UNINITIALIZED'; + ens = null; + } + }); + }, +}); + +const { reducer, actions } = slice; +export default reducer; + +const { + disableEnsLookup, + ensLookup, + enableEnsLookup, + resetResolution, +} = actions; +export { resetResolution }; + +export function initializeEnsSlice() { + return (dispatch, getState) => { + const state = getState(); + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + const networkIsSupported = Boolean(networkMap[network]); + if (networkIsSupported) { + ens = new ENS({ provider: global.ethereumProvider, network }); + dispatch(enableEnsLookup(network)); + } else { + ens = null; + dispatch(disableEnsLookup()); + } + }; +} + +export function lookupEnsName(ensName) { + return async (dispatch, getState) => { + const trimmedEnsName = ensName.trim(); + let state = getState(); + if (state[name].stage === 'UNINITIALIZED') { + await dispatch(initializeEnsSlice()); + } + state = getState(); + if ( + state[name].stage === 'NO_NETWORK_SUPPORT' && + !( + isBurnAddress(trimmedEnsName) === false && + isValidHexAddress(trimmedEnsName, { mixedCaseUseChecksum: true }) + ) && + !isHexString(trimmedEnsName) + ) { + await dispatch(resetResolution()); + } else { + log.info(`ENS attempting to resolve name: ${trimmedEnsName}`); + let address; + let error; + try { + address = await ens.lookup(trimmedEnsName); + } catch (err) { + error = err; + } + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + await dispatch( + ensLookup({ + ensName: trimmedEnsName, + address, + error, + chainId, + network, + }), + ); + } + }; +} + +export function getEnsResolution(state) { + return state[name].resolution; +} + +export function getEnsError(state) { + return state[name].error; +} + +export function getEnsWarning(state) { + return state[name].warning; +} diff --git a/ui/ducks/gas/gas-action-constants.js b/ui/ducks/gas/gas-action-constants.js new file mode 100644 index 000000000..19cb16ee7 --- /dev/null +++ b/ui/ducks/gas/gas-action-constants.js @@ -0,0 +1,14 @@ +// This file has been separated because it is required in both the gas and send +// slices. This created a circular dependency problem as both slices also +// import from the actions and selectors files. This easiest path for +// untangling is having the constants separate. + +// Actions +export const BASIC_GAS_ESTIMATE_STATUS = + 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; +export const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; +export const SET_BASIC_GAS_ESTIMATE_DATA = + 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; +export const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; +export const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; +export const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; diff --git a/ui/ducks/gas/gas-duck.test.js b/ui/ducks/gas/gas-duck.test.js index d4301f9b3..221e4dbd8 100644 --- a/ui/ducks/gas/gas-duck.test.js +++ b/ui/ducks/gas/gas-duck.test.js @@ -10,6 +10,14 @@ import GasReducer, { fetchBasicGasEstimates, } from './gas.duck'; +import { + BASIC_GAS_ESTIMATE_STATUS, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_PRICE, + SET_CUSTOM_GAS_LIMIT, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; + jest.mock('../../helpers/utils/storage-helpers.js', () => ({ getStorageItem: jest.fn(), setStorageItem: jest.fn(), @@ -61,13 +69,6 @@ describe('Gas Duck', () => { type: 'mainnet', }; - const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; - const SET_BASIC_GAS_ESTIMATE_DATA = - 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; - const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; - const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; - const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - describe('GasReducer()', () => { it('should initialize state', () => { expect(GasReducer(undefined, {})).toStrictEqual(initState); diff --git a/ui/ducks/gas/gas.duck.js b/ui/ducks/gas/gas.duck.js index e991e5e73..a41c313c5 100644 --- a/ui/ducks/gas/gas.duck.js +++ b/ui/ducks/gas/gas.duck.js @@ -10,6 +10,14 @@ import { } from '../../helpers/utils/conversions.util'; import { getIsMainnet, getCurrentChainId } from '../../selectors'; import fetchWithCache from '../../helpers/utils/fetch-with-cache'; +import { + BASIC_GAS_ESTIMATE_STATUS, + RESET_CUSTOM_DATA, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_LIMIT, + SET_CUSTOM_GAS_PRICE, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; export const BASIC_ESTIMATE_STATES = { LOADING: 'LOADING', @@ -22,14 +30,6 @@ export const GAS_SOURCE = { ETHGASPRICE: 'eth_gasprice', }; -// Actions -const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; -const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; -const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; -const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; -const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; -const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - const initState = { customData: { price: null, diff --git a/ui/ducks/index.js b/ui/ducks/index.js index bae560536..11b525e4c 100644 --- a/ui/ducks/index.js +++ b/ui/ducks/index.js @@ -2,7 +2,8 @@ import { combineReducers } from 'redux'; import { ALERT_TYPES } from '../../shared/constants/alerts'; import metamaskReducer from './metamask/metamask'; import localeMessagesReducer from './locale/locale'; -import sendReducer from './send/send.duck'; +import sendReducer from './send/send'; +import ensReducer from './ens'; import appStateReducer from './app/app'; import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck'; import gasReducer from './gas/gas.duck'; @@ -16,6 +17,7 @@ export default combineReducers({ activeTab: (s) => (s === undefined ? null : s), metamask: metamaskReducer, appState: appStateReducer, + ENS: ensReducer, history: historyReducer, send: sendReducer, confirmTransaction: confirmTransactionReducer, diff --git a/ui/ducks/send/index.js b/ui/ducks/send/index.js new file mode 100644 index 000000000..d1ab99c82 --- /dev/null +++ b/ui/ducks/send/index.js @@ -0,0 +1 @@ +export * from './send'; diff --git a/ui/ducks/send/send-duck.test.js b/ui/ducks/send/send-duck.test.js deleted file mode 100644 index 7c05e8689..000000000 --- a/ui/ducks/send/send-duck.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import SendReducer, { - openToDropdown, - closeToDropdown, - updateSendErrors, - showGasButtonGroup, - hideGasButtonGroup, -} from './send.duck'; - -describe('Send Duck', () => { - const mockState = { - mockProp: 123, - }; - const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, - }; - const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; - const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; - const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; - const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; - const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; - const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; - - describe('SendReducer()', () => { - it('should initialize state', () => { - expect(SendReducer(undefined, {})).toStrictEqual(initState); - }); - - it('should return state unchanged if it does not match a dispatched actions type', () => { - expect( - SendReducer(mockState, { - type: 'someOtherAction', - value: 'someValue', - }), - ).toStrictEqual(mockState); - }); - - it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: OPEN_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: true, ...mockState }); - }); - - it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: CLOSE_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: false, ...mockState }); - }); - - it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer( - { ...mockState, gasButtonGroupShown: false }, - { type: SHOW_GAS_BUTTON_GROUP }, - ), - ).toStrictEqual({ gasButtonGroupShown: true, ...mockState }); - }); - - it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer(mockState, { type: HIDE_GAS_BUTTON_GROUP }), - ).toStrictEqual({ gasButtonGroupShown: false, ...mockState }); - }); - - it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => { - const modifiedMockState = { - ...mockState, - errors: { - someError: false, - }, - }; - expect( - SendReducer(modifiedMockState, { - type: UPDATE_SEND_ERRORS, - value: { someOtherError: true }, - }), - ).toStrictEqual({ - ...modifiedMockState, - errors: { - someError: false, - someOtherError: true, - }, - }); - }); - - it('should return the initial state in response to a RESET_SEND_STATE action', () => { - expect( - SendReducer(mockState, { - type: RESET_SEND_STATE, - }), - ).toStrictEqual(initState); - }); - }); - - describe('Send Duck Actions', () => { - it('calls openToDropdown action', () => { - expect(openToDropdown()).toStrictEqual({ type: OPEN_TO_DROPDOWN }); - }); - - it('calls closeToDropdown action', () => { - expect(closeToDropdown()).toStrictEqual({ type: CLOSE_TO_DROPDOWN }); - }); - - it('calls showGasButtonGroup action', () => { - expect(showGasButtonGroup()).toStrictEqual({ - type: SHOW_GAS_BUTTON_GROUP, - }); - }); - - it('calls hideGasButtonGroup action', () => { - expect(hideGasButtonGroup()).toStrictEqual({ - type: HIDE_GAS_BUTTON_GROUP, - }); - }); - - it('calls updateSendErrors action', () => { - expect(updateSendErrors('mockErrorObject')).toStrictEqual({ - type: UPDATE_SEND_ERRORS, - value: 'mockErrorObject', - }); - }); - }); -}); diff --git a/ui/ducks/send/send.duck.js b/ui/ducks/send/send.duck.js deleted file mode 100644 index 82d9b9d82..000000000 --- a/ui/ducks/send/send.duck.js +++ /dev/null @@ -1,382 +0,0 @@ -import log from 'loglevel'; -import { estimateGas } from '../../store/actions'; -import { setCustomGasLimit } from '../gas/gas.duck'; -import { - estimateGasForSend, - calcTokenBalance, -} from '../../pages/send/send.utils'; - -// Actions -const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; -const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; -const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; -const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; -const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; -const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; -const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT'; -const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE'; -const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL'; -const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA'; -const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE'; -const UPDATE_SEND_TO = 'UPDATE_SEND_TO'; -const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT'; -const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE'; -const UPDATE_SEND = 'UPDATE_SEND'; -const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN'; -const CLEAR_SEND = 'CLEAR_SEND'; -const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED'; -const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED'; -const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION'; -const UPDATE_SEND_ENS_RESOLUTION_ERROR = 'UPDATE_SEND_ENS_RESOLUTION_ERROR'; - -const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, -}; - -// Reducer -export default function reducer(state = initState, action) { - switch (action.type) { - case OPEN_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: true, - }; - case CLOSE_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: false, - }; - case UPDATE_SEND_ERRORS: - return { - ...state, - errors: { - ...state.errors, - ...action.value, - }, - }; - case SHOW_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: true, - }; - case HIDE_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: false, - }; - case UPDATE_GAS_LIMIT: - return { - ...state, - gasLimit: action.value, - }; - case UPDATE_GAS_PRICE: - return { - ...state, - gasPrice: action.value, - }; - case RESET_SEND_STATE: - return { ...initState }; - case UPDATE_GAS_TOTAL: - return { - ...state, - gasTotal: action.value, - }; - case UPDATE_SEND_TOKEN_BALANCE: - return { - ...state, - tokenBalance: action.value, - }; - case UPDATE_SEND_HEX_DATA: - return { - ...state, - data: action.value, - }; - case UPDATE_SEND_TO: - return { - ...state, - to: action.value.to, - toNickname: action.value.nickname, - }; - case UPDATE_SEND_AMOUNT: - return { - ...state, - amount: action.value, - }; - case UPDATE_MAX_MODE: - return { - ...state, - maxModeOn: action.value, - }; - case UPDATE_SEND: - return Object.assign(state, action.value); - case UPDATE_SEND_TOKEN: { - const newSend = { - ...state, - token: action.value, - }; - // erase token-related state when switching back to native currency - if (newSend.editingTransactionId && !newSend.token) { - const unapprovedTx = - newSend?.unapprovedTxs?.[newSend.editingTransactionId] || {}; - const txParams = unapprovedTx.txParams || {}; - Object.assign(newSend, { - tokenBalance: null, - balance: '0', - from: unapprovedTx.from || '', - unapprovedTxs: { - ...newSend.unapprovedTxs, - [newSend.editingTransactionId]: { - ...unapprovedTx, - txParams: { - ...txParams, - data: '', - }, - }, - }, - }); - } - return Object.assign(state, newSend); - } - case UPDATE_SEND_ENS_RESOLUTION: - return { - ...state, - ensResolution: action.payload, - ensResolutionError: '', - }; - case UPDATE_SEND_ENS_RESOLUTION_ERROR: - return { - ...state, - ensResolution: null, - ensResolutionError: action.payload, - }; - case CLEAR_SEND: - return { - ...state, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - }; - case GAS_LOADING_STARTED: - return { - ...state, - gasIsLoading: true, - }; - - case GAS_LOADING_FINISHED: - return { - ...state, - gasIsLoading: false, - }; - default: - return state; - } -} - -// Action Creators -export function openToDropdown() { - return { type: OPEN_TO_DROPDOWN }; -} - -export function closeToDropdown() { - return { type: CLOSE_TO_DROPDOWN }; -} - -export function showGasButtonGroup() { - return { type: SHOW_GAS_BUTTON_GROUP }; -} - -export function hideGasButtonGroup() { - return { type: HIDE_GAS_BUTTON_GROUP }; -} - -export function updateSendErrors(errorObject) { - return { - type: UPDATE_SEND_ERRORS, - value: errorObject, - }; -} - -export function resetSendState() { - return { type: RESET_SEND_STATE }; -} - -export function setGasLimit(gasLimit) { - return { - type: UPDATE_GAS_LIMIT, - value: gasLimit, - }; -} - -export function setGasPrice(gasPrice) { - return { - type: UPDATE_GAS_PRICE, - value: gasPrice, - }; -} - -export function setGasTotal(gasTotal) { - return { - type: UPDATE_GAS_TOTAL, - value: gasTotal, - }; -} - -export function updateGasData({ - gasPrice, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - data, -}) { - return (dispatch) => { - dispatch(gasLoadingStarted()); - return estimateGasForSend({ - estimateGasMethod: estimateGas, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - estimateGasPrice: gasPrice, - data, - }) - .then((gas) => { - dispatch(setGasLimit(gas)); - dispatch(setCustomGasLimit(gas)); - dispatch(updateSendErrors({ gasLoadingError: null })); - dispatch(gasLoadingFinished()); - }) - .catch((err) => { - log.error(err); - dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })); - dispatch(gasLoadingFinished()); - }); - }; -} - -export function gasLoadingStarted() { - return { - type: GAS_LOADING_STARTED, - }; -} - -export function gasLoadingFinished() { - return { - type: GAS_LOADING_FINISHED, - }; -} - -export function updateSendTokenBalance({ sendToken, tokenContract, address }) { - return (dispatch) => { - const tokenBalancePromise = tokenContract - ? tokenContract.balanceOf(address) - : Promise.resolve(); - return tokenBalancePromise - .then((usersToken) => { - if (usersToken) { - const newTokenBalance = calcTokenBalance({ sendToken, usersToken }); - dispatch(setSendTokenBalance(newTokenBalance)); - } - }) - .catch((err) => { - log.error(err); - updateSendErrors({ tokenBalance: 'tokenBalanceError' }); - }); - }; -} - -export function setSendTokenBalance(tokenBalance) { - return { - type: UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, - }; -} - -export function updateSendHexData(value) { - return { - type: UPDATE_SEND_HEX_DATA, - value, - }; -} - -export function updateSendTo(to, nickname = '') { - return { - type: UPDATE_SEND_TO, - value: { to, nickname }, - }; -} - -export function updateSendAmount(amount) { - return { - type: UPDATE_SEND_AMOUNT, - value: amount, - }; -} - -export function setMaxModeTo(bool) { - return { - type: UPDATE_MAX_MODE, - value: bool, - }; -} - -export function updateSend(newSend) { - return { - type: UPDATE_SEND, - value: newSend, - }; -} - -export function updateSendToken(token) { - return { - type: UPDATE_SEND_TOKEN, - value: token, - }; -} - -export function clearSend() { - return { - type: CLEAR_SEND, - }; -} - -export function updateSendEnsResolution(ensResolution) { - return { - type: UPDATE_SEND_ENS_RESOLUTION, - payload: ensResolution, - }; -} - -export function updateSendEnsResolutionError(errorMessage) { - return { - type: UPDATE_SEND_ENS_RESOLUTION_ERROR, - payload: errorMessage, - }; -} diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js new file mode 100644 index 000000000..3ed41512b --- /dev/null +++ b/ui/ducks/send/send.js @@ -0,0 +1,1472 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import abi from 'human-standard-token-abi'; +import contractMap from '@metamask/contract-metadata'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; +import { debounce } from 'lodash'; +import { + conversionGreaterThan, + conversionUtil, + multiplyCurrencies, + subtractCurrencies, +} from '../../helpers/utils/conversion-util'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + MIN_GAS_LIMIT_HEX, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; + +import { + addGasBuffer, + calcGasTotal, + generateTokenTransferData, + isBalanceSufficient, + isTokenBalanceSufficient, +} from '../../pages/send/send.utils'; +import { + getAddressBookEntry, + getAdvancedInlineGasShown, + getCurrentChainId, + getGasPriceInHexWei, + getIsMainnet, + getSelectedAddress, + getTargetAccount, +} from '../../selectors'; +import { + displayWarning, + estimateGas, + hideLoadingIndication, + showConfTxPage, + showLoadingIndication, + updateTokenType, + updateTransaction, +} from '../../store/actions'; +import { + fetchBasicGasEstimates, + setCustomGasLimit, + BASIC_ESTIMATE_STATES, +} from '../gas/gas.duck'; +import { + SET_BASIC_GAS_ESTIMATE_DATA, + BASIC_GAS_ESTIMATE_STATUS, +} from '../gas/gas-action-constants'; +import { + QR_CODE_DETECTED, + SELECTED_ACCOUNT_CHANGED, + ACCOUNT_CHANGED, + ADDRESS_BOOK_UPDATED, +} from '../../store/actionConstants'; +import { + calcTokenAmount, + getTokenAddressParam, + getTokenValueParam, +} from '../../helpers/utils/token-util'; +import { + checkExistingAddresses, + isDefaultMetaMaskChain, + isOriginContractAddress, + isValidDomainName, +} from '../../helpers/utils/util'; +import { getTokens, getUnapprovedTxs } from '../metamask/metamask'; +import { resetResolution } from '../ens'; +import { + isBurnAddress, + isValidHexAddress, +} from '../../../shared/modules/hexstring-utils'; + +// typedefs +/** + * @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction + */ + +const name = 'send'; + +/** + * The Stages that the send slice can be in + * 1. UNINITIALIZED - The send state is idle, and hasn't yet fetched required + * data for gasPrice and gasLimit estimations, etc. + * 2. ADD_RECIPIENT - The user is selecting which address to send an asset to + * 3. DRAFT - The send form is shown for a transaction yet to be sent to the + * Transaction Controller. + * 4. EDIT - The send form is shown for a transaction already submitted to the + * Transaction Controller but not yet confirmed. This happens when a + * confirmation is shown for a transaction and the 'edit' button in the header + * is clicked. + */ +export const SEND_STAGES = { + INACTIVE: 'INACTIVE', + ADD_RECIPIENT: 'ADD_RECIPIENT', + DRAFT: 'DRAFT', + EDIT: 'EDIT', +}; + +/** + * The status that the send slice can be in is either + * 1. VALID - the transaction is valid and can be submitted + * 2. INVALID - the transaction is invalid and cannot be submitted + * + * A number of cases would result in an invalid form + * 1. The recipient is not yet defined + * 2. The amount + gasTotal is greater than the user's balance when sending + * native currency + * 3. The gasTotal is greater than the user's *native* balance + * 4. The amount of sent asset is greater than the user's *asset* balance + * 5. Gas price estimates failed to load entirely + * 6. The gasLimit is less than 21000 (0x5208) + */ +export const SEND_STATUSES = { + VALID: 'VALID', + INVALID: 'INVALID', +}; + +/** + * Controls what is displayed in the send-gas-row component. + * 1. BASIC - Shows the basic estimate slow/avg/fast buttons when on mainnet + * and the metaswaps API request is successful. + * 2. INLINE - Shows inline gasLimit/gasPrice fields when on any other network + * or metaswaps API fails and we use eth_gasPrice + * 3. CUSTOM - Shows GasFeeDisplay component that is a read only display of the + * values the user has set in the advanced gas modal (stored in the gas duck + * under the customData key). + */ +export const GAS_INPUT_MODES = { + BASIC: 'BASIC', + INLINE: 'INLINE', + CUSTOM: 'CUSTOM', +}; + +/** + * The types of assets that a user can send + * 1. NATIVE - The native asset for the current network, such as ETH + * 2. TOKEN - An ERC20 token. + */ +export const ASSET_TYPES = { + NATIVE: 'NATIVE', + TOKEN: 'TOKEN', +}; + +/** + * The modes that the amount field can be set by + * 1. INPUT - the user provides the amount by typing in the field + * 2. MAX - The user selects the MAX button and amount is calculated based on + * balance - (amount + gasTotal) + */ +export const AMOUNT_MODES = { + INPUT: 'INPUT', + MAX: 'MAX', +}; + +export const RECIPIENT_SEARCH_MODES = { + MY_ACCOUNTS: 'MY_ACCOUNTS', + CONTACT_LIST: 'CONTACT_LIST', +}; + +async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + ...options +}) { + // blockGasLimit may be a falsy, but defined, value when we receive it from + // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. + const blockGasLimit = options.blockGasLimit || MIN_GAS_LIMIT_HEX; + // The parameters below will be sent to our background process to estimate + // how much gas will be used for a transaction. That background process is + // located in tx-gas-utils.js in the transaction controller folder. + const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; + + if (sendToken) { + if (!to) { + // if no to address is provided, we cannot generate the token transfer + // hexData. hexData in a transaction largely dictates how much gas will + // be consumed by a transaction. We must use our best guess, which is + // represented in the gas shared constants. + return GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + paramsForGasEstimate.value = '0x0'; + // We have to generate the erc20 contract call to transfer tokens in + // order to get a proper estimate for gasLimit. + paramsForGasEstimate.data = generateTokenTransferData({ + toAddress: to, + amount: value, + sendToken, + }); + paramsForGasEstimate.to = sendToken.address; + } else { + if (!data) { + // eth.getCode will return the compiled smart contract code at the + // address. If this returns 0x, 0x0 or a nullish value then the address + // is an externally owned account (NOT a contract account). For these + // types of transactions the gasLimit will always be 21,000 or 0x5208 + const contractCode = Boolean(to) && (await global.eth.getCode(to)); + // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' + const contractCodeIsEmpty = + !contractCode || contractCode === '0x' || contractCode === '0x0'; + if (contractCodeIsEmpty) { + return GAS_LIMITS.SIMPLE; + } + } + + paramsForGasEstimate.data = data; + + if (to) { + paramsForGasEstimate.to = to; + } + + if (!value || value === '0') { + // TODO: Figure out what's going on here. According to eth_estimateGas + // docs this value can be zero, or undefined, yet we are setting it to a + // value here when the value is undefined or zero. For more context: + // https://github.com/MetaMask/metamask-extension/pull/6195 + paramsForGasEstimate.value = '0xff'; + } + } + + // If we do not yet have a gasLimit, we must call into our background + // process to get an estimate for gasLimit based on known parameters. + + paramsForGasEstimate.gas = addHexPrefix( + multiplyCurrencies(blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }), + ); + try { + // call into the background process that will simulate transaction + // execution on the node and return an estimate of gasLimit + const estimatedGasLimit = await estimateGas(paramsForGasEstimate); + const estimateWithBuffer = addGasBuffer( + estimatedGasLimit, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } catch (error) { + const simulationFailed = + error.message.includes('Transaction execution error.') || + error.message.includes( + 'gas required exceeds allowance or always failing transaction', + ); + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer( + paramsForGasEstimate.gas, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } + throw error; + } +} + +export async function getERC20Balance(token, accountAddress) { + const contract = global.eth.contract(abi).at(token.address); + const usersToken = (await contract.balanceOf(accountAddress)) ?? null; + if (!usersToken) { + return '0x0'; + } + const amount = calcTokenAmount( + usersToken.balance.toString(), + token.decimals, + ).toString(16); + return addHexPrefix(amount); +} + +// After modification of specific fields in specific circumstances we must +// recompute the gasLimit estimate to be as accurate as possible. the cases +// that necessitate this logic are listed below: +// 1. when the amount sent changes when sending a token due to the amount being +// part of the hex encoded data property of the transaction. +// 2. when updating the data property while sending NATIVE currency (ex: ETH) +// because the data parameter defines function calls that the EVM will have +// to execute which is where a large chunk of gas is potentially consumed. +// 3. when the recipient changes while sending a token due to the recipient's +// address being included in the hex encoded data property of the +// transaction +// 4. when the asset being sent changes due to the contract address and details +// of the token being included in the hex encoded data property of the +// transaction. If switching to NATIVE currency (ex: ETH), the gasLimit will +// change due to hex data being removed (unless supplied by user). +// This method computes the gasLimit estimate which is written to state in an +// action handler in extraReducers. +export const computeEstimatedGasLimit = createAsyncThunk( + 'send/computeEstimatedGasLimit', + async (_, thunkApi) => { + const { send, metamask } = thunkApi.getState(); + if (send.stage !== SEND_STAGES.EDIT) { + const gasLimit = await estimateGasLimitForSend({ + gasPrice: send.gas.gasPrice, + blockGasLimit: metamask.blockGasLimit, + selectedAddress: metamask.selectedAddress, + sendToken: send.asset.details, + to: send.recipient.address?.toLowerCase(), + value: send.amount.value, + data: send.draftTransaction.userInputHexData, + }); + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + return { + gasLimit, + }; + } + return null; + }, +); + +/** + * Responsible for initializing required state for the send slice. + * This method is dispatched from the send page in the componentDidMount + * method. It is also dispatched anytime the network changes to ensure that + * the slice remains valid with changing token and account balances. To do so + * it keys into state to get necessary values and computes a starting point for + * the send slice. It returns the values that might change from this action and + * those values are written to the slice in the `initializeSendState.fulfilled` + * action handler. + */ +export const initializeSendState = createAsyncThunk( + 'send/initializeSendState', + async (_, thunkApi) => { + const state = thunkApi.getState(); + const { + send: { asset, stage, recipient, amount, draftTransaction }, + metamask, + } = state; + // First determine the correct from address. For new sends this is always + // the currently selected account and switching accounts switches the from + // address. If editing an existing transaction (by clicking 'edit' on the + // send page), the fromAddress is always the address from the txParams. + const fromAddress = + stage === SEND_STAGES.EDIT + ? draftTransaction.txParams.from + : metamask.selectedAddress; + // We need the account's balance which is calculated from cachedBalances in + // the getMetaMaskAccounts selector. getTargetAccount consumes this + // selector and returns the account at the specified address. + const account = getTargetAccount(state, fromAddress); + // Initiate gas slices work to fetch gasPrice estimates. We need to get the + // new state after this is set to determine if initialization can proceed. + await thunkApi.dispatch(fetchBasicGasEstimates()); + const { + gas: { basicEstimateStatus, basicEstimates }, + } = thunkApi.getState(); + // Default gasPrice to 1 gwei if all estimation fails + const gasPrice = + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY + ? getGasPriceInHexWei(basicEstimates.average) + : '0x1'; + // Set a basic gasLimit in the event that other estimation fails + let gasLimit = + asset.type === ASSET_TYPES.TOKEN + ? GAS_LIMITS.BASE_TOKEN_ESTIMATE + : GAS_LIMITS.SIMPLE; + if ( + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY && + stage !== SEND_STAGES.EDIT + ) { + // Run our estimateGasLimit logic to get a more accurate estimation of + // required gas. If this value isn't nullish, set it as the new gasLimit + const estimatedGasLimit = await estimateGasLimitForSend({ + gasPrice: getGasPriceInHexWei(basicEstimates.average), + blockGasLimit: metamask.blockGasLimit, + selectedAddress: fromAddress, + sendToken: asset.details, + to: recipient.address.toLowerCase(), + value: amount.value, + data: draftTransaction.userInputHexData, + }); + gasLimit = estimatedGasLimit || gasLimit; + } + // We have to keep the gas slice in sync with the draft send transaction + // so that it'll be initialized correctly if the gas modal is opened. + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + // We must determine the balance of the asset that the transaction will be + // sending. This is done by referencing the native balance on the account + // for native assets, and calling the balanceOf method on the ERC20 + // contract for token sends. + let { balance } = account; + if (asset.type === ASSET_TYPES.TOKEN) { + if (asset.details === null) { + // If we're sending a token but details have not been provided we must + // abort and set the send slice into invalid status. + throw new Error( + 'Send slice initialized as token send without token details', + ); + } + balance = await getERC20Balance(asset.details, fromAddress); + } + return { + address: fromAddress, + nativeBalance: account.balance, + assetBalance: balance, + chainId: getCurrentChainId(state), + tokens: getTokens(state), + gasPrice, + gasLimit, + gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), + }; + }, +); + +export const initialState = { + // which stage of the send flow is the user on + stage: SEND_STAGES.UNINITIALIZED, + // status of the send slice, either VALID or INVALID + status: SEND_STATUSES.VALID, + account: { + // from account address, defaults to selected account. will be the account + // the original transaction was sent from in the case of the EDIT stage + address: null, + // balance of the from account + balance: '0x0', + }, + gas: { + // indicate whether the gas estimate is loading + isGasEstimateLoading: true, + // has the user set custom gas in the custom gas modal + isCustomGasSet: false, + // maximum gas needed for tx + gasLimit: '0x0', + // price in gwei to pay per gas + gasPrice: '0x0', + // maximum total price in gwei to pay + gasTotal: '0x0', + // minimum supported gasLimit + minimumGasLimit: GAS_LIMITS.SIMPLE, + // error to display for gas fields + error: null, + }, + amount: { + // The mode to use when determining new amounts. For INPUT mode the + // provided payload is always used. For MAX it is calculated based on avail + // asset balance + mode: AMOUNT_MODES.INPUT, + // Current value of the transaction, how much of the asset are we sending + value: '0x0', + // error to display for amount field + error: null, + }, + asset: { + // type can be either NATIVE such as ETH or TOKEN for ERC20 tokens + type: ASSET_TYPES.NATIVE, + // the balance the user holds at the from address for this asset + balance: '0x0', + // In the case of tokens, the address, decimals and symbol of the token + // will be included in details + details: null, + }, + draftTransaction: { + // The metamask internal id of the transaction. Only populated in the EDIT + // stage. + id: null, + // The hex encoded data provided by the user who has enabled hex data field + // in advanced settings + userInputHexData: null, + // The txParams that should be submitted to the network once this + // transaction is confirmed. This object is computed on every write to the + // slice of fields that would result in the txParams changing + txParams: { + to: '', + from: '', + data: undefined, + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }, + }, + recipient: { + // Defines which mode to use for searching for matches in the input field + mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + // Partial, not yet validated, entry into the address field. Used to share + // user input amongst the AddRecipient and EnsInput components. + userInput: '', + // The address of the recipient + address: '', + // The nickname stored in the user's address book for the recipient address + nickname: '', + // Error to display on the address field + error: null, + // Warning to display on the address field + warning: null, + }, +}; + +const slice = createSlice({ + name, + initialState, + reducers: { + /** + * update current amount.value in state and run post update validation of + * the amount field and the send state. Recomputes the draftTransaction + */ + updateSendAmount: (state, action) => { + state.amount.value = addHexPrefix(action.payload); + // Once amount has changed, validate the field + slice.caseReducers.validateAmountField(state); + if (state.asset.type === ASSET_TYPES.NATIVE) { + // if sending the native asset the amount being sent will impact the + // gas field as well because the gas validation takes into + // consideration the available balance minus amount sent before + // checking if there is enough left to cover the gas fee. + slice.caseReducers.validateGasField(state); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * computes the maximum amount of asset that can be sent and then calls + * the updateSendAmount action above with the computed value, which will + * revalidate the field and form and recomputes the draftTransaction + */ + updateAmountToMax: (state) => { + let amount = '0x0'; + if (state.asset.type === ASSET_TYPES.TOKEN) { + const decimals = state.asset.details?.decimals ?? 0; + const multiplier = Math.pow(10, Number(decimals)); + + amount = multiplyCurrencies(state.asset.balance, multiplier, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }); + } else { + amount = subtractCurrencies( + addHexPrefix(state.asset.balance), + addHexPrefix(state.gas.gasTotal), + { + toNumericBase: 'hex', + aBase: 16, + bBase: 16, + }, + ); + } + slice.caseReducers.updateSendAmount(state, { + payload: amount, + }); + // draftTransaction update happens in updateSendAmount + }, + /** + * updates the draftTransaction.userInputHexData state key and then + * recomputes the draftTransaction if the user is currently sending the + * native asset. When sending ERC20 assets, this is unnecessary because the + * hex data used in the transaction will be that for interacting with the + * ERC20 contract + */ + updateUserInputHexData: (state, action) => { + state.draftTransaction.userInputHexData = action.payload; + if (state.asset.type === ASSET_TYPES.NATIVE) { + slice.caseReducers.updateDraftTransaction(state); + } + }, + /** + * Initiates the edit transaction flow by setting the stage to 'EDIT' and + * then pulling the details of the previously submitted transaction from + * the action payload. It also computes a new draftTransaction that will be + * used when updating the transaction in the provider + */ + editTransaction: (state, action) => { + state.stage = SEND_STAGES.EDIT; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.amount.value = action.payload.amount; + state.gas.error = null; + state.amount.error = null; + state.recipient.address = action.payload.address; + state.recipient.nickname = action.payload.nickname; + state.draftTransaction.id = action.payload.id; + state.draftTransaction.txParams.from = action.payload.from; + slice.caseReducers.updateDraftTransaction(state); + }, + /** + * gasTotal is computed based on gasPrice and gasLimit and set in state + * recomputes the maximum amount if the current amount mode is 'MAX' and + * sending the native token. ERC20 assets max amount is unaffected by + * gasTotal so does not need to be recomputed. Finally, validates the gas + * field and send state, then updates the draft transaction. + */ + calculateGasTotal: (state) => { + state.gas.gasTotal = addHexPrefix( + calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), + ); + if ( + state.amount.mode === AMOUNT_MODES.MAX && + state.asset.type === ASSET_TYPES.NATIVE + ) { + slice.caseReducers.updateAmountToMax(state); + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal. + */ + updateGasLimit: (state, action) => { + state.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the provided gasPrice in state and then recomputes the gasTotal + */ + updateGasPrice: (state, action) => { + state.gas.gasPrice = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the amount mode to the provided value as long as it is one of the + * supported modes (MAX|INPUT) + */ + updateAmountMode: (state, action) => { + if (Object.values(AMOUNT_MODES).includes(action.payload)) { + state.amount.mode = action.payload; + } + }, + updateAsset: (state, action) => { + state.asset.type = action.payload.type; + state.asset.balance = action.payload.balance; + if (state.asset.type === ASSET_TYPES.TOKEN) { + state.asset.details = action.payload.details; + } else { + // clear the details object when sending native currency + state.asset.details = null; + if (state.recipient.error === CONTRACT_ADDRESS_ERROR) { + // Errors related to sending tokens to their own contract address + // are no longer valid when sending native currency. + state.recipient.error = null; + } + + if (state.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING) { + // Warning related to sending tokens to a known contract address + // are no longer valid when sending native currency. + state.recipient.warning = null; + } + } + // if amount mode is MAX update amount to max of new asset, otherwise set + // to zero. This will revalidate the send amount field. + if (state.amount.mode === AMOUNT_MODES.MAX) { + slice.caseReducers.updateAmountToMax(state); + } else { + slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateRecipient: (state, action) => { + state.recipient.error = null; + state.recipient.userInput = ''; + state.recipient.address = action.payload.address ?? ''; + state.recipient.nickname = action.payload.nickname ?? ''; + + if (state.recipient.address === '') { + // If address is null we are clearing the recipient and must return + // to the ADD_RECIPIENT stage. + state.stage = SEND_STAGES.ADD_RECIPIENT; + } else { + // if and address is provided and an id exists on the draft transaction, + // we progress to the EDIT stage, otherwise we progress to the DRAFT + // stage. We also reset the search mode for recipient search. + state.stage = + state.draftTransaction.id === null + ? SEND_STAGES.DRAFT + : SEND_STAGES.EDIT; + state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; + } + + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateDraftTransaction: (state) => { + // We keep a copy of txParams in state that could be submitted to the + // network if the form state is valid. + if (state.status === SEND_STATUSES.VALID) { + state.draftTransaction.txParams.from = state.account.address; + switch (state.asset.type) { + case ASSET_TYPES.TOKEN: + // When sending a token the to address is the contract address of + // the token being sent. The value is set to '0x0' and the data + // is generated from the recipient address, token being sent and + // amount. + state.draftTransaction.txParams.to = state.asset.details.address; + state.draftTransaction.txParams.value = '0x0'; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = generateTokenTransferData({ + toAddress: state.recipient.address, + amount: state.amount.value, + sendToken: state.asset.details, + }); + break; + case ASSET_TYPES.NATIVE: + default: + // When sending native currency the to and value fields use the + // recipient and amount values and the data key is either null or + // populated with the user input provided in hex field. + state.draftTransaction.txParams.to = state.recipient.address; + state.draftTransaction.txParams.value = state.amount.value; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = + state.draftTransaction.userInputHexData ?? undefined; + } + } + }, + useDefaultGas: (state) => { + // Show the default gas price/limit fields in the send page + state.gas.isCustomGasSet = false; + }, + useCustomGas: (state) => { + // Show the gas fees set in the custom gas modal (state.gas.customData) + state.gas.isCustomGasSet = true; + }, + updateRecipientUserInput: (state, action) => { + // Update the value in state to match what the user is typing into the + // input field + state.recipient.userInput = action.payload; + }, + validateRecipientUserInput: (state, action) => { + const { asset, recipient } = state; + + if ( + recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || + recipient.userInput === '' || + recipient.userInput === null + ) { + recipient.error = null; + recipient.warning = null; + } else { + const isSendingToken = asset.type === ASSET_TYPES.TOKEN; + const { chainId, tokens } = action.payload; + if ( + isBurnAddress(recipient.userInput) || + (!isValidHexAddress(recipient.userInput, { + mixedCaseUseChecksum: true, + }) && + !isValidDomainName(recipient.userInput)) + ) { + recipient.error = isDefaultMetaMaskChain(chainId) + ? INVALID_RECIPIENT_ADDRESS_ERROR + : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; + } else if ( + isSendingToken && + isOriginContractAddress(recipient.userInput, asset.details.address) + ) { + recipient.error = CONTRACT_ADDRESS_ERROR; + } else { + recipient.error = null; + } + + if ( + isSendingToken && + (toChecksumAddress(recipient.userInput) in contractMap || + checkExistingAddresses(recipient.userInput, tokens)) + ) { + recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; + } else { + recipient.warning = null; + } + } + }, + updateRecipientSearchMode: (state, action) => { + state.recipient.userInput = ''; + state.recipient.mode = action.payload; + }, + resetSendState: () => initialState, + validateAmountField: (state) => { + switch (true) { + // set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower + // than the total price of the transaction inclusive of gas fees. + case state.asset.type === ASSET_TYPES.NATIVE && + !isBalanceSufficient({ + amount: state.amount.value, + balance: state.asset.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }): + state.amount.error = INSUFFICIENT_FUNDS_ERROR; + break; + // set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower + // than the amount of token the user is attempting to send. + case state.asset.type === ASSET_TYPES.TOKEN && + !isTokenBalanceSufficient({ + tokenBalance: state.asset.balance ?? '0x0', + amount: state.amount.value, + decimals: state.asset.details.decimals, + }): + state.amount.error = INSUFFICIENT_TOKENS_ERROR; + break; + // if the amount is negative, set error to NEGATIVE_ETH_ERROR + // TODO: change this to NEGATIVE_ERROR and remove the currency bias. + case conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: state.amount.value, fromNumericBase: 'hex' }, + ): + state.amount.error = NEGATIVE_ETH_ERROR; + break; + // If none of the above are true, set error to null + default: + state.amount.error = null; + } + }, + validateGasField: (state) => { + // Checks if the user has enough funds to cover the cost of gas, always + // uses the native currency and does not take into account the amount + // being sent. If the user has enough to cover cost of gas but not gas + // + amount then the error will be displayed on the amount field. + const insufficientFunds = !isBalanceSufficient({ + amount: + state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0', + balance: state.account.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }); + + state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null; + }, + validateSendState: (state) => { + switch (true) { + // 1 + 2. State is invalid when either gas or amount fields have errors + // 3. State is invalid if asset type is a token and the token details + // are unknown. + // 4. State is invalid if no recipient has been added + // 5. State is invalid if the send state is uninitialized + // 6. State is invalid if gas estimates are loading + // 7. State is invalid if gasLimit is less than the minimumGasLimit + // 8. State is invalid if the selected asset is a ERC721 + case Boolean(state.amount.error): + case Boolean(state.gas.error): + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details === null: + case state.stage === SEND_STAGES.ADD_RECIPIENT: + case state.stage === SEND_STAGES.UNINITIALIZED: + case state.gas.isGasEstimateLoading: + case new BigNumber(state.gas.gasLimit, 16).lessThan( + new BigNumber(state.gas.minimumGasLimit), + ): + state.status = SEND_STATUSES.INVALID; + break; + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details.isERC721 === true: + state.state = SEND_STATUSES.INVALID; + break; + default: + state.status = SEND_STATUSES.VALID; + // Recompute the draftTransaction object + slice.caseReducers.updateDraftTransaction(state); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(QR_CODE_DETECTED, (state, action) => { + // When data is received from the QR Code Scanner we set the recipient + // as long as a valid address can be pulled from the data. If an + // address is pulled but it is invalid, we display an error. + const qrCodeData = action.value; + if (qrCodeData) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (state.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } + }) + .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow the account we are keyed into will be the + // original 'from' account, which may differ from the selected account + if (state.stage !== SEND_STAGES.EDIT) { + // This event occurs when the user selects a new account from the + // account menu, or the currently active account's balance updates. + state.account.balance = action.payload.account.balance; + state.account.address = action.payload.account.address; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow then we need to watch for changes to the + // current account.address in state and keep balance updated + // appropriately + if ( + state.stage === SEND_STAGES.EDIT && + action.payload.account.address === state.account.address + ) { + // This event occurs when the user's account details update due to + // background state changes. If the account that is being updated is + // the current from account on the edit flow we need to update + // the balance for the account and revalidate the send state. + state.account.balance = action.payload.account.balance; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ADDRESS_BOOK_UPDATED, (state, action) => { + // When the address book updates from background state changes we need + // to check to see if an entry exists for the current address or if the + // entry changed. + const { addressBook } = action.payload; + if (addressBook[state.recipient.address]?.name) { + state.recipient.nickname = addressBook[state.recipient.address].name; + } + }) + .addCase(initializeSendState.pending, (state) => { + // when we begin initializing state, which can happen when switching + // chains even after loading the send flow, we set + // gas.isGasEstimateLoading as initialization will trigger a fetch + // for gasPrice estimates. + state.gas.isGasEstimateLoading = true; + }) + .addCase(initializeSendState.fulfilled, (state, action) => { + // writes the computed initialized state values into the slice and then + // calculates slice validity using the caseReducers. + state.account.address = action.payload.address; + state.account.balance = action.payload.nativeBalance; + state.asset.balance = action.payload.assetBalance; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.gas.gasTotal = action.payload.gasTotal; + if (state.stage !== SEND_STAGES.UNINITIALIZED) { + slice.caseReducers.validateRecipientUserInput(state, { + payload: { + chainId: action.payload.chainId, + tokens: action.payload.tokens, + }, + }); + } + state.stage = + state.stage === SEND_STAGES.UNINITIALIZED + ? SEND_STAGES.ADD_RECIPIENT + : state.stage; + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit we should indicate we are loading + // a gas estimate. + state.gas.isGasEstimateLoading = true; + }) + .addCase(computeEstimatedGasLimit.fulfilled, (state, action) => { + // When we receive a new gasLimit from the computeEstimatedGasLimit + // thunk we need to update our gasLimit in the slice. We call into the + // caseReducer updateGasLimit to tap into the appropriate follow up + // checks and gasTotal calculation. First set isGasEstimateLoading to + // false. + state.gas.isGasEstimateLoading = false; + if (action.payload?.gasLimit) { + slice.caseReducers.updateGasLimit(state, { + payload: action.payload.gasLimit, + }); + } + }) + .addCase(SET_BASIC_GAS_ESTIMATE_DATA, (state, action) => { + // When we receive a new gasPrice via the gas duck we need to update + // the gasPrice in our slice. We call into the caseReducer + // updateGasPrice to also tap into the appropriate follow up checks + // and gasTotal calculation. + slice.caseReducers.updateGasPrice(state, { + payload: getGasPriceInHexWei(action.value.average), + }); + }) + .addCase(BASIC_GAS_ESTIMATE_STATUS, (state, action) => { + // When we fetch gas prices we should temporarily set the form invalid + // Once the price updates we get that value in the + // SET_BASIC_GAS_ESTIMATE_DATA extraReducer above. Finally as long as + // the state is 'READY' we will revalidate the form. + switch (action.value) { + case BASIC_ESTIMATE_STATES.FAILED: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.LOADING: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.READY: + default: + state.gas.isGasEstimateLoading = false; + slice.caseReducers.validateSendState(state); + } + }); + }, +}); + +const { actions, reducer } = slice; + +export default reducer; + +const { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, + validateRecipientUserInput, + updateRecipientSearchMode, +} = actions; + +export { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, +}; + +// Action Creators + +/** + * Updates the amount the user intends to send and performs side effects. + * 1. If the current mode is MAX change to INPUT + * 2. If sending a token, recompute the gasLimit estimate + * @param {string} amount - hex string representing value + * @returns {void} + */ +export function updateSendAmount(amount) { + return async (dispatch, getState) => { + await dispatch(actions.updateSendAmount(amount)); + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + } + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * updates the asset to send to one of NATIVE or TOKEN and ensures that the + * asset balance is set. If sending a TOKEN also updates the asset details + * object with the appropriate ERC20 details including address, symbol and + * decimals. + * @param {Object} payload - action payload + * @param {string} payload.type - type of asset to send + * @param {Object} [payload.details] - ERC20 details if sending TOKEN asset + * @param {string} [payload.details.address] - contract address for ERC20 + * @param {string} [payload.details.decimals] - Number of token decimals + * @param {string} [payload.details.symbol] - asset symbol to display + * @returns {void} + */ +export function updateSendAsset({ type, details }) { + return async (dispatch, getState) => { + const state = getState(); + let { balance } = state.send.asset; + if (type === ASSET_TYPES.TOKEN) { + // if changing to a token, get the balance from the network. The asset + // overview page and asset list on the wallet overview page contain + // send buttons that call this method before initialization occurs. + // When this happens we don't yet have an account.address so default to + // the currently active account. In addition its possible for the balance + // check to take a decent amount of time, so we display a loading + // indication so that that immediate feedback is displayed to the user. + await dispatch(showLoadingIndication()); + balance = await getERC20Balance( + details, + state.send.account.address ?? getSelectedAddress(state), + ); + if (details && details.isERC721 === undefined) { + const updatedAssetDetails = await updateTokenType(details.address); + details.isERC721 = updatedAssetDetails.isERC721; + } + + await dispatch(hideLoadingIndication()); + } else { + // if changing to native currency, get it from the account key in send + // state which is kept in sync when accounts change. + balance = state.send.account.balance; + } + // update the asset in state which will re-run amount and gas validation + await dispatch(actions.updateAsset({ type, details, balance })); + await dispatch(computeEstimatedGasLimit()); + }; +} + +/** + * This method is for usage when validating user input so that validation + * is only run after a delay in typing of 300ms. Usage at callsites requires + * passing in both the dispatch method and the payload to dispatch, which makes + * it only applicable for use within action creators. + */ +const debouncedValidateRecipientUserInput = debounce((dispatch, payload) => { + dispatch(validateRecipientUserInput(payload)); +}, 300); + +/** + * This method is called to update the user's input into the ENS input field. + * Once the field is updated, the field will be validated using a debounced + * version of the validateRecipientUserInput action. This way validation only + * occurs once the user has stopped typing. + * @param {string} userInput - the value that the user is typing into the field + * @returns {void} + */ +export function updateRecipientUserInput(userInput) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipientUserInput(userInput)); + const state = getState(); + const chainId = getCurrentChainId(state); + const tokens = getTokens(state); + debouncedValidateRecipientUserInput(dispatch, { chainId, tokens }); + }; +} + +export function useContactListForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); + }; +} + +export function useMyAccountsForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); + }; +} + +/** + * Updates the recipient in state based on the input provided, and then will + * recompute gas limit when sending a TOKEN asset type. Changing the recipient + * address results in hex data changing because the recipient address is + * encoded in the data instead of being in the 'to' field. The to field in a + * token send will always be the token contract address. + * @param {Object} recipient - Recipient information + * @param {string} recipient.address - hex address to send the transaction to + * @param {string} [recipient.nickname] - Alias for the address to display + * to the user + * @returns {void} + */ +export function updateRecipient({ address, nickname }) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipient({ address, nickname })); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Clears out the recipient user input, ENS resolution and recipient validation + * @returns {void} + */ +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(updateRecipientUserInput('')); + await dispatch(updateRecipient({ address: '', nickname: '' })); + await dispatch(resetResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +/** + * When a user has enabled hex data field in advanced settings they will be + * able to supply hex data on a transaction. This method updates the user + * supplied data. Note, when sending native assets this will result in + * recomputing estimated gasLimit. When sending a ERC20 asset this is not done + * because the data sent in the transaction will be determined by the asset, + * recipient and value, NOT what the user has supplied. + * @param {string} hexData - hex encoded string representing transaction data + * @returns {void} + */ +export function updateSendHexData(hexData) { + return async (dispatch, getState) => { + await dispatch(actions.updateUserInputHexData(hexData)); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.NATIVE) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Toggles the amount.mode between INPUT and MAX modes. + * As a result, the amount.value will change to either '0x0' when moving from + * MAX to INPUT, or to the maximum allowable amount based on current asset when + * moving from INPUT to MAX. + * @returns {void} + */ +export function toggleSendMaxMode() { + return async (dispatch, getState) => { + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + await dispatch(actions.updateSendAmount('0x0')); + } else { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); + await dispatch(actions.updateAmountToMax()); + } + }; +} + +/** + * Signs a transaction or updates a transaction in state if editing. + * This method is called when a user clicks the next button in the footer of + * the send page, signaling that a transaction should be executed. This method + * will create the transaction in state (by way of the various global provider + * constructs) which will eventually (and fairly quickly from user perspective) + * result in a confirmation window being displayed for the transaction. + * @returns {void} + */ +export function signTransaction() { + return async (dispatch, getState) => { + const state = getState(); + const { + asset, + stage, + draftTransaction: { id, txParams }, + recipient: { address }, + amount: { value }, + } = state[name]; + if (stage === SEND_STAGES.EDIT) { + // When dealing with the edit flow there is already a transaction in + // state that we must update, this branch is responsible for that logic. + // We first must grab the previous transaction object from state and then + // merge in the modified txParams. Once the transaction has been modified + // we can send that to the background to update the transaction in state. + const unapprovedTxs = getUnapprovedTxs(state); + const unapprovedTx = unapprovedTxs[id]; + const editingTx = { + ...unapprovedTx, + txParams: Object.assign(unapprovedTx.txParams, txParams), + }; + dispatch(updateTransaction(editingTx)); + } else if (asset.type === ASSET_TYPES.TOKEN) { + // When sending a token transaction we have to the token.transfer method + // on the token contract to construct the transaction. This results in + // the proper transaction data and properties being set and a new + // transaction being added to background state. Once the new transaction + // is added to state a subsequent confirmation will be queued. + try { + const token = global.eth.contract(abi).at(asset.details.address); + token.transfer(address, value, { + ...txParams, + to: undefined, + data: undefined, + }); + dispatch(showConfTxPage()); + dispatch(hideLoadingIndication()); + } catch (error) { + dispatch(hideLoadingIndication()); + dispatch(displayWarning(error.message)); + } + } else { + // When sending a native asset we use the ethQuery.sendTransaction method + // which will result in the transaction being added to background state + // and a subsequent confirmation will be queued. + global.ethQuery.sendTransaction(txParams, (err) => { + if (err) { + dispatch(displayWarning(err.message)); + } + }); + dispatch(showConfTxPage()); + } + }; +} + +export function editTransaction( + assetType, + transactionId, + tokenData, + assetDetails, +) { + return async (dispatch, getState) => { + const state = getState(); + const unapprovedTransactions = getUnapprovedTxs(state); + const transaction = unapprovedTransactions[transactionId]; + const { txParams } = transaction; + if (assetType === ASSET_TYPES.NATIVE) { + const { + from, + gas: gasLimit, + gasPrice, + to: address, + value: amount, + } = txParams; + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount, + address, + nickname, + }), + ); + } else if (!tokenData || !assetDetails) { + throw new Error( + `send/editTransaction dispatched with assetType 'TOKEN' but missing assetData or assetDetails parameter`, + ); + } else { + const { from, to: tokenAddress, gas: gasLimit, gasPrice } = txParams; + const tokenAmountInDec = getTokenValueParam(tokenData); + const address = getTokenAddressParam(tokenData); + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + + const tokenAmountInHex = addHexPrefix( + conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }), + ); + + await dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { ...assetDetails, address: tokenAddress }, + }), + ); + + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount: tokenAmountInHex, + address, + nickname, + }), + ); + } + }; +} + +// Selectors + +// Gas selectors +export function getGasLimit(state) { + return state[name].gas.gasLimit; +} + +export function getGasPrice(state) { + return state[name].gas.gasPrice; +} + +export function getGasTotal(state) { + return state[name].gas.gasTotal; +} + +export function gasFeeIsInError(state) { + return Boolean(state[name].gas.error); +} + +export function getMinimumGasLimitForSend(state) { + return state[name].gas.minimumGasLimit; +} + +export function getGasInputMode(state) { + const isMainnet = getIsMainnet(state); + const showAdvancedGasFields = getAdvancedInlineGasShown(state); + if (state[name].gas.isCustomGasSet) { + return GAS_INPUT_MODES.CUSTOM; + } + if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { + return GAS_INPUT_MODES.INLINE; + } + return GAS_INPUT_MODES.BASIC; +} + +// Asset Selectors + +export function getSendAsset(state) { + return state[name].asset; +} + +export function getSendAssetAddress(state) { + return getSendAsset(state)?.details?.address; +} + +export function getIsAssetSendable(state) { + if (state[name].asset.type === ASSET_TYPES.NATIVE) { + return true; + } + return state[name].asset.details.isERC721 === false; +} + +// Amount Selectors +export function getSendAmount(state) { + return state[name].amount.value; +} + +export function getIsBalanceInsufficient(state) { + return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; +} +export function getSendMaxModeState(state) { + return state[name].amount.mode === AMOUNT_MODES.MAX; +} + +export function getSendHexData(state) { + return state[name].draftTransaction.userInputHexData; +} + +export function sendAmountIsInError(state) { + return Boolean(state[name].amount.error); +} + +// Recipient Selectors + +export function getSendTo(state) { + return state[name].recipient.address; +} + +export function getIsUsingMyAccountForRecipientSearch(state) { + return state[name].recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; +} + +export function getRecipientUserInput(state) { + return state[name].recipient.userInput; +} + +export function getRecipient(state) { + return state[name].recipient; +} + +// Overall validity and stage selectors + +export function getSendErrors(state) { + return { + gasFee: state.send.gas.error, + amount: state.send.amount.error, + }; +} + +export function isSendStateInitialized(state) { + return state[name].stage !== SEND_STAGES.UNINITIALIZED; +} + +export function isSendFormInvalid(state) { + return state[name].status === SEND_STATUSES.INVALID; +} + +export function getSendStage(state) { + return state[name].stage; +} diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js new file mode 100644 index 000000000..b4918d01d --- /dev/null +++ b/ui/ducks/send/send.test.js @@ -0,0 +1,1808 @@ +import sinon from 'sinon'; +import createMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { ethers } from 'ethers'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; +import { BASIC_ESTIMATE_STATES } from '../gas/gas.duck'; +import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; +import sendReducer, { + initialState, + initializeSendState, + updateSendAmount, + updateSendAsset, + updateRecipientUserInput, + useContactListForRecipientSearch, + useMyAccountsForRecipientSearch, + updateRecipient, + resetRecipientInput, + updateSendHexData, + toggleSendMaxMode, + signTransaction, + SEND_STATUSES, + ASSET_TYPES, + SEND_STAGES, + AMOUNT_MODES, + RECIPIENT_SEARCH_MODES, + editTransaction, +} from './send'; + +const mockStore = createMockStore([thunk]); + +jest.mock('../../store/actions', () => { + const actual = jest.requireActual('../../store/actions'); + return { + ...actual, + estimateGas: jest.fn(() => Promise.resolve('0x0')), + updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })), + }; +}); + +jest.mock('./send', () => { + const actual = jest.requireActual('./send'); + return { + __esModule: true, + ...actual, + getERC20Balance: jest.fn(() => '0x0'), + }; +}); + +describe('Send Slice', () => { + describe('Reducers', () => { + describe('updateSendAmount', () => { + it('should', async () => { + const action = { type: 'send/updateSendAmount', payload: '0x1' }; + const result = sendReducer(initialState, action); + expect(result.amount.value).toStrictEqual('0x1'); + }); + }); + + describe('updateAmountToMax', () => { + it('should calculate the max amount based off of the asset balance and gas total then updates send amount value', () => { + const maxAmountState = { + amount: { + value: '', + }, + asset: { + balance: '0x56bc75e2d63100000', // 100000000000000000000 + }, + gas: { + gasLimit: '0x5208', // 21000 + gasTotal: '0x1319718a5000', // 21000000000000 + minimumGasLimit: '0x5208', + }, + }; + + const state = { ...initialState, ...maxAmountState }; + const action = { type: 'send/updateAmountToMax' }; + const result = sendReducer(state, action); + + expect(result.amount.value).toStrictEqual('0x56bc74b13f185b000'); // 99999979000000000000 + }); + }); + + describe('updateUserInputHexData', () => { + it('should', () => { + const action = { + type: 'send/updateUserInputHexData', + payload: 'TestData', + }; + const result = sendReducer(initialState, action); + + expect(result.draftTransaction.userInputHexData).toStrictEqual( + action.payload, + ); + }); + }); + + describe('updateGasLimit', () => { + const action = { + type: 'send/updateGasLimit', + payload: '0x5208', // 21000 + }; + + it('should', () => { + const result = sendReducer( + { + ...initialState, + stage: SEND_STAGES.DRAFT, + gas: { ...initialState.gas, isGasEstimateLoading: false }, + }, + action, + ); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + ...initialState, + gas: { + gasLimit: '0x0', + gasPrice: '0x3b9aca00', // 1000000000 + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.gas.gasPrice).toStrictEqual(gasState.gas.gasPrice); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateGasPrice', () => { + const action = { + type: 'send/updateGasPrice', + payload: '0x3b9aca00', // 1000000000 + }; + + it('should update gas price and update draft transaction with validated state', () => { + const validSendState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + account: { + balance: '0x56bc75e2d63100000', + }, + asset: { + balance: '0x56bc75e2d63100000', + type: ASSET_TYPES.NATIVE, + }, + gas: { + isGasEstimateLoading: false, + gasTotal: '0x1319718a5000', // 21000000000000 + gasLimit: '0x5208', // 21000 + minimumGasLimit: '0x5208', + }, + }; + + const result = sendReducer(validSendState, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + gas: { + gasLimit: '0x5208', // 21000, + gasPrice: '0x0', + }, + }; + + const state = { ...initialState, ...gasState }; + const result = sendReducer(state, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateAmountMode', () => { + it('should change to INPUT amount mode', () => { + const emptyAmountModeState = { + amount: { + mode: '', + }, + }; + + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.INPUT, + }; + const result = sendReducer(emptyAmountModeState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should change to MAX amount mode', () => { + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.MAX, + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should', () => { + const action = { + type: 'send/updateAmountMode', + payload: 'RANDOM', + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).not.toStrictEqual(action.payload); + }); + }); + + describe('updateAsset', () => { + it('should update asset type and balance from respective action payload', () => { + const updateAssetState = { + ...initialState, + asset: { + type: 'old type', + balance: 'old balance', + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'new type', + balance: 'new balance', + }, + }; + + const result = sendReducer(updateAssetState, action); + + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.balance).toStrictEqual(action.payload.balance); + }); + + it('should nullify old contract address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + error: CONTRACT_ADDRESS_ERROR, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.error).not.toStrictEqual( + recipientErrorState.recipient.error, + ); + expect(result.recipient.error).toBeNull(); + }); + + it('should nullify old known address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + warning: KNOWN_RECIPIENT_ADDRESS_WARNING, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.warning).not.toStrictEqual( + recipientErrorState.recipient.warning, + ); + expect(result.recipient.warning).toBeNull(); + }); + + it('should update asset type and details to TOKEN payload', () => { + const action = { + type: 'send/updateAsset', + payload: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 0, + symbol: 'TKN', + }, + }, + }; + + const result = sendReducer(initialState, action); + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.details).toStrictEqual(action.payload.details); + }); + }); + + describe('updateRecipient', () => { + it('should', () => { + const action = { + type: 'send/updateRecipient', + payload: { + address: '0xNewAddress', + }, + }; + + const result = sendReducer(initialState, action); + + expect(result.stage).toStrictEqual(SEND_STAGES.DRAFT); + expect(result.recipient.address).toStrictEqual(action.payload.address); + }); + }); + + describe('updateDraftTransaction', () => { + it('should', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: '', + }, + recipient: { + address: '0xRecipientAddress', + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.recipient.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + detailsForDraftTransactionState.amount.value, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + }); + + it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + }, + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.asset.details.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + expect(result.draftTransaction.txParams.data).toStrictEqual( + '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + ); + }); + }); + + describe('useDefaultGas', () => { + it('should', () => { + const action = { + type: 'send/useDefaultGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(false); + }); + }); + + describe('useCustomGas', () => { + it('should', () => { + const action = { + type: 'send/useCustomGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(true); + }); + }); + + describe('updateRecipientUserInput', () => { + it('should update recipient user input with payload', () => { + const action = { + type: 'send/updateRecipientUserInput', + payload: 'user input', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.userInput).toStrictEqual(action.payload); + }); + }); + + describe('validateRecipientUserInput', () => { + it('should set recipient error and warning to null when user input is', () => { + const noUserInputState = { + recipient: { + mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + userInput: '', + error: 'someError', + warning: 'someWarning', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + }; + + const result = sendReducer(noUserInputState, action); + + expect(result.recipient.error).toBeNull(); + expect(result.recipient.warning).toBeNull(); + }); + + it('should error with an invalid address error when user input is not a valid hex string', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + // TODO: Expectation might change in the future + it('should error with an invalid network error when user input is not a valid hex string on a non default network', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x55', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual( + 'invalidAddressRecipientNotEthNetwork', + ); + }); + + it('should error with invalid address recipient when the user inputs the burn address', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0x0000000000000000000000000000000000000000', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + it('should error with same address recipient as a token', () => { + const tokenAssetTypeState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + recipient: { + userInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('contractAddressError'); + }); + }); + + describe('updateRecipientSearchMode', () => { + it('should', () => { + const action = { + type: 'send/updateRecipientSearchMode', + payload: 'a-random-string', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.mode).toStrictEqual(action.payload); + }); + }); + + describe('resetSendState', () => { + it('should', () => { + const action = { + type: 'send/resetSendState', + }; + + const result = sendReducer({}, action); + + expect(result).toStrictEqual(initialState); + }); + }); + + describe('validateAmountField', () => { + it('should error with insufficient funds when amount asset value plust gas is higher than asset balance', () => { + const nativeAssetState = { + ...initialState, + amount: { + value: '0x6fc23ac0', // 1875000000 + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0x77359400', // 2000000000 + }, + gas: { + gasTotal: '0x8f0d180', // 150000000 + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(nativeAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + + it('should error with insufficient tokens when amount value of tokens is higher than asset balance of token', () => { + const tokenAssetState = { + ...initialState, + amount: { + value: '0x77359400', // 2000000000 + }, + asset: { + type: ASSET_TYPES.TOKEN, + balance: '0x6fc23ac0', // 1875000000 + details: { + decimals: 0, + }, + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(tokenAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_TOKENS_ERROR); + }); + + it('should error negative value amount', () => { + const negativeAmountState = { + ...initialState, + amount: { + value: '-1', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(negativeAmountState, action); + + expect(result.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); + }); + + it('should not error for positive value amount', () => { + const otherState = { + ...initialState, + amount: { + error: 'someError', + value: '1', + }, + asset: { + type: '', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(otherState, action); + expect(result.amount.error).toBeNull(); + }); + }); + + describe('validateGasField', () => { + it('should error when total amount of gas is higher than account balance', () => { + const gasFieldState = { + ...initialState, + account: { + balance: '0x0', + }, + gas: { + gasTotal: '0x1319718a5000', // 21000000000000 + }, + }; + + const action = { + type: 'send/validateGasField', + }; + + const result = sendReducer(gasFieldState, action); + expect(result.gas.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + }); + + describe('validateSendState', () => { + it('should set `INVALID` send state status when amount error is present', () => { + const amountErrorState = { + ...initialState, + amount: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(amountErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gas error is present', () => { + const gasErrorState = { + ...initialState, + gas: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when asset type is `TOKEN` without token details present', () => { + const assetErrorState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(assetErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gasLimit is under the minimumGasLimit', () => { + const gasLimitErroState = { + ...initialState, + gas: { + gasLimit: '0x5207', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasLimitErroState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `VALID` send state status when conditionals have not been met', () => { + const validSendStatusState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x000', + }, + }, + gas: { + isGasEstimateLoading: false, + gasLimit: '0x5208', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).toStrictEqual(SEND_STATUSES.VALID); + }); + }); + }); + + describe('extraReducers/externalReducers', () => { + describe('QR Code Detected', () => { + const qrCodestate = { + ...initialState, + recipient: { + address: '0xAddress', + }, + }; + + it('should set the recipient address to the scanned address value if they are not equal', () => { + const action = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + }; + + const result = sendReducer(qrCodestate, action); + expect(result.recipient.address).toStrictEqual( + action.value.values.address, + ); + }); + + it('should not set the recipient address to invalid scanned address and errors', () => { + const badQRAddressAction = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0xBadAddress', + }, + }, + }; + + const result = sendReducer(qrCodestate, badQRAddressAction); + + expect(result.recipient.address).toStrictEqual( + qrCodestate.recipient.address, + ); + expect(result.recipient.error).toStrictEqual( + INVALID_RECIPIENT_ADDRESS_ERROR, + ); + }); + }); + + describe('Selected Address Changed', () => { + it('should update selected account address and balance on non-edit stages', () => { + const olderState = { + ...initialState, + account: { + balance: '0x0', + address: '0xAddress', + }, + }; + + const action = { + type: 'SELECTED_ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(olderState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + expect(result.account.address).toStrictEqual( + action.payload.account.address, + ); + }); + }); + + describe('Account Changed', () => { + it('should', () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + }); + + it(`should not edit account balance if action payload address is not the same as state's address`, () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + expect(result.account.address).not.toStrictEqual( + action.payload.account.address, + ); + expect(result.account.balance).not.toStrictEqual( + action.payload.account.balance, + ); + }); + }); + + describe('Initialize Pending Send State', () => { + let dispatchSpy; + let getState; + + beforeEach(() => { + dispatchSpy = jest.fn(); + }); + + it('should dispatch async action thunk first with pending, then finally fulfilling from minimal state', async () => { + getState = jest.fn().mockReturnValue({ + metamask: { + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + 0x4: { + '0xAddress': '0x0', + }, + }, + selectedAddress: '0xAddress', + provider: { + chainId: '0x4', + }, + }, + send: initialState, + gas: { + basicEstimateStatus: 'LOADING', + basicEstimatesStatus: { + safeLow: null, + average: null, + fast: null, + }, + }, + }); + + const action = initializeSendState(); + await action(dispatchSpy, getState, undefined); + + expect(dispatchSpy).toHaveBeenCalledTimes(4); + + expect(dispatchSpy.mock.calls[0][0].type).toStrictEqual( + 'send/initializeSendState/pending', + ); + expect(dispatchSpy.mock.calls[3][0].type).toStrictEqual( + 'send/initializeSendState/fulfilled', + ); + }); + }); + + describe('Set Basic Gas Estimate Data', () => { + it('should recalculate gas based off of average basic estimate data', () => { + const gasState = { + ...initialState, + gas: { + gasPrice: '0x0', + gasLimit: '0x5208', + gasTotal: '0x0', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA', + value: { + average: '1', + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); + }); + }); + + describe('BASIC_GAS_ESTIMATE_STATUS', () => { + it('should invalidate the send status when status is LOADING', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.LOADING, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + + it('should invalidate the send status when status is FAILED and use INLINE gas input mode', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.FAILED, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + }); + }); + + describe('Action Creators', () => { + describe('UpdateSendAmount', () => { + const defaultSendAmountState = { + send: { + amount: { + mode: undefined, + }, + asset: { + type: '', + }, + }, + }; + + it('should create an action to update send amount', async () => { + const store = mockStore(defaultSendAmountState); + + const newSendAmount = 'aNewSendAmount'; + + await store.dispatch(updateSendAmount(newSendAmount)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: 'aNewSendAmount' }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action to update send amount mode to `INPUT` when mode is `MAX`', async () => { + const maxModeSendState = { + send: { + ...defaultSendAmountState.send, + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + + const store = mockStore(maxModeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: undefined }, + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action computeEstimateGasLimit and change states from pending to fulfilled with token asset types', async () => { + const tokenAssetTypeSendState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + ...defaultSendAmountState.send, + send: { + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenAssetTypeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateSendAmount'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('UpdateSendAsset', () => { + const defaultSendAssetState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: '', + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + it('should create actions for updateSendAsset', async () => { + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: '', + details: { + address: '', + symbol: '', + decimals: '', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + + expect(actionResult[0].type).toStrictEqual('send/updateAsset'); + expect(actionResult[0].payload).toStrictEqual({ + ...newSendAsset, + balance: '', + }); + + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + + it('should create actions for updateSendAsset with tokens', async () => { + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + }; + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: ASSET_TYPES.TOKEN, + details: { + address: 'tokenAddress', + symbol: 'tokenSymbol', + decimals: 'tokenDecimals', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(6); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].payload).toStrictEqual({ + ...newSendAsset, + balance: '0x0', + }); + + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('updateRecipientUserInput', () => { + const updateRecipientUserInputState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + }; + + it('should create actions for updateRecipientUserInput and checks debounce for validation', async () => { + const clock = sinon.useFakeTimers(); + + const store = mockStore(updateRecipientUserInputState); + const newUserRecipientInput = 'newUserRecipientInput'; + + await store.dispatch(updateRecipientUserInput(newUserRecipientInput)); + + expect(store.getActions()).toHaveLength(1); + expect(store.getActions()[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(store.getActions()[0].payload).toStrictEqual( + newUserRecipientInput, + ); + + clock.tick(300); // debounce + + expect(store.getActions()).toHaveLength(2); + expect(store.getActions()[1].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + expect(store.getActions()[1].payload).toStrictEqual({ + chainId: '', + tokens: [], + }); + }); + }); + + describe('useContactListForRecipientSearch', () => { + it('should create action to change send recipient search to contact list', async () => { + const store = mockStore(); + + await store.dispatch(useContactListForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + }, + ]); + }); + }); + + describe('UseMyAccountsForRecipientSearch', () => { + it('should create action to change send recipient search to derived accounts', async () => { + const store = mockStore(); + + await store.dispatch(useMyAccountsForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + }, + ]); + }); + }); + + describe('UpdateRecipient', () => { + const recipient = { + address: '', + nickname: '', + }; + + it('should create an action to update recipient', async () => { + const updateRecipientState = { + send: { + asset: { + type: '', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { + type: 'send/updateRecipient', + payload: recipient, + }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create actions to update recipient and recalculate gas limit if the asset is a token', async () => { + const tokenState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('ResetRecipientInput', () => { + it('should create actions to reset recipient input and ens then validates input', async () => { + const updateRecipientState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(resetRecipientInput()); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(actionResult[0].payload).toStrictEqual(''); + expect(actionResult[1].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[2].type).toStrictEqual('ENS/resetResolution'); + expect(actionResult[3].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + }); + }); + + describe('UpdateSendHexData', () => { + const sendHexDataState = { + send: { + asset: { + type: '', + }, + }, + }; + + it('should create action to update hexData', async () => { + const hexData = '0x1'; + const store = mockStore(sendHexDataState); + + await store.dispatch(updateSendHexData(hexData)); + + const actionResult = store.getActions(); + + const expectActionResult = [ + { type: 'send/updateUserInputHexData', payload: hexData }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectActionResult); + }); + }); + + describe('ToggleSendMaxMode', () => { + it('should create actions to toggle update max mode when send amount mode is not max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: '', + }, + }, + }; + + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX }, + { type: 'send/updateAmountToMax', payload: undefined }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + + it('should create actions to toggle off max mode when send amount mode is max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + { type: 'send/updateSendAmount', payload: '0x0' }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + }); + + describe('SignTransaction', () => { + const signTransactionState = { + send: { + asset: {}, + stage: '', + draftTransaction: {}, + recipient: {}, + amount: {}, + }, + }; + + it('should show confirm tx page when no other conditions for signing have been met', async () => { + global.ethQuery = { + sendTransaction: sinon.stub(), + }; + + const store = mockStore(signTransactionState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('SHOW_CONF_TX_PAGE'); + }); + + it('should create actions for updateTransaction rejecting', async () => { + const editStageSignTxState = { + metamask: { + unapprovedTxs: { + 1: { + id: 1, + txParams: { + value: 'oldTxValue', + }, + }, + }, + }, + send: { + ...signTransactionState.send, + stage: SEND_STAGES.EDIT, + draftTransaction: { + id: 1, + txParams: { + value: 'newTxValue', + }, + }, + }, + }; + + jest.mock('../../store/actions.js'); + + const store = mockStore(editStageSignTxState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(5); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('UPDATE_TRANSACTION_PARAMS'); + expect(actionResult[2].type).toStrictEqual('HIDE_LOADING_INDICATION'); + }); + }); + + describe('editTransaction', () => { + it('should set up the appropriate state for editing a native asset transaction', async () => { + const editTransactionState = { + metamask: { + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xRecipientAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0xde0b6b3a7640000', // 1000000000000000000 + }, + }, + }, + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(editTransactionState); + + await store.dispatch(editTransaction(ASSET_TYPES.NATIVE, 1)); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('send/editTransaction'); + expect(actionResult[0].payload).toStrictEqual({ + address: '0xRecipientAddress', + amount: '0xde0b6b3a7640000', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[0]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + + it('should set up the appropriate state for editing a token asset transaction', async () => { + const editTransactionState = { + metamask: { + blockGasLimit: '0x3a98', + selectedAddress: '', + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xTokenAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0x0', + }, + }, + }, + }, + send: { + account: { + address: '0xAddress', + balance: '0x0', + }, + asset: { + type: '', + }, + gas: { + gasPrice: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + getCode: jest.fn(() => '0xa'), + }; + + const store = mockStore(editTransactionState); + + await store.dispatch( + editTransaction( + ASSET_TYPES.TOKEN, + 1, + { + name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, + args: { + _to: '0xRecipientAddress', + _value: ethers.BigNumber.from(15000), + }, + }, + { address: '0xAddress', symbol: 'SYMB', decimals: 18 }, + ), + ); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(7); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].type).toStrictEqual('send/updateAsset'); + expect(actionResult[2].payload).toStrictEqual({ + balance: '0x0', + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 18, + symbol: 'SYMB', + isERC721: false, + }, + }); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + expect(actionResult[6].type).toStrictEqual('send/editTransaction'); + expect(actionResult[6].payload).toStrictEqual({ + address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase + amount: '0x3a98', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[6]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + }); + }); +}); diff --git a/ui/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/pages/confirm-send-ether/confirm-send-ether.container.js index 475ee5213..5f7527226 100644 --- a/ui/pages/confirm-send-ether/confirm-send-ether.container.js +++ b/ui/pages/confirm-send-ether/confirm-send-ether.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; -import { updateSend } from '../../ducks/send/send.duck'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -18,22 +18,8 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { editTransaction: (txData) => { - const { id, txParams } = txData; - const { from, gas: gasLimit, gasPrice, to, value: amount } = txParams; - - dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - }), - ); - + const { id } = txData; + dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString())); dispatch(clearConfirmTransaction()); }, }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.container.js b/ui/pages/confirm-send-token/confirm-send-token.container.js index fd869a9a7..d823e25aa 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -3,13 +3,8 @@ import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { showSendTokenPage } from '../../store/actions'; -import { conversionUtil } from '../../helpers/utils/conversion-util'; -import { - getTokenValueParam, - getTokenAddressParam, -} from '../../helpers/utils/token-util'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors'; -import { updateSend } from '../../ducks/send/send.duck'; import ConfirmSendToken from './confirm-send-token.component'; const mapStateToProps = (state) => { @@ -22,35 +17,15 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - editTransaction: ({ txData, tokenData, tokenProps }) => { - const { - id, - txParams: { from, to: tokenAddress, gas: gasLimit, gasPrice } = {}, - } = txData; - - const to = getTokenValueParam(tokenData); - const tokenAmountInDec = getTokenAddressParam(tokenData); - - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }); - + editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => { + const { id } = txData; dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - token: { - ...tokenProps, - address: tokenAddress, - }, - }), + editTransaction( + ASSET_TYPES.TOKEN, + id.toString(), + tokenData, + assetDetails, + ), ); dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); diff --git a/ui/pages/confirm-transaction/conf-tx.js b/ui/pages/confirm-transaction/conf-tx.js index 4f2028197..8990e21c5 100644 --- a/ui/pages/confirm-transaction/conf-tx.js +++ b/ui/pages/confirm-transaction/conf-tx.js @@ -12,6 +12,7 @@ import Loading from '../../components/ui/loading-screen'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; +import { getSendTo } from '../../ducks/send'; function mapStateToProps(state) { const { metamask, appState } = state; @@ -38,7 +39,7 @@ function mapStateToProps(state) { unapprovedMsgCount, unapprovedPersonalMsgCount, unapprovedTypedMessagesCount, - send: state.send, + sendTo: getSendTo(state), currentNetworkTxList: state.metamask.currentNetworkTxList, }; } @@ -68,9 +69,7 @@ class ConfirmTxScreen extends Component { history: PropTypes.object, identities: PropTypes.object, dispatch: PropTypes.func.isRequired, - send: PropTypes.shape({ - to: PropTypes.string, - }).isRequired, + sendTo: PropTypes.string, }; getUnapprovedMessagesTotal() { @@ -182,13 +181,13 @@ class ConfirmTxScreen extends Component { mostRecentOverviewPage, network, chainId, - send, + sendTo, } = this.props; const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId); if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { history.push(mostRecentOverviewPage); @@ -201,7 +200,7 @@ class ConfirmTxScreen extends Component { network, chainId, currentNetworkTxList, - send, + sendTo, history, match: { params: { id: transactionId } = {} }, mostRecentOverviewPage, @@ -241,7 +240,7 @@ class ConfirmTxScreen extends Component { if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { this.props.history.push(mostRecentOverviewPage); diff --git a/ui/pages/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirm-transaction/confirm-transaction.component.js index b88424cc7..5bc374edc 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirm-transaction/confirm-transaction.component.js @@ -35,7 +35,7 @@ export default class ConfirmTransaction extends Component { static propTypes = { history: PropTypes.object.isRequired, totalUnapprovedCount: PropTypes.number.isRequired, - send: PropTypes.object, + sendTo: PropTypes.string, setTransactionToConfirm: PropTypes.func, clearConfirmTransaction: PropTypes.func, fetchBasicGasEstimates: PropTypes.func, @@ -52,7 +52,7 @@ export default class ConfirmTransaction extends Component { componentDidMount() { const { totalUnapprovedCount = 0, - send = {}, + sendTo, history, mostRecentOverviewPage, transaction: { txParams: { data, to } = {} } = {}, @@ -64,7 +64,7 @@ export default class ConfirmTransaction extends Component { isTokenMethodAction, } = this.props; - if (!totalUnapprovedCount && !send.to) { + if (!totalUnapprovedCount && !sendTo) { history.replace(mostRecentOverviewPage); return; } diff --git a/ui/pages/confirm-transaction/confirm-transaction.container.js b/ui/pages/confirm-transaction/confirm-transaction.container.js index b04ee76fa..bf0020c64 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.container.js +++ b/ui/pages/confirm-transaction/confirm-transaction.container.js @@ -15,17 +15,18 @@ import { } from '../../store/actions'; import { unconfirmedTransactionsListSelector } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; +import { getSendTo } from '../../ducks/send'; import ConfirmTransaction from './confirm-transaction.component'; const mapStateToProps = (state, ownProps) => { const { metamask: { unapprovedTxs }, - send, } = state; const { match: { params = {} }, } = ownProps; const { id } = params; + const sendTo = getSendTo(state); const unconfirmedTransactions = unconfirmedTransactionsListSelector(state); const totalUnconfirmed = unconfirmedTransactions.length; @@ -36,7 +37,7 @@ const mapStateToProps = (state, ownProps) => { return { totalUnapprovedCount: totalUnconfirmed, - send, + sendTo, unapprovedTxs, id, mostRecentOverviewPage: getMostRecentOverviewPage(state), diff --git a/ui/pages/send/index.js b/ui/pages/send/index.js index 36fa285d4..2fc7580b7 100644 --- a/ui/pages/send/index.js +++ b/ui/pages/send/index.js @@ -1 +1 @@ -export { default } from './send.container'; +export { default } from './send'; diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.js index da7999c94..322dca677 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.js @@ -8,26 +8,28 @@ import RecipientGroup from '../../../../components/app/contact-list/recipient-gr import { ellipsify } from '../../send.utils'; import Button from '../../../../components/ui/button'; import Confusable from '../../../../components/ui/confusable'; -import { - isBurnAddress, - isValidHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; export default class AddRecipient extends Component { static propTypes = { - query: PropTypes.string, + userInput: PropTypes.string, ownedAccounts: PropTypes.array, addressBook: PropTypes.array, - updateGas: PropTypes.func, - updateSendTo: PropTypes.func, + updateRecipient: PropTypes.func, ensResolution: PropTypes.string, - toError: PropTypes.string, - toWarning: PropTypes.string, - ensResolutionError: PropTypes.string, + ensError: PropTypes.string, + ensWarning: PropTypes.string, addressBookEntryName: PropTypes.string, contacts: PropTypes.array, nonContacts: PropTypes.array, - setInternalSearch: PropTypes.func, + useMyAccountsForRecipientSearch: PropTypes.func, + useContactListForRecipientSearch: PropTypes.func, + isUsingMyAccountsForRecipientSearch: PropTypes.bool, + recipient: PropTypes.shape({ + address: PropTypes.string, + nickname: PropTypes.nickname, + error: PropTypes.string, + warning: PropTypes.string, + }), }; constructor(props) { @@ -61,60 +63,58 @@ export default class AddRecipient extends Component { metricsEvent: PropTypes.func, }; - state = { - isShowingTransfer: false, - }; - - selectRecipient = (to, nickname = '') => { - const { updateSendTo, updateGas } = this.props; - - updateSendTo(to, nickname); - updateGas({ to }); + selectRecipient = (address, nickname = '') => { + this.props.updateRecipient({ address, nickname }); }; searchForContacts = () => { - const { query, contacts } = this.props; + const { userInput, contacts } = this.props; let _contacts = contacts; - if (query) { + if (userInput) { this.contactFuse.setCollection(contacts); - _contacts = this.contactFuse.search(query); + _contacts = this.contactFuse.search(userInput); } return _contacts; }; searchForRecents = () => { - const { query, nonContacts } = this.props; + const { userInput, nonContacts } = this.props; let _nonContacts = nonContacts; - if (query) { + if (userInput) { this.recentFuse.setCollection(nonContacts); - _nonContacts = this.recentFuse.search(query); + _nonContacts = this.recentFuse.search(userInput); } return _nonContacts; }; render() { - const { ensResolution, query, addressBookEntryName } = this.props; - const { isShowingTransfer } = this.state; + const { + ensResolution, + recipient, + userInput, + addressBookEntryName, + isUsingMyAccountsForRecipientSearch, + } = this.props; let content; - if ( - !isBurnAddress(query) && - isValidHexAddress(query, { mixedCaseUseChecksum: true }) - ) { - content = this.renderExplicitAddress(query); + if (recipient.address) { + content = this.renderExplicitAddress( + recipient.address, + recipient.nickname, + ); } else if (ensResolution) { content = this.renderExplicitAddress( ensResolution, - addressBookEntryName || query, + addressBookEntryName || userInput, ); - } else if (isShowingTransfer) { + } else if (isUsingMyAccountsForRecipientSearch) { content = this.renderTransfer(); } @@ -150,15 +150,18 @@ export default class AddRecipient extends Component { renderTransfer() { let { ownedAccounts } = this.props; - const { query, setInternalSearch } = this.props; + const { + userInput, + useContactListForRecipientSearch, + isUsingMyAccountsForRecipientSearch, + } = this.props; const { t } = this.context; - const { isShowingTransfer } = this.state; - if (isShowingTransfer && query) { + if (isUsingMyAccountsForRecipientSearch && userInput) { ownedAccounts = ownedAccounts.filter( (item) => - item.name.toLowerCase().indexOf(query.toLowerCase()) > -1 || - item.address.toLowerCase().indexOf(query.toLowerCase()) > -1, + item.name.toLowerCase().indexOf(userInput.toLowerCase()) > -1 || + item.address.toLowerCase().indexOf(userInput.toLowerCase()) > -1, ); } @@ -167,10 +170,7 @@ export default class AddRecipient extends Component { @@ -219,30 +216,19 @@ export default class AddRecipient extends Component { } renderDialogs() { - const { - toError, - toWarning, - ensResolutionError, - ensResolution, - } = this.props; + const { ensError, recipient, ensWarning } = this.props; const { t } = this.context; - if (ensResolutionError) { - return ( - - {ensResolutionError} - - ); - } else if (toError && toError !== 'required' && !ensResolution) { + if (ensError || (recipient.error && recipient.error !== 'required')) { return ( - {t(toError)} + {t(ensError ?? recipient.error)} ); - } else if (toWarning) { + } else if (ensWarning || recipient.warning) { return ( - {t(toWarning)} + {t(ensWarning ?? recipient.warning)} ); } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js index ec2772f13..7c58d36fa 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js @@ -5,30 +5,24 @@ import Dialog from '../../../../components/ui/dialog'; import AddRecipient from './add-recipient.component'; const propsMethodSpies = { - closeToDropdown: sinon.spy(), - openToDropdown: sinon.spy(), - updateGas: sinon.spy(), - updateSendTo: sinon.spy(), - updateSendToError: sinon.spy(), - updateSendToWarning: sinon.spy(), + updateRecipient: sinon.spy(), + useMyAccountsForRecipientSearch: sinon.spy(), + useContactListForRecipientSearch: sinon.spy(), }; describe('AddRecipient Component', () => { let wrapper; - let instance; beforeEach(() => { wrapper = shallow( { />, { context: { t: (str) => `${str}_t` } }, ); - instance = wrapper.instance(); }); afterEach(() => { - propsMethodSpies.closeToDropdown.resetHistory(); - propsMethodSpies.openToDropdown.resetHistory(); - propsMethodSpies.updateSendTo.resetHistory(); - propsMethodSpies.updateSendToError.resetHistory(); - propsMethodSpies.updateSendToWarning.resetHistory(); - propsMethodSpies.updateGas.resetHistory(); - }); - - describe('selectRecipient', () => { - it('should call updateSendTo', () => { - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(0); - instance.selectRecipient('mockTo2', 'mockNickname'); - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.updateSendTo.getCall(0).args).toStrictEqual([ - 'mockTo2', - 'mockNickname', - ]); - }); - - it('should call updateGas if there is no to error', () => { - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(0); - instance.selectRecipient(false); - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(1); - }); + propsMethodSpies.updateRecipient.resetHistory(); + propsMethodSpies.useMyAccountsForRecipientSearch.resetHistory(); + propsMethodSpies.useContactListForRecipientSearch.resetHistory(); }); describe('render', () => { @@ -104,6 +76,7 @@ describe('AddRecipient Component', () => { it('should render transfer', () => { wrapper.setProps({ + isUsingMyAccountsForRecipientSearch: true, ownedAccounts: [ { address: '0x123', name: '123' }, { address: '0x124', name: '124' }, @@ -163,7 +136,7 @@ describe('AddRecipient Component', () => { it('should render error when query has no results', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', contacts: [], nonContacts: [], }); @@ -178,8 +151,7 @@ describe('AddRecipient Component', () => { it('should render error when query has ens does not resolve', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', - ensResolutionError: 'very bad', + ensError: 'very bad', contacts: [], nonContacts: [], }); @@ -187,20 +159,20 @@ describe('AddRecipient Component', () => { const dialog = wrapper.find(Dialog); expect(dialog.props().type).toStrictEqual('error'); - expect(dialog.props().children).toStrictEqual('very bad'); + expect(dialog.props().children).toStrictEqual('very bad_t'); expect(dialog).toHaveLength(1); }); - it('should not render error when ens resolved', () => { + it('should render error when ens resolved but ens error exists', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', ensResolution: '0x128', }); const dialog = wrapper.find(Dialog); - expect(dialog).toHaveLength(0); + expect(dialog).toHaveLength(1); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.js index c131ebb7f..27353e778 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.js @@ -1,19 +1,30 @@ import { connect } from 'react-redux'; import { - getSendEnsResolution, - getSendEnsResolutionError, accountsWithSendEtherInfoSelector, getAddressBook, getAddressBookEntry, } from '../../../../selectors'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; +import { + updateRecipient, + updateRecipientUserInput, + useMyAccountsForRecipientSearch, + useContactListForRecipientSearch, + getIsUsingMyAccountForRecipientSearch, + getRecipientUserInput, + getRecipient, +} from '../../../../ducks/send'; +import { + getEnsResolution, + getEnsError, + getEnsWarning, +} from '../../../../ducks/ens'; import AddRecipient from './add-recipient.component'; export default connect(mapStateToProps, mapDispatchToProps)(AddRecipient); function mapStateToProps(state) { - const ensResolution = getSendEnsResolution(state); + const ensResolution = getEnsResolution(state); let addressBookEntryName = ''; if (ensResolution) { @@ -32,14 +43,27 @@ function mapStateToProps(state) { addressBookEntryName, contacts: addressBook.filter(({ name }) => Boolean(name)), ensResolution, - ensResolutionError: getSendEnsResolutionError(state), + ensError: getEnsError(state), + ensWarning: getEnsWarning(state), nonContacts: addressBook.filter(({ name }) => !name), ownedAccounts, + isUsingMyAccountsForRecipientSearch: getIsUsingMyAccountForRecipientSearch( + state, + ), + userInput: getRecipientUserInput(state), + recipient: getRecipient(state), }; } function mapDispatchToProps(dispatch) { return { - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), + updateRecipient: ({ address, nickname }) => + dispatch(updateRecipient({ address, nickname })), + updateRecipientUserInput: (newInput) => + dispatch(updateRecipientUserInput(newInput)), + useMyAccountsForRecipientSearch: () => + dispatch(useMyAccountsForRecipientSearch()), + useContactListForRecipientSearch: () => + dispatch(useContactListForRecipientSearch()), }; } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js index 1d8e05bdc..81db6cf28 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js @@ -1,6 +1,3 @@ -import sinon from 'sinon'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; - let mapStateToProps; let mapDispatchToProps; @@ -13,8 +10,6 @@ jest.mock('react-redux', () => ({ })); jest.mock('../../../../selectors', () => ({ - getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`, - getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`, getAddressBook: (s) => [{ name: `mockAddressBook:${s}` }], getAddressBookEntry: (s) => `mockAddressBookEntry:${s}`, accountsWithSendEtherInfoSelector: () => [ @@ -23,8 +18,26 @@ jest.mock('../../../../selectors', () => ({ ], })); -jest.mock('../../../../ducks/send/send.duck.js', () => ({ - updateSendTo: jest.fn(), +jest.mock('../../../../ducks/ens', () => ({ + getEnsResolution: (s) => `mockSendEnsResolution:${s}`, + getEnsError: (s) => `mockSendEnsResolutionError:${s}`, + getEnsWarning: (s) => `mockSendEnsResolutionWarning:${s}`, + useMyAccountsForRecipientSearch: (s) => + `useMyAccountsForRecipientSearch:${s}`, +})); + +jest.mock('../../../../ducks/send', () => ({ + updateRecipient: ({ address, nickname }) => + `{mockUpdateRecipient: {address: ${address}, nickname: ${nickname}}}`, + updateRecipientUserInput: (s) => `mockUpdateRecipientUserInput:${s}`, + useMyAccountsForRecipientSearch: (s) => + `mockUseMyAccountsForRecipientSearch:${s}`, + useContactListForRecipientSearch: (s) => + `mockUseContactListForRecipientSearch:${s}`, + getIsUsingMyAccountForRecipientSearch: (s) => + `mockGetIsUsingMyAccountForRecipientSearch:${s}`, + getRecipientUserInput: (s) => `mockRecipientUserInput:${s}`, + getRecipient: (s) => `mockRecipient:${s}`, })); require('./add-recipient.container.js'); @@ -34,29 +47,40 @@ describe('add-recipient container', () => { it('should map the correct properties to props', () => { expect(mapStateToProps('mockState')).toStrictEqual({ addressBook: [{ name: 'mockAddressBook:mockState' }], + addressBookEntryName: undefined, contacts: [{ name: 'mockAddressBook:mockState' }], ensResolution: 'mockSendEnsResolution:mockState', - ensResolutionError: 'mockSendEnsResolutionError:mockState', + ensError: 'mockSendEnsResolutionError:mockState', + ensWarning: 'mockSendEnsResolutionWarning:mockState', + nonContacts: [], ownedAccounts: [ - { name: `account1:mockState` }, - { name: `account2:mockState` }, + { name: 'account1:mockState' }, + { name: 'account2:mockState' }, ], - addressBookEntryName: undefined, - nonContacts: [], + isUsingMyAccountsForRecipientSearch: + 'mockGetIsUsingMyAccountForRecipientSearch:mockState', + userInput: 'mockRecipientUserInput:mockState', + recipient: 'mockRecipient:mockState', }); }); }); describe('mapDispatchToProps()', () => { - describe('updateSendTo()', () => { - const dispatchSpy = sinon.spy(); + describe('updateRecipient()', () => { + const dispatchSpy = jest.fn(); + const mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendTo).toHaveBeenCalled(); - expect(updateSendTo).toHaveBeenCalledWith('mockTo', 'mockNickname'); + mapDispatchToPropsObject.updateRecipient({ + address: 'mockAddress', + nickname: 'mockNickname', + }); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy.mock.calls[0][0]).toStrictEqual( + '{mockUpdateRecipient: {address: mockAddress, nickname: mockNickname}}', + ); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.js b/ui/pages/send/send-content/add-recipient/add-recipient.js deleted file mode 100644 index 5141fda1d..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.js +++ /dev/null @@ -1,56 +0,0 @@ -import contractMap from '@metamask/contract-metadata'; -import { isConfusing } from 'unicode-confusables'; -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; - -import { - checkExistingAddresses, - isValidDomainName, - isOriginContractAddress, - isDefaultMetaMaskChain, -} from '../../../../helpers/utils/util'; -import { - isBurnAddress, - isValidHexAddress, - toChecksumHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; - -export function getToErrorObject(to, sendTokenAddress, chainId) { - let toError = null; - if (!to) { - toError = REQUIRED_ERROR; - } else if ( - isBurnAddress(to) || - (!isValidHexAddress(to, { mixedCaseUseChecksum: true }) && - !isValidDomainName(to)) - ) { - toError = isDefaultMetaMaskChain(chainId) - ? INVALID_RECIPIENT_ADDRESS_ERROR - : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; - } else if (isOriginContractAddress(to, sendTokenAddress)) { - toError = CONTRACT_ADDRESS_ERROR; - } - - return { to: toError }; -} - -export function getToWarningObject(to, tokens = [], sendToken = null) { - let toWarning = null; - if ( - sendToken && - (toChecksumHexAddress(to) in contractMap || - checkExistingAddresses(to, tokens)) - ) { - toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR; - } else if (isValidDomainName(to) && isConfusing(to)) { - toWarning = CONFUSING_ENS_ERROR; - } - - return { to: toWarning }; -} diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js deleted file mode 100644 index 4a9605d32..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; -import { getToErrorObject, getToWarningObject } from './add-recipient'; - -jest.mock('../../../../helpers/utils/util', () => ({ - isDefaultMetaMaskChain: jest.fn().mockReturnValue(true), - isEthNetwork: jest.fn().mockReturnValue(true), - checkExistingAddresses: jest.fn().mockReturnValue(true), - isValidDomainName: jest.requireActual('../../../../helpers/utils/util') - .isValidDomainName, - isOriginContractAddress: jest.requireActual('../../../../helpers/utils/util') - .isOriginContractAddress, -})); - -jest.mock('../../../../../shared/modules/hexstring-utils', () => ({ - isValidHexAddress: jest.fn((to) => - Boolean(to.match(/^[0xabcdef123456798]+$/u)), - ), - isBurnAddress: jest.fn(() => false), - toChecksumHexAddress: jest.fn((input) => input), -})); - -describe('add-recipient utils', () => { - describe('getToErrorObject()', () => { - it('should return a required error if "to" is falsy', () => { - expect(getToErrorObject(null)).toStrictEqual({ - to: REQUIRED_ERROR, - }); - }); - - it('should return an invalid recipient error if "to" is truthy but invalid', () => { - expect(getToErrorObject('mockInvalidTo')).toStrictEqual({ - to: INVALID_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should return null if "to" is truthy and valid', () => { - expect(getToErrorObject('0xabc123')).toStrictEqual({ - to: null, - }); - }); - - it('should return a contract address error if the recipient is the same as the tokens contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc123')).toStrictEqual({ - to: CONTRACT_ADDRESS_ERROR, - }); - }); - - it('should return null if the recipient address is not the token contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc456')).toStrictEqual({ - to: null, - }); - }); - }); - - describe('getToWarningObject()', () => { - it('should return a known address recipient error if "to" is a token address', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }], { - address: '0xabc123', - }), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should null if "to" is a token address but sendToken is falsy', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }]), - ).toStrictEqual({ - to: null, - }); - }); - - it('should return a known address recipient error if "to" is part of contract metadata', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - it('should null if "to" is part of contract metadata but sendToken is falsy', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should warn if name is a valid domain and confusable', () => { - expect(getToWarningObject('demo.eth')).toStrictEqual({ - to: CONFUSING_ENS_ERROR, - }); - }); - - it('should not warn if name is a valid domain and not confusable', () => { - expect(getToWarningObject('vitalik.eth')).toStrictEqual({ - to: null, - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/add-recipient/ens-input.component.js b/ui/pages/send/send-content/add-recipient/ens-input.component.js index 658ac9bde..bb1c7f3e7 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.component.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.component.js @@ -2,146 +2,39 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { debounce } from 'lodash'; -import copyToClipboard from 'copy-to-clipboard/index'; -import ENS from 'ethjs-ens'; -import networkMap from 'ethereum-ens-network-map'; -import log from 'loglevel'; -import { isHexString } from 'ethereumjs-util'; import { ellipsify } from '../../send.utils'; import { isValidDomainName } from '../../../../helpers/utils/util'; -import { MAINNET_NETWORK_ID } from '../../../../../shared/constants/network'; import { isBurnAddress, isValidHexAddress, } from '../../../../../shared/modules/hexstring-utils'; -// Local Constants -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -const ZERO_X_ERROR_ADDRESS = '0x'; - export default class EnsInput extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, }; static propTypes = { className: PropTypes.string, - network: PropTypes.string, selectedAddress: PropTypes.string, selectedName: PropTypes.string, - onChange: PropTypes.func, - updateEnsResolution: PropTypes.func, scanQrCode: PropTypes.func, - updateEnsResolutionError: PropTypes.func, onPaste: PropTypes.func, - onReset: PropTypes.func, onValidAddressTyped: PropTypes.func, - contact: PropTypes.object, - value: PropTypes.string, internalSearch: PropTypes.bool, - }; - - state = { - input: '', - toError: null, - ensResolution: undefined, + userInput: PropTypes.string, + onChange: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + lookupEnsName: PropTypes.func.isRequired, + initializeEnsSlice: PropTypes.func.isRequired, + resetEnsResolution: PropTypes.func.isRequired, }; componentDidMount() { - const { network, internalSearch } = this.props; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ ensResolution: ZERO_ADDRESS }); - - if (networkHasEnsSupport && !internalSearch) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - } + this.props.initializeEnsSlice(); } - componentDidUpdate(prevProps) { - const { input } = this.state; - const { network, value, internalSearch } = this.props; - - let newValue; - - // Set the value of our input based on QR code provided by parent - const newProvidedValue = input !== value && prevProps.value !== value; - if (newProvidedValue) { - newValue = value; - } - - if (prevProps.network !== network) { - if (getNetworkEnsSupport(network)) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - if (!newProvidedValue) { - newValue = input; - } - } else { - // ens is null on mount on a network that does not have ens support - // this is intended to prevent accidental lookup of domains across - // networks - this.ens = null; - this.checkName = null; - } - } - - if (newValue !== undefined) { - this.onChange({ target: { value: newValue } }); - } - if (!internalSearch && prevProps.internalSearch) { - this.resetInput(); - } - } - - resetInput = () => { - const { - updateEnsResolution, - updateEnsResolutionError, - onReset, - } = this.props; - this.onChange({ target: { value: '' } }); - onReset(); - updateEnsResolution(''); - updateEnsResolutionError(''); - }; - - lookupEnsName = (ensName) => { - const { network } = this.props; - const recipient = ensName.trim(); - - log.info(`ENS attempting to resolve name: ${recipient}`); - this.ens - .lookup(recipient) - .then((address) => { - if (address === ZERO_ADDRESS) { - throw new Error(this.context.t('noAddressForName')); - } - if (address === ZERO_X_ERROR_ADDRESS) { - throw new Error(this.context.t('ensRegistrationError')); - } - this.props.updateEnsResolution(address); - }) - .catch((reason) => { - if ( - isValidDomainName(recipient) && - reason.message === 'ENS name not defined.' - ) { - this.props.updateEnsResolutionError( - network === MAINNET_NETWORK_ID - ? this.context.t('noAddressForName') - : this.context.t('ensNotFoundOnCurrentNetwork'), - ); - } else { - log.error(reason); - this.props.updateEnsResolutionError(reason.message); - } - }); - }; - onPaste = (event) => { event.clipboardData.items[0].getAsString((text) => { if ( @@ -155,40 +48,23 @@ export default class EnsInput extends Component { onChange = (e) => { const { - network, - onChange, - updateEnsResolution, - updateEnsResolutionError, onValidAddressTyped, internalSearch, + onChange, + lookupEnsName, + resetEnsResolution, } = this.props; const input = e.target.value; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ input }, () => onChange(input)); + onChange(input); if (internalSearch) { return null; } // Empty ENS state if input is empty // maybe scan ENS - if ( - !networkHasEnsSupport && - !( - isBurnAddress(input) === false && - isValidHexAddress(input, { mixedCaseUseChecksum: true }) - ) && - !isHexString(input) - ) { - updateEnsResolution(''); - updateEnsResolutionError( - networkHasEnsSupport ? '' : 'Network does not support ENS', - ); - return null; - } - if (isValidDomainName(input)) { - this.lookupEnsName(input); + lookupEnsName(input); } else if ( onValidAddressTyped && !isBurnAddress(input) && @@ -196,20 +72,16 @@ export default class EnsInput extends Component { ) { onValidAddressTyped(input); } else { - updateEnsResolution(''); - updateEnsResolutionError(''); + resetEnsResolution(); } return null; }; render() { const { t } = this.context; - const { className, selectedAddress } = this.props; - const { input } = this.state; + const { className, selectedAddress, selectedName, userInput } = this.props; - if (selectedAddress) { - return this.renderSelected(); - } + const hasSelectedAddress = Boolean(selectedAddress); return (
@@ -217,135 +89,61 @@ export default class EnsInput extends Component { className={classnames('ens-input__wrapper', { 'ens-input__wrapper__status-icon--error': false, 'ens-input__wrapper__status-icon--valid': false, + 'ens-input__wrapper--valid': hasSelectedAddress, })} > -
- -
-
- ); - } - - renderSelected() { - const { t } = this.context; - const { - className, - selectedAddress, - selectedName, - contact = {}, - } = this.props; - const name = contact.name || selectedName; - - return ( -
-
-
-
-
- {name || ellipsify(selectedAddress)} -
- {name && ( -
- {selectedAddress} + {hasSelectedAddress ? ( + <> +
+
+ {selectedName || ellipsify(selectedAddress)} +
+ {selectedName && ( +
+ {selectedAddress} +
+ )}
- )} -
-
+
+ + ) : ( + <> + +
); } - - ensIcon(recipient) { - const { hoverText } = this.state; - - return ( - - {this.ensIconContents(recipient)} - - ); - } - - ensIconContents() { - const { loadingEns, ensFailure, ensResolution, toError } = this.state; - - if (toError) { - return null; - } - - if (loadingEns) { - return ( - - ); - } - - if (ensFailure) { - return ; - } - - if (ensResolution && ensResolution !== ZERO_ADDRESS) { - return ( - { - event.preventDefault(); - event.stopPropagation(); - copyToClipboard(ensResolution); - }} - /> - ); - } - - return null; - } -} - -function getNetworkEnsSupport(network) { - return Boolean(networkMap[network]); } diff --git a/ui/pages/send/send-content/add-recipient/ens-input.container.js b/ui/pages/send/send-content/add-recipient/ens-input.container.js index 90d2c3ff4..ef61fce85 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.container.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.container.js @@ -1,20 +1,18 @@ +import { debounce } from 'lodash'; import { connect } from 'react-redux'; -import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network'; import { - getSendTo, - getSendToNickname, - getAddressBookEntry, - getCurrentChainId, -} from '../../../../selectors'; + lookupEnsName, + initializeEnsSlice, + resetResolution, +} from '../../../../ducks/ens'; import EnsInput from './ens-input.component'; -export default connect((state) => { - const selectedAddress = getSendTo(state); - const chainId = getCurrentChainId(state); +function mapDispatchToProps(dispatch) { return { - network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], - selectedAddress, - selectedName: getSendToNickname(state), - contact: getAddressBookEntry(state, selectedAddress), + lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150), + initializeEnsSlice: () => dispatch(initializeEnsSlice()), + resetEnsResolution: debounce(() => dispatch(resetResolution()), 300), }; -})(EnsInput); +} + +export default connect(null, mapDispatchToProps)(EnsInput); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js deleted file mode 100644 index 8c38d3be5..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import AmountMaxButton from './amount-max-button.component'; - -describe('AmountMaxButton Component', () => { - let wrapper; - let instance; - - const propsMethodSpies = { - setAmountToMax: sinon.spy(), - setMaxModeTo: sinon.spy(), - }; - - const MOCK_EVENT = { preventDefault: () => undefined }; - - beforeAll(() => { - sinon.spy(AmountMaxButton.prototype, 'setMaxAmount'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { - context: { - t: (str) => `${str}_t`, - metricsEvent: () => undefined, - }, - }, - ); - instance = wrapper.instance(); - }); - - afterEach(() => { - propsMethodSpies.setAmountToMax.resetHistory(); - propsMethodSpies.setMaxModeTo.resetHistory(); - AmountMaxButton.prototype.setMaxAmount.resetHistory(); - }); - - afterAll(() => { - sinon.restore(); - }); - - describe('setMaxAmount', () => { - it('should call setAmountToMax with the correct params', () => { - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(0); - instance.setMaxAmount(); - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(1); - expect(propsMethodSpies.setAmountToMax.getCall(0).args).toStrictEqual([ - { - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }, - ]); - }); - }); - - describe('render', () => { - it('should render an element with a send-v2__amount-max class', () => { - expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1); - }); - - it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => { - const { onClick } = wrapper.find('.send-v2__amount-max').props(); - - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(0); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(0); - onClick(MOCK_EVENT); - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.getCall(0).args).toStrictEqual([ - true, - ]); - }); - - it('should render the expected text when maxModeOn is false', () => { - wrapper.setProps({ maxModeOn: false }); - expect(wrapper.find('.send-v2__amount-max').text()).toStrictEqual( - 'max_t', - ); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js deleted file mode 100644 index a2fe64b94..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ /dev/null @@ -1,42 +0,0 @@ -import { connect } from 'react-redux'; -import { - getGasTotal, - getSendToken, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getBasicGasEstimateLoadingStatus, -} from '../../../../../selectors'; -import { - updateSendErrors, - updateSendAmount, - setMaxModeTo, -} from '../../../../../ducks/send/send.duck'; -import { calcMaxAmount } from './amount-max-button.utils'; -import AmountMaxButton from './amount-max-button.component'; - -export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton); - -function mapStateToProps(state) { - return { - balance: getSendFromBalance(state), - buttonDataLoading: getBasicGasEstimateLoadingStatus(state), - gasTotal: getGasTotal(state), - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, - clearMaxAmount: () => { - dispatch(updateSendAmount('0')); - }, - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), - }; -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js deleted file mode 100644 index cb86c88ff..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../../ducks/send/send.duck'; - -let mapStateToProps; -let mapDispatchToProps; - -jest.mock('react-redux', () => ({ - connect: (ms, md) => { - mapStateToProps = ms; - mapDispatchToProps = md; - return () => ({}); - }, -})); - -jest.mock('../../../../../selectors', () => ({ - getGasTotal: (s) => `mockGasTotal:${s}`, - getSendToken: (s) => `mockSendToken:${s}`, - getSendFromBalance: (s) => `mockBalance:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, - getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`, -})); - -jest.mock('./amount-max-button.utils.js', () => ({ - calcMaxAmount: (mockObj) => mockObj.val + 1, -})); - -jest.mock('../../../../../ducks/send/send.duck', () => ({ - setMaxModeTo: jest.fn(), - updateSendAmount: jest.fn(), - updateSendErrors: jest.fn(), -})); - -require('./amount-max-button.container.js'); - -describe('amount-max-button container', () => { - describe('mapStateToProps()', () => { - it('should map the correct properties to props', () => { - expect(mapStateToProps('mockState')).toStrictEqual({ - balance: 'mockBalance:mockState', - buttonDataLoading: 'mockButtonDataLoading:mockState', - gasTotal: 'mockGasTotal:mockState', - maxModeOn: 'mockMaxModeOn:mockState', - sendToken: 'mockSendToken:mockState', - tokenBalance: 'mockTokenBalance:mockState', - }); - }); - }); - - describe('mapDispatchToProps()', () => { - let dispatchSpy; - let mapDispatchToPropsObject; - - beforeEach(() => { - dispatchSpy = sinon.spy(); - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); - }); - - describe('setAmountToMax()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }); - expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ amount: null }); - expect(updateSendAmount).toHaveBeenCalled(); - expect(updateSendAmount).toHaveBeenCalledWith(12); - }); - }); - - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockVal'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalledWith('mockVal'); - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js new file mode 100644 index 000000000..7f143879b --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js @@ -0,0 +1,49 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { getBasicGasEstimateLoadingStatus } from '../../../../../selectors'; +import { + getSendMaxModeState, + isSendFormInvalid, + toggleSendMaxMode, +} from '../../../../../ducks/send'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useMetricEvent } from '../../../../../hooks/useMetricEvent'; + +export default function AmountMaxButton() { + const buttonDataLoading = useSelector(getBasicGasEstimateLoadingStatus); + const isDraftTransactionInvalid = useSelector(isSendFormInvalid); + const maxModeOn = useSelector(getSendMaxModeState); + const dispatch = useDispatch(); + const trackClickedMax = useMetricEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Clicked "Amount Max"', + }, + }); + const t = useI18nContext(); + + const onMaxClick = () => { + trackClickedMax(); + dispatch(toggleSendMaxMode()); + }; + + return ( + + ); +} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js new file mode 100644 index 000000000..7f4482517 --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; +import { renderWithProvider } from '../../../../../../test/jest'; +import AmountMaxButton from './amount-max-button'; + +const middleware = [thunk]; + +describe('AmountMaxButton Component', () => { + describe('render', () => { + it('should render a "Max" button', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + }), + ); + expect(getByText('Max')).toBeTruthy(); + }); + + it('should dispatch action to set mode to MAX', () => { + const store = configureMockStore(middleware)({ + send: { ...initialState, status: SEND_STATUSES.VALID }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'MAX' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + + it('should dispatch action to set amount mode to INPUT', () => { + const store = configureMockStore(middleware)({ + send: { + ...initialState, + status: SEND_STATUSES.VALID, + amount: { ...initialState.amount, mode: 'MAX' }, + }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'INPUT' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + }); +}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js deleted file mode 100644 index 6826b5e39..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - multiplyCurrencies, - subtractCurrencies, -} from '../../../../../helpers/utils/conversion-util'; -import { addHexPrefix } from '../../../../../../app/scripts/lib/util'; - -export function calcMaxAmount({ balance, gasTotal, sendToken, tokenBalance }) { - const { decimals } = sendToken || {}; - const multiplier = Math.pow(10, Number(decimals || 0)); - - return sendToken - ? multiplyCurrencies(tokenBalance, multiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }) - : subtractCurrencies(addHexPrefix(balance), addHexPrefix(gasTotal), { - toNumericBase: 'hex', - aBase: 16, - bBase: 16, - }); -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js deleted file mode 100644 index 87b334386..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { calcMaxAmount } from './amount-max-button.utils'; - -describe('amount-max-button utils', () => { - describe('calcMaxAmount()', () => { - it('should calculate the correct amount when no sendToken defined', () => { - expect( - calcMaxAmount({ - balance: 'ffffff', - gasTotal: 'ff', - sendToken: false, - }), - ).toStrictEqual('ffff00'); - }); - - it('should calculate the correct amount when a sendToken is defined', () => { - expect( - calcMaxAmount({ - sendToken: { - decimals: 10, - }, - tokenBalance: '64', - }), - ).toStrictEqual('e8d4a51000'); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js index 26d87ffb5..16657e95d 100644 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js @@ -1 +1 @@ -export { default } from './amount-max-button.container'; +export { default } from './amount-max-button'; diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js index 3f3d64e45..7cf67fdd1 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js @@ -1,111 +1,35 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; import SendRowWrapper from '../send-row-wrapper'; import UserPreferencedCurrencyInput from '../../../../components/app/user-preferenced-currency-input'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import AmountMaxButton from './amount-max-button'; export default class SendAmountRow extends Component { static propTypes = { amount: PropTypes.string, - balance: PropTypes.string, - conversionRate: PropTypes.number, - gasTotal: PropTypes.string, inError: PropTypes.bool, - primaryCurrency: PropTypes.string, - sendToken: PropTypes.object, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - updateGasFeeError: PropTypes.func, + asset: PropTypes.object, updateSendAmount: PropTypes.func, - updateSendAmountError: PropTypes.func, - updateGas: PropTypes.func, - maxModeOn: PropTypes.bool, }; static contextTypes = { t: PropTypes.func, }; - componentDidUpdate(prevProps) { - const { maxModeOn: prevMaxModeOn, gasTotal: prevGasTotal } = prevProps; - const { maxModeOn, amount, gasTotal, sendToken } = this.props; - - if (maxModeOn && sendToken && !prevMaxModeOn) { - this.updateGas(amount); - } - - if (prevGasTotal !== gasTotal) { - this.validateAmount(amount); - } - } - - updateGas = debounce(this.updateGas.bind(this), 500); - - validateAmount(amount) { - const { - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - updateGasFeeError, - updateSendAmountError, - } = this.props; - - updateSendAmountError({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - - if (sendToken) { - updateGasFeeError({ - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - } - } - - updateAmount(amount) { - const { updateSendAmount, setMaxModeTo } = this.props; - - setMaxModeTo(false); - updateSendAmount(amount); - } - - updateGas(amount) { - const { sendToken, updateGas } = this.props; - - if (sendToken) { - updateGas({ amount }); - } - } - handleChange = (newAmount) => { - this.validateAmount(newAmount); - this.updateGas(newAmount); - this.updateAmount(newAmount); + this.props.updateSendAmount(newAmount); }; renderInput() { - const { amount, inError, sendToken } = this.props; + const { amount, inError, asset } = this.props; - return sendToken ? ( + return asset.type === ASSET_TYPES.TOKEN ? ( ) : ( @@ -118,7 +42,7 @@ export default class SendAmountRow extends Component { } render() { - const { gasTotal, inError } = this.props; + const { inError } = this.props; return ( - {gasTotal && } + {this.renderInput()} ); diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js index 8ed1a7438..c6e8e23be 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js @@ -3,88 +3,13 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; -import AmountMaxButton from './amount-max-button/amount-max-button.container'; +import AmountMaxButton from './amount-max-button/amount-max-button'; describe('SendAmountRow Component', () => { - describe('validateAmount', () => { - it('should call updateSendAmountError with the correct params', () => { - const { - instance, - propsMethodSpies: { updateSendAmountError }, - } = shallowRenderSendAmountRow(); - - expect(updateSendAmountError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateSendAmountError.calledOnceWithExactly({ - amount: 'someAmount', - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call updateGasFeeError if sendToken is truthy', () => { - const { - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateGasFeeError.calledOnceWithExactly({ - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call not updateGasFeeError if sendToken is falsey', () => { - const { - wrapper, - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - wrapper.setProps({ sendToken: null }); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - }); - }); - describe('updateAmount', () => { - it('should call setMaxModeTo', () => { - const { - instance, - propsMethodSpies: { setMaxModeTo }, - } = shallowRenderSendAmountRow(); - - expect(setMaxModeTo.callCount).toStrictEqual(0); - - instance.updateAmount('someAmount'); - - expect(setMaxModeTo.calledOnceWithExactly(false)).toStrictEqual(true); - }); - it('should call updateSendAmount', () => { const { instance, @@ -93,7 +18,7 @@ describe('SendAmountRow Component', () => { expect(updateSendAmount.callCount).toStrictEqual(0); - instance.updateAmount('someAmount'); + instance.handleChange('someAmount'); expect( updateSendAmount.calledOnceWithExactly('someAmount'), @@ -136,10 +61,7 @@ describe('SendAmountRow Component', () => { }); it('should render the UserPreferencedTokenInput with the correct props', () => { - const { - wrapper, - instanceSpies: { updateGas, updateAmount, validateAmount }, - } = shallowRenderSendAmountRow(); + const { wrapper } = shallowRenderSendAmountRow(); const { onChange, error, value } = wrapper .find(SendRowWrapper) .childAt(1) @@ -147,67 +69,34 @@ describe('SendAmountRow Component', () => { expect(error).toStrictEqual(false); expect(value).toStrictEqual('mockAmount'); - expect(updateGas.callCount).toStrictEqual(0); - expect(updateAmount.callCount).toStrictEqual(0); - expect(validateAmount.callCount).toStrictEqual(0); onChange('mockNewAmount'); - - expect(updateGas.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect(updateAmount.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect( - validateAmount.calledOnceWithExactly('mockNewAmount'), - ).toStrictEqual(true); }); }); }); function shallowRenderSendAmountRow() { - const setMaxModeTo = sinon.spy(); - const updateGasFeeError = sinon.spy(); const updateSendAmount = sinon.spy(); - const updateSendAmountError = sinon.spy(); const wrapper = shallow( undefined} />, { context: { t: (str) => `${str}_t` } }, ); const instance = wrapper.instance(); - const updateAmount = sinon.spy(instance, 'updateAmount'); - const updateGas = sinon.spy(instance, 'updateGas'); - const validateAmount = sinon.spy(instance, 'validateAmount'); return { instance, wrapper, propsMethodSpies: { - setMaxModeTo, - updateGasFeeError, updateSendAmount, - updateSendAmountError, - }, - instanceSpies: { - updateAmount, - updateGas, - validateAmount, }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js index ea76e87a4..261c91168 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js @@ -1,21 +1,10 @@ import { connect } from 'react-redux'; import { - getGasTotal, - getPrimaryCurrency, - getSendToken, + updateSendAmount, getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, sendAmountIsInError, -} from '../../../../selectors'; -import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils'; -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; + getSendAsset, +} from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); @@ -23,26 +12,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); function mapStateToProps(state) { return { amount: getSendAmount(state), - balance: getSendFromBalance(state), - conversionRate: getConversionRate(state), - gasTotal: getGasTotal(state), inError: sendAmountIsInError(state), - primaryCurrency: getPrimaryCurrency(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - maxModeOn: getSendMaxModeState(state), + asset: getSendAsset(state), }; } function mapDispatchToProps(dispatch) { return { - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), updateSendAmount: (newAmount) => dispatch(updateSendAmount(newAmount)), - updateGasFeeError: (amountDataObject) => { - dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))); - }, - updateSendAmountError: (amountDataObject) => { - dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))); - }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js index edad05014..4911cb612 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js @@ -1,10 +1,6 @@ import sinon from 'sinon'; -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; +import { updateSendAmount } from '../../../../ducks/send'; let mapDispatchToProps; @@ -15,24 +11,7 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors/send.js', () => ({ - sendAmountIsInError: (s) => `mockInError:${s}`, -})); - -jest.mock('../../send.utils', () => ({ - getAmountErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockChange: true, - }), - getGasFeeErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockGasFeeErrorChange: true, - }), -})); - -jest.mock('../../../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - setMaxModeTo: jest.fn(), +jest.mock('../../../../ducks/send', () => ({ updateSendAmount: jest.fn(), })); @@ -48,15 +27,6 @@ describe('send-amount-row container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); }); - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockBool'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalled(); - expect(setMaxModeTo).toHaveBeenCalledWith('mockBool'); - }); - }); - describe('updateSendAmount()', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.updateSendAmount('mockAmount'); @@ -65,29 +35,5 @@ describe('send-amount-row container', () => { expect(updateSendAmount).toHaveBeenCalledWith('mockAmount'); }); }); - - describe('updateGasFeeError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockGasFeeErrorChange: true, - }); - }); - }); - - describe('updateSendAmountError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockChange: true, - }); - }); - }); }); }); 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 785b711b0..e6c1d9bb9 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 @@ -5,6 +5,7 @@ import Identicon from '../../../../components/ui/identicon/identicon.component'; import TokenBalance from '../../../../components/ui/token-balance'; import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display'; import { ERC20, PRIMARY } from '../../../../helpers/constants/common'; +import { ASSET_TYPES } from '../../../../ducks/send'; export default class SendAssetRow extends Component { static propTypes = { @@ -18,13 +19,10 @@ export default class SendAssetRow extends Component { accounts: PropTypes.object.isRequired, assetImages: PropTypes.object, selectedAddress: PropTypes.string.isRequired, - sendTokenAddress: PropTypes.string, - setSendToken: PropTypes.func.isRequired, + sendAssetAddress: PropTypes.string, + updateSendAsset: PropTypes.func.isRequired, nativeCurrency: PropTypes.string, nativeCurrencyImage: PropTypes.string, - setUnsendableAssetError: PropTypes.func.isRequired, - updateSendErrors: PropTypes.func.isRequired, - updateTokenType: PropTypes.func.isRequired, }; static contextTypes = { @@ -46,29 +44,7 @@ export default class SendAssetRow extends Component { closeDropdown = () => this.setState({ isShowingDropdown: false }); - 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(); - } - + selectToken = (type, token) => { this.setState( { isShowingDropdown: false, @@ -84,7 +60,10 @@ export default class SendAssetRow extends Component { assetSelected: token ? ERC20 : this.props.nativeCurrency, }, }); - this.props.setSendToken(token); + this.props.updateSendAsset({ + type, + details: type === ASSET_TYPES.NATIVE ? null : token, + }); }, ); }; @@ -105,9 +84,9 @@ export default class SendAssetRow extends Component { } renderSendToken() { - const { sendTokenAddress } = this.props; + const { sendAssetAddress } = this.props; const token = this.props.tokens.find( - ({ address }) => address === sendTokenAddress, + ({ address }) => address === sendAssetAddress, ); return (
this.selectToken()} + onClick={() => this.selectToken(ASSET_TYPES.NATIVE)} >
this.selectToken(token)} + onClick={() => this.selectToken(ASSET_TYPES.TOKEN, token)} >
dispatch(updateSendToken(token)), - updateTokenType: (tokenAddress) => dispatch(updateTokenType(tokenAddress)), - updateSendErrors: (error) => { - dispatch(updateSendErrors(error)); - }, + updateSendAsset: ({ type, details }) => + dispatch(updateSendAsset({ type, details })), }; } diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 96d728024..fc27aff25 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -18,12 +18,8 @@ export default class SendContent extends Component { t: PropTypes.func, }; - state = { - unsendableAssetError: false, - }; - static propTypes = { - updateGas: PropTypes.func, + isAssetSendable: PropTypes.bool, showAddToAddressBookModal: PropTypes.func, showHexData: PropTypes.bool, contact: PropTypes.object, @@ -35,11 +31,6 @@ export default class SendContent extends Component { noGasPrice: PropTypes.bool, }; - updateGas = (updateData) => this.props.updateGas(updateData); - - setUnsendableAssetError = (unsendableAssetError) => - this.setState({ unsendableAssetError }); - render() { const { warning, @@ -47,9 +38,9 @@ export default class SendContent extends Component { gasIsExcessive, isEthGasPrice, noGasPrice, + isAssetSendable, } = 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; @@ -59,18 +50,15 @@ export default class SendContent extends Component {
{gasError && this.renderError(gasError)} {isEthGasPrice && this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)} - {unsendableAssetError && this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} + {isAssetSendable === false && + this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} {error && this.renderError(error)} {warning && this.renderWarning()} {this.maybeRenderAddContact()} - - + + - {this.props.showHexData && ( - - )} + {this.props.showHexData && }
); diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index 3c99b3237..be623f937 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -1,12 +1,13 @@ import { connect } from 'react-redux'; import { - getSendTo, accountsWithSendEtherInfoSelector, getAddressBookEntry, getIsEthGasPriceFetched, getNoGasPriceFetched, } from '../../../selectors'; +import { getIsAssetSendable, getSendTo } from '../../../ducks/send'; + import * as actions from '../../../store/actions'; import SendContent from './send-content.component'; @@ -14,15 +15,16 @@ function mapStateToProps(state) { const ownedAccounts = accountsWithSendEtherInfoSelector(state); const to = getSendTo(state); return { + isAssetSendable: getIsAssetSendable(state), isOwnedAccount: Boolean( ownedAccounts.find( ({ address }) => address.toLowerCase() === to.toLowerCase(), ), ), contact: getAddressBookEntry(state, to), - to, isEthGasPrice: getIsEthGasPriceFetched(state), noGasPrice: getNoGasPriceFetched(state), + to, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js index 0ee7a6030..b662261bb 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js @@ -3,31 +3,25 @@ import PropTypes from 'prop-types'; import SendRowWrapper from '../send-row-wrapper'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; import AdvancedGasInputs from '../../../../components/app/gas-customization/advanced-gas-inputs'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; export default class SendGasRow extends Component { static propTypes = { - balance: PropTypes.string, gasFeeError: PropTypes.bool, gasLoadingError: PropTypes.bool, gasTotal: PropTypes.string, - maxModeOn: PropTypes.bool, showCustomizeGasModal: PropTypes.func, - sendToken: PropTypes.object, - setAmountToMax: PropTypes.func, - setGasPrice: PropTypes.func, - setGasLimit: PropTypes.func, - tokenBalance: PropTypes.string, + updateGasPrice: PropTypes.func, + updateGasLimit: PropTypes.func, + gasInputMode: PropTypes.oneOf(Object.values(GAS_INPUT_MODES)), gasPriceButtonGroupProps: PropTypes.object, - gasButtonGroupShown: PropTypes.bool, advancedInlineGasShown: PropTypes.bool, resetGasButtons: PropTypes.func, gasPrice: PropTypes.string, gasLimit: PropTypes.string, insufficientBalance: PropTypes.bool, - isMainnet: PropTypes.bool, - isEthGasPrice: PropTypes.bool, - noGasPrice: PropTypes.bool, + minimumGasLimit: PropTypes.string, }; static contextTypes = { @@ -37,19 +31,7 @@ export default class SendGasRow extends Component { renderAdvancedOptionsButton() { const { trackEvent } = this.context; - const { - showCustomizeGasModal, - isMainnet, - isEthGasPrice, - noGasPrice, - } = this.props; - // Tests should behave in same way as mainnet, but are using Localhost - if (!isMainnet && !process.env.IN_TEST) { - return null; - } - if (isEthGasPrice || noGasPrice) { - return null; - } + const { showCustomizeGasModal } = this.props; return (
@@ -120,9 +80,6 @@ export default class SendGasRow extends Component { }, }); await gasPriceButtonGroupProps.handleGasPriceSelection(opts); - if (maxModeOn) { - this.setMaxAmount(); - } }} />
@@ -131,51 +88,38 @@ export default class SendGasRow extends Component { { - resetGasButtons(); - if (maxModeOn) { - this.setMaxAmount(); - } - }} - onClick={() => showCustomizeGasModal()} + onReset={resetGasButtons} + onClick={showCustomizeGasModal} /> ); const advancedGasInputs = (
- setGasPrice({ gasPrice: newGasPrice, gasLimit }) - } - updateCustomGasLimit={(newGasLimit) => - setGasLimit(newGasLimit, gasPrice) - } + updateCustomGasPrice={updateGasPrice} + updateCustomGasLimit={updateGasLimit} customGasPrice={gasPrice} customGasLimit={gasLimit} insufficientBalance={insufficientBalance} + minimumGasLimit={minimumGasLimit} customPriceIsSafe isSpeedUp={false} />
); // Tests should behave in same way as mainnet, but are using Localhost - if ( - advancedInlineGasShown || - (!isMainnet && !process.env.IN_TEST) || - gasPriceFetchFailure - ) { - return advancedGasInputs; - } else if (gasButtonGroupShown) { - return gasPriceButtonGroup; + switch (gasInputMode) { + case GAS_INPUT_MODES.BASIC: + return gasPriceButtonGroup; + case GAS_INPUT_MODES.INLINE: + return advancedGasInputs; + case GAS_INPUT_MODES.CUSTOM: + default: + return gasFeeDisplay; } - return gasFeeDisplay; } render() { - const { - gasFeeError, - gasButtonGroupShown, - advancedInlineGasShown, - } = this.props; + const { gasFeeError, gasInputMode, advancedInlineGasShown } = this.props; return ( <> @@ -186,7 +130,7 @@ export default class SendGasRow extends Component { > {this.renderContent()} - {gasButtonGroupShown || advancedInlineGasShown ? ( + {gasInputMode === GAS_INPUT_MODES.BASIC || advancedInlineGasShown ? ( {this.renderAdvancedOptionsButton()} ) : null} diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js index 7f4505558..9c5cfa30f 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js @@ -3,6 +3,7 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import SendGasRow from './send-gas-row.component'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; @@ -24,7 +25,7 @@ describe('SendGasRow Component', () => { gasFeeError gasLoadingError={false} gasTotal="mockGasTotal" - gasButtonGroupShown={false} + gasInputMode={GAS_INPUT_MODES.CUSTOM} showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal} resetGasButtons={propsMethodSpies.resetGasButtons} gasPriceButtonGroupProps={{ @@ -76,8 +77,8 @@ describe('SendGasRow Component', () => { expect(propsMethodSpies.resetGasButtons.callCount).toStrictEqual(1); }); - it('should render the GasPriceButtonGroup if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render the GasPriceButtonGroup if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).first().childAt(0); expect(wrapper.children()).toHaveLength(2); @@ -95,8 +96,8 @@ describe('SendGasRow Component', () => { ).toStrictEqual('bar'); }); - it('should render an advanced options button if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render an advanced options button if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).last(); expect(wrapper.children()).toHaveLength(2); diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js index 84d6886fb..189fa95e4 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js @@ -1,42 +1,30 @@ import { connect } from 'react-redux'; import { - getGasTotal, - getGasPrice, - getGasLimit, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getGasLoadingError, - gasFeeIsInError, - getGasButtonGroupShown, - getAdvancedInlineGasShown, - getCurrentEthBalance, - getSendToken, getBasicGasEstimateLoadingStatus, getRenderableEstimateDataForSmallButtonsFromGWEI, getDefaultActiveButtonIndex, - getIsMainnet, - getIsEthGasPriceFetched, - getNoGasPriceFetched, + getAdvancedInlineGasShown, } from '../../../../selectors'; -import { isBalanceSufficient, calcGasTotal } from '../../send.utils'; -import { calcMaxAmount } from '../send-amount-row/amount-max-button/amount-max-button.utils'; import { - showGasButtonGroup, - updateSendErrors, - setGasPrice, - setGasLimit, - setGasTotal, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; + getGasTotal, + getGasPrice, + getGasLimit, + gasFeeIsInError, + getGasInputMode, + updateGasPrice, + updateGasLimit, + isSendStateInitialized, + getIsBalanceInsufficient, + getMinimumGasLimitForSend, + useDefaultGas, +} from '../../../../ducks/send'; import { resetCustomData, setCustomGasPrice, setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { showModal } from '../../../../store/actions'; +import { hexToDecimal } from '../../../../helpers/utils/conversions.util'; import SendGasRow from './send-gas-row.component'; export default connect( @@ -55,40 +43,25 @@ function mapStateToProps(state) { ); const gasTotal = getGasTotal(state); - const conversionRate = getConversionRate(state); - const balance = getCurrentEthBalance(state); - const insufficientBalance = !isBalanceSufficient({ - amount: getSendToken(state) ? '0x0' : getSendAmount(state), - gasTotal, - balance, - conversionRate, - }); - const isEthGasPrice = getIsEthGasPriceFetched(state); - const noGasPrice = getNoGasPriceFetched(state); + const minimumGasLimit = getMinimumGasLimitForSend(state); return { - balance: getSendFromBalance(state), gasTotal, + minimumGasLimit: hexToDecimal(minimumGasLimit), gasFeeError: gasFeeIsInError(state), - gasLoadingError: getGasLoadingError(state), + gasLoadingError: isSendStateInitialized(state), gasPriceButtonGroupProps: { buttonDataLoading: getBasicGasEstimateLoadingStatus(state), defaultActiveButtonIndex: 1, newActiveButtonIndex: activeButtonIndex > -1 ? activeButtonIndex : null, gasButtonInfo, }, - gasButtonGroupShown: getGasButtonGroupShown(state), advancedInlineGasShown: getAdvancedInlineGasShown(state), + gasInputMode: getGasInputMode(state), gasPrice, gasLimit, - insufficientBalance, - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - isMainnet: getIsMainnet(state), - isEthGasPrice, - noGasPrice, + insufficientBalance: getIsBalanceInsufficient(state), }; } @@ -96,26 +69,16 @@ function mapDispatchToProps(dispatch) { return { showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS', hideBasic: true })), - setGasPrice: ({ gasPrice, gasLimit }) => { - dispatch(setGasPrice(gasPrice)); + updateGasPrice: (gasPrice) => { + dispatch(updateGasPrice(gasPrice)); dispatch(setCustomGasPrice(gasPrice)); - if (gasLimit) { - dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))); - } }, - setGasLimit: (newLimit, gasPrice) => { - dispatch(setGasLimit(newLimit)); + updateGasLimit: (newLimit) => { + dispatch(updateGasLimit(newLimit)); dispatch(setCustomGasLimit(newLimit)); - if (gasPrice) { - dispatch(setGasTotal(calcGasTotal(newLimit, gasPrice))); - } - }, - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); }, - showGasButtonGroup: () => dispatch(showGasButtonGroup()), resetCustomData: () => dispatch(resetCustomData()), + useDefaultGas: () => dispatch(useDefaultGas()), }; } @@ -123,8 +86,8 @@ function mergeProps(stateProps, dispatchProps, ownProps) { const { gasPriceButtonGroupProps } = stateProps; const { gasButtonInfo } = gasPriceButtonGroupProps; const { - setGasPrice: dispatchSetGasPrice, - showGasButtonGroup: dispatchShowGasButtonGroup, + updateGasPrice: dispatchUpdateGasPrice, + useDefaultGas: dispatchUseDefaultGas, resetCustomData: dispatchResetCustomData, ...otherDispatchProps } = dispatchProps; @@ -135,13 +98,14 @@ function mergeProps(stateProps, dispatchProps, ownProps) { ...ownProps, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, - handleGasPriceSelection: dispatchSetGasPrice, + handleGasPriceSelection: ({ gasPrice }) => + dispatchUpdateGasPrice(gasPrice), }, resetGasButtons: () => { dispatchResetCustomData(); - dispatchSetGasPrice(gasButtonInfo[1].priceInHexWei); - dispatchShowGasButtonGroup(); + dispatchUpdateGasPrice(gasButtonInfo[1].priceInHexWei); + dispatchUseDefaultGas(); }, - setGasPrice: dispatchSetGasPrice, + updateGasPrice: dispatchUpdateGasPrice, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js index 80757f230..b7aa23c86 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js @@ -8,12 +8,7 @@ import { setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { - showGasButtonGroup, - setGasPrice, - setGasTotal, - setGasLimit, -} from '../../../../ducks/send/send.duck'; +import { updateGasPrice, updateGasLimit } from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -26,9 +21,15 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors', () => ({ - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, -})); +jest.mock('../../../../ducks/send', () => { + const original = jest.requireActual('../../../../ducks/send'); + return { + ...original, + getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, + updateGasPrice: jest.fn(), + updateGasLimit: jest.fn(), + }; +}); jest.mock('../../send.utils.js', () => ({ isBalanceSufficient: ({ amount, gasTotal, balance, conversionRate }) => @@ -41,13 +42,6 @@ jest.mock('../../../../store/actions', () => ({ showModal: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - showGasButtonGroup: jest.fn(), - setGasPrice: jest.fn(), - setGasTotal: jest.fn(), - setGasLimit: jest.fn(), -})); - jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), setCustomGasPrice: jest.fn(), @@ -77,36 +71,21 @@ describe('send-gas-row container', () => { }); }); - describe('setGasPrice()', () => { + describe('updateGasPrice()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasPrice({ - gasPrice: 'mockNewPrice', - gasLimit: 'mockLimit', - }); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasPrice('mockNewPrice'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasPrice).toHaveBeenCalled(); expect(setCustomGasPrice).toHaveBeenCalledWith('mockNewPrice'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockLimitmockNewPrice'); }); }); - describe('setGasLimit()', () => { + describe('updateGasLimit()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasLimit('mockNewLimit', 'mockPrice'); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasLimit).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasLimit('mockNewLimit'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasLimit).toHaveBeenCalled(); expect(setCustomGasLimit).toHaveBeenCalledWith('mockNewLimit'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockNewLimitmockPrice'); - }); - }); - - describe('showGasButtonGroup()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.showGasButtonGroup(); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(showGasButtonGroup).toHaveBeenCalled(); }); }); @@ -129,7 +108,7 @@ describe('send-gas-row container', () => { someOtherStateProp: 'baz', }; const dispatchProps = { - setGasPrice: sinon.spy(), + updateGasPrice: sinon.spy(), someOtherDispatchProp: sinon.spy(), }; const ownProps = { someOwnProp: 123 }; @@ -144,9 +123,11 @@ describe('send-gas-row container', () => { ).toStrictEqual('bar'); expect(result.someOwnProp).toStrictEqual(123); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(0); - result.gasPriceButtonGroupProps.handleGasPriceSelection(); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(1); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(0); + result.gasPriceButtonGroupProps.handleGasPriceSelection({ + gasPrice: undefined, + }); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(1); expect(dispatchProps.someOtherDispatchProp.callCount).toStrictEqual(0); result.someOtherDispatchProp(); diff --git a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js index 080b97b37..291013016 100644 --- a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js +++ b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js @@ -6,7 +6,6 @@ export default class SendHexDataRow extends Component { static propTypes = { inError: PropTypes.bool, updateSendHexData: PropTypes.func.isRequired, - updateGas: PropTypes.func.isRequired, }; static contextTypes = { @@ -14,10 +13,9 @@ export default class SendHexDataRow extends Component { }; onInput = (event) => { - const { updateSendHexData, updateGas } = this.props; + const { updateSendHexData } = this.props; const data = event.target.value.replace(/\n/gu, '') || null; updateSendHexData(data); - updateGas({ data }); }; render() { diff --git a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js index f645aff7a..044f3eb69 100644 --- a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js +++ b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js @@ -1,12 +1,12 @@ import { connect } from 'react-redux'; -import { updateSendHexData } from '../../../../ducks/send/send.duck'; +import { getSendHexData, updateSendHexData } from '../../../../ducks/send'; import SendHexDataRow from './send-hex-data-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow); function mapStateToProps(state) { return { - data: state.send.data, + data: getSendHexData(state), }; } diff --git a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js index f857183f3..45a208537 100644 --- a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js +++ b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getSendErrors } from '../../../../../selectors'; +import { getSendErrors } from '../../../../../ducks/send'; import SendRowErrorMessage from './send-row-error-message.component'; export default connect(mapStateToProps)(SendRowErrorMessage); diff --git a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js index a8012f200..23f1d2c68 100644 --- a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js +++ b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js @@ -8,7 +8,7 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../../selectors', () => ({ +jest.mock('../../../../../ducks/send', () => ({ getSendErrors: (s) => `mockErrors:${s}`, })); diff --git a/ui/pages/send/send-footer/send-footer.component.js b/ui/pages/send/send-footer/send-footer.component.js index ef18f48b1..840146f5f 100644 --- a/ui/pages/send/send-footer/send-footer.component.js +++ b/ui/pages/send/send-footer/send-footer.component.js @@ -1,33 +1,21 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import { isEqual } from 'lodash'; import PageContainerFooter from '../../../components/ui/page-container/page-container-footer'; import { CONFIRM_TRANSACTION_ROUTE } from '../../../helpers/constants/routes'; export default class SendFooter extends Component { static propTypes = { addToAddressBookIfNew: PropTypes.func, - amount: PropTypes.string, - data: PropTypes.string, - clearSend: PropTypes.func, - editingTransactionId: PropTypes.string, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, + resetSendState: PropTypes.func, + disabled: PropTypes.bool.isRequired, history: PropTypes.object, - inError: PropTypes.bool, - sendToken: PropTypes.object, sign: PropTypes.func, to: PropTypes.string, toAccounts: PropTypes.array, - tokenBalance: PropTypes.string, - unapprovedTxs: PropTypes.object, - update: PropTypes.func, sendErrors: PropTypes.object, gasEstimateType: PropTypes.string, - gasIsLoading: PropTypes.bool, mostRecentOverviewPage: PropTypes.string.isRequired, - noGasPrice: PropTypes.bool, }; static contextTypes = { @@ -36,8 +24,8 @@ export default class SendFooter extends Component { }; onCancel() { - const { clearSend, history, mostRecentOverviewPage } = this.props; - clearSend(); + const { resetSendState, history, mostRecentOverviewPage } = this.props; + resetSendState(); history.push(mostRecentOverviewPage); } @@ -45,45 +33,17 @@ export default class SendFooter extends Component { event.preventDefault(); const { addToAddressBookIfNew, - amount, - data, - editingTransactionId, - from: { address: from }, - gasLimit: gas, - gasPrice, - sendToken, sign, to, - unapprovedTxs, - update, toAccounts, history, gasEstimateType, } = this.props; const { metricsEvent } = this.context; - // Should not be needed because submit should be disabled if there are errors. - // const noErrors = !amountError && toError === null - - // if (!noErrors) { - // return - // } - // TODO: add nickname functionality await addToAddressBookIfNew(to, toAccounts); - const promise = editingTransactionId - ? update({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }) - : sign({ data, sendToken, to, amount, from, gas, gasPrice }); + const promise = sign(); Promise.resolve(promise).then(() => { metricsEvent({ @@ -100,35 +60,13 @@ export default class SendFooter extends Component { }); } - formShouldBeDisabled() { - const { - data, - inError, - sendToken, - tokenBalance, - gasTotal, - to, - gasLimit, - gasIsLoading, - noGasPrice, - } = this.props; - const missingTokenBalance = sendToken && !tokenBalance; - const gasLimitTooLow = gasLimit < 5208; // 5208 is hex value of 21000, minimum gas limit - const shouldBeDisabled = - inError || - !gasTotal || - missingTokenBalance || - !(data || to) || - gasLimitTooLow || - gasIsLoading || - noGasPrice; - return shouldBeDisabled; - } - componentDidUpdate(prevProps) { - const { inError, sendErrors } = this.props; + const { sendErrors } = this.props; const { metricsEvent } = this.context; - if (!prevProps.inError && inError) { + if ( + Object.keys(sendErrors).length > 0 && + isEqual(sendErrors, prevProps.sendErrors) === false + ) { const errorField = Object.keys(sendErrors).find((key) => sendErrors[key]); const errorMessage = sendErrors[errorField]; @@ -151,7 +89,7 @@ export default class SendFooter extends Component { this.onCancel()} onSubmit={(e) => this.onSubmit(e)} - disabled={this.formShouldBeDisabled()} + disabled={this.props.disabled} /> ); } diff --git a/ui/pages/send/send-footer/send-footer.component.test.js b/ui/pages/send/send-footer/send-footer.component.test.js index 900c26b2a..fcd4472d6 100644 --- a/ui/pages/send/send-footer/send-footer.component.test.js +++ b/ui/pages/send/send-footer/send-footer.component.test.js @@ -10,7 +10,7 @@ describe('SendFooter Component', () => { const propsMethodSpies = { addToAddressBookIfNew: sinon.spy(), - clearSend: sinon.spy(), + resetSendState: sinon.spy(), sign: sinon.spy(), update: sinon.spy(), mostRecentOverviewPage: '/', @@ -29,36 +29,24 @@ describe('SendFooter Component', () => { wrapper = shallow( , { context: { t: (str) => str, metricsEvent: () => ({}) } }, ); }); afterEach(() => { - propsMethodSpies.clearSend.resetHistory(); + propsMethodSpies.resetSendState.resetHistory(); propsMethodSpies.addToAddressBookIfNew.resetHistory(); - propsMethodSpies.clearSend.resetHistory(); + propsMethodSpies.resetSendState.resetHistory(); propsMethodSpies.sign.resetHistory(); propsMethodSpies.update.resetHistory(); historySpies.push.resetHistory(); @@ -71,10 +59,10 @@ describe('SendFooter Component', () => { }); describe('onCancel', () => { - it('should call clearSend', () => { - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(0); + it('should call resetSendState', () => { + expect(propsMethodSpies.resetSendState.callCount).toStrictEqual(0); wrapper.instance().onCancel(); - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(1); + expect(propsMethodSpies.resetSendState.callCount).toStrictEqual(1); }); it('should call history.push', () => { @@ -87,59 +75,6 @@ describe('SendFooter Component', () => { }); }); - describe('formShouldBeDisabled()', () => { - const config = { - 'should return true if inError is truthy': { - inError: true, - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if gasTotal is falsy': { - inError: false, - gasTotal: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if to is truthy': { - to: '0xsomevalidAddress', - inError: false, - gasTotal: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if sendToken is truthy and tokenBalance is falsy': { - sendToken: { mockProp: 'mockSendTokenProp' }, - tokenBalance: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if gasIsLoading is truthy but all other params are falsy': { - inError: false, - gasTotal: '', - sendToken: null, - tokenBalance: '', - expectedResult: true, - gasIsLoading: true, - }, - 'should return false if inError is false and all other params are truthy': { - inError: false, - gasTotal: '0x123', - sendToken: { mockProp: 'mockSendTokenProp' }, - tokenBalance: '123', - expectedResult: false, - gasIsLoading: false, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - wrapper.setProps(obj); - expect(wrapper.instance().formShouldBeDisabled()).toStrictEqual( - obj.expectedResult, - ); - }); - }); - }); - describe('onSubmit', () => { it('should call addToAddressBookIfNew with the correct params', () => { wrapper.instance().onSubmit(MOCK_EVENT); @@ -151,43 +86,9 @@ describe('SendFooter Component', () => { ).toStrictEqual(['mockTo', ['mockAccount']]); }); - it('should call props.update if editingTransactionId is truthy', async () => { - await wrapper.instance().onSubmit(MOCK_EVENT); - expect(propsMethodSpies.update.calledOnce).toStrictEqual(true); - expect(propsMethodSpies.update.getCall(0).args[0]).toStrictEqual({ - data: undefined, - amount: 'mockAmount', - editingTransactionId: 'mockEditingTransactionId', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - sendToken: { mockProp: 'mockSendTokenProp' }, - to: 'mockTo', - unapprovedTxs: {}, - }); - }); - - it('should not call props.sign if editingTransactionId is truthy', () => { - expect(propsMethodSpies.sign.callCount).toStrictEqual(0); - }); - - it('should call props.sign if editingTransactionId is falsy', async () => { - wrapper.setProps({ editingTransactionId: null }); + it('should call props.sign whe submitting', async () => { await wrapper.instance().onSubmit(MOCK_EVENT); expect(propsMethodSpies.sign.calledOnce).toStrictEqual(true); - expect(propsMethodSpies.sign.getCall(0).args[0]).toStrictEqual({ - data: undefined, - amount: 'mockAmount', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - sendToken: { mockProp: 'mockSendTokenProp' }, - to: 'mockTo', - }); - }); - - it('should not call props.update if editingTransactionId is falsy', () => { - expect(propsMethodSpies.update.callCount).toStrictEqual(0); }); it('should call history.push', async () => { @@ -201,12 +102,11 @@ describe('SendFooter Component', () => { describe('render', () => { beforeEach(() => { - sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns(true); wrapper = shallow( { gasPrice="mockGasPrice" gasTotal="mockGasTotal" history={historySpies} - inError={false} sendToken={{ mockProp: 'mockSendTokenProp' }} sign={propsMethodSpies.sign} to="mockTo" @@ -229,10 +128,6 @@ describe('SendFooter Component', () => { ); }); - afterEach(() => { - SendFooter.prototype.formShouldBeDisabled.restore(); - }); - it('should render a PageContainerFooter component', () => { expect(wrapper.find(PageContainerFooter)).toHaveLength(1); }); diff --git a/ui/pages/send/send-footer/send-footer.container.js b/ui/pages/send/send-footer/send-footer.container.js index 8848255d3..bcdb796e1 100644 --- a/ui/pages/send/send-footer/send-footer.container.js +++ b/ui/pages/send/send-footer/send-footer.container.js @@ -1,44 +1,32 @@ import { connect } from 'react-redux'; +import { addToAddressBook } from '../../../store/actions'; import { - addToAddressBook, - signTokenTx, - signTx, - updateTransaction, -} from '../../../store/actions'; + getRenderableEstimateDataForSmallButtonsFromGWEI, + getDefaultActiveButtonIndex, +} from '../../../selectors'; import { - getGasLimit, + resetSendState, getGasPrice, - getGasTotal, - getSendToken, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, getSendTo, - getSendHexData, - getTokenBalance, getSendErrors, - isSendFormInError, - getGasIsLoading, - getRenderableEstimateDataForSmallButtonsFromGWEI, - getDefaultActiveButtonIndex, - getNoGasPriceFetched, -} from '../../../selectors'; + isSendFormInvalid, + signTransaction, +} from '../../../ducks/send'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { addHexPrefix } from '../../../../app/scripts/lib/util'; -import { - getSendToAccounts, - getUnapprovedTxs, -} from '../../../ducks/metamask/metamask'; -import { clearSend } from '../../../ducks/send/send.duck'; +import { getSendToAccounts } from '../../../ducks/metamask/metamask'; import SendFooter from './send-footer.component'; -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; export default connect(mapStateToProps, mapDispatchToProps)(SendFooter); +function addressIsNew(toAccounts, newAddress) { + const newAddressNormalized = newAddress.toLowerCase(); + const foundMatching = toAccounts.some( + ({ address }) => address.toLowerCase() === newAddressNormalized, + ); + return !foundMatching; +} + function mapStateToProps(state) { const gasButtonInfo = getRenderableEstimateDataForSmallButtonsFromGWEI(state); const gasPrice = getGasPrice(state); @@ -50,74 +38,21 @@ function mapStateToProps(state) { activeButtonIndex >= 0 ? gasButtonInfo[activeButtonIndex].gasEstimateType : 'custom'; - const editingTransactionId = getSendEditingTransactionId(state); return { - amount: getSendAmount(state), - data: getSendHexData(state), - editingTransactionId, - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - inError: isSendFormInError(state), - sendToken: getSendToken(state), + disabled: isSendFormInvalid(state), to: getSendTo(state), toAccounts: getSendToAccounts(state), - tokenBalance: getTokenBalance(state), - unapprovedTxs: getUnapprovedTxs(state), sendErrors: getSendErrors(state), gasEstimateType, - gasIsLoading: getGasIsLoading(state), mostRecentOverviewPage: getMostRecentOverviewPage(state), - noGasPrice: getNoGasPriceFetched(state), }; } function mapDispatchToProps(dispatch) { return { - clearSend: () => dispatch(clearSend()), - sign: ({ sendToken, to, amount, from, gas, gasPrice, data }) => { - const txParams = constructTxParams({ - amount, - data, - from, - gas, - gasPrice, - sendToken, - to, - }); - - return sendToken - ? dispatch(signTokenTx(sendToken.address, to, amount, txParams)) - : dispatch(signTx(txParams)); - }, - update: ({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }) => { - const editingTx = constructUpdatedTx({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }); - - return dispatch(updateTransaction(editingTx)); - }, - + resetSendState: () => dispatch(resetSendState()), + sign: () => dispatch(signTransaction()), addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { const hexPrefixedAddress = addHexPrefix(newAddress); if (addressIsNew(toAccounts, hexPrefixedAddress)) { diff --git a/ui/pages/send/send-footer/send-footer.container.test.js b/ui/pages/send/send-footer/send-footer.container.test.js index 3cb6e474e..61c081719 100644 --- a/ui/pages/send/send-footer/send-footer.container.test.js +++ b/ui/pages/send/send-footer/send-footer.container.test.js @@ -1,12 +1,7 @@ import sinon from 'sinon'; -import { clearSend } from '../../../ducks/send/send.duck'; -import { signTx, signTokenTx, addToAddressBook } from '../../../store/actions'; -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; +import { addToAddressBook } from '../../../store/actions'; +import { resetSendState, signTransaction } from '../../../ducks/send'; let mapDispatchToProps; @@ -19,32 +14,18 @@ jest.mock('react-redux', () => ({ jest.mock('../../../store/actions.js', () => ({ addToAddressBook: jest.fn(), - signTokenTx: jest.fn(), - signTx: jest.fn(), - updateTransaction: jest.fn(), })); -jest.mock('../../../ducks/send/send.duck.js', () => ({ - clearSend: jest.fn(), +jest.mock('../../../ducks/metamask/metamask', () => ({ + getSendToAccounts: (s) => [`mockToAccounts:${s}`], })); -jest.mock('../../../selectors/send.js', () => ({ - getGasLimit: (s) => `mockGasLimit:${s}`, +jest.mock('../../../ducks/send', () => ({ getGasPrice: (s) => `mockGasPrice:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - getSendToken: (s) => `mockSendToken:${s}`, - getSendAmount: (s) => `mockAmount:${s}`, - getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, - getSendFromObject: (s) => `mockFromObject:${s}`, getSendTo: (s) => `mockTo:${s}`, - getSendToNickname: (s) => `mockToNickname:${s}`, - getSendToAccounts: (s) => `mockToAccounts:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getSendHexData: (s) => `mockHexData:${s}`, - getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`, getSendErrors: (s) => `mockSendErrors:${s}`, - isSendFormInError: (s) => `mockInError:${s}`, - getDefaultActiveButtonIndex: () => 0, + resetSendState: jest.fn(), + signTransaction: jest.fn(), })); jest.mock('../../../selectors/custom-gas.js', () => ({ @@ -52,15 +33,6 @@ jest.mock('../../../selectors/custom-gas.js', () => ({ { gasEstimateType: `mockGasEstimateType:${s}` }, ], })); - -jest.mock('./send-footer.utils', () => ({ - addressIsNew: jest.fn().mockReturnValue(true), - constructTxParams: jest.fn().mockReturnValue({ value: 'mockAmount' }), - constructUpdatedTx: jest - .fn() - .mockReturnValue('mockConstructedUpdatedTxParams'), -})); - require('./send-footer.container.js'); describe('send-footer container', () => { @@ -73,94 +45,19 @@ describe('send-footer container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); }); - describe('clearSend()', () => { + describe('resetSendState()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.clearSend(); + mapDispatchToPropsObject.resetSendState(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(clearSend).toHaveBeenCalled(); + expect(resetSendState).toHaveBeenCalled(); }); }); describe('sign()', () => { - it('should dispatch a signTokenTx action if sendToken is defined', () => { - mapDispatchToPropsObject.sign({ - sendToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructTxParams).toHaveBeenCalledWith({ - data: undefined, - sendToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(signTokenTx).toHaveBeenCalledWith( - '0xabc', - 'mockTo', - 'mockAmount', - { value: 'mockAmount' }, - ); - }); - - it('should dispatch a sign action if sendToken is not defined', () => { - mapDispatchToPropsObject.sign({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); + it('should dispatch a signTransaction action', () => { + mapDispatchToPropsObject.sign(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructTxParams).toHaveBeenCalledWith({ - data: undefined, - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(signTx).toHaveBeenCalledWith({ - value: 'mockAmount', - }); - }); - }); - - describe('update()', () => { - it('should dispatch an updateTransaction action', () => { - mapDispatchToPropsObject.update({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - sendToken: { address: 'mockAddress' }, - unapprovedTxs: 'mockUnapprovedTxs', - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructUpdatedTx).toHaveBeenCalledWith({ - data: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - sendToken: { address: 'mockAddress' }, - unapprovedTxs: 'mockUnapprovedTxs', - }); + expect(signTransaction).toHaveBeenCalledTimes(1); }); }); @@ -168,14 +65,10 @@ describe('send-footer container', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.addToAddressBookIfNew( 'mockNewAddress', - 'mockToAccounts', + [{ address: 'mockToAccounts' }], 'mockNickname', ); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(addressIsNew).toHaveBeenCalledWith( - 'mockToAccounts', - '0xmockNewAddress', - ); expect(addToAddressBook).toHaveBeenCalledWith( '0xmockNewAddress', 'mockNickname', diff --git a/ui/pages/send/send-footer/send-footer.utils.js b/ui/pages/send/send-footer/send-footer.utils.js deleted file mode 100644 index 778b07867..000000000 --- a/ui/pages/send/send-footer/send-footer.utils.js +++ /dev/null @@ -1,96 +0,0 @@ -import ethAbi from 'ethereumjs-abi'; -import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from '../send.constants'; -import { addHexPrefix } from '../../../../app/scripts/lib/util'; -import { addHexPrefixToObjectValues } from '../../../helpers/utils/util'; - -export function constructTxParams({ - sendToken, - data, - to, - amount, - from, - gas, - gasPrice, -}) { - const txParams = { - data, - from, - value: '0', - gas, - gasPrice, - }; - - if (!sendToken) { - txParams.value = amount; - txParams.to = to; - } - - return addHexPrefixToObjectValues(txParams); -} - -export function constructUpdatedTx({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, -}) { - const unapprovedTx = unapprovedTxs[editingTransactionId]; - const txParamsData = unapprovedTx.txParams.data - ? unapprovedTx.txParams.data - : data; - - const editingTx = { - ...unapprovedTx, - txParams: Object.assign( - unapprovedTx.txParams, - addHexPrefixToObjectValues({ - data: txParamsData, - to, - from, - gas, - gasPrice, - value: amount, - }), - ), - }; - - if (sendToken) { - Object.assign( - editingTx.txParams, - addHexPrefixToObjectValues({ - value: '0', - to: sendToken.address, - data: - TOKEN_TRANSFER_FUNCTION_SIGNATURE + - Array.prototype.map - .call( - ethAbi.rawEncode( - ['address', 'uint256'], - [to, addHexPrefix(amount)], - ), - (x) => `00${x.toString(16)}`.slice(-2), - ) - .join(''), - }), - ); - } - - if (typeof editingTx.txParams.data === 'undefined') { - delete editingTx.txParams.data; - } - - return editingTx; -} - -export function addressIsNew(toAccounts, newAddress) { - const newAddressNormalized = newAddress.toLowerCase(); - const foundMatching = toAccounts.some( - ({ address }) => address.toLowerCase() === newAddressNormalized, - ); - return !foundMatching; -} diff --git a/ui/pages/send/send-footer/send-footer.utils.test.js b/ui/pages/send/send-footer/send-footer.utils.test.js deleted file mode 100644 index 034ca2ecd..000000000 --- a/ui/pages/send/send-footer/send-footer.utils.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { addHexPrefixToObjectValues } from '../../../helpers/utils/util'; -import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from '../send.constants'; - -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; - -jest.mock('ethereumjs-abi', () => ({ - rawEncode: jest.fn((arr1, arr2) => { - return [...arr1, ...arr2]; - }), -})); - -describe('send-footer utils', () => { - describe('addHexPrefixToObjectValues()', () => { - it('should return a new object with the same properties with a 0x prefix', () => { - expect( - addHexPrefixToObjectValues({ - prop1: '0x123', - prop2: '456', - prop3: 'x', - }), - ).toStrictEqual({ - prop1: '0x123', - prop2: '0x456', - prop3: '0xx', - }); - }); - }); - - describe('addressIsNew()', () => { - it('should return false if the address exists in toAccounts', () => { - expect( - addressIsNew( - [{ address: '0xabc' }, { address: '0xdef' }, { address: '0xghi' }], - '0xdef', - ), - ).toStrictEqual(false); - }); - - it('should return true if the address does not exists in toAccounts', () => { - expect( - addressIsNew( - [{ address: '0xabc' }, { address: '0xdef' }, { address: '0xghi' }], - '0xxyz', - ), - ).toStrictEqual(true); - }); - }); - - describe('constructTxParams()', () => { - it('should return a new txParams object with data if there data is given', () => { - expect( - constructTxParams({ - data: 'someData', - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: '0xsomeData', - to: '0xmockTo', - value: '0xmockAmount', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - - it('should return a new txParams object with value and to properties if there is no sendToken', () => { - expect( - constructTxParams({ - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: undefined, - to: '0xmockTo', - value: '0xmockAmount', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - - it('should return a new txParams object without a to property and a 0 value if there is a sendToken', () => { - expect( - constructTxParams({ - sendToken: { address: '0x0' }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: undefined, - value: '0x0', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - }); - - describe('constructUpdatedTx()', () => { - it('should return a new object with an updated txParams', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: false, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: { - data: 'someData', - }, - }, - }, - }); - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0xmockAmount', - to: '0xmockTo', - data: '0xsomeData', - }, - }); - }); - - it('should not have data property if there is non in the original tx', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: false, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: { - from: 'oldFrom', - gas: 'oldGas', - gasPrice: 'oldGasPrice', - }, - }, - }, - }); - - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0xmockAmount', - to: '0xmockTo', - }, - }); - }); - - it('should have token property values if sendToken is truthy', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: { - address: 'mockTokenAddress', - }, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: {}, - }, - }, - }); - - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0x0', - to: '0xmockTokenAddress', - data: `${TOKEN_TRANSFER_FUNCTION_SIGNATURE}ss56Tont`, - }, - }); - }); - }); -}); diff --git a/ui/pages/send/send-header/index.js b/ui/pages/send/send-header/index.js index cfb482303..b4bda8af7 100644 --- a/ui/pages/send/send-header/index.js +++ b/ui/pages/send/send-header/index.js @@ -1 +1 @@ -export { default } from './send-header.container'; +export { default } from './send-header.component'; diff --git a/ui/pages/send/send-header/send-header.component.js b/ui/pages/send/send-header/send-header.component.js index 303ef4c7a..1b8af5312 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -1,33 +1,47 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import { getMostRecentOverviewPage } from '../../../ducks/history/history'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + ASSET_TYPES, + getSendAsset, + getSendStage, + resetSendState, + SEND_STAGES, +} from '../../../ducks/send'; -export default class SendHeader extends Component { - static propTypes = { - clearSend: PropTypes.func, - history: PropTypes.object, - mostRecentOverviewPage: PropTypes.string, - titleKey: PropTypes.string, - }; +export default function SendHeader() { + const history = useHistory(); + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const dispatch = useDispatch(); + const stage = useSelector(getSendStage); + const asset = useSelector(getSendAsset); + const t = useI18nContext(); - static contextTypes = { - t: PropTypes.func, + const onClose = () => { + dispatch(resetSendState()); + history.push(mostRecentOverviewPage); }; - onClose() { - const { clearSend, history, mostRecentOverviewPage } = this.props; - clearSend(); - history.push(mostRecentOverviewPage); - } + let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); - render() { - return ( - this.onClose()} - title={this.context.t(this.props.titleKey)} - headerCloseText={this.context.t('cancel')} - /> - ); + if ( + stage === SEND_STAGES.ADD_RECIPIENT || + stage === SEND_STAGES.UNINITIALIZED + ) { + title = t('addRecipient'); + } else if (stage === SEND_STAGES.EDIT) { + title = t('edit'); } + + return ( + + ); } diff --git a/ui/pages/send/send-header/send-header.component.test.js b/ui/pages/send/send-header/send-header.component.test.js index 8ff76c35e..a8fa64342 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -1,73 +1,120 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { ASSET_TYPES, initialState, SEND_STAGES } from '../../../ducks/send'; +import { renderWithProvider } from '../../../../test/jest'; import SendHeader from './send-header.component'; -describe('SendHeader Component', () => { - let wrapper; +const middleware = [thunk]; - const propsMethodSpies = { - clearSend: sinon.spy(), - }; - const historySpies = { - push: sinon.spy(), +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: () => ({ + push: jest.fn(), + }), }; +}); - beforeAll(() => { - sinon.spy(SendHeader.prototype, 'onClose'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { context: { t: (str1, str2) => (str2 ? str1 + str2 : str1) } }, - ); - }); - - afterEach(() => { - propsMethodSpies.clearSend.resetHistory(); - historySpies.push.resetHistory(); - SendHeader.prototype.onClose.resetHistory(); - }); +describe('SendHeader Component', () => { + describe('Title', () => { + it('should render "Add Recipient" for UNINITIALIZED or ADD_RECIPIENT stages', () => { + const { getByText, rerender } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Add Recipient')).toBeTruthy(); + rerender( + , + configureMockStore(middleware)({ + send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Add Recipient')).toBeTruthy(); + }); - afterAll(() => { - sinon.restore(); - }); + it('should render "Send" for DRAFT stage when asset type is NATIVE', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send')).toBeTruthy(); + }); - describe('onClose', () => { - it('should call clearSend', () => { - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(1); + it('should render "Send Tokens" for DRAFT stage when asset type is TOKEN', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send Tokens')).toBeTruthy(); }); - it('should call history.push', () => { - expect(historySpies.push.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(historySpies.push.callCount).toStrictEqual(1); - expect(historySpies.push.getCall(0).args[0]).toStrictEqual( - 'mostRecentOverviewPage', + it('should render "Edit" for EDIT stage', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.EDIT, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), ); + expect(getByText('Edit')).toBeTruthy(); }); }); - describe('render', () => { - it('should render a PageContainerHeader component', () => { - expect(wrapper.find(PageContainerHeader)).toHaveLength(1); + describe('Cancel Button', () => { + it('has a cancel button in header', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Cancel')).toBeTruthy(); }); - it('should pass the correct props to PageContainerHeader', () => { - const { onClose, title } = wrapper.find(PageContainerHeader).props(); - expect(title).toStrictEqual('mockTitleKey'); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(0); - onClose(); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(1); + it('resets send state when clicked', () => { + const store = configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }); + const { getByText } = renderWithProvider(, store); + const expectedActions = [ + { type: 'send/resetSendState', payload: undefined }, + ]; + fireEvent.click(getByText('Cancel')); + expect(store.getActions()).toStrictEqual(expectedActions); }); }); }); diff --git a/ui/pages/send/send-header/send-header.container.js b/ui/pages/send/send-header/send-header.container.js deleted file mode 100644 index b66a9ba89..000000000 --- a/ui/pages/send/send-header/send-header.container.js +++ /dev/null @@ -1,20 +0,0 @@ -import { connect } from 'react-redux'; -import { clearSend } from '../../../ducks/send/send.duck'; -import { getTitleKey } from '../../../selectors'; -import { getMostRecentOverviewPage } from '../../../ducks/history/history'; -import SendHeader from './send-header.component'; - -export default connect(mapStateToProps, mapDispatchToProps)(SendHeader); - -function mapStateToProps(state) { - return { - mostRecentOverviewPage: getMostRecentOverviewPage(state), - titleKey: getTitleKey(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - clearSend: () => dispatch(clearSend()), - }; -} diff --git a/ui/pages/send/send.component.js b/ui/pages/send/send.component.js deleted file mode 100644 index 6954f9cfc..000000000 --- a/ui/pages/send/send.component.js +++ /dev/null @@ -1,403 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { isValidHexAddress } from '../../../shared/modules/hexstring-utils'; -import { - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - doesAmountErrorRequireUpdate, -} from './send.utils'; -import { - getToWarningObject, - getToErrorObject, -} from './send-content/add-recipient/add-recipient'; -import SendHeader from './send-header'; -import AddRecipient from './send-content/add-recipient'; -import SendContent from './send-content'; -import SendFooter from './send-footer'; -import EnsInput from './send-content/add-recipient/ens-input'; -import { - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from './send.constants'; - -export default class SendTransactionScreen extends Component { - static propTypes = { - addressBook: PropTypes.arrayOf(PropTypes.object), - amount: PropTypes.string, - blockGasLimit: PropTypes.string, - conversionRate: PropTypes.number, - editingTransactionId: PropTypes.string, - fetchBasicGasEstimates: PropTypes.func.isRequired, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, - history: PropTypes.object, - chainId: PropTypes.string, - primaryCurrency: PropTypes.string, - resetSendState: PropTypes.func.isRequired, - selectedAddress: PropTypes.string, - sendToken: PropTypes.object, - showHexData: PropTypes.bool, - to: PropTypes.string, - toNickname: PropTypes.string, - tokens: PropTypes.array, - tokenBalance: PropTypes.string, - tokenContract: PropTypes.object, - updateAndSetGasLimit: PropTypes.func.isRequired, - updateSendEnsResolution: PropTypes.func.isRequired, - updateSendEnsResolutionError: PropTypes.func.isRequired, - updateSendErrors: PropTypes.func.isRequired, - updateSendTo: PropTypes.func.isRequired, - updateSendTokenBalance: PropTypes.func.isRequired, - updateToNicknameIfNecessary: PropTypes.func.isRequired, - scanQrCode: PropTypes.func.isRequired, - qrCodeDetected: PropTypes.func.isRequired, - qrCodeData: PropTypes.object, - sendTokenAddress: PropTypes.string, - gasIsExcessive: PropTypes.bool.isRequired, - }; - - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - }; - - state = { - query: '', - toError: null, - toWarning: null, - internalSearch: false, - }; - - constructor(props) { - super(props); - this.dValidate = debounce(this.validate, 1000); - } - - componentDidUpdate(prevProps) { - const { - amount, - conversionRate, - from: { address, balance }, - gasTotal, - chainId, - primaryCurrency, - sendToken, - tokenBalance, - updateSendErrors, - updateSendTo, - updateSendTokenBalance, - tokenContract, - to, - toNickname, - addressBook, - updateToNicknameIfNecessary, - qrCodeData, - qrCodeDetected, - } = this.props; - const { toError, toWarning } = this.state; - - let updateGas = false; - const { - from: { balance: prevBalance }, - gasTotal: prevGasTotal, - tokenBalance: prevTokenBalance, - chainId: prevChainId, - sendToken: prevSendToken, - to: prevTo, - } = prevProps; - - const uninitialized = [prevBalance, prevGasTotal].every((n) => n === null); - - const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - sendToken, - tokenBalance, - }); - - if (amountErrorRequiresUpdate) { - const amountErrorObject = getAmountErrorObject({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - const gasFeeErrorObject = sendToken - ? getGasFeeErrorObject({ - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - }) - : { gasFee: null }; - updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)); - } - - if (!uninitialized) { - if (chainId !== prevChainId && chainId !== undefined) { - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }); - updateToNicknameIfNecessary(to, toNickname, addressBook); - this.props.fetchBasicGasEstimates(); - updateGas = true; - } - } - - const prevTokenAddress = prevSendToken && prevSendToken.address; - const sendTokenAddress = sendToken && sendToken.address; - - if (sendTokenAddress && prevTokenAddress !== sendTokenAddress) { - this.updateSendToken(); - this.validate(this.state.query); - updateGas = true; - } - - let scannedAddress; - if (qrCodeData) { - if (qrCodeData.type === 'address') { - scannedAddress = qrCodeData.values.address.toLowerCase(); - if (isValidHexAddress(scannedAddress, { allowNonPrefixed: false })) { - const currentAddress = prevTo?.toLowerCase(); - if (currentAddress !== scannedAddress) { - updateSendTo(scannedAddress); - updateGas = true; - // Clean up QR code data after handling - qrCodeDetected(null); - } - } else { - scannedAddress = null; - qrCodeDetected(null); - this.setState({ toError: INVALID_RECIPIENT_ADDRESS_ERROR }); - } - } - } - - if (updateGas) { - if (scannedAddress) { - this.updateGas({ to: scannedAddress }); - } else { - this.updateGas(); - } - } - - // If selecting ETH after selecting a token, clear token related messages. - if (prevSendToken && !sendToken) { - let error = toError; - let warning = toWarning; - - if (toError === CONTRACT_ADDRESS_ERROR) { - error = null; - } - - if (toWarning === KNOWN_RECIPIENT_ADDRESS_ERROR) { - warning = null; - } - - this.setState({ - toError: error, - toWarning: warning, - }); - } - } - - componentDidMount() { - this.props.fetchBasicGasEstimates().then(() => { - this.updateGas(); - }); - } - - UNSAFE_componentWillMount() { - this.updateSendToken(); - - // Show QR Scanner modal if ?scan=true - if (window.location.search === '?scan=true') { - this.props.scanQrCode(); - - // Clear the queryString param after showing the modal - const cleanUrl = window.location.href.split('?')[0]; - window.history.pushState({}, null, `${cleanUrl}`); - window.location.hash = '#send'; - } - } - - componentWillUnmount() { - this.props.resetSendState(); - } - - onRecipientInputChange = (query) => { - const { internalSearch } = this.state; - - if (!internalSearch) { - if (query) { - this.dValidate(query); - } else { - this.dValidate.cancel(); - this.validate(query); - } - } - - this.setState({ query }); - }; - - setInternalSearch(internalSearch) { - this.setState({ query: '', internalSearch }); - } - - validate(query) { - const { tokens, sendToken, chainId, sendTokenAddress } = this.props; - - const { internalSearch } = this.state; - - if (!query || internalSearch) { - this.setState({ toError: '', toWarning: '' }); - return; - } - - const toErrorObject = getToErrorObject(query, sendTokenAddress, chainId); - const toWarningObject = getToWarningObject(query, tokens, sendToken); - - this.setState({ - toError: toErrorObject.to, - toWarning: toWarningObject.to, - }); - } - - updateSendToken() { - const { - from: { address }, - sendToken, - tokenContract, - updateSendTokenBalance, - } = this.props; - - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }); - } - - updateGas({ to: updatedToAddress, amount: value, data } = {}) { - const { - amount, - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to: currentToAddress, - updateAndSetGasLimit, - } = this.props; - - updateAndSetGasLimit({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), - value: value || amount, - data, - }); - } - - render() { - const { history, to } = this.props; - let content; - - if (to) { - content = this.renderSendContent(); - } else { - content = this.renderAddRecipient(); - } - - return ( -
- - {this.renderInput()} - {content} -
- ); - } - - renderInput() { - const { internalSearch } = this.state; - return ( - { - this.context.metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Used QR scanner', - }, - }); - this.props.scanQrCode(); - }} - onChange={this.onRecipientInputChange} - onValidAddressTyped={(address) => this.props.updateSendTo(address, '')} - onPaste={(text) => { - this.props.updateSendTo(text) && this.updateGas(); - }} - onReset={() => this.props.updateSendTo('', '')} - updateEnsResolution={this.props.updateSendEnsResolution} - updateEnsResolutionError={this.props.updateSendEnsResolutionError} - internalSearch={internalSearch} - /> - ); - } - - renderAddRecipient() { - const { toError, toWarning } = this.state; - return ( - - this.updateGas({ to, amount, data }) - } - query={this.state.query} - toError={toError} - toWarning={toWarning} - setInternalSearch={(internalSearch) => - this.setInternalSearch(internalSearch) - } - /> - ); - } - - renderSendContent() { - const { history, showHexData, gasIsExcessive } = this.props; - const { toWarning, toError } = this.state; - - return [ - - this.updateGas({ to, amount, data }) - } - showHexData={showHexData} - warning={toWarning} - error={toError} - gasIsExcessive={gasIsExcessive} - />, - , - ]; - } -} diff --git a/ui/pages/send/send.component.test.js b/ui/pages/send/send.component.test.js deleted file mode 100644 index 5cc90307e..000000000 --- a/ui/pages/send/send.component.test.js +++ /dev/null @@ -1,467 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import { - RINKEBY_CHAIN_ID, - ROPSTEN_CHAIN_ID, -} from '../../../shared/constants/network'; -import SendTransactionScreen from './send.component'; -import * as util from './send.utils'; - -import SendHeader from './send-header/send-header.container'; -import SendContent from './send-content/send-content.container'; -import SendFooter from './send-footer/send-footer.container'; - -import AddRecipient from './send-content/add-recipient/add-recipient.container'; - -jest.mock('./send.utils', () => ({ - getToAddressForGasUpdate: jest.fn().mockReturnValue('mockAddress'), - getAmountErrorObject: jest.fn().mockReturnValue({ - amount: 'mockAmountError', - }), - getGasFeeErrorObject: jest.fn().mockReturnValue({ - gasFee: 'mockGasFeeError', - }), - doesAmountErrorRequireUpdate: jest.fn( - (obj) => obj.balance !== obj.prevBalance, - ), -})); - -describe('Send Component', () => { - let wrapper, didMountSpy, updateGasSpy; - - const mockBasicGasEstimates = { - blockTime: 'mockBlockTime', - }; - - const propsMethodSpies = { - updateAndSetGasLimit: jest.fn(), - updateSendErrors: jest.fn(), - updateSendTokenBalance: jest.fn(), - resetSendState: jest.fn(), - fetchBasicGasEstimates: jest.fn(() => - Promise.resolve(mockBasicGasEstimates), - ), - fetchGasEstimates: jest.fn(), - updateToNicknameIfNecessary: jest.fn(), - }; - - beforeAll(() => { - didMountSpy = sinon.spy( - SendTransactionScreen.prototype, - 'componentDidMount', - ); - updateGasSpy = sinon.spy(SendTransactionScreen.prototype, 'updateGas'); - }); - - beforeEach(() => { - wrapper = shallow( - undefined} - scanQrCode={() => undefined} - updateSendEnsResolution={() => undefined} - updateSendEnsResolutionError={() => undefined} - updateSendErrors={propsMethodSpies.updateSendErrors} - updateSendTo={() => undefined} - updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} - resetSendState={propsMethodSpies.resetSendState} - updateToNicknameIfNecessary={ - propsMethodSpies.updateToNicknameIfNecessary - } - gasIsExcessive={false} - />, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - didMountSpy.resetHistory(); - updateGasSpy.resetHistory(); - }); - - describe('componentDidMount', () => { - it('should call componentDidMount', () => { - expect(didMountSpy.callCount).toStrictEqual(1); - }); - - it('should call props.fetchBasicGasAndTimeEstimates', () => { - propsMethodSpies.fetchBasicGasEstimates.mockClear(); - expect(propsMethodSpies.fetchBasicGasEstimates).not.toHaveBeenCalled(); - wrapper.instance().componentDidMount(); - expect(propsMethodSpies.fetchBasicGasEstimates).toHaveBeenCalled(); - }); - - it('should call this.updateGas', () => { - expect(updateGasSpy.callCount).toStrictEqual(1); - }); - }); - - describe('componentWillUnmount', () => { - it('should call this.props.resetSendState', () => { - propsMethodSpies.resetSendState.mockClear(); - expect(propsMethodSpies.resetSendState).not.toHaveBeenCalled(); - wrapper.instance().componentWillUnmount(); - expect(propsMethodSpies.resetSendState).toHaveBeenCalled(); - }); - }); - - describe('componentDidUpdate', () => { - it('should call doesAmountErrorRequireUpdate with the expected params', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: '', - }, - }); - expect(util.doesAmountErrorRequireUpdate).toHaveBeenCalled(); - expect(util.doesAmountErrorRequireUpdate.mock.calls[0][0]).toMatchObject({ - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - prevBalance: '', - prevGasTotal: undefined, - prevTokenBalance: undefined, - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - tokenBalance: 'mockTokenBalance', - }); - }); - - it('should not call getAmountErrorObject if doesAmountErrorRequireUpdate returns false', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'mockBalance', - }, - }); - expect(util.getAmountErrorObject).not.toHaveBeenCalled(); - }); - - it('should call getAmountErrorObject if doesAmountErrorRequireUpdate returns true', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getAmountErrorObject).toHaveBeenCalled(); - expect(util.getAmountErrorObject.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmount', - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - tokenBalance: 'mockTokenBalance', - }); - }); - - it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and sendToken is truthy', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getGasFeeErrorObject).toHaveBeenCalled(); - expect(util.getGasFeeErrorObject.mock.calls[0][0]).toMatchObject({ - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - }); - }); - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => { - wrapper.instance().componentDidUpdate({ - from: { address: 'mockAddress', balance: 'mockBalance' }, - }); - expect(util.getGasFeeErrorObject).not.toHaveBeenCalled(); - }); - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but sendToken is falsy', () => { - wrapper.setProps({ sendToken: null }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getGasFeeErrorObject).not.toHaveBeenCalled(); - }); - - it('should call updateSendErrors with the expected params if sendToken is falsy', () => { - wrapper.setProps({ sendToken: null }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(propsMethodSpies.updateSendErrors).toHaveBeenCalledTimes(1); - expect(propsMethodSpies.updateSendErrors.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmountError', - gasFee: null, - }); - }); - - it('should call updateSendErrors with the expected params if sendToken is truthy', () => { - wrapper.setProps({ - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, - }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(propsMethodSpies.updateSendErrors).toHaveBeenCalled(); - expect(propsMethodSpies.updateSendErrors.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmountError', - gasFee: 'mockGasFeeError', - }); - }); - - it('should not call updateSendTokenBalance or this.updateGas if network === prevNetwork', () => { - propsMethodSpies.updateSendTokenBalance.mockClear(); - updateGasSpy.resetHistory(); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: ROPSTEN_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).not.toHaveBeenCalled(); - expect(updateGasSpy.callCount).toStrictEqual(0); - }); - - it('should not call updateSendTokenBalance or this.updateGas if network === loading', () => { - propsMethodSpies.updateSendTokenBalance.mockClear(); - updateGasSpy.resetHistory(); - wrapper.setProps({ network: 'loading' }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: ROPSTEN_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).not.toHaveBeenCalled(); - expect(updateGasSpy.callCount).toStrictEqual(0); - }); - - it('should call updateSendTokenBalance and this.updateGas with the correct params', () => { - updateGasSpy.resetHistory(); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: RINKEBY_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).toHaveBeenCalled(); - expect( - propsMethodSpies.updateSendTokenBalance.mock.calls[0][0], - ).toMatchObject({ - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, // Make sure not to hit updateGas when changing asset - tokenContract: { method: 'mockTokenMethod' }, - address: 'mockAddress', - }); - expect(updateGasSpy.callCount).toStrictEqual(1); - }); - - it('should call updateGas when sendToken.address is changed', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balancedChanged', - }, - chainId: ROPSTEN_CHAIN_ID, // Make sure not to hit updateGas when changing network - sendToken: { address: 'newSelectedToken' }, - }); - expect( - propsMethodSpies.updateToNicknameIfNecessary, - ).not.toHaveBeenCalled(); // Network did not change - expect(propsMethodSpies.updateAndSetGasLimit).toHaveBeenCalled(); - }); - }); - - describe('updateGas', () => { - it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => { - propsMethodSpies.updateAndSetGasLimit.mockClear(); - wrapper.instance().updateGas(); - expect(propsMethodSpies.updateAndSetGasLimit).toHaveBeenCalled(); - expect( - propsMethodSpies.updateAndSetGasLimit.mock.calls[0][0], - ).toMatchObject({ - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: 'mockEditingTransactionId', - gasLimit: 'mockGasLimit', - gasPrice: 'mockGasPrice', - selectedAddress: 'mockSelectedAddress', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - to: 'mockAddress', - value: 'mockAmount', - data: undefined, - }); - }); - }); - - describe('render', () => { - it('should render a page-container class', () => { - expect(wrapper.find('.page-container')).toHaveLength(1); - }); - - it('should render SendHeader and AddRecipient', () => { - expect(wrapper.find(SendHeader)).toHaveLength(1); - expect(wrapper.find(AddRecipient)).toHaveLength(1); - }); - - it('should pass the history prop to SendHeader and SendFooter', () => { - wrapper.setProps({ - to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - }); - expect(wrapper.find(SendHeader)).toHaveLength(1); - expect(wrapper.find(SendContent)).toHaveLength(1); - expect(wrapper.find(SendFooter)).toHaveLength(1); - expect(wrapper.find(SendFooter).props()).toStrictEqual({ - history: { mockProp: 'history-abc' }, - }); - }); - - it('should pass showHexData to SendContent', () => { - wrapper.setProps({ - to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - }); - expect(wrapper.find(SendContent).props().showHexData).toStrictEqual(true); - }); - }); - - describe('validate when input change', () => { - let clock; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should validate when input changes', () => { - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - ); - - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - toError: null, - toWarning: null, - }); - }); - - it('should validate when input changes and has error', () => { - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - }); - - it('should validate when input changes and has error on a bad network', () => { - wrapper.setProps({ network: 'bad' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - }); - - it('should synchronously validate when input changes to ""', () => { - wrapper.setProps({ network: 'bad' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - - instance.onRecipientInputChange(''); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '', - toError: '', - toWarning: '', - }); - }); - - it('should warn when send to a known token contract address', () => { - wrapper.setProps({ address: '0x888', decimals: 18, symbol: '888' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64', - toError: null, - toWarning: 'knownAddressRecipient', - }); - }); - }); -}); diff --git a/ui/pages/send/send.constants.js b/ui/pages/send/send.constants.js index ba5113603..48e96ef95 100644 --- a/ui/pages/send/send.constants.js +++ b/ui/pages/send/send.constants.js @@ -34,17 +34,29 @@ const INVALID_RECIPIENT_ADDRESS_ERROR = 'invalidAddressRecipient'; const INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR = 'invalidAddressRecipientNotEthNetwork'; const REQUIRED_ERROR = 'required'; -const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient'; +const KNOWN_RECIPIENT_ADDRESS_WARNING = 'knownAddressRecipient'; const CONTRACT_ADDRESS_ERROR = 'contractAddressError'; const CONFUSING_ENS_ERROR = 'confusingEnsDomain'; +const ENS_NO_ADDRESS_FOR_NAME = 'noAddressForName'; +const ENS_NOT_FOUND_ON_NETWORK = 'ensNotFoundOnCurrentNetwork'; +const ENS_NOT_SUPPORTED_ON_NETWORK = 'ensNotSupportedOnNetwork'; +const ENS_ILLEGAL_CHARACTER = 'ensIllegalCharacter'; +const ENS_UNKNOWN_ERROR = 'ensUnknownError'; +const ENS_REGISTRATION_ERROR = 'ensRegistrationError'; export { INSUFFICIENT_FUNDS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, CONTRACT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + ENS_NO_ADDRESS_FOR_NAME, + ENS_NOT_FOUND_ON_NETWORK, + ENS_NOT_SUPPORTED_ON_NETWORK, + ENS_ILLEGAL_CHARACTER, + ENS_UNKNOWN_ERROR, + ENS_REGISTRATION_ERROR, MIN_GAS_LIMIT_DEC, MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_DEC, diff --git a/ui/pages/send/send.container.js b/ui/pages/send/send.container.js deleted file mode 100644 index f942131dd..000000000 --- a/ui/pages/send/send.container.js +++ /dev/null @@ -1,138 +0,0 @@ -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { compose } from 'redux'; - -import { - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, - getSendTo, - getSendToNickname, - getTokenBalance, - getQrCodeData, - getSelectedAddress, - getAddressBook, - getSendTokenAddress, - isCustomPriceExcessive, - getCurrentChainId, -} from '../../selectors'; - -import { showQrScanner, qrCodeDetected } from '../../store/actions'; -import { - resetSendState, - updateSendErrors, - updateSendTo, - updateSendTokenBalance, - updateGasData, - setGasTotal, - updateSendEnsResolution, - updateSendEnsResolutionError, -} from '../../ducks/send/send.duck'; -import { fetchBasicGasEstimates } from '../../ducks/gas/gas.duck'; -import { - getBlockGasLimit, - getConversionRate, - getSendHexDataFeatureFlagState, - getTokens, -} from '../../ducks/metamask/metamask'; -import { isValidDomainName } from '../../helpers/utils/util'; -import { calcGasTotal } from './send.utils'; -import SendEther from './send.component'; - -function mapStateToProps(state) { - const editingTransactionId = getSendEditingTransactionId(state); - - return { - addressBook: getAddressBook(state), - amount: getSendAmount(state), - blockGasLimit: getBlockGasLimit(state), - conversionRate: getConversionRate(state), - editingTransactionId, - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - chainId: getCurrentChainId(state), - primaryCurrency: getPrimaryCurrency(state), - qrCodeData: getQrCodeData(state), - selectedAddress: getSelectedAddress(state), - sendToken: getSendToken(state), - showHexData: getSendHexDataFeatureFlagState(state), - to: getSendTo(state), - toNickname: getSendToNickname(state), - tokens: getTokens(state), - tokenBalance: getTokenBalance(state), - tokenContract: getSendTokenContract(state), - sendTokenAddress: getSendTokenAddress(state), - gasIsExcessive: isCustomPriceExcessive(state, true), - }; -} - -function mapDispatchToProps(dispatch) { - return { - updateAndSetGasLimit: ({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to, - value, - data, - }) => { - editingTransactionId - ? dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) - : dispatch( - updateGasData({ - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - }), - ); - }, - updateSendTokenBalance: ({ sendToken, tokenContract, address }) => { - dispatch( - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }), - ); - }, - updateSendErrors: (newError) => dispatch(updateSendErrors(newError)), - resetSendState: () => dispatch(resetSendState()), - scanQrCode: () => dispatch(showQrScanner()), - qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), - fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()), - updateSendEnsResolution: (ensResolution) => - dispatch(updateSendEnsResolution(ensResolution)), - updateSendEnsResolutionError: (message) => - dispatch(updateSendEnsResolutionError(message)), - updateToNicknameIfNecessary: (to, toNickname, addressBook) => { - if (isValidDomainName(toNickname)) { - const addressBookEntry = - addressBook.find(({ address }) => to === address) || {}; - if (!addressBookEntry.name !== toNickname) { - dispatch(updateSendTo(to, addressBookEntry.name || '')); - } - } - }, - }; -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps), -)(SendEther); diff --git a/ui/pages/send/send.container.test.js b/ui/pages/send/send.container.test.js deleted file mode 100644 index 3072b3243..000000000 --- a/ui/pages/send/send.container.test.js +++ /dev/null @@ -1,128 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendTokenBalance, - updateGasData, - setGasTotal, - updateSendErrors, - resetSendState, -} from '../../ducks/send/send.duck'; - -let mapDispatchToProps; - -jest.mock('react-redux', () => ({ - connect: (_, md) => { - mapDispatchToProps = md; - return () => ({}); - }, -})); - -jest.mock('react-router-dom', () => ({ - withRouter: () => undefined, -})); - -jest.mock('redux', () => ({ - compose: (_, arg2) => () => arg2(), -})); - -jest.mock('../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - resetSendState: jest.fn(), - updateSendTokenBalance: jest.fn(), - updateGasData: jest.fn(), - setGasTotal: jest.fn(), -})); - -jest.mock('./send.utils.js', () => ({ - calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, -})); - -require('./send.container.js'); - -describe('send container', () => { - describe('mapDispatchToProps()', () => { - let dispatchSpy; - let mapDispatchToPropsObject; - - beforeEach(() => { - dispatchSpy = sinon.spy(); - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); - }); - - describe('updateAndSetGasLimit()', () => { - const mockProps = { - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: '0x2', - gasLimit: '0x3', - gasPrice: '0x4', - selectedAddress: '0x4', - sendToken: { address: '0x1' }, - to: 'mockTo', - value: 'mockValue', - data: undefined, - }; - - it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { - mapDispatchToPropsObject.updateAndSetGasLimit(mockProps); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setGasTotal).toHaveBeenCalledWith('0x30x4'); - }); - - it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { - const { - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - } = mockProps; - mapDispatchToPropsObject.updateAndSetGasLimit({ - ...mockProps, - editingTransactionId: false, - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateGasData).toHaveBeenCalledWith({ - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - }); - }); - }); - - describe('updateSendTokenBalance()', () => { - const mockProps = { - address: '0x10', - tokenContract: '0x00a', - sendToken: { address: '0x1' }, - }; - - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTokenBalance({ ...mockProps }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendTokenBalance).toHaveBeenCalledWith(mockProps); - }); - }); - - describe('updateSendErrors()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendErrors('mockError'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalledWith('mockError'); - }); - }); - - describe('resetSendState()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.resetSendState(); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(resetSendState).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js new file mode 100644 index 000000000..1e908d9de --- /dev/null +++ b/ui/pages/send/send.js @@ -0,0 +1,112 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { + getIsUsingMyAccountForRecipientSearch, + getRecipient, + getRecipientUserInput, + getSendStage, + initializeSendState, + resetRecipientInput, + resetSendState, + SEND_STAGES, + updateRecipient, + updateRecipientUserInput, +} from '../../ducks/send'; +import { getCurrentChainId, isCustomPriceExcessive } from '../../selectors'; +import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask'; +import { showQrScanner } from '../../store/actions'; +import { useMetricEvent } from '../../hooks/useMetricEvent'; +import SendHeader from './send-header'; +import AddRecipient from './send-content/add-recipient'; +import SendContent from './send-content'; +import SendFooter from './send-footer'; +import EnsInput from './send-content/add-recipient/ens-input'; + +const sendSliceIsCustomPriceExcessive = (state) => + isCustomPriceExcessive(state, true); + +export default function SendTransactionScreen() { + const history = useHistory(); + const chainId = useSelector(getCurrentChainId); + const stage = useSelector(getSendStage); + const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive); + const isUsingMyAccountsForRecipientSearch = useSelector( + getIsUsingMyAccountForRecipientSearch, + ); + const recipient = useSelector(getRecipient); + const showHexData = useSelector(getSendHexDataFeatureFlagState); + const userInput = useSelector(getRecipientUserInput); + const location = useLocation(); + const trackUsedQRScanner = useMetricEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Used QR scanner', + }, + }); + + const dispatch = useDispatch(); + useEffect(() => { + if (chainId !== undefined) { + dispatch(initializeSendState()); + } + }, [chainId, dispatch]); + + useEffect(() => { + if (location.search === '?scan=true') { + dispatch(showQrScanner()); + + // Clear the queryString param after showing the modal + const cleanUrl = window.location.href.split('?')[0]; + window.history.pushState({}, null, `${cleanUrl}`); + window.location.hash = '#send'; + } + }, [location, dispatch]); + + useEffect(() => { + return () => { + dispatch(resetSendState()); + }; + }, [dispatch]); + + let content; + + if ([SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage)) { + content = ( + <> + + + + ); + } else { + content = ; + } + + return ( +
+ + dispatch(updateRecipientUserInput(address))} + onValidAddressTyped={(address) => + dispatch(updateRecipient({ address, nickname: '' })) + } + internalSearch={isUsingMyAccountsForRecipientSearch} + selectedAddress={recipient.address} + selectedName={recipient.nickname} + onPaste={(text) => updateRecipient({ address: text, nickname: '' })} + onReset={() => dispatch(resetRecipientInput())} + scanQrCode={() => { + trackUsedQRScanner(); + dispatch(showQrScanner()); + }} + /> + {content} +
+ ); +} diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js new file mode 100644 index 000000000..07bef5b26 --- /dev/null +++ b/ui/pages/send/send.test.js @@ -0,0 +1,173 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { useLocation } from 'react-router-dom'; +import { describe } from 'globalthis/implementation'; +import { initialState, SEND_STAGES } from '../../ducks/send'; +import { ensInitialState } from '../../ducks/ens'; +import { renderWithProvider } from '../../../test/jest'; +import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import Send from './send'; + +const middleware = [thunk]; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useLocation: jest.fn(() => ({ search: '' })), + useHistory: () => ({ + push: jest.fn(), + }), + }; +}); + +jest.mock( + 'ethjs-ens', + () => + class MocKENS { + async ensLookup() { + return ''; + } + }, +); + +const baseStore = { + send: initialState, + ENS: ensInitialState, + gas: { + basicEstimateStatus: 'READY', + basicEstimates: { slow: '0x0', average: '0x1', fast: '0x2' }, + customData: { limit: null, price: null }, + }, + history: { mostRecentOverviewPage: 'activity' }, + metamask: { + tokens: [], + preferences: { + useNativeCurrencyAsPrimaryCurrency: false, + }, + currentCurrency: 'USD', + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + nativeCurrency: 'ETH', + featureFlags: { + sendHexData: false, + }, + addressBook: { + [RINKEBY_CHAIN_ID]: [], + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: {}, + }, + accounts: { + '0x0': { balance: '0x0', address: '0x0' }, + }, + identities: { '0x0': {} }, + }, +}; + +describe('Send Page', () => { + describe('Send Flow Initialization', () => { + it('should initialize the send, ENS, and gas slices on render', () => { + const store = configureMockStore(middleware)(baseStore); + renderWithProvider(, store); + const actions = store.getActions(); + expect(actions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ENS/enableEnsLookup', + }), + expect.objectContaining({ + type: 'send/initializeSendState/pending', + }), + expect.objectContaining({ + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + }), + expect.objectContaining({ + type: 'metamask/gas/SET_ESTIMATE_SOURCE', + }), + ]), + ); + }); + + it('should showQrScanner when location.search is ?scan=true', () => { + useLocation.mockImplementation(() => ({ search: '?scan=true' })); + const store = configureMockStore(middleware)(baseStore); + renderWithProvider(, store); + const actions = store.getActions(); + expect(actions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ENS/enableEnsLookup', + }), + expect.objectContaining({ + type: 'send/initializeSendState/pending', + }), + expect.objectContaining({ + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + }), + expect.objectContaining({ + type: 'metamask/gas/SET_ESTIMATE_SOURCE', + }), + expect.objectContaining({ + type: 'UI_MODAL_OPEN', + payload: { name: 'QR_SCANNER' }, + }), + ]), + ); + useLocation.mockImplementation(() => ({ search: '' })); + }); + }); + + describe('Add Recipient Flow', () => { + it('should render the header with Add Recipient displayed', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByText } = renderWithProvider(, store); + expect(getByText('Add Recipient')).toBeTruthy(); + }); + + it('should render the EnsInput field', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByPlaceholderText } = renderWithProvider(, store); + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + }); + + it('should not render the footer', () => { + const store = configureMockStore(middleware)(baseStore); + const { queryByText } = renderWithProvider(, store); + expect(queryByText('Next')).toBeNull(); + }); + }); + + describe('Send and Edit Flow', () => { + it('should render the header with Send displayed', () => { + const store = configureMockStore(middleware)({ + ...baseStore, + send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + }); + const { getByText } = renderWithProvider(, store); + expect(getByText('Send')).toBeTruthy(); + }); + + it('should render the EnsInput field', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByPlaceholderText } = renderWithProvider(, store); + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + }); + + it('should render the footer', () => { + const store = configureMockStore(middleware)({ + ...baseStore, + send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + }); + const { getByText } = renderWithProvider(, store); + expect(getByText('Next')).toBeTruthy(); + }); + }); +}); diff --git a/ui/pages/send/send.utils.js b/ui/pages/send/send.utils.js index 1d7fb3562..d4d4e7670 100644 --- a/ui/pages/send/send.utils.js +++ b/ui/pages/send/send.utils.js @@ -11,28 +11,14 @@ import { import { calcTokenAmount } from '../../helpers/utils/token-util'; import { addHexPrefix } from '../../../app/scripts/lib/util'; -import { GAS_LIMITS } from '../../../shared/constants/gas'; -import { - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, - MIN_GAS_LIMIT_HEX, - NEGATIVE_ETH_ERROR, - TOKEN_TRANSFER_FUNCTION_SIGNATURE, -} from './send.constants'; +import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from './send.constants'; export { addGasBuffer, calcGasTotal, - calcTokenBalance, - doesAmountErrorRequireUpdate, - estimateGasForSend, generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, isBalanceSufficient, isTokenBalanceSufficient, - removeLeadingZeroes, ellipsify, }; @@ -93,186 +79,6 @@ function isTokenBalanceSufficient({ amount = '0x0', tokenBalance, decimals }) { return tokenBalanceIsSufficient; } -function getAmountErrorObject({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, -}) { - let insufficientFunds = false; - if (gasTotal && conversionRate && !sendToken) { - insufficientFunds = !isBalanceSufficient({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - }); - } - - let inSufficientTokens = false; - if (sendToken && tokenBalance !== null) { - const { decimals } = sendToken; - inSufficientTokens = !isTokenBalanceSufficient({ - tokenBalance, - amount, - decimals, - }); - } - - const amountLessThanZero = conversionGreaterThan( - { value: 0, fromNumericBase: 'dec' }, - { value: amount, fromNumericBase: 'hex' }, - ); - - let amountError = null; - - if (insufficientFunds) { - amountError = INSUFFICIENT_FUNDS_ERROR; - } else if (inSufficientTokens) { - amountError = INSUFFICIENT_TOKENS_ERROR; - } else if (amountLessThanZero) { - amountError = NEGATIVE_ETH_ERROR; - } - - return { amount: amountError }; -} - -function getGasFeeErrorObject({ - balance, - conversionRate, - gasTotal, - primaryCurrency, -}) { - let gasFeeError = null; - - if (gasTotal && conversionRate) { - const insufficientFunds = !isBalanceSufficient({ - amount: '0x0', - balance, - conversionRate, - gasTotal, - primaryCurrency, - }); - - if (insufficientFunds) { - gasFeeError = INSUFFICIENT_FUNDS_ERROR; - } - } - - return { gasFee: gasFeeError }; -} - -function calcTokenBalance({ sendToken, usersToken }) { - const { decimals } = sendToken || {}; - return calcTokenAmount(usersToken.balance.toString(), decimals).toString(16); -} - -function doesAmountErrorRequireUpdate({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - sendToken, - tokenBalance, -}) { - const balanceHasChanged = balance !== prevBalance; - const gasTotalHasChange = gasTotal !== prevGasTotal; - const tokenBalanceHasChanged = sendToken && tokenBalance !== prevTokenBalance; - const amountErrorRequiresUpdate = - balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged; - - return amountErrorRequiresUpdate; -} - -async function estimateGasForSend({ - selectedAddress, - sendToken, - blockGasLimit = MIN_GAS_LIMIT_HEX, - to, - value, - data, - gasPrice, - estimateGasMethod, -}) { - const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; - - // if recipient has no code, gas is 21k max: - if (!sendToken && !data) { - const code = Boolean(to) && (await global.eth.getCode(to)); - // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' - const codeIsEmpty = !code || code === '0x' || code === '0x0'; - if (codeIsEmpty) { - return GAS_LIMITS.SIMPLE; - } - } else if (sendToken && !to) { - return GAS_LIMITS.BASE_TOKEN_ESTIMATE; - } - - if (sendToken) { - paramsForGasEstimate.value = '0x0'; - paramsForGasEstimate.data = generateTokenTransferData({ - toAddress: to, - amount: value, - sendToken, - }); - paramsForGasEstimate.to = sendToken.address; - } else { - if (data) { - paramsForGasEstimate.data = data; - } - - if (to) { - paramsForGasEstimate.to = to; - } - - if (!value || value === '0') { - paramsForGasEstimate.value = '0xff'; - } - } - - // if not, fall back to block gasLimit - if (!blockGasLimit) { - // eslint-disable-next-line no-param-reassign - blockGasLimit = MIN_GAS_LIMIT_HEX; - } - - paramsForGasEstimate.gas = addHexPrefix( - multiplyCurrencies(blockGasLimit, 0.95, { - multiplicandBase: 16, - multiplierBase: 10, - roundDown: '0', - toNumericBase: 'hex', - }), - ); - - // run tx - try { - const estimatedGas = await estimateGasMethod(paramsForGasEstimate); - const estimateWithBuffer = addGasBuffer(estimatedGas, blockGasLimit, 1.5); - return addHexPrefix(estimateWithBuffer); - } catch (error) { - const simulationFailed = - error.message.includes('Transaction execution error.') || - error.message.includes( - 'gas required exceeds allowance or always failing transaction', - ); - if (simulationFailed) { - const estimateWithBuffer = addGasBuffer( - paramsForGasEstimate.gas, - blockGasLimit, - 1.5, - ); - return addHexPrefix(estimateWithBuffer); - } - throw error; - } -} - function addGasBuffer( initialGasLimitHex, blockGasLimitHex, @@ -339,16 +145,6 @@ function generateTokenTransferData({ ); } -function getToAddressForGasUpdate(...addresses) { - return [...addresses, ''] - .find((str) => str !== undefined && str !== null) - .toLowerCase(); -} - -function removeLeadingZeroes(str) { - return str.replace(/^0*(?=\d)/u, ''); -} - function ellipsify(text, first = 6, last = 4) { return `${text.slice(0, first)}...${text.slice(-last)}`; } diff --git a/ui/pages/send/send.utils.test.js b/ui/pages/send/send.utils.test.js index 02b45f1fa..7960b4aca 100644 --- a/ui/pages/send/send.utils.test.js +++ b/ui/pages/send/send.utils.test.js @@ -1,4 +1,3 @@ -import sinon from 'sinon'; import { rawEncode } from 'ethereumjs-abi'; import { @@ -8,26 +7,13 @@ import { conversionUtil, } from '../../helpers/utils/conversion-util'; -import { GAS_LIMITS } from '../../../shared/constants/gas'; import { calcGasTotal, - estimateGasForSend, - doesAmountErrorRequireUpdate, generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - calcTokenBalance, isBalanceSufficient, isTokenBalanceSufficient, - removeLeadingZeroes, } from './send.utils'; -import { - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, -} from './send.constants'; - jest.mock('../../helpers/utils/conversion-util', () => ({ addCurrencies: jest.fn((a, b) => { let [a1, b1] = [a, b]; @@ -67,44 +53,6 @@ describe('send utils', () => { }); }); - describe('doesAmountErrorRequireUpdate()', () => { - const config = { - 'should return true if balances are different': { - balance: 0, - prevBalance: 1, - expectedResult: true, - }, - 'should return true if gasTotals are different': { - gasTotal: 0, - prevGasTotal: 1, - expectedResult: true, - }, - 'should return true if token balances are different': { - tokenBalance: 0, - prevTokenBalance: 1, - sendToken: { address: '0x0' }, - expectedResult: true, - }, - 'should return false if they are all the same': { - balance: 1, - prevBalance: 1, - gasTotal: 1, - prevGasTotal: 1, - tokenBalance: 1, - prevTokenBalance: 1, - sendToken: { address: '0x0' }, - expectedResult: false, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(doesAmountErrorRequireUpdate(obj)).toStrictEqual( - obj.expectedResult, - ); - }); - }); - }); - describe('generateTokenTransferData()', () => { it('should return undefined if not passed a send token', () => { expect( @@ -141,86 +89,6 @@ describe('send utils', () => { }); }); - describe('getAmountErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - amount: 15, - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should not return insufficientFunds error if sendToken is truthy': { - amount: '0x0', - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - sendToken: { address: '0x0', symbol: 'DEF', decimals: 0 }, - decimals: 0, - tokenBalance: 'sometokenbalance', - expectedResult: { amount: null }, - }, - 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': { - amount: '0x10', - balance: 100, - conversionRate: 3, - decimals: 10, - gasTotal: 17, - primaryCurrency: 'ABC', - sendToken: { address: '0x0' }, - tokenBalance: 123, - expectedResult: { amount: INSUFFICIENT_TOKENS_ERROR }, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(getAmountErrorObject(obj)).toStrictEqual(obj.expectedResult); - }); - }); - }); - - describe('getGasFeeErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - balance: 16, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should return null error if isBalanceSufficient returns true': { - balance: 16, - conversionRate: 3, - gasTotal: 15, - primaryCurrency: 'ABC', - expectedResult: { gasFee: null }, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(getGasFeeErrorObject(obj)).toStrictEqual(obj.expectedResult); - }); - }); - }); - - describe('calcTokenBalance()', () => { - it('should return the calculated token balance', () => { - expect( - calcTokenBalance({ - sendToken: { - address: '0x0', - decimals: 11, - }, - usersToken: { - balance: 20, - }, - }), - ).toStrictEqual('calc:2011'); - }); - }); - describe('isBalanceSufficient()', () => { it('should correctly call addCurrencies and return the result of calling conversionGTE', () => { const result = isBalanceSufficient({ @@ -279,201 +147,4 @@ describe('send utils', () => { expect(result).toStrictEqual(false); }); }); - - describe('estimateGasForSend', () => { - const baseMockParams = { - blockGasLimit: '0x64', - selectedAddress: 'mockAddress', - to: '0xisContract', - estimateGasMethod: sinon.stub().callsFake(({ to }) => { - if (typeof to === 'string' && to.match(/willFailBecauseOf:/u)) { - throw new Error(to.match(/:(.+)$/u)[1]); - } - return '0xabc16'; - }), - }; - const baseexpectedCall = { - from: 'mockAddress', - gas: '0x64x0.95', - to: '0xisContract', - value: '0xff', - }; - - beforeEach(() => { - global.eth = { - getCode: sinon - .stub() - .callsFake((address) => - Promise.resolve(address.match(/isContract/u) ? 'not-0x' : '0x'), - ), - }; - }); - - afterEach(() => { - baseMockParams.estimateGasMethod.resetHistory(); - global.eth.getCode.resetHistory(); - }); - - it('should call ethQuery.estimateGasForSend with the expected params', async () => { - const result = await estimateGasForSend(baseMockParams); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: undefined, - ...baseexpectedCall, - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it('should call ethQuery.estimateGasForSend with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - blockGasLimit: '0xbcd', - }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: undefined, - ...baseexpectedCall, - gas: '0xbcdx0.95', - }, - ); - expect(result).toStrictEqual('0xabc16x1.5'); - }); - - it('should call ethQuery.estimateGasForSend with a value of 0x0 and the expected data and to if passed a sendToken', async () => { - const result = await estimateGasForSend({ - data: 'mockData', - sendToken: { address: 'mockAddress' }, - ...baseMockParams, - }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - ...baseexpectedCall, - gasPrice: undefined, - value: '0x0', - data: '0xa9059cbb', - to: 'mockAddress', - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it('should call ethQuery.estimateGasForSend without a recipient if the recipient is empty and data passed', async () => { - const data = 'mockData'; - const to = ''; - const result = await estimateGasForSend({ ...baseMockParams, data, to }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: '0xff', - data, - from: baseexpectedCall.from, - gas: baseexpectedCall.gas, - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it(`should return ${GAS_LIMITS.SIMPLE} if ethQuery.getCode does not return '0x'`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ - ...baseMockParams, - to: '0x123', - }); - expect(result).toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should return ${GAS_LIMITS.SIMPLE} if not passed a sendToken or truthy to address`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ ...baseMockParams, to: null }); - expect(result).toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should not return ${GAS_LIMITS.SIMPLE} if passed a sendToken`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ - ...baseMockParams, - to: '0x123', - sendToken: { address: '0x0' }, - }); - expect(result).not.toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should return ${GAS_LIMITS.BASE_TOKEN_ESTIMATE} if passed a sendToken but no to address`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: null, - sendToken: { address: '0x0' }, - }); - expect(result).toStrictEqual(GAS_LIMITS.BASE_TOKEN_ESTIMATE); - }); - - it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: 'isContract willFailBecauseOf:Transaction execution error.', - }); - expect(result).toStrictEqual('0x64x0.95'); - }); - - it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: - 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.', - }); - expect(result).toStrictEqual('0x64x0.95'); - }); - - it(`should reject other errors`, async () => { - await expect( - estimateGasForSend({ - ...baseMockParams, - to: 'isContract willFailBecauseOf:some other error', - }), - ).rejects.toThrow('some other error'); - }); - }); - - describe('getToAddressForGasUpdate()', () => { - it('should return empty string if all params are undefined or null', () => { - expect(getToAddressForGasUpdate(undefined, null)).toStrictEqual(''); - }); - - it('should return the first string that is not defined or null in lower case', () => { - expect(getToAddressForGasUpdate('A', null)).toStrictEqual('a'); - expect(getToAddressForGasUpdate(undefined, 'B')).toStrictEqual('b'); - }); - }); - - describe('removeLeadingZeroes()', () => { - it('should remove leading zeroes from int when user types', () => { - expect(removeLeadingZeroes('0')).toStrictEqual('0'); - expect(removeLeadingZeroes('1')).toStrictEqual('1'); - expect(removeLeadingZeroes('00')).toStrictEqual('0'); - expect(removeLeadingZeroes('01')).toStrictEqual('1'); - }); - - it('should remove leading zeroes from int when user copy/paste', () => { - expect(removeLeadingZeroes('001')).toStrictEqual('1'); - }); - - it('should remove leading zeroes from float when user types', () => { - expect(removeLeadingZeroes('0.')).toStrictEqual('0.'); - expect(removeLeadingZeroes('0.0')).toStrictEqual('0.0'); - expect(removeLeadingZeroes('0.00')).toStrictEqual('0.00'); - expect(removeLeadingZeroes('0.001')).toStrictEqual('0.001'); - expect(removeLeadingZeroes('0.10')).toStrictEqual('0.10'); - }); - - it('should remove leading zeroes from float when user copy/paste', () => { - expect(removeLeadingZeroes('00.1')).toStrictEqual('0.1'); - }); - }); }); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js index e454838d1..64e076973 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js @@ -11,6 +11,7 @@ import { isBurnAddress, isValidHexAddress, } from '../../../../../shared/modules/hexstring-utils'; +import { INVALID_RECIPIENT_ADDRESS_ERROR } from '../../../send/send.constants'; export default class AddContact extends PureComponent { static contextTypes = { @@ -24,28 +25,32 @@ export default class AddContact extends PureComponent { qrCodeData: PropTypes.object /* eslint-disable-line react/no-unused-prop-types */, qrCodeDetected: PropTypes.func, + ensResolution: PropTypes.string, + ensError: PropTypes.string, + resetResolution: PropTypes.func, }; state = { newName: '', ethAddress: '', - ensAddress: '', error: '', - ensError: '', + input: '', }; constructor(props) { super(props); - this.dValidate = debounce(this.validate, 1000); + this.dValidate = debounce(this.validate, 500); } UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.qrCodeData) { if (nextProps.qrCodeData.type === 'address') { + const { ensResolution } = this.props; const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase(); - const currentAddress = this.state.ensAddress || this.state.ethAddress; + const currentAddress = ensResolution || this.state.ethAddress; if (currentAddress.toLowerCase() !== scannedAddress) { - this.setState({ ethAddress: scannedAddress, ensAddress: '' }); + this.setState({ input: scannedAddress }); + this.validate(scannedAddress); // Clean up QR code data after handling this.props.qrCodeDetected(null); } @@ -62,43 +67,48 @@ export default class AddContact extends PureComponent { if (valid || validEnsAddress || address === '') { this.setState({ error: '', ethAddress: address }); } else { - this.setState({ error: 'Invalid Address' }); + this.setState({ error: INVALID_RECIPIENT_ADDRESS_ERROR }); } }; + onChange = (input) => { + this.setState({ input }); + this.dValidate(input); + }; + renderInput() { return ( { this.props.scanQrCode(); }} - onChange={this.dValidate} - onPaste={(text) => this.setState({ ethAddress: text })} - onReset={() => this.setState({ ethAddress: '', ensAddress: '' })} - updateEnsResolution={(address) => { - this.setState({ ensAddress: address, error: '', ensError: '' }); + onChange={this.onChange} + onPaste={(text) => { + this.setState({ input: text }); + this.validate(text); }} - updateEnsResolutionError={(message) => - this.setState({ ensError: message }) - } - value={this.state.ethAddress || ''} + onReset={() => { + this.props.resetResolution(); + this.setState({ ethAddress: '', input: '' }); + }} + userInput={this.state.input} /> ); } render() { const { t } = this.context; - const { history, addToAddressBook } = this.props; + const { history, addToAddressBook, ensError, ensResolution } = this.props; - const errorToRender = this.state.ensError || this.state.error; + const errorToRender = ensError || this.state.error; return (
- {this.state.ensAddress && ( + {ensResolution && (
- +
- {this.state.ensAddress} + {ensResolution}
)} @@ -124,7 +134,7 @@ export default class AddContact extends PureComponent { {this.renderInput()} {errorToRender && (
- {errorToRender} + {t(errorToRender)}
)}
@@ -134,7 +144,7 @@ export default class AddContact extends PureComponent { disabled={Boolean(this.state.error)} onSubmit={async () => { await addToAddressBook( - this.state.ensAddress || this.state.ethAddress, + ensResolution || this.state.ethAddress, this.state.newName, ); history.push(CONTACT_LIST_ROUTE); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 8d3c63c5f..49f4deb70 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js @@ -6,12 +6,19 @@ import { showQrScanner, qrCodeDetected, } from '../../../../store/actions'; -import { getQrCodeData } from '../../../../selectors'; +import { getQrCodeData } from '../../../../ducks/app/app'; +import { + getEnsError, + getEnsResolution, + resetResolution, +} from '../../../../ducks/ens'; import AddContact from './add-contact.component'; const mapStateToProps = (state) => { return { qrCodeData: getQrCodeData(state), + ensError: getEnsError(state), + ensResolution: getEnsResolution(state), }; }; @@ -21,6 +28,7 @@ const mapDispatchToProps = (dispatch) => { dispatch(addToAddressBook(recipient, nickname)), scanQrCode: () => dispatch(showQrScanner()), qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), + resetResolution: () => dispatch(resetResolution()), }; }; diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index cd0770c99..70982d1b1 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -218,12 +218,8 @@ export const transactionFeeSelector = function (state, txData) { const conversionRate = conversionRateSelector(state); const nativeCurrency = getNativeCurrency(state); - 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 }, + const { + txParams: { value = '0x0', gas: gasLimit = '0x0', gasPrice = '0x0' } = {}, } = txData; const fiatTransactionAmount = getValueFromWeiHex({ diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index 4a3cbdf1e..f32b67a7a 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -9,14 +9,10 @@ import { formatETHFee } from '../helpers/utils/formatters'; import { calcGasTotal } from '../pages/send/send.utils'; import { GAS_ESTIMATE_TYPES } from '../helpers/constants/common'; +import { getGasPrice } from '../ducks/send'; import { BASIC_ESTIMATE_STATES, GAS_SOURCE } from '../ducks/gas/gas.duck'; import { GAS_LIMITS } from '../../shared/constants/gas'; -import { - getCurrentCurrency, - getIsMainnet, - getPreferences, - getGasPrice, -} from '.'; +import { getCurrentCurrency, getIsMainnet, getPreferences } from '.'; const NUMBER_OF_DECIMALS_SM_BTNS = 5; @@ -322,7 +318,7 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { const isMainnet = getIsMainnet(state); const showFiat = isMainnet || Boolean(showFiatInTestnets); const gasLimit = - state.send.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; + state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); const { diff --git a/ui/selectors/custom-gas.test.js b/ui/selectors/custom-gas.test.js index 91344b96b..fb383248d 100644 --- a/ui/selectors/custom-gas.test.js +++ b/ui/selectors/custom-gas.test.js @@ -112,7 +112,9 @@ describe('custom-gas selectors', () => { it('should return false gas.basicEstimates.price 0x28bed01600 (175) (checkSend=true)', () => { const mockState = { send: { - gasPrice: '0x28bed0160', + gas: { + gasPrice: '0x28bed0160', + }, }, gas: { customData: { price: null }, @@ -124,7 +126,9 @@ describe('custom-gas selectors', () => { it('should return true gas.basicEstimates.price 0x30e4f9b400 (210) (checkSend=true)', () => { const mockState = { send: { - gasPrice: '0x30e4f9b400', + gas: { + gasPrice: '0x30e4f9b400', + }, }, gas: { customData: { price: null }, @@ -226,7 +230,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -277,7 +283,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -328,7 +336,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -373,7 +383,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -434,7 +446,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -479,7 +493,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -530,7 +546,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -581,7 +599,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -626,7 +646,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { diff --git a/ui/selectors/index.js b/ui/selectors/index.js index b82c59c05..3f4ff3b0e 100644 --- a/ui/selectors/index.js +++ b/ui/selectors/index.js @@ -3,5 +3,4 @@ export * from './custom-gas'; export * from './first-time-flow'; export * from './permissions'; export * from './selectors'; -export * from './send'; export * from './transactions'; diff --git a/ui/selectors/send-selectors-test-data.js b/ui/selectors/send-selectors-test-data.js deleted file mode 100644 index b2663aadb..000000000 --- a/ui/selectors/send-selectors-test-data.js +++ /dev/null @@ -1,214 +0,0 @@ -import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; - -const state = { - metamask: { - isInitialized: true, - isUnlocked: true, - featureFlags: { sendHexData: true }, - identities: { - '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - name: 'Send Account 1', - }, - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': { - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - name: 'Send Account 2', - }, - '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': { - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - name: 'Send Account 3', - }, - '0xd85a4b6a394794842887b8284293d69163007bbb': { - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }, - }, - cachedBalances: {}, - currentBlockGasLimit: '0x4c1878', - currentCurrency: 'USD', - conversionRate: 1200.88200327, - conversionDate: 1489013762, - nativeCurrency: 'ETH', - frequentRpcList: [], - network: '3', - provider: { - type: 'testnet', - chainId: '0x3', - }, - accounts: { - '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { - code: '0x', - balance: '0x47c9d71831c76efe', - nonce: '0x1b', - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - }, - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': { - code: '0x', - balance: '0x37452b1315889f80', - nonce: '0xa', - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - }, - '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': { - code: '0x', - balance: '0x30c9d71831c76efe', - nonce: '0x1c', - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - }, - '0xd85a4b6a394794842887b8284293d69163007bbb': { - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - }, - addressBook: { - '0x3': { - '0x06195827297c7a80a443b6894d3bdb8824b43896': { - address: '0x06195827297c7a80a443b6894d3bdb8824b43896', - name: 'Address Book Account 1', - chainId: '0x3', - }, - }, - }, - tokens: [ - { - address: '0x1a195821297c7a80a433b6894d3bdb8824b43896', - decimals: 18, - symbol: 'ABC', - }, - { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - { - address: '0xa42084c8d1d9a2198631988579bb36b48433a72b', - decimals: 18, - symbol: 'GHI', - }, - ], - transactions: {}, - currentNetworkTxList: [ - { - id: 'mockTokenTx1', - txParams: { - to: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1700000000000, - }, - { - id: 'mockTokenTx2', - txParams: { - to: '0xafaketokenaddress', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1600000000000, - }, - { - id: 'mockTokenTx3', - txParams: { - to: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1500000000000, - }, - { - id: 'mockEthTx1', - txParams: { - to: '0xd85a4b6a394794842887b8284293d69163007bbb', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1400000000000, - }, - ], - unapprovedMsgs: { - '0xabc': { id: 'unapprovedMessage1', time: 1650000000000 }, - '0xdef': { id: 'unapprovedMessage2', time: 1550000000000 }, - '0xghi': { id: 'unapprovedMessage3', time: 1450000000000 }, - }, - unapprovedMsgCount: 0, - unapprovedPersonalMsgs: {}, - unapprovedPersonalMsgCount: 0, - unapprovedDecryptMsgs: {}, - unapprovedDecryptMsgCount: 0, - unapprovedEncryptionPublicKeyMsgs: {}, - unapprovedEncryptionPublicKeyMsgCount: 0, - keyringTypes: ['Simple Key Pair', 'HD Key Tree'], - keyrings: [ - { - type: 'HD Key Tree', - accounts: [ - 'fdea65c8e26263f6d9a1b5de9555d2931a33b825', - 'c5b8dbac4c1d3f152cdeb400e2313f309c410acb', - '2f8d4a878cfa04a6e60d46362f5644deab66572d', - ], - }, - { - type: 'Simple Key Pair', - accounts: ['0xd85a4b6a394794842887b8284293d69163007bbb'], - }, - ], - selectedAddress: '0xd85a4b6a394794842887b8284293d69163007bbb', - unapprovedTxs: { - 4768706228115573: { - id: 4768706228115573, - time: 1487363153561, - status: TRANSACTION_STATUSES.UNAPPROVED, - gasMultiplier: 1, - metamaskNetworkId: '3', - txParams: { - from: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - to: '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761', - value: '0xde0b6b3a7640000', - metamaskId: 4768706228115573, - metamaskNetworkId: '3', - gas: '0x5209', - }, - txFee: '17e0186e60800', - txValue: 'de0b6b3a7640000', - maxCost: 'de234b52e4a0800', - gasPrice: '4a817c800', - }, - }, - currentLocale: 'en', - }, - appState: { - menuOpen: false, - currentView: { - name: 'accountDetail', - detailView: null, - context: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - }, - accountDetail: { - subview: 'transactions', - }, - modal: { - modalState: {}, - previousModalState: {}, - }, - isLoading: false, - warning: null, - scrollToBottom: false, - forgottenPassword: null, - }, - identities: {}, - send: { - fromDropdownOpen: false, - gasLimit: '0xFFFF', - gasPrice: '0xaa', - gasTotal: '0xb451dc41b578', - tokenBalance: 3434, - from: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - to: '0x987fedabc', - amount: '0x080', - memo: '', - errors: { - someError: null, - }, - maxModeOn: false, - editingTransactionId: 97531, - }, -}; - -export default state; diff --git a/ui/selectors/send.js b/ui/selectors/send.js deleted file mode 100644 index 3d0b11d83..000000000 --- a/ui/selectors/send.js +++ /dev/null @@ -1,135 +0,0 @@ -import abi from 'human-standard-token-abi'; -import { calcGasTotal } from '../pages/send/send.utils'; -import { - getSelectedAccount, - getTargetAccount, - getAveragePriceEstimateInHexWEI, -} from '.'; - -export function getGasLimit(state) { - return state.send.gasLimit || '0'; -} - -export function getGasPrice(state) { - return state.send.gasPrice || getAveragePriceEstimateInHexWEI(state); -} - -export function getGasTotal(state) { - return calcGasTotal(getGasLimit(state), getGasPrice(state)); -} - -export function getPrimaryCurrency(state) { - const sendToken = getSendToken(state); - return sendToken?.symbol; -} - -export function getSendToken(state) { - return state.send.token; -} - -export function getSendTokenAddress(state) { - return getSendToken(state)?.address; -} - -export function getSendTokenContract(state) { - const sendTokenAddress = getSendTokenAddress(state); - return sendTokenAddress - ? global.eth.contract(abi).at(sendTokenAddress) - : null; -} - -export function getSendAmount(state) { - return state.send.amount; -} - -export function getSendHexData(state) { - return state.send.data; -} - -export function getSendEditingTransactionId(state) { - return state.send.editingTransactionId; -} - -export function getSendErrors(state) { - return state.send.errors; -} - -export function sendAmountIsInError(state) { - return Boolean(state.send.errors.amount); -} - -export function getSendFrom(state) { - return state.send.from; -} - -export function getSendFromBalance(state) { - const fromAccount = getSendFromObject(state); - return fromAccount.balance; -} - -export function getSendFromObject(state) { - const fromAddress = getSendFrom(state); - return fromAddress - ? getTargetAccount(state, fromAddress) - : getSelectedAccount(state); -} - -export function getSendMaxModeState(state) { - return state.send.maxModeOn; -} - -export function getSendTo(state) { - return state.send.to; -} - -export function getSendToNickname(state) { - return state.send.toNickname; -} - -export function getTokenBalance(state) { - return state.send.tokenBalance; -} - -export function getSendEnsResolution(state) { - return state.send.ensResolution; -} - -export function getSendEnsResolutionError(state) { - return state.send.ensResolutionError; -} - -export function getQrCodeData(state) { - return state.appState.qrCodeData; -} - -export function getGasLoadingError(state) { - return state.send.errors.gasLoading; -} - -export function gasFeeIsInError(state) { - return Boolean(state.send.errors.gasFee); -} - -export function getGasButtonGroupShown(state) { - return state.send.gasButtonGroupShown; -} - -export function getTitleKey(state) { - const isEditing = Boolean(getSendEditingTransactionId(state)); - const isToken = Boolean(getSendToken(state)); - - if (!getSendTo(state)) { - return 'addRecipient'; - } - - if (isEditing) { - return 'edit'; - } else if (isToken) { - return 'sendTokens'; - } - return 'send'; -} - -export function isSendFormInError(state) { - return Object.values(getSendErrors(state)).some((n) => n); -} diff --git a/ui/selectors/send.test.js b/ui/selectors/send.test.js deleted file mode 100644 index aadbc28e5..000000000 --- a/ui/selectors/send.test.js +++ /dev/null @@ -1,417 +0,0 @@ -import sinon from 'sinon'; -import { - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - sendAmountIsInError, - getSendEditingTransactionId, - getSendErrors, - getSendFrom, - getSendFromBalance, - getSendFromObject, - getSendMaxModeState, - getSendTo, - getTokenBalance, - gasFeeIsInError, - getGasLoadingError, - getGasButtonGroupShown, - getTitleKey, - isSendFormInError, -} from './send'; -import mockState from './send-selectors-test-data'; -import { - accountsWithSendEtherInfoSelector, - getCurrentAccountWithSendEtherInfo, -} from '.'; - -describe('send selectors', () => { - const tempGlobalEth = { ...global.eth }; - beforeEach(() => { - global.eth = { - contract: sinon.stub().returns({ - at: (address) => `mockAt:${address}`, - }), - }; - }); - - afterEach(() => { - global.eth = tempGlobalEth; - }); - - describe('accountsWithSendEtherInfoSelector()', () => { - it('should return an array of account objects with name info from identities', () => { - expect(accountsWithSendEtherInfoSelector(mockState)).toStrictEqual([ - { - code: '0x', - balance: '0x47c9d71831c76efe', - nonce: '0x1b', - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - name: 'Send Account 1', - }, - { - code: '0x', - balance: '0x37452b1315889f80', - nonce: '0xa', - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - name: 'Send Account 2', - }, - { - code: '0x', - balance: '0x30c9d71831c76efe', - nonce: '0x1c', - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - name: 'Send Account 3', - }, - { - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }, - ]); - }); - }); - - describe('getCurrentAccountWithSendEtherInfo()', () => { - it('should return the currently selected account with identity info', () => { - expect(getCurrentAccountWithSendEtherInfo(mockState)).toStrictEqual({ - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }); - }); - }); - - describe('getGasLimit()', () => { - it('should return the send.gasLimit', () => { - expect(getGasLimit(mockState)).toStrictEqual('0xFFFF'); - }); - }); - - describe('getGasPrice()', () => { - it('should return the send.gasPrice', () => { - expect(getGasPrice(mockState)).toStrictEqual('0xaa'); - }); - }); - - describe('getGasTotal()', () => { - it('should return the send.gasTotal', () => { - expect(getGasTotal(mockState)).toStrictEqual('a9ff56'); - }); - }); - - describe('getPrimaryCurrency()', () => { - it('should return the symbol of the send token', () => { - expect( - getPrimaryCurrency({ - send: { token: { symbol: 'DEF' } }, - }), - ).toStrictEqual('DEF'); - }); - }); - - describe('getSendToken()', () => { - it('should return the current send token if set', () => { - expect( - getSendToken({ - send: { - token: { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - }, - }), - ).toStrictEqual({ - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }); - }); - }); - - describe('getSendTokenContract()', () => { - it('should return the contract at the send token address', () => { - expect( - getSendTokenContract({ - send: { - token: { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - }, - }), - ).toStrictEqual('mockAt:0x8d6b81208414189a58339873ab429b6c47ab92d3'); - }); - - it('should return null if send token is not set', () => { - expect(getSendTokenContract({ ...mockState, send: {} })).toBeNull(); - }); - }); - - describe('getSendAmount()', () => { - it('should return the send.amount', () => { - expect(getSendAmount(mockState)).toStrictEqual('0x080'); - }); - }); - - describe('getSendEditingTransactionId()', () => { - it('should return the send.editingTransactionId', () => { - expect(getSendEditingTransactionId(mockState)).toStrictEqual(97531); - }); - }); - - describe('getSendErrors()', () => { - it('should return the send.errors', () => { - expect(getSendErrors(mockState)).toStrictEqual({ someError: null }); - }); - }); - - describe('getSendFrom()', () => { - it('should return the send.from', () => { - expect(getSendFrom(mockState)).toStrictEqual( - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - ); - }); - }); - - describe('getSendFromBalance()', () => { - it('should get the send.from balance if it exists', () => { - expect(getSendFromBalance(mockState)).toStrictEqual('0x37452b1315889f80'); - }); - - it('should get the selected account balance if the send.from does not exist', () => { - const editedMockState = { - ...mockState, - send: { - ...mockState.send, - from: null, - }, - }; - expect(getSendFromBalance(editedMockState)).toStrictEqual('0x0'); - }); - }); - - describe('getSendFromObject()', () => { - it('should return send.from if it exists', () => { - expect(getSendFromObject(mockState)).toStrictEqual({ - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - balance: '0x37452b1315889f80', - code: '0x', - nonce: '0xa', - }); - }); - - it('should return the current account if send.from does not exist', () => { - const editedMockState = { - ...mockState, - send: { - from: null, - }, - }; - expect(getSendFromObject(editedMockState)).toStrictEqual({ - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - }); - }); - }); - - describe('getSendMaxModeState()', () => { - it('should return send.maxModeOn', () => { - expect(getSendMaxModeState(mockState)).toStrictEqual(false); - }); - }); - - describe('getSendTo()', () => { - it('should return send.to', () => { - expect(getSendTo(mockState)).toStrictEqual('0x987fedabc'); - }); - }); - - describe('getTokenBalance()', () => { - it('should', () => { - expect(getTokenBalance(mockState)).toStrictEqual(3434); - }); - }); - - describe('send-amount-row selectors', () => { - describe('sendAmountIsInError()', () => { - it('should return true if send.errors.amount is truthy', () => { - const state = { - send: { - errors: { - amount: 'abc', - }, - }, - }; - - expect(sendAmountIsInError(state)).toStrictEqual(true); - }); - - it('should return false if send.errors.amount is falsy', () => { - const state = { - send: { - errors: { - amount: null, - }, - }, - }; - - expect(sendAmountIsInError(state)).toStrictEqual(false); - }); - }); - }); - - describe('send-gas-row selectors', () => { - describe('getGasLoadingError()', () => { - it('should return send.errors.gasLoading', () => { - const state = { - send: { - errors: { - gasLoading: 'abc', - }, - }, - }; - - expect(getGasLoadingError(state)).toStrictEqual('abc'); - }); - }); - - describe('gasFeeIsInError()', () => { - it('should return true if send.errors.gasFee is truthy', () => { - const state = { - send: { - errors: { - gasFee: 'def', - }, - }, - }; - - expect(gasFeeIsInError(state)).toStrictEqual(true); - }); - - it('should return false send.errors.gasFee is falsely', () => { - const state = { - send: { - errors: { - gasFee: null, - }, - }, - }; - - expect(gasFeeIsInError(state)).toStrictEqual(false); - }); - }); - - describe('getGasButtonGroupShown()', () => { - it('should return send.gasButtonGroupShown', () => { - const state = { - send: { - gasButtonGroupShown: 'foobar', - }, - }; - - expect(getGasButtonGroupShown(state)).toStrictEqual('foobar'); - }); - }); - }); - - describe('send-header selectors', () => { - const getMetamaskSendMockState = (send) => { - return { - send: { ...send }, - }; - }; - - describe('getTitleKey()', () => { - it('should return the correct key when "to" is empty', () => { - expect(getTitleKey(getMetamaskSendMockState({}))).toStrictEqual( - 'addRecipient', - ); - }); - - it('should return the correct key when getSendEditingTransactionId is truthy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: true, - token: {}, - }), - ), - ).toStrictEqual('edit'); - }); - - it('should return the correct key when getSendEditingTransactionId is falsy and getSendToken is truthy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: false, - token: {}, - }), - ), - ).toStrictEqual('sendTokens'); - }); - - it('should return the correct key when getSendEditingTransactionId is falsy and getSendToken is falsy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: false, - token: null, - }), - ), - ).toStrictEqual('send'); - }); - }); - }); - - describe('send-footer selectors', () => { - const getSendMockState = (send) => { - return { - send: { ...send }, - }; - }; - - describe('isSendFormInError()', () => { - it('should return true if any of the values of the object returned by getSendErrors are truthy', () => { - expect( - isSendFormInError( - getSendMockState({ - errors: [true], - }), - ), - ).toStrictEqual(true); - }); - - it('should return false if all of the values of the object returned by getSendErrors are falsy', () => { - expect( - isSendFormInError( - getSendMockState({ - errors: [], - }), - ), - ).toStrictEqual(false); - expect( - isSendFormInError( - getSendMockState({ - errors: [false], - }), - ), - ).toStrictEqual(false); - }); - }); - }); -}); diff --git a/ui/store/actionConstants.js b/ui/store/actionConstants.js index ed0bee9af..c96088f2f 100644 --- a/ui/store/actionConstants.js +++ b/ui/store/actionConstants.js @@ -15,6 +15,10 @@ export const NETWORK_DROPDOWN_CLOSE = 'UI_NETWORK_DROPDOWN_CLOSE'; // remote state export const UPDATE_METAMASK_STATE = 'UPDATE_METAMASK_STATE'; export const SELECTED_ADDRESS_CHANGED = 'SELECTED_ADDRESS_CHANGED'; +export const SELECTED_ACCOUNT_CHANGED = 'SELECTED_ACCOUNT_CHANGED'; +export const ACCOUNT_CHANGED = 'ACCOUNT_CHANGED'; +export const CHAIN_CHANGED = 'CHAIN_CHANGED'; +export const ADDRESS_BOOK_UPDATED = 'ADDRESS_BOOK_UPDATED'; export const FORGOT_PASSWORD = 'FORGOT_PASSWORD'; export const CLOSE_WELCOME_SCREEN = 'CLOSE_WELCOME_SCREEN'; // unlock screen diff --git a/ui/store/actionConstants.test.js b/ui/store/actionConstants.test.js index 7cfef827f..eba555a51 100644 --- a/ui/store/actionConstants.test.js +++ b/ui/store/actionConstants.test.js @@ -63,9 +63,7 @@ describe('Redux actionConstants', () => { describe('SHOW_ACCOUNT_DETAIL', () => { it('updates metamask state', () => { const initialState = { - metamask: { - selectedAddress: 'foo', - }, + metamask: {}, }; freeze(initialState); @@ -76,9 +74,8 @@ describe('Redux actionConstants', () => { freeze(action); const resultingState = reducers(initialState, action); - expect(resultingState.metamask.selectedAddress).toStrictEqual( - action.value, - ); + expect(resultingState.metamask.isUnlocked).toStrictEqual(true); + expect(resultingState.metamask.isInitialized).toStrictEqual(true); }); }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 9f680fabb..8945de10b 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -1,7 +1,6 @@ -import abi from 'human-standard-token-abi'; import pify from 'pify'; import log from 'loglevel'; -import { capitalize } from 'lodash'; +import { capitalize, isEqual } from 'lodash'; import getBuyEthUrl from '../../app/scripts/lib/buy-eth-url'; import { fetchLocale, @@ -15,14 +14,15 @@ import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util'; import txHelper from '../helpers/utils/tx-helper'; import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util'; import { + getMetaMaskAccounts, getPermittedAccountsForCurrentTab, getSelectedAddress, } from '../selectors'; +import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; import { LISTED_CONTRACT_ADDRESSES } from '../../shared/constants/tokens'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; -import { clearSend } from '../ducks/send/send.duck'; import * as actionConstants from './actionConstants'; let background = null; @@ -621,19 +621,6 @@ export function signTypedMsg(msgData) { }; } -export function signTx(txData) { - return async (dispatch) => { - dispatch(showLoadingIndication()); - global.ethQuery.sendTransaction(txData, (err) => { - if (err) { - dispatch(displayWarning(err.message)); - } - }); - dispatch(hideLoadingIndication()); - dispatch(showConfTxPage()); - }; -} - export function updateCustomNonce(value) { return { type: actionConstants.UPDATE_CUSTOM_NONCE, @@ -641,22 +628,6 @@ export function updateCustomNonce(value) { }; } -export function signTokenTx(tokenAddress, toAddress, amount, txData) { - return async (dispatch) => { - dispatch(showLoadingIndication()); - - try { - const token = global.eth.contract(abi).at(tokenAddress); - token.transfer(toAddress, addHexPrefix(amount), txData); - dispatch(showConfTxPage()); - dispatch(hideLoadingIndication()); - } catch (error) { - dispatch(hideLoadingIndication()); - dispatch(displayWarning(error.message)); - } - }; -} - const updateMetamaskStateFromBackground = () => { log.debug(`background.getState`); @@ -721,7 +692,7 @@ export function updateAndApproveTx(txData, dontShowLoadingIndicator) { return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, (err) => { dispatch(updateTransactionParams(txData.id, txData.txParams)); - dispatch(clearSend()); + dispatch(resetSendState()); if (err) { dispatch(txError(err)); @@ -737,7 +708,7 @@ export function updateAndApproveTx(txData, dontShowLoadingIndicator) { .then(() => updateMetamaskStateFromBackground()) .then((newState) => dispatch(updateMetamaskState(newState))) .then(() => { - dispatch(clearSend()); + dispatch(resetSendState()); dispatch(completedTx(txData.id)); dispatch(hideLoadingIndication()); dispatch(updateCustomNonce('')); @@ -907,7 +878,7 @@ export function cancelTx(txData, _showLoadingIndication = true) { .then(() => updateMetamaskStateFromBackground()) .then((newState) => dispatch(updateMetamaskState(newState))) .then(() => { - dispatch(clearSend()); + dispatch(resetSendState()); dispatch(completedTx(txData.id)); dispatch(hideLoadingIndication()); dispatch(closeCurrentNotificationWindow()); @@ -950,7 +921,7 @@ export function cancelTxs(txDataList) { const newState = await updateMetamaskStateFromBackground(); dispatch(updateMetamaskState(newState)); - dispatch(clearSend()); + dispatch(resetSendState()); txIds.forEach((id) => { dispatch(completedTx(id)); @@ -1038,19 +1009,59 @@ export function updateMetamaskState(newState) { return (dispatch, getState) => { const { metamask: currentState } = getState(); - const { currentLocale, selectedAddress } = currentState; + const { currentLocale, selectedAddress, provider } = currentState; const { currentLocale: newLocale, selectedAddress: newSelectedAddress, + provider: newProvider, } = newState; if (currentLocale && newLocale && currentLocale !== newLocale) { dispatch(updateCurrentLocale(newLocale)); } + if (selectedAddress !== newSelectedAddress) { dispatch({ type: actionConstants.SELECTED_ADDRESS_CHANGED }); } + const newAddressBook = newState.addressBook?.[newProvider?.chainId] ?? {}; + const oldAddressBook = currentState.addressBook?.[provider?.chainId] ?? {}; + const newAccounts = getMetaMaskAccounts({ metamask: newState }); + const oldAccounts = getMetaMaskAccounts({ metamask: currentState }); + const newSelectedAccount = newAccounts[newSelectedAddress]; + const oldSelectedAccount = newAccounts[selectedAddress]; + // dispatch an ACCOUNT_CHANGED for any account whose balance or other + // properties changed in this update + Object.entries(oldAccounts).forEach(([address, oldAccount]) => { + if (!isEqual(oldAccount, newAccounts[address])) { + dispatch({ + type: actionConstants.ACCOUNT_CHANGED, + payload: { account: newAccounts[address] }, + }); + } + }); + // Also emit an event for the selected account changing, either due to a + // property update or if the entire account changes. + if (isEqual(oldSelectedAccount, newSelectedAccount) === false) { + dispatch({ + type: actionConstants.SELECTED_ACCOUNT_CHANGED, + payload: { account: newSelectedAccount }, + }); + } + // We need to keep track of changing address book entries + if (isEqual(oldAddressBook, newAddressBook) === false) { + dispatch({ + type: actionConstants.ADDRESS_BOOK_UPDATED, + payload: { addressBook: newAddressBook }, + }); + } + + if (provider.chainId !== newProvider.chainId) { + dispatch({ + type: actionConstants.CHAIN_CHANGED, + payload: newProvider.chainId, + }); + } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE, value: newState, @@ -1141,6 +1152,7 @@ export function showAccountDetail(address) { try { await _setSelectedAddress(dispatch, address); + await forceUpdateMetamaskState(dispatch); } catch (error) { dispatch(displayWarning(error.message)); return; @@ -1234,21 +1246,6 @@ 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()); @@ -1672,9 +1669,16 @@ export function hideAlert() { * or null (used to clear the previous value) */ export function qrCodeDetected(qrCodeData) { - return { - type: actionConstants.QR_CODE_DETECTED, - value: qrCodeData, + return async (dispatch) => { + await dispatch({ + type: actionConstants.QR_CODE_DETECTED, + value: qrCodeData, + }); + + // If on the send page, the send slice will listen for the QR_CODE_DETECTED + // action and update its state. Address changes need to recompute gasLimit + // so we fire this method so that the send page gasLimit can be recomputed + dispatch(computeEstimatedGasLimit()); }; } @@ -2698,6 +2702,16 @@ export function estimateGas(params) { return promisifiedBackground.estimateGas(params); } +export async function updateTokenType(tokenAddress) { + let token = {}; + try { + token = await promisifiedBackground.updateTokenType(tokenAddress); + } catch (error) { + log.error(error); + } + return token; +} + // MetaMetrics /** * @typedef {import('../../shared/constants/metametrics').MetaMetricsEventPayload} MetaMetricsEventPayload diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index d28defb8c..847141fd5 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1,7 +1,6 @@ import sinon from 'sinon'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import EthQuery from 'eth-query'; import enLocale from '../../app/_locales/en/messages.json'; import MetaMaskController from '../../app/scripts/metamask-controller'; import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; @@ -14,10 +13,22 @@ const defaultState = { currentLocale: 'test', selectedAddress: '0xFirstAddress', provider: { chainId: '0x1' }, + accounts: { + '0xFirstAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xFirstAddress': '0x0', + }, + }, }, }; const mockStore = (state = defaultState) => configureStore(middleware)(state); +const baseMockState = defaultState.metamask; + describe('Actions', () => { let background; @@ -25,12 +36,7 @@ describe('Actions', () => { beforeEach(async () => { background = sinon.createStubInstance(MetaMaskController, { - getState: sinon.stub().callsFake((cb) => - cb(null, { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }), - ), + getState: sinon.stub().callsFake((cb) => cb(null, baseMockState)), }); }); @@ -58,10 +64,7 @@ describe('Actions', () => { { type: 'UNLOCK_SUCCEEDED', value: undefined }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, { type: 'HIDE_LOADING_INDICATION' }, ]; @@ -111,7 +114,7 @@ describe('Actions', () => { { type: 'UNLOCK_SUCCEEDED', value: undefined }, { type: 'UPDATE_METAMASK_STATE', - value: { currentLocale: 'test', selectedAddress: '0xFirstAddress' }, + value: baseMockState, }, { type: 'DISPLAY_WARNING', value: 'error' }, { type: 'UNLOCK_FAILED', value: 'error' }, @@ -159,10 +162,7 @@ describe('Actions', () => { { type: 'FORGOT_PASSWORD', value: false }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, { type: 'SHOW_ACCOUNTS_PAGE' }, { type: 'HIDE_LOADING_INDICATION' }, @@ -254,6 +254,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xAnotherAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xAnotherAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xAnotherAddress': '0x0', + }, + }, }), ); @@ -264,6 +277,8 @@ describe('Actions', () => { const expectedActions = [ 'SHOW_LOADING_INDICATION', 'SELECTED_ADDRESS_CHANGED', + 'ACCOUNT_CHANGED', + 'SELECTED_ACCOUNT_CHANGED', 'UPDATE_METAMASK_STATE', 'HIDE_LOADING_INDICATION', 'SHOW_ACCOUNTS_PAGE', @@ -400,7 +415,9 @@ describe('Actions', () => { describe('#addNewAccount', () => { it('adds a new account', async () => { - const store = mockStore({ metamask: { identities: {} } }); + const store = mockStore({ + metamask: { identities: {}, ...defaultState.metamask }, + }); const addNewAccount = background.addNewAccount.callsFake((cb) => cb(null, { @@ -660,7 +677,7 @@ describe('Actions', () => { const store = mockStore(); const signMessage = background.signMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -705,7 +722,7 @@ describe('Actions', () => { const store = mockStore(); const signPersonalMessage = background.signPersonalMessage.callsFake( - (_, cb) => cb(null, defaultState), + (_, cb) => cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -786,7 +803,7 @@ describe('Actions', () => { const store = mockStore(); const signTypedMsg = background.signTypedMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -816,58 +833,6 @@ describe('Actions', () => { }); }); - describe('#signTx', () => { - beforeEach(() => { - global.ethQuery = sinon.createStubInstance(EthQuery); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('calls sendTransaction in global ethQuery', async () => { - const store = mockStore(); - - actions._setBackgroundConnection(background); - - await store.dispatch(actions.signTx()); - - expect(global.ethQuery.sendTransaction.callCount).toStrictEqual(1); - }); - - it('errors in when sendTransaction throws', async () => { - const store = mockStore(); - const expectedActions = [ - { type: 'SHOW_LOADING_INDICATION', value: undefined }, - { type: 'DISPLAY_WARNING', value: 'error' }, - { type: 'HIDE_LOADING_INDICATION' }, - { type: 'SHOW_CONF_TX_PAGE', id: undefined }, - ]; - - global.ethQuery.sendTransaction.callsFake((_, callback) => { - callback(new Error('error')); - }); - - actions._setBackgroundConnection(background); - - await store.dispatch(actions.signTx()); - expect(store.getActions()).toStrictEqual(expectedActions); - }); - }); - - describe('#signTokenTx', () => { - it('calls eth.contract', async () => { - global.eth = { - contract: sinon.stub(), - }; - - const store = mockStore(); - - await store.dispatch(actions.signTokenTx()); - expect(global.eth.contract.callCount).toStrictEqual(1); - }); - }); - describe('#updateTransaction', () => { const txParams = { from: '0x1', @@ -895,12 +860,7 @@ describe('Actions', () => { background.getApi.returns({ updateTransaction: updateTransactionStub, - getState: sinon.stub().callsFake((cb) => - cb(null, { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }), - ), + getState: sinon.stub().callsFake((cb) => cb(null, baseMockState)), }); actions._setBackgroundConnection(background.getApi()); @@ -1699,10 +1659,7 @@ describe('Actions', () => { { type: 'FORGOT_PASSWORD', value: true }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, ]; @@ -1762,6 +1719,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xFirstAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xFirstAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xFirstAddress': '0x0', + }, + }, }), ), }); From 6d53b00dcdffff585f09c070bfa493f4a1e8a34f Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Wed, 30 Jun 2021 23:24:47 -0230 Subject: [PATCH 03/12] Ensure that eth_estimateGas is called to estimate gas limit for simple sends on custom networks (#11418) * Ensure that eth_estimateGas is called to estimate gas limit for simple sends on custom networks * getIsNonStandardEthChain returns false when in test * Add comment explaining gas limit buffer multipliers in estimateGasLimitForSend --- ui/ducks/send/send.js | 55 ++++++++++++++++++++++++++++---------- ui/ducks/send/send.test.js | 9 +++++++ ui/selectors/selectors.js | 4 +++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 3ed41512b..c8e926296 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -37,6 +37,7 @@ import { getIsMainnet, getSelectedAddress, getTargetAccount, + getIsNonStandardEthChain, } from '../../selectors'; import { displayWarning, @@ -174,8 +175,11 @@ async function estimateGasLimitForSend({ sendToken, to, data, + isNonStandardEthChain, ...options }) { + let isSimpleSendOnNonStandardNetwork = false; + // blockGasLimit may be a falsy, but defined, value when we receive it from // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. const blockGasLimit = options.blockGasLimit || MIN_GAS_LIMIT_HEX; @@ -211,8 +215,10 @@ async function estimateGasLimitForSend({ // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' const contractCodeIsEmpty = !contractCode || contractCode === '0x' || contractCode === '0x0'; - if (contractCodeIsEmpty) { + if (contractCodeIsEmpty && !isNonStandardEthChain) { return GAS_LIMITS.SIMPLE; + } else if (contractCodeIsEmpty && isNonStandardEthChain) { + isSimpleSendOnNonStandardNetwork = true; } } @@ -231,17 +237,33 @@ async function estimateGasLimitForSend({ } } - // If we do not yet have a gasLimit, we must call into our background - // process to get an estimate for gasLimit based on known parameters. - - paramsForGasEstimate.gas = addHexPrefix( - multiplyCurrencies(blockGasLimit, 0.95, { - multiplicandBase: 16, - multiplierBase: 10, - roundDown: '0', - toNumericBase: 'hex', - }), - ); + if (!isSimpleSendOnNonStandardNetwork) { + // If we do not yet have a gasLimit, we must call into our background + // process to get an estimate for gasLimit based on known parameters. + + paramsForGasEstimate.gas = addHexPrefix( + multiplyCurrencies(blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }), + ); + } + + // The buffer multipler reduces transaction failures by ensuring that the + // estimated gas is always sufficient. Without the multiplier, estimates + // for contract interactions can become inaccurate over time. This is because + // gas estimation is non-deterministic. The gas required for the exact same + // transaction call can change based on state of a contract or changes in the + // contracts environment (blockchain data or contracts it interacts with). + // Applying the 1.5 buffer has proven to be a useful guard against this non- + // deterministic behaviour. + // + // Gas estimation of simple sends should, however, be deterministic. As such + // no buffer is needed in those cases. + const bufferMultiplier = isSimpleSendOnNonStandardNetwork ? 1 : 1.5; + try { // call into the background process that will simulate transaction // execution on the node and return an estimate of gasLimit @@ -249,7 +271,7 @@ async function estimateGasLimitForSend({ const estimateWithBuffer = addGasBuffer( estimatedGasLimit, blockGasLimit, - 1.5, + bufferMultiplier, ); return addHexPrefix(estimateWithBuffer); } catch (error) { @@ -303,7 +325,9 @@ export async function getERC20Balance(token, accountAddress) { export const computeEstimatedGasLimit = createAsyncThunk( 'send/computeEstimatedGasLimit', async (_, thunkApi) => { - const { send, metamask } = thunkApi.getState(); + const state = thunkApi.getState(); + const { send, metamask } = state; + const isNonStandardEthChain = getIsNonStandardEthChain(state); if (send.stage !== SEND_STAGES.EDIT) { const gasLimit = await estimateGasLimitForSend({ gasPrice: send.gas.gasPrice, @@ -313,6 +337,7 @@ export const computeEstimatedGasLimit = createAsyncThunk( to: send.recipient.address?.toLowerCase(), value: send.amount.value, data: send.draftTransaction.userInputHexData, + isNonStandardEthChain, }); await thunkApi.dispatch(setCustomGasLimit(gasLimit)); return { @@ -337,6 +362,7 @@ export const initializeSendState = createAsyncThunk( 'send/initializeSendState', async (_, thunkApi) => { const state = thunkApi.getState(); + const isNonStandardEthChain = getIsNonStandardEthChain(state); const { send: { asset, stage, recipient, amount, draftTransaction }, metamask, @@ -383,6 +409,7 @@ export const initializeSendState = createAsyncThunk( to: recipient.address.toLowerCase(), value: amount.value, data: draftTransaction.userInputHexData, + isNonStandardEthChain, }); gasLimit = estimatedGasLimit || gasLimit; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index b4918d01d..57f84071f 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -1113,6 +1113,9 @@ describe('Send Slice', () => { metamask: { blockGasLimit: '', selectedAddress: '', + provider: { + chainId: '0x1', + }, }, ...defaultSendAmountState.send, send: { @@ -1160,6 +1163,9 @@ describe('Send Slice', () => { metamask: { blockGasLimit: '', selectedAddress: '', + provider: { + chainId: '0x1', + }, }, send: { account: { @@ -1372,6 +1378,9 @@ describe('Send Slice', () => { metamask: { blockGasLimit: '', selectedAddress: '', + provider: { + chainId: '0x1', + }, }, send: { account: { diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index a41005a7e..f6029ccb5 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -373,6 +373,10 @@ export function getIsTestnet(state) { return TEST_CHAINS.includes(chainId); } +export function getIsNonStandardEthChain(state) { + return !(getIsMainnet(state) || getIsTestnet(state) || process.env.IN_TEST); +} + export function getPreferences({ metamask }) { return metamask.preferences; } From 70b86acd451610e45b19f96ffe7543cc58f5cf1a Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 1 Jul 2021 19:18:30 -0230 Subject: [PATCH 04/12] Add list of custom networks with special gas limit buffers (#11435) --- shared/constants/network.js | 7 +++++++ ui/ducks/send/send.js | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/shared/constants/network.js b/shared/constants/network.js index 01891d57c..0101ab723 100644 --- a/shared/constants/network.js +++ b/shared/constants/network.js @@ -19,6 +19,8 @@ export const GOERLI_CHAIN_ID = '0x5'; export const KOVAN_CHAIN_ID = '0x2a'; export const LOCALHOST_CHAIN_ID = '0x539'; export const BSC_CHAIN_ID = '0x38'; +export const OPTIMISM_CHAIN_ID = '0xa'; +export const OPTIMISM_TESTNET_CHAIN_ID = '0x45'; /** * The largest possible chain ID we can handle. @@ -120,3 +122,8 @@ export const NATIVE_CURRENCY_TOKEN_IMAGE_MAP = { }; export const INFURA_BLOCKED_KEY = 'countryBlocked'; + +export const CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP = { + [OPTIMISM_CHAIN_ID]: 1, + [OPTIMISM_TESTNET_CHAIN_ID]: 1, +}; diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index c8e926296..037f56cdf 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -80,6 +80,7 @@ import { isBurnAddress, isValidHexAddress, } from '../../../shared/modules/hexstring-utils'; +import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; // typedefs /** @@ -176,6 +177,7 @@ async function estimateGasLimitForSend({ to, data, isNonStandardEthChain, + chainId, ...options }) { let isSimpleSendOnNonStandardNetwork = false; @@ -262,7 +264,12 @@ async function estimateGasLimitForSend({ // // Gas estimation of simple sends should, however, be deterministic. As such // no buffer is needed in those cases. - const bufferMultiplier = isSimpleSendOnNonStandardNetwork ? 1 : 1.5; + let bufferMultiplier = 1.5; + if (isSimpleSendOnNonStandardNetwork) { + bufferMultiplier = 1; + } else if (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]) { + bufferMultiplier = CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]; + } try { // call into the background process that will simulate transaction @@ -328,6 +335,7 @@ export const computeEstimatedGasLimit = createAsyncThunk( const state = thunkApi.getState(); const { send, metamask } = state; const isNonStandardEthChain = getIsNonStandardEthChain(state); + const chainId = getCurrentChainId(state); if (send.stage !== SEND_STAGES.EDIT) { const gasLimit = await estimateGasLimitForSend({ gasPrice: send.gas.gasPrice, @@ -338,6 +346,7 @@ export const computeEstimatedGasLimit = createAsyncThunk( value: send.amount.value, data: send.draftTransaction.userInputHexData, isNonStandardEthChain, + chainId, }); await thunkApi.dispatch(setCustomGasLimit(gasLimit)); return { @@ -363,6 +372,7 @@ export const initializeSendState = createAsyncThunk( async (_, thunkApi) => { const state = thunkApi.getState(); const isNonStandardEthChain = getIsNonStandardEthChain(state); + const chainId = getCurrentChainId(state); const { send: { asset, stage, recipient, amount, draftTransaction }, metamask, @@ -410,6 +420,7 @@ export const initializeSendState = createAsyncThunk( value: amount.value, data: draftTransaction.userInputHexData, isNonStandardEthChain, + chainId, }); gasLimit = estimatedGasLimit || gasLimit; } From e5d54afe804dea073948b598c47a7fc9ec04f1a8 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Fri, 2 Jul 2021 13:01:27 -0230 Subject: [PATCH 05/12] Ensure transaction controller correctly estimates gas for special custom networks (#11441) --- app/scripts/controllers/transactions/index.js | 10 +++++++++- app/scripts/lib/util.js | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 95d5e533d..3f6948f06 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -15,6 +15,7 @@ import { bnToHex, BnMultiplyByFraction, addHexPrefix, + getChainType, } from '../../lib/util'; import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/helpers/constants/error-keys'; import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/pages/swaps/swaps.util'; @@ -24,6 +25,7 @@ import { } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import { GAS_LIMITS } from '../../../../shared/constants/gas'; +import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../../shared/constants/network'; import { isEIP1559Transaction } from '../../../../shared/modules/transaction.utils'; import TransactionStateManager from './tx-state-manager'; import TxGasUtil from './tx-gas-utils'; @@ -356,11 +358,16 @@ export default class TransactionController extends EventEmitter { * @returns {Promise} Object containing the default gas limit, or the simulation failure object */ async _getDefaultGasLimit(txMeta, getCodeResponse) { + const chainId = this._getCurrentChainId(); + const customNetworkGasBuffer = CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]; + const chainType = getChainType(chainId); + if (txMeta.txParams.gas) { return {}; } else if ( txMeta.txParams.to && - txMeta.type === TRANSACTION_TYPES.SENT_ETHER + txMeta.type === TRANSACTION_TYPES.SENT_ETHER && + chainType !== 'custom' ) { // if there's data in the params, but there's no contract code, it's not a valid transaction if (txMeta.txParams.data) { @@ -389,6 +396,7 @@ export default class TransactionController extends EventEmitter { const gasLimit = this.txGasUtil.addGasBuffer( addHexPrefix(estimatedGasHex), blockGasLimit, + customNetworkGasBuffer, ); return { gasLimit, simulationFails }; } diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index 126e5c4f6..1b5c8e7e7 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -3,6 +3,10 @@ import extension from 'extensionizer'; import { stripHexPrefix } from 'ethereumjs-util'; import BN from 'bn.js'; import { memoize } from 'lodash'; +import { + MAINNET_CHAIN_ID, + TEST_CHAINS, +} from '../../../shared/constants/network'; import { ENVIRONMENT_TYPE_POPUP, @@ -180,6 +184,15 @@ function bnToHex(inputBn) { return addHexPrefix(inputBn.toString(16)); } +function getChainType(chainId) { + if (chainId === MAINNET_CHAIN_ID) { + return 'mainnet'; + } else if (TEST_CHAINS.includes(chainId)) { + return 'testnet'; + } + return 'custom'; +} + export { getPlatform, getEnvironmentType, @@ -189,4 +202,5 @@ export { checkForError, addHexPrefix, bnToHex, + getChainType, }; From 2da9acd325c03c78a72de347de133d681316b7ab Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Fri, 2 Jul 2021 17:00:40 -0230 Subject: [PATCH 06/12] Add some metrics events to the 3box controller (#11447) --- app/scripts/controllers/threebox.js | 43 +++++++++++++++++++++++++++++ app/scripts/metamask-controller.js | 3 ++ 2 files changed, 46 insertions(+) diff --git a/app/scripts/controllers/threebox.js b/app/scripts/controllers/threebox.js index 8355fb4fe..10b231b38 100644 --- a/app/scripts/controllers/threebox.js +++ b/app/scripts/controllers/threebox.js @@ -25,6 +25,7 @@ export default class ThreeBoxController { addressBookController, version, getKeyringControllerState, + trackMetaMetricsEvent, } = opts; this.preferencesController = preferencesController; @@ -59,6 +60,7 @@ export default class ThreeBoxController { ); }, }); + this._trackMetaMetricsEvent = trackMetaMetricsEvent; const initState = { threeBoxSyncingAllowed: false, @@ -83,6 +85,12 @@ export default class ThreeBoxController { async init() { const accounts = await this.keyringController.getAccounts(); this.address = accounts[0]; + + this._trackMetaMetricsEvent({ + event: '3Box Initiated', + category: '3Box', + }); + if (this.address && !(this.box && this.store.getState().threeBoxSynced)) { await this.new3Box(); } @@ -140,8 +148,18 @@ export default class ThreeBoxController { backupExists = threeBoxConfig.spaces && threeBoxConfig.spaces.metamask; } catch (e) { if (e.message.match(/^Error: Invalid response \(404\)/u)) { + this._trackMetaMetricsEvent({ + event: '3Box Backup does not exist', + category: '3Box', + }); + backupExists = false; } else { + this._trackMetaMetricsEvent({ + event: '3Box Config Error', + category: '3Box', + }); + throw e; } } @@ -175,9 +193,19 @@ export default class ThreeBoxController { this.store.updateState(stateUpdate); log.debug('3Box space sync done'); + + this._trackMetaMetricsEvent({ + event: '3Box Synced', + category: '3Box', + }); }, }); } catch (e) { + this._trackMetaMetricsEvent({ + event: '3Box Initiation Error', + category: '3Box', + }); + console.error(e); throw e; } @@ -216,13 +244,28 @@ export default class ThreeBoxController { preferences && this.preferencesController.store.updateState(preferences); addressBook && this.addressBookController.update(addressBook, true); this.setShowRestorePromptToFalse(); + + this._trackMetaMetricsEvent({ + event: '3Box Restored Data', + category: '3Box', + }); } turnThreeBoxSyncingOn() { + this._trackMetaMetricsEvent({ + event: '3Box Sync Turned On', + category: '3Box', + }); + this._registerUpdates(); } turnThreeBoxSyncingOff() { + this._trackMetaMetricsEvent({ + event: '3Box Sync Turned Off', + category: '3Box', + }); + this.box.logout(); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e0296ca32..76ac3a74e 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -315,6 +315,9 @@ export default class MetamaskController extends EventEmitter { this.keyringController.memStore, ), version, + trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind( + this.metaMetricsController, + ), }); this.txController = new TransactionController({ From 17682e3368df1c84072a763d166b971fac69441f Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Mon, 5 Jul 2021 09:14:05 +0000 Subject: [PATCH 07/12] Version v9.8.0 --- CHANGELOG.md | 12 +++++++++++- package.json | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b6e58c4..b24ac3a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [9.8.0] +### Uncategorized +- Add some metrics events to the 3box controller ([#11447](https://github.com/MetaMask/metamask-extension/pull/11447)) +- Ensure transaction controller correctly estimates gas for special custom networks ([#11441](https://github.com/MetaMask/metamask-extension/pull/11441)) +- Add list of custom networks with special gas limit buffers ([#11435](https://github.com/MetaMask/metamask-extension/pull/11435)) +- Ensure that eth_estimateGas is called to estimate gas limit for simple sends on custom networks ([#11418](https://github.com/MetaMask/metamask-extension/pull/11418)) +- Refactor send page state management ([#10965](https://github.com/MetaMask/metamask-extension/pull/10965)) +- add erc-721 token detection and flag to disable sending ([#11210](https://github.com/MetaMask/metamask-extension/pull/11210)) + ## [9.7.1] ### Fixed - [#11426](https://github.com/MetaMask/metamask-extension/pull/11426): Fixed bug that broke transaction speed up and cancel, when attempting those actions immediately after opening MetaMask @@ -2321,7 +2330,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Uncategorized - Added the ability to restore accounts from seed words. -[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v9.7.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v9.8.0...HEAD +[9.8.0]: https://github.com/MetaMask/metamask-extension/compare/v9.7.1...v9.8.0 [9.7.1]: https://github.com/MetaMask/metamask-extension/compare/v9.7.0...v9.7.1 [9.7.0]: https://github.com/MetaMask/metamask-extension/compare/v9.6.1...v9.7.0 [9.6.1]: https://github.com/MetaMask/metamask-extension/compare/v9.6.0...v9.6.1 diff --git a/package.json b/package.json index 09c16a206..9aebd4a97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "9.7.1", + "version": "9.8.0", "private": true, "repository": { "type": "git", From 1b2cfe74d5db157d0b2881c6c6100fe1b3cdc093 Mon Sep 17 00:00:00 2001 From: ryanml Date: Tue, 6 Jul 2021 14:01:01 -0700 Subject: [PATCH 08/12] [skip e2e] Update changelog for v9.8.0 (#11463) --- CHANGELOG.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac3a65..6e7759969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [9.8.0] -### Uncategorized -- Add some metrics events to the 3box controller ([#11447](https://github.com/MetaMask/metamask-extension/pull/11447)) -- Ensure transaction controller correctly estimates gas for special custom networks ([#11441](https://github.com/MetaMask/metamask-extension/pull/11441)) -- Add list of custom networks with special gas limit buffers ([#11435](https://github.com/MetaMask/metamask-extension/pull/11435)) -- Ensure that eth_estimateGas is called to estimate gas limit for simple sends on custom networks ([#11418](https://github.com/MetaMask/metamask-extension/pull/11418)) -- Refactor send page state management ([#10965](https://github.com/MetaMask/metamask-extension/pull/10965)) -- add erc-721 token detection and flag to disable sending ([#11210](https://github.com/MetaMask/metamask-extension/pull/11210)) +### Added +- [#11435](https://github.com/MetaMask/metamask-extension/pull/11435): Add gas limit buffers for optimism network + +### Changed +- [#11210](https://github.com/MetaMask/metamask-extension/pull/11210): Disable sending ERC-721 assets (NFTs) +- [#11418](https://github.com/MetaMask/metamask-extension/pull/11418): Use network gas estimate for gas limits of simple sends on custom networks ## [9.7.1] ### Fixed From ece437b1392ae9b890a1a6f8823135f550baa333 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 23 Jun 2021 18:28:49 -0500 Subject: [PATCH 09/12] Update controllers with conversionRate change with minimal required changes in extension (#11361) * updating controllers with conversionRate change with minimal required changes in extension * swapping showFiat selector in places where possible * adding invalid conversion protection * lint fixes * adjusting list-item styling logic --- package.json | 2 +- .../app/asset-list-item/asset-list-item.js | 2 +- ui/components/app/asset-list/asset-list.js | 18 +++--- .../gas-modal-page-container.container.js | 5 +- .../transaction-breakdown.container.js | 6 +- .../currency-input.container.js | 7 +-- ui/components/ui/list-item/index.scss | 9 +++ .../ui/list-item/list-item.component.js | 6 +- .../token-input/token-input.component.test.js | 2 + .../ui/token-input/token-input.container.js | 10 +--- ui/helpers/utils/conversion-util.js | 8 ++- ui/hooks/useCurrencyDisplay.js | 42 +++++++++----- ui/hooks/useUserPreferencedCurrency.js | 8 ++- ui/hooks/useUserPreferencedCurrency.test.js | 11 +++- .../confirm-transaction-base.container.js | 7 +-- ui/selectors/custom-gas.js | 11 +--- ui/selectors/selectors.js | 8 ++- yarn.lock | 56 +++++++++---------- 18 files changed, 128 insertions(+), 90 deletions(-) diff --git a/package.json b/package.json index 9aebd4a97..cd16d69c9 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@lavamoat/preinstall-always-fail": "^1.0.0", "@material-ui/core": "^4.11.0", "@metamask/contract-metadata": "^1.26.0", - "@metamask/controllers": "^9.0.0", + "@metamask/controllers": "^10.0.0", "@metamask/eth-ledger-bridge-keyring": "^0.5.0", "@metamask/eth-token-tracker": "^3.0.1", "@metamask/etherscan-link": "^2.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 3c2325d48..801a8735b 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -112,7 +112,7 @@ const AssetListItem = ({ } titleIcon={titleIcon} - subtitle={

{secondary}

} + subtitle={secondary ?

{secondary}

: null} onClick={onClick} icon={ { }, ); - const [secondaryCurrencyDisplay] = useCurrencyDisplay( - selectedAccountBalance, - { - numberOfDecimals: secondaryNumberOfDecimals, - currency: secondaryCurrency, - }, - ); + const [ + secondaryCurrencyDisplay, + secondaryCurrencyProperties, + ] = useCurrencyDisplay(selectedAccountBalance, { + numberOfDecimals: secondaryNumberOfDecimals, + currency: secondaryCurrency, + }); const primaryTokenImage = useSelector(getNativeCurrencyImage); @@ -71,7 +71,9 @@ const AssetList = ({ onClickAsset }) => { onClickAsset(nativeCurrency)} data-testid="wallet-balance" - primary={primaryCurrencyProperties.value} + primary={ + primaryCurrencyProperties.value ?? secondaryCurrencyProperties.value + } tokenSymbol={primaryCurrencyProperties.suffix} secondary={showFiat ? secondaryCurrencyDisplay : undefined} tokenImage={primaryTokenImage} diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js index 95660b8b4..4d477f486 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -29,7 +29,6 @@ import { getCurrentCurrency, getCurrentEthBalance, getIsMainnet, - getPreferences, getIsTestnet, getBasicGasEstimateLoadingStatus, getCustomGasLimit, @@ -42,6 +41,7 @@ import { isCustomPriceExcessive, getIsGasEstimatesFetched, getIsCustomNetworkGasPriceFetched, + getShouldShowFiat, } from '../../../../selectors'; import { @@ -115,9 +115,8 @@ const mapStateToProps = (state, ownProps) => { const balance = getCurrentEthBalance(state); - const { showFiatInTestnets } = getPreferences(state); const isMainnet = getIsMainnet(state); - const showFiat = Boolean(isMainnet || showFiatInTestnets); + const showFiat = getShouldShowFiat(state); const isTestnet = getIsTestnet(state); diff --git a/ui/components/app/transaction-breakdown/transaction-breakdown.container.js b/ui/components/app/transaction-breakdown/transaction-breakdown.container.js index 01cdc036e..7650c6faa 100644 --- a/ui/components/app/transaction-breakdown/transaction-breakdown.container.js +++ b/ui/components/app/transaction-breakdown/transaction-breakdown.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getIsMainnet, getPreferences } from '../../../selectors'; +import { getShouldShowFiat } from '../../../selectors'; import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { getHexGasTotal } from '../../../helpers/utils/confirm-tx.util'; import { sumHexes } from '../../../helpers/utils/transactions.util'; @@ -11,8 +11,6 @@ const mapStateToProps = (state, ownProps) => { txParams: { gas, gasPrice, value } = {}, txReceipt: { gasUsed } = {}, } = transaction; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); const gasLimit = typeof gasUsed === 'string' ? gasUsed : gas; @@ -22,7 +20,7 @@ const mapStateToProps = (state, ownProps) => { return { nativeCurrency: getNativeCurrency(state), - showFiat: isMainnet || Boolean(showFiatInTestnets), + showFiat: getShouldShowFiat(state), totalInHex, gas, gasPrice, diff --git a/ui/components/ui/currency-input/currency-input.container.js b/ui/components/ui/currency-input/currency-input.container.js index 23d792838..1d3c09620 100644 --- a/ui/components/ui/currency-input/currency-input.container.js +++ b/ui/components/ui/currency-input/currency-input.container.js @@ -1,20 +1,19 @@ import { connect } from 'react-redux'; import { ETH } from '../../../helpers/constants/common'; -import { getIsMainnet, getPreferences } from '../../../selectors'; +import { getShouldShowFiat } from '../../../selectors'; import CurrencyInput from './currency-input.component'; const mapStateToProps = (state) => { const { metamask: { nativeCurrency, currentCurrency, conversionRate }, } = state; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); + const showFiat = getShouldShowFiat(state); return { nativeCurrency, currentCurrency, conversionRate, - hideFiat: !isMainnet && !showFiatInTestnets, + hideFiat: !showFiat, }; }; diff --git a/ui/components/ui/list-item/index.scss b/ui/components/ui/list-item/index.scss index 5470a692c..dc58b90b5 100644 --- a/ui/components/ui/list-item/index.scss +++ b/ui/components/ui/list-item/index.scss @@ -110,3 +110,12 @@ '. actions actions actions actions mid mid mid mid right right right'; } } + +.list-item--single-content-row { + grid-template-areas: 'icon head head head head head head head right right right right'; + align-items: center; + + @media (min-width: 576px) { + grid-template-areas: 'icon head head head head mid mid mid mid right right right'; + } +} diff --git a/ui/components/ui/list-item/list-item.component.js b/ui/components/ui/list-item/list-item.component.js index e6b3da462..61f4c8a58 100644 --- a/ui/components/ui/list-item/list-item.component.js +++ b/ui/components/ui/list-item/list-item.component.js @@ -14,7 +14,11 @@ export default function ListItem({ className, 'data-testid': dataTestId, }) { - const primaryClassName = classnames('list-item', className); + const primaryClassName = classnames( + 'list-item', + className, + subtitle || children ? '' : 'list-item--single-content-row', + ); return (
{ }} tokenExchangeRates={{ '0x1': 2 }} showFiat + currentCurrency="usd" /> , ); @@ -278,6 +279,7 @@ describe('TokenInput Component', () => { }} tokenExchangeRates={{ '0x1': 2 }} showFiat + currentCurrency="usd" /> , ); diff --git a/ui/components/ui/token-input/token-input.container.js b/ui/components/ui/token-input/token-input.container.js index a34c2d247..9f964feec 100644 --- a/ui/components/ui/token-input/token-input.container.js +++ b/ui/components/ui/token-input/token-input.container.js @@ -1,23 +1,17 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { - getIsMainnet, - getTokenExchangeRates, - getPreferences, -} from '../../../selectors'; +import { getTokenExchangeRates, getShouldShowFiat } from '../../../selectors'; import TokenInput from './token-input.component'; const mapStateToProps = (state) => { const { metamask: { currentCurrency }, } = state; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); return { currentCurrency, tokenExchangeRates: getTokenExchangeRates(state), - hideConversion: !isMainnet && !showFiatInTestnets, + hideConversion: !getShouldShowFiat(state), }; }; diff --git a/ui/helpers/utils/conversion-util.js b/ui/helpers/utils/conversion-util.js index 3d61f4410..0b550e67b 100644 --- a/ui/helpers/utils/conversion-util.js +++ b/ui/helpers/utils/conversion-util.js @@ -150,8 +150,11 @@ const conversionUtil = ( conversionRate, invertConversionRate, }, -) => - converter({ +) => { + if (fromCurrency !== toCurrency && !conversionRate) { + return 0; + } + return converter({ fromCurrency, toCurrency, fromNumericBase, @@ -163,6 +166,7 @@ const conversionUtil = ( invertConversionRate, value: value || '0', }); +}; const getBigNumber = (value, base) => { if (!isValidBase(base)) { diff --git a/ui/hooks/useCurrencyDisplay.js b/ui/hooks/useCurrencyDisplay.js index 6b770e38e..a1b67213e 100644 --- a/ui/hooks/useCurrencyDisplay.js +++ b/ui/hooks/useCurrencyDisplay.js @@ -10,6 +10,8 @@ import { getNativeCurrency, } from '../ducks/metamask/metamask'; +import { conversionUtil } from '../helpers/utils/conversion-util'; + /** * Defines the shape of the options parameter for useCurrencyDisplay * @typedef {Object} UseCurrencyOptions @@ -45,24 +47,37 @@ export function useCurrencyDisplay( const currentCurrency = useSelector(getCurrentCurrency); const nativeCurrency = useSelector(getNativeCurrency); const conversionRate = useSelector(getConversionRate); - - const toCurrency = currency || currentCurrency; + const isUserPreferredCurrency = currency === currentCurrency; const value = useMemo(() => { if (displayValue) { return displayValue; } - return formatCurrency( - getValueFromWeiHex({ - value: inputValue, - fromCurrency: nativeCurrency, - toCurrency, - conversionRate, + if ( + currency === nativeCurrency || + (!isUserPreferredCurrency && !nativeCurrency) + ) { + return conversionUtil(inputValue, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', numberOfDecimals: numberOfDecimals || 2, toDenomination: denomination, - }), - toCurrency, - ); + }); + } else if (isUserPreferredCurrency && conversionRate) { + return formatCurrency( + getValueFromWeiHex({ + value: inputValue, + fromCurrency: nativeCurrency, + toCurrency: currency, + conversionRate, + numberOfDecimals: numberOfDecimals || 2, + toDenomination: denomination, + }), + currency, + ); + } + return null; }, [ inputValue, nativeCurrency, @@ -70,13 +85,14 @@ export function useCurrencyDisplay( displayValue, numberOfDecimals, denomination, - toCurrency, + currency, + isUserPreferredCurrency, ]); let suffix; if (!opts.hideLabel) { - suffix = opts.suffix || toCurrency.toUpperCase(); + suffix = opts.suffix || currency?.toUpperCase(); } return [ diff --git a/ui/hooks/useUserPreferencedCurrency.js b/ui/hooks/useUserPreferencedCurrency.js index 50b742abf..c41195345 100644 --- a/ui/hooks/useUserPreferencedCurrency.js +++ b/ui/hooks/useUserPreferencedCurrency.js @@ -1,5 +1,9 @@ import { useSelector } from 'react-redux'; -import { getPreferences, getShouldShowFiat } from '../selectors'; +import { + getPreferences, + getShouldShowFiat, + getCurrentCurrency, +} from '../selectors'; import { getNativeCurrency } from '../ducks/metamask/metamask'; import { PRIMARY, SECONDARY, ETH } from '../helpers/constants/common'; @@ -35,6 +39,7 @@ export function useUserPreferencedCurrency(type, opts = {}) { const nativeCurrency = useSelector(getNativeCurrency); const { useNativeCurrencyAsPrimaryCurrency } = useSelector(getPreferences); const showFiat = useSelector(getShouldShowFiat); + const currentCurrency = useSelector(getCurrentCurrency); let currency, numberOfDecimals; if ( @@ -50,6 +55,7 @@ export function useUserPreferencedCurrency(type, opts = {}) { (type === PRIMARY && !useNativeCurrencyAsPrimaryCurrency) ) { // Display Fiat + currency = currentCurrency; numberOfDecimals = opts.numberOfDecimals || opts.fiatNumberOfDecimals || 2; } diff --git a/ui/hooks/useUserPreferencedCurrency.test.js b/ui/hooks/useUserPreferencedCurrency.test.js index 5689dc11e..62030b7e3 100644 --- a/ui/hooks/useUserPreferencedCurrency.test.js +++ b/ui/hooks/useUserPreferencedCurrency.test.js @@ -1,7 +1,11 @@ import { renderHook } from '@testing-library/react-hooks'; import * as reactRedux from 'react-redux'; import sinon from 'sinon'; -import { getPreferences, getShouldShowFiat } from '../selectors'; +import { + getCurrentCurrency, + getPreferences, + getShouldShowFiat, +} from '../selectors'; import { useUserPreferencedCurrency } from './useUserPreferencedCurrency'; const tests = [ @@ -24,12 +28,13 @@ const tests = [ useNativeCurrencyAsPrimaryCurrency: false, nativeCurrency: 'ETH', showFiat: true, + currentCurrency: 'usd', }, params: { type: 'PRIMARY', }, result: { - currency: undefined, + currency: 'usd', numberOfDecimals: 2, }, }, @@ -116,6 +121,8 @@ function getFakeUseSelector(state) { return state; } else if (selector === getShouldShowFiat) { return state.showFiat; + } else if (selector === getCurrentCurrency) { + return state.currentCurrency; } return state.nativeCurrency; }; diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index 711b12157..09361ad93 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -31,10 +31,10 @@ import { getKnownMethodData, getMetaMaskAccounts, getUseNonceField, - getPreferences, transactionFeeSelector, getNoGasPriceFetched, getIsEthGasPriceFetched, + getShouldShowFiat, } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { transactionMatchesNetwork } from '../../../shared/modules/transaction.utils'; @@ -64,7 +64,6 @@ const mapStateToProps = (state, ownProps) => { match: { params = {} }, } = ownProps; const { id: paramsTransactionId } = params; - const { showFiatInTestnets } = getPreferences(state); const isMainnet = getIsMainnet(state); const { confirmTransaction, metamask } = state; const { @@ -182,8 +181,8 @@ const mapStateToProps = (state, ownProps) => { useNonceField: getUseNonceField(state), customNonceValue, insufficientBalance, - hideSubtitle: !isMainnet && !showFiatInTestnets, - hideFiatConversion: !isMainnet && !showFiatInTestnets, + hideSubtitle: !getShouldShowFiat(state), + hideFiatConversion: !getShouldShowFiat(state), type, nextNonce, mostRecentOverviewPage: getMostRecentOverviewPage(state), diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index f32b67a7a..c36742204 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -12,7 +12,7 @@ import { GAS_ESTIMATE_TYPES } from '../helpers/constants/common'; import { getGasPrice } from '../ducks/send'; import { BASIC_ESTIMATE_STATES, GAS_SOURCE } from '../ducks/gas/gas.duck'; import { GAS_LIMITS } from '../../shared/constants/gas'; -import { getCurrentCurrency, getIsMainnet, getPreferences } from '.'; +import { getCurrentCurrency, getIsMainnet, getShouldShowFiat } from '.'; const NUMBER_OF_DECIMALS_SM_BTNS = 5; @@ -288,9 +288,7 @@ export function getRenderableBasicEstimateData(state, gasLimit) { return []; } - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); - const showFiat = isMainnet || Boolean(showFiatInTestnets); + const showFiat = getShouldShowFiat(state); const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); @@ -313,10 +311,7 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { if (getBasicGasEstimateLoadingStatus(state)) { return []; } - - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); - const showFiat = isMainnet || Boolean(showFiatInTestnets); + const showFiat = getShouldShowFiat(state); const gasLimit = state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; const { conversionRate } = state.metamask; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index f6029ccb5..e3d090c85 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -24,7 +24,10 @@ import { TEMPLATED_CONFIRMATION_MESSAGE_TYPES } from '../pages/confirmation/temp import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { DAY } from '../../shared/constants/time'; -import { getNativeCurrency } from '../ducks/metamask/metamask'; +import { + getNativeCurrency, + getConversionRate, +} from '../ducks/metamask/metamask'; /** * One of the only remaining valid uses of selecting the network subkey of the @@ -383,8 +386,9 @@ export function getPreferences({ metamask }) { export function getShouldShowFiat(state) { const isMainNet = getIsMainnet(state); + const conversionRate = getConversionRate(state); const { showFiatInTestnets } = getPreferences(state); - return Boolean(isMainNet || showFiatInTestnets); + return Boolean((isMainNet || showFiatInTestnets) && conversionRate); } export function getShouldHideZeroBalanceTokens(state) { diff --git a/yarn.lock b/yarn.lock index c5537275a..af5ddc747 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2640,64 +2640,64 @@ resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-1.26.0.tgz#06be4f4dc645da69f6364f75cb2bd47afa642fe6" integrity sha512-58A8csapIPoc854n6AI+3jwJcQfh75BzVrl6SAySgJ9fWTa1XItm9Tx/ORaqYrwaR/9JqH4SnkbXtqyFwuUL6w== -"@metamask/controllers@^5.0.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-5.1.0.tgz#02c1957295bcb6db1655a716d165665d170e7f34" - integrity sha512-4piqkIrpphe+9nEy68WH+yBw9wsXZyCMVeBZeRtliVHAJFXUdz+KZDUi/R1Y+568JBzqAvsOtOzbUIU4btD3Fw== +"@metamask/controllers@^10.0.0": + version "10.1.0" + resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-10.1.0.tgz#ec0a3751fa658966e9818038784ab6b555c95382" + integrity sha512-Me0jzI5CIOdfXkpm3eBOEIxDGlgbKQaW1au0GQdVgbW93ZxDueTqLUg9xSGSfGSJ3i+Alfqi/tnGqT/nwa/5CQ== dependencies: - "@metamask/contract-metadata" "^1.19.0" - await-semaphore "^0.1.3" + "@metamask/contract-metadata" "^1.25.0" + "@types/uuid" "^8.3.0" + async-mutex "^0.2.6" + babel-runtime "^6.26.0" eth-ens-namehash "^2.0.8" eth-json-rpc-infura "^5.1.0" - eth-keyring-controller "^6.1.0" + eth-keyring-controller "^6.2.1" eth-method-registry "1.1.0" - eth-phishing-detect "^1.1.13" + eth-phishing-detect "^1.1.14" eth-query "^2.1.2" eth-rpc-errors "^4.0.0" eth-sig-util "^3.0.0" - ethereumjs-util "^6.1.0" - ethereumjs-wallet "^0.6.4" - ethjs-query "^0.3.8" + ethereumjs-tx "^1.3.7" + ethereumjs-util "^7.0.10" + ethereumjs-wallet "^1.0.1" + ethjs-util "^0.1.6" human-standard-collectible-abi "^1.0.2" human-standard-token-abi "^2.0.0" + immer "^8.0.1" isomorphic-fetch "^3.0.0" jsonschema "^1.2.4" nanoid "^3.1.12" + punycode "^2.1.1" single-call-balance-checker-abi "^1.0.0" - uuid "^3.3.2" + uuid "^8.3.2" web3 "^0.20.7" web3-provider-engine "^16.0.1" -"@metamask/controllers@^9.0.0": - version "9.1.0" - resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-9.1.0.tgz#4434f22eba2522889224b35aa08bc7b67d7248b7" - integrity sha512-jn/F0BNbaPsgEevHaPqk0lGAONKom4re1a4yBC67h7Vu6yu26CRi30SJl4xIh3IW4+ySbPhVLaiXFiXr3fESRQ== +"@metamask/controllers@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-5.1.0.tgz#02c1957295bcb6db1655a716d165665d170e7f34" + integrity sha512-4piqkIrpphe+9nEy68WH+yBw9wsXZyCMVeBZeRtliVHAJFXUdz+KZDUi/R1Y+568JBzqAvsOtOzbUIU4btD3Fw== dependencies: - "@metamask/contract-metadata" "^1.25.0" - "@types/uuid" "^8.3.0" - async-mutex "^0.2.6" - babel-runtime "^6.26.0" + "@metamask/contract-metadata" "^1.19.0" + await-semaphore "^0.1.3" eth-ens-namehash "^2.0.8" eth-json-rpc-infura "^5.1.0" - eth-keyring-controller "^6.2.1" + eth-keyring-controller "^6.1.0" eth-method-registry "1.1.0" - eth-phishing-detect "^1.1.14" + eth-phishing-detect "^1.1.13" eth-query "^2.1.2" eth-rpc-errors "^4.0.0" eth-sig-util "^3.0.0" - ethereumjs-tx "^1.3.7" ethereumjs-util "^6.1.0" - ethereumjs-wallet "^1.0.1" - ethjs-util "^0.1.6" + ethereumjs-wallet "^0.6.4" + ethjs-query "^0.3.8" human-standard-collectible-abi "^1.0.2" human-standard-token-abi "^2.0.0" - immer "^8.0.1" isomorphic-fetch "^3.0.0" jsonschema "^1.2.4" nanoid "^3.1.12" - punycode "^2.1.1" single-call-balance-checker-abi "^1.0.0" - uuid "^8.3.2" + uuid "^3.3.2" web3 "^0.20.7" web3-provider-engine "^16.0.1" From 54a65886282f3706d30753839e79f035ad4eee1d Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Fri, 9 Jul 2021 17:24:00 +0200 Subject: [PATCH 10/12] Swaps: Add conditional routing to new APIs based on a feature flag (#11470) --- app/_locales/en/messages.json | 3 + app/scripts/controllers/swaps.js | 28 ++-- app/scripts/controllers/swaps.test.js | 19 ++- jest.config.js | 6 +- shared/constants/network.js | 1 + shared/constants/swaps.js | 4 + test/data/fetch-mocks.json | 18 ++- test/e2e/webdriver/index.js | 6 +- test/jest/constants.js | 1 + test/jest/mock-store.js | 1 + test/jest/mocks.js | 20 +++ ui/ducks/swaps/swaps.js | 54 +++++-- ui/ducks/swaps/swaps.test.js | 107 +++++++++---- ui/pages/home/home.container.js | 4 +- ui/pages/swaps/build-quote/build-quote.js | 18 ++- ui/pages/swaps/fee-card/fee-card.js | 3 + ui/pages/swaps/index.js | 47 +++--- ui/pages/swaps/index.test.js | 10 +- .../list-item-search.component.js | 4 +- ui/pages/swaps/swaps.util.js | 149 ++++++++++++++---- ui/pages/swaps/swaps.util.test.js | 80 +++++++++- ui/store/actions.js | 4 +- 22 files changed, 447 insertions(+), 140 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 45a08b5a9..04326924c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1183,6 +1183,9 @@ "networkNameEthereum": { "message": "Ethereum" }, + "networkNamePolygon": { + "message": "Polygon" + }, "networkNameTestnet": { "message": "Testnet" }, diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js index 9af3be87c..6fb42644e 100644 --- a/app/scripts/controllers/swaps.js +++ b/app/scripts/controllers/swaps.js @@ -19,7 +19,6 @@ import { isSwapsDefaultTokenAddress } from '../../../shared/modules/swaps.utils' import { fetchTradesInfo as defaultFetchTradesInfo, - fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness, fetchSwapsQuoteRefreshTime as defaultFetchSwapsQuoteRefreshTime, } from '../../../ui/pages/swaps/swaps.util'; import { MINUTE, SECOND } from '../../../shared/constants/time'; @@ -73,6 +72,7 @@ const initialState = { topAggId: null, routeState: '', swapsFeatureIsLive: true, + useNewSwapsApi: false, swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, }, }; @@ -85,7 +85,6 @@ export default class SwapsController { getProviderConfig, tokenRatesStore, fetchTradesInfo = defaultFetchTradesInfo, - fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness, fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime, getCurrentChainId, }) { @@ -94,7 +93,6 @@ export default class SwapsController { }); this._fetchTradesInfo = fetchTradesInfo; - this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness; this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime; this._getCurrentChainId = getCurrentChainId; @@ -119,15 +117,19 @@ export default class SwapsController { // Sets the refresh rate for quote updates from the MetaSwap API async _setSwapsQuoteRefreshTime() { const chainId = this._getCurrentChainId(); + const { swapsState } = this.store.getState(); + // Default to fallback time unless API returns valid response let swapsQuoteRefreshTime = FALLBACK_QUOTE_REFRESH_TIME; try { - swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime(chainId); + swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime( + chainId, + swapsState.useNewSwapsApi, + ); } catch (e) { console.error('Request for swaps quote refresh time failed: ', e); } - const { swapsState } = this.store.getState(); this.store.updateState({ swapsState: { ...swapsState, swapsQuoteRefreshTime }, }); @@ -162,6 +164,9 @@ export default class SwapsController { isPolledRequest, ) { const { chainId } = fetchParamsMetaData; + const { + swapsState: { useNewSwapsApi }, + } = this.store.getState(); if (!fetchParams) { return null; @@ -182,7 +187,10 @@ export default class SwapsController { this.indexOfNewestCallInFlight = indexOfCurrentCall; let [newQuotes] = await Promise.all([ - this._fetchTradesInfo(fetchParams, fetchParamsMetaData), + this._fetchTradesInfo(fetchParams, { + ...fetchParamsMetaData, + useNewSwapsApi, + }), this._setSwapsQuoteRefreshTime(), ]); @@ -449,22 +457,23 @@ export default class SwapsController { this.store.updateState({ swapsState: { ...swapsState, routeState } }); } - setSwapsLiveness(swapsFeatureIsLive) { + setSwapsLiveness(swapsLiveness) { const { swapsState } = this.store.getState(); + const { swapsFeatureIsLive, useNewSwapsApi } = swapsLiveness; this.store.updateState({ - swapsState: { ...swapsState, swapsFeatureIsLive }, + swapsState: { ...swapsState, swapsFeatureIsLive, useNewSwapsApi }, }); } resetPostFetchState() { const { swapsState } = this.store.getState(); - this.store.updateState({ swapsState: { ...initialState.swapsState, tokens: swapsState.tokens, fetchParams: swapsState.fetchParams, swapsFeatureIsLive: swapsState.swapsFeatureIsLive, + useNewSwapsApi: swapsState.useNewSwapsApi, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, }, }); @@ -473,7 +482,6 @@ export default class SwapsController { resetSwapsState() { const { swapsState } = this.store.getState(); - this.store.updateState({ swapsState: { ...initialState.swapsState, diff --git a/app/scripts/controllers/swaps.test.js b/app/scripts/controllers/swaps.test.js index d3b96a098..642191f92 100644 --- a/app/scripts/controllers/swaps.test.js +++ b/app/scripts/controllers/swaps.test.js @@ -128,13 +128,13 @@ const EMPTY_INIT_STATE = { topAggId: null, routeState: '', swapsFeatureIsLive: true, + useNewSwapsApi: false, swapsQuoteRefreshTime: 60000, }, }; const sandbox = sinon.createSandbox(); const fetchTradesInfoStub = sandbox.stub(); -const fetchSwapsFeatureLivenessStub = sandbox.stub(); const fetchSwapsQuoteRefreshTimeStub = sandbox.stub(); const getCurrentChainIdStub = sandbox.stub(); getCurrentChainIdStub.returns(MAINNET_CHAIN_ID); @@ -150,7 +150,6 @@ describe('SwapsController', function () { getProviderConfig: MOCK_GET_PROVIDER_CONFIG, tokenRatesStore: MOCK_TOKEN_RATES_STORE, fetchTradesInfo: fetchTradesInfoStub, - fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub, getCurrentChainId: getCurrentChainIdStub, }); @@ -201,7 +200,6 @@ describe('SwapsController', function () { getProviderConfig: MOCK_GET_PROVIDER_CONFIG, tokenRatesStore: MOCK_TOKEN_RATES_STORE, fetchTradesInfo: fetchTradesInfoStub, - fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, getCurrentChainId: getCurrentChainIdStub, }); const currentEthersInstance = swapsController.ethersProvider; @@ -226,7 +224,6 @@ describe('SwapsController', function () { getProviderConfig: MOCK_GET_PROVIDER_CONFIG, tokenRatesStore: MOCK_TOKEN_RATES_STORE, fetchTradesInfo: fetchTradesInfoStub, - fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, getCurrentChainId: getCurrentChainIdStub, }); const currentEthersInstance = swapsController.ethersProvider; @@ -251,7 +248,6 @@ describe('SwapsController', function () { getProviderConfig: MOCK_GET_PROVIDER_CONFIG, tokenRatesStore: MOCK_TOKEN_RATES_STORE, fetchTradesInfo: fetchTradesInfoStub, - fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, getCurrentChainId: getCurrentChainIdStub, }); const currentEthersInstance = swapsController.ethersProvider; @@ -658,6 +654,7 @@ describe('SwapsController', function () { const quotes = await swapsController.fetchAndSetQuotes(undefined); assert.strictEqual(quotes, null); }); + it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () { fetchTradesInfoStub.resolves(getMockQuotes()); fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()); @@ -695,15 +692,15 @@ describe('SwapsController', function () { metaMaskFeeInEth: '0.5050505050505050505', ethValueOfTokens: '50', }); - assert.strictEqual( - fetchTradesInfoStub.calledOnceWithExactly( - MOCK_FETCH_PARAMS, - MOCK_FETCH_METADATA, - ), + fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS, { + ...MOCK_FETCH_METADATA, + useNewSwapsApi: false, + }), true, ); }); + it('performs the allowance check', async function () { fetchTradesInfoStub.resolves(getMockQuotes()); fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()); @@ -878,12 +875,14 @@ describe('SwapsController', function () { const tokens = 'test'; const fetchParams = 'test'; const swapsFeatureIsLive = false; + const useNewSwapsApi = false; const swapsQuoteRefreshTime = 0; swapsController.store.updateState({ swapsState: { tokens, fetchParams, swapsFeatureIsLive, + useNewSwapsApi, swapsQuoteRefreshTime, }, }); diff --git a/jest.config.js b/jest.config.js index 8e215b994..09bc15c14 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,9 +6,9 @@ module.exports = { coverageThreshold: { global: { branches: 32.75, - functions: 42.9, - lines: 43.12, - statements: 43.67, + functions: 40, + lines: 42.29, + statements: 42.83, }, }, setupFiles: ['./test/setup.js', './test/env.js'], diff --git a/shared/constants/network.js b/shared/constants/network.js index 0101ab723..66bd1e949 100644 --- a/shared/constants/network.js +++ b/shared/constants/network.js @@ -21,6 +21,7 @@ export const LOCALHOST_CHAIN_ID = '0x539'; export const BSC_CHAIN_ID = '0x38'; export const OPTIMISM_CHAIN_ID = '0xa'; export const OPTIMISM_TESTNET_CHAIN_ID = '0x45'; +export const POLYGON_CHAIN_ID = '0x89'; /** * The largest possible chain ID we can handle. diff --git a/shared/constants/swaps.js b/shared/constants/swaps.js index 42a2167f3..00512c0cc 100644 --- a/shared/constants/swaps.js +++ b/shared/constants/swaps.js @@ -93,3 +93,7 @@ export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = { [BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL, [MAINNET_CHAIN_ID]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL, }; + +export const ETHEREUM = 'ethereum'; +export const POLYGON = 'polygon'; +export const BSC = 'bsc'; diff --git a/test/data/fetch-mocks.json b/test/data/fetch-mocks.json index 886f87697..9299b4602 100644 --- a/test/data/fetch-mocks.json +++ b/test/data/fetch-mocks.json @@ -8,9 +8,21 @@ "mockMetaMetricsResponse": true }, "swaps": { - "featureFlag": { - "status": { - "active": true + "featureFlags": { + "bsc": { + "mobile_active": false, + "extension_active": true, + "fallback_to_v1": true + }, + "ethereum": { + "mobile_active": false, + "extension_active": true, + "fallback_to_v1": true + }, + "polygon": { + "mobile_active": false, + "extension_active": true, + "fallback_to_v1": false } } } diff --git a/test/e2e/webdriver/index.js b/test/e2e/webdriver/index.js index b7647f96a..075718631 100644 --- a/test/e2e/webdriver/index.js +++ b/test/e2e/webdriver/index.js @@ -48,9 +48,9 @@ async function setupFetchMocking(driver) { return { json: async () => clone(mockResponses.gasPricesBasic) }; } else if (url.match(/chromeextensionmm/u)) { return { json: async () => clone(mockResponses.metametrics) }; - } else if (url.match(/^https:\/\/(api\.metaswap|.*airswap-dev)/u)) { - if (url.match(/featureFlag$/u)) { - return { json: async () => clone(mockResponses.swaps.featureFlag) }; + } else if (url.match(/^https:\/\/(api2\.metaswap\.codefi\.network)/u)) { + if (url.match(/featureFlags$/u)) { + return { json: async () => clone(mockResponses.swaps.featureFlags) }; } } return window.origFetch(...args); diff --git a/test/jest/constants.js b/test/jest/constants.js index 2ed360574..da5c54bdf 100644 --- a/test/jest/constants.js +++ b/test/jest/constants.js @@ -1 +1,2 @@ export const METASWAP_BASE_URL = 'https://api.metaswap.codefi.network'; +export const METASWAP_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network'; diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index f09999f10..7a8057ecc 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -106,6 +106,7 @@ export const createSwapsMockStore = () => { topAggId: null, routeState: '', swapsFeatureIsLive: false, + useNewSwapsApi: false, }, }, }; diff --git a/test/jest/mocks.js b/test/jest/mocks.js index c57b2068c..4d7b4cd68 100644 --- a/test/jest/mocks.js +++ b/test/jest/mocks.js @@ -59,3 +59,23 @@ export const TOKENS_GET_RESPONSE = [ address: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF', }, ]; + +export const createFeatureFlagsResponse = () => { + return { + bsc: { + mobile_active: false, + extension_active: true, + fallback_to_v1: true, + }, + ethereum: { + mobile_active: false, + extension_active: true, + fallback_to_v1: true, + }, + polygon: { + mobile_active: false, + extension_active: true, + fallback_to_v1: false, + }, + }; +}; diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index 34f7a84fe..376be522a 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -34,9 +34,10 @@ import { SWAPS_MAINTENANCE_ROUTE, } from '../../helpers/constants/routes'; import { - fetchSwapsFeatureLiveness, + fetchSwapsFeatureFlags, fetchSwapsGasPrices, isContractAddressValid, + getSwapsLivenessForNetwork, } from '../../pages/swaps/swaps.util'; import { calcGasTotal } from '../../pages/send/send.utils'; import { @@ -223,9 +224,12 @@ export function shouldShowCustomPriceTooLowWarning(state) { const getSwapsState = (state) => state.metamask.swapsState; -export const getSwapsFeatureLiveness = (state) => +export const getSwapsFeatureIsLive = (state) => state.metamask.swapsState.swapsFeatureIsLive; +export const getUseNewSwapsApi = (state) => + state.metamask.swapsState.useNewSwapsApi; + export const getSwapsQuoteRefreshTime = (state) => state.metamask.swapsState.swapsQuoteRefreshTime; @@ -373,16 +377,21 @@ export const fetchAndSetSwapsGasPriceInfo = () => { export const fetchSwapsLiveness = () => { return async (dispatch, getState) => { - let swapsFeatureIsLive = false; + let swapsLivenessForNetwork = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; try { - swapsFeatureIsLive = await fetchSwapsFeatureLiveness( + const swapsFeatureFlags = await fetchSwapsFeatureFlags(); + swapsLivenessForNetwork = getSwapsLivenessForNetwork( + swapsFeatureFlags, getCurrentChainId(getState()), ); } catch (error) { log.error('Failed to fetch Swaps liveness, defaulting to false.', error); } - await dispatch(setSwapsLiveness(swapsFeatureIsLive)); - return swapsFeatureIsLive; + await dispatch(setSwapsLiveness(swapsLivenessForNetwork)); + return swapsLivenessForNetwork; }; }; @@ -395,15 +404,22 @@ export const fetchQuotesAndSetQuoteState = ( return async (dispatch, getState) => { const state = getState(); const chainId = getCurrentChainId(state); - let swapsFeatureIsLive = false; + let swapsLivenessForNetwork = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; try { - swapsFeatureIsLive = await fetchSwapsFeatureLiveness(chainId); + const swapsFeatureFlags = await fetchSwapsFeatureFlags(); + swapsLivenessForNetwork = getSwapsLivenessForNetwork( + swapsFeatureFlags, + chainId, + ); } catch (error) { log.error('Failed to fetch Swaps liveness, defaulting to false.', error); } - await dispatch(setSwapsLiveness(swapsFeatureIsLive)); + await dispatch(setSwapsLiveness(swapsLivenessForNetwork)); - if (!swapsFeatureIsLive) { + if (!swapsLivenessForNetwork.swapsFeatureIsLive) { await history.push(SWAPS_MAINTENANCE_ROUTE); return; } @@ -600,15 +616,22 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { const state = getState(); const chainId = getCurrentChainId(state); const hardwareWalletUsed = isHardwareWallet(state); - let swapsFeatureIsLive = false; + let swapsLivenessForNetwork = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; try { - swapsFeatureIsLive = await fetchSwapsFeatureLiveness(chainId); + const swapsFeatureFlags = await fetchSwapsFeatureFlags(); + swapsLivenessForNetwork = getSwapsLivenessForNetwork( + swapsFeatureFlags, + chainId, + ); } catch (error) { log.error('Failed to fetch Swaps liveness, defaulting to false.', error); } - await dispatch(setSwapsLiveness(swapsFeatureIsLive)); + await dispatch(setSwapsLiveness(swapsLivenessForNetwork)); - if (!swapsFeatureIsLive) { + if (!swapsLivenessForNetwork.swapsFeatureIsLive) { await history.push(SWAPS_MAINTENANCE_ROUTE); return; } @@ -808,12 +831,13 @@ export function fetchMetaSwapsGasPriceEstimates() { return async (dispatch, getState) => { const state = getState(); const chainId = getCurrentChainId(state); + const useNewSwapsApi = getUseNewSwapsApi(state); dispatch(swapGasPriceEstimatesFetchStarted()); let priceEstimates; try { - priceEstimates = await fetchSwapsGasPrices(chainId); + priceEstimates = await fetchSwapsGasPrices(chainId, useNewSwapsApi); } catch (e) { log.warn('Fetching swaps gas prices failed:', e); diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js index a94869267..c3a9c9e3e 100644 --- a/ui/ducks/swaps/swaps.test.js +++ b/ui/ducks/swaps/swaps.test.js @@ -1,5 +1,6 @@ import nock from 'nock'; +import { MOCKS } from '../../../test/jest'; import { setSwapsLiveness } from '../../store/actions'; import { setStorageItem } from '../../helpers/utils/storage-helpers'; import * as swaps from './swaps'; @@ -25,7 +26,7 @@ describe('Ducks - Swaps', () => { describe('fetchSwapsLiveness', () => { const cleanFeatureFlagApiCache = () => { setStorageItem( - 'cachedFetch:https://api.metaswap.codefi.network/featureFlag', + 'cachedFetch:https://api2.metaswap.codefi.network/featureFlags', null, ); }; @@ -34,12 +35,12 @@ describe('Ducks - Swaps', () => { cleanFeatureFlagApiCache(); }); - const mockFeatureFlagApiResponse = ({ - active = false, + const mockFeatureFlagsApiResponse = ({ + featureFlagsResponse, replyWithError = false, } = {}) => { - const apiNock = nock('https://api.metaswap.codefi.network').get( - '/featureFlag', + const apiNock = nock('https://api2.metaswap.codefi.network').get( + '/featureFlags', ); if (replyWithError) { return apiNock.replyWithError({ @@ -47,9 +48,7 @@ describe('Ducks - Swaps', () => { code: 'serverSideError', }); } - return apiNock.reply(200, { - active, - }); + return apiNock.reply(200, featureFlagsResponse); }; const createGetState = () => { @@ -58,61 +57,111 @@ describe('Ducks - Swaps', () => { }); }; - it('returns true if the Swaps feature is enabled', async () => { + it('checks that Swaps for ETH are enabled and can use new API', async () => { + const mockDispatch = jest.fn(); + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: true, + }; + const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); + const featureFlagApiNock = mockFeatureFlagsApiResponse({ + featureFlagsResponse, + }); + const swapsLiveness = await swaps.fetchSwapsLiveness()( + mockDispatch, + createGetState(), + ); + expect(featureFlagApiNock.isDone()).toBe(true); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); + expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); + }); + + it('checks that Swaps for ETH are disabled for API v2 and enabled for API v1', async () => { const mockDispatch = jest.fn(); - const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true }); - const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()( + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: false, + }; + const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); + featureFlagsResponse.ethereum.extension_active = false; + const featureFlagApiNock = mockFeatureFlagsApiResponse({ + featureFlagsResponse, + }); + const swapsLiveness = await swaps.fetchSwapsLiveness()( mockDispatch, createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(setSwapsLiveness).toHaveBeenCalledWith(true); - expect(isSwapsFeatureEnabled).toBe(true); + expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); + expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); - it('returns false if the Swaps feature is disabled', async () => { + it('checks that Swaps for ETH are disabled for API v1 and v2', async () => { const mockDispatch = jest.fn(); - const featureFlagApiNock = mockFeatureFlagApiResponse({ active: false }); - const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()( + const expectedSwapsLiveness = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; + const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); + featureFlagsResponse.ethereum.extension_active = false; + featureFlagsResponse.ethereum.fallback_to_v1 = false; + const featureFlagApiNock = mockFeatureFlagsApiResponse({ + featureFlagsResponse, + }); + const swapsLiveness = await swaps.fetchSwapsLiveness()( mockDispatch, createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(setSwapsLiveness).toHaveBeenCalledWith(false); - expect(isSwapsFeatureEnabled).toBe(false); + expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); + expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); - it('returns false if the /featureFlag API call throws an error', async () => { + it('checks that Swaps for ETH are disabled if the /featureFlags API call throws an error', async () => { const mockDispatch = jest.fn(); - const featureFlagApiNock = mockFeatureFlagApiResponse({ + const expectedSwapsLiveness = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; + const featureFlagApiNock = mockFeatureFlagsApiResponse({ replyWithError: true, }); - const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()( + const swapsLiveness = await swaps.fetchSwapsLiveness()( mockDispatch, createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(setSwapsLiveness).toHaveBeenCalledWith(false); - expect(isSwapsFeatureEnabled).toBe(false); + expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); + expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); - it('only calls the API once and returns true from cache for the second call', async () => { + it('only calls the API once and returns response from cache for the second call', async () => { const mockDispatch = jest.fn(); - const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true }); + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: true, + }; + const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); + const featureFlagApiNock = mockFeatureFlagsApiResponse({ + featureFlagsResponse, + }); await swaps.fetchSwapsLiveness()(mockDispatch, createGetState()); expect(featureFlagApiNock.isDone()).toBe(true); - const featureFlagApiNock2 = mockFeatureFlagApiResponse({ active: true }); - const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()( + const featureFlagApiNock2 = mockFeatureFlagsApiResponse({ + featureFlagsResponse, + }); + const swapsLiveness = await swaps.fetchSwapsLiveness()( mockDispatch, createGetState(), ); expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead. expect(mockDispatch).toHaveBeenCalledTimes(2); - expect(setSwapsLiveness).toHaveBeenCalledWith(true); - expect(isSwapsFeatureEnabled).toBe(true); + expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); + expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); }); }); diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 2b989ae03..4d179abf9 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -31,7 +31,7 @@ import { } from '../../store/actions'; import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app'; import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask'; -import { getSwapsFeatureLiveness } from '../../ducks/swaps/swaps'; +import { getSwapsFeatureIsLive } from '../../ducks/swaps/swaps'; import { getEnvironmentType } from '../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_NOTIFICATION, @@ -60,7 +60,7 @@ const mapStateToProps = (state) => { const accountBalance = getCurrentEthBalance(state); const { forgottenPassword, threeBoxLastUpdated } = appState; const totalUnapprovedCount = getTotalUnapprovedCount(state); - const swapsEnabled = getSwapsFeatureLiveness(state); + const swapsEnabled = getSwapsFeatureIsLive(state); const pendingConfirmations = getUnapprovedTemplatedConfirmations(state); const envType = getEnvironmentType(); diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js index 0ac479977..4ee5a0874 100644 --- a/ui/pages/swaps/build-quote/build-quote.js +++ b/ui/pages/swaps/build-quote/build-quote.js @@ -141,7 +141,9 @@ export default function BuildQuote({ const toTokenIsNotDefault = selectedToToken?.address && !isSwapsDefaultTokenAddress(selectedToToken?.address, chainId); - const occurances = Number(selectedToToken?.occurances || 0); + const occurrences = Number( + selectedToToken?.occurances || selectedToToken?.occurrences || 0, + ); const { address: fromTokenAddress, symbol: fromTokenSymbol, @@ -354,11 +356,11 @@ export default function BuildQuote({ let tokenVerificationDescription = ''; if (blockExplorerTokenLink) { - if (occurances === 1) { + if (occurrences === 1) { tokenVerificationDescription = t('verifyThisTokenOn', [ , ]); - } else if (occurances === 0) { + } else if (occurrences === 0) { tokenVerificationDescription = t('verifyThisUnconfirmedTokenOn', [ , ]); @@ -470,13 +472,13 @@ export default function BuildQuote({ />
{toTokenIsNotDefault && - (occurances < 2 ? ( + (occurrences < 2 ? (
- {occurances === 1 + {occurrences === 1 ? t('swapTokenVerificationOnlyOneSource') : t('swapTokenVerificationAddedManually')}
@@ -503,7 +505,7 @@ export default function BuildQuote({ className="build-quote__bold" key="token-verification-bold-text" > - {t('swapTokenVerificationSources', [occurances])} + {t('swapTokenVerificationSources', [occurrences])} {blockExplorerTokenLink && ( <> @@ -563,7 +565,7 @@ export default function BuildQuote({ !selectedToToken?.address || Number(maxSlippage) < 0 || Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE || - (toTokenIsNotDefault && occurances < 2 && !verificationClicked) + (toTokenIsNotDefault && occurrences < 2 && !verificationClicked) } hideCancel showTermsOfService diff --git a/ui/pages/swaps/fee-card/fee-card.js b/ui/pages/swaps/fee-card/fee-card.js index 68ee4faec..67b448a5d 100644 --- a/ui/pages/swaps/fee-card/fee-card.js +++ b/ui/pages/swaps/fee-card/fee-card.js @@ -6,6 +6,7 @@ import { MAINNET_CHAIN_ID, BSC_CHAIN_ID, LOCALHOST_CHAIN_ID, + POLYGON_CHAIN_ID, } from '../../../../shared/constants/network'; export default function FeeCard({ @@ -38,6 +39,8 @@ export default function FeeCard({ return t('networkNameEthereum'); case BSC_CHAIN_ID: return t('networkNameBSC'); + case POLYGON_CHAIN_ID: + return t('networkNamePolygon'); case LOCALHOST_CHAIN_ID: return t('networkNameTestnet'); default: diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js index 05a0d6d42..d2a30b904 100644 --- a/ui/pages/swaps/index.js +++ b/ui/pages/swaps/index.js @@ -29,10 +29,11 @@ import { getAggregatorMetadata, getBackgroundSwapRouteState, getSwapsErrorKey, - getSwapsFeatureLiveness, + getSwapsFeatureIsLive, prepareToLeaveSwaps, fetchAndSetSwapsGasPriceInfo, fetchSwapsLiveness, + getUseNewSwapsApi, } from '../../ducks/swaps/swaps'; import { AWAITING_SIGNATURES_ROUTE, @@ -103,9 +104,10 @@ export default function Swap() { const aggregatorMetadata = useSelector(getAggregatorMetadata); const fetchingQuotes = useSelector(getFetchingQuotes); let swapsErrorKey = useSelector(getSwapsErrorKey); - const swapsEnabled = useSelector(getSwapsFeatureLiveness); + const swapsEnabled = useSelector(getSwapsFeatureIsLive); const chainId = useSelector(getCurrentChainId); const isSwapsChain = useSelector(getIsSwapsChain); + const useNewSwapsApi = useSelector(getUseNewSwapsApi); const { balance: ethBalance, @@ -165,27 +167,28 @@ export default function Swap() { }; }, []); + // eslint-disable-next-line useEffect(() => { - fetchTokens(chainId) - .then((tokens) => { - dispatch(setSwapsTokens(tokens)); - }) - .catch((error) => console.error(error)); - - fetchTopAssets(chainId).then((topAssets) => { - dispatch(setTopAssets(topAssets)); - }); - - fetchAggregatorMetadata(chainId).then((newAggregatorMetadata) => { - dispatch(setAggregatorMetadata(newAggregatorMetadata)); - }); - - dispatch(fetchAndSetSwapsGasPriceInfo(chainId)); - - return () => { - dispatch(prepareToLeaveSwaps()); - }; - }, [dispatch, chainId]); + if (isFeatureFlagLoaded) { + fetchTokens(chainId, useNewSwapsApi) + .then((tokens) => { + dispatch(setSwapsTokens(tokens)); + }) + .catch((error) => console.error(error)); + fetchTopAssets(chainId, useNewSwapsApi).then((topAssets) => { + dispatch(setTopAssets(topAssets)); + }); + fetchAggregatorMetadata(chainId, useNewSwapsApi).then( + (newAggregatorMetadata) => { + dispatch(setAggregatorMetadata(newAggregatorMetadata)); + }, + ); + dispatch(fetchAndSetSwapsGasPriceInfo(chainId)); + return () => { + dispatch(prepareToLeaveSwaps()); + }; + } + }, [dispatch, chainId, isFeatureFlagLoaded, useNewSwapsApi]); const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletType = useSelector(getHardwareWalletType); diff --git a/ui/pages/swaps/index.test.js b/ui/pages/swaps/index.test.js index a3162ae2a..ed2323e40 100644 --- a/ui/pages/swaps/index.test.js +++ b/ui/pages/swaps/index.test.js @@ -24,7 +24,7 @@ setBackgroundConnection({ }); describe('Swap', () => { - let tokensNock; + let featureFlagsNock; beforeEach(() => { nock(CONSTANTS.METASWAP_BASE_URL) @@ -43,9 +43,13 @@ describe('Swap', () => { .get('/gasPrices') .reply(200, MOCKS.GAS_PRICES_GET_RESPONSE); - tokensNock = nock(CONSTANTS.METASWAP_BASE_URL) + nock(CONSTANTS.METASWAP_BASE_URL) .get('/tokens') .reply(200, MOCKS.TOKENS_GET_RESPONSE); + + featureFlagsNock = nock(CONSTANTS.METASWAP_API_V2_BASE_URL) + .get('/featureFlags') + .reply(200, MOCKS.createFeatureFlagsResponse()); }); afterAll(() => { @@ -55,7 +59,7 @@ describe('Swap', () => { it('renders the component with initial props', async () => { const store = configureMockStore(middleware)(createSwapsMockStore()); const { container, getByText } = renderWithProvider(, store); - await waitFor(() => expect(tokensNock.isDone()).toBe(true)); + await waitFor(() => expect(featureFlagsNock.isDone()).toBe(true)); expect(getByText('Swap')).toBeInTheDocument(); expect(getByText('Cancel')).toBeInTheDocument(); expect(container).toMatchSnapshot(); diff --git a/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js b/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js index 26fbc0124..ed1b9e257 100644 --- a/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js +++ b/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js @@ -9,6 +9,7 @@ import { usePrevious } from '../../../../hooks/usePrevious'; import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils'; import { fetchToken } from '../../swaps.util'; import { getCurrentChainId } from '../../../../selectors/selectors'; +import { getUseNewSwapsApi } from '../../../../ducks/swaps/swaps'; const renderAdornment = () => ( @@ -28,6 +29,7 @@ export default function ListItemSearch({ const fuseRef = useRef(); const [searchQuery, setSearchQuery] = useState(''); const chainId = useSelector(getCurrentChainId); + const useNewSwapsApi = useSelector(getUseNewSwapsApi); /** * Search a custom token for import based on a contract address. @@ -36,7 +38,7 @@ export default function ListItemSearch({ const handleSearchTokenForImport = async (contractAddress) => { setSearchQuery(contractAddress); try { - const token = await fetchToken(contractAddress, chainId); + const token = await fetchToken(contractAddress, chainId, useNewSwapsApi); if (token) { token.primaryLabel = token.symbol; token.secondaryLabel = token.name; diff --git a/ui/pages/swaps/swaps.util.js b/ui/pages/swaps/swaps.util.js index 58ef33f63..068c3f870 100644 --- a/ui/pages/swaps/swaps.util.js +++ b/ui/pages/swaps/swaps.util.js @@ -6,6 +6,9 @@ import { METASWAP_CHAINID_API_HOST_MAP, SWAPS_CHAINID_CONTRACT_ADDRESS_MAP, ETH_WETH_CONTRACT_ADDRESS, + ETHEREUM, + POLYGON, + BSC, } from '../../../shared/constants/swaps'; import { isSwapsDefaultTokenAddress, @@ -15,6 +18,9 @@ import { ETH_SYMBOL, WETH_SYMBOL, MAINNET_CHAIN_ID, + BSC_CHAIN_ID, + POLYGON_CHAIN_ID, + LOCALHOST_CHAIN_ID, } from '../../../shared/constants/network'; import { SECOND } from '../../../shared/constants/time'; import { @@ -42,24 +48,50 @@ const TOKEN_TRANSFER_LOG_TOPIC_HASH = const CACHE_REFRESH_FIVE_MINUTES = 300000; -const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) { +const SWAPS_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network'; +const GAS_API_BASE_URL = 'https://gas-api.metaswap.codefi.network'; + +/** + * @param {string} type Type of an API call, e.g. "tokens" + * @param {string} chainId + * @returns string + */ +const getBaseUrlForNewSwapsApi = (type, chainId) => { + const noNetworkSpecificTypes = ['refreshTime']; // These types don't need network info in the URL. + if (noNetworkSpecificTypes.includes(type)) { + return SWAPS_API_V2_BASE_URL; + } + const chainIdDecimal = chainId && parseInt(chainId, 16); + const gasApiTypes = ['gasPrices']; + if (gasApiTypes.includes(type)) { + return `${GAS_API_BASE_URL}/networks/${chainIdDecimal}`; // Gas calculations are in its own repo. + } + return `${SWAPS_API_V2_BASE_URL}/networks/${chainIdDecimal}`; +}; + +const getBaseApi = function ( + type, + chainId = MAINNET_CHAIN_ID, + useNewSwapsApi = false, +) { + const baseUrl = useNewSwapsApi + ? getBaseUrlForNewSwapsApi(type, chainId) + : METASWAP_CHAINID_API_HOST_MAP[chainId]; switch (type) { case 'trade': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`; + return `${baseUrl}/trades?`; case 'tokens': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`; + return `${baseUrl}/tokens`; case 'token': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/token`; + return `${baseUrl}/token`; case 'topAssets': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`; - case 'featureFlag': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/featureFlag`; + return `${baseUrl}/topAssets`; case 'aggregatorMetadata': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/aggregatorMetadata`; + return `${baseUrl}/aggregatorMetadata`; case 'gasPrices': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/gasPrices`; + return `${baseUrl}/gasPrices`; case 'refreshTime': - return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/quoteRefreshRate`; + return `${baseUrl}/quoteRefreshRate`; default: throw new Error('getBaseApi requires an api call type'); } @@ -233,7 +265,7 @@ export async function fetchTradesInfo( fromAddress, exchangeList, }, - { chainId }, + { chainId, useNewSwapsApi }, ) { const urlParams = { destinationToken, @@ -249,7 +281,11 @@ export async function fetchTradesInfo( } const queryString = new URLSearchParams(urlParams).toString(); - const tradeURL = `${getBaseApi('trade', chainId)}${queryString}`; + const tradeURL = `${getBaseApi( + 'trade', + chainId, + useNewSwapsApi, + )}${queryString}`; const tradesResponse = await fetchWithCache( tradeURL, { method: 'GET' }, @@ -293,8 +329,8 @@ export async function fetchTradesInfo( return newQuotes; } -export async function fetchToken(contractAddress, chainId) { - const tokenUrl = getBaseApi('token', chainId); +export async function fetchToken(contractAddress, chainId, useNewSwapsApi) { + const tokenUrl = getBaseApi('token', chainId, useNewSwapsApi); const token = await fetchWithCache( `${tokenUrl}?address=${contractAddress}`, { method: 'GET' }, @@ -303,8 +339,8 @@ export async function fetchToken(contractAddress, chainId) { return token; } -export async function fetchTokens(chainId) { - const tokensUrl = getBaseApi('tokens', chainId); +export async function fetchTokens(chainId, useNewSwapsApi) { + const tokensUrl = getBaseApi('tokens', chainId, useNewSwapsApi); const tokens = await fetchWithCache( tokensUrl, { method: 'GET' }, @@ -325,8 +361,12 @@ export async function fetchTokens(chainId) { return filteredTokens; } -export async function fetchAggregatorMetadata(chainId) { - const aggregatorMetadataUrl = getBaseApi('aggregatorMetadata', chainId); +export async function fetchAggregatorMetadata(chainId, useNewSwapsApi) { + const aggregatorMetadataUrl = getBaseApi( + 'aggregatorMetadata', + chainId, + useNewSwapsApi, + ); const aggregators = await fetchWithCache( aggregatorMetadataUrl, { method: 'GET' }, @@ -347,8 +387,8 @@ export async function fetchAggregatorMetadata(chainId) { return filteredAggregators; } -export async function fetchTopAssets(chainId) { - const topAssetsUrl = getBaseApi('topAssets', chainId); +export async function fetchTopAssets(chainId, useNewSwapsApi) { + const topAssetsUrl = getBaseApi('topAssets', chainId, useNewSwapsApi); const response = await fetchWithCache( topAssetsUrl, { method: 'GET' }, @@ -363,18 +403,18 @@ export async function fetchTopAssets(chainId) { return topAssetsMap; } -export async function fetchSwapsFeatureLiveness(chainId) { - const status = await fetchWithCache( - getBaseApi('featureFlag', chainId), +export async function fetchSwapsFeatureFlags() { + const response = await fetchWithCache( + `${SWAPS_API_V2_BASE_URL}/featureFlags`, { method: 'GET' }, { cacheRefreshTime: 600000 }, ); - return status?.active; + return response; } -export async function fetchSwapsQuoteRefreshTime(chainId) { +export async function fetchSwapsQuoteRefreshTime(chainId, useNewSwapsApi) { const response = await fetchWithCache( - getBaseApi('refreshTime', chainId), + getBaseApi('refreshTime', chainId, useNewSwapsApi), { method: 'GET' }, { cacheRefreshTime: 600000 }, ); @@ -409,8 +449,8 @@ export async function fetchTokenBalance(address, userAddress) { return usersToken; } -export async function fetchSwapsGasPrices(chainId) { - const gasPricesUrl = getBaseApi('gasPrices', chainId); +export async function fetchSwapsGasPrices(chainId, useNewSwapsApi) { + const gasPricesUrl = getBaseApi('gasPrices', chainId, useNewSwapsApi); const response = await fetchWithCache( gasPricesUrl, { method: 'GET' }, @@ -730,3 +770,56 @@ export const isContractAddressValid = ( contractAddressForChainId.toUpperCase() === contractAddress.toUpperCase() ); }; + +/** + * @param {string} chainId + * @returns string e.g. ethereum, bsc or polygon + */ +export const getNetworkNameByChainId = (chainId) => { + switch (chainId) { + case MAINNET_CHAIN_ID: + return ETHEREUM; + case BSC_CHAIN_ID: + return BSC; + case POLYGON_CHAIN_ID: + return POLYGON; + default: + return ''; + } +}; + +/** + * It returns info about if Swaps are enabled and if we should use our new APIs for it. + * @param {object} swapsFeatureFlags + * @param {string} chainId + * @returns object with 2 items: "swapsFeatureIsLive" and "useNewSwapsApi" + */ +export const getSwapsLivenessForNetwork = (swapsFeatureFlags = {}, chainId) => { + const networkName = getNetworkNameByChainId(chainId); + // Use old APIs for testnet. + if (chainId === LOCALHOST_CHAIN_ID) { + return { + swapsFeatureIsLive: true, + useNewSwapsApi: false, + }; + } + // If a network name is not found in the list of feature flags, disable Swaps. + if (!swapsFeatureFlags[networkName]) { + return { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; + } + const isNetworkEnabledForNewApi = + swapsFeatureFlags[networkName].extension_active; + if (isNetworkEnabledForNewApi) { + return { + swapsFeatureIsLive: true, + useNewSwapsApi: true, + }; + } + return { + swapsFeatureIsLive: swapsFeatureFlags[networkName].fallback_to_v1, + useNewSwapsApi: false, + }; +}; diff --git a/ui/pages/swaps/swaps.util.test.js b/ui/pages/swaps/swaps.util.test.js index 936864f0f..81220cbd4 100644 --- a/ui/pages/swaps/swaps.util.test.js +++ b/ui/pages/swaps/swaps.util.test.js @@ -1,14 +1,20 @@ import nock from 'nock'; +import { MOCKS } from '../../../test/jest'; import { ETH_SYMBOL, WETH_SYMBOL, MAINNET_CHAIN_ID, BSC_CHAIN_ID, + POLYGON_CHAIN_ID, LOCALHOST_CHAIN_ID, + RINKEBY_CHAIN_ID, } from '../../../shared/constants/network'; import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP, ETH_WETH_CONTRACT_ADDRESS, + ETHEREUM, + POLYGON, + BSC, } from '../../../shared/constants/swaps'; import { TOKENS, @@ -17,13 +23,14 @@ import { AGGREGATOR_METADATA, TOP_ASSETS, } from './swaps-util-test-constants'; - import { fetchTradesInfo, fetchTokens, fetchAggregatorMetadata, fetchTopAssets, isContractAddressValid, + getNetworkNameByChainId, + getSwapsLivenessForNetwork, } from './swaps.util'; jest.mock('../../helpers/utils/storage-helpers.js', () => ({ @@ -372,4 +379,75 @@ describe('Swaps Util', () => { ).toBe(false); }); }); + + describe('getNetworkNameByChainId', () => { + it('returns "ethereum" for mainnet chain ID', () => { + expect(getNetworkNameByChainId(MAINNET_CHAIN_ID)).toBe(ETHEREUM); + }); + + it('returns "bsc" for mainnet chain ID', () => { + expect(getNetworkNameByChainId(BSC_CHAIN_ID)).toBe(BSC); + }); + + it('returns "polygon" for mainnet chain ID', () => { + expect(getNetworkNameByChainId(POLYGON_CHAIN_ID)).toBe(POLYGON); + }); + + it('returns an empty string for an unsupported network', () => { + expect(getNetworkNameByChainId(RINKEBY_CHAIN_ID)).toBe(''); + }); + }); + + describe('getSwapsLivenessForNetwork', () => { + it('returns info that Swaps are enabled and cannot use API v2 for localhost chain ID', () => { + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: false, + }; + expect( + getSwapsLivenessForNetwork( + MOCKS.createFeatureFlagsResponse(), + LOCALHOST_CHAIN_ID, + ), + ).toMatchObject(expectedSwapsLiveness); + }); + + it('returns info that Swaps are disabled and cannot use API v2 if network name is not found', () => { + const expectedSwapsLiveness = { + swapsFeatureIsLive: false, + useNewSwapsApi: false, + }; + expect( + getSwapsLivenessForNetwork( + MOCKS.createFeatureFlagsResponse(), + RINKEBY_CHAIN_ID, + ), + ).toMatchObject(expectedSwapsLiveness); + }); + + it('returns info that Swaps are enabled and can use API v2 for mainnet chain ID', () => { + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: true, + }; + expect( + getSwapsLivenessForNetwork( + MOCKS.createFeatureFlagsResponse(), + MAINNET_CHAIN_ID, + ), + ).toMatchObject(expectedSwapsLiveness); + }); + + it('returns info that Swaps are enabled but can only use API v1 for mainnet chain ID', () => { + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: false, + }; + const swapsFeatureFlags = MOCKS.createFeatureFlagsResponse(); + swapsFeatureFlags[ETHEREUM].extension_active = false; + expect( + getSwapsLivenessForNetwork(swapsFeatureFlags, MAINNET_CHAIN_ID), + ).toMatchObject(expectedSwapsLiveness); + }); + }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 8945de10b..0e3b9fe0d 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -2109,9 +2109,9 @@ export function setPendingTokens(pendingTokens) { // Swaps -export function setSwapsLiveness(swapsFeatureIsLive) { +export function setSwapsLiveness(swapsLiveness) { return async (dispatch) => { - await promisifiedBackground.setSwapsLiveness(swapsFeatureIsLive); + await promisifiedBackground.setSwapsLiveness(swapsLiveness); await forceUpdateMetamaskState(dispatch); }; } From bff17c6873bbe828dd4ded1d297c01100d0cd97c Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Fri, 9 Jul 2021 09:17:02 -0500 Subject: [PATCH 11/12] fix up ens/hex address validation error handling (#11477) * fix up ens/hex address validation error handling * fix lint/tests --- ui/ducks/ens.js | 21 +++++++++++-------- ui/ducks/send/send.js | 4 ++-- ui/ducks/send/send.test.js | 2 +- .../add-recipient/ens-input.component.js | 15 ++++++------- .../add-recipient/ens-input.container.js | 4 ++-- .../add-contact/add-contact.component.js | 4 ++-- .../add-contact/add-contact.container.js | 4 ++-- 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/ui/ducks/ens.js b/ui/ducks/ens.js index de72739e6..d2786f1d7 100644 --- a/ui/ducks/ens.js +++ b/ui/ducks/ens.js @@ -92,18 +92,20 @@ const slice = createSlice({ }, disableEnsLookup: (state) => { state.stage = 'NO_NETWORK_SUPPORT'; - state.error = ENS_NOT_SUPPORTED_ON_NETWORK; + state.error = null; state.warning = null; state.resolution = null; state.network = null; }, - resetResolution: (state) => { + ensNotSupported: (state) => { state.resolution = null; state.warning = null; - state.error = - state.stage === 'NO_NETWORK_SUPPORT' - ? ENS_NOT_SUPPORTED_ON_NETWORK - : null; + state.error = ENS_NOT_SUPPORTED_ON_NETWORK; + }, + resetEnsResolution: (state) => { + state.resolution = null; + state.warning = null; + state.error = null; }, }, extraReducers: (builder) => { @@ -123,9 +125,10 @@ const { disableEnsLookup, ensLookup, enableEnsLookup, - resetResolution, + ensNotSupported, + resetEnsResolution, } = actions; -export { resetResolution }; +export { resetEnsResolution }; export function initializeEnsSlice() { return (dispatch, getState) => { @@ -159,7 +162,7 @@ export function lookupEnsName(ensName) { ) && !isHexString(trimmedEnsName) ) { - await dispatch(resetResolution()); + await dispatch(ensNotSupported()); } else { log.info(`ENS attempting to resolve name: ${trimmedEnsName}`); let address; diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 037f56cdf..122ca2c94 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -75,7 +75,7 @@ import { isValidDomainName, } from '../../helpers/utils/util'; import { getTokens, getUnapprovedTxs } from '../metamask/metamask'; -import { resetResolution } from '../ens'; +import { resetEnsResolution } from '../ens'; import { isBurnAddress, isValidHexAddress, @@ -1218,7 +1218,7 @@ export function resetRecipientInput() { return async (dispatch) => { await dispatch(updateRecipientUserInput('')); await dispatch(updateRecipient({ address: '', nickname: '' })); - await dispatch(resetResolution()); + await dispatch(resetEnsResolution()); await dispatch(validateRecipientUserInput()); }; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 57f84071f..dd182882e 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -1456,7 +1456,7 @@ describe('Send Slice', () => { ); expect(actionResult[0].payload).toStrictEqual(''); expect(actionResult[1].type).toStrictEqual('send/updateRecipient'); - expect(actionResult[2].type).toStrictEqual('ENS/resetResolution'); + expect(actionResult[2].type).toStrictEqual('ENS/resetEnsResolution'); expect(actionResult[3].type).toStrictEqual( 'send/validateRecipientUserInput', ); diff --git a/ui/pages/send/send-content/add-recipient/ens-input.component.js b/ui/pages/send/send-content/add-recipient/ens-input.component.js index bb1c7f3e7..47d641013 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.component.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.component.js @@ -62,18 +62,19 @@ export default class EnsInput extends Component { } // Empty ENS state if input is empty // maybe scan ENS - if (isValidDomainName(input)) { lookupEnsName(input); - } else if ( - onValidAddressTyped && - !isBurnAddress(input) && - isValidHexAddress(input, { mixedCaseUseChecksum: true }) - ) { - onValidAddressTyped(input); } else { resetEnsResolution(); + if ( + onValidAddressTyped && + !isBurnAddress(input) && + isValidHexAddress(input, { mixedCaseUseChecksum: true }) + ) { + onValidAddressTyped(input); + } } + return null; }; diff --git a/ui/pages/send/send-content/add-recipient/ens-input.container.js b/ui/pages/send/send-content/add-recipient/ens-input.container.js index ef61fce85..bf0650b0e 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.container.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.container.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { lookupEnsName, initializeEnsSlice, - resetResolution, + resetEnsResolution, } from '../../../../ducks/ens'; import EnsInput from './ens-input.component'; @@ -11,7 +11,7 @@ function mapDispatchToProps(dispatch) { return { lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150), initializeEnsSlice: () => dispatch(initializeEnsSlice()), - resetEnsResolution: debounce(() => dispatch(resetResolution()), 300), + resetEnsResolution: debounce(() => dispatch(resetEnsResolution()), 300), }; } diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js index 64e076973..6ce7f06f0 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js @@ -27,7 +27,7 @@ export default class AddContact extends PureComponent { qrCodeDetected: PropTypes.func, ensResolution: PropTypes.string, ensError: PropTypes.string, - resetResolution: PropTypes.func, + resetEnsResolution: PropTypes.func, }; state = { @@ -88,7 +88,7 @@ export default class AddContact extends PureComponent { this.validate(text); }} onReset={() => { - this.props.resetResolution(); + this.props.resetEnsResolution(); this.setState({ ethAddress: '', input: '' }); }} userInput={this.state.input} diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 49f4deb70..604b10fee 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js @@ -10,7 +10,7 @@ import { getQrCodeData } from '../../../../ducks/app/app'; import { getEnsError, getEnsResolution, - resetResolution, + resetEnsResolution, } from '../../../../ducks/ens'; import AddContact from './add-contact.component'; @@ -28,7 +28,7 @@ const mapDispatchToProps = (dispatch) => { dispatch(addToAddressBook(recipient, nickname)), scanQrCode: () => dispatch(showQrScanner()), qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), - resetResolution: () => dispatch(resetResolution()), + resetEnsResolution: () => dispatch(resetEnsResolution()), }; }; From 3ab5419dec8cd9d973147b30870d256b53a443ac Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Fri, 9 Jul 2021 15:39:43 -0230 Subject: [PATCH 12/12] =?UTF-8?q?Ensure=20simple=20send=20gas=20estimation?= =?UTF-8?q?=20happens=20after=20the=20recipient=20is=20iden=E2=80=A6=20(#1?= =?UTF-8?q?1485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ensure simple send gas estimation happens after the recipient is identified * Update ui/ducks/send/send.test.js Co-authored-by: Alex Donesky * Improve test name * Lint fix Co-authored-by: Alex Donesky --- ui/ducks/send/send.js | 10 +++--- ui/ducks/send/send.test.js | 68 ++++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 122ca2c94..718d78d93 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -407,7 +407,8 @@ export const initializeSendState = createAsyncThunk( : GAS_LIMITS.SIMPLE; if ( basicEstimateStatus === BASIC_ESTIMATE_STATES.READY && - stage !== SEND_STAGES.EDIT + stage !== SEND_STAGES.EDIT && + recipient.address ) { // Run our estimateGasLimit logic to get a more accurate estimation of // required gas. If this value isn't nullish, set it as the new gasLimit @@ -1201,12 +1202,9 @@ export function useMyAccountsForRecipientSearch() { * @returns {void} */ export function updateRecipient({ address, nickname }) { - return async (dispatch, getState) => { + return async (dispatch) => { await dispatch(actions.updateRecipient({ address, nickname })); - const state = getState(); - if (state.send.asset.type === ASSET_TYPES.TOKEN) { - await dispatch(computeEstimatedGasLimit()); - } + await dispatch(computeEstimatedGasLimit()); }; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index dd182882e..c9f9fe32d 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -1347,12 +1347,36 @@ describe('Send Slice', () => { nickname: '', }; - it('should create an action to update recipient', async () => { + it('should create actions to update recipient and recalculate gas limit if the asset type is not set', async () => { + global.eth = { + getCode: sinon.stub(), + }; + const updateRecipientState = { + metamask: { + provider: { + chainId: '0x1', + }, + }, send: { + account: { + balance: '', + }, asset: { type: '', }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, }, }; @@ -1362,18 +1386,20 @@ describe('Send Slice', () => { const actionResult = store.getActions(); - const expectedActionResult = [ - { - type: 'send/updateRecipient', - payload: recipient, - }, - ]; - - expect(actionResult).toHaveLength(1); - expect(actionResult).toStrictEqual(expectedActionResult); + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); }); - it('should create actions to update recipient and recalculate gas limit if the asset is a token', async () => { + it('should create actions to reset recipient input and ens, calculate gas and then validate input', async () => { const tokenState = { metamask: { blockGasLimit: '', @@ -1442,6 +1468,13 @@ describe('Send Slice', () => { address: 'Address', nickname: 'NickName', }, + gas: { + gasPrice: '0x1', + }, + amount: { + value: '0x1', + }, + draftTransaction: {}, }, }; @@ -1450,14 +1483,23 @@ describe('Send Slice', () => { await store.dispatch(resetRecipientInput()); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(4); + expect(actionResult).toHaveLength(7); expect(actionResult[0].type).toStrictEqual( 'send/updateRecipientUserInput', ); expect(actionResult[0].payload).toStrictEqual(''); expect(actionResult[1].type).toStrictEqual('send/updateRecipient'); - expect(actionResult[2].type).toStrictEqual('ENS/resetEnsResolution'); + expect(actionResult[2].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); expect(actionResult[3].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[4].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + expect(actionResult[5].type).toStrictEqual('ENS/resetEnsResolution'); + expect(actionResult[6].type).toStrictEqual( 'send/validateRecipientUserInput', ); });