diff --git a/.circleci/config.yml b/.circleci/config.yml index 749402c7f..92afe9183 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,10 +3,10 @@ version: 2.1 executors: node-browsers: docker: - - image: circleci/node:14-browsers + - image: circleci/node:16-browsers node-browsers-medium-plus: docker: - - image: circleci/node:14-browsers + - image: circleci/node:16-browsers resource_class: medium+ environment: NODE_OPTIONS: --max_old_space_size=2048 diff --git a/.circleci/scripts/chrome-install.sh b/.circleci/scripts/chrome-install.sh index f97d0486d..dcd122fb8 100755 --- a/.circleci/scripts/chrome-install.sh +++ b/.circleci/scripts/chrome-install.sh @@ -5,12 +5,12 @@ set -u set -o pipefail # To get the latest version, see <https://www.ubuntuupdates.org/ppa/google_chrome?dist=stable> -CHROME_VERSION='102.0.5005.61-1' +CHROME_VERSION='103.0.5060.53-1' CHROME_BINARY="google-chrome-stable_${CHROME_VERSION}_amd64.deb" CHROME_BINARY_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/${CHROME_BINARY}" # To retrieve this checksum, run the `wget` and `shasum` commands below -CHROME_BINARY_SHA512SUM='dd701b99febf7d927657f38716d90f3a0b967ae75dac5f6e8fbf9df632c8a531ccb9f37ee09340ad730b4fe40d0564c1b64201121d2d3e4e503f3f167ca632cd' +CHROME_BINARY_SHA512SUM='36f4e79f46cb71c1431dccf1489f5f8e89d35204a717a4618c7f6f638123ddc2b37bd5cbd00498be8f84c7713149f2faa447cb6da3518be1cb9703e99d110e1a' wget -O "${CHROME_BINARY}" -t 5 "${CHROME_BINARY_URL}" diff --git a/.circleci/scripts/firefox-install.sh b/.circleci/scripts/firefox-install.sh index 0c3512114..f2f9f284d 100755 --- a/.circleci/scripts/firefox-install.sh +++ b/.circleci/scripts/firefox-install.sh @@ -4,7 +4,7 @@ set -e set -u set -o pipefail -FIREFOX_VERSION='83.0' +FIREFOX_VERSION='102.0' FIREFOX_BINARY="firefox-${FIREFOX_VERSION}.tar.bz2" FIREFOX_BINARY_URL="https://ftp.mozilla.org/pub/firefox/releases/${FIREFOX_VERSION}/linux-x86_64/en-US/${FIREFOX_BINARY}" FIREFOX_PATH='/opt/firefox' diff --git a/.metamaskrc.dist b/.metamaskrc.dist index d3bff3b46..8ab56bf00 100644 --- a/.metamaskrc.dist +++ b/.metamaskrc.dist @@ -6,6 +6,7 @@ ONBOARDING_V2= SWAPS_USE_DEV_APIS= COLLECTIBLES_V1= TOKEN_DETECTION_V2= +ADD_POPULAR_NETWORKS= ; Set this to test changes to the phishing warning page. PHISHING_WARNING_PAGE_URL= diff --git a/.nvmrc b/.nvmrc index 958b5a36e..6f7f377bf 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14 +v16 diff --git a/CHANGELOG.md b/CHANGELOG.md index a7895a826..06e058fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.18.0] +### Added +- Add setApprovalForAll confirmation view so granted permissions are displayed in a digested manner, instead of a simple contract interaction([#15010](https://github.com/MetaMask/metamask-extension/pull/15010)) +- Add warning when performing a Send directly to a token contract([#13588](https://github.com/MetaMask/metamask-extension/pull/13588)) + +### Changed +- Update Optimism ChainID from Kovan to Goerli ([#15119](https://github.com/MetaMask/metamask-extension/pull/15119)) + +### Fixed +- Fix one of the possible causes for "Sending to a random cached address", by removing the global transaction state from the Send flow ([#14777](https://github.com/MetaMask/metamask-extension/pull/14777)) +- Fix Chinese translation for the message of Importing repeated tokens ([#14994](https://github.com/MetaMask/metamask-extension/pull/14994)) +- Fix Japanese translation for the word Sign ([#15078](https://github.com/MetaMask/metamask-extension/pull/15078)) +- Fix partially the error "Seedphrase is invalid" by disabling Seedphrase Import button after switching the Seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) +- Fix Edit Transaction flow by ensuring that changing a tx from a Transfer to a Send resets data and updates tx type ([#15248](https://github.com/MetaMask/metamask-extension/pull/15248)) +- Fix UI on Import Seedphrase page by disabling Import button, if any of the characters of the Seedphrase is in uppercase ([#15186](https://github.com/MetaMask/metamask-extension/pull/15186)) + ## [10.17.0] ### Added - Add cost estimation for canceling a Smart Transaction on Awaiting Swap page ([#15011](https://github.com/MetaMask/metamask-extension/pull/15011)) @@ -3068,7 +3084,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/v10.17.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.18.0...HEAD +[10.18.0]: https://github.com/MetaMask/metamask-extension/compare/v10.17.0...v10.18.0 [10.17.0]: https://github.com/MetaMask/metamask-extension/compare/v10.16.2...v10.17.0 [10.16.2]: https://github.com/MetaMask/metamask-extension/compare/v10.16.1...v10.16.2 [10.16.1]: https://github.com/MetaMask/metamask-extension/compare/v10.16.0...v10.16.1 diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0f6747b4b..4e3a7ec46 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -157,6 +157,9 @@ "addMemo": { "message": "Add memo" }, + "addMoreNetworks": { + "message": "add more networks manually" + }, "addNetwork": { "message": "Add Network" }, @@ -227,6 +230,10 @@ "alerts": { "message": "Alerts" }, + "allOfYour": { + "message": "All of your $1", + "description": "$1 is the symbol or name of the token that the user is approving spending" + }, "allowExternalExtensionTo": { "message": "Allow this external extension to:" }, @@ -263,6 +270,10 @@ "approve": { "message": "Approve spend limit" }, + "approveAllTokensTitle": { + "message": "Give permission to access all of your $1?", + "description": "$1 is the symbol of the token for which the user is granting approval" + }, "approveAndInstall": { "message": "Approve & Install" }, @@ -1284,6 +1295,9 @@ "functionApprove": { "message": "Function: Approve" }, + "functionSetApprovalForAll": { + "message": "Function: SetApprovalForAll" + }, "functionType": { "message": "Function Type" }, @@ -1954,6 +1968,9 @@ "network": { "message": "Network:" }, + "networkAddedSuccessfully": { + "message": "Network added successfully!" + }, "networkDetails": { "message": "Network Details" }, @@ -2690,6 +2707,14 @@ "revealTheSeedPhrase": { "message": "Reveal seed phrase" }, + "revokeAllTokensTitle": { + "message": "Revoke permission to access all of your $1?", + "description": "$1 is the symbol of the token for which the user is revoking approval" + }, + "revokeApproveForAllDescription": { + "message": "By revoking permission, the following $1 will no longer be able to access your $2", + "description": "$1 is either key 'account' or 'contract', and $2 is either a string or link of a given token symbol or name" + }, "rinkeby": { "message": "Rinkeby Test Network" }, @@ -2866,12 +2891,23 @@ "message": "Sending $1", "description": "$1 represents the native currency symbol for the current network (e.g. ETH or BNB)" }, + "sendingToTokenContractWarning": { + "message": "Warning: you are about to send to a token contract which could result in a loss of funds. $1", + "description": "$1 is a clickable link with text defined by the 'learnMoreUpperCase' key. The link will open to a support article regarding the known contract address warning" + }, "setAdvancedPrivacySettings": { "message": "Set advanced privacy settings" }, "setAdvancedPrivacySettingsDetails": { "message": "MetaMask uses these trusted third-party services to enhance product usability and safety." }, + "setApprovalForAll": { + "message": "Set Approval for All" + }, + "setApprovalForAllTitle": { + "message": "Approve $1 with no spend limit", + "description": "The token symbol that is being approved" + }, "settings": { "message": "Settings" }, @@ -2891,6 +2927,12 @@ "showAdvancedGasInlineDescription": { "message": "Select this to show gas price and limit controls directly on the send and confirm screens." }, + "showCustomNetworkList": { + "message": "Show Custom Network List" + }, + "showCustomNetworkListDescription": { + "message": "Select this to show a list of networks with prefilled details when adding a new network." + }, "showFiatConversionInTestnets": { "message": "Show Conversion on test networks" }, @@ -3000,6 +3042,9 @@ "snapsToggle": { "message": "A snap will only run if it is enabled" }, + "someNetworksMayPoseSecurity": { + "message": "Some networks may pose security and/or privacy risks. Understand the risks before adding & using a network." + }, "somethingWentWrong": { "message": "Oops! Something went wrong." }, @@ -3562,6 +3607,10 @@ "switchNetworks": { "message": "Switch Networks" }, + "switchToNetwork": { + "message": "Switch to $1", + "description": "$1 represents the custom network that has previously been added" + }, "switchToThisAccount": { "message": "Switch to this account" }, @@ -3865,6 +3914,9 @@ "unknownCameraErrorTitle": { "message": "Ooops! Something went wrong...." }, + "unknownCollection": { + "message": "Unnamed collection" + }, "unknownNetwork": { "message": "Unknown Private Network" }, @@ -4005,6 +4057,9 @@ "walletCreationSuccessTitle": { "message": "Wallet creation successful" }, + "wantToAddThisNetwork": { + "message": "Want to add this network?" + }, "warning": { "message": "Warning" }, @@ -4067,6 +4122,10 @@ "yesLetsTry": { "message": "Yes, let's try" }, + "youHaveAddedAll": { + "message": "You've added all the popular networks. You can discover more networks $1 Or you can $2", + "description": "$1 is a link with the text 'here' and $2 is a button with the text 'add more networks manually'" + }, "youNeedToAllowCameraAccess": { "message": "You need to allow camera access to use this feature." }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index b4a6108af..33386655d 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -4061,7 +4061,7 @@ "message": "この機能を使用するには、カメラへのアクセスを許可する必要があります。" }, "youSign": { - "message": "著名しています" + "message": "署名しています" }, "yourPrivateSeedPhrase": { "message": "秘密のシークレットリカバリーフレーズ" diff --git a/app/_locales/zh/messages.json b/app/_locales/zh/messages.json index 83080c849..f1c97fe44 100644 --- a/app/_locales/zh/messages.json +++ b/app/_locales/zh/messages.json @@ -1646,7 +1646,7 @@ "message": "已知合约地址。" }, "knownTokenWarning": { - "message": "此操作将编辑已经在您的钱包中列出的代币,有肯能被用来欺骗您。只有确定要更改这些代币的内容时,才通过此操作。了解更多关于 $1" + "message": "此操作将编辑已经在您的钱包中列出的代币,有可能被用来欺骗您。只有确定要更改这些代币的内容时,才通过此操作。了解更多关于 $1" }, "kovan": { "message": "Kovan 测试网络" diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index f1bcc95b7..1252d6a14 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1377,7 +1377,7 @@ "message": "已知接收方地址。" }, "knownTokenWarning": { - "message": "此操作将编辑已经在您的钱包中列出的代币,有肯能被用来欺骗您。只有确定要更改这些代币的内容时,才通过此操作。了解更多关于 $1" + "message": "此操作将编辑已经在您的钱包中列出的代币,有可能被用来欺骗您。只有确定要更改这些代币的内容时,才通过此操作。了解更多关于 $1" }, "kovan": { "message": "Kovan 测试网络" diff --git a/app/images/fantom-opera.svg b/app/images/fantom-opera.svg new file mode 100644 index 000000000..02297ee3a --- /dev/null +++ b/app/images/fantom-opera.svg @@ -0,0 +1 @@ +<svg width="1024" height="1024" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle fill="#1969FF" cx="512" cy="512" r="512"/><path d="M480.953 162.82c17.25-9.093 43.496-9.093 60.746 0l176.016 92.795c10.39 5.477 16.095 13.638 17.117 22.063H735V744.11c-.23 9.19-5.988 18.32-17.285 24.275L541.7 861.18c-17.25 9.093-43.497 9.093-60.746 0l-176.017-92.795c-11.249-5.93-16.647-15.123-16.914-24.275a32.372 32.372 0 0 1-.001-2.35V280.82a24 24 0 0 1 0-1.937v-1.204h.08c.781-8.518 6.228-16.47 16.835-22.063l176.017-92.795ZM707 537l-165.355 87.46c-17.225 9.111-43.433 9.111-60.658 0L316 537.195v205.474l164.987 86.802c9.75 5.217 19.888 10.3 29.76 10.521l.569.008c9.852.032 19.418-4.978 29.117-9.72L707 741.92V537ZM260.424 734c0 17.88 2.06 29.633 6.15 37.912 3.389 6.863 8.475 12.107 17.761 18.489l.53.362c2.038 1.387 4.283 2.839 7.016 4.545l3.223 1.992 9.896 6.025L290.806 827l-11.076-6.75-1.862-1.153c-3.202-1.995-5.857-3.707-8.333-5.392-26.467-18.003-36.337-37.63-36.532-78.461L233 734h27.424ZM498 413c-1.28.44-2.481.951-3.575 1.53l-175.748 93.094c-.185.097-.36.194-.528.29L318 508l.276.159.4.217 175.749 93.094c1.094.579 2.294 1.09 3.575 1.53V413Zm28 0v190a25.085 25.085 0 0 0 3.576-1.53l175.747-93.094c.184-.097.36-.194.528-.29L706 508l-.276-.159-.401-.217-175.747-93.094A25.085 25.085 0 0 0 526 413Zm181-102-158 83 158 83V311Zm-391 0v166l158-83-158-83Zm213.422-123.373c-9.147-4.836-25.697-4.836-34.844 0l-175.9 92.997c-.185.098-.362.194-.529.29L318 281l.276.158.401.218 175.9 92.996c9.148 4.837 25.698 4.837 34.845 0l175.9-92.996c.185-.098.361-.194.528-.29L706 281l-.276-.158-.402-.218-175.9-92.997ZM733.194 197l11.076 6.75 1.862 1.152c3.202 1.995 5.857 3.709 8.333 5.393 26.467 18.003 36.337 37.63 36.532 78.461L791 290h-27.424c0-17.882-2.06-29.633-6.15-37.913-3.388-6.862-8.474-12.107-17.76-18.488l-.531-.362a212.559 212.559 0 0 0-7.016-4.545l-3.223-1.992-9.896-6.025L733.194 197Z" fill="#FFF" fill-rule="nonzero"/></g></svg> \ No newline at end of file diff --git a/app/images/harmony-one.svg b/app/images/harmony-one.svg new file mode 100644 index 000000000..e8466d96d --- /dev/null +++ b/app/images/harmony-one.svg @@ -0,0 +1 @@ +<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><defs><linearGradient id="a" x1="71.37" y1="228.63" x2="228.63" y2="71.37" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#00aee9"/><stop offset="1" stop-color="#69fabd"/></linearGradient></defs><path d="M201.17 60a38.81 38.81 0 0 0-38.84 38.71v42.92c-4 .27-8.09.44-12.33.44s-8.31.17-12.33.41V98.71a38.84 38.84 0 0 0-77.67 0v102.58a38.84 38.84 0 0 0 77.67 0v-42.92c4-.27 8.09-.44 12.33-.44s8.31-.17 12.33-.41v43.77a38.84 38.84 0 0 0 77.67 0V98.71A38.81 38.81 0 0 0 201.17 60ZM98.83 75.86a22.91 22.91 0 0 1 22.92 22.85v45.45a130.64 130.64 0 0 0-33 9.33 60 60 0 0 0-12.8 7.64V98.71a22.91 22.91 0 0 1 22.88-22.85Zm22.92 125.43a22.92 22.92 0 0 1-45.84 0V191c0-9.09 7.2-17.7 19.27-23.06a113 113 0 0 1 26.57-7.77Zm79.42 22.85a22.91 22.91 0 0 1-22.92-22.85v-45.45a130.64 130.64 0 0 0 33-9.33 60 60 0 0 0 12.8-7.64v62.42a22.91 22.91 0 0 1-22.88 22.85Zm3.65-92.14a113 113 0 0 1-26.57 7.77V98.71a22.92 22.92 0 0 1 45.84 0V109c0 9.05-7.2 17.66-19.27 23Z" style="fill:url(#a)"/><path style="fill:none" d="M0 0h300v300H0z"/></svg> \ No newline at end of file diff --git a/app/images/info-fox.svg b/app/images/info-fox.svg new file mode 100644 index 000000000..57660c1fe --- /dev/null +++ b/app/images/info-fox.svg @@ -0,0 +1 @@ +<svg width="60" height="45" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="url(#a)" d="M0 0h60v45H0z"/><defs><pattern id="a" patternContentUnits="objectBoundingBox" width="1" height="1"><use xlink:href="#b" transform="matrix(.00101 0 0 .00135 -.001 0)"/></pattern><image id="b" width="990" height="741" xlink:href="data:image/png;base64,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"/></defs></svg> \ No newline at end of file diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 706260b24..9b9c16d11 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -69,6 +69,7 @@ export default class PreferencesController { ? LEDGER_TRANSPORT_TYPES.WEBHID : LEDGER_TRANSPORT_TYPES.U2F, theme: 'light', + customNetworkListEnabled: false, ...opts.initState, }; @@ -179,6 +180,17 @@ export default class PreferencesController { this.store.updateState({ theme: val }); } + /** + * Setter for the `customNetworkListEnabled` property + * + * @param customNetworkListEnabled + */ + setCustomNetworkListEnabled(customNetworkListEnabled) { + this.store.updateState({ + customNetworkListEnabled, + }); + } + /** * Add new methodData to state, to avoid requesting this information again through Infura * diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 0818fc894..87d56c714 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -462,7 +462,10 @@ export default class TransactionController extends EventEmitter { }; // only update what is defined - editableParams.txParams = pickBy(editableParams.txParams); + editableParams.txParams = pickBy( + editableParams.txParams, + (prop) => prop !== undefined, + ); // update transaction type in case it has changes const transactionBeforeEdit = this._getTransaction(txId); diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index 422aaadd7..c8adf4191 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -2274,6 +2274,7 @@ describe('Transaction Controller', function () { }); it('updates editible params when type changes from simple send to token transfer', async function () { + providerResultStub.eth_getCode = '0xab'; // test update gasFees await txController.updateEditableParams('1', { data: diff --git a/app/scripts/lib/ComposableObservableStore.test.js b/app/scripts/lib/ComposableObservableStore.test.js index 0beeacdfb..9d03eb98d 100644 --- a/app/scripts/lib/ComposableObservableStore.test.js +++ b/app/scripts/lib/ComposableObservableStore.test.js @@ -165,7 +165,7 @@ describe('ComposableObservableStore', () => { Example: exampleController, }, }), - ).toThrow(`Cannot read property 'subscribe' of undefined`); + ).toThrow(`Cannot read properties of undefined (reading 'subscribe')`); }); it('should throw if the controller messenger is omitted and updateStructure called with a BaseControllerV2 controller', () => { @@ -175,7 +175,7 @@ describe('ComposableObservableStore', () => { }); const store = new ComposableObservableStore({}); expect(() => store.updateStructure({ Example: exampleController })).toThrow( - `Cannot read property 'subscribe' of undefined`, + `Cannot read properties of undefined (reading 'subscribe')`, ); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d8f79b6a7..d01d277e7 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1572,7 +1572,8 @@ export default class MetamaskController extends EventEmitter { setCustomRpc: this.setCustomRpc.bind(this), updateAndSetCustomRpc: this.updateAndSetCustomRpc.bind(this), delCustomRpc: this.delCustomRpc.bind(this), - + addCustomNetwork: this.addCustomNetwork.bind(this), + requestUserApproval: this.requestUserApproval.bind(this), // PreferencesController setSelectedAddress: preferencesController.setSelectedAddress.bind( preferencesController, @@ -1609,7 +1610,9 @@ export default class MetamaskController extends EventEmitter { preferencesController, ), setTheme: preferencesController.setTheme.bind(preferencesController), - + setCustomNetworkListEnabled: preferencesController.setCustomNetworkListEnabled.bind( + preferencesController, + ), // AssetsContractController getTokenStandardAndDetails: this.getTokenStandardAndDetails.bind(this), @@ -2026,6 +2029,43 @@ export default class MetamaskController extends EventEmitter { } } + async requestUserApproval(customRpc, originIsMetaMask) { + try { + await this.approvalController.addAndShowApprovalRequest({ + origin: 'metamask', + type: 'wallet_addEthereumChain', + requestData: { + chainId: customRpc.chainId, + blockExplorerUrl: customRpc.rpcPrefs.blockExplorerUrl, + chainName: customRpc.nickname, + rpcUrl: customRpc.rpcUrl, + ticker: customRpc.ticker, + imageUrl: customRpc.rpcPrefs.imageUrl, + }, + }); + } catch (error) { + if ( + !(originIsMetaMask && error.message === 'User rejected the request.') + ) { + throw error; + } + } + } + + async addCustomNetwork(customRpc) { + const { chainId, chainName, rpcUrl, ticker, blockExplorerUrl } = customRpc; + + await this.preferencesController.addToFrequentRpcList( + rpcUrl, + chainId, + ticker, + chainName, + { + blockExplorerUrl, + }, + ); + } + /** * Create a new Vault and restore an existent keyring. * diff --git a/development/build/scripts.js b/development/build/scripts.js index 70db2a694..95833b6ed 100644 --- a/development/build/scripts.js +++ b/development/build/scripts.js @@ -36,6 +36,7 @@ const metamaskrc = require('rc')('metamask', { COLLECTIBLES_V1: process.env.COLLECTIBLES_V1, PHISHING_WARNING_PAGE_URL: process.env.PHISHING_WARNING_PAGE_URL, TOKEN_DETECTION_V2: process.env.TOKEN_DETECTION_V2, + ADD_POPULAR_NETWORKS: process.env.ADD_POPULAR_NETWORKS, SEGMENT_HOST: process.env.SEGMENT_HOST, SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY, SEGMENT_BETA_WRITE_KEY: process.env.SEGMENT_BETA_WRITE_KEY, @@ -940,6 +941,7 @@ function getEnvironmentVariables({ buildType, devMode, testing, version }) { ONBOARDING_V2: metamaskrc.ONBOARDING_V2 === '1', COLLECTIBLES_V1: metamaskrc.COLLECTIBLES_V1 === '1', TOKEN_DETECTION_V2: metamaskrc.TOKEN_DETECTION_V2 === '1', + ADD_POPULAR_NETWORKS: metamaskrc.ADD_POPULAR_NETWORKS === '1', }; } diff --git a/development/build/transforms/utils.test.js b/development/build/transforms/utils.test.js index ba273a15b..303b954d3 100644 --- a/development/build/transforms/utils.test.js +++ b/development/build/transforms/utils.test.js @@ -26,7 +26,7 @@ describe('transform utils', () => { // This error is an artifact of how we're mocking the ESLint singleton, // and won't actually occur in production. await expect(() => lintTransformedFile()).rejects.toThrow( - `Cannot read property '0' of undefined`, + `Cannot read properties of undefined (reading '0')`, ); expect(mockESLint).toBeDefined(); }); diff --git a/package.json b/package.json index 318365230..828853ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "10.17.0", + "version": "10.18.0", "private": true, "repository": { "type": "git", @@ -282,7 +282,7 @@ "browser-util-inspect": "^0.2.0", "browserify": "^16.5.1", "chalk": "^3.0.0", - "chromedriver": "^102.0.0", + "chromedriver": "^103.0.0", "concurrently": "^5.2.0", "copy-webpack-plugin": "^6.0.3", "cross-spawn": "^7.0.3", @@ -381,7 +381,7 @@ "yarn-deduplicate": "^3.1.0" }, "engines": { - "node": "^14.15.1", + "node": "^16.0.0", "yarn": "^1.16.0" }, "lavamoat": { diff --git a/shared/constants/network.js b/shared/constants/network.js index 11f7803a6..ba43e13de 100644 --- a/shared/constants/network.js +++ b/shared/constants/network.js @@ -23,11 +23,14 @@ 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'; +export const OPTIMISM_TESTNET_CHAIN_ID = '0x1a4'; export const POLYGON_CHAIN_ID = '0x89'; export const AVALANCHE_CHAIN_ID = '0xa86a'; export const FANTOM_CHAIN_ID = '0xfa'; export const CELO_CHAIN_ID = '0xa4ec'; +export const ARBITRUM_CHAIN_ID = '0xa4b1'; +export const HARMONY_CHAIN_ID = '0x63564c40'; +export const PALM_CHAIN_ID = '0x2a15c308d'; /** * The largest possible chain ID we can handle. @@ -43,7 +46,14 @@ export const GOERLI_DISPLAY_NAME = 'Goerli'; export const LOCALHOST_DISPLAY_NAME = 'Localhost 8545'; export const BSC_DISPLAY_NAME = 'Binance Smart Chain'; export const POLYGON_DISPLAY_NAME = 'Polygon'; -export const AVALANCHE_DISPLAY_NAME = 'Avalanche'; +export const AVALANCHE_DISPLAY_NAME = 'Avalanche Network C-Chain'; +export const ARBITRUM_DISPLAY_NAME = 'Arbitrum One'; +export const BNB_DISPLAY_NAME = + 'BNB Smart Chain (previously Binance Smart Chain Mainnet)'; +export const OPTIMISM_DISPLAY_NAME = 'Optimism'; +export const FANTOM_DISPLAY_NAME = 'Fantom Opera'; +export const HARMONY_DISPLAY_NAME = 'Harmony Mainnet Shard 0'; +export const PALM_DISPLAY_NAME = 'Palm'; const infuraProjectId = process.env.INFURA_PROJECT_ID; export const getRpcUrl = ({ network, excludeProjectId = false }) => @@ -64,12 +74,20 @@ export const MATIC_SYMBOL = 'MATIC'; export const AVALANCHE_SYMBOL = 'AVAX'; export const FANTOM_SYMBOL = 'FTM'; export const CELO_SYMBOL = 'CELO'; +export const ARBITRUM_SYMBOL = 'AETH'; +export const HARMONY_SYMBOL = 'ONE'; +export const PALM_SYMBOL = 'PALM'; export const ETH_TOKEN_IMAGE_URL = './images/eth_logo.svg'; export const TEST_ETH_TOKEN_IMAGE_URL = './images/black-eth-logo.svg'; export const BNB_TOKEN_IMAGE_URL = './images/bnb.png'; export const MATIC_TOKEN_IMAGE_URL = './images/matic-token.png'; export const AVAX_TOKEN_IMAGE_URL = './images/avax-token.png'; +export const AETH_TOKEN_IMAGE_URL = './images/arbitrum.svg'; +export const FTM_TOKEN_IMAGE_URL = './images/fantom-opera.svg'; +export const HARMONY_ONE_TOKEN_IMAGE_URL = './images/harmony-one.svg'; +export const OPTIMISM_TOKEN_IMAGE_URL = './images/optimism.svg'; +export const PALM_TOKEN_IMAGE_URL = './images/palm.svg'; export const INFURA_PROVIDER_TYPES = [ROPSTEN, RINKEBY, KOVAN, MAINNET, GOERLI]; @@ -166,6 +184,12 @@ export const CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP = { [AVALANCHE_CHAIN_ID]: AVAX_TOKEN_IMAGE_URL, [BSC_CHAIN_ID]: BNB_TOKEN_IMAGE_URL, [POLYGON_CHAIN_ID]: MATIC_TOKEN_IMAGE_URL, + [ARBITRUM_CHAIN_ID]: AETH_TOKEN_IMAGE_URL, + [BSC_CHAIN_ID]: BNB_TOKEN_IMAGE_URL, + [FANTOM_CHAIN_ID]: FTM_TOKEN_IMAGE_URL, + [HARMONY_CHAIN_ID]: HARMONY_ONE_TOKEN_IMAGE_URL, + [OPTIMISM_CHAIN_ID]: OPTIMISM_TOKEN_IMAGE_URL, + [PALM_CHAIN_ID]: PALM_TOKEN_IMAGE_URL, }; export const CHAIN_ID_TO_NETWORK_ID_MAP = Object.values( @@ -309,3 +333,86 @@ export const BUYABLE_CHAINS_MAP = { }, }, }; + +export const FEATURED_RPCS = [ + { + chainId: ARBITRUM_CHAIN_ID, + nickname: ARBITRUM_DISPLAY_NAME, + rpcUrl: `https://arbitrum-mainnet.infura.io/v3/${infuraProjectId}`, + ticker: ARBITRUM_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://explorer.arbitrum.io', + imageUrl: AETH_TOKEN_IMAGE_URL, + }, + }, + { + chainId: AVALANCHE_CHAIN_ID, + nickname: AVALANCHE_DISPLAY_NAME, + rpcUrl: 'https://api.avax.network/ext/bc/C/rpc', + ticker: AVALANCHE_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://snowtrace.io/', + imageUrl: AVAX_TOKEN_IMAGE_URL, + }, + }, + { + chainId: BSC_CHAIN_ID, + nickname: BNB_DISPLAY_NAME, + rpcUrl: 'https://bsc-dataseed.binance.org/', + ticker: BNB_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://bscscan.com/', + imageUrl: BNB_TOKEN_IMAGE_URL, + }, + }, + { + chainId: FANTOM_CHAIN_ID, + nickname: FANTOM_DISPLAY_NAME, + rpcUrl: 'https://rpc.ftm.tools/', + ticker: FANTOM_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://ftmscan.com/', + imageUrl: FTM_TOKEN_IMAGE_URL, + }, + }, + { + chainId: HARMONY_CHAIN_ID, + nickname: HARMONY_DISPLAY_NAME, + rpcUrl: 'https://api.harmony.one/', + ticker: HARMONY_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://explorer.harmony.one/', + imageUrl: HARMONY_ONE_TOKEN_IMAGE_URL, + }, + }, + { + chainId: OPTIMISM_CHAIN_ID, + nickname: OPTIMISM_DISPLAY_NAME, + rpcUrl: `https://optimism-mainnet.infura.io/v3/${infuraProjectId}`, + ticker: ETH_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://optimistic.etherscan.io/', + imageUrl: OPTIMISM_TOKEN_IMAGE_URL, + }, + }, + { + chainId: PALM_CHAIN_ID, + nickname: PALM_DISPLAY_NAME, + rpcUrl: `https://palm-mainnet.infura.io/v3/${infuraProjectId}`, + ticker: PALM_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://explorer.palm.io/', + imageUrl: PALM_TOKEN_IMAGE_URL, + }, + }, + { + chainId: POLYGON_CHAIN_ID, + nickname: `${POLYGON_DISPLAY_NAME} ${capitalize(MAINNET)}`, + rpcUrl: `https://polygon-mainnet.infura.io/v3/${infuraProjectId}`, + ticker: MATIC_SYMBOL, + rpcPrefs: { + blockExplorerUrl: 'https://polygonscan.com/', + imageUrl: MATIC_TOKEN_IMAGE_URL, + }, + }, +]; diff --git a/shared/constants/tokens.js b/shared/constants/tokens.js index c4e52317a..2a878f3df 100644 --- a/shared/constants/tokens.js +++ b/shared/constants/tokens.js @@ -8,3 +8,16 @@ import contractMap from '@metamask/contract-metadata'; export const LISTED_CONTRACT_ADDRESSES = Object.keys( contractMap, ).map((address) => address.toLowerCase()); + +/** + * @typedef {Object} TokenDetails + * @property {string} address - The address of the selected 'TOKEN' or + * 'COLLECTIBLE' contract. + * @property {string} [symbol] - The symbol of the token. + * @property {number} [decimals] - The number of decimals of the selected + * 'ERC20' asset. + * @property {number} [tokenId] - The id of the selected 'COLLECTIBLE' asset. + * @property {TokenStandardStrings} [standard] - The standard of the selected + * asset. + * @property {boolean} [isERC721] - True when the asset is a ERC721 token. + */ diff --git a/shared/constants/transaction.js b/shared/constants/transaction.js index 8cb365f79..d2a54ae35 100644 --- a/shared/constants/transaction.js +++ b/shared/constants/transaction.js @@ -15,6 +15,8 @@ import { MESSAGE_TYPE } from './app'; * to ensure that the receiver is an address capable of handling with the token being sent. * @property {'approve'} TOKEN_METHOD_APPROVE - A token transaction requesting an * allowance of the token to spend on behalf of the user + * @property {'setapprovalforall'} TOKEN_METHOD_SET_APPROVAL_FOR_ALL - A token transaction requesting an + * allowance of all of a user's token to spend on behalf of the user * @property {'incoming'} INCOMING - An incoming (deposit) transaction * @property {'simpleSend'} SIMPLE_SEND - A transaction sending a network's native asset to a recipient * @property {'contractInteraction'} CONTRACT_INTERACTION - A transaction that is @@ -66,6 +68,7 @@ export const TRANSACTION_TYPES = { TOKEN_METHOD_SAFE_TRANSFER_FROM: 'safetransferfrom', TOKEN_METHOD_TRANSFER: 'transfer', TOKEN_METHOD_TRANSFER_FROM: 'transferfrom', + TOKEN_METHOD_SET_APPROVAL_FOR_ALL: 'setapprovalforall', }; /** diff --git a/shared/modules/transaction.utils.js b/shared/modules/transaction.utils.js index 08adb46e3..688548b8e 100644 --- a/shared/modules/transaction.utils.js +++ b/shared/modules/transaction.utils.js @@ -8,7 +8,7 @@ import { readAddressAsContract } from './contract-utils'; import { isEqualCaseInsensitive } from './string-utils'; /** - * @typedef { 'transfer' | 'approve' | 'transferfrom' | 'contractInteraction'| 'simpleSend' } InferrableTransactionTypes + * @typedef { 'transfer' | 'approve' | 'setapprovalforall' | 'transferfrom' | 'contractInteraction'| 'simpleSend' } InferrableTransactionTypes */ /** @@ -148,32 +148,35 @@ export async function determineTransactionType(txParams, query) { log.debug('Failed to parse transaction data.', error, data); } - const tokenMethodName = [ - TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, - TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, - TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, - TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, - ].find((methodName) => isEqualCaseInsensitive(methodName, name)); - let result; - if (data && tokenMethodName) { - result = tokenMethodName; - } else if (data && !to) { - result = TRANSACTION_TYPES.DEPLOY_CONTRACT; - } - let contractCode; - if (!result) { + if (data && !to) { + result = TRANSACTION_TYPES.DEPLOY_CONTRACT; + } else { const { contractCode: resultCode, isContractAddress, } = await readAddressAsContract(query, to); contractCode = resultCode; - result = isContractAddress - ? TRANSACTION_TYPES.CONTRACT_INTERACTION - : TRANSACTION_TYPES.SIMPLE_SEND; + + if (isContractAddress) { + const tokenMethodName = [ + TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, + TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, + TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, + TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, + ].find((methodName) => isEqualCaseInsensitive(methodName, name)); + + result = + data && tokenMethodName + ? tokenMethodName + : TRANSACTION_TYPES.CONTRACT_INTERACTION; + } else { + result = TRANSACTION_TYPES.SIMPLE_SEND; + } } return { type: result, getCodeResponse: contractCode }; @@ -181,6 +184,7 @@ export async function determineTransactionType(txParams, query) { const INFERRABLE_TRANSACTION_TYPES = [ TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.CONTRACT_INTERACTION, @@ -220,6 +224,7 @@ export async function determineTransactionAssetType( // method to get the asset type. const isTokenMethod = [ TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, ].find((methodName) => methodName === inferrableType); diff --git a/shared/modules/transaction.utils.test.js b/shared/modules/transaction.utils.test.js index fba8c7827..6998022e4 100644 --- a/shared/modules/transaction.utils.test.js +++ b/shared/modules/transaction.utils.test.js @@ -111,13 +111,23 @@ describe('Transaction.utils', function () { const genericProvider = createTestProviderTools().provider; const query = new EthQuery(genericProvider); - it('should return a simple send type when to is truthy but data is falsy', async function () { + it('should return a simple send type when to is truthy and is not a contract address', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { to: '0xabc', data: '', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.SIMPLE_SEND, @@ -125,33 +135,78 @@ describe('Transaction.utils', function () { }); }); - it('should return a token transfer type when data is for the respective method call', async function () { + it('should return a token transfer type when the recipient is a contract and data is for the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0xab', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, - getCodeResponse: undefined, + getCodeResponse: '0xab', }); }); - it('should return a token approve type when data is for the respective method call', async function () { + it('should NOT return a token transfer type when the recipient is not a contract but the data matches the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', + data: + '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', + }, + new EthQuery(_provider), + ); + expect(result).toMatchObject({ + type: TRANSACTION_TYPES.SIMPLE_SEND, + getCodeResponse: '0x', + }); + }); + + it('should return a token approve type when when the recipient is a contract and data is for the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0xab', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + + const result = await determineTransactionType( + { + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0x095ea7b30000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C9700000000000000000000000000000000000000000000000000000000000000005', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, - getCodeResponse: undefined, + getCodeResponse: '0xab', }); }); @@ -184,12 +239,22 @@ describe('Transaction.utils', function () { }); it('should return a simple send type with a null getCodeResponse when to is truthy and there is data and but getCode returns an error', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: null, + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0xabd', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.SIMPLE_SEND, diff --git a/test/e2e/fixtures/special-settings/state.json b/test/e2e/fixtures/special-settings/state.json new file mode 100644 index 000000000..6163d7621 --- /dev/null +++ b/test/e2e/fixtures/special-settings/state.json @@ -0,0 +1,146 @@ +{ + "data": { + "AppStateController": { + "mkrMigrationReminderTimestamp": null + }, + "CachedBalancesController": { + "cachedBalances": { + "4": {} + } + }, + "CurrencyController": { + "conversionDate": 1575697244.188, + "conversionRate": 149.61, + "currentCurrency": "usd", + "nativeCurrency": "ETH" + }, + "IncomingTransactionsController": { + "incomingTransactions": {}, + "incomingTxLastFetchedBlocksByNetwork": { + "goerli": null, + "kovan": null, + "mainnet": null, + "rinkeby": 5570536 + } + }, + "KeyringController": { + "vault": "{\"data\":\"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT\",\"iv\":\"FbeHDAW5afeWNORfNJBR0Q==\",\"salt\":\"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8=\"}" + }, + "NetworkController": { + "network": "1337", + "provider": { + "nickname": "Localhost 8545", + "rpcUrl": "http://localhost:8545", + "chainId": "0x539", + "ticker": "ETH", + "type": "rpc" + } + }, + "NotificationController": { + "notifications": { + "1": { + "isShown": true + }, + "3": { + "isShown": true + }, + "5": { + "isShown": true + }, + "6": { + "isShown": true + }, + "8": { + "isShown": true + }, + "12": { + "isShown": true + } + } + }, + "OnboardingController": { + "onboardingTabs": {}, + "seedPhraseBackedUp": false + }, + "PermissionsMetadata": { + "domainMetadata": { + "metamask.github.io": { + "icon": null, + "name": "M E T A M A S K M E S H T E S T" + } + }, + "permissionsHistory": {}, + "permissionsLog": [ + { + "id": 746677923, + "method": "eth_accounts", + "methodType": "restricted", + "origin": "metamask.github.io", + "request": { + "id": 746677923, + "jsonrpc": "2.0", + "method": "eth_accounts", + "origin": "metamask.github.io", + "params": [] + }, + "requestTime": 1575697241368, + "response": { + "id": 746677923, + "jsonrpc": "2.0", + "result": [] + }, + "responseTime": 1575697241370, + "success": true + } + ] + }, + "PreferencesController": { + "accountTokens": { + "0x5cfe73b6021e818b776b421b1c4db2474086a7e1": { + "rinkeby": [], + "ropsten": [] + } + }, + "assetImages": {}, + "completedOnboarding": true, + "dismissSeedBackUpReminder": true, + "currentLocale": "en", + "featureFlags": { + "showIncomingTransactions": true, + "transactionTime": false, + "sendHexData": true + }, + "firstTimeFlowType": "create", + "forgottenPassword": false, + "frequentRpcListDetail": [], + "identities": { + "0x5cfe73b6021e818b776b421b1c4db2474086a7e1": { + "address": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1", + "name": "Account 1" + } + }, + "knownMethodData": {}, + "lostIdentities": {}, + "metaMetricsId": null, + "participateInMetaMetrics": false, + "preferences": { + "useNativeCurrencyAsPrimaryCurrency": true + }, + "selectedAddress": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1", + "suggestedTokens": {}, + "tokens": [], + "useBlockie": false, + "useNonceField": false, + "usePhishDetect": true, + "useTokenDetection": true + }, + "config": {}, + "firstTimeInfo": { + "date": 1575697234195, + "version": "7.7.0" + } + }, + "meta": { + "version": 40 + } +} diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index a6d5d6518..99f5c6e57 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -25,6 +25,28 @@ async function setupMocking(server, testSpecificMock) { }; }); + await server + .forGet('https://www.4byte.directory/api/v1/signatures/') + .thenCallback(() => { + return { + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 1, + created_at: null, + text_signature: 'deposit()', + hex_signature: null, + bytes_signature: null, + }, + ], + }, + }; + }); + await server .forGet('https://gas-api.metaswap.codefi.network/networks/1/gasPrices') .thenCallback(() => { diff --git a/test/e2e/snaps/enums.js b/test/e2e/snaps/enums.js index 086ebdd7d..5787056e8 100644 --- a/test/e2e/snaps/enums.js +++ b/test/e2e/snaps/enums.js @@ -1,3 +1,3 @@ module.exports = { - TEST_SNAPS_WEBSITE_URL: 'https://metamask.github.io/test-snaps/1.0.0', + TEST_SNAPS_WEBSITE_URL: 'https://metamask.github.io/test-snaps/2.0.0', }; diff --git a/test/e2e/snaps/test-snap-bip-44.spec.js b/test/e2e/snaps/test-snap-bip-44.spec.js index e1529fd28..763d6a3a7 100644 --- a/test/e2e/snaps/test-snap-bip-44.spec.js +++ b/test/e2e/snaps/test-snap-bip-44.spec.js @@ -31,11 +31,16 @@ describe('Test Snap bip-44', function () { // navigate to test snaps page and connect await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - await driver.fill('.snapId3', 'npm:@metamask/test-snap-bip44'); - await driver.clickElement({ - text: 'Connect BIP-44 Snap', - tag: 'button', - }); + await driver.delay(1000); + await driver.fill('#snapId3', 'npm:@metamask/test-snap-bip44'); + + // reveal snapId3 by finding and scrolling to #snapId4 + const snapButton = await driver.findElement('#snapId4'); + await driver.scrollToElement(snapButton); + await driver.delay(500); + + // connect the snap + await driver.clickElement('#connectBip44'); // switch to metamask extension and click connect await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -77,14 +82,11 @@ describe('Test Snap bip-44', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Send Test to BIP-44 Snap', - tag: 'button', - }); + await driver.clickElement('#sendBip44'); // check the results of the public key test await driver.delay(2000); - const bip44Result = await driver.findElement('.bip44Result'); + const bip44Result = await driver.findElement('#bip44Result'); assert.equal( await bip44Result.getText(), 'Public key: "0x86debb44fb3a984d93f326131d4c1db0bc39644f1a67b673b3ab45941a1cea6a385981755185ac4594b6521e4d1e8d1"', diff --git a/test/e2e/snaps/test-snap-confirm.spec.js b/test/e2e/snaps/test-snap-confirm.spec.js index ba5212e85..49caa9e89 100644 --- a/test/e2e/snaps/test-snap-confirm.spec.js +++ b/test/e2e/snaps/test-snap-confirm.spec.js @@ -31,11 +31,8 @@ describe('Test Snap Confirm', function () { // navigate to test snaps page and connect await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - await driver.fill('.snapId1', 'npm:@metamask/test-snap-confirm'); - await driver.clickElement({ - text: 'Connect To Confirm Snap', - tag: 'button', - }); + await driver.fill('#snapId1', 'npm:@metamask/test-snap-confirm'); + await driver.clickElement('#connectHello'); // switch to metamask extension and click connect await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -70,7 +67,7 @@ describe('Test Snap Confirm', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement('.sendConfirmButton'); + await driver.clickElement('#sendConfirmButton'); // hit 'approve' on the custom confirm await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -88,7 +85,7 @@ describe('Test Snap Confirm', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - const confirmResult = await driver.findElement('.confirmResult'); + const confirmResult = await driver.findElement('#confirmResult'); assert.equal(await confirmResult.getText(), 'true'); }, ); diff --git a/test/e2e/snaps/test-snap-error.spec.js b/test/e2e/snaps/test-snap-error.spec.js index e8fcb49c1..734520600 100644 --- a/test/e2e/snaps/test-snap-error.spec.js +++ b/test/e2e/snaps/test-snap-error.spec.js @@ -30,11 +30,8 @@ describe('Test Snap Error', function () { // navigate to test snaps page and connect await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - await driver.fill('.snapId2', 'npm:@metamask/test-snap-error'); - await driver.clickElement({ - text: 'Connect Error Snap', - tag: 'button', - }); + await driver.fill('#snapId2', 'npm:@metamask/test-snap-error'); + await driver.clickElement('#connectError'); // switch to metamask extension and click connect await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -68,10 +65,7 @@ describe('Test Snap Error', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Send Test to Error Snap', - tag: 'button', - }); + await driver.clickElement('#sendError'); await driver.navigate(PAGES.HOME); diff --git a/test/e2e/snaps/test-snap-managestate.spec.js b/test/e2e/snaps/test-snap-managestate.spec.js index fbf2ec76c..f22e2fce1 100644 --- a/test/e2e/snaps/test-snap-managestate.spec.js +++ b/test/e2e/snaps/test-snap-managestate.spec.js @@ -13,6 +13,7 @@ describe('Test Snap manageState', function () { }, ], }; + await withFixtures( { fixtures: 'imported-account', @@ -29,13 +30,18 @@ describe('Test Snap manageState', function () { await driver.fill('#password', 'correct horse battery staple'); await driver.press('#password', driver.Key.ENTER); - // navigate to test snaps page and connect + // navigate to test snaps page, then fill in the snapId await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - await driver.fill('.snapId3', 'npm:@metamask/test-snap-managestate'); - await driver.clickElement({ - text: 'Connect manageState Snap', - tag: 'button', - }); + await driver.delay(1000); + await driver.fill('#snapId4', 'npm:@metamask/test-snap-managestate'); + + // find and scroll to the rest of the card + const snapButton = await driver.findElement('#snapId4'); + await driver.scrollToElement(snapButton); + await driver.delay(500); + + // connect the snap + await driver.clickElement('#connectManageState'); // switch to metamask extension and click connect await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -51,7 +57,6 @@ describe('Test Snap manageState', function () { }, 10000, ); - await driver.delay(2000); // approve install of snap @@ -70,32 +75,23 @@ describe('Test Snap manageState', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.fill('.dataManageState', '23'); - await driver.clickElement({ - text: 'Send data to manageState Snap', - tag: 'button', - }); + await driver.fill('#dataManageState', '23'); + await driver.clickElement('#sendManageState'); // check the results of the public key test - await driver.delay(2000); + await driver.delay(500); const manageStateResult = await driver.findElement( - '.sendManageStateResult', + '#sendManageStateResult', ); assert.equal(await manageStateResult.getText(), 'true'); // click get results - await driver.waitUntilXWindowHandles(1, 5000, 10000); - windowHandles = await driver.getAllWindowHandles(); - await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Get data from manageState Snap', - tag: 'button', - }); + await driver.clickElement('#retrieveManageState'); // check the results - await driver.delay(2000); + await driver.delay(500); const retrieveManageStateResult = await driver.findElement( - '.retrieveManageStateResult', + '#retrieveManageStateResult', ); assert.equal( await retrieveManageStateResult.getText(), @@ -103,34 +99,22 @@ describe('Test Snap manageState', function () { ); // click clear results - await driver.waitUntilXWindowHandles(1, 5000, 10000); - windowHandles = await driver.getAllWindowHandles(); - await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Clear data of manageState Snap', - tag: 'button', - }); + await driver.clickElement('#clearManageState'); // check if true - await driver.delay(2000); + await driver.delay(500); const clearManageStateResult = await driver.findElement( - '.clearManageStateResult', + '#clearManageStateResult', ); assert.equal(await clearManageStateResult.getText(), 'true'); // click get results again - await driver.waitUntilXWindowHandles(1, 5000, 10000); - windowHandles = await driver.getAllWindowHandles(); - await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Get data from manageState Snap', - tag: 'button', - }); + await driver.clickElement('#retrieveManageState'); // check result array is empty - await driver.delay(2000); + await driver.delay(500); const retrieveManageStateResult2 = await driver.findElement( - '.retrieveManageStateResult', + '#retrieveManageStateResult', ); assert.equal( await retrieveManageStateResult2.getText(), diff --git a/test/e2e/snaps/test-snap-notification.spec.js b/test/e2e/snaps/test-snap-notification.spec.js index 01359cf84..85ab18267 100644 --- a/test/e2e/snaps/test-snap-notification.spec.js +++ b/test/e2e/snaps/test-snap-notification.spec.js @@ -30,13 +30,18 @@ describe('Test Snap Notification', function () { await driver.fill('#password', 'correct horse battery staple'); await driver.press('#password', driver.Key.ENTER); - // navigate to test snaps page and connect + // navigate to test snaps page await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - await driver.fill('.snapId5', 'npm:@metamask/test-snap-notification'); - await driver.clickElement({ - text: 'Connect Notification Snap', - tag: 'button', - }); + await driver.delay(1000); + + // find and scroll down to snapId5 + const snapButton = await driver.findElement('#snapId5'); + await driver.scrollToElement(snapButton); + await driver.delay(500); + await driver.fill('#snapId5', 'npm:@metamask/test-snap-notification'); + + // connect the snap + await driver.clickElement('#connectNotification'); // switch to metamask extension and click connect await driver.waitUntilXWindowHandles(2, 5000, 10000); @@ -70,10 +75,7 @@ describe('Test Snap Notification', function () { await driver.waitUntilXWindowHandles(1, 5000, 10000); windowHandles = await driver.getAllWindowHandles(); await driver.switchToWindowWithTitle('Test Snaps', windowHandles); - await driver.clickElement({ - text: 'Send InApp Notification', - tag: 'button', - }); + await driver.clickElement('#sendInAppNotification'); // try to go to the MM pages await driver.navigate(PAGES.HOME); diff --git a/test/e2e/tests/contract-interactions.spec.js b/test/e2e/tests/contract-interactions.spec.js index db70f0845..ad0ce7fd6 100644 --- a/test/e2e/tests/contract-interactions.spec.js +++ b/test/e2e/tests/contract-interactions.spec.js @@ -103,7 +103,7 @@ describe('Deploy contract and call contract methods', function () { ); await driver.waitForSelector({ css: '.confirm-page-container-summary__action__name', - text: 'Withdraw', + text: 'Deposit', }); await driver.clickElement({ text: 'Confirm', tag: 'button' }); await driver.waitUntilXWindowHandles(2); diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js index e34ab5731..f14bb1bc5 100644 --- a/test/e2e/tests/send-eth.spec.js +++ b/test/e2e/tests/send-eth.spec.js @@ -92,6 +92,54 @@ describe('Send ETH from inside MetaMask using default gas', function () { }); }); +describe('Send ETH non-contract address with data that matches ERC20 transfer data signature', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('renders the correct recipient on the confirmation screen', async function () { + await withFixtures( + { + fixtures: 'special-settings', + 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"]', + '0xc427D562164062a23a5cFf596A4a3208e72Acd28', + ); + + await driver.fill( + 'textarea[placeholder="Optional', + '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', + ); + + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: '0xc42...cd28' }); + + const recipientAddress = await driver.findElements({ + text: '0xc427D562164062a23a5cFf596A4a3208e72Acd28', + }); + + assert.equal(recipientAddress.length, 1); + }, + ); + }); +}); + /* eslint-disable-next-line mocha/max-top-level-suites */ describe('Send ETH from inside MetaMask using advanced gas modal', function () { const ganacheOptions = { diff --git a/test/e2e/tests/send-hex-address.spec.js b/test/e2e/tests/send-hex-address.spec.js new file mode 100644 index 000000000..ed89901db --- /dev/null +++ b/test/e2e/tests/send-hex-address.spec.js @@ -0,0 +1,329 @@ +const { strict: assert } = require('assert'); +const { convertToHexValue, withFixtures } = require('../helpers'); + +const hexPrefixedAddress = '0x2f318C334780961FB129D2a6c30D0763d9a5C970'; +const nonHexPrefixedAddress = hexPrefixedAddress.substring(2); + +describe('Send ETH to a 40 character hexadecimal address', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('should ensure the address is prefixed with 0x when pasted and should send ETH to a valid hexadecimal address', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Send ETH + await driver.clickElement('[data-testid="eth-overview-send"]'); + + // Paste address without hex prefix + await driver.pasteIntoField( + 'input[placeholder="Search, public address (0x), or ENS"]', + nonHexPrefixedAddress, + ); + await driver.waitForSelector({ + css: '.ens-input__selected-input__title', + text: hexPrefixedAddress, + }); + await driver.wait(async () => { + const sendDialogMsgs = await driver.findElements( + '.send-v2__form div.dialog', + ); + return sendDialogMsgs.length === 1; + }, 10000); + await driver.clickElement({ text: 'Next', tag: 'button' }); + + // Confirm transaction + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement('[data-testid="home__activity-tab"]'); + const sendTransactionListItem = await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item', + ); + await sendTransactionListItem.click(); + await driver.clickElement({ text: 'Activity log', tag: 'summary' }); + await driver.clickElement('.sender-to-recipient__name:nth-of-type(2)'); + + // Verify address in activity log + const publicAddress = await driver.findElement( + '.nickname-popover__public-address', + ); + assert.equal(await publicAddress.getText(), hexPrefixedAddress); + }, + ); + }); + it('should ensure the address is prefixed with 0x when typed and should send ETH to a valid hexadecimal address', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Send ETH + await driver.clickElement('[data-testid="eth-overview-send"]'); + + // Type address without hex prefix + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + nonHexPrefixedAddress, + ); + await driver.waitForSelector({ + css: '.ens-input__selected-input__title', + text: hexPrefixedAddress, + }); + await driver.wait(async () => { + const sendDialogMsgs = await driver.findElements( + '.send-v2__form div.dialog', + ); + return sendDialogMsgs.length === 1; + }, 10000); + await driver.clickElement({ text: 'Next', tag: 'button' }); + + // Confirm transaction + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement('[data-testid="home__activity-tab"]'); + const sendTransactionListItem = await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item', + ); + await sendTransactionListItem.click(); + await driver.clickElement({ text: 'Activity log', tag: 'summary' }); + await driver.clickElement('.sender-to-recipient__name:nth-of-type(2)'); + + // Verify address in activity log + const publicAddress = await driver.findElement( + '.nickname-popover__public-address', + ); + assert.equal(await publicAddress.getText(), hexPrefixedAddress); + }, + ); + }); +}); + +describe('Send ERC20 to a 40 character hexadecimal address', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('should ensure the address is prefixed with 0x when pasted and should send TST to a valid hexadecimal address', async function () { + await withFixtures( + { + dapp: true, + fixtures: 'connected-state', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Create TST + await driver.openNewPage('http://127.0.0.1:8080/'); + await driver.clickElement('#createToken'); + await driver.waitUntilXWindowHandles(3); + let windowHandles = await driver.getAllWindowHandles(); + const extension = windowHandles[0]; + const dapp = await driver.switchToWindowWithTitle( + 'E2E Test Dapp', + windowHandles, + ); + await driver.switchToWindowWithTitle( + 'MetaMask Notification', + windowHandles, + ); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindow(extension); + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(1)', + { timeout: 10000 }, + ); + + // Add token + await driver.switchToWindow(dapp); + await driver.clickElement('#watchAsset'); + await driver.waitUntilXWindowHandles(3); + windowHandles = await driver.getAllWindowHandles(); + await driver.switchToWindowWithTitle( + 'MetaMask Notification', + windowHandles, + ); + await driver.clickElement({ text: 'Add Token', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindow(extension); + + // Send TST + await driver.clickElement('[data-testid="home__asset-tab"]'); + await driver.clickElement('.token-cell'); + await driver.clickElement('[data-testid="eth-overview-send"]'); + + // Paste address without hex prefix + await driver.pasteIntoField( + 'input[placeholder="Search, public address (0x), or ENS"]', + nonHexPrefixedAddress, + ); + await driver.waitForSelector({ + css: '.ens-input__selected-input__title', + text: hexPrefixedAddress, + }); + await driver.wait(async () => { + const sendDialogMsgs = await driver.findElements( + '.send-v2__form div.dialog', + ); + return sendDialogMsgs.length === 1; + }, 10000); + await driver.delay(2000); + await driver.clickElement({ text: 'Next', tag: 'button' }); + + // Confirm transaction + await driver.waitForSelector({ + css: '.confirm-page-container-summary__title', + text: '0 TST', + }); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(2)', + { timeout: 10000 }, + ); + const sendTransactionListItem = await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(1)', + ); + await sendTransactionListItem.click(); + await driver.clickElement({ text: 'Activity log', tag: 'summary' }); + await driver.clickElement('.sender-to-recipient__name:nth-of-type(2)'); + + // Verify address in activity log + const publicAddress = await driver.findElement( + '.nickname-popover__public-address', + ); + assert.equal(await publicAddress.getText(), hexPrefixedAddress); + }, + ); + }); + it('should ensure the address is prefixed with 0x when typed and should send TST to a valid hexadecimal address', async function () { + await withFixtures( + { + dapp: true, + fixtures: 'connected-state', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Create TST + await driver.openNewPage('http://127.0.0.1:8080/'); + await driver.clickElement('#createToken'); + await driver.waitUntilXWindowHandles(3); + let windowHandles = await driver.getAllWindowHandles(); + const extension = windowHandles[0]; + const dapp = await driver.switchToWindowWithTitle( + 'E2E Test Dapp', + windowHandles, + ); + await driver.switchToWindowWithTitle( + 'MetaMask Notification', + windowHandles, + ); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindow(extension); + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(1)', + { timeout: 10000 }, + ); + + // Add token + await driver.switchToWindow(dapp); + await driver.clickElement('#watchAsset'); + await driver.waitUntilXWindowHandles(3); + windowHandles = await driver.getAllWindowHandles(); + await driver.switchToWindowWithTitle( + 'MetaMask Notification', + windowHandles, + ); + await driver.clickElement({ text: 'Add Token', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindow(extension); + + // Send TST + await driver.clickElement('[data-testid="home__asset-tab"]'); + await driver.clickElement('.token-cell'); + await driver.clickElement('[data-testid="eth-overview-send"]'); + + // Type address without hex prefix + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + nonHexPrefixedAddress, + ); + await driver.waitForSelector({ + css: '.ens-input__selected-input__title', + text: hexPrefixedAddress, + }); + await driver.wait(async () => { + const sendDialogMsgs = await driver.findElements( + '.send-v2__form div.dialog', + ); + return sendDialogMsgs.length === 1; + }, 10000); + await driver.delay(2000); + await driver.clickElement({ text: 'Next', tag: 'button' }); + + // Confirm transaction + await driver.waitForSelector({ + css: '.confirm-page-container-summary__title', + text: '0 TST', + }); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(2)', + { timeout: 10000 }, + ); + const sendTransactionListItem = await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(1)', + ); + await sendTransactionListItem.click(); + await driver.clickElement({ text: 'Activity log', tag: 'summary' }); + await driver.clickElement('.sender-to-recipient__name:nth-of-type(2)'); + + // Verify address in activity log + const publicAddress = await driver.findElement( + '.nickname-popover__public-address', + ); + assert.equal(await publicAddress.getText(), hexPrefixedAddress); + }, + ); + }); +}); diff --git a/test/e2e/tests/state-logs.spec.js b/test/e2e/tests/state-logs.spec.js new file mode 100644 index 000000000..6152ff01a --- /dev/null +++ b/test/e2e/tests/state-logs.spec.js @@ -0,0 +1,65 @@ +const { strict: assert } = require('assert'); +const { promises: fs } = require('fs'); +const { convertToHexValue, withFixtures } = require('../helpers'); + +const downloadsFolder = `${process.cwd()}/test-artifacts/downloads`; + +const createDownloadFolder = async () => { + await fs.rm(downloadsFolder, { recursive: true, force: true }); + await fs.mkdir(downloadsFolder, { recursive: true }); +}; + +const stateLogsExist = async () => { + try { + const stateLogs = `${downloadsFolder}/MetaMask State Logs.json`; + await fs.access(stateLogs); + return true; + } catch (e) { + return false; + } +}; + +describe('State logs', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('should download state logs for the account', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await createDownloadFolder(); + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Download State Logs + await driver.clickElement('.account-menu__icon'); + await driver.clickElement({ text: 'Settings', tag: 'div' }); + await driver.clickElement({ text: 'Advanced', tag: 'div' }); + await driver.clickElement({ + text: 'Download State Logs', + tag: 'button', + }); + + // Verify download + let fileExists; + await driver.wait(async () => { + fileExists = await stateLogsExist(); + return fileExists === true; + }, 10000); + assert.equal(fileExists, true); + }, + ); + }); +}); diff --git a/test/e2e/webdriver/chrome.js b/test/e2e/webdriver/chrome.js index 13ff00f62..a9df3e646 100644 --- a/test/e2e/webdriver/chrome.js +++ b/test/e2e/webdriver/chrome.js @@ -22,6 +22,9 @@ class ChromeDriver { const options = new chrome.Options().addArguments(args); options.setProxy(proxy.manual({ https: HTTPS_PROXY_HOST })); options.setAcceptInsecureCerts(true); + options.setUserPreferences({ + 'download.default_directory': `${process.cwd()}/test-artifacts/downloads`, + }); const builder = new Builder() .forBrowser('chrome') .setChromeOptions(options); diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index 630bd6683..73c029bfe 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -266,21 +266,21 @@ class Driver { /** * Paste a string into a field. * - * @param {string} element - The element locator. + * @param {string} rawLocator - The element locator. * @param {string} contentToPaste - The content to paste. */ - async pasteIntoField(element, contentToPaste) { + async pasteIntoField(rawLocator, contentToPaste) { // Throw if double-quote is present in content to paste // so that we don't have to worry about escaping double-quotes if (contentToPaste.includes('"')) { throw new Error('Cannot paste content with double-quote'); } // Click to focus the field - await this.clickElement(element); + await this.clickElement(rawLocator); await this.executeScript( `navigator.clipboard.writeText("${contentToPaste}")`, ); - await this.fill(element, Key.chord(this.Key.MODIFIER, 'v')); + await this.fill(rawLocator, Key.chord(this.Key.MODIFIER, 'v')); } // Navigation diff --git a/test/e2e/webdriver/firefox.js b/test/e2e/webdriver/firefox.js index 71111df58..3e3dd84cd 100644 --- a/test/e2e/webdriver/firefox.js +++ b/test/e2e/webdriver/firefox.js @@ -40,6 +40,11 @@ class FirefoxDriver { const options = new firefox.Options().setProfile(templateProfile); options.setProxy(proxy.manual({ https: HTTPS_PROXY_HOST })); options.setAcceptInsecureCerts(true); + options.setPreference('browser.download.folderList', 2); + options.setPreference( + 'browser.download.dir', + `${process.cwd()}/test-artifacts/downloads`, + ); const builder = new Builder() .forBrowser('firefox') .setFirefoxOptions(options); diff --git a/test/jest/mocks.js b/test/jest/mocks.js index 6e257adaa..47c8a1f99 100644 --- a/test/jest/mocks.js +++ b/test/jest/mocks.js @@ -1,3 +1,8 @@ +import { + draftTransactionInitialState, + initialState, +} from '../../ui/ducks/send'; + export const TOP_ASSETS_GET_RESPONSE = [ { symbol: 'LINK', @@ -103,3 +108,42 @@ export const createGasFeeEstimatesForFeeMarket = () => { estimatedBaseFee: '50', }; }; + +export const INITIAL_SEND_STATE_FOR_EXISTING_DRAFT = { + ...initialState, + currentTransactionUUID: 'test-uuid', + draftTransactions: { + 'test-uuid': { + ...draftTransactionInitialState, + }, + }, +}; + +export const getInitialSendStateWithExistingTxState = (draftTxState) => ({ + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + draftTransactions: { + 'test-uuid': { + ...draftTransactionInitialState, + ...draftTxState, + amount: { + ...draftTransactionInitialState.amount, + ...draftTxState.amount, + }, + asset: { + ...draftTransactionInitialState.asset, + ...draftTxState.asset, + }, + gas: { + ...draftTransactionInitialState.gas, + ...draftTxState.gas, + }, + recipient: { + ...draftTransactionInitialState.recipient, + ...draftTxState.recipient, + }, + history: draftTxState.history ?? [], + // Use this key if you want to console.log inside the send.js file. + test: draftTxState.test ?? 'yo', + }, + }, +}); diff --git a/ui/components/app/add-network/add-network.js b/ui/components/app/add-network/add-network.js index c453eedf5..3ebf2d293 100644 --- a/ui/components/app/add-network/add-network.js +++ b/ui/components/app/add-network/add-network.js @@ -1,168 +1,286 @@ -import React, { useContext } from 'react'; -import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; +import React, { useContext, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { I18nContext } from '../../../contexts/i18n'; import Box from '../../ui/box'; import Typography from '../../ui/typography'; import { ALIGN_ITEMS, - BLOCK_SIZES, COLORS, DISPLAY, FLEX_DIRECTION, FONT_WEIGHT, TYPOGRAPHY, JUSTIFY_CONTENT, + SIZES, } from '../../../helpers/constants/design-system'; import Button from '../../ui/button'; -import IconCaretLeft from '../../ui/icon/icon-caret-left'; import Tooltip from '../../ui/tooltip'; import IconWithFallback from '../../ui/icon-with-fallback'; import IconBorder from '../../ui/icon-border'; -import { getTheme } from '../../../selectors'; -import { THEME_TYPE } from '../../../pages/settings/experimental-tab/experimental-tab.constant'; +import { + getFrequentRpcListDetail, + getUnapprovedConfirmations, +} from '../../../selectors'; -const AddNetwork = ({ - onBackClick, - onAddNetworkClick, - onAddNetworkManuallyClick, - featuredRPCS, -}) => { +import { + ENVIRONMENT_TYPE_FULLSCREEN, + ENVIRONMENT_TYPE_POPUP, + MESSAGE_TYPE, +} from '../../../../shared/constants/app'; +import { requestUserApproval } from '../../../store/actions'; +import Popover from '../../ui/popover'; +import ConfirmationPage from '../../../pages/confirmation/confirmation'; +import { FEATURED_RPCS } from '../../../../shared/constants/network'; +import { ADD_NETWORK_ROUTE } from '../../../helpers/constants/routes'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; + +const AddNetwork = () => { const t = useContext(I18nContext); - const theme = useSelector(getTheme); + const dispatch = useDispatch(); + const history = useHistory(); + const frequentRpcList = useSelector(getFrequentRpcListDetail); + + const frequentRpcListChainIds = Object.values(frequentRpcList).map( + (net) => net.chainId, + ); const infuraRegex = /infura.io/u; - const nets = featuredRPCS - .sort((a, b) => (a.ticker > b.ticker ? 1 : -1)) - .slice(0, 8); + const nets = FEATURED_RPCS.sort((a, b) => + a.ticker > b.ticker ? 1 : -1, + ).slice(0, FEATURED_RPCS.length); + + const notFrequentRpcNetworks = nets.filter( + (net) => frequentRpcListChainIds.indexOf(net.chainId) === -1, + ); + const unapprovedConfirmations = useSelector(getUnapprovedConfirmations); + const [showPopover, setShowPopover] = useState(false); + + useEffect(() => { + const anAddNetworkConfirmationFromMetaMaskExists = unapprovedConfirmations?.find( + (confirmation) => { + return ( + confirmation.origin === 'metamask' && + confirmation.type === MESSAGE_TYPE.ADD_ETHEREUM_CHAIN + ); + }, + ); + if (!showPopover && anAddNetworkConfirmationFromMetaMaskExists) { + setShowPopover(true); + } + + if (showPopover && !anAddNetworkConfirmationFromMetaMaskExists) { + setShowPopover(false); + } + }, [unapprovedConfirmations, showPopover]); return ( - <Box> - <Box - height={BLOCK_SIZES.TWO_TWELFTHS} - padding={[4, 0, 4, 0]} - display={DISPLAY.FLEX} - alignItems={ALIGN_ITEMS.CENTER} - flexDirection={FLEX_DIRECTION.ROW} - className="add-network__header" - > - <IconCaretLeft - aria-label={t('back')} - onClick={onBackClick} - className="add-network__header__back-icon" - /> - <Typography variant={TYPOGRAPHY.H3} color={COLORS.TEXT_DEFAULT}> - {t('addNetwork')} - </Typography> - </Box> - <Box - height={BLOCK_SIZES.FOUR_FIFTHS} - width={BLOCK_SIZES.TEN_TWELFTHS} - margin={[0, 6, 0, 6]} - > - <Typography - variant={TYPOGRAPHY.H6} - color={COLORS.TEXT_ALTERNATIVE} - margin={[4, 0, 0, 0]} + <> + {Object.keys(notFrequentRpcNetworks).length === 0 ? ( + <Box + className="add-network__edge-case-box" + borderRadius={SIZES.MD} + padding={4} + margin={[4, 6, 0, 6]} + display={DISPLAY.FLEX} + flexDirection={FLEX_DIRECTION.ROW} + backgroundColor={COLORS.BACKGROUND_ALTERNATIVE} > - {t('addFromAListOfPopularNetworks')} - </Typography> - <Typography - variant={TYPOGRAPHY.H7} - color={COLORS.TEXT_MUTED} - margin={[4, 0, 3, 0]} - > - {t('popularCustomNetworks')} - </Typography> - {nets.map((item, index) => ( - <Box - key={index} - display={DISPLAY.FLEX} - alignItems={ALIGN_ITEMS.CENTER} - justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN} - marginBottom={6} - > - <Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}> - <IconBorder size={24}> - <IconWithFallback - icon={item.rpcPrefs.imageUrl} - name={item.nickname} - size={24} - /> - </IconBorder> - <Typography - variant={TYPOGRAPHY.H7} - color={COLORS.TEXT_DEFAULT} - fontWeight={FONT_WEIGHT.BOLD} - boxProps={{ marginLeft: 2 }} - > - {item.nickname} + <Box marginRight={4}> + <img src="images/info-fox.svg" /> + </Box> + <Box> + <Typography variant={TYPOGRAPHY.H7}> + {t('youHaveAddedAll', [ + <a + key="link" + className="add-network__edge-case-box__link" + href="https://chainlist.wtf/" + target="_blank" + rel="noreferrer" + > + {t('here')}. + </a>, + <Button + key="button" + type="inline" + onClick={(event) => { + event.preventDefault(); + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? global.platform.openExtensionInBrowser( + ADD_NETWORK_ROUTE, + ) + : history.push(ADD_NETWORK_ROUTE); + }} + > + <Typography + variant={TYPOGRAPHY.H7} + color={COLORS.INFO_DEFAULT} + > + {t('addMoreNetworks')}. + </Typography> + </Button>, + ])} + </Typography> + </Box> + </Box> + ) : ( + <Box className="add-network__networks-container"> + {getEnvironmentType() === ENVIRONMENT_TYPE_FULLSCREEN && ( + <Box + display={DISPLAY.FLEX} + alignItems={ALIGN_ITEMS.CENTER} + flexDirection={FLEX_DIRECTION.ROW} + marginTop={7} + marginBottom={4} + paddingBottom={2} + className="add-network__header" + > + <Typography variant={TYPOGRAPHY.H4} color={COLORS.TEXT_MUTED}> + {t('networks')} + </Typography> + <span className="add-network__header__subtitle">{' > '}</span> + <Typography variant={TYPOGRAPHY.H4} color={COLORS.TEXT_DEFAULT}> + {t('addANetwork')} </Typography> </Box> - <Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}> - { - // Warning for the networks that doesn't use infura.io as the RPC - !infuraRegex.test(item.rpcUrl) && ( - <Tooltip - className="add-network__warning-tooltip" - position="top" - interactive - html={ - <Box margin={3} className="add-network__warning-tooltip"> - {t('addNetworkTooltipWarning', [ - <a - key="zendesk_page_link" - href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971" - rel="noreferrer" - target="_blank" - > - {t('learnMoreUpperCase')} - </a>, - ])} - </Box> - } - trigger="mouseenter" - theme={theme === THEME_TYPE.DEFAULT ? 'light' : 'dark'} - > - <i - className="fa fa-exclamation-triangle add-network__warning-icon" - title={t('warning')} - /> - </Tooltip> - ) - } - <Button - type="inline" - className="add-network__add-button" - onClick={onAddNetworkClick} + )} + <Box + margin={ + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? [0, 0, 1, 0] + : [4, 0, 1, 0] + } + className="add-network__main-container" + > + <Typography + variant={TYPOGRAPHY.H6} + color={COLORS.TEXT_ALTERNATIVE} + margin={[4, 0, 0, 0]} + > + {t('addFromAListOfPopularNetworks')} + </Typography> + <Typography + variant={TYPOGRAPHY.H7} + color={COLORS.TEXT_MUTED} + margin={[4, 0, 3, 0]} + > + {t('popularCustomNetworks')} + </Typography> + {notFrequentRpcNetworks.map((item, index) => ( + <Box + key={index} + display={DISPLAY.FLEX} + alignItems={ALIGN_ITEMS.CENTER} + justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN} + marginBottom={6} + className="add-network__list-of-networks" > - {t('add')} - </Button> - </Box> + <Box display={DISPLAY.FLEX} alignItems={ALIGN_ITEMS.CENTER}> + <Box> + <IconBorder size={24}> + <IconWithFallback + icon={item.rpcPrefs.imageUrl} + name={item.nickname} + size={24} + /> + </IconBorder> + </Box> + <Box marginLeft={2}> + <Typography + variant={TYPOGRAPHY.H7} + color={COLORS.TEXT_DEFAULT} + fontWeight={FONT_WEIGHT.BOLD} + > + {item.nickname} + </Typography> + </Box> + </Box> + <Box + display={DISPLAY.FLEX} + alignItems={ALIGN_ITEMS.CENTER} + marginLeft={1} + > + { + // Warning for the networks that doesn't use infura.io as the RPC + !infuraRegex.test(item.rpcUrl) && ( + <Tooltip + position="top" + interactive + html={ + <Box + margin={3} + className="add-network__warning-tooltip" + > + {t('addNetworkTooltipWarning', [ + <a + key="zendesk_page_link" + href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971" + rel="noreferrer" + target="_blank" + > + {t('learnMoreUpperCase')} + </a>, + ])} + </Box> + } + trigger="mouseenter" + > + <i + className="fa fa-exclamation-triangle add-network__warning-icon" + title={t('warning')} + /> + </Tooltip> + ) + } + <Button + type="inline" + className="add-network__add-button" + onClick={async () => { + await dispatch(requestUserApproval(item, true)); + }} + > + {t('add')} + </Button> + </Box> + </Box> + ))} </Box> - ))} - </Box> - <Box - height={BLOCK_SIZES.ONE_TWELFTH} - padding={[4, 4, 4, 4]} - className="add-network__footer" - > - <Button type="link" onClick={onAddNetworkManuallyClick}> - <Typography variant={TYPOGRAPHY.H6} color={COLORS.PRIMARY_DEFAULT}> - {t('addANetworkManually')} - </Typography> - </Button> - </Box> - </Box> + <Box + padding={ + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? [2, 0, 2, 6] + : [2, 0, 2, 0] + } + className="add-network__footer" + > + <Button + type="link" + onClick={(event) => { + event.preventDefault(); + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE) + : history.push(ADD_NETWORK_ROUTE); + }} + > + <Typography + variant={TYPOGRAPHY.H6} + color={COLORS.PRIMARY_DEFAULT} + > + {t('addANetworkManually')} + </Typography> + </Button> + </Box> + </Box> + )} + {showPopover && ( + <Popover> + <ConfirmationPage /> + </Popover> + )} + </> ); }; -AddNetwork.propTypes = { - onBackClick: PropTypes.func, - onAddNetworkClick: PropTypes.func, - onAddNetworkManuallyClick: PropTypes.func, - featuredRPCS: PropTypes.array, -}; - export default AddNetwork; diff --git a/ui/components/app/add-network/add-network.test.js b/ui/components/app/add-network/add-network.test.js new file mode 100644 index 000000000..d0272608f --- /dev/null +++ b/ui/components/app/add-network/add-network.test.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWithProvider } from '../../../../test/jest'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import AddNetwork from './add-network'; + +jest.mock('../../../selectors', () => ({ + getFrequentRpcListDetail: () => ({ + frequentRpcList: [ + { + chainId: '0x539', + nickname: 'Localhost 8545', + rpcPrefs: {}, + rpcUrl: 'http://localhost:8545', + ticker: 'ETH', + }, + { + chainId: '0xA4B1', + nickname: 'Arbitrum One', + rpcPrefs: { blockExplorerUrl: 'https://explorer.arbitrum.io' }, + rpcUrl: + 'https://arbitrum-mainnet.infura.io/v3/7e127583378c4732a858df2550aff333', + ticker: 'AETH', + }, + ], + }), + getUnapprovedConfirmations: jest.fn(), + getTheme: () => 'light', +})); + +const render = () => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + }, + }); + return renderWithProvider(<AddNetwork />, store); +}; + +describe('AddNetwork', () => { + it('should show Add from a list.. text', () => { + render(); + expect( + screen.getByText( + 'Add from a list of popular networks or add a network manually. Only interact with the entities you trust.', + ), + ).toBeInTheDocument(); + }); + + it('should show Popular custom networks text', () => { + render(); + expect(screen.getByText('Popular custom networks')).toBeInTheDocument(); + }); + + it('should show Arbitrum One network nickname', () => { + render(); + expect(screen.getByText('Arbitrum One')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/add-network/index.scss b/ui/components/app/add-network/index.scss index 6da064bf5..4c7d9f909 100644 --- a/ui/components/app/add-network/index.scss +++ b/ui/components/app/add-network/index.scss @@ -1,10 +1,36 @@ .add-network { + &__networks-container { + padding-inline-end: 24px; + + @media screen and (max-width: $break-small) { + padding: 0; + } + } + &__header { border-bottom: 1px solid var(--color-border-default); - &__back-icon { - margin-left: 24px; - margin-right: 16px; + @media screen and (max-width: 575px) { + padding-inline-start: 24px; + padding-inline-end: 24px; + } + + &__subtitle { + margin-inline-start: 10px; + margin-inline-end: 10px; + } + } + + &__main-container { + @media screen and (max-width: 575px) { + padding-inline-start: 24px; + padding-inline-end: 24px; + } + } + + &__list-of-networks { + @media screen and (min-width: $break-large) { + width: 75%; } } @@ -23,19 +49,25 @@ &__add-icon { color: var(--color-text-alternative); - margin-left: auto; - margin-right: 0; + margin-inline-start: auto; + margin-inline-end: 0; cursor: pointer; } &__add-button.button { color: var(--color-primary-default); font-size: $font-size-h7; - margin-left: 24px; + margin-inline-start: 24px; } &__footer { border-top: 1px solid var(--color-border-muted); + width: 100%; + padding-bottom: 8px; + + @media screen and (max-width: 575px) { + padding-inline-start: 24px !important; + } & .btn-link { display: initial; @@ -51,6 +83,14 @@ color: var(--color-text-alternative); } } + + &__edge-case-box { + border: 1px solid var(--color-border-muted); + + &__link { + color: var(--color-info-default); + display: inline; + padding: 0; + } + } } - - diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 3920e5372..6fd148adc 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -14,6 +14,7 @@ @import 'collectibles-items/index'; @import 'collectibles-tab/index'; @import 'collectible-details/index'; +@import 'collectible-default-image/index'; @import 'collectible-options/index'; @import 'collectibles-detection-notice/index'; @import 'connected-accounts-list/index'; 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 77ab7f53d..bc19aff87 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -9,7 +9,7 @@ import Tooltip from '../../ui/tooltip'; import InfoIcon from '../../ui/icon/info-icon.component'; import Button from '../../ui/button'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { updateSendAsset } from '../../../ducks/send'; +import { startNewDraftTransaction } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys'; @@ -74,7 +74,7 @@ const AssetListItem = ({ }); try { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.TOKEN, details: { address: tokenAddress, diff --git a/ui/components/app/collectible-default-image/collectible-default-image.js b/ui/components/app/collectible-default-image/collectible-default-image.js new file mode 100644 index 000000000..c301c2c2c --- /dev/null +++ b/ui/components/app/collectible-default-image/collectible-default-image.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import Typography from '../../ui/typography'; +import { TYPOGRAPHY } from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export default function CollectibleDefaultImage({ + name, + tokenId, + handleImageClick, +}) { + const t = useI18nContext(); + return ( + <div + className={classnames('collectible-default', { + 'collectible-default--clickable': handleImageClick, + })} + onClick={handleImageClick} + > + <Typography variant={TYPOGRAPHY.H6} className="collectible-default__text"> + {name ?? t('unknownCollection')} <br /> #{tokenId} + </Typography> + </div> + ); +} + +CollectibleDefaultImage.propTypes = { + /** + * The name of the collectible collection if not supplied will default to "Unnamed collection" + */ + name: PropTypes.string, + /** + * The token id of the collectible + */ + tokenId: PropTypes.string, + /** + * The click handler for the collectible default image + */ + handleImageClick: PropTypes.func, +}; diff --git a/ui/components/app/collectible-default-image/collectible-default-image.stories.js b/ui/components/app/collectible-default-image/collectible-default-image.stories.js new file mode 100644 index 000000000..d4b7a2a69 --- /dev/null +++ b/ui/components/app/collectible-default-image/collectible-default-image.stories.js @@ -0,0 +1,42 @@ +import React from 'react'; +import CollectibleDefaultImage from '.'; + +export default { + title: 'Components/App/CollectibleDefaultImage', + id: __filename, + argTypes: { + name: { + control: 'text', + }, + tokenId: { + control: 'text', + }, + handleImageClick: { + action: 'handleImageClick', + }, + }, + args: { + name: null, + tokenId: '12345', + handleImageClick: null, + }, +}; + +export const DefaultStory = (args) => ( + <div style={{ width: 200, height: 200 }}> + <CollectibleDefaultImage {...args} /> + </div> +); + +DefaultStory.storyName = 'Default'; + +export const handleImageClick = (args) => ( + <div style={{ width: 200, height: 200 }}> + <CollectibleDefaultImage {...args} /> + </div> +); + +handleImageClick.args = { + // eslint-disable-next-line no-alert + handleImageClick: () => window.alert('CollectibleDefaultImage clicked!'), +}; diff --git a/ui/components/app/collectible-default-image/index.js b/ui/components/app/collectible-default-image/index.js new file mode 100644 index 000000000..76ab58746 --- /dev/null +++ b/ui/components/app/collectible-default-image/index.js @@ -0,0 +1 @@ +export { default } from './collectible-default-image'; diff --git a/ui/components/app/collectible-default-image/index.scss b/ui/components/app/collectible-default-image/index.scss new file mode 100644 index 000000000..ff2ba60e1 --- /dev/null +++ b/ui/components/app/collectible-default-image/index.scss @@ -0,0 +1,22 @@ +.collectible-default { + background-color: var(--color-background-alternative); + padding-top: 100%; // retains 1:1 aspect ratio + position: relative; + width: 100%; + + &__text { + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + position: absolute; + white-space: nowrap; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(100% - 32px); + } + + &--clickable { + cursor: pointer; + } +} diff --git a/ui/components/app/collectible-details/collectible-details.js b/ui/components/app/collectible-details/collectible-details.js index 5c8bf8e3a..df991634e 100644 --- a/ui/components/app/collectible-details/collectible-details.js +++ b/ui/components/app/collectible-details/collectible-details.js @@ -45,13 +45,14 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import CollectibleOptions from '../collectible-options/collectible-options'; import Button from '../../ui/button'; -import { updateSendAsset } from '../../../ducks/send'; +import { startNewDraftTransaction } from '../../../ducks/send'; import InfoTooltip from '../../ui/info-tooltip'; import { ERC721 } from '../../../helpers/constants/common'; import { usePrevious } from '../../../hooks/usePrevious'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; +import CollectibleDefaultImage from '../collectible-default-image'; export default function CollectibleDetails({ collectible }) { const { @@ -119,7 +120,7 @@ export default function CollectibleDetails({ collectible }) { const onSend = async () => { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.COLLECTIBLE, details: collectible, }), @@ -176,7 +177,11 @@ export default function CollectibleDetails({ collectible }) { justifyContent={JUSTIFY_CONTENT.CENTER} className="collectible-details__card" > - <img className="collectible-details__image" src={image} /> + {image ? ( + <img className="collectible-details__image" src={image} /> + ) : ( + <CollectibleDefaultImage name={name} tokenId={tokenId} /> + )} </Card> <Box flexDirection={FLEX_DIRECTION.COLUMN} @@ -215,6 +220,7 @@ export default function CollectibleDetails({ collectible }) { <Typography color={COLORS.TEXT_ALTERNATIVE} variant={TYPOGRAPHY.H6} + overflowWrap={OVERFLOW_WRAP.BREAK_WORD} boxProps={{ margin: 0, marginBottom: 4 }} > {description} diff --git a/ui/components/app/collectible-details/collectible-details.stories.js b/ui/components/app/collectible-details/collectible-details.stories.js index bbd18bac0..a40e81c02 100644 --- a/ui/components/app/collectible-details/collectible-details.stories.js +++ b/ui/components/app/collectible-details/collectible-details.stories.js @@ -1,16 +1,6 @@ import React from 'react'; import CollectibleDetails from './collectible-details'; -export default { - title: 'Components/App/CollectiblesDetail', - id: __filename, - argTypes: { - collectible: { - control: 'object', - }, - }, -}; - const collectible = { name: 'Catnip Spicywright', tokenId: '1124157', @@ -20,12 +10,32 @@ const collectible = { "Good day. My name is Catnip Spicywight, which got me teased a lot in high school. If I want to put low fat mayo all over my hamburgers, I shouldn't have to answer to anyone about it, am I right? One time I beat Arlene in an arm wrestle.", }; -export const DefaultStory = () => { - return <CollectibleDetails collectible={collectible} />; +export default { + title: 'Components/App/CollectiblesDetail', + id: __filename, + argTypes: { + collectible: { + control: 'object', + }, + }, + args: { + collectible, + }, +}; + +export const DefaultStory = (args) => { + return <CollectibleDetails {...args} />; }; DefaultStory.storyName = 'Default'; -DefaultStory.args = { - collectible, +export const NoImage = (args) => { + return <CollectibleDetails {...args} />; +}; + +NoImage.args = { + collectible: { + ...collectible, + image: undefined, + }, }; diff --git a/ui/components/app/collectibles-items/collectibles-items.js b/ui/components/app/collectibles-items/collectibles-items.js index 15e45b434..07f5d2eae 100644 --- a/ui/components/app/collectibles-items/collectibles-items.js +++ b/ui/components/app/collectibles-items/collectibles-items.js @@ -28,6 +28,8 @@ import { getAssetImageURL } from '../../../helpers/utils/util'; import { updateCollectibleDropDownState } from '../../../store/actions'; import { usePrevious } from '../../../hooks/usePrevious'; import { getCollectiblesDropdownState } from '../../../ducks/metamask/metamask'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import CollectibleDefaultImage from '../collectible-default-image'; const width = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP @@ -46,6 +48,7 @@ export default function CollectiblesItems({ const previousCollectionKeys = usePrevious(collectionsKeys); const selectedAddress = useSelector(getSelectedAddress); const chainId = useSelector(getCurrentChainId); + const t = useI18nContext(); useEffect(() => { if ( @@ -101,7 +104,7 @@ export default function CollectiblesItems({ } return ( <div className="collectibles-items__collection-image-alt"> - {collectionName[0]} + {collectionName?.[0]?.toUpperCase() ?? null} </div> ); }; @@ -164,7 +167,9 @@ export default function CollectiblesItems({ variant={TYPOGRAPHY.H5} margin={[0, 0, 0, 2]} > - {`${collectionName} (${collectibles.length})`} + {`${collectionName ?? t('unknownCollection')} (${ + collectibles.length + })`} </Typography> </Box> <Box alignItems={ALIGN_ITEMS.FLEX_END}> @@ -180,29 +185,48 @@ export default function CollectiblesItems({ {isExpanded ? ( <Box display={DISPLAY.FLEX} flexWrap={FLEX_WRAP.WRAP} gap={4}> {collectibles.map((collectible, i) => { - const { image, address, tokenId, backgroundColor } = collectible; + const { + image, + address, + tokenId, + backgroundColor, + name, + } = collectible; const collectibleImage = getAssetImageURL(image, ipfsGateway); + const handleImageClick = () => + history.push(`${ASSET_ROUTE}/${address}/${tokenId}`); + return ( <Box width={width} key={`collectible-${i}`} - className="collectibles-items__collection-item-wrapper" + className="collectibles-items__item-wrapper" > - <Card padding={0} justifyContent={JUSTIFY_CONTENT.CENTER}> - <div - className="collectibles-items__collection-item" - style={{ - backgroundColor, - }} - > - <img - onClick={() => - history.push(`${ASSET_ROUTE}/${address}/${tokenId}`) - } - className="collectibles-items__collection-item-image" - src={collectibleImage} + <Card + padding={0} + justifyContent={JUSTIFY_CONTENT.CENTER} + className="collectibles-items__item-wrapper__card" + > + {collectibleImage ? ( + <div + className="collectibles-items__item" + style={{ + backgroundColor, + }} + > + <img + onClick={handleImageClick} + className="collectibles-items__item-image" + src={collectibleImage} + /> + </div> + ) : ( + <CollectibleDefaultImage + name={name} + tokenId={tokenId} + handleImageClick={handleImageClick} /> - </div> + )} </Card> </Box> ); diff --git a/ui/components/app/collectibles-items/index.scss b/ui/components/app/collectibles-items/index.scss index 7087ac481..545528fde 100644 --- a/ui/components/app/collectibles-items/index.scss +++ b/ui/components/app/collectibles-items/index.scss @@ -27,29 +27,33 @@ color: var(--color-overlay-inverse); text-align: center; } + } - &-item-wrapper { - align-self: center; + &__item-wrapper { + align-self: center; + + &__card { + overflow: hidden; } + } - &-item { - border-radius: 4px; - width: 100%; - display: flex; - justify-content: center; - cursor: pointer; - align-self: center; - } + &__item { + border-radius: 4px; + width: 100%; + display: flex; + justify-content: center; + cursor: pointer; + align-self: center; - &-item-image { + &-image { border-radius: 4px; width: 100%; height: 100%; cursor: pointer; } + } - &__icon-chevron { - color: var(--color-icon-default); - } + &__icon-chevron { + color: var(--color-icon-default); } } diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js index 2d5fb8193..e31e81bce 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -53,7 +53,8 @@ const ConfirmPageContainerSummary = (props) => { contractAddress = transactionType === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER || transactionType === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM || - transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM + transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM || + transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL ? tokenAddress : toAddress; } diff --git a/ui/components/app/dropdowns/network-dropdown.js b/ui/components/app/dropdowns/network-dropdown.js index 33ac6af44..54af45b12 100644 --- a/ui/components/app/dropdowns/network-dropdown.js +++ b/ui/components/app/dropdowns/network-dropdown.js @@ -20,6 +20,7 @@ import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import { EVENT } from '../../../../shared/constants/metametrics'; import { ADD_NETWORK_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, ADVANCED_ROUTE, } from '../../../helpers/constants/routes'; import IconCheck from '../../ui/icon/icon-check'; @@ -49,6 +50,7 @@ function mapStateToProps(state) { frequentRpcListDetail: state.metamask.frequentRpcListDetail || [], networkDropdownOpen: state.appState.networkDropdownOpen, showTestnetMessageInDropdown: state.metamask.showTestnetMessageInDropdown, + addPopularNetworkFeatureToggledOn: state.metamask.customNetworkListEnabled, }; } @@ -101,6 +103,7 @@ class NetworkDropdown extends Component { showTestnetMessageInDropdown: PropTypes.bool.isRequired, hideTestNetMessage: PropTypes.func.isRequired, history: PropTypes.object, + addPopularNetworkFeatureToggledOn: PropTypes.bool, }; handleClick(newProviderType) { @@ -129,10 +132,12 @@ class NetworkDropdown extends Component { <Button type="secondary" onClick={() => { - if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) { - global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE); + if (this.props.addPopularNetworkFeatureToggledOn) { + this.props.history.push(ADD_POPULAR_CUSTOM_NETWORK); } else { - this.props.history.push(ADD_NETWORK_ROUTE); + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE) + : this.props.history.push(ADD_NETWORK_ROUTE); } this.props.hideNetworkDropdown(); }} diff --git a/ui/components/app/metamask-template-renderer/metamask-template-renderer.js b/ui/components/app/metamask-template-renderer/metamask-template-renderer.js index 6aaca546b..ad15d03cb 100644 --- a/ui/components/app/metamask-template-renderer/metamask-template-renderer.js +++ b/ui/components/app/metamask-template-renderer/metamask-template-renderer.js @@ -43,6 +43,9 @@ const MetaMaskTemplateRenderer = ({ sections }) => { return ( <> {sections.reduce((allChildren, child) => { + if (child?.hide === true) { + return allChildren; + } if (typeof child === 'string') { // React can render strings directly, so push them into the accumulator allChildren.push(child); diff --git a/ui/components/app/metamask-template-renderer/safe-component-list.js b/ui/components/app/metamask-template-renderer/safe-component-list.js index 29ded06ac..07aad3630 100644 --- a/ui/components/app/metamask-template-renderer/safe-component-list.js +++ b/ui/components/app/metamask-template-renderer/safe-component-list.js @@ -9,6 +9,8 @@ import MetaMaskTranslation from '../metamask-translation'; import NetworkDisplay from '../network-display'; import TextArea from '../../ui/textarea/textarea'; import ConfirmationNetworkSwitch from '../../../pages/confirmation/components/confirmation-network-switch'; +import UrlIcon from '../../ui/url-icon'; +import Tooltip from '../../ui/tooltip/tooltip'; export const safeComponentList = { MetaMaskTranslation, @@ -27,4 +29,7 @@ export const safeComponentList = { NetworkDisplay, TextArea, ConfirmationNetworkSwitch, + UrlIcon, + Tooltip, + i: 'i', }; diff --git a/ui/components/app/srp-input/srp-input.js b/ui/components/app/srp-input/srp-input.js index 1a3eb5810..d8b0a77de 100644 --- a/ui/components/app/srp-input/srp-input.js +++ b/ui/components/app/srp-input/srp-input.js @@ -35,7 +35,7 @@ export default function SrpInput({ onChange, srpText }) { const onSrpChange = useCallback( (newDraftSrp) => { let newSrpError = ''; - const joinedDraftSrp = newDraftSrp.join(' '); + const joinedDraftSrp = newDraftSrp.join(' ').trim(); if (newDraftSrp.some((word) => word !== '')) { if (newDraftSrp.some((word) => word === '')) { diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 331885364..f65c82f63 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -251,7 +251,10 @@ export default class TransactionListItemDetails extends PureComponent { <div className="transaction-list-item-details__cards-container"> <TransactionBreakdown nonce={transactionGroup.initialTransaction.txParams.nonce} - isTokenApprove={type === TRANSACTION_TYPES.TOKEN_METHOD_APPROVE} + isTokenApprove={ + type === TRANSACTION_TYPES.TOKEN_METHOD_APPROVE || + type === TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL + } transaction={transaction} primaryCurrency={primaryCurrency} className="transaction-list-item-details__transaction-breakdown" diff --git a/ui/components/app/wallet-overview/eth-overview.js b/ui/components/app/wallet-overview/eth-overview.js index 4b9e22a41..26b0019b1 100644 --- a/ui/components/app/wallet-overview/eth-overview.js +++ b/ui/components/app/wallet-overview/eth-overview.js @@ -33,6 +33,8 @@ import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { EVENT } from '../../../../shared/constants/metametrics'; import Spinner from '../../ui/spinner'; +import { startNewDraftTransaction } from '../../../ducks/send'; +import { ASSET_TYPES } from '../../../../shared/constants/transaction'; import WalletOverview from './wallet-overview'; const EthOverview = ({ className }) => { @@ -131,7 +133,11 @@ const EthOverview = ({ className }) => { legacy_event: true, }, }); - history.push(SEND_ROUTE); + dispatch( + startNewDraftTransaction({ type: ASSET_TYPES.NATIVE }), + ).then(() => { + history.push(SEND_ROUTE); + }); }} /> <IconButton diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index 23d8997d8..a02f0263e 100644 --- a/ui/components/app/wallet-overview/token-overview.js +++ b/ui/components/app/wallet-overview/token-overview.js @@ -14,7 +14,7 @@ import { } from '../../../helpers/constants/routes'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; -import { updateSendAsset } from '../../../ducks/send'; +import { startNewDraftTransaction } from '../../../ducks/send'; import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; import { getCurrentKeyring, @@ -93,7 +93,7 @@ const TokenOverview = ({ className, token }) => { }); try { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.TOKEN, details: token, }), diff --git a/ui/components/ui/chip/chip.js b/ui/components/ui/chip/chip.js index 21b4fe13f..fa217e31d 100644 --- a/ui/components/ui/chip/chip.js +++ b/ui/components/ui/chip/chip.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { omit } from 'lodash'; import Typography from '../typography'; +import UrlIcon from '../url-icon'; import { COLORS, TYPOGRAPHY } from '../../../helpers/constants/design-system'; export default function Chip({ @@ -14,9 +15,11 @@ export default function Chip({ label, labelProps = {}, leftIcon, + leftIconUrl = '', rightIcon, onClick, maxContent = true, + displayInlineBlock = false, }) { const onKeyPress = (event) => { if (event.key === 'Enter' && onClick) { @@ -37,11 +40,17 @@ export default function Chip({ [`chip--border-color-${borderColor}`]: true, [`chip--background-color-${backgroundColor}`]: true, 'chip--max-content': maxContent, + 'chip--display-inline-block': displayInlineBlock, })} role={isInteractive ? 'button' : undefined} tabIndex={isInteractive ? 0 : undefined} > - {leftIcon ? <div className="chip__left-icon">{leftIcon}</div> : null} + {leftIcon && !leftIconUrl ? ( + <div className="chip__left-icon">{leftIcon}</div> + ) : null} + {leftIconUrl ? ( + <UrlIcon className="chip__left-url-icon" url={leftIconUrl} /> + ) : null} {children ?? ( <Typography className="chip__label" @@ -106,4 +115,12 @@ Chip.propTypes = { * max-content can overflow the parent's width and break designs */ maxContent: PropTypes.bool, + /** + * Icon location + */ + leftIconUrl: PropTypes.string, + /** + * Display or not the inline block + */ + displayInlineBlock: PropTypes.bool, }; diff --git a/ui/components/ui/chip/chip.scss b/ui/components/ui/chip/chip.scss index 7eba44619..b0a430819 100644 --- a/ui/components/ui/chip/chip.scss +++ b/ui/components/ui/chip/chip.scss @@ -16,6 +16,10 @@ align-items: center; } + &__left-url-icon { + margin-right: 8px; + }; + @each $variant, $color in design-system.$color-map { &--border-color-#{$variant} { border-color: var($color); @@ -67,4 +71,8 @@ &--max-content { width: max-content; } + + &--display-inline-block { + display: inline-block; + } } diff --git a/ui/components/ui/definition-list/definition-list.js b/ui/components/ui/definition-list/definition-list.js index 05ca7b912..2d1560814 100644 --- a/ui/components/ui/definition-list/definition-list.js +++ b/ui/components/ui/definition-list/definition-list.js @@ -7,6 +7,7 @@ import { SIZES, TYPOGRAPHY, FONT_WEIGHT, + OVERFLOW_WRAP, } from '../../../helpers/constants/design-system'; import Tooltip from '../tooltip'; @@ -60,6 +61,7 @@ export default function DefinitionList({ marginBottom: MARGIN_MAP[gapSize], }} className="definition-list__definition" + overflowWrap={OVERFLOW_WRAP.BREAK_WORD} tag="dd" > {definition} diff --git a/ui/components/ui/token-input/token-input.component.js b/ui/components/ui/token-input/token-input.component.js index 6de1191dc..5f2cab056 100644 --- a/ui/components/ui/token-input/token-input.component.js +++ b/ui/components/ui/token-input/token-input.component.js @@ -118,7 +118,7 @@ export default class TokenInput extends PureComponent { isEqualCaseInsensitive(address, token.address), ); - const tokenExchangeRate = tokenExchangeRates?.[existingToken.address] || 0; + const tokenExchangeRate = tokenExchangeRates?.[existingToken?.address] ?? 0; let currency, numberOfDecimals; if (hideConversion) { diff --git a/ui/ducks/app/app.js b/ui/ducks/app/app.js index 93267bd27..94c3bb6f2 100644 --- a/ui/ducks/app/app.js +++ b/ui/ducks/app/app.js @@ -60,6 +60,7 @@ export default function reduceApp(state = {}, action) { newCollectibleAddedMessage: '', sendInputCurrencySwitched: false, newTokensImported: '', + newCustomNetworkAdded: {}, ...state, }; @@ -393,6 +394,11 @@ export default function reduceApp(state = {}, action) { ...appState, sendInputCurrencySwitched: !appState.sendInputCurrencySwitched, }; + case actionConstants.SET_NEW_CUSTOM_NETWORK_ADDED: + return { + ...appState, + newCustomNetworkAdded: action.value, + }; default: return appState; } @@ -444,3 +450,7 @@ export function getLedgerTransportStatus(state) { export function toggleCurrencySwitch() { return { type: actionConstants.TOGGLE_CURRENCY_INPUT_SWITCH }; } + +export function setNewCustomNetworkAdded(value) { + return { type: actionConstants.SET_NEW_CUSTOM_NETWORK_ADDED, value }; +} diff --git a/ui/ducks/send/helpers.js b/ui/ducks/send/helpers.js new file mode 100644 index 000000000..f1233a9c1 --- /dev/null +++ b/ui/ducks/send/helpers.js @@ -0,0 +1,295 @@ +import { addHexPrefix } from 'ethereumjs-util'; +import abi from 'human-standard-token-abi'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; +import { + ASSET_TYPES, + TRANSACTION_ENVELOPE_TYPES, +} from '../../../shared/constants/transaction'; +import { readAddressAsContract } from '../../../shared/modules/contract-utils'; +import { + conversionUtil, + multiplyCurrencies, +} from '../../../shared/modules/conversion.utils'; +import { ETH, GWEI } from '../../helpers/constants/common'; +import { calcTokenAmount } from '../../helpers/utils/token-util'; +import { MIN_GAS_LIMIT_HEX } from '../../pages/send/send.constants'; +import { + addGasBuffer, + generateERC20TransferData, + generateERC721TransferData, + getAssetTransferData, +} from '../../pages/send/send.utils'; +import { getGasPriceInHexWei } from '../../selectors'; +import { estimateGas } from '../../store/actions'; + +export async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + isNonStandardEthChain, + chainId, + gasLimit, + ...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. Some + // network implementations check the gas parameter supplied to + // eth_estimateGas for validity. For this reason, we set token sends + // blockGasLimit default to a higher number. Note that the current gasLimit + // on a BLOCK is 15,000,000 and will be 30,000,000 on mainnet after London. + // Meanwhile, MIN_GAS_LIMIT_HEX is 0x5208. + let blockGasLimit = MIN_GAS_LIMIT_HEX; + if (options.blockGasLimit) { + blockGasLimit = options.blockGasLimit; + } else if (sendToken) { + blockGasLimit = GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + + // 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/erc721 contract call to transfer tokens in + // order to get a proper estimate for gasLimit. + paramsForGasEstimate.data = getAssetTransferData({ + sendToken, + fromAddress: selectedAddress, + toAddress: to, + amount: value, + }); + + 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 { isContractAddress } = to + ? await readAddressAsContract(global.eth, to) + : {}; + if (!isContractAddress && !isNonStandardEthChain) { + return GAS_LIMITS.SIMPLE; + } else if (!isContractAddress && isNonStandardEthChain) { + isSimpleSendOnNonStandardNetwork = true; + } + } + + 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 (!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. + 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 + // execution on the node and return an estimate of gasLimit + const estimatedGasLimit = await estimateGas(paramsForGasEstimate); + const estimateWithBuffer = addGasBuffer( + estimatedGasLimit, + blockGasLimit, + bufferMultiplier, + ); + return addHexPrefix(estimateWithBuffer); + } catch (error) { + const simulationFailed = + error.message.includes('Transaction execution error.') || + error.message.includes( + 'gas required exceeds allowance or always failing transaction', + ) || + (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId] && + error.message.includes('gas required exceeds allowance')); + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer( + paramsForGasEstimate?.gas ?? gasLimit, + blockGasLimit, + bufferMultiplier, + ); + return addHexPrefix(estimateWithBuffer); + } + throw error; + } +} + +/** + * Generates a txParams from the send slice. + * + * @param {import('.').SendState} sendState - the state of the send slice + * @returns {import( + * '../../../shared/constants/transaction' + * ).TxParams} A txParams object that can be used to create a transaction or + * update an existing transaction. + */ +export function generateTransactionParams(sendState) { + const draftTransaction = + sendState.draftTransactions[sendState.currentTransactionUUID]; + const txParams = { + // If the fromAccount has been specified we use that, if not we use the + // selected account. + from: + draftTransaction.fromAccount?.address || + sendState.selectedAccount.address, + // gasLimit always needs to be set regardless of the asset being sent + // or the type of transaction. + gas: draftTransaction.gas.gasLimit, + }; + switch (draftTransaction.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. + txParams.to = draftTransaction.asset.details.address; + txParams.value = '0x0'; + txParams.data = generateERC20TransferData({ + toAddress: draftTransaction.recipient.address, + amount: draftTransaction.amount.value, + sendToken: draftTransaction.asset.details, + }); + break; + case ASSET_TYPES.COLLECTIBLE: + // 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. + txParams.to = draftTransaction.asset.details.address; + txParams.value = '0x0'; + txParams.data = generateERC721TransferData({ + toAddress: draftTransaction.recipient.address, + fromAddress: + draftTransaction.fromAccount?.address ?? + sendState.selectedAccount.address, + tokenId: draftTransaction.asset.details.tokenId, + }); + 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. + txParams.to = draftTransaction.recipient.address; + txParams.value = draftTransaction.amount.value; + txParams.data = draftTransaction.userInputHexData ?? undefined; + } + + // We need to make sure that we only include the right gas fee fields + // based on the type of transaction the network supports. We will also set + // the type param here. + if (sendState.eip1559support) { + txParams.type = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + + txParams.maxFeePerGas = draftTransaction.gas.maxFeePerGas; + txParams.maxPriorityFeePerGas = draftTransaction.gas.maxPriorityFeePerGas; + + if (!txParams.maxFeePerGas || txParams.maxFeePerGas === '0x0') { + txParams.maxFeePerGas = draftTransaction.gas.gasPrice; + } + + if ( + !txParams.maxPriorityFeePerGas || + txParams.maxPriorityFeePerGas === '0x0' + ) { + txParams.maxPriorityFeePerGas = txParams.maxFeePerGas; + } + } else { + txParams.gasPrice = draftTransaction.gas.gasPrice; + txParams.type = TRANSACTION_ENVELOPE_TYPES.LEGACY; + } + + return txParams; +} + +/** + * This method is used to keep the original logic from the gas.duck.js file + * after receiving a gasPrice from eth_gasPrice. First, the returned gasPrice + * was converted to GWEI, then it was converted to a Number, then in the send + * duck (here) we would use getGasPriceInHexWei to get back to hexWei. Now that + * we receive a GWEI estimate from the controller, we still need to do this + * weird conversion to get the proper rounding. + * + * @param {string} gasPriceEstimate + * @returns {string} + */ +export function getRoundedGasPrice(gasPriceEstimate) { + const gasPriceInDecGwei = conversionUtil(gasPriceEstimate, { + numberOfDecimals: 9, + toDenomination: GWEI, + fromNumericBase: 'dec', + toNumericBase: 'dec', + fromCurrency: ETH, + fromDenomination: GWEI, + }); + const gasPriceAsNumber = Number(gasPriceInDecGwei); + return getGasPriceInHexWei(gasPriceAsNumber); +} + +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); +} diff --git a/ui/ducks/send/helpers.test.js b/ui/ducks/send/helpers.test.js new file mode 100644 index 000000000..a8ec656a9 --- /dev/null +++ b/ui/ducks/send/helpers.test.js @@ -0,0 +1,163 @@ +import { ethers } from 'ethers'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { + ASSET_TYPES, + TRANSACTION_ENVELOPE_TYPES, +} from '../../../shared/constants/transaction'; +import { BURN_ADDRESS } from '../../../shared/modules/hexstring-utils'; +import { getInitialSendStateWithExistingTxState } from '../../../test/jest/mocks'; +import { TOKEN_STANDARDS } from '../../helpers/constants/common'; +import { + generateERC20TransferData, + generateERC721TransferData, +} from '../../pages/send/send.utils'; +import { generateTransactionParams } from './helpers'; + +describe('Send Slice Helpers', () => { + describe('generateTransactionParams', () => { + it('should generate a txParams for a token transfer', () => { + const tokenDetails = { + address: '0xToken', + symbol: 'SYMB', + decimals: 18, + }; + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.TOKEN, + balance: '0xaf', + details: tokenDetails, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: generateERC20TransferData({ + toAddress: BURN_ADDRESS, + amount: '0x1', + sendToken: tokenDetails, + }), + to: '0xToken', + type: '0x0', + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a collectible transfer', () => { + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.COLLECTIBLE, + balance: '0xaf', + details: { + address: '0xToken', + standard: TOKEN_STANDARDS.ERC721, + tokenId: ethers.BigNumber.from(15000).toString(), + }, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: generateERC721TransferData({ + toAddress: BURN_ADDRESS, + fromAddress: '0x00', + tokenId: ethers.BigNumber.from(15000).toString(), + }), + to: '0xToken', + type: '0x0', + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a native legacy transaction', () => { + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0xaf', + details: null, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: undefined, + to: BURN_ADDRESS, + type: '0x0', + value: '0x1', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a native fee market transaction', () => { + const txParams = generateTransactionParams({ + ...getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0xaf', + details: null, + }, + recipient: { + address: BURN_ADDRESS, + }, + gas: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + gasLimit: GAS_LIMITS.SIMPLE, + }, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + }), + eip1559support: true, + }); + expect(txParams).toStrictEqual({ + from: '0x00', + data: undefined, + to: BURN_ADDRESS, + type: '0x2', + value: '0x1', + gas: GAS_LIMITS.SIMPLE, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + }); + }); + }); +}); diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 2b3d59f12..4ca6b6ca4 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -1,8 +1,8 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import abi from 'human-standard-token-abi'; import BigNumber from 'bignumber.js'; import { addHexPrefix } from 'ethereumjs-util'; import { debounce } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; import { conversionGreaterThan, conversionUtil, @@ -17,26 +17,19 @@ import { 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, - generateERC20TransferData, - generateERC721TransferData, - getAssetTransferData, isBalanceSufficient, isTokenBalanceSufficient, } from '../../pages/send/send.utils'; import { - getAddressBookEntry, getAdvancedInlineGasShown, getCurrentChainId, getGasPriceInHexWei, getIsMainnet, - getSelectedAddress, getTargetAccount, getIsNonStandardEthChain, checkNetworkAndAccountSupports1559, @@ -45,11 +38,12 @@ import { getAddressBookEntryOrAccountName, getIsMultiLayerFeeNetwork, getEnsResolutionByAddress, + getSelectedAccount, + getSelectedAddress, } from '../../selectors'; import { disconnectGasFeeEstimatePoller, displayWarning, - estimateGas, getGasFeeEstimatesAndStartPolling, hideLoadingIndication, showLoadingIndication, @@ -75,6 +69,7 @@ import { calcTokenAmount, getTokenAddressParam, getTokenValueParam, + getTokenMetadata, } from '../../helpers/utils/token-util'; import { checkExistingAddresses, @@ -97,17 +92,21 @@ import { import { sumHexes } from '../../helpers/utils/transactions.util'; import fetchEstimatedL1Fee from '../../helpers/utils/optimism/fetchEstimatedL1Fee'; -import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; -import { TOKEN_STANDARDS, ETH, GWEI } from '../../helpers/constants/common'; +import { TOKEN_STANDARDS, ETH } from '../../helpers/constants/common'; import { ASSET_TYPES, TRANSACTION_ENVELOPE_TYPES, TRANSACTION_TYPES, } from '../../../shared/constants/transaction'; -import { readAddressAsContract } from '../../../shared/modules/contract-utils'; import { INVALID_ASSET_TYPE } from '../../helpers/constants/error-keys'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; +import { parseStandardTokenTransactionData } from '../../../shared/modules/transaction.utils'; +import { + estimateGasLimitForSend, + generateTransactionParams, + getRoundedGasPrice, +} from './helpers'; // typedef import statements /** * @typedef {( @@ -120,6 +119,9 @@ import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; * import( '../../helpers/constants/common').TokenStandardStrings * )} TokenStandardStrings * @typedef {( + * import( '../../../shared/constants/tokens').TokenDetails + * )} TokenDetails + * @typedef {( * import('../../../shared/constants/transaction').TransactionTypeString * )} TransactionTypeString * @typedef {( @@ -134,14 +136,28 @@ import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; * @typedef {( * import('@metamask/controllers').GasEstimateType * )} GasEstimateType + * @typedef {( + * import('redux').AnyAction + * )} AnyAction */ -const name = 'send'; +/** + * @template R - Return type of the async function + * @typedef {( + * import('redux-thunk').ThunkAction<R, MetaMaskState, unknown, AnyAction> + * )} ThunkAction<R> + */ + +/** + * This type will take a typical constant string mapped object and turn it into + * a union type of the values. + * + * @template O - The object to make strings out of + * @typedef {O[keyof O]} MapValuesToUnion<O> + */ /** * @typedef {Object} SendStateStages - * @property {'INACTIVE'} INACTIVE - The send state is idle, and hasn't yet - * fetched required data for gasPrice and gasLimit estimations, etc. * @property {'ADD_RECIPIENT'} ADD_RECIPIENT - The user is selecting which * address to send an asset to. * @property {'DRAFT'} DRAFT - The send form is shown for a transaction yet to @@ -150,13 +166,8 @@ const name = 'send'; * 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. - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above Stages - * - * @typedef {SendStateStages[keyof SendStateStages]} SendStateStagesStrings + * @property {'INACTIVE'} INACTIVE - The send state is idle, and hasn't yet + * fetched required data for gasPrice and gasLimit estimations, etc. */ /** @@ -165,15 +176,14 @@ const name = 'send'; * @type {SendStateStages} */ export const SEND_STAGES = { - INACTIVE: 'INACTIVE', ADD_RECIPIENT: 'ADD_RECIPIENT', DRAFT: 'DRAFT', EDIT: 'EDIT', + INACTIVE: 'INACTIVE', }; /** - * @typedef {Object} SendStateStatuses - * @property {'VALID'} VALID - The transaction is valid and can be submitted. + * @typedef {Object} DraftTxStatus * @property {'INVALID'} INVALID - The transaction is invalid and cannot be * submitted. There are a number of cases that would result in an invalid * send state: @@ -184,41 +194,28 @@ export const SEND_STAGES = { * 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) - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above statuses - * - * @typedef {SendStateStatuses[keyof SendStateStatuses]} SendStateStatusStrings + * @property {'VALID'} VALID - The transaction is valid and can be submitted. */ /** * The status of the send slice * - * @type {SendStateStatuses} + * @type {DraftTxStatus} */ export const SEND_STATUSES = { - VALID: 'VALID', INVALID: 'INVALID', + VALID: 'VALID', }; /** * @typedef {Object} SendStateGasModes * @property {'BASIC'} BASIC - Shows the basic estimate slow/avg/fast buttons * when on mainnet and the metaswaps API request is successful. - * @property {'INLINE'} INLINE - Shows inline gasLimit/gasPrice fields when on - * any other network or metaswaps API fails and we use eth_gasPrice. * @property {'CUSTOM'} 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). - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above gas modes - * - * @typedef {SendStateGasModes[keyof SendStateGasModes]} SendStateGasModeStrings + * @property {'INLINE'} INLINE - Shows inline gasLimit/gasPrice fields when on + * any other network or metaswaps API fails and we use eth_gasPrice. */ /** @@ -228,8 +225,8 @@ export const SEND_STATUSES = { */ export const GAS_INPUT_MODES = { BASIC: 'BASIC', - INLINE: 'INLINE', CUSTOM: 'CUSTOM', + INLINE: 'INLINE', }; /** @@ -240,13 +237,6 @@ export const GAS_INPUT_MODES = { * calculated based on balance - (amount + gasTotal). */ -/** - * This type will work anywhere you expect a string that can be one of the - * above gas modes - * - * @typedef {SendStateAmountModes[keyof SendStateAmountModes]} SendStateAmountModeStrings - */ - /** * The modes that the amount field can be set by * @@ -259,17 +249,10 @@ export const AMOUNT_MODES = { /** * @typedef {Object} SendStateRecipientModes - * @property {'MY_ACCOUNTS'} MY_ACCOUNTS - the user is displayed a list of - * their own accounts to send to. * @property {'CONTACT_LIST'} CONTACT_LIST - The user is displayed a list of * their contacts and addresses they have recently send to. - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above recipient modes - * - * @typedef {SendStateRecipientModes[keyof SendStateRecipientModes]} SendStateRecipientModeStrings + * @property {'MY_ACCOUNTS'} MY_ACCOUNTS - the user is displayed a list of + * their own accounts to send to. */ /** @@ -278,168 +261,217 @@ export const AMOUNT_MODES = { * @type {SendStateRecipientModes} */ export const RECIPIENT_SEARCH_MODES = { - MY_ACCOUNTS: 'MY_ACCOUNTS', CONTACT_LIST: 'CONTACT_LIST', + MY_ACCOUNTS: 'MY_ACCOUNTS', }; -async function estimateGasLimitForSend({ - selectedAddress, - value, - gasPrice, - sendToken, - to, - data, - isNonStandardEthChain, - chainId, - gasLimit, - ...options -}) { - let isSimpleSendOnNonStandardNetwork = false; +/** + * @typedef {Object} Account + * @property {string} address - The hex address of the account. + * @property {string} balance - Hex string representing the native asset + * balance of the account the transaction will be sent from. + */ - // 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. Some - // network implementations check the gas parameter supplied to - // eth_estimateGas for validity. For this reason, we set token sends - // blockGasLimit default to a higher number. Note that the current gasLimit - // on a BLOCK is 15,000,000 and will be 30,000,000 on mainnet after London. - // Meanwhile, MIN_GAS_LIMIT_HEX is 0x5208. - let blockGasLimit = MIN_GAS_LIMIT_HEX; - if (options.blockGasLimit) { - blockGasLimit = options.blockGasLimit; - } else if (sendToken) { - blockGasLimit = GAS_LIMITS.BASE_TOKEN_ESTIMATE; - } +/** + * @typedef {Object} Amount + * @property {string} [error] - Error to display for the amount field. + * @property {string} value - A hex string representing the amount of the + * selected currency to send. + */ - // 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 }; +/** + * @typedef {Object} Asset + * @property {string} balance - A hex string representing the balance + * that the user holds of the asset that they are attempting to send. + * @property {TokenDetails} [details] - An object that describes the + * selected asset in the case that the user is sending a token or collectibe. + * Will be null when asset.type is 'NATIVE'. + * @property {string} [error] - Error to display when there is an issue + * with the asset. + * @property {AssetTypesString} type - The type of asset that the user + * is attempting to send. Defaults to 'NATIVE' which represents the native + * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. + */ - 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'; +/** + * @typedef {Object} GasFees + * @property {string} [error] - error to display for gas fields. + * @property {string} gasLimit - maximum gas needed for tx. + * @property {string} gasPrice - price in wei to pay per gas. + * @property {string} gasTotal - maximum total price in wei to pay. + * @property {string} maxFeePerGas - Maximum price in wei to pay per gas. + * @property {string} maxPriorityFeePerGas - Maximum priority fee in wei to pay + * per gas. + */ - // We have to generate the erc20/erc721 contract call to transfer tokens in - // order to get a proper estimate for gasLimit. - paramsForGasEstimate.data = getAssetTransferData({ - sendToken, - fromAddress: selectedAddress, - toAddress: to, - amount: value, - }); +/** + * An object that describes the intended recipient of a transaction. + * + * @typedef {Object} Recipient + * @property {string} address - The fully qualified address of the recipient. + * This is set after the recipient.userInput is validated, the userInput field + * is quickly updated to avoid delay between keystrokes and seeing the input + * field updated. After a debounce the address typed is validated and then the + * address field is updated. The address field is also set when the user + * selects a contact or account from the list, or an ENS resolution when + * typing ENS names. + * @property {string} [error] - Error to display on the address field. + * @property {string} nickname - The nickname that the user has added to their + * address book for the recipient.address. + * @property {string} [warning] - Warning to display on the address field. + */ - 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 { isContractAddress } = to - ? await readAddressAsContract(global.eth, to) - : {}; - if (!isContractAddress && !isNonStandardEthChain) { - return GAS_LIMITS.SIMPLE; - } else if (!isContractAddress && isNonStandardEthChain) { - isSimpleSendOnNonStandardNetwork = true; - } - } +/** + * @typedef {Object} DraftTransaction + * @property {Amount} amount - An object containing information about the + * amount of currency to send. + * @property {Asset} asset - An object that describes the asset that the user + * has selected to send. + * @property {Account} [fromAccount] - The send flow is usually only relative to + * the currently selected account. When editing a transaction, however, the + * account may differ. In that case, the details of that account will be + * stored in this object within the draftTransaction. + * @property {GasFees} gas - Details about the current gas settings + * @property {Array<{event: string, timestamp: number}>} history - An array of + * entries that describe the user's journey through the send flow. This is + * sent to the controller for attaching to state logs for troubleshooting and + * support. + * @property {string} [id] - If the transaction has already been added to the + * TransactionController this field will be populated with its id from the + * TransactionController state. This is required to be able to update the + * transaction in the controller. + * @property {Recipient} recipient - An object that describes the intended + * recipient of the transaction. + * @property {MapValuesToUnion<DraftTxStatus>} status - Describes the + * validity of the draft transaction, which will be either 'VALID' or + * 'INVALID', depending on our ability to generate a valid txParams object for + * submission. + * @property {string} transactionType - Determines type of transaction being + * sent, defaulted to 0x0 (legacy). + * @property {string} [userInputHexData] - When a user has enabled custom hex + * data field in advanced options, they can supply data to the field which is + * stored under this key. + */ - paramsForGasEstimate.data = data; +/** + * @type {DraftTransaction} + */ +export const draftTransactionInitialState = { + amount: { + error: null, + value: '0x0', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: null, + gas: { + error: null, + gasLimit: '0x0', + gasPrice: '0x0', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: [], + id: null, + recipient: { + address: '', + error: null, + nickname: '', + warning: null, + recipientWarningAcknowledged: false, + }, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + userInputHexData: null, +}; - if (to) { - paramsForGasEstimate.to = to; - } +/** + * Describes the state tree of the send slice + * + * @typedef {Object} SendState + * @property {MapValuesToUnion<SendStateAmountModes>} amountMode - Describe + * whether the user has manually input an amount or if they have selected max + * to send the maximum amount of the selected currency. + * @property {string} currentTransactionUUID - The UUID of the transaction + * currently being modified by the send flow. This UUID is generated upon + * initialization of the send flow, any previous UUIDs are discarded at + * clean up AND during initialization. When a transaction is edited a new UUID + * is generated for it and the state of that transaction is copied into a new + * entry in the draftTransactions object. + * @property {Object.<string, DraftTransaction>} draftTransactions - An object keyed + * by UUID with draftTransactions as the values. + * @property {boolean} eip1559support - tracks whether the current network + * supports EIP 1559 transactions. + * @property {boolean} gasEstimateIsLoading - Indicates whether the gas + * estimate is loading. + * @property {string} [gasEstimatePollToken] - String token identifying a + * listener for polling on the gasFeeController + * @property {boolean} gasIsSetInModal - true if the user set custom gas in the + * custom gas modal + * @property {string} gasLimitMinimum - minimum supported gasLimit. + * @property {string} gasPriceEstimate - Expected price in wei necessary to + * pay per gas used for a transaction to be included in a reasonable timeframe. + * Comes from the GasFeeController. + * @property {string} gasTotalForLayer1 - Layer 1 gas fee total on multi-layer + * fee networks + * @property {string} recipientInput - The user input of the recipient + * which is updated quickly to avoid delays in the UI reflecting manual entry + * of addresses. + * @property {MapValuesToUnion<SendStateRecipientModes>} recipientMode - + * Describes which list of recipients the user is shown on the add recipient + * screen. When this key is set to 'MY_ACCOUNTS' the user is shown the list of + * accounts they own. When it is 'CONTACT_LIST' the user is shown the list of + * contacts they have saved in MetaMask and any addresses they have recently + * sent to. + * @property {Account} selectedAccount - The currently selected account in + * MetaMask. Native balance and address will be pulled from this account if a + * fromAccount is not specified in the draftTransaction object. During an edit + * the fromAccount is specified. + * @property {MapValuesToUnion<SendStateStages>} stage - The stage of the + * send flow that the user has progressed to. Defaults to 'INACTIVE' which + * results in the send screen not being shown. + */ - 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'; - } - } +/** + * @type {SendState} + */ +export const initialState = { + amountMode: AMOUNT_MODES.INPUT, + currentTransactionUUID: null, + draftTransactions: {}, + eip1559support: false, + gasEstimateIsLoading: true, + gasEstimatePollToken: null, + gasIsSetInModal: false, + gasPriceEstimate: '0x0', + gasLimitMinimum: GAS_LIMITS.SIMPLE, + gasTotalForLayer1: '0x0', + recipientMode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + recipientInput: '', + selectedAccount: { + address: null, + balance: '0x0', + }, + stage: SEND_STAGES.INACTIVE, +}; - 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. +/** + * TODO: We really need to start creating the metamask state type, and the + * entire state tree of redux. Would be *extremely* valuable in future + * typescript conversions. The metamask key is typed as an object on purpose + * here because I cannot go so far in this work as to type that entire object. + * + * @typedef {Object} MetaMaskState + * @property {SendState} send - The state of the send flow. + * @property {Object} metamask - The state of the metamask store. + */ - 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. - 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 - // execution on the node and return an estimate of gasLimit - const estimatedGasLimit = await estimateGas(paramsForGasEstimate); - const estimateWithBuffer = addGasBuffer( - estimatedGasLimit, - blockGasLimit, - bufferMultiplier, - ); - return addHexPrefix(estimateWithBuffer); - } catch (error) { - const simulationFailed = - error.message.includes('Transaction execution error.') || - error.message.includes( - 'gas required exceeds allowance or always failing transaction', - ) || - (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId] && - error.message.includes('gas required exceeds allowance')); - if (simulationFailed) { - const estimateWithBuffer = addGasBuffer( - paramsForGasEstimate?.gas ?? gasLimit, - blockGasLimit, - bufferMultiplier, - ); - 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); -} +const name = 'send'; // After modification of specific fields in specific circumstances we must // recompute the gasLimit estimate to be as accurate as possible. the cases @@ -463,25 +495,27 @@ export const computeEstimatedGasLimit = createAsyncThunk( async (_, thunkApi) => { const state = thunkApi.getState(); const { send, metamask } = state; + const draftTransaction = + send.draftTransactions[send.currentTransactionUUID]; const unapprovedTxs = getUnapprovedTxs(state); const isMultiLayerFeeNetwork = getIsMultiLayerFeeNetwork(state); - const transaction = unapprovedTxs[send.id]; + const transaction = unapprovedTxs[draftTransaction.id]; const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); - let layer1GasTotal; + let gasTotalForLayer1; if (isMultiLayerFeeNetwork) { - layer1GasTotal = await fetchEstimatedL1Fee(global.eth, { + gasTotalForLayer1 = await fetchEstimatedL1Fee(global.eth, { txParams: { - gasPrice: send.gas.gasPrice, - gas: send.gas.gasLimit, - to: send.recipient.address?.toLowerCase(), + gasPrice: draftTransaction.gas.gasPrice, + gas: draftTransaction.gas.gasLimit, + to: draftTransaction.recipient.address?.toLowerCase(), value: - send.amount.mode === 'MAX' - ? send.account.balance + send.amountMode === AMOUNT_MODES.MAX + ? send.selectedAccount.balance : send.amount.value, - from: send.account.address, - data: send.userInputHexData, + from: send.selectedAccount.address, + data: draftTransaction.userInputHexData, type: '0x0', }, }); @@ -493,21 +527,21 @@ export const computeEstimatedGasLimit = createAsyncThunk( !transaction.userEditedGasLimit ) { const gasLimit = await estimateGasLimitForSend({ - gasPrice: send.gas.gasPrice, + gasPrice: draftTransaction.gas.gasPrice, blockGasLimit: metamask.currentBlockGasLimit, selectedAddress: metamask.selectedAddress, - sendToken: send.asset.details, - to: send.recipient.address?.toLowerCase(), - value: send.amount.value, - data: send.userInputHexData, + sendToken: draftTransaction.asset.details, + to: draftTransaction.recipient.address?.toLowerCase(), + value: draftTransaction.amount.value, + data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, - gasLimit: send.gas.gasLimit, + gasLimit: draftTransaction.gas.gasLimit, }); await thunkApi.dispatch(setCustomGasLimit(gasLimit)); return { gasLimit, - layer1GasTotal, + gasTotalForLayer1, }; } return null; @@ -515,28 +549,18 @@ export const computeEstimatedGasLimit = createAsyncThunk( ); /** - * This method is used to keep the original logic from the gas.duck.js file - * after receiving a gasPrice from eth_gasPrice. First, the returned gasPrice - * was converted to GWEI, then it was converted to a Number, then in the send - * duck (here) we would use getGasPriceInHexWei to get back to hexWei. Now that - * we receive a GWEI estimate from the controller, we still need to do this - * weird conversion to get the proper rounding. - * - * @param {string} gasPriceEstimate - * @returns {string} + * @typedef {Object} Asset + * @property {AssetTypesString} type - The type of asset that the user + * is attempting to send. Defaults to 'NATIVE' which represents the native + * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. + * @property {string} balance - A hex string representing the balance + * that the user holds of the asset that they are attempting to send. + * @property {TokenDetails} [details] - An object that describes the + * selected asset in the case that the user is sending a token or collectibe. + * Will be null when asset.type is 'NATIVE'. + * @property {string} [error] - Error to display when there is an issue + * with the asset. */ -function getRoundedGasPrice(gasPriceEstimate) { - const gasPriceInDecGwei = conversionUtil(gasPriceEstimate, { - numberOfDecimals: 9, - toDenomination: GWEI, - fromNumericBase: 'dec', - toNumericBase: 'dec', - fromCurrency: ETH, - fromDenomination: GWEI, - }); - const gasPriceAsNumber = Number(gasPriceInDecGwei); - return getGasPriceInHexWei(gasPriceAsNumber); -} /** * Responsible for initializing required state for the send slice. @@ -550,34 +574,43 @@ function getRoundedGasPrice(gasPriceEstimate) { */ export const initializeSendState = createAsyncThunk( 'send/initializeSendState', - async (_, thunkApi) => { + async ({ chainHasChanged = false } = {}, thunkApi) => { + /** + * @typedef {Object} ReduxState + * @property {Object} metamask - Half baked type for the MetaMask object + * @property {SendState} send - the send state + */ + + /** + * @type {ReduxState} + */ const state = thunkApi.getState(); const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); const eip1559support = checkNetworkAndAccountSupports1559(state); - const { - send: { asset, stage, recipient, amount, userInputHexData }, - metamask, - } = state; + const account = getSelectedAccount(state); + const { send: sendState, metamask } = state; + const draftTransaction = + sendState.draftTransactions[sendState.currentTransactionUUID]; - // 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 - ? state.send.account.address - : 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); + // If the draft transaction is not present, then this action has been + // dispatched out of sync with the intended flow. This is not always a bug. + // For instance, in the actions.js file we dispatch this action anytime the + // chain changes. + if (!draftTransaction) { + thunkApi.rejectWithValue( + 'draftTransaction not found, possibly not on send flow', + ); + } // Default gasPrice to 1 gwei if all estimation fails, this is only used // for gasLimit estimation and won't be set directly in state. Instead, we // will return the gasFeeEstimates and gasEstimateType so that the reducer // can set the appropriate gas fees in state. - let gasPrice = '0x1'; + let gasPrice = + sendState.stage === SEND_STAGES.EDIT + ? draftTransaction.gas.gasPrice + : '0x1'; let gasEstimatePollToken = null; // Instruct the background process that polling for gas prices should begin @@ -589,43 +622,49 @@ export const initializeSendState = createAsyncThunk( metamask: { gasFeeEstimates, gasEstimateType }, } = thunkApi.getState(); - // Because we are only interested in getting a gasLimit estimation we only - // need to worry about gasPrice. So we use maxFeePerGas as gasPrice if we - // have a fee market estimation. - if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { - gasPrice = getGasPriceInHexWei(gasFeeEstimates.medium); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { - gasPrice = getRoundedGasPrice(gasFeeEstimates.gasPrice); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - gasPrice = getGasPriceInHexWei( - gasFeeEstimates.medium.suggestedMaxFeePerGas, - ); - } else { - gasPrice = gasFeeEstimates.gasPrice - ? getRoundedGasPrice(gasFeeEstimates.gasPrice) - : '0x0'; + if (sendState.stage !== SEND_STAGES.EDIT) { + // Because we are only interested in getting a gasLimit estimation we only + // need to worry about gasPrice. So we use maxFeePerGas as gasPrice if we + // have a fee market estimation. + if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + gasPrice = getGasPriceInHexWei(gasFeeEstimates.medium); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { + gasPrice = getRoundedGasPrice(gasFeeEstimates.gasPrice); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + gasPrice = getGasPriceInHexWei( + gasFeeEstimates.medium.suggestedMaxFeePerGas, + ); + } else { + gasPrice = gasFeeEstimates.gasPrice + ? getRoundedGasPrice(gasFeeEstimates.gasPrice) + : '0x0'; + } } // Set a basic gasLimit in the event that other estimation fails - let gasLimit = - asset.type === ASSET_TYPES.TOKEN || asset.type === ASSET_TYPES.COLLECTIBLE - ? GAS_LIMITS.BASE_TOKEN_ESTIMATE - : GAS_LIMITS.SIMPLE; + let { gasLimit } = draftTransaction.gas; if ( gasEstimateType !== GAS_ESTIMATE_TYPES.NONE && - stage !== SEND_STAGES.EDIT && - recipient.address + sendState.stage !== SEND_STAGES.EDIT && + draftTransaction.recipient.address ) { + gasLimit = + draftTransaction.asset.type === ASSET_TYPES.TOKEN || + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE + ? GAS_LIMITS.BASE_TOKEN_ESTIMATE + : GAS_LIMITS.SIMPLE; // 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, blockGasLimit: metamask.currentBlockGasLimit, - selectedAddress: fromAddress, - sendToken: asset.details, - to: recipient.address.toLowerCase(), - value: amount.value, - data: userInputHexData, + selectedAddress: + draftTransaction.fromAccount?.address ?? + sendState.selectedAccount.address, + sendToken: draftTransaction.asset.details, + to: draftTransaction.recipient.address.toLowerCase(), + value: draftTransaction.amount.value, + data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, }); @@ -634,38 +673,11 @@ export const initializeSendState = createAsyncThunk( // We have to keep the gas slice in sync with the send slice state // 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); - } - - if (asset.type === ASSET_TYPES.COLLECTIBLE) { - if (asset.details === null) { - // If we're sending a collectible but details have not been provided we must - // abort and set the send slice into invalid status. - throw new Error( - 'Send slice initialized as collectibles send without token details', - ); - } - balance = '0x1'; - } return { - address: fromAddress, - nativeBalance: account.balance, - assetBalance: balance, + account, chainId: getCurrentChainId(state), tokens: getTokens(state), + chainHasChanged, gasFeeEstimates, gasEstimateType, gasLimit, @@ -678,275 +690,187 @@ export const initializeSendState = createAsyncThunk( }, ); +// Action Payload Typedefs /** - * @typedef {Object} SendState - * @property {string} [id] - The id of a transaction that is being edited - * @property {SendStateStagesStrings} stage - The stage of the send flow that - * the user has progressed to. Defaults to 'INACTIVE' which results in the - * send screen not being shown. - * @property {SendStateStatusStrings} status - The status of the send slice - * which will be either 'VALID' or 'INVALID' - * @property {string} transactionType - Determines type of transaction being - * sent, defaulted to 0x0 (legacy). - * @property {boolean} eip1559support - tracks whether the current network - * supports EIP 1559 transactions. - * @property {Object} account - Details about the user's account. - * @property {string} [account.address] - from account address, defaults to - * selected account. will be the account the original transaction was sent - * from in the case of the EDIT stage. - * @property {string} [account.balance] - Hex string representing the balance - * of the from account. - * @property {string} [userInputHexData] - When a user has enabled custom hex - * data field in advanced options, they can supply data to the field which is - * stored under this key. - * @property {Object} gas - Details about the current gas settings - * @property {boolean} gas.isGasEstimateLoading - Indicates whether the gas - * estimate is loading. - * @property {string} [gas.gasEstimatePollToken] - String token identifying a - * listener for polling on the gasFeeController - * @property {boolean} gas.isCustomGasSet - true if the user set custom gas in - * the custom gas modal - * @property {string} gas.gasLimit - maximum gas needed for tx. - * @property {string} gas.gasPrice - price in wei to pay per gas. - * @property {string} gas.maxFeePerGas - Maximum price in wei to pay per gas. - * @property {string} gas.maxPriorityFeePerGas - Maximum priority fee in wei to - * pay per gas. - * @property {string} gas.gasPriceEstimate - Expected price in wei necessary to - * pay per gas used for a transaction to be included in a reasonable timeframe. - * Comes from the GasFeeController. - * @property {string} gas.gasTotal - maximum total price in wei to pay. - * @property {string} gas.minimumGasLimit - minimum supported gasLimit. - * @property {string} [gas.error] - error to display for gas fields. - * @property {Object} amount - An object containing information about the - * amount of currency to send. - * @property {SendStateAmountModeStrings} amount.mode - Describe whether the - * user has manually input an amount or if they have selected max to send the - * maximum amount of the selected currency. - * @property {string} amount.value - A hex string representing the amount of - * the selected currency to send. - * @property {string} [amount.error] - Error to display for the amount field. - * @property {Object} asset - An object that describes the asset that the user - * has selected to send. - * @property {AssetTypesString} asset.type - The type of asset that the user - * is attempting to send. Defaults to 'NATIVE' which represents the native - * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. - * @property {string} asset.balance - A hex string representing the balance - * that the user holds of the asset that they are attempting to send. - * @property {Object} [asset.details] - An object that describes the selected - * asset in the case that the user is sending a token or collectibe. Will be - * null when asset.type is 'NATIVE'. - * @property {string} [asset.details.address] - The address of the selected - * 'TOKEN' or 'COLLECTIBLE' contract. - * @property {string} [asset.details.symbol] - The symbol of the selected - * asset. - * @property {number} [asset.details.decimals] - The number of decimals of the - * selected 'TOKEN' asset. - * @property {number} [asset.details.tokenId] - The id of the selected - * 'COLLECTIBLE' asset. - * @property {TokenStandardStrings} [asset.details.standard] - The standard - * of the selected 'TOKEN' or 'COLLECTIBLE' asset. - * @property {boolean} [asset.details.isERC721] - True when the asset is a - * ERC721 token. - * @property {string} [asset.error] - Error to display when there is an issue - * with the asset. - * @property {Object} recipient - An object that describes the intended - * recipient of the transaction. - * @property {SendStateRecipientModeStrings} recipient.mode - Describes which - * list of recipients the user is shown on the add recipient screen. When this - * key is set to 'MY_ACCOUNTS' the user is shown the list of accounts they - * own. When it is 'CONTACT_LIST' the user is shown the list of contacts they - * have saved in MetaMask and any addresses they have recently sent to. - * @property {string} recipient.address - The fully qualified address of the - * recipient. This is set after the recipient.userInput is validated, the - * userInput field is quickly updated to avoid delay between keystrokes and - * seeing the input field updated. After a debounc the address typed is - * validated and then the address field is updated. The address field is also - * set when the user selects a contact or account from the list, or an ENS - * resolution when typing ENS names. - * @property {string} recipient.userInput - The user input of the recipient - * which is updated quickly to avoid delays in the UI reflecting manual entry - * of addresses. - * @property {string} recipient.nickname - The nickname that the user has added - * to their address book for the recipient.address. - * @property {string} [recipient.error] - Error to display on the address field. - * @property {string} [recipient.warning] - Warning to display on the address - * field. - * @property {Object} multiLayerFees - An object containing attributes for use - * on chains that have layer 1 and layer 2 fees to consider for gas - * calculations. - * @property {string} multiLayerFees.layer1GasTotal - Layer 1 gas fee total on - * multi-layer fee networks - * @property {Array<{event: string, timestamp: number}>} history - An array of - * entries that describe the user's journey through the send flow. This is - * sent to the controller for attaching to state logs for troubleshooting and - * support. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<string> + * )} SimpleStringPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<MapValuesToUnion<SendStateAmountModes>> + * )} SendStateAmountModePayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<DraftTransaction['asset']> + * )} UpdateAssetPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<Partial< + * Pick<DraftTransaction['recipient'], 'address' | 'nickname'>> + * > + * )} updateRecipientPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<SendState['recipientMode']> + * )} UpdateRecipientModePayload */ /** - * @type {SendState} + * @typedef {Object} GasFeeUpdateParams + * @property {TransactionTypeString} transactionType - The transaction type + * @property {string} [maxFeePerGas] - The maximum amount in hex wei to pay + * per gas on a FEE_MARKET transaction. + * @property {string} [maxPriorityFeePerGas] - The maximum amount in hex + * wei to pay per gas as an incentive to miners on a FEE_MARKET + * transaction. + * @property {string} [gasPrice] - The amount in hex wei to pay per gas on + * a LEGACY transaction. + * @property {boolean} [isAutomaticUpdate] - true if the update is the + * result of a gas estimate update from the controller. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<GasFeeUpdateParams> + * )} GasFeeUpdatePayload */ -export const initialState = { - id: null, - stage: SEND_STAGES.INACTIVE, - status: SEND_STATUSES.VALID, - transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, - eip1559support: false, - account: { - address: null, - balance: '0x0', - }, - userInputHexData: null, - gas: { - isGasEstimateLoading: true, - gasEstimatePollToken: null, - isCustomGasSet: false, - gasLimit: '0x0', - gasPrice: '0x0', - maxFeePerGas: '0x0', - maxPriorityFeePerGas: '0x0', - gasPriceEstimate: '0x0', - gasTotal: '0x0', - minimumGasLimit: GAS_LIMITS.SIMPLE, - error: null, - }, - amount: { - mode: AMOUNT_MODES.INPUT, - value: '0x0', - error: null, - }, - asset: { - type: ASSET_TYPES.NATIVE, - balance: '0x0', - details: null, - error: null, - }, - recipient: { - mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, - userInput: '', - address: '', - nickname: '', - error: null, - warning: null, - }, - multiLayerFees: { - layer1GasTotal: '0x0', - }, - history: [], -}; /** - * Generates a txParams from the send slice. - * - * @param {SendState} state - the Send slice state - * @returns {import( - * '../../../shared/constants/transaction' - * ).TxParams} A txParams object that can be used to create a transaction or - * update an existing transaction. + * @typedef {Object} GasEstimateUpdateParams + * @property {GasEstimateType} gasEstimateType - The type of gas estimation + * provided by the controller. + * @property {( + * EthGasPriceEstimate | LegacyGasPriceEstimate | GasFeeEstimates + * )} gasFeeEstimates - The gas fee estimates provided by the controller. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<GasEstimateUpdateParams> + * )} GasEstimateUpdatePayload */ -function generateTransactionParams(state) { - const txParams = { - from: state.account.address, - // gasLimit always needs to be set regardless of the asset being sent - // or the type of transaction. - gas: state.gas.gasLimit, - }; - 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. - txParams.to = state.asset.details.address; - txParams.value = '0x0'; - txParams.data = generateERC20TransferData({ - toAddress: state.recipient.address, - amount: state.amount.value, - sendToken: state.asset.details, - }); - break; - case ASSET_TYPES.COLLECTIBLE: - // 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. - txParams.to = state.asset.details.address; - txParams.value = '0x0'; - txParams.data = generateERC721TransferData({ - toAddress: state.recipient.address, - fromAddress: state.account.address, - tokenId: state.asset.details.tokenId, - }); - 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. - txParams.to = state.recipient.address; - txParams.value = state.amount.value; - txParams.data = state.userInputHexData ?? undefined; - } - // We need to make sure that we only include the right gas fee fields - // based on the type of transaction the network supports. We will also set - // the type param here. - if (state.eip1559support) { - txParams.type = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; - - txParams.maxFeePerGas = state.gas.maxFeePerGas; - txParams.maxPriorityFeePerGas = state.gas.maxPriorityFeePerGas; - - if (!txParams.maxFeePerGas || txParams.maxFeePerGas === '0x0') { - txParams.maxFeePerGas = state.gas.gasPrice; - } - - if ( - !txParams.maxPriorityFeePerGas || - txParams.maxPriorityFeePerGas === '0x0' - ) { - txParams.maxPriorityFeePerGas = txParams.maxFeePerGas; - } - } else { - txParams.gasPrice = state.gas.gasPrice; - txParams.type = TRANSACTION_ENVELOPE_TYPES.LEGACY; - } - - return txParams; -} +/** + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<DraftTransaction['asset']> + * )} UpdateAssetPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction<DraftTransaction> + * )} DraftTransactionPayload + */ const slice = createSlice({ name, initialState, reducers: { - addHistoryEntry: (state, action) => { - state.history.push({ - entry: action.payload, - timestamp: Date.now(), - }); - }, /** - * update current amount.value in state and run post update validation of - * the amount field and the send state. + * Adds a new draft transaction to state, first generating a new UUID for + * the transaction and setting that as the currentTransactionUUID. If the + * draft has an id property set, the stage is set to EDIT. * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction<string>} action - The - * hex string to be set as the amount value. + * @param {DraftTransactionPayload} action - An action with payload that is + * a new draft transaction that will be added to state. + * @returns {void} */ - 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); + addNewDraft: (state, action) => { + state.currentTransactionUUID = uuidv4(); + state.draftTransactions[state.currentTransactionUUID] = action.payload; + if (action.payload.id) { + state.stage = SEND_STAGES.EDIT; + } else { + state.stage = SEND_STAGES.ADD_RECIPIENT; } + }, + /** + * Adds an entry, with timestamp, to the draftTransaction history. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - An action with payload that is + * a string to be added to the history of the draftTransaction + * @returns {void} + */ + addHistoryEntry: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction) { + draftTransaction.history.push({ + entry: action.payload, + timestamp: Date.now(), + }); + } + }, + /** + * 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. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + calculateGasTotal: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + // use maxFeePerGas as the multiplier if working with a FEE_MARKET transaction + // otherwise use gasPrice + if ( + draftTransaction.transactionType === + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET + ) { + draftTransaction.gas.gasTotal = addHexPrefix( + calcGasTotal( + draftTransaction.gas.gasLimit, + draftTransaction.gas.maxFeePerGas, + ), + ); + } else { + draftTransaction.gas.gasTotal = addHexPrefix( + calcGasTotal( + draftTransaction.gas.gasLimit, + draftTransaction.gas.gasPrice, + ), + ); + } + if ( + state.amountMode === AMOUNT_MODES.MAX && + draftTransaction.asset.type === ASSET_TYPES.NATIVE + ) { + slice.caseReducers.updateAmountToMax(state); + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); // validate send state slice.caseReducers.validateSendState(state); }, + /** + * Clears all drafts from send state and drops the currentTransactionUUID. + * This is an important first step before adding a new draft transaction to + * avoid possible collision. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + clearPreviousDrafts: (state) => { + state.currentTransactionUUID = null; + state.draftTransactions = {}; + }, + /** + * Clears the send state by setting it to the initial value + * + * @returns {SendState} + */ + resetSendState: () => initialState, + /** + * sets the amount mode to the provided value as long as it is one of the + * supported modes (MAX|INPUT) + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SendStateAmountModePayload} action - The amount mode + * to set the state to. + * @returns {void} + */ + updateAmountMode: (state, action) => { + if (Object.values(AMOUNT_MODES).includes(action.payload)) { + state.amountMode = action.payload; + } + }, /** * computes the maximum amount of asset that can be sent and then calls * the updateSendAmount action above with the computed value, which will @@ -954,25 +878,32 @@ const slice = createSlice({ * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. + * @returns {void} */ updateAmountToMax: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; let amount = '0x0'; - if (state.asset.type === ASSET_TYPES.TOKEN) { - const decimals = state.asset.details?.decimals ?? 0; + if (draftTransaction.asset.type === ASSET_TYPES.TOKEN) { + const decimals = draftTransaction.asset.details?.decimals ?? 0; const multiplier = Math.pow(10, Number(decimals)); - amount = multiplyCurrencies(state.asset.balance, multiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }); + amount = multiplyCurrencies( + draftTransaction.asset.balance, + multiplier, + { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }, + ); } else { const _gasTotal = sumHexes( - state.gas.gasTotal || '0x0', - state.multiLayerFees?.layer1GasTotal || '0x0', + draftTransaction.gas.gasTotal || '0x0', + state.gasTotalForLayer1 || '0x0', ); amount = subtractCurrencies( - addHexPrefix(state.asset.balance), + addHexPrefix(draftTransaction.asset.balance), addHexPrefix(_gasTotal), { toNumericBase: 'hex', @@ -986,176 +917,62 @@ const slice = createSlice({ }); }, /** - * updates the userInputHexData state key + * Updates the currently selected asset * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction<string>} action - The - * hex string to be set as the userInputHexData value. + * @param {UpdateAssetPayload} action - The asest to set in the + * draftTransaction. + * @returns {void} */ - updateUserInputHexData: (state, action) => { - state.userInputHexData = action.payload; - }, - /** - * Transaction details of a previously created transaction that the user - * has selected to edit. - * - * @typedef {Object} EditTransactionPayload - * @property {string} gasLimit - The hex string maximum gas to use. - * @property {string} gasPrice - The amount in wei to pay for gas, in hex - * format. - * @property {string} amount - The amount of the currency to send, in hex - * format. - * @property {string} address - The address to send the transaction to. - * @property {string} [nickname] - The nickname the user has associated - * with the address in their contact book. - * @property {string} id - The id of the transaction in the - * TransactionController state[ - * @property {string} from - the address that the user is sending from - * @property {string} [data] - The hex data that describes the transaction. - * Used primarily for contract interactions, like token sends, but can - * also be provided by the user. - */ - /** - * 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. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction<EditTransactionPayload>} action - The details of the - * transaction to be edited. - */ - 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.asset.error = null; - state.recipient.address = action.payload.address; - state.recipient.nickname = action.payload.nickname; - state.id = action.payload.id; - state.account.address = action.payload.from; - state.userInputHexData = action.payload.data; - }, - /** - * 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. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - */ - calculateGasTotal: (state) => { - // use maxFeePerGas as the multiplier if working with a FEE_MARKET transaction - // otherwise use gasPrice - if (state.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET) { - state.gas.gasTotal = addHexPrefix( - calcGasTotal(state.gas.gasLimit, state.gas.maxFeePerGas), - ); - } else { - state.gas.gasTotal = addHexPrefix( - calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), - ); - } + updateAsset: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + + // If an asset update occurs that changes the type from 'NATIVE' to + // 'NATIVE' then this is likely the initial asset set of an edit + // transaction. We don't need to set the amount to zero in this case. + // The only times where an update would occur of this nature that we + // would want to set the amount to zero is on a network or account change + // but that update is handled elsewhere. + const skipAmountUpdate = + action.payload.type === ASSET_TYPES.NATIVE && + draftTransaction.asset.type === ASSET_TYPES.NATIVE; + draftTransaction.asset.type = action.payload.type; + draftTransaction.asset.balance = action.payload.balance; + draftTransaction.asset.error = action.payload.error; + if ( - state.amount.mode === AMOUNT_MODES.MAX && - state.asset.type === ASSET_TYPES.NATIVE + draftTransaction.asset.type === ASSET_TYPES.TOKEN || + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ) { - slice.caseReducers.updateAmountToMax(state); + draftTransaction.asset.details = action.payload.details; + } else { + // clear the details object when sending native currency + draftTransaction.asset.details = null; + if (draftTransaction.recipient.error === CONTRACT_ADDRESS_ERROR) { + // Errors related to sending tokens to their own contract address + // are no longer valid when sending native currency. + draftTransaction.recipient.error = 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.amountMode === AMOUNT_MODES.MAX) { + slice.caseReducers.updateAmountToMax(state); + } else if (skipAmountUpdate === false) { + slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); } - 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. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import('@reduxjs/toolkit').PayloadAction<string>} action - The - * gasLimit in hex to set in state. - */ - updateGasLimit: (state, action) => { - state.gas.gasLimit = addHexPrefix(action.payload); - slice.caseReducers.calculateGasTotal(state); - }, - /** - * @typedef {Object} GasFeeUpdatePayload - * @property {TransactionTypeString} transactionType - The transaction type - * @property {string} [maxFeePerGas] - The maximum amount in hex wei to pay - * per gas on a FEE_MARKET transaction. - * @property {string} [maxPriorityFeePerGas] - The maximum amount in hex - * wei to pay per gas as an incentive to miners on a FEE_MARKET - * transaction. - * @property {string} [gasPrice] - The amount in hex wei to pay per gas on - * a LEGACY transaction. - * @property {boolean} [isAutomaticUpdate] - true if the update is the - * result of a gas estimate update from the controller. - */ - /** - * Sets the appropriate gas fees in state and determines and sets the - * appropriate transactionType based on gas fee fields received. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction<GasFeeUpdatePayload>} action - */ - updateGasFees: (state, action) => { - if ( - action.payload.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET - ) { - state.gas.maxFeePerGas = addHexPrefix(action.payload.maxFeePerGas); - state.gas.maxPriorityFeePerGas = addHexPrefix( - action.payload.maxPriorityFeePerGas, - ); - state.transactionType = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; - } else { - // Until we remove the old UI we don't want to automatically update - // gasPrice if the user has already manually changed the field value. - // When receiving a new estimate the isAutomaticUpdate property will be - // on the payload (and set to true). If isAutomaticUpdate is true, - // then we check if the previous estimate was '0x0' or if the previous - // gasPrice equals the previous gasEstimate. if either of those cases - // are true then we update the gasPrice otherwise we skip it because - // it indicates the user has ejected from the estimates by modifying - // the field. - if ( - action.payload.isAutomaticUpdate !== true || - state.gas.gasPriceEstimate === '0x0' || - state.gas.gasPrice === state.gas.gasPriceEstimate - ) { - state.gas.gasPrice = addHexPrefix(action.payload.gasPrice); - } - state.transactionType = TRANSACTION_ENVELOPE_TYPES.LEGACY; - } - slice.caseReducers.calculateGasTotal(state); - }, - /** - * @typedef {Object} GasEstimateUpdatePayload - * @property {GasEstimateType} gasEstimateType - The type of gas estimation - * provided by the controller. - * @property {( - * EthGasPriceEstimate | LegacyGasPriceEstimate | GasFeeEstimates - * )} gasFeeEstimates - The gas fee estimates provided by the controller. - */ /** * Sets the appropriate gas fees in state after receiving new estimates. * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {( - * import('@reduxjs/toolkit').PayloadAction<GasEstimateUpdatePayload - * )} action - The gas fee update payload + * @param {GasEstimateUpdatePayload)} action - The gas fee update payload + * @returns {void} */ updateGasFeeEstimates: (state, action) => { const { gasFeeEstimates, gasEstimateType } = action.payload; @@ -1199,82 +1016,112 @@ const slice = createSlice({ break; } // Record the latest gasPriceEstimate for future comparisons - state.gas.gasPriceEstimate = addHexPrefix(gasPriceEstimate); + state.gasPriceEstimate = addHexPrefix(gasPriceEstimate); + }, + /** + * Sets the appropriate gas fees in state and determines and sets the + * appropriate transactionType based on gas fee fields received. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {GasFeeUpdatePayload} action - The gas fees to update with + * @returns {void} + */ + updateGasFees: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction) { + if ( + action.payload.transactionType === + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET + ) { + draftTransaction.gas.maxFeePerGas = addHexPrefix( + action.payload.maxFeePerGas, + ); + draftTransaction.gas.maxPriorityFeePerGas = addHexPrefix( + action.payload.maxPriorityFeePerGas, + ); + draftTransaction.transactionType = + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + } else { + // Until we remove the old UI we don't want to automatically update + // gasPrice if the user has already manually changed the field value. + // When receiving a new estimate the isAutomaticUpdate property will be + // on the payload (and set to true). If isAutomaticUpdate is true, + // then we check if the previous estimate was '0x0' or if the previous + // gasPrice equals the previous gasEstimate. if either of those cases + // are true then we update the gasPrice otherwise we skip it because + // it indicates the user has ejected from the estimates by modifying + // the field. + if ( + action.payload.isAutomaticUpdate !== true || + state.gasPriceEstimate === '0x0' || + draftTransaction.gas.gasPrice === state.gasPriceEstimate + ) { + draftTransaction.gas.gasPrice = addHexPrefix( + action.payload.gasPrice, + ); + } + draftTransaction.transactionType = TRANSACTION_ENVELOPE_TYPES.LEGACY; + } + slice.caseReducers.calculateGasTotal(state); + } + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The + * gasLimit in hex to set in state. + * @returns {void} + */ + updateGasLimit: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction) { + draftTransaction.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + } }, /** * sets the layer 1 fees total (for a multi-layer fee network) * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction<string>} action - the - * layer1GasTotal to set in hex wei. + * @param {SimpleStringPayload} action - the + * gasTotalForLayer1 to set in hex wei. + * @returns {void} */ updateLayer1Fees: (state, action) => { - state.multiLayerFees.layer1GasTotal = action.payload; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + state.gasTotalForLayer1 = action.payload; if ( - state.amount.mode === AMOUNT_MODES.MAX && - state.asset.type === ASSET_TYPES.NATIVE + state.amountMode === AMOUNT_MODES.MAX && + draftTransaction.asset.type === ASSET_TYPES.NATIVE ) { slice.caseReducers.updateAmountToMax(state); } }, /** - * sets the amount mode to the provided value as long as it is one of the - * supported modes (MAX|INPUT) + * Updates the recipient of the draftTransaction * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction<SendStateAmountModeStrings>} action - The amount mode - * to set the state to. + * @param {updateRecipientPayload} action - The recipient to set in the + * draftTransaction. + * @returns {void} */ - 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; - state.asset.error = action.payload.error; - if ( - state.asset.type === ASSET_TYPES.TOKEN || - state.asset.type === ASSET_TYPES.COLLECTIBLE - ) { - 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 ?? ''; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.error = null; + state.recipientInput = ''; + draftTransaction.recipient.address = action.payload.address ?? ''; + draftTransaction.recipient.nickname = action.payload.nickname ?? ''; - if (state.recipient.address === '') { + if (draftTransaction.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; @@ -1282,214 +1129,321 @@ const slice = createSlice({ // if an address is provided and an id exists, 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.id === null ? SEND_STAGES.DRAFT : SEND_STAGES.EDIT; - state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; + state.stage = + draftTransaction.id === null ? SEND_STAGES.DRAFT : SEND_STAGES.EDIT; + state.recipientMode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; } // validate send state slice.caseReducers.validateSendState(state); }, - useDefaultGas: (state) => { - // Show the default gas price/limit fields in the send page - state.gas.isCustomGasSet = false; + /** + * Clears the user input and changes the recipient search mode to the + * specified value + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {UpdateRecipientModePayload} action - The mode to set the + * recipient search to + * @returns {void} + */ + updateRecipientSearchMode: (state, action) => { + state.recipientInput = ''; + state.recipientMode = action.payload; }, - useCustomGas: (state) => { - // Show the gas fees set in the custom gas modal (state.gas.customData) - state.gas.isCustomGasSet = true; + + updateRecipientWarning: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.warning = action.payload; }, + + updateDraftTransactionStatus: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.status = action.payload; + }, + + acknowledgeRecipientWarning: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.recipientWarningAcknowledged = true; + slice.caseReducers.validateSendState(state); + }, + + /** + * Updates the value of the recipientInput key with what the user has + * typed into the recipient input field in the UI. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - the value the user has typed into + * the recipient field. + * @returns {void} + */ updateRecipientUserInput: (state, action) => { // Update the value in state to match what the user is typing into the // input field - state.recipient.userInput = action.payload; + state.recipientInput = 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 || - asset.type === ASSET_TYPES.COLLECTIBLE; - const { chainId, tokens, tokenAddressList } = 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 && - isValidHexAddress(recipient.userInput) && - (tokenAddressList.find((address) => - isEqualCaseInsensitive(address, recipient.userInput), - ) || - checkExistingAddresses(recipient.userInput, tokens)) - ) { - recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; - } else { - recipient.warning = null; - } + /** + * update current amount.value in state and run post update validation of + * the amount field and the send state. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The hex string to be set as the + * amount value. + * @returns {void} + */ + updateSendAmount: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.amount.value = addHexPrefix(action.payload); + // Once amount has changed, validate the field + slice.caseReducers.validateAmountField(state); + if (draftTransaction.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); }, - updateRecipientSearchMode: (state, action) => { - state.recipient.userInput = ''; - state.recipient.mode = action.payload; + /** + * updates the userInputHexData state key + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The hex string to be set as the + * userInputHexData value. + * @returns {void} + */ + updateUserInputHexData: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.userInputHexData = action.payload; }, - resetSendState: () => initialState, + /** + * Updates the gasIsSetInModal property to true which results in showing + * the gas fees from the custom gas modal in the send page. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + useCustomGas: (state) => { + state.gasIsSetInModal = true; + }, + /** + * Updates the gasIsSetInModal property to false which results in showing + * the default gas price/limit fields in the send page. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + useDefaultGas: (state) => { + state.gasIsSetInModal = false; + }, + /** + * Checks for the validity of the draftTransactions selected amount to send + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ validateAmountField: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; 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 && + case draftTransaction.asset.type === ASSET_TYPES.NATIVE && !isBalanceSufficient({ - amount: state.amount.value, - balance: state.asset.balance, - gasTotal: state.gas.gasTotal ?? '0x0', + amount: draftTransaction.amount.value, + balance: draftTransaction.asset.balance, + gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }): - state.amount.error = INSUFFICIENT_FUNDS_ERROR; + draftTransaction.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 && + case draftTransaction.asset.type === ASSET_TYPES.TOKEN && !isTokenBalanceSufficient({ - tokenBalance: state.asset.balance ?? '0x0', - amount: state.amount.value, - decimals: state.asset.details.decimals, + tokenBalance: draftTransaction.asset.balance ?? '0x0', + amount: draftTransaction.amount.value, + decimals: draftTransaction.asset.details.decimals, }): - state.amount.error = INSUFFICIENT_TOKENS_ERROR; + draftTransaction.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' }, + { value: draftTransaction.amount.value, fromNumericBase: 'hex' }, ): - state.amount.error = NEGATIVE_ETH_ERROR; + draftTransaction.amount.error = NEGATIVE_ETH_ERROR; break; // If none of the above are true, set error to null default: - state.amount.error = null; + draftTransaction.amount.error = null; } }, + /** + * 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. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ 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 draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; const insufficientFunds = !isBalanceSufficient({ amount: - state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0', - balance: state.account.balance, - gasTotal: state.gas.gasTotal ?? '0x0', + draftTransaction.asset.type === ASSET_TYPES.NATIVE + ? draftTransaction.amount.value + : '0x0', + balance: + draftTransaction.fromAccount?.balance ?? + state.selectedAccount.balance, + gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }); - state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null; + draftTransaction.gas.error = insufficientFunds + ? INSUFFICIENT_FUNDS_ERROR + : null; }, + validateRecipientUserInput: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + + if (draftTransaction) { + if ( + state.recipientMode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || + state.recipientInput === '' || + state.recipientInput === null + ) { + draftTransaction.recipient.error = null; + draftTransaction.recipient.warning = null; + } else { + const { + chainId, + tokens, + tokenAddressList, + isProbablyAnAssetContract, + } = action.payload; + + if ( + isBurnAddress(state.recipientInput) || + (!isValidHexAddress(state.recipientInput, { + mixedCaseUseChecksum: true, + }) && + !isValidDomainName(state.recipientInput)) + ) { + draftTransaction.recipient.error = isDefaultMetaMaskChain(chainId) + ? INVALID_RECIPIENT_ADDRESS_ERROR + : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; + } else if ( + isOriginContractAddress( + state.recipientInput, + draftTransaction.asset?.details?.address, + ) + ) { + draftTransaction.recipient.error = CONTRACT_ADDRESS_ERROR; + } else { + draftTransaction.recipient.error = null; + } + if ( + (isValidHexAddress(state.recipientInput) && + (tokenAddressList.find((address) => + isEqualCaseInsensitive(address, state.recipientInput), + ) || + checkExistingAddresses(state.recipientInput, tokens))) || + isProbablyAnAssetContract + ) { + draftTransaction.recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; + } else { + draftTransaction.recipient.warning = null; + } + } + } + slice.caseReducers.validateSendState(state); + }, + /** + * Checks if the draftTransaction is currently valid. The following list of + * cases from the switch statement in this function describe when the + * transaction is invalid. Please keep this comment updated. + * + * case 1: State is invalid when amount field has an error. + * case 2: State is invalid when gas field has an error. + * case 3: State is invalid when asset field has an error. + * case 4: State is invalid if asset type is a token and the token details + * are unknown. + * case 5: State is invalid if no recipient has been added. + * case 6: State is invalid if the send state is uninitialized. + * case 7: State is invalid if gas estimates are loading. + * case 8: State is invalid if gasLimit is less than the gasLimitMinimum. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ validateSendState: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; switch (true) { - // 1 + 2. State is invalid when either gas or amount or asset 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 Boolean(state.asset.error): - case state.asset.type === ASSET_TYPES.TOKEN && - state.asset.details === null: + case Boolean(draftTransaction.amount.error): + case Boolean(draftTransaction.gas.error): + case Boolean(draftTransaction.asset.error): + case draftTransaction.asset.type === ASSET_TYPES.TOKEN && + draftTransaction.asset.details === null: case state.stage === SEND_STAGES.ADD_RECIPIENT: case state.stage === SEND_STAGES.INACTIVE: - case state.gas.isGasEstimateLoading: - case new BigNumber(state.gas.gasLimit, 16).lessThan( - new BigNumber(state.gas.minimumGasLimit), + case state.gasEstimateIsLoading: + case new BigNumber(draftTransaction.gas.gasLimit, 16).lessThan( + new BigNumber(state.gasLimitMinimum), ): - state.status = SEND_STATUSES.INVALID; + draftTransaction.status = SEND_STATUSES.INVALID; + break; + case draftTransaction.recipient.warning === 'loading': + case draftTransaction.recipient.warning === + KNOWN_RECIPIENT_ADDRESS_WARNING && + draftTransaction.recipient.recipientWarningAcknowledged === false: + draftTransaction.status = SEND_STATUSES.INVALID; break; default: - state.status = SEND_STATUSES.VALID; + draftTransaction.status = SEND_STATUSES.VALID; } }, }, 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 + action.payload.account.address === state.selectedAccount.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; + state.selectedAccount.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; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction?.asset.type === ASSET_TYPES.NATIVE) { + draftTransaction.asset.balance = action.payload.account.balance; } slice.caseReducers.validateAmountField(state); slice.caseReducers.validateGasField(state); @@ -1501,35 +1455,87 @@ const slice = createSlice({ // 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; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if ( + draftTransaction && + addressBook[draftTransaction.recipient.address]?.name + ) { + draftTransaction.recipient.nickname = + addressBook[draftTransaction.recipient.address].name; } }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit we should indicate we are loading + // a gas estimate. + state.gasEstimateIsLoading = 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 gasEstimateIsLoading to + // false. + state.gasEstimateIsLoading = false; + if (action.payload?.gasLimit) { + slice.caseReducers.updateGasLimit(state, { + payload: action.payload.gasLimit, + }); + } + if (action.payload?.gasTotalForLayer1) { + slice.caseReducers.updateLayer1Fees(state, { + payload: action.payload.gasTotalForLayer1, + }); + } + }) + .addCase(computeEstimatedGasLimit.rejected, (state) => { + // If gas estimation fails, we should set the loading state to false, + // because it is no longer loading + state.gasEstimateIsLoading = false; + }) + .addCase(GAS_FEE_ESTIMATES_UPDATED, (state, action) => { + // When the gasFeeController updates its gas fee estimates we need to + // update and validate state based on those new values + slice.caseReducers.updateGasFeeEstimates(state, { + payload: action.payload, + }); + }) .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; + // chains even after loading the send flow, we set gasEstimateIsLoading + // as initialization will trigger a fetch for gasPrice estimates. + state.gasEstimateIsLoading = true; }) .addCase(initializeSendState.fulfilled, (state, action) => { // writes the computed initialized state values into the slice and then // calculates slice validity using the caseReducers. state.eip1559support = action.payload.eip1559support; - 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.selectedAccount.address = action.payload.account.address; + state.selectedAccount.balance = action.payload.account.balance; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.gas.gasLimit = action.payload.gasLimit; slice.caseReducers.updateGasFeeEstimates(state, { payload: { gasFeeEstimates: action.payload.gasFeeEstimates, gasEstimateType: action.payload.gasEstimateType, }, }); - state.gas.gasTotal = action.payload.gasTotal; - state.gas.gasEstimatePollToken = action.payload.gasEstimatePollToken; + draftTransaction.gas.gasTotal = action.payload.gasTotal; + state.gasEstimatePollToken = action.payload.gasEstimatePollToken; + if (action.payload.chainHasChanged) { + // If the state was reinitialized as a result of the user changing + // the network from the network dropdown, then the selected asset is + // no longer valid and should be set to the native asset for the + // network. + draftTransaction.asset.type = ASSET_TYPES.NATIVE; + draftTransaction.asset.balance = + draftTransaction.fromAccount?.balance ?? + state.selectedAccount.balance; + draftTransaction.asset.details = null; + } if (action.payload.gasEstimatePollToken) { - state.gas.isGasEstimateLoading = false; + state.gasEstimateIsLoading = false; } if (state.stage !== SEND_STAGES.INACTIVE) { slice.caseReducers.validateRecipientUserInput(state, { @@ -1541,48 +1547,59 @@ const slice = createSlice({ }, }); } - state.stage = - state.stage === SEND_STAGES.INACTIVE - ? 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, - }); - } - if (action.payload?.layer1GasTotal) { - slice.caseReducers.updateLayer1Fees(state, { - payload: action.payload.layer1GasTotal, - }); + .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.selectedAccount.balance = action.payload.account.balance; + state.selectedAccount.address = action.payload.account.address; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + // This action will occur even when we aren't on the send flow, which + // is okay as it keeps the selectedAccount details up to date. We do + // not need to validate anything if there isn't a current draft + // transaction. If there is, we need to update the asset balance if + // the asset is set to the native network asset, and then validate + // the transaction. + if (draftTransaction) { + if (draftTransaction?.asset.type === ASSET_TYPES.NATIVE) { + draftTransaction.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } } }) - .addCase(computeEstimatedGasLimit.rejected, (state) => { - // If gas estimation fails, we should set the loading state to false, - // because it is no longer loading - state.gas.isGasEstimateLoading = false; - }) - .addCase(GAS_FEE_ESTIMATES_UPDATED, (state, action) => { - // When the gasFeeController updates its gas fee estimates we need to - // update and validate state based on those new values - slice.caseReducers.updateGasFeeEstimates(state, { - payload: action.payload, - }); + .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; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (qrCodeData && draftTransaction) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (draftTransaction.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + draftTransaction.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } }); }, }); @@ -1598,12 +1615,157 @@ const { validateRecipientUserInput, updateRecipientSearchMode, addHistoryEntry, + acknowledgeRecipientWarning, } = actions; -export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; +export { + useDefaultGas, + useCustomGas, + updateGasLimit, + addHistoryEntry, + acknowledgeRecipientWarning, +}; // Action Creators +/** + * 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, resolve) => { + dispatch( + addHistoryEntry( + `sendFlow - user typed ${payload.userInput} into recipient input field`, + ), + ); + dispatch(validateRecipientUserInput(payload)); + resolve(); + }, + 300, +); + +/** + * Begins a new draft transaction, derived from the txParams of an existing + * transaction in the TransactionController. This action will first clear out + * the previous draft transactions and currentTransactionUUID from state. This + * action is one of the two entry points into the send flow. NOTE: You must + * route to the send page *after* dispatching this action resolves to ensure + * that the draftTransaction is properly created. + * + * @param {AssetTypesString} assetType - The type of asset the transaction + * being edited was sending. The details of the asset will be retrieved from + * the transaction data in state. + * @param {string} transactionId - The id of the transaction being edited. + * @returns {ThunkAction<void>} + */ +export function editExistingTransaction(assetType, transactionId) { + return async (dispatch, getState) => { + await dispatch(actions.clearPreviousDrafts()); + const state = getState(); + const unapprovedTransactions = getUnapprovedTxs(state); + const transaction = unapprovedTransactions[transactionId]; + const account = getTargetAccount(state, transaction.txParams.from); + + if (assetType === ASSET_TYPES.NATIVE) { + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + id: transactionId, + fromAccount: account, + gas: { + ...draftTransactionInitialState.gas, + gasLimit: transaction.txParams.gas, + gasPrice: transaction.txParams.gasPrice, + }, + userInputHexData: transaction.txParams.data, + recipient: { + ...draftTransactionInitialState.recipient, + address: transaction.txParams.to, + nickname: + getAddressBookEntryOrAccountName(state, transaction.txParams.to) + ?.name ?? '', + }, + amount: { + ...draftTransactionInitialState.amount, + value: transaction.txParams.value, + }, + history: [ + `sendFlow - user clicked edit on transaction with id ${transactionId}`, + ], + }), + ); + await dispatch( + updateSendAsset( + { type: ASSET_TYPES.NATIVE }, + { skipComputeEstimatedGasLimit: true }, + ), + ); + } else { + const tokenData = parseStandardTokenTransactionData( + transaction.txParams.data, + ); + const tokenAmountInDec = + assetType === ASSET_TYPES.TOKEN ? getTokenValueParam(tokenData) : '1'; + const address = getTokenAddressParam(tokenData); + const nickname = + getAddressBookEntryOrAccountName(state, address)?.name ?? ''; + + const tokenAmountInHex = addHexPrefix( + conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }), + ); + + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + id: transactionId, + fromAccount: account, + gas: { + ...draftTransactionInitialState.gas, + gasLimit: transaction.txParams.gas, + gasPrice: transaction.txParams.gasPrice, + }, + userInputHexData: transaction.txParams.data, + recipient: { + ...draftTransactionInitialState.recipient, + address, + nickname, + }, + amount: { + ...draftTransactionInitialState.amount, + value: tokenAmountInHex, + }, + history: [ + `sendFlow - user clicked edit on transaction with id ${transactionId}`, + ], + }), + ); + + await dispatch( + updateSendAsset( + { + type: assetType, + details: { + address: transaction.txParams.to, + ...(assetType === ASSET_TYPES.COLLECTIBLE + ? { tokenId: getTokenValueParam(tokenData) } + : {}), + }, + }, + { skipComputeEstimatedGasLimit: true }, + ), + ); + } + + await dispatch(initializeSendState()); + }; +} + /** * This method is a temporary placeholder to support the old UI in both the * gas modal and the send flow. Soon we won't need to modify gasPrice from the @@ -1614,6 +1776,7 @@ export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; * * @deprecated - don't extend the usage of this temporary method * @param {string} gasPrice - new gas price in hex wei + * @returns {ThunkAction<void>} */ export function updateGasPrice(gasPrice) { return (dispatch) => { @@ -1629,255 +1792,6 @@ export function updateGasPrice(gasPrice) { }; } -export function resetSendState() { - return async (dispatch, getState) => { - const state = getState(); - dispatch(actions.resetSendState()); - - if (state[name].gas.gasEstimatePollToken) { - await disconnectGasFeeEstimatePoller( - state[name].gas.gasEstimatePollToken, - ); - removePollingTokenFromAppState(state[name].gas.gasEstimatePollToken); - } - }; -} -/** - * 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 - */ -export function updateSendAmount(amount) { - return async (dispatch, getState) => { - const state = getState(); - const { metamask } = state; - let logAmount = amount; - if (state[name].asset.type === ASSET_TYPES.TOKEN) { - const multiplier = Math.pow( - 10, - Number(state[name].asset.details?.decimals || 0), - ); - const decimalValueString = conversionUtil(addHexPrefix(amount), { - fromNumericBase: 'hex', - toNumericBase: 'dec', - toCurrency: state[name].asset.details?.symbol, - conversionRate: multiplier, - invertConversionRate: true, - }); - - logAmount = `${Number(decimalValueString) ? decimalValueString : ''} ${ - state[name].asset.details?.symbol - }`; - } else { - const ethValue = getValueFromWeiHex({ - value: amount, - toCurrency: ETH, - numberOfDecimals: 8, - }); - logAmount = `${ethValue} ${metamask?.provider?.ticker || ETH}`; - } - await dispatch( - addHistoryEntry(`sendFlow - user set amount to ${logAmount}`), - ); - await dispatch(actions.updateSendAmount(amount)); - if (state.send.amount.mode === AMOUNT_MODES.MAX) { - await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); - } - await dispatch(computeEstimatedGasLimit()); - }; -} - -/** - * Defines the shape for the details input parameter for updateSendAsset - * - * @typedef {Object} TokenDetails - * @property {string} address - The contract address for the ERC20 token. - * @property {string} decimals - The number of token decimals. - * @property {string} symbol - The asset symbol to display. - */ - -/** - * 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 {TokenDetails} [payload.details] - ERC20 details if sending TOKEN asset - */ -export function updateSendAsset({ type, details }) { - return async (dispatch, getState) => { - dispatch(addHistoryEntry(`sendFlow - user set asset type to ${type}`)); - dispatch( - addHistoryEntry( - `sendFlow - user set asset symbol to ${details?.symbol ?? 'undefined'}`, - ), - ); - dispatch( - addHistoryEntry( - `sendFlow - user set asset address to ${ - details?.address ?? 'undefined' - }`, - ), - ); - const state = getState(); - let { balance, error } = state.send.asset; - const userAddress = state.send.account.address ?? getSelectedAddress(state); - if (type === ASSET_TYPES.TOKEN) { - if (details) { - if (details.standard === undefined) { - await dispatch(showLoadingIndication()); - const { standard } = await getTokenStandardAndDetails( - details.address, - userAddress, - ); - if ( - process.env.COLLECTIBLES_V1 && - (standard === TOKEN_STANDARDS.ERC721 || - standard === TOKEN_STANDARDS.ERC1155) - ) { - await dispatch(hideLoadingIndication()); - dispatch( - showModal({ - name: 'CONVERT_TOKEN_TO_NFT', - tokenAddress: details.address, - }), - ); - error = INVALID_ASSET_TYPE; - throw new Error(error); - } - details.standard = standard; - } - - // 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. - if (details.standard === TOKEN_STANDARDS.ERC20) { - error = null; - balance = await getERC20Balance(details, userAddress); - } - await dispatch(hideLoadingIndication()); - } - } else if (type === ASSET_TYPES.COLLECTIBLE) { - let isCurrentOwner = true; - try { - isCurrentOwner = await isCollectibleOwner( - getSelectedAddress(state), - details.address, - details.tokenId, - ); - } catch (err) { - if (err.message.includes('Unable to verify ownership.')) { - // this would indicate that either our attempts to verify ownership failed because of network issues, - // or, somehow a token has been added to collectibles state with an incorrect chainId. - } else { - // Any other error is unexpected and should be surfaced. - dispatch(displayWarning(err.message)); - } - } - - if (details.standard === undefined) { - const { standard } = await getTokenStandardAndDetails( - details.address, - userAddress, - ); - details.standard = standard; - } - - if (details.standard === TOKEN_STANDARDS.ERC1155) { - throw new Error('Sends of ERC1155 tokens are not currently supported'); - } - - if (isCurrentOwner) { - error = null; - balance = '0x1'; - } else { - throw new Error( - 'Send slice initialized as collectible send with a collectible not currently owned by the select account', - ); - } - } else { - error = null; - // 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, error })); - 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( - addHistoryEntry( - `sendFlow - user typed ${payload.userInput} into recipient input field`, - ), - ); - 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 - */ -export function updateRecipientUserInput(userInput) { - return async (dispatch, getState) => { - await dispatch(actions.updateRecipientUserInput(userInput)); - const state = getState(); - const chainId = getCurrentChainId(state); - const tokens = getTokens(state); - const useTokenDetection = getUseTokenDetection(state); - const tokenAddressList = Object.keys(getTokenList(state)); - debouncedValidateRecipientUserInput(dispatch, { - userInput, - chainId, - tokens, - useTokenDetection, - tokenAddressList, - }); - }; -} - -export function useContactListForRecipientSearch() { - return (dispatch) => { - dispatch( - addHistoryEntry( - `sendFlow - user selected back to all on recipient screen`, - ), - ); - dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); - }; -} - -export function useMyAccountsForRecipientSearch() { - return (dispatch) => { - dispatch( - addHistoryEntry( - `sendFlow - user selected transfer to my accounts on recipient screen`, - ), - ); - 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 @@ -1893,6 +1807,7 @@ export function useMyAccountsForRecipientSearch() { * @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 {ThunkAction<void>} */ export function updateRecipient({ address, nickname }) { return async (dispatch, getState) => { @@ -1912,15 +1827,259 @@ export function updateRecipient({ address, nickname }) { } /** - * Clears out the recipient user input, ENS resolution and recipient validation. + * 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 */ -export function resetRecipientInput() { - return async (dispatch) => { - await dispatch(addHistoryEntry(`sendFlow - user cleared recipient input`)); - await dispatch(updateRecipientUserInput('')); - await dispatch(updateRecipient({ address: '', nickname: '' })); - await dispatch(resetEnsResolution()); - await dispatch(validateRecipientUserInput()); +export function updateRecipientUserInput(userInput) { + return async (dispatch, getState) => { + dispatch(actions.updateRecipientWarning('loading')); + dispatch(actions.updateDraftTransactionStatus(SEND_STATUSES.INVALID)); + await dispatch(actions.updateRecipientUserInput(userInput)); + const state = getState(); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + const sendingAddress = + draftTransaction.fromAccount?.address ?? + state[name].selectedAccount.address ?? + getSelectedAddress(state); + const chainId = getCurrentChainId(state); + const tokens = getTokens(state); + const useTokenDetection = getUseTokenDetection(state); + const tokenMap = getTokenList(state); + const tokenAddressList = Object.keys(tokenMap); + + const inputIsValidHexAddress = isValidHexAddress(userInput); + let isProbablyAnAssetContract = false; + if (inputIsValidHexAddress) { + const { symbol, decimals } = getTokenMetadata(userInput, tokenMap) || {}; + + isProbablyAnAssetContract = symbol && decimals !== undefined; + + if (!isProbablyAnAssetContract) { + try { + const { standard } = await getTokenStandardAndDetails( + userInput, + sendingAddress, + ); + isProbablyAnAssetContract = Boolean(standard); + } catch (e) { + console.log(e); + } + } + } + + return new Promise((resolve) => { + debouncedValidateRecipientUserInput( + dispatch, + { + userInput, + chainId, + tokens, + useTokenDetection, + tokenAddressList, + isProbablyAnAssetContract, + }, + resolve, + ); + }); + }; +} + +/** + * 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 {ThunkAction<void>} + */ +export function updateSendAmount(amount) { + return async (dispatch, getState) => { + const state = getState(); + const { metamask } = state; + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + let logAmount = amount; + if (draftTransaction.asset.type === ASSET_TYPES.TOKEN) { + const multiplier = Math.pow( + 10, + Number(draftTransaction.asset.details?.decimals || 0), + ); + const decimalValueString = conversionUtil(addHexPrefix(amount), { + fromNumericBase: 'hex', + toNumericBase: 'dec', + toCurrency: draftTransaction.asset.details?.symbol, + conversionRate: multiplier, + invertConversionRate: true, + }); + + logAmount = `${Number(decimalValueString) ? decimalValueString : ''} ${ + draftTransaction.asset.details?.symbol + }`; + } else { + const ethValue = getValueFromWeiHex({ + value: amount, + toCurrency: ETH, + numberOfDecimals: 8, + }); + logAmount = `${ethValue} ${metamask?.provider?.ticker || ETH}`; + } + await dispatch( + addHistoryEntry(`sendFlow - user set amount to ${logAmount}`), + ); + await dispatch(actions.updateSendAmount(amount)); + if (state[name].amountMode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + } + 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 {TokenDetails} [payload.details] - ERC20 details if sending TOKEN asset + * @returns {ThunkAction<void>} + */ +export function updateSendAsset( + { type, details: providedDetails }, + { skipComputeEstimatedGasLimit = false } = {}, +) { + return async (dispatch, getState) => { + const state = getState(); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + const sendingAddress = + draftTransaction.fromAccount?.address ?? + state[name].selectedAccount.address ?? + getSelectedAddress(state); + const account = getTargetAccount(state, sendingAddress); + if (type === ASSET_TYPES.NATIVE) { + const unapprovedTxs = getUnapprovedTxs(state); + const unapprovedTx = unapprovedTxs?.[draftTransaction.id]; + + await dispatch( + addHistoryEntry( + `sendFlow - user set asset of type ${ + ASSET_TYPES.NATIVE + } with symbol ${state.metamask.provider?.ticker ?? ETH}`, + ), + ); + await dispatch( + actions.updateAsset({ + type, + details: null, + balance: account.balance, + error: null, + }), + ); + + // This is meant to handle cases where we are editing an unapprovedTx from the background state + // and its type is a token method. In such a case, the hex data will be the necessary hex data + // for calling the contract transfer method. + // Now that we are updating the transaction to be a send of a native asset type, we should + // set the hex data of the transaction being editing to be empty. + // then the user will not want to send any hex data now that they have change the + if ( + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM || + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER || + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM + ) { + await dispatch(actions.updateUserInputHexData('')); + } + } else { + await dispatch(showLoadingIndication()); + const details = { + ...providedDetails, + ...(await getTokenStandardAndDetails( + providedDetails.address, + sendingAddress, + providedDetails.tokenId, + )), + }; + await dispatch(hideLoadingIndication()); + const balance = addHexPrefix( + calcTokenAmount(details.balance, details.decimals).toString(16), + ); + const asset = { + type, + details, + balance, + error: null, + }; + if ( + details.standard === TOKEN_STANDARDS.ERC1155 && + type === ASSET_TYPES.COLLECTIBLE + ) { + throw new Error('Sends of ERC1155 tokens are not currently supported'); + } else if ( + details.standard === TOKEN_STANDARDS.ERC1155 || + details.standard === TOKEN_STANDARDS.ERC721 + ) { + if (type === ASSET_TYPES.TOKEN && process.env.COLLECTIBLES_V1) { + dispatch( + showModal({ + name: 'CONVERT_TOKEN_TO_NFT', + tokenAddress: details.address, + }), + ); + asset.error = INVALID_ASSET_TYPE; + throw new Error(INVALID_ASSET_TYPE); + } else { + let isCurrentOwner = true; + try { + isCurrentOwner = await isCollectibleOwner( + sendingAddress, + details.address, + details.tokenId, + ); + } catch (err) { + if (err.message.includes('Unable to verify ownership.')) { + // this would indicate that either our attempts to verify ownership failed because of network issues, + // or, somehow a token has been added to collectibles state with an incorrect chainId. + } else { + // Any other error is unexpected and should be surfaced. + dispatch(displayWarning(err.message)); + } + } + + if (isCurrentOwner) { + asset.error = null; + asset.balance = '0x1'; + } else { + throw new Error( + 'Send slice initialized as collectible send with a collectible not currently owned by the select account', + ); + } + await dispatch( + addHistoryEntry( + `sendFlow - user set asset to NFT with tokenId ${details.tokenId} and address ${details.address}`, + ), + ); + } + } else { + await dispatch( + addHistoryEntry( + `sendFlow - user set asset to ERC20 token with symbol ${details.symbol} and address ${details.address}`, + ), + ); + // do nothing extra. + } + + await dispatch(actions.updateAsset(asset)); + } + if (skipComputeEstimatedGasLimit === false) { + await dispatch(computeEstimatedGasLimit()); + } }; } @@ -1933,39 +2092,87 @@ export function resetRecipientInput() { * recipient and value, NOT what the user has supplied. * * @param {string} hexData - hex encoded string representing transaction data. + * @returns {ThunkAction<void>} */ export function updateSendHexData(hexData) { return async (dispatch, getState) => { await dispatch( addHistoryEntry(`sendFlow - user added custom hexData ${hexData}`), ); + await dispatch(actions.updateUserInputHexData(hexData)); const state = getState(); - if (state.send.asset.type === ASSET_TYPES.NATIVE) { + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + if (draftTransaction.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. + * Sets the recipient search mode to show a list of the user's contacts and + * recently interacted with addresses. + * + * @returns {ThunkAction<void>} */ -export function toggleSendMaxMode() { +export function useContactListForRecipientSearch() { + return (dispatch) => { + dispatch( + addHistoryEntry( + `sendFlow - user selected back to all on recipient screen`, + ), + ); + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); + }; +} + +/** + * Sets the recipient search mode to show a list of the user's own accounts. + * + * @returns {ThunkAction<void>} + */ +export function useMyAccountsForRecipientSearch() { + return (dispatch) => { + dispatch( + addHistoryEntry( + `sendFlow - user selected transfer to my accounts on recipient screen`, + ), + ); + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); + }; +} + +/** + * Clears out the recipient user input, ENS resolution and recipient validation. + * + * @returns {ThunkAction<void>} + */ +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(addHistoryEntry(`sendFlow - user cleared recipient input`)); + await dispatch(updateRecipientUserInput('')); + await dispatch(updateRecipient({ address: '', nickname: '' })); + await dispatch(resetEnsResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +/** + * Resets the entire send state tree to the initial state. It also disconnects + * polling from the gas controller if the token is present in state. + * + * @returns {ThunkAction<void>} + */ +export function resetSendState() { 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')); - await dispatch(addHistoryEntry(`sendFlow - user toggled max mode off`)); - } else { - await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); - await dispatch(actions.updateAmountToMax()); - await dispatch(addHistoryEntry(`sendFlow - user toggled max mode on`)); + dispatch(actions.resetSendState()); + + if (state[name].gasEstimatePollToken) { + await disconnectGasFeeEstimatePoller(state[name].gasEstimatePollToken); + removePollingTokenFromAppState(state[name].gasEstimatePollToken); } - await dispatch(computeEstimatedGasLimit()); }; } @@ -1976,12 +2183,16 @@ export function toggleSendMaxMode() { * 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 {ThunkAction<void>} */ export function signTransaction() { return async (dispatch, getState) => { const state = getState(); - const { id, asset, stage, eip1559support } = state[name]; + const { stage, eip1559support } = state[name]; const txParams = generateTransactionParams(state[name]); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; 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. @@ -1989,7 +2200,7 @@ export function signTransaction() { // 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 unapprovedTx = unapprovedTxs[draftTransaction.id]; // We only update the tx params that can be changed via the edit flow UX const eip1559OnlyTxParamsToUpdate = { data: txParams.data, @@ -2014,15 +2225,24 @@ export function signTransaction() { `sendFlow - user clicked next and transaction should be updated in controller`, ), ); - await dispatch(updateTransactionSendFlowHistory(id, state[name].history)); - dispatch(updateEditableParams(id, editingTx.txParams)); - dispatch(updateTransactionGasFees(id, editingTx.txParams)); + await dispatch( + updateTransactionSendFlowHistory( + draftTransaction.id, + draftTransaction.history, + ), + ); + await dispatch( + updateEditableParams(draftTransaction.id, editingTx.txParams), + ); + await dispatch( + updateTransactionGasFees(draftTransaction.id, editingTx.txParams), + ); } else { let transactionType = TRANSACTION_TYPES.SIMPLE_SEND; - if (asset.type !== ASSET_TYPES.NATIVE) { + if (draftTransaction.asset.type !== ASSET_TYPES.NATIVE) { transactionType = - asset.type === ASSET_TYPES.COLLECTIBLE + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ? TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM : TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER; } @@ -2036,155 +2256,168 @@ export function signTransaction() { addUnapprovedTransactionAndRouteToConfirmationPage( txParams, transactionType, - state[name].history, + draftTransaction.history, ), ); } }; } -export function editTransaction( - assetType, - transactionId, - tokenData, - assetDetails, -) { +/** + * 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 {ThunkAction<void>} + */ +export function toggleSendMaxMode() { return async (dispatch, getState) => { const state = getState(); - await dispatch( - addHistoryEntry( - `sendFlow - user clicked edit on transaction with id ${transactionId}`, - ), - ); - const unapprovedTransactions = getUnapprovedTxs(state); - const transaction = unapprovedTransactions[transactionId]; - const { txParams } = transaction; - if (assetType === ASSET_TYPES.NATIVE) { - const { - data, - from, - gas: gasLimit, - gasPrice, - to: address, - value: amount, - } = txParams; - const nickname = getAddressBookEntry(state, address)?.name ?? ''; - await dispatch( - actions.editTransaction({ - data, - 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 if (assetType === ASSET_TYPES.TOKEN) { - const { - data, - 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({ - data, - id: transactionId, - gasLimit, - gasPrice, - from, - amount: tokenAmountInHex, - address, - nickname, - }), - ); - } else if (assetType === ASSET_TYPES.COLLECTIBLE) { - const { - data, - from, - to: tokenAddress, - gas: gasLimit, - gasPrice, - } = txParams; - const address = getTokenAddressParam(tokenData); - const nickname = getAddressBookEntry(state, address)?.name ?? ''; - - await dispatch( - updateSendAsset({ - type: ASSET_TYPES.COLLECTIBLE, - details: { ...assetDetails, address: tokenAddress }, - }), - ); - - await dispatch( - actions.editTransaction({ - data, - id: transactionId, - gasLimit, - gasPrice, - from, - amount: '0x1', - address, - nickname, - }), - ); + if (state[name].amountMode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + await dispatch(actions.updateSendAmount('0x0')); + await dispatch(addHistoryEntry(`sendFlow - user toggled max mode off`)); + } else { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); + await dispatch(actions.updateAmountToMax()); + await dispatch(addHistoryEntry(`sendFlow - user toggled max mode on`)); } + await dispatch(computeEstimatedGasLimit()); + }; +} + +/** + * Begins a new draft transaction, clearing out the previous draft transactions + * from state, and clearing the currentTransactionUUID. This action is one of + * the two entry points into the send flow. NOTE: You must route to the send + * page *after* dispatching this action resolves to ensure that the + * draftTransaction is properly created. + * + * @param {Pick<Asset, 'type' | 'details'>} asset - A partial asset + * object containing at least the asset type. If specifying a non-native asset + * then the asset details must be included with at least the address. + * @returns {ThunkAction<void>} + */ +export function startNewDraftTransaction(asset) { + return async (dispatch) => { + await dispatch(actions.clearPreviousDrafts()); + + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + history: [`sendFlow - User started new draft transaction`], + }), + ); + + await dispatch( + updateSendAsset({ + type: asset.type ?? ASSET_TYPES.NATIVE, + details: asset.details, + }), + ); + + await dispatch(initializeSendState()); }; } // Selectors +/** + * The following typedef is a shortcut for typing selectors below. It uses a + * generic type, T, so that each selector can specify it's return type. + * + * @template T + * @typedef {(state: MetaMaskState) => T} Selector + */ + +/** + * Selector that returns the current draft transaction's UUID. + * + * @type {Selector<string>} + */ +export function getCurrentTransactionUUID(state) { + return state[name].currentTransactionUUID; +} + +/** + * Selector that returns the current draft transaction. + * + * @type {Selector<DraftTransaction>} + */ +export function getCurrentDraftTransaction(state) { + return state[name].draftTransactions[getCurrentTransactionUUID(state)] ?? {}; +} + +/** + * Selector that returns true if a draft transaction exists. + * + * @type {Selector<boolean>} + */ +export function getDraftTransactionExists(state) { + const draftTransaction = getCurrentDraftTransaction(state); + if (Object.keys(draftTransaction).length === 0) { + return false; + } + return true; +} // Gas selectors + +/** + * Selector that returns the current draft transaction's gasLimit. + * + * @type {Selector<?string>} + */ export function getGasLimit(state) { - return state[name].gas.gasLimit; + return getCurrentDraftTransaction(state).gas?.gasLimit; } +/** + * Selector that returns the current draft transaction's gasPrice. + * + * @type {Selector<?string>} + */ export function getGasPrice(state) { - return state[name].gas.gasPrice; + return getCurrentDraftTransaction(state).gas?.gasPrice; } +/** + * Selector that returns the current draft transaction's gasTotal. + * + * @type {Selector<?string>} + */ export function getGasTotal(state) { - return state[name].gas.gasTotal; + return getCurrentDraftTransaction(state).gas?.gasTotal; } +/** + * Selector that returns the error, if present, for the gas fields. + * + * @type {Selector<?string>} + */ export function gasFeeIsInError(state) { - return Boolean(state[name].gas.error); + return Boolean(getCurrentDraftTransaction(state).gas?.error); } +/** + * Selector that returns the minimum gasLimit for the current network. + * + * @type {Selector<string>} + */ export function getMinimumGasLimitForSend(state) { - return state[name].gas.minimumGasLimit; + return state[name].gasLimitMinimum; } +/** + * Selector that returns the current draft transaction's gasLimit. + * + * @type {Selector<MapValuesToUnion<SendStateGasModes>>} + */ export function getGasInputMode(state) { const isMainnet = getIsMainnet(state); const gasEstimateType = getGasEstimateType(state); const showAdvancedGasFields = getAdvancedInlineGasShown(state); - if (state[name].gas.isCustomGasSet) { + if (state[name].gasIsSetInModal) { return GAS_INPUT_MODES.CUSTOM; } if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { @@ -2204,95 +2437,207 @@ export function getGasInputMode(state) { } // Asset Selectors +/** + * Selector that returns the asset the current draft transaction is sending. + * + * @type {Selector<?Asset>} + */ export function getSendAsset(state) { - return state[name].asset; + return getCurrentDraftTransaction(state).asset; } +/** + * Selector that returns the contract address of the non-native asset that + * the current transaction is sending, if it exists. + * + * @type {Selector<?string>} + */ export function getSendAssetAddress(state) { return getSendAsset(state)?.details?.address; } +/** + * Selector that returns a boolean value describing whether the currently + * selected asset is sendable, based upon the standard of the token. + * + * @type {Selector<boolean>} + */ export function getIsAssetSendable(state) { - if (state[name].asset.type === ASSET_TYPES.NATIVE) { + if (getSendAsset(state)?.type === ASSET_TYPES.NATIVE) { return true; } - return state[name].asset.details.isERC721 === false; + return getSendAsset(state)?.details?.isERC721 === false; } +/** + * Selector that returns the asset error if it exists. + * + * @type {Selector<?string>} + */ export function getAssetError(state) { - return state[name].asset.error; + return getSendAsset(state).error; } // Amount Selectors +/** + * Selector that returns the amount that current draft transaction is sending. + * + * @type {Selector<?string>} + */ export function getSendAmount(state) { - return state[name].amount.value; + return getCurrentDraftTransaction(state).amount?.value; } +/** + * Selector that returns true if the user has enough native asset balance to + * cover the cost of the transaction. + * + * @type {Selector<boolean>} + */ export function getIsBalanceInsufficient(state) { - return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; + return ( + getCurrentDraftTransaction(state).gas?.error === INSUFFICIENT_FUNDS_ERROR + ); } + +/** + * Selector that returns the amoung send mode, either MAX or INPUT. + * + * @type {Selector<boolean>} + */ export function getSendMaxModeState(state) { - return state[name].amount.mode === AMOUNT_MODES.MAX; + return state[name].amountMode === AMOUNT_MODES.MAX; } +/** + * Selector that returns the current draft transaction's data field. + * + * @type {Selector<?string>} + */ export function getSendHexData(state) { - return state[name].userInputHexData; + return getCurrentDraftTransaction(state).userInputHexData; } +/** + * Selector that returns the current draft transaction's id, if present. + * + * @type {Selector<?string>} + */ export function getDraftTransactionID(state) { - return state[name].id; + return getCurrentDraftTransaction(state).id; } +/** + * Selector that returns true if there is an error on the amount field. + * + * @type {Selector<boolean>} + */ export function sendAmountIsInError(state) { - return Boolean(state[name].amount.error); + return Boolean(getCurrentDraftTransaction(state).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; -} - +/** + * Selector that returns the current draft transaction's recipient. + * + * @type {Selector<DraftTransaction['recipient']>} + */ export function getRecipient(state) { - const checksummedAddress = toChecksumHexAddress( - state[name].recipient.address, - ); + const draft = getCurrentDraftTransaction(state); + if (!draft.recipient) { + return { + address: '', + nickname: '', + error: null, + warning: null, + }; + } + const checksummedAddress = toChecksumHexAddress(draft.recipient.address); if (state.metamask.ensResolutionsByAddress) { return { - ...state[name].recipient, + ...draft.recipient, nickname: - state[name].recipient.nickname || + draft.recipient.nickname || getEnsResolutionByAddress(state, checksummedAddress), }; } - return state[name].recipient; + return draft.recipient; +} + +/** + * Selector that returns the addres of the current draft transaction's + * recipient. + * + * @type {Selector<?string>} + */ +export function getSendTo(state) { + return getRecipient(state)?.address; +} + +/** + * Selector that returns true if the current recipientMode is MY_ACCOUNTS + * + * @type {Selector<boolean>} + */ +export function getIsUsingMyAccountForRecipientSearch(state) { + return state[name].recipientMode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; +} + +/** + * Selector that returns the value that the user has typed into the recipient + * input field. + * + * @type {Selector<?string>} + */ +export function getRecipientUserInput(state) { + return state[name].recipientInput; +} + +export function getRecipientWarningAcknowledgement(state) { + return ( + getCurrentDraftTransaction(state).recipient?.recipientWarningAcknowledged ?? + false + ); } // Overall validity and stage selectors +/** + * Selector that returns the gasFee and amount errors, if they exist. + * + * @type {Selector<{ gasFee?: string, amount?: string}>} + */ export function getSendErrors(state) { return { - gasFee: state.send.gas.error, - amount: state.send.amount.error, + gasFee: getCurrentDraftTransaction(state).gas?.error, + amount: getCurrentDraftTransaction(state).amount?.error, }; } +/** + * Selector that returns true if the stage is anything except INACTIVE + * + * @type {Selector<boolean>} + */ export function isSendStateInitialized(state) { return state[name].stage !== SEND_STAGES.INACTIVE; } +/** + * Selector that returns true if the current draft transaction is valid and in + * a sendable state. + * + * @type {Selector<boolean>} + */ export function isSendFormInvalid(state) { - return state[name].status === SEND_STATUSES.INVALID; + return getCurrentDraftTransaction(state).status === SEND_STATUSES.INVALID; } +/** + * Selector that returns the current stage of the send flow + * + * @type {Selector<MapValuesToUnion<SendStateStages>>} + */ export function getSendStage(state) { return state[name].stage; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 707460980..339770198 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -18,10 +18,19 @@ import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../../shared/constants/gas'; import { ASSET_TYPES, TRANSACTION_ENVELOPE_TYPES, - TRANSACTION_TYPES, } from '../../../shared/constants/transaction'; import * as Actions from '../../store/actions'; import { setBackgroundConnection } from '../../../test/jest'; +import { + generateERC20TransferData, + generateERC721TransferData, +} from '../../pages/send/send.utils'; +import { BURN_ADDRESS } from '../../../shared/modules/hexstring-utils'; +import { TOKEN_STANDARDS } from '../../helpers/constants/common'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../test/jest/mocks'; import sendReducer, { initialState, initializeSendState, @@ -39,7 +48,6 @@ import sendReducer, { SEND_STAGES, AMOUNT_MODES, RECIPIENT_SEARCH_MODES, - editTransaction, getGasLimit, getGasPrice, getGasTotal, @@ -66,6 +74,7 @@ import sendReducer, { getSendStage, updateGasPrice, } from './send'; +import { draftTransactionInitialState, editExistingTransaction } from '.'; const mockStore = createMockStore([thunk]); @@ -78,6 +87,11 @@ jest.mock('./send', () => { }; }); +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn) => fn, +})); + setBackgroundConnection({ addPollingTokenToAppState: jest.fn(), addUnapprovedTransaction: jest.fn((_w, _x, _y, _z, cb) => { @@ -86,6 +100,8 @@ setBackgroundConnection({ updateTransactionSendFlowHistory: jest.fn((_x, _y, cb) => cb(null)), }); +const getTestUUIDTx = (state) => state.draftTransactions['test-uuid']; + describe('Send Slice', () => { let getTokenStandardAndDetailsStub; let addUnapprovedTransactionAndRouteToConfirmationPageStub; @@ -93,7 +109,14 @@ describe('Send Slice', () => { jest.useFakeTimers(); getTokenStandardAndDetailsStub = jest .spyOn(Actions, 'getTokenStandardAndDetails') - .mockImplementation(() => Promise.resolve({ standard: 'ERC20' })); + .mockImplementation(() => + Promise.resolve({ + standard: 'ERC20', + balance: '0x0', + symbol: 'SYMB', + decimals: 18, + }), + ); addUnapprovedTransactionAndRouteToConfirmationPageStub = jest.spyOn( Actions, 'addUnapprovedTransactionAndRouteToConfirmationPage', @@ -119,11 +142,130 @@ describe('Send Slice', () => { }); describe('Reducers', () => { + describe('addNewDraft', () => { + it('should add new draft transaction and set currentTransactionUUID', () => { + const action = { + type: 'send/addNewDraft', + payload: { ...draftTransactionInitialState, id: 4 }, + }; + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); + const uuid = result.currentTransactionUUID; + const draft = result.draftTransactions[uuid]; + expect(draft.id).toStrictEqual(4); + }); + }); + describe('addHistoryEntry', () => { + it('should append a history item to the current draft transaction, including timestamp', () => { + const action = { + type: 'send/addHistoryEntry', + payload: 'test entry', + }; + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + const latestHistory = draft.history[draft.history.length - 1]; + expect(latestHistory.timestamp).toBeDefined(); + expect(latestHistory.entry).toStrictEqual('test entry'); + }); + }); + describe('calculateGasTotal', () => { + it('should set gasTotal to maxFeePerGax * gasLimit for FEE_MARKET transaction', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x1', + maxFeePerGas: '0x2', + gasLimit: GAS_LIMITS.SIMPLE, + }, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + }), + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.gas.gasTotal).toStrictEqual(`0xa410`); + }); + + it('should set gasTotal to gasPrice * gasLimit for non FEE_MARKET transaction', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x1', + maxFeePerGas: '0x2', + gasLimit: GAS_LIMITS.SIMPLE, + }, + }), + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.gas.gasTotal).toStrictEqual(GAS_LIMITS.SIMPLE); + }); + + it('should call updateAmountToMax if amount mode is max', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + { + ...getInitialSendStateWithExistingTxState({ + asset: { balance: '0xffff' }, + gas: { + gasPrice: '0x1', + gasLimit: GAS_LIMITS.SIMPLE, + }, + recipient: { + address: '0x00', + }, + }), + selectedAccount: { + balance: '0xffff', + address: '0x00', + }, + gasEstimateIsLoading: false, + amountMode: AMOUNT_MODES.MAX, + stage: SEND_STAGES.DRAFT, + }, + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.amount.value).toStrictEqual('0xadf7'); + expect(draft.status).toStrictEqual(SEND_STATUSES.VALID); + }); + }); + describe('resetSendState', () => { + it('should set the state back to a blank slate matching the initialState object', () => { + const action = { + type: 'send/resetSendState', + }; + + const result = sendReducer({}, action); + + expect(result).toStrictEqual(initialState); + }); + }); describe('updateSendAmount', () => { it('should', async () => { const action = { type: 'send/updateSendAmount', payload: '0x1' }; - const result = sendReducer(initialState, action); - expect(result.amount.value).toStrictEqual('0x1'); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(getTestUUIDTx(result).amount.value).toStrictEqual('0x1'); }); }); @@ -137,17 +279,19 @@ describe('Send Slice', () => { balance: '0x56bc75e2d63100000', // 100000000000000000000 }, gas: { - gasLimit: '0x5208', // 21000 + gasLimit: GAS_LIMITS.SIMPLE, // 21000 gasTotal: '0x1319718a5000', // 21000000000000 - minimumGasLimit: '0x5208', + minimumGasLimit: GAS_LIMITS.SIMPLE, }, }; - const state = { ...initialState, ...maxAmountState }; + const state = getInitialSendStateWithExistingTxState(maxAmountState); const action = { type: 'send/updateAmountToMax' }; const result = sendReducer(state, action); - expect(result.amount.value).toStrictEqual('0x56bc74b13f185b000'); // 99999979000000000000 + expect(getTestUUIDTx(result).amount.value).toStrictEqual( + '0x56bc74b13f185b000', + ); // 99999979000000000000 }); }); @@ -161,17 +305,22 @@ describe('Send Slice', () => { maxPriorityFeePerGas: '0x1', }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.maxFeePerGas).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.maxFeePerGas).toStrictEqual( action.payload.maxFeePerGas, ); - expect(result.gas.maxPriorityFeePerGas).toStrictEqual( + expect(draftTransaction.gas.maxPriorityFeePerGas).toStrictEqual( action.payload.maxPriorityFeePerGas, ); - expect(result.transactionType).toBe( + expect(draftTransaction.transactionType).toBe( TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, ); }); @@ -184,10 +333,19 @@ describe('Send Slice', () => { gasPrice: '0x1', }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); - expect(result.transactionType).toBe(TRANSACTION_ENVELOPE_TYPES.LEGACY); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(draftTransaction.transactionType).toBe( + TRANSACTION_ENVELOPE_TYPES.LEGACY, + ); }); }); @@ -197,54 +355,59 @@ describe('Send Slice', () => { type: 'send/updateUserInputHexData', payload: 'TestData', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + const draftTransaction = getTestUUIDTx(result); - expect(result.userInputHexData).toStrictEqual(action.payload); + expect(draftTransaction.userInputHexData).toStrictEqual(action.payload); }); }); describe('updateGasLimit', () => { const action = { type: 'send/updateGasLimit', - payload: '0x5208', // 21000 + payload: GAS_LIMITS.SIMPLE, // 21000 }; it('should', () => { const result = sendReducer( { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.DRAFT, - gas: { ...initialState.gas, isGasEstimateLoading: false }, + gasEstimateIsLoading: false, }, action, ); - expect(result.gas.gasLimit).toStrictEqual(action.payload); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasLimit).toStrictEqual(action.payload); }); it('should recalculate gasTotal', () => { - const gasState = { - ...initialState, + const gasState = getInitialSendStateWithExistingTxState({ 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 + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasLimit).toStrictEqual(action.payload); + expect(draftTransaction.gas.gasPrice).toStrictEqual('0x3b9aca00'); + expect(draftTransaction.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 }); }); describe('updateAmountMode', () => { it('should change to INPUT amount mode', () => { const emptyAmountModeState = { - amount: { - mode: '', - }, + amountMode: '', }; const action = { @@ -253,7 +416,7 @@ describe('Send Slice', () => { }; const result = sendReducer(emptyAmountModeState, action); - expect(result.amount.mode).toStrictEqual(action.payload); + expect(result.amountMode).toStrictEqual(action.payload); }); it('should change to MAX amount mode', () => { @@ -261,9 +424,12 @@ describe('Send Slice', () => { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.mode).toStrictEqual(action.payload); + expect(result.amountMode).toStrictEqual(action.payload); }); it('should', () => { @@ -271,21 +437,23 @@ describe('Send Slice', () => { type: 'send/updateAmountMode', payload: 'RANDOM', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.mode).not.toStrictEqual(action.payload); + expect(result.amountMode).not.toStrictEqual(action.payload); }); }); describe('updateAsset', () => { it('should update asset type and balance from respective action payload', () => { - const updateAssetState = { - ...initialState, + const updateAssetState = getInitialSendStateWithExistingTxState({ asset: { type: 'old type', balance: 'old balance', }, - }; + }); const action = { type: 'send/updateAsset', @@ -297,20 +465,23 @@ describe('Send Slice', () => { const result = sendReducer(updateAssetState, action); - expect(result.asset.type).toStrictEqual(action.payload.type); - expect(result.asset.balance).toStrictEqual(action.payload.balance); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.asset.type).toStrictEqual(action.payload.type); + expect(draftTransaction.asset.balance).toStrictEqual( + action.payload.balance, + ); }); it('should nullify old contract address error when asset types is not TOKEN', () => { - const recipientErrorState = { - ...initialState, + const recipientErrorState = getInitialSendStateWithExistingTxState({ recipient: { error: CONTRACT_ADDRESS_ERROR, }, asset: { type: ASSET_TYPES.TOKEN, }, - }; + }); const action = { type: 'send/updateAsset', @@ -321,36 +492,12 @@ describe('Send Slice', () => { const result = sendReducer(recipientErrorState, action); - expect(result.recipient.error).not.toStrictEqual( - recipientErrorState.recipient.error, + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).not.toStrictEqual( + CONTRACT_ADDRESS_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(); + expect(draftTransaction.recipient.error).toBeNull(); }); it('should update asset type and details to TOKEN payload', () => { @@ -366,9 +513,17 @@ describe('Send Slice', () => { }, }; - const result = sendReducer(initialState, action); - expect(result.asset.type).toStrictEqual(action.payload.type); - expect(result.asset.details).toStrictEqual(action.payload.details); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.asset.type).toStrictEqual(action.payload.type); + expect(draftTransaction.asset.details).toStrictEqual( + action.payload.details, + ); }); }); @@ -381,10 +536,17 @@ describe('Send Slice', () => { }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + + const draftTransaction = getTestUUIDTx(result); expect(result.stage).toStrictEqual(SEND_STAGES.DRAFT); - expect(result.recipient.address).toStrictEqual(action.payload.address); + expect(draftTransaction.recipient.address).toStrictEqual( + action.payload.address, + ); }); }); @@ -394,9 +556,12 @@ describe('Send Slice', () => { type: 'send/useDefaultGas', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.isCustomGasSet).toStrictEqual(false); + expect(result.gasIsSetInModal).toStrictEqual(false); }); }); @@ -406,9 +571,12 @@ describe('Send Slice', () => { type: 'send/useCustomGas', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.isCustomGasSet).toStrictEqual(true); + expect(result.gasIsSetInModal).toStrictEqual(true); }); }); @@ -419,21 +587,32 @@ describe('Send Slice', () => { payload: 'user input', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.recipient.userInput).toStrictEqual(action.payload); + expect(result.recipientInput).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', - }, + ...getInitialSendStateWithExistingTxState({ + recipient: { + error: 'someError', + warning: 'someWarning', + }, + amount: {}, + gas: { + gasLimit: '0x0', + minimumGasLimit: '0x0', + }, + asset: {}, + }), + recipientInput: '', + recipientMode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, }; const action = { @@ -442,16 +621,16 @@ describe('Send Slice', () => { const result = sendReducer(noUserInputState, action); - expect(result.recipient.error).toBeNull(); - expect(result.recipient.warning).toBeNull(); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toBeNull(); + expect(draftTransaction.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', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0xValidateError', }; const action = { type: 'send/validateRecipientUserInput', @@ -465,16 +644,18 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.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', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0xValidateError', }; const action = { type: 'send/validateRecipientUserInput', @@ -488,17 +669,17 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toStrictEqual( 'invalidAddressRecipientNotEthNetwork', ); }); it('should error with invalid address recipient when the user inputs the burn address', () => { const tokenAssetTypeState = { - ...initialState, - recipient: { - userInput: '0x0000000000000000000000000000000000000000', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0000000000000000000000000000000000000000', }; const action = { type: 'send/validateRecipientUserInput', @@ -512,21 +693,24 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.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', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, }, - }, - recipient: { - userInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - }, + }), + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', }; const action = { @@ -540,8 +724,87 @@ describe('Send Slice', () => { }; const result = sendReducer(tokenAssetTypeState, action); + const draftTransaction = getTestUUIDTx(result); - expect(result.recipient.error).toStrictEqual('contractAddressError'); + expect(draftTransaction.recipient.error).toStrictEqual( + 'contractAddressError', + ); + }); + + it('should set a warning when sending to a token address in the token address list', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [], + useTokenDetection: true, + tokenAddressList: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); + }); + + it('should set a warning when sending to a token address in the token list', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [{ address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc' }], + useTokenDetection: true, + tokenAddressList: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); + }); + + it('should set a warning when sending to an address that is probably a token contract', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [{ address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }], + useTokenDetection: true, + tokenAddressList: ['0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + isProbablyAnAssetContract: true, + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); }); }); @@ -552,28 +815,18 @@ describe('Send Slice', () => { payload: 'a-random-string', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + 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); + expect(result.recipientMode).toStrictEqual(action.payload); }); }); describe('validateAmountField', () => { it('should error with insufficient funds when amount asset value plust gas is higher than asset balance', () => { - const nativeAssetState = { - ...initialState, + const nativeAssetState = getInitialSendStateWithExistingTxState({ amount: { value: '0x6fc23ac0', // 1875000000 }, @@ -584,7 +837,7 @@ describe('Send Slice', () => { gas: { gasTotal: '0x8f0d180', // 150000000 }, - }; + }); const action = { type: 'send/validateAmountField', @@ -592,12 +845,15 @@ describe('Send Slice', () => { const result = sendReducer(nativeAssetState, action); - expect(result.amount.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.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, + const tokenAssetState = getInitialSendStateWithExistingTxState({ amount: { value: '0x77359400', // 2000000000 }, @@ -608,7 +864,7 @@ describe('Send Slice', () => { decimals: 0, }, }, - }; + }); const action = { type: 'send/validateAmountField', @@ -616,16 +872,19 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetState, action); - expect(result.amount.error).toStrictEqual(INSUFFICIENT_TOKENS_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toStrictEqual( + INSUFFICIENT_TOKENS_ERROR, + ); }); it('should error negative value amount', () => { - const negativeAmountState = { - ...initialState, + const negativeAmountState = getInitialSendStateWithExistingTxState({ amount: { value: '-1', }, - }; + }); const action = { type: 'send/validateAmountField', @@ -633,12 +892,13 @@ describe('Send Slice', () => { const result = sendReducer(negativeAmountState, action); - expect(result.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); }); it('should not error for positive value amount', () => { - const otherState = { - ...initialState, + const otherState = getInitialSendStateWithExistingTxState({ amount: { error: 'someError', value: '1', @@ -646,119 +906,135 @@ describe('Send Slice', () => { asset: { type: '', }, - }; + }); const action = { type: 'send/validateAmountField', }; const result = sendReducer(otherState, action); - expect(result.amount.error).toBeNull(); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toBeNull(); }); }); describe('validateGasField', () => { it('should error when total amount of gas is higher than account balance', () => { - const gasFieldState = { - ...initialState, + const gasFieldState = getInitialSendStateWithExistingTxState({ 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); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.error).toStrictEqual( + INSUFFICIENT_FUNDS_ERROR, + ); }); }); describe('validateSendState', () => { it('should set `INVALID` send state status when amount error is present', () => { - const amountErrorState = { - ...initialState, + const amountErrorState = getInitialSendStateWithExistingTxState({ amount: { error: 'Some Amount Error', }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(amountErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when gas error is present', () => { - const gasErrorState = { - ...initialState, + const gasErrorState = getInitialSendStateWithExistingTxState({ gas: { error: 'Some Amount Error', }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(gasErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when asset type is `TOKEN` without token details present', () => { - const assetErrorState = { - ...initialState, + const assetErrorState = getInitialSendStateWithExistingTxState({ asset: { type: ASSET_TYPES.TOKEN, }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(assetErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when gasLimit is under the minimumGasLimit', () => { - const gasLimitErroState = { - ...initialState, + const gasLimitErroState = getInitialSendStateWithExistingTxState({ gas: { gasLimit: '0x5207', - minimumGasLimit: '0x5208', + minimumGasLimit: GAS_LIMITS.SIMPLE, }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(gasLimitErroState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.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', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x000', + }, }, - }, - gas: { - isGasEstimateLoading: false, - gasLimit: '0x5208', - minimumGasLimit: '0x5208', - }, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, + }), + stage: SEND_STAGES.DRAFT, + gasEstimateIsLoading: false, + minimumGasLimit: GAS_LIMITS.SIMPLE, }; const action = { @@ -767,19 +1043,20 @@ describe('Send Slice', () => { const result = sendReducer(validSendStatusState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.VALID); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.VALID); }); }); }); describe('extraReducers/externalReducers', () => { describe('QR Code Detected', () => { - const qrCodestate = { - ...initialState, + const qrCodestate = getInitialSendStateWithExistingTxState({ recipient: { address: '0xAddress', }, - }; + }); it('should set the recipient address to the scanned address value if they are not equal', () => { const action = { @@ -793,7 +1070,10 @@ describe('Send Slice', () => { }; const result = sendReducer(qrCodestate, action); - expect(result.recipient.address).toStrictEqual( + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.address).toStrictEqual( action.value.values.address, ); }); @@ -811,10 +1091,10 @@ describe('Send Slice', () => { const result = sendReducer(qrCodestate, badQRAddressAction); - expect(result.recipient.address).toStrictEqual( - qrCodestate.recipient.address, - ); - expect(result.recipient.error).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.address).toStrictEqual('0xAddress'); + expect(draftTransaction.recipient.error).toStrictEqual( INVALID_RECIPIENT_ADDRESS_ERROR, ); }); @@ -823,8 +1103,8 @@ describe('Send Slice', () => { describe('Selected Address Changed', () => { it('should update selected account address and balance on non-edit stages', () => { const olderState = { - ...initialState, - account: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + selectedAccount: { balance: '0x0', address: '0xAddress', }, @@ -842,10 +1122,10 @@ describe('Send Slice', () => { const result = sendReducer(olderState, action); - expect(result.account.balance).toStrictEqual( + expect(result.selectedAccount.balance).toStrictEqual( action.payload.account.balance, ); - expect(result.account.address).toStrictEqual( + expect(result.selectedAccount.address).toStrictEqual( action.payload.account.address, ); }); @@ -854,9 +1134,9 @@ describe('Send Slice', () => { describe('Account Changed', () => { it('should', () => { const accountsChangedState = { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, - account: { + selectedAccount: { address: '0xAddress', balance: '0x0', }, @@ -874,16 +1154,16 @@ describe('Send Slice', () => { const result = sendReducer(accountsChangedState, action); - expect(result.account.balance).toStrictEqual( + expect(result.selectedAccount.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, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, - account: { + selectedAccount: { address: '0xAddress', balance: '0x0', }, @@ -900,10 +1180,10 @@ describe('Send Slice', () => { }; const result = sendReducer(accountsChangedState, action); - expect(result.account.address).not.toStrictEqual( + expect(result.selectedAccount.address).not.toStrictEqual( action.payload.account.address, ); - expect(result.account.balance).not.toStrictEqual( + expect(result.selectedAccount.balance).not.toStrictEqual( action.payload.account.balance, ); }); @@ -976,7 +1256,7 @@ describe('Send Slice', () => { }, }, }, - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING', basicEstimatesStatus: { @@ -1004,14 +1284,15 @@ describe('Send Slice', () => { describe('Set Basic Gas Estimate Data', () => { it('should recalculate gas based off of average basic estimate data', () => { const gasState = { - ...initialState, - gas: { - gasPrice: '0x0', - gasPriceEstimate: '0x0', - gasLimit: '0x5208', - gasTotal: '0x0', - minimumGasLimit: '0x5208', - }, + ...getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x0', + gasLimit: GAS_LIMITS.SIMPLE, + gasTotal: '0x0', + }, + }), + minimumGasLimit: GAS_LIMITS.SIMPLE, + gasPriceEstimate: '0x0', }; const action = { @@ -1026,9 +1307,11 @@ describe('Send Slice', () => { 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'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 + expect(draftTransaction.gas.gasLimit).toStrictEqual(GAS_LIMITS.SIMPLE); + expect(draftTransaction.gas.gasTotal).toStrictEqual('0x1319718a5000'); }); }); }); @@ -1037,11 +1320,11 @@ describe('Send Slice', () => { describe('updateGasPrice', () => { it('should update gas price and update draft transaction with validated state', async () => { const store = mockStore({ - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: undefined, }, - }, + }), }); const newGasPrice = '0x0'; @@ -1069,17 +1352,6 @@ describe('Send Slice', () => { }); describe('UpdateSendAmount', () => { - const defaultSendAmountState = { - send: { - amount: { - mode: undefined, - }, - asset: { - type: '', - }, - }, - }; - it('should create an action to update send amount', async () => { const sendState = { metamask: { @@ -1089,8 +1361,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { details: {}, }, @@ -1104,7 +1375,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(sendState); @@ -1143,8 +1414,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { details: {}, }, @@ -1158,7 +1428,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(sendState); @@ -1196,8 +1466,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { type: ASSET_TYPES.TOKEN, details: {}, @@ -1212,7 +1481,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(tokenAssetTypeSendState); @@ -1239,27 +1508,39 @@ describe('Send Slice', () => { blockGasLimit: '', selectedAddress: '', provider: { - chainId: '0x1', + chainId: RINKEBY_CHAIN_ID, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + accounts: { + '0xAddress': { + address: '0xAddress', + }, }, }, send: { - account: { - balance: '', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: '', + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + userInputHexData: '', + }), + selectedAccount: { + address: '0xAddress', }, - asset: { - type: '', - details: {}, - }, - gas: { - gasPrice: '', - }, - recipient: { - address: '', - }, - amount: { - value: '', - }, - userInputHexData: '', }, }; @@ -1267,48 +1548,44 @@ describe('Send Slice', () => { const store = mockStore(defaultSendAssetState); const newSendAsset = { - type: '', - details: { - address: '', - symbol: '', - decimals: '', - }, + type: ASSET_TYPES.NATIVE, }; await store.dispatch(updateSendAsset(newSendAsset)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(6); + expect(actionResult).toHaveLength(4); + expect(actionResult[0]).toMatchObject({ type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset type to ', + payload: 'sendFlow - user set asset of type NATIVE with symbol ETH', }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to ', - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to ', - }); - - expect(actionResult[3].type).toStrictEqual('send/updateAsset'); - expect(actionResult[3].payload).toStrictEqual({ - ...newSendAsset, - balance: '', + expect(actionResult[1].type).toStrictEqual('send/updateAsset'); + expect(actionResult[1].payload).toStrictEqual({ + type: ASSET_TYPES.NATIVE, + balance: '0x0', error: null, + details: null, }); - expect(actionResult[4].type).toStrictEqual( + expect(actionResult[2].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[5].type).toStrictEqual( + expect(actionResult[3].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); }); it('should create actions for updateSendAsset with tokens', async () => { + getTokenStandardAndDetailsStub.mockImplementation(() => + Promise.resolve({ + standard: 'ERC20', + balance: '0x0', + symbol: 'TokenSymbol', + decimals: 18, + }), + ); global.eth = { contract: sinon.stub().returns({ at: sinon.stub().returns({ @@ -1331,31 +1608,30 @@ describe('Send Slice', () => { const actionResult = store.getActions(); - expect(actionResult).toHaveLength(8); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to tokenSymbol', - }); + expect(actionResult).toHaveLength(6); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); expect(actionResult[2]).toMatchObject({ type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to tokenAddress', + payload: `sendFlow - user set asset to ERC20 token with symbol TokenSymbol and address tokenAddress`, }); - expect(actionResult[3].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[4].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[5].payload).toStrictEqual({ - ...newSendAsset, + expect(actionResult[3].payload).toStrictEqual({ + type: ASSET_TYPES.TOKEN, + details: { + address: 'tokenAddress', + symbol: 'TokenSymbol', + decimals: 18, + standard: 'ERC20', + balance: '0x0', + }, balance: '0x0', error: null, }); - expect(actionResult[6].type).toStrictEqual( + expect(actionResult[4].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[7].type).toStrictEqual( + expect(actionResult[5].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); }); @@ -1363,7 +1639,7 @@ describe('Send Slice', () => { it('should show ConvertTokenToNFT modal and throw "invalidAssetType" error when token passed in props is an ERC721 or ERC1155', async () => { process.env.COLLECTIBLES_V1 = true; getTokenStandardAndDetailsStub.mockImplementation(() => - Promise.resolve({ standard: 'ERC1155' }), + Promise.resolve({ standard: 'ERC1155', balance: '0x1' }), ); const store = mockStore(defaultSendAssetState); @@ -1380,22 +1656,10 @@ describe('Send Slice', () => { store.dispatch(updateSendAsset(newSendAsset)), ).rejects.toThrow('invalidAssetType'); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(6); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to tokenSymbol', - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to tokenAddress', - }); - expect(actionResult[3].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[4].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[5]).toStrictEqual({ + expect(actionResult).toHaveLength(3); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2]).toStrictEqual({ payload: { name: 'CONVERT_TOKEN_TO_NFT', tokenAddress: 'tokenAddress', @@ -1439,11 +1703,10 @@ describe('Send Slice', () => { }, }, }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }; it('should create actions for updateRecipientUserInput and checks debounce for validation', async () => { - const clock = sinon.useFakeTimers(); - const store = mockStore(updateRecipientUserInputState); const newUserRecipientInput = 'newUserRecipientInput'; @@ -1451,29 +1714,35 @@ describe('Send Slice', () => { const actionResult = store.getActions(); - expect(actionResult).toHaveLength(1); + expect(actionResult).toHaveLength(5); + expect(actionResult[0].type).toStrictEqual( + 'send/updateRecipientWarning', + ); + expect(actionResult[0].payload).toStrictEqual('loading'); + + expect(actionResult[1].type).toStrictEqual( + 'send/updateDraftTransactionStatus', + ); + + expect(actionResult[2].type).toStrictEqual( 'send/updateRecipientUserInput', ); - expect(actionResult[0].payload).toStrictEqual(newUserRecipientInput); + expect(actionResult[2].payload).toStrictEqual(newUserRecipientInput); - clock.tick(300); // debounce - - const actionResultAfterDebounce = store.getActions(); - expect(actionResultAfterDebounce).toHaveLength(3); - - expect(actionResultAfterDebounce[1]).toMatchObject({ + expect(actionResult[3]).toMatchObject({ type: 'send/addHistoryEntry', payload: `sendFlow - user typed ${newUserRecipientInput} into recipient input field`, }); - expect(actionResultAfterDebounce[2].type).toStrictEqual( + expect(actionResult[4].type).toStrictEqual( 'send/validateRecipientUserInput', ); - expect(actionResultAfterDebounce[2].payload).toStrictEqual({ + expect(actionResult[4].payload).toStrictEqual({ chainId: '', tokens: [], useTokenDetection: true, + isProbablyAnAssetContract: false, userInput: newUserRecipientInput, tokenAddressList: ['0x514910771af9ca656af840dff83e8264ecf986ca'], }); @@ -1730,21 +1999,7 @@ describe('Send Slice', () => { }, }, }, - send: { - asset: { - type: '', - }, - recipient: { - address: 'Address', - nickname: 'NickName', - }, - gas: { - gasPrice: '0x1', - }, - amount: { - value: '0x1', - }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }; const store = mockStore(updateRecipientState); @@ -1752,24 +2007,36 @@ describe('Send Slice', () => { await store.dispatch(resetRecipientInput()); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(7); + expect(actionResult).toHaveLength(11); expect(actionResult[0]).toMatchObject({ type: 'send/addHistoryEntry', payload: 'sendFlow - user cleared recipient input', }); expect(actionResult[1].type).toStrictEqual( + 'send/updateRecipientWarning', + ); + expect(actionResult[2].type).toStrictEqual( + 'send/updateDraftTransactionStatus', + ); + + expect(actionResult[3].type).toStrictEqual( 'send/updateRecipientUserInput', ); - expect(actionResult[1].payload).toStrictEqual(''); - expect(actionResult[2].type).toStrictEqual('send/updateRecipient'); - expect(actionResult[3].type).toStrictEqual( + expect(actionResult[4].payload).toStrictEqual( + 'sendFlow - user typed into recipient input field', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + expect(actionResult[6].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[7].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[4].type).toStrictEqual( + expect(actionResult[8].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); - expect(actionResult[5].type).toStrictEqual('ENS/resetEnsResolution'); - expect(actionResult[6].type).toStrictEqual( + expect(actionResult[9].type).toStrictEqual('ENS/resetEnsResolution'); + expect(actionResult[10].type).toStrictEqual( 'send/validateRecipientUserInput', ); }); @@ -1777,11 +2044,11 @@ describe('Send Slice', () => { describe('UpdateSendHexData', () => { const sendHexDataState = { - send: { + send: getInitialSendStateWithExistingTxState({ asset: { type: '', }, - }, + }), }; it('should create action to update hexData', async () => { @@ -1853,24 +2120,26 @@ describe('Send Slice', () => { ); }); - it('should create actions to toggle off max mode when send amount mode is max', async () => { + it('should create actions to toggle off max mode when send amount mode is max', async () => { const sendMaxModeState = { send: { - asset: { - type: ASSET_TYPES.TOKEN, - details: {}, - }, - gas: { - gasPrice: '', - }, - recipient: { - address: '', - }, - amount: { - mode: AMOUNT_MODES.MAX, - value: '', - }, - userInputHexData: '', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + userInputHexData: '', + }), + amountMode: AMOUNT_MODES.MAX, }, metamask: { provider: { @@ -1902,16 +2171,15 @@ describe('Send Slice', () => { describe('SignTransaction', () => { const signTransactionState = { - send: { + send: getInitialSendStateWithExistingTxState({ + id: 1, asset: {}, - stage: '', recipient: {}, amount: {}, - account: {}, gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }; it('should show confirm tx page when no other conditions for signing have been met', async () => { @@ -1944,23 +2212,24 @@ describe('Send Slice', () => { }, }, send: { - ...signTransactionState.send, - stage: SEND_STAGES.DRAFT, - id: 1, - account: { - address: '0x6784e8507A1A46443f7bDc8f8cA39bdA92A675A6', - }, - asset: { - details: { - address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + ...getInitialSendStateWithExistingTxState({ + id: 1, + asset: { + details: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, + type: 'TOKEN', }, - type: 'TOKEN', - }, - recipient: { - address: '4F90e18605Fd46F9F9Fab0e225D88e1ACf5F5324', - }, - amount: { - value: '0x1', + recipient: { + address: '4F90e18605Fd46F9F9Fab0e225D88e1ACf5F5324', + }, + amount: { + value: '0x1', + }, + }), + stage: SEND_STAGES.DRAFT, + selectedAccount: { + address: '0x6784e8507A1A46443f7bDc8f8cA39bdA92A675A6', }, }, }; @@ -1999,7 +2268,6 @@ describe('Send Slice', () => { send: { ...signTransactionState.send, stage: SEND_STAGES.EDIT, - id: 1, }, }; @@ -2026,10 +2294,12 @@ describe('Send Slice', () => { }); }); - describe('editTransaction', () => { + describe('editExistingTransaction', () => { it('should set up the appropriate state for editing a native asset transaction', async () => { const editTransactionState = { metamask: { + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + gasFeeEstimates: {}, provider: { chainId: RINKEBY_CHAIN_ID, }, @@ -2038,6 +2308,18 @@ describe('Send Slice', () => { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + tokenList: {}, unapprovedTxs: { 1: { id: 1, @@ -2053,49 +2335,100 @@ describe('Send Slice', () => { }, }, send: { - asset: { - type: '', - }, - recipient: { - address: 'Address', - nickname: 'NickName', - }, + // We are going to remove this transaction as a part of the flow, + // but we need this stub to have the fromAccount because for our + // action checker the state isn't actually modified after each + // action is ran. + ...getInitialSendStateWithExistingTxState({ + id: 1, + fromAccount: { + address: '0xAddress', + }, + }), }, }; const store = mockStore(editTransactionState); - await store.dispatch(editTransaction(ASSET_TYPES.NATIVE, 1)); + await store.dispatch(editExistingTransaction(ASSET_TYPES.NATIVE, 1)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(2); + expect(actionResult).toHaveLength(7); expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', + type: 'send/clearPreviousDrafts', }); - expect(actionResult[1].type).toStrictEqual('send/editTransaction'); - expect(actionResult[1].payload).toStrictEqual({ - address: '0xRecipientAddress', - amount: '0xde0b6b3a7640000', - data: '', - from: '0xAddress', - gasLimit: GAS_LIMITS.SIMPLE, - gasPrice: '0x3b9aca00', - id: 1, - nickname: '', + expect(actionResult[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + value: '0xde0b6b3a7640000', + error: null, + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: '0xRecipientAddress', + error: null, + nickname: '', + warning: null, + recipientWarningAcknowledged: false, + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: '', + }, }); const action = actionResult[1]; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); - expect(result.amount.value).toStrictEqual(action.payload.amount); + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); it('should set up the appropriate state for editing a collectible asset transaction', async () => { + getTokenStandardAndDetailsStub.mockImplementation(() => + Promise.resolve({ + standard: 'ERC721', + balance: '0x1', + address: '0xCollectibleAddress', + }), + ); const editTransactionState = { metamask: { blockGasLimit: '0x3a98', @@ -2108,13 +2441,29 @@ describe('Send Slice', () => { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + tokenList: {}, unapprovedTxs: { 1: { id: 1, txParams: { - data: '', + data: generateERC721TransferData({ + toAddress: BURN_ADDRESS, + fromAddress: '0xAddress', + tokenId: ethers.BigNumber.from(15000).toString(), + }), from: '0xAddress', - to: '0xTokenAddress', + to: '0xCollectibleAddress', gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE, gasPrice: '0x3b9aca00', // 1000000000 value: '0x0', @@ -2123,25 +2472,12 @@ describe('Send Slice', () => { }, }, send: { - account: { - address: '0xAddress', - balance: '0x0', - }, - asset: { - type: '', - }, - gas: { - gasPrice: '', - }, - amount: { - value: '', - }, - userInputHexData: '', - - recipient: { - address: 'Address', - nickname: 'NickName', - }, + ...getInitialSendStateWithExistingTxState({ + id: 1, + test: 'wow', + gas: { gasLimit: GAS_LIMITS.SIMPLE }, + }), + stage: SEND_STAGES.EDIT, }, }; @@ -2157,78 +2493,107 @@ describe('Send Slice', () => { const store = mockStore(editTransactionState); await store.dispatch( - editTransaction( - ASSET_TYPES.COLLECTIBLE, - 1, - { - name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, - args: { - _to: '0xRecipientAddress', - _value: ethers.BigNumber.from(15000), - }, - }, - { - address: '0xf5de760f2e916647fd766B4AD9E85ff943cE3A2b', - description: 'A test NFT dispensed from faucet.paradigm.xyz.', - image: - 'https://ipfs.io/ipfs/bafybeifvwitulq6elvka2hoqhwixfhgb42l4aiukmtrw335osetikviuuu', - name: 'MultiFaucet Test NFT', - standard: 'ERC721', - tokenId: '26847', - }, - ), + editExistingTransaction(ASSET_TYPES.COLLECTIBLE, 1), ); const actionResult = store.getActions(); expect(actionResult).toHaveLength(9); expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', + type: 'send/clearPreviousDrafts', }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.COLLECTIBLE}`, - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to undefined', - }); - expect(actionResult[3]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to 0xTokenAddress', - }); - expect(actionResult[4].type).toStrictEqual('send/updateAsset'); - expect(actionResult[4].payload).toStrictEqual({ - balance: '0x1', - type: ASSET_TYPES.COLLECTIBLE, - error: null, - details: { - address: '0xTokenAddress', - description: 'A test NFT dispensed from faucet.paradigm.xyz.', - image: - 'https://ipfs.io/ipfs/bafybeifvwitulq6elvka2hoqhwixfhgb42l4aiukmtrw335osetikviuuu', - name: 'MultiFaucet Test NFT', - standard: 'ERC721', - tokenId: '26847', + expect(actionResult[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + error: null, + value: '0x1', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: GAS_LIMITS.BASE_TOKEN_ESTIMATE, + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: BURN_ADDRESS, + error: null, + nickname: '', + warning: null, + recipientWarningAcknowledged: false, + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: + editTransactionState.metamask.unapprovedTxs[1].txParams.data, + }, + }); + expect(actionResult[2].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[3].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[4]).toStrictEqual({ + type: 'send/addHistoryEntry', + payload: + 'sendFlow - user set asset to NFT with tokenId 15000 and address 0xCollectibleAddress', + }); + expect(actionResult[5]).toStrictEqual({ + type: 'send/updateAsset', + payload: { + balance: '0x1', + details: { + address: '0xCollectibleAddress', + balance: '0x1', + standard: TOKEN_STANDARDS.ERC721, + tokenId: '15000', + }, + error: null, + type: ASSET_TYPES.COLLECTIBLE, }, }); - expect(actionResult[5].type).toStrictEqual( - 'send/computeEstimatedGasLimit/pending', - ); expect(actionResult[6].type).toStrictEqual( - 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + 'send/initializeSendState/pending', ); - expect(actionResult[7].type).toStrictEqual( - 'send/computeEstimatedGasLimit/fulfilled', + expect(actionResult[7]).toStrictEqual({ + type: 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + value: GAS_LIMITS.SIMPLE, + }); + expect(actionResult[8].type).toStrictEqual( + 'send/initializeSendState/fulfilled', ); - expect(actionResult[8].type).toStrictEqual('send/editTransaction'); - const action = actionResult[8]; - const result = sendReducer(initialState, action); + const action = actionResult[1]; - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.value).toStrictEqual(action.payload.amount); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); + + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); }); @@ -2240,16 +2605,46 @@ describe('Send Slice', () => { provider: { chainId: RINKEBY_CHAIN_ID, }, - tokens: [], + tokens: [ + { + address: '0xTokenAddress', + symbol: 'SYMB', + }, + ], + tokenList: { + '0xTokenAddress': { + symbol: 'SYMB', + address: '0xTokenAddress', + }, + }, addressBook: { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, unapprovedTxs: { 1: { id: 1, txParams: { - data: '', + data: generateERC20TransferData({ + toAddress: BURN_ADDRESS, + amount: '0x3a98', + sendToken: { + address: '0xTokenAddress', + symbol: 'SYMB', + decimals: 18, + }, + }), from: '0xAddress', to: '0xTokenAddress', gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE, @@ -2260,24 +2655,18 @@ describe('Send Slice', () => { }, }, send: { - account: { + ...getInitialSendStateWithExistingTxState({ + id: 1, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }), + selectedAccount: { address: '0xAddress', balance: '0x0', }, - asset: { - type: '', - }, - gas: { - gasPrice: '', - }, - amount: { - value: '', - }, - userInputHexData: '', - recipient: { - address: 'Address', - nickname: 'NickName', - }, + stage: SEND_STAGES.EDIT, }, }; @@ -2292,118 +2681,146 @@ describe('Send Slice', () => { 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 }, - ), - ); + await store.dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, 1)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(11); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to SYMB', - }); - expect(actionResult[3]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to 0xTokenAddress', - }); - expect(actionResult[4].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[5].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[6].type).toStrictEqual('send/updateAsset'); - expect(actionResult[6].payload).toStrictEqual({ - balance: '0x0', - type: ASSET_TYPES.TOKEN, - error: null, - details: { - address: '0xTokenAddress', - decimals: 18, - symbol: 'SYMB', - standard: 'ERC20', + expect(actionResult).toHaveLength(9); + expect(actionResult[0].type).toStrictEqual('send/clearPreviousDrafts'); + expect(actionResult[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + error: null, + value: '0x3a98', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: '0x186a0', + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: BURN_ADDRESS, + error: null, + warning: null, + nickname: '', + recipientWarningAcknowledged: false, + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: + editTransactionState.metamask.unapprovedTxs[1].txParams.data, }, }); - expect(actionResult[7].type).toStrictEqual( - 'send/computeEstimatedGasLimit/pending', + expect(actionResult[2].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[3].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[4]).toMatchObject({ + type: 'send/addHistoryEntry', + payload: + 'sendFlow - user set asset to ERC20 token with symbol SYMB and address 0xTokenAddress', + }); + expect(actionResult[5]).toStrictEqual({ + type: 'send/updateAsset', + payload: { + balance: '0x0', + type: ASSET_TYPES.TOKEN, + error: null, + details: { + balance: '0x0', + address: '0xTokenAddress', + decimals: 18, + symbol: 'SYMB', + standard: 'ERC20', + }, + }, + }); + expect(actionResult[6].type).toStrictEqual( + 'send/initializeSendState/pending', ); - expect(actionResult[8].type).toStrictEqual( + expect(actionResult[7].type).toStrictEqual( 'metamask/gas/SET_CUSTOM_GAS_LIMIT', ); - expect(actionResult[9].type).toStrictEqual( - 'send/computeEstimatedGasLimit/fulfilled', + expect(actionResult[8].type).toStrictEqual( + 'send/initializeSendState/fulfilled', ); - expect(actionResult[10].type).toStrictEqual('send/editTransaction'); - expect(actionResult[10].payload).toStrictEqual({ - address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase - amount: '0x3a98', - data: '', - from: '0xAddress', - gasLimit: GAS_LIMITS.BASE_TOKEN_ESTIMATE, - gasPrice: '0x3b9aca00', - id: 1, - nickname: '', - }); - const action = actionResult[10]; + const action = actionResult[1]; - const result = sendReducer(initialState, action); + const result = sendReducer(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, action); - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); - expect(result.amount.value).toStrictEqual(action.payload.amount); + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); }); describe('selectors', () => { describe('gas selectors', () => { it('has a selector that gets gasLimit', () => { - expect(getGasLimit({ send: initialState })).toBe('0x0'); + expect( + getGasLimit({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector that gets gasPrice', () => { - expect(getGasPrice({ send: initialState })).toBe('0x0'); + expect( + getGasPrice({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector that gets gasTotal', () => { - expect(getGasTotal({ send: initialState })).toBe('0x0'); + expect( + getGasTotal({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector to determine if gas fee is in error', () => { - expect(gasFeeIsInError({ send: initialState })).toBe(false); + expect( + gasFeeIsInError({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( gasFeeIsInError({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ gas: { - ...initialState.gas, error: 'yes', }, - }, + }), }), ).toBe(true); }); it('has a selector that gets minimumGasLimit', () => { - expect(getMinimumGasLimitForSend({ send: initialState })).toBe( - GAS_LIMITS.SIMPLE, - ); + expect( + getMinimumGasLimitForSend({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(GAS_LIMITS.SIMPLE); }); describe('getGasInputMode selector', () => { @@ -2473,7 +2890,7 @@ describe('Send Slice', () => { process.env.IN_TEST = false; }); - it('returns CUSTOM if isCustomGasSet is true', () => { + it('returns CUSTOM if gasIsSetInModal is true', () => { expect( getGasInputMode({ metamask: { @@ -2481,11 +2898,8 @@ describe('Send Slice', () => { featureFlags: { advancedInlineGas: true }, }, send: { - ...initialState, - gas: { - ...initialState.send, - isCustomGasSet: true, - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + gasIsSetInModal: true, }, }), ).toBe(GAS_INPUT_MODES.CUSTOM); @@ -2495,38 +2909,39 @@ describe('Send Slice', () => { describe('asset selectors', () => { it('has a selector to get the asset', () => { - expect(getSendAsset({ send: initialState })).toMatchObject( - initialState.asset, + expect( + getSendAsset({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toMatchObject( + getTestUUIDTx(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT).asset, ); }); it('has a selector to get the asset address', () => { expect( getSendAssetAddress({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ asset: { balance: '0x0', details: { address: '0x0' }, type: ASSET_TYPES.TOKEN, }, - }, + }), }), ).toBe('0x0'); }); it('has a selector that determines if asset is sendable based on ERC721 status', () => { - expect(getIsAssetSendable({ send: initialState })).toBe(true); + expect( + getIsAssetSendable({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(true); expect( getIsAssetSendable({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ asset: { - ...initialState, type: ASSET_TYPES.TOKEN, details: { isERC721: true }, }, - }, + }), }), ).toBe(false); }); @@ -2534,65 +2949,77 @@ describe('Send Slice', () => { describe('amount selectors', () => { it('has a selector to get send amount', () => { - expect(getSendAmount({ send: initialState })).toBe('0x0'); + expect( + getSendAmount({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector to get if there is an insufficient funds error', () => { - expect(getIsBalanceInsufficient({ send: initialState })).toBe(false); expect( getIsBalanceInsufficient({ - send: { - ...initialState, - gas: { ...initialState.gas, error: INSUFFICIENT_FUNDS_ERROR }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(false); + expect( + getIsBalanceInsufficient({ + send: getInitialSendStateWithExistingTxState({ + gas: { error: INSUFFICIENT_FUNDS_ERROR }, + }), }), ).toBe(true); }); it('has a selector to get max mode state', () => { - expect(getSendMaxModeState({ send: initialState })).toBe(false); + expect( + getSendMaxModeState({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( getSendMaxModeState({ send: { - ...initialState, - amount: { ...initialState.amount, mode: AMOUNT_MODES.MAX }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + amountMode: AMOUNT_MODES.MAX, }, }), ).toBe(true); }); it('has a selector to get the draft transaction ID', () => { - expect(getDraftTransactionID({ send: initialState })).toBeNull(); expect( getDraftTransactionID({ - send: { - ...initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBeNull(); + expect( + getDraftTransactionID({ + send: getInitialSendStateWithExistingTxState({ id: 'ID', - }, + }), }), ).toBe('ID'); }); it('has a selector to get the user entered hex data', () => { - expect(getSendHexData({ send: initialState })).toBeNull(); + expect( + getSendHexData({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBeNull(); expect( getSendHexData({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ userInputHexData: '0x0', - }, + }), }), ).toBe('0x0'); }); it('has a selector to get if there is an amount error', () => { - expect(sendAmountIsInError({ send: initialState })).toBe(false); + expect( + sendAmountIsInError({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( sendAmountIsInError({ - send: { - ...initialState, - amount: { ...initialState.amount, error: 'any' }, - }, + send: getInitialSendStateWithExistingTxState({ + amount: { error: 'any' }, + }), }), ).toBe(true); }); @@ -2600,44 +3027,49 @@ describe('Send Slice', () => { describe('recipient selectors', () => { it('has a selector to get recipient address', () => { - expect(getSendTo({ send: initialState })).toBe(''); expect( getSendTo({ - send: { - ...initialState, - recipient: { ...initialState.recipient, address: '0xb' }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + metamask: { ensResolutionsByAddress: {} }, + }), + ).toBe(''); + expect( + getSendTo({ + send: getInitialSendStateWithExistingTxState({ + recipient: { address: '0xb' }, + }), + metamask: { ensResolutionsByAddress: {} }, }), ).toBe('0xb'); }); it('has a selector to check if using the my accounts option for recipient selection', () => { expect( - getIsUsingMyAccountForRecipientSearch({ send: initialState }), + getIsUsingMyAccountForRecipientSearch({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), ).toBe(false); expect( getIsUsingMyAccountForRecipientSearch({ send: { - ...initialState, - recipient: { - ...initialState.recipient, - mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientMode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, }, }), ).toBe(true); }); it('has a selector to get recipient user input in input field', () => { - expect(getRecipientUserInput({ send: initialState })).toBe(''); + expect( + getRecipientUserInput({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(''); expect( getRecipientUserInput({ send: { - ...initialState, - recipient: { - ...initialState.recipient, - userInput: 'domain.eth', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: 'domain.eth', }, }), ).toBe('domain.eth'); @@ -2646,42 +3078,47 @@ describe('Send Slice', () => { it('has a selector to get recipient state', () => { expect( getRecipient({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, metamask: { ensResolutionsByAddress: {} }, }), - ).toMatchObject(initialState.recipient); + ).toMatchObject( + getTestUUIDTx(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT).recipient, + ); }); }); describe('send validity selectors', () => { it('has a selector to get send errors', () => { - expect(getSendErrors({ send: initialState })).toMatchObject({ + expect( + getSendErrors({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toMatchObject({ gasFee: null, amount: null, }); expect( getSendErrors({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ gas: { - ...initialState.gas, error: 'gasFeeTest', }, amount: { - ...initialState.amount, error: 'amountTest', }, - }, + }), }), ).toMatchObject({ gasFee: 'gasFeeTest', amount: 'amountTest' }); }); it('has a selector to get send state initialization status', () => { - expect(isSendStateInitialized({ send: initialState })).toBe(false); + expect( + isSendStateInitialized({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(false); expect( isSendStateInitialized({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STATUSES.ADD_RECIPIENT, }, }), @@ -2689,19 +3126,28 @@ describe('Send Slice', () => { }); it('has a selector to get send state validity', () => { - expect(isSendFormInvalid({ send: initialState })).toBe(false); + expect( + isSendFormInvalid({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( isSendFormInvalid({ - send: { ...initialState, status: SEND_STATUSES.INVALID }, + send: getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.INVALID, + }), }), ).toBe(true); }); it('has a selector to get send stage', () => { - expect(getSendStage({ send: initialState })).toBe(SEND_STAGES.INACTIVE); + expect( + getSendStage({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(SEND_STAGES.INACTIVE); expect( getSendStage({ - send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.ADD_RECIPIENT, + }, }), ).toBe(SEND_STAGES.ADD_RECIPIENT); }); diff --git a/ui/helpers/constants/common.js b/ui/helpers/constants/common.js index 2663c2108..7e980f822 100644 --- a/ui/helpers/constants/common.js +++ b/ui/helpers/constants/common.js @@ -47,6 +47,8 @@ export const GAS_ESTIMATE_TYPES = { let _supportLink = 'https://support.metamask.io'; let _supportRequestLink = 'https://metamask.zendesk.com/hc/en-us/requests/new'; +const _contractAddressLink = + 'https://metamask.zendesk.com/hc/en-us/articles/360020028092-What-is-the-known-contract-address-warning-'; ///: BEGIN:ONLY_INCLUDE_IN(flask) _supportLink = 'https://metamask-flask.zendesk.com/hc'; @@ -56,3 +58,4 @@ _supportRequestLink = export const SUPPORT_LINK = _supportLink; export const SUPPORT_REQUEST_LINK = _supportRequestLink; +export const CONTRACT_ADDRESS_LINK = _contractAddressLink; diff --git a/ui/helpers/constants/routes.js b/ui/helpers/constants/routes.js index 9f5f028e1..fa816e412 100644 --- a/ui/helpers/constants/routes.js +++ b/ui/helpers/constants/routes.js @@ -12,6 +12,8 @@ const ALERTS_ROUTE = '/settings/alerts'; const NETWORKS_ROUTE = '/settings/networks'; const NETWORKS_FORM_ROUTE = '/settings/networks/form'; const ADD_NETWORK_ROUTE = '/settings/networks/add-network'; +const ADD_POPULAR_CUSTOM_NETWORK = + '/settings/networks/add-popular-custom-network'; const SNAPS_LIST_ROUTE = '/settings/snaps-list'; const SNAPS_VIEW_ROUTE = '/settings/snaps-view'; const CONTACT_LIST_ROUTE = '/settings/contact-list'; @@ -88,6 +90,7 @@ const CONFIRM_SEND_ETHER_PATH = '/send-ether'; const CONFIRM_SEND_TOKEN_PATH = '/send-token'; const CONFIRM_DEPLOY_CONTRACT_PATH = '/deploy-contract'; const CONFIRM_APPROVE_PATH = '/approve'; +const CONFIRM_SET_APPROVAL_FOR_ALL_PATH = '/set-approval-for-all'; const CONFIRM_TRANSFER_FROM_PATH = '/transfer-from'; const CONFIRM_SAFE_TRANSFER_FROM_PATH = '/safe-transfer-from'; const CONFIRM_TOKEN_METHOD_PATH = '/token-method'; @@ -113,6 +116,8 @@ const PATH_NAME_MAP = { [NETWORKS_ROUTE]: 'Network Settings Page', [NETWORKS_FORM_ROUTE]: 'Network Settings Page Form', [ADD_NETWORK_ROUTE]: 'Add Network From Settings Page Form', + [ADD_POPULAR_CUSTOM_NETWORK]: + 'Add Network From A List Of Popular Custom Networks', [CONTACT_LIST_ROUTE]: 'Contact List Settings Page', [`${CONTACT_EDIT_ROUTE}/:address`]: 'Edit Contact Settings Page', [CONTACT_ADD_ROUTE]: 'Add Contact Settings Page', @@ -141,6 +146,7 @@ const PATH_NAME_MAP = { [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_SEND_TOKEN_PATH}`]: 'Confirm Send Token Transaction Page', [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_DEPLOY_CONTRACT_PATH}`]: 'Confirm Deploy Contract Transaction Page', [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_APPROVE_PATH}`]: 'Confirm Approve Transaction Page', + [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_SET_APPROVAL_FOR_ALL_PATH}`]: 'Confirm Set Approval For All Transaction Page', [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_TRANSFER_FROM_PATH}`]: 'Confirm Transfer From Transaction Page', [`${CONFIRM_TRANSACTION_ROUTE}/:id${CONFIRM_SAFE_TRANSFER_FROM_PATH}`]: 'Confirm Safe Transfer From Transaction Page', [`${CONFIRM_TRANSACTION_ROUTE}/:id${SIGNATURE_REQUEST_PATH}`]: 'Signature Request Page', @@ -202,6 +208,7 @@ export { CONFIRM_SEND_TOKEN_PATH, CONFIRM_DEPLOY_CONTRACT_PATH, CONFIRM_APPROVE_PATH, + CONFIRM_SET_APPROVAL_FOR_ALL_PATH, CONFIRM_TRANSFER_FROM_PATH, CONFIRM_SAFE_TRANSFER_FROM_PATH, CONFIRM_TOKEN_METHOD_PATH, @@ -224,6 +231,7 @@ export { NETWORKS_ROUTE, NETWORKS_FORM_ROUTE, ADD_NETWORK_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, INITIALIZE_BACKUP_SEED_PHRASE_ROUTE, INITIALIZE_SEED_PHRASE_INTRO_ROUTE, CONNECT_ROUTE, diff --git a/ui/helpers/constants/settings.js b/ui/helpers/constants/settings.js index 3f05221e4..3148e7543 100644 --- a/ui/helpers/constants/settings.js +++ b/ui/helpers/constants/settings.js @@ -346,4 +346,11 @@ export const SETTINGS_CONSTANTS = [ icon: 'fa fa-flask', featureFlag: 'COLLECTIBLES_V1', }, + { + tabMessage: (t) => t('experimental'), + sectionMessage: (t) => t('showCustomNetworkList'), + descriptionMessage: (t) => t('showCustomNetworkListDescription'), + route: `${EXPERIMENTAL_ROUTE}#show-custom-network`, + icon: 'fa fa-flask', + }, ]; diff --git a/ui/helpers/constants/transactions.js b/ui/helpers/constants/transactions.js index b7f932054..4dc66ab79 100644 --- a/ui/helpers/constants/transactions.js +++ b/ui/helpers/constants/transactions.js @@ -17,6 +17,7 @@ export const PRIORITY_STATUS_HASH = { export const TOKEN_CATEGORY_HASH = { [TRANSACTION_TYPES.TOKEN_METHOD_APPROVE]: true, + [TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL]: true, [TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER]: true, [TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM]: true, }; diff --git a/ui/helpers/utils/settings-search.test.js b/ui/helpers/utils/settings-search.test.js index 3ef7825d9..9f0264e1d 100644 --- a/ui/helpers/utils/settings-search.test.js +++ b/ui/helpers/utils/settings-search.test.js @@ -195,7 +195,7 @@ describe('Settings Search Utils', () => { it('should get good experimental section number', () => { expect(getNumberOfSettingsInSection(t, t('experimental'))).toStrictEqual( - 3, + 4, ); }); diff --git a/ui/helpers/utils/token-util.js b/ui/helpers/utils/token-util.js index b934ed176..784ceb772 100644 --- a/ui/helpers/utils/token-util.js +++ b/ui/helpers/utils/token-util.js @@ -43,7 +43,7 @@ async function getDecimalsFromContract(tokenAddress) { } } -function getTokenMetadata(tokenAddress, tokenList) { +export function getTokenMetadata(tokenAddress, tokenList) { const casedTokenList = Object.keys(tokenList).reduce((acc, base) => { return { ...acc, @@ -151,6 +151,10 @@ export function getTokenValueParam(tokenData = {}) { return tokenData?.args?._value?.toString(); } +export function getTokenApprovedParam(tokenData = {}) { + return tokenData?.args?._approved; +} + export function getTokenValue(tokenParams = []) { const valueData = tokenParams.find((param) => param.name === '_value'); return valueData && valueData.value; diff --git a/ui/helpers/utils/transactions.util.js b/ui/helpers/utils/transactions.util.js index d1380d8c0..07d37341d 100644 --- a/ui/helpers/utils/transactions.util.js +++ b/ui/helpers/utils/transactions.util.js @@ -116,6 +116,7 @@ export function isTokenMethodAction(type) { return [ TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, ].includes(type); @@ -217,6 +218,9 @@ export function getTransactionTypeTitle(t, type, nativeCurrency = 'ETH') { case TRANSACTION_TYPES.TOKEN_METHOD_APPROVE: { return t('approve'); } + case TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL: { + return t('setApprovalForAll'); + } case TRANSACTION_TYPES.SIMPLE_SEND: { return t('sendingNativeAsset', [nativeCurrency]); } diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index 184374496..9c6a13768 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -222,6 +222,12 @@ export function useTransactionDisplayData(transactionGroup) { title = t('approveSpendLimit', [token?.symbol || t('token')]); subtitle = origin; subtitleContainsOrigin = true; + } else if (type === TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL) { + category = TRANSACTION_GROUP_CATEGORIES.APPROVAL; + prefix = ''; + title = t('setApprovalForAllTitle', [token?.symbol || t('token')]); + subtitle = origin; + subtitleContainsOrigin = true; } else if (type === TRANSACTION_TYPES.CONTRACT_INTERACTION) { category = TRANSACTION_GROUP_CATEGORIES.INTERACTION; const transactionTypeTitle = getTransactionTypeTitle(t, type); diff --git a/ui/pages/add-collectible/add-collectible.js b/ui/pages/add-collectible/add-collectible.js index 7d9112a92..758e03dc7 100644 --- a/ui/pages/add-collectible/add-collectible.js +++ b/ui/pages/add-collectible/add-collectible.js @@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { util } from '@metamask/controllers'; import { useI18nContext } from '../../hooks/useI18nContext'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; - import { DISPLAY, FONT_WEIGHT, @@ -55,9 +54,7 @@ export default function AddCollectible() { const handleAddCollectible = async () => { try { - await dispatch( - addCollectibleVerifyOwnership(address, tokenId.toString()), - ); + await dispatch(addCollectibleVerifyOwnership(address, tokenId)); } catch (error) { const { message } = error; dispatch(setNewCollectibleAddedMessage(message)); @@ -99,7 +96,7 @@ export default function AddCollectible() { }; const validateAndSetTokenId = (val) => { - setDisabled(!util.isValidHexAddress(address) || !val); + setDisabled(!util.isValidHexAddress(address) || !val || isNaN(Number(val))); setTokenId(val); }; @@ -149,7 +146,7 @@ export default function AddCollectible() { )} <Box margin={4}> <FormField - id="address" + dataTestId="address" titleText={t('address')} placeholder="0x..." value={address} @@ -161,7 +158,7 @@ export default function AddCollectible() { autoFocus /> <FormField - id="token-id" + dataTestId="token-id" titleText={t('tokenId')} placeholder={t('nftTokenIdPlaceholder')} value={tokenId} @@ -170,7 +167,6 @@ export default function AddCollectible() { setCollectibleAddFailed(false); }} tooltipText={t('importNFTTokenIdToolTip')} - numeric /> </Box> </Box> diff --git a/ui/pages/add-collectible/add-collectible.test.js b/ui/pages/add-collectible/add-collectible.test.js new file mode 100644 index 000000000..be693e444 --- /dev/null +++ b/ui/pages/add-collectible/add-collectible.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../test/jest/rendering'; +import * as Actions from '../../store/actions'; +import AddCollectible from '.'; + +const VALID_ADDRESS = '0x312BE6a98441F9F6e3F6246B13CA19701e0AC3B9'; +const INVALID_ADDRESS = 'aoinsafasdfa'; +const VALID_TOKENID = '1201'; +const INVALID_TOKENID = 'abcde'; + +describe('AddCollectible', () => { + const store = configureMockStore([])({ + metamask: { provider: { chainId: '0x1' } }, + }); + + it('should enable the "Add" button when valid entries are input into both Address and TokenId fields', () => { + const { getByTestId, getByText } = renderWithProvider( + <AddCollectible />, + store, + ); + expect(getByText('Add')).not.toBeEnabled(); + fireEvent.change(getByTestId('address'), { + target: { value: VALID_ADDRESS }, + }); + fireEvent.change(getByTestId('token-id'), { + target: { value: VALID_TOKENID }, + }); + expect(getByText('Add')).toBeEnabled(); + }); + + it('should not enable the "Add" button when an invalid entry is input into one or both Address and TokenId fields', () => { + const { getByTestId, getByText } = renderWithProvider( + <AddCollectible />, + store, + ); + expect(getByText('Add')).not.toBeEnabled(); + fireEvent.change(getByTestId('address'), { + target: { value: INVALID_ADDRESS }, + }); + fireEvent.change(getByTestId('token-id'), { + target: { value: VALID_TOKENID }, + }); + expect(getByText('Add')).not.toBeEnabled(); + fireEvent.change(getByTestId('address'), { + target: { value: VALID_ADDRESS }, + }); + expect(getByText('Add')).toBeEnabled(); + fireEvent.change(getByTestId('token-id'), { + target: { value: INVALID_TOKENID }, + }); + expect(getByText('Add')).not.toBeEnabled(); + }); + + it('should call addCollectibleVerifyOwnership action with correct values (tokenId should not be in scientific notation)', () => { + const { getByTestId, getByText } = renderWithProvider( + <AddCollectible />, + store, + ); + fireEvent.change(getByTestId('address'), { + target: { value: VALID_ADDRESS }, + }); + const LARGE_TOKEN_ID = Number.MAX_SAFE_INTEGER + 1; + fireEvent.change(getByTestId('token-id'), { + target: { value: LARGE_TOKEN_ID }, + }); + const addCollectibleVerifyOwnershipSpy = jest.spyOn( + Actions, + 'addCollectibleVerifyOwnership', + ); + + fireEvent.click(getByText('Add')); + expect(addCollectibleVerifyOwnershipSpy).toHaveBeenCalledWith( + '0x312BE6a98441F9F6e3F6246B13CA19701e0AC3B9', + '9007199254740992', + ); + }); +}); diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index 7a6d32cce..b87c405b9 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -71,6 +71,9 @@ export default class ConfirmApproveContent extends Component { assetName: PropTypes.string, tokenId: PropTypes.string, assetStandard: PropTypes.string, + isSetApproveForAll: PropTypes.bool, + setApproveForAllArg: PropTypes.bool, + userAddress: PropTypes.string, }; state = { @@ -100,7 +103,7 @@ export default class ConfirmApproveContent extends Component { > {showHeader && ( <div className="confirm-approve-content__card-header"> - {!supportsEIP1559V2 && ( + {supportsEIP1559V2 && title === t('transactionFee') ? null : ( <> <div className="confirm-approve-content__card-header__symbol"> {symbol} @@ -184,7 +187,7 @@ export default class ConfirmApproveContent extends Component { renderERC721OrERC1155PermissionContent() { const { t } = this.context; - const { origin, toAddress, isContract } = this.props; + const { origin, toAddress, isContract, isSetApproveForAll } = this.props; const titleTokenDescription = this.getTitleTokenDescription(); @@ -201,7 +204,9 @@ export default class ConfirmApproveContent extends Component { {t('approvedAsset')}: </div> <div className="confirm-approve-content__medium-text"> - {titleTokenDescription} + {isSetApproveForAll + ? t('allOfYour', [titleTokenDescription]) + : titleTokenDescription} </div> </div> <div className="flex-row"> @@ -299,12 +304,19 @@ export default class ConfirmApproveContent extends Component { renderDataContent() { const { t } = this.context; - const { data } = this.props; + const { data, isSetApproveForAll, setApproveForAllArg } = this.props; return ( <div className="flex-column"> <div className="confirm-approve-content__small-text"> - {t('functionApprove')} + {isSetApproveForAll + ? t('functionSetApprovalForAll') + : t('functionApprove')} </div> + {isSetApproveForAll && setApproveForAllArg !== undefined ? ( + <div className="confirm-approve-content__small-text"> + {`${t('parameters')}: ${setApproveForAllArg}`} + </div> + ) : null} <div className="confirm-approve-content__small-text confirm-approve-content__data__data-block"> {data} </div> @@ -442,6 +454,8 @@ export default class ConfirmApproveContent extends Component { chainId, assetStandard, tokenSymbol, + isSetApproveForAll, + userAddress, } = this.props; const { t } = this.context; let titleTokenDescription = t('token'); @@ -450,6 +464,7 @@ export default class ConfirmApproveContent extends Component { tokenAddress, chainId, null, + userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }, @@ -468,7 +483,10 @@ export default class ConfirmApproveContent extends Component { titleTokenDescription = unknownTokenLink; } - if (assetStandard === ERC20 || (tokenSymbol && !tokenId)) { + if ( + assetStandard === ERC20 || + (tokenSymbol && !tokenId && !isSetApproveForAll) + ) { titleTokenDescription = tokenSymbol; } else if ( assetStandard === ERC721 || @@ -477,14 +495,15 @@ export default class ConfirmApproveContent extends Component { (assetName && tokenId) || (tokenSymbol && tokenId) ) { - const tokenIdWrapped = tokenId ? ` (#${tokenId})` : null; + const tokenIdWrapped = tokenId ? ` (#${tokenId})` : ''; if (assetName || tokenSymbol) { - titleTokenDescription = `${assetName ?? tokenSymbol} ${tokenIdWrapped}`; + titleTokenDescription = `${assetName ?? tokenSymbol}${tokenIdWrapped}`; } else { const unknownNFTBlockExplorerLink = getTokenTrackerLink( tokenAddress, chainId, null, + userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }, @@ -509,6 +528,41 @@ export default class ConfirmApproveContent extends Component { return titleTokenDescription; } + renderTitle() { + const { t } = this.context; + const { isSetApproveForAll, setApproveForAllArg } = this.props; + const titleTokenDescription = this.getTitleTokenDescription(); + + let title; + + if (isSetApproveForAll) { + title = t('approveAllTokensTitle', [titleTokenDescription]); + if (setApproveForAllArg === false) { + title = t('revokeAllTokensTitle', [titleTokenDescription]); + } + } + return title || t('allowSpendToken', [titleTokenDescription]); + } + + renderDescription() { + const { t } = this.context; + const { isContract, isSetApproveForAll, setApproveForAllArg } = this.props; + const grantee = isContract + ? t('contract').toLowerCase() + : t('account').toLowerCase(); + + let description = t('trustSiteApprovePermission', [grantee]); + + if (isSetApproveForAll && setApproveForAllArg === false) { + description = t('revokeApproveForAllDescription', [ + grantee, + this.getTitleTokenDescription(), + ]); + } + + return description; + } + render() { const { t } = this.context; const { @@ -531,11 +585,10 @@ export default class ConfirmApproveContent extends Component { rpcPrefs, isContract, assetStandard, + userAddress, } = this.props; const { showFullTxDetails } = this.state; - const titleTokenDescription = this.getTitleTokenDescription(); - return ( <div className={classnames('confirm-approve-content', { @@ -575,14 +628,10 @@ export default class ConfirmApproveContent extends Component { </Box> </Box> <div className="confirm-approve-content__title"> - {t('allowSpendToken', [titleTokenDescription])} + {this.renderTitle()} </div> <div className="confirm-approve-content__description"> - {t('trustSiteApprovePermission', [ - isContract - ? t('contract').toLowerCase() - : t('account').toLowerCase(), - ])} + {this.renderDescription()} </div> <Box className="confirm-approve-content__address-display-content"> <Box display={DISPLAY.FLEX}> @@ -623,7 +672,7 @@ export default class ConfirmApproveContent extends Component { className="confirm-approve-content__etherscan-link" onClick={() => { const blockExplorerTokenLink = isContract - ? getTokenTrackerLink(toAddress, chainId, null, null, { + ? getTokenTrackerLink(toAddress, chainId, null, userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }) : getAccountLink( diff --git a/ui/pages/confirm-approve/confirm-approve.js b/ui/pages/confirm-approve/confirm-approve.js index 6dca97d1b..072a4d330 100644 --- a/ui/pages/confirm-approve/confirm-approve.js +++ b/ui/pages/confirm-approve/confirm-approve.js @@ -8,7 +8,10 @@ import { updateCustomNonce, getNextNonce, } from '../../store/actions'; -import { calcTokenAmount } from '../../helpers/utils/token-util'; +import { + calcTokenAmount, + getTokenApprovedParam, +} from '../../helpers/utils/token-util'; import { readAddressAsContract } from '../../../shared/modules/contract-utils'; import { GasFeeContextProvider } from '../../contexts/gasFee'; import { TransactionModalContextProvider } from '../../contexts/transaction-modal'; @@ -34,6 +37,7 @@ import EditGasFeePopover from '../../components/app/edit-gas-fee-popover'; import EditGasPopover from '../../components/app/edit-gas-popover/edit-gas-popover.component'; import Loading from '../../components/ui/loading-screen'; import { ERC20, ERC1155, ERC721 } from '../../helpers/constants/common'; +import { parseStandardTokenTransactionData } from '../../../shared/modules/transaction.utils'; import { getCustomTxParamsData } from './confirm-approve.util'; import ConfirmApproveContent from './confirm-approve-content'; @@ -57,6 +61,7 @@ export default function ConfirmApprove({ ethTransactionTotal, fiatTransactionTotal, hexTransactionTotal, + isSetApproveForAll, }) { const dispatch = useDispatch(); const { txParams: { data: transactionData } = {} } = transaction; @@ -150,6 +155,11 @@ export default function ConfirmApprove({ }) : null; + const parsedTransactionData = parseStandardTokenTransactionData( + transactionData, + ); + const setApproveForAllArg = getTokenApprovedParam(parsedTransactionData); + return tokenSymbol === undefined && assetName === undefined ? ( <Loading /> ) : ( @@ -162,6 +172,9 @@ export default function ConfirmApprove({ contentComponent={ <TransactionModalContextProvider> <ConfirmApproveContent + userAddress={userAddress} + isSetApproveForAll={isSetApproveForAll} + setApproveForAllArg={setApproveForAllArg} decimals={decimals} siteImage={siteImage} setCustomAmount={setCustomPermissionAmount} @@ -290,4 +303,5 @@ ConfirmApprove.propTypes = { ethTransactionTotal: PropTypes.string, fiatTransactionTotal: PropTypes.string, hexTransactionTotal: PropTypes.string, + isSetApproveForAll: PropTypes.bool, }; 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 eb794b6ce..80318ea46 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 { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { ASSET_TYPES } from '../../../shared/constants/transaction'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -20,7 +20,9 @@ const mapDispatchToProps = (dispatch) => { return { editTransaction: async (txData) => { const { id } = txData; - await dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString())); + await dispatch( + editExistingTransaction(ASSET_TYPES.NATIVE, id.toString()), + ); dispatch(clearConfirmTransaction()); }, }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.component.js b/ui/pages/confirm-send-token/confirm-send-token.component.js index 6e040fb06..774fbf670 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.component.js +++ b/ui/pages/confirm-send-token/confirm-send-token.component.js @@ -6,14 +6,15 @@ import { SEND_ROUTE } from '../../helpers/constants/routes'; export default class ConfirmSendToken extends Component { static propTypes = { history: PropTypes.object, - editTransaction: PropTypes.func, + editExistingTransaction: PropTypes.func, tokenAmount: PropTypes.string, }; handleEdit(confirmTransactionData) { - const { editTransaction, history } = this.props; - editTransaction(confirmTransactionData); - history.push(SEND_ROUTE); + const { editExistingTransaction, history } = this.props; + editExistingTransaction(confirmTransactionData).then(() => { + history.push(SEND_ROUTE); + }); } render() { 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 d8a498424..62cee9ac3 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -3,7 +3,7 @@ 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 { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors'; import { ASSET_TYPES } from '../../../shared/constants/transaction'; import ConfirmSendToken from './confirm-send-token.component'; @@ -18,18 +18,11 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => { + editExistingTransaction: async ({ txData }) => { const { id } = txData; - dispatch( - editTransaction( - ASSET_TYPES.TOKEN, - id.toString(), - tokenData, - assetDetails, - ), - ); - dispatch(clearConfirmTransaction()); - dispatch(showSendTokenPage()); + await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString())); + await dispatch(clearConfirmTransaction()); + await dispatch(showSendTokenPage()); }, }; }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.js b/ui/pages/confirm-send-token/confirm-send-token.js index 8d40d36c9..a7afb7af4 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.js +++ b/ui/pages/confirm-send-token/confirm-send-token.js @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import ConfirmTokenTransactionBase from '../confirm-token-transaction-base/confirm-token-transaction-base'; import { SEND_ROUTE } from '../../helpers/constants/routes'; -import { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { contractExchangeRateSelector, getCurrentCurrency, @@ -35,27 +35,17 @@ export default function ConfirmSendToken({ const dispatch = useDispatch(); const history = useHistory(); - const handleEditTransaction = ({ - txData, - tokenData, - tokenProps: assetDetails, - }) => { + const handleEditTransaction = async ({ txData }) => { const { id } = txData; - dispatch( - editTransaction( - ASSET_TYPES.TOKEN, - id.toString(), - tokenData, - assetDetails, - ), - ); + await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString())); dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); }; const handleEdit = (confirmTransactionData) => { - handleEditTransaction(confirmTransactionData); - history.push(SEND_ROUTE); + handleEditTransaction(confirmTransactionData).then(() => { + history.push(SEND_ROUTE); + }); }; const conversionRate = useSelector(getConversionRate); const nativeCurrency = useSelector(getNativeCurrency); diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js index c48e0d256..a5435e42b 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -338,6 +338,7 @@ export default class ConfirmTransactionBase extends Component { }; const hasSimulationError = Boolean(txData.simulationFails); + const renderSimulationFailureWarning = hasSimulationError && !userAcknowledgedGasMissing; const networkName = NETWORK_TO_NAME_MAP[txData.chainId]; 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 e36049c80..7321d2793 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -54,6 +54,7 @@ import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; import { getGasLoadingAnimationIsShowing } from '../../ducks/app/app'; import { isLegacyTransaction } from '../../helpers/utils/transactions.util'; import { CUSTOM_GAS_ESTIMATE } from '../../../shared/constants/gas'; +import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; import { getTokenAddressParam } from '../../helpers/utils/token-util'; import ConfirmTransactionBase from './confirm-transaction-base.component'; @@ -112,7 +113,10 @@ const mapStateToProps = (state, ownProps) => { const { balance } = accounts[fromAddress]; const { name: fromName } = identities[fromAddress]; - const toAddress = propsToAddress || tokenToAddress || txParamsToAddress; + let toAddress = txParamsToAddress; + if (type !== TRANSACTION_TYPES.SIMPLE_SEND) { + toAddress = propsToAddress || tokenToAddress || txParamsToAddress; + } const tokenList = getTokenList(state); const useTokenDetection = getUseTokenDetection(state); diff --git a/ui/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/pages/confirm-transaction-switch/confirm-transaction-switch.component.js index db58cc640..4c6f964fa 100644 --- a/ui/pages/confirm-transaction-switch/confirm-transaction-switch.component.js +++ b/ui/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -14,6 +14,7 @@ import { DECRYPT_MESSAGE_REQUEST_PATH, ENCRYPTION_PUBLIC_KEY_REQUEST_PATH, CONFIRM_SAFE_TRANSFER_FROM_PATH, + CONFIRM_SET_APPROVAL_FOR_ALL_PATH, } from '../../helpers/constants/routes'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; @@ -47,6 +48,10 @@ export default class ConfirmTransactionSwitch extends Component { const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_APPROVE_PATH}`; return <Redirect to={{ pathname }} />; } + case TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SET_APPROVAL_FOR_ALL_PATH}`; + return <Redirect to={{ pathname }} />; + } case TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM: { const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TRANSFER_FROM_PATH}`; return <Redirect to={{ pathname }} />; diff --git a/ui/pages/confirm-transaction/confirm-token-transaction-switch.js b/ui/pages/confirm-transaction/confirm-token-transaction-switch.js index 036f2b149..43b554595 100644 --- a/ui/pages/confirm-transaction/confirm-token-transaction-switch.js +++ b/ui/pages/confirm-transaction/confirm-token-transaction-switch.js @@ -6,6 +6,7 @@ import { CONFIRM_APPROVE_PATH, CONFIRM_SAFE_TRANSFER_FROM_PATH, CONFIRM_SEND_TOKEN_PATH, + CONFIRM_SET_APPROVAL_FOR_ALL_PATH, CONFIRM_TRANSACTION_ROUTE, CONFIRM_TRANSFER_FROM_PATH, } from '../../helpers/constants/routes'; @@ -66,6 +67,30 @@ export default function ConfirmTokenTransactionSwitch({ transaction }) { /> )} /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SET_APPROVAL_FOR_ALL_PATH}`} + render={() => ( + <ConfirmApprove + isSetApproveForAll + assetStandard={assetStandard} + assetName={assetName} + userBalance={userBalance} + tokenSymbol={tokenSymbol} + decimals={decimals} + tokenImage={tokenImage} + tokenAmount={tokenAmount} + tokenId={tokenId} + userAddress={userAddress} + tokenAddress={tokenAddress} + toAddress={toAddress} + transaction={transaction} + ethTransactionTotal={ethTransactionTotal} + fiatTransactionTotal={fiatTransactionTotal} + hexTransactionTotal={hexTransactionTotal} + /> + )} + /> <Route exact path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TRANSFER_FROM_PATH}`} diff --git a/ui/pages/confirmation/confirmation.js b/ui/pages/confirmation/confirmation.js index b824155bc..9ccbda847 100644 --- a/ui/pages/confirmation/confirmation.js +++ b/ui/pages/confirmation/confirmation.js @@ -25,6 +25,7 @@ import { getUnapprovedTemplatedConfirmations } from '../../selectors'; import NetworkDisplay from '../../components/app/network-display/network-display'; import Callout from '../../components/ui/callout'; import SiteOrigin from '../../components/ui/site-origin'; +import { addCustomNetwork } from '../../store/actions'; import ConfirmationFooter from './components/confirmation-footer'; import { getTemplateValues, getTemplateAlerts } from './templates'; @@ -130,6 +131,7 @@ export default function ConfirmationPage() { const pendingConfirmation = pendingConfirmations[currentPendingConfirmation]; const originMetadata = useOriginMetadata(pendingConfirmation?.origin) || {}; const [alertState, dismissAlert] = useAlertState(pendingConfirmation); + const [stayOnPage, setStayOnPage] = useState(false); // Generating templatedValues is potentially expensive, and if done on every render // will result in a new object. Avoiding calling this generation unnecessarily will @@ -146,11 +148,11 @@ export default function ConfirmationPage() { // confirmations reduces to a number that is less than the currently // viewed index, reset the index. if (pendingConfirmations.length === 0) { - history.push(DEFAULT_ROUTE); + !stayOnPage && history.push(DEFAULT_ROUTE); } else if (pendingConfirmations.length <= currentPendingConfirmation) { setCurrentPendingConfirmation(pendingConfirmations.length - 1); } - }, [pendingConfirmations, history, currentPendingConfirmation]); + }, [pendingConfirmations, history, currentPendingConfirmation, stayOnPage]); if (!pendingConfirmation) { return null; } @@ -197,23 +199,25 @@ export default function ConfirmationPage() { /> </Box> ) : null} - <Box - alignItems="center" - marginTop={1} - padding={[1, 4, 4]} - flexDirection={FLEX_DIRECTION.COLUMN} - > - <SiteIcon - icon={originMetadata.iconUrl} - name={originMetadata.hostname} - size={36} - /> - <SiteOrigin - chip - siteOrigin={stripHttpsScheme(originMetadata.origin)} - title={stripHttpsScheme(originMetadata.origin)} - /> - </Box> + {pendingConfirmation.origin === 'metamask' ? null : ( + <Box + alignItems="center" + marginTop={1} + padding={[1, 4, 4]} + flexDirection={FLEX_DIRECTION.COLUMN} + > + <SiteIcon + icon={originMetadata.iconUrl} + name={originMetadata.hostname} + size={36} + /> + <SiteOrigin + chip + siteOrigin={stripHttpsScheme(originMetadata.origin)} + title={stripHttpsScheme(originMetadata.origin)} + /> + </Box> + )} <MetaMaskTemplateRenderer sections={templatedValues.content} /> </div> <ConfirmationFooter @@ -234,8 +238,15 @@ export default function ConfirmationPage() { </Callout> )) } - onApprove={templatedValues.onApprove} - onCancel={templatedValues.onCancel} + onApprove={() => { + templatedValues.onApprove.apply(); + pendingConfirmation.origin === 'metamask' && + dispatch(addCustomNetwork(pendingConfirmation.requestData)); + }} + onCancel={() => { + templatedValues.onCancel.apply(); + pendingConfirmation.origin === 'metamask' && setStayOnPage(true); + }} approveText={templatedValues.approvalText} cancelText={templatedValues.cancelText} /> diff --git a/ui/pages/confirmation/templates/add-ethereum-chain.js b/ui/pages/confirmation/templates/add-ethereum-chain.js index c417a1111..5e4594eab 100644 --- a/ui/pages/confirmation/templates/add-ethereum-chain.js +++ b/ui/pages/confirmation/templates/add-ethereum-chain.js @@ -1,7 +1,12 @@ import { ethErrors } from 'eth-rpc-errors'; +import React from 'react'; import { SEVERITIES, TYPOGRAPHY, + TEXT_ALIGN, + JUSTIFY_CONTENT, + DISPLAY, + COLORS, } from '../../../helpers/constants/design-system'; import fetchWithCache from '../../../helpers/utils/fetch-with-cache'; @@ -79,6 +84,11 @@ async function getAlerts(pendingApproval) { ); let validated = Boolean(matchedChain); + const originIsMetaMask = pendingApproval.origin === 'metamask'; + if (originIsMetaMask && validated) { + return []; + } + if (matchedChain) { if ( matchedChain.nativeCurrency?.decimals !== 18 || @@ -104,12 +114,39 @@ async function getAlerts(pendingApproval) { } function getValues(pendingApproval, t, actions) { + const originIsMetaMask = pendingApproval.origin === 'metamask'; + return { content: [ + { + hide: !originIsMetaMask, + element: 'Box', + key: 'network-box', + props: { + textAlign: TEXT_ALIGN.CENTER, + display: DISPLAY.FLEX, + justifyContent: JUSTIFY_CONTENT.CENTER, + marginTop: 4, + marginBottom: 2, + }, + children: [ + { + element: 'Chip', + key: 'network-chip', + props: { + label: pendingApproval.requestData.chainName, + backgroundColor: COLORS.BACKGROUND_ALTERNATIVE, + leftIconUrl: pendingApproval.requestData.imageUrl, + }, + }, + ], + }, { element: 'Typography', key: 'title', - children: t('addEthereumChainConfirmationTitle'), + children: originIsMetaMask + ? t('wantToAddThisNetwork') + : t('addEthereumChainConfirmationTitle'), props: { variant: TYPOGRAPHY.H3, align: 'center', @@ -127,7 +164,7 @@ function getValues(pendingApproval, t, actions) { variant: TYPOGRAPHY.H7, align: 'center', boxProps: { - margin: [0, 0, 4], + margin: originIsMetaMask ? [0, 8, 4] : [0, 0, 4], }, }, }, @@ -138,7 +175,55 @@ function getValues(pendingApproval, t, actions) { { element: 'b', key: 'bolded-text', - children: `${t('addEthereumChainConfirmationRisks')} `, + props: { + style: { display: originIsMetaMask && '-webkit-box' }, + }, + children: [ + `${t('addEthereumChainConfirmationRisks')} `, + { + hide: !originIsMetaMask, + element: 'Tooltip', + key: 'tooltip-info', + props: { + position: 'bottom', + interactive: true, + trigger: 'mouseenter', + html: ( + <div + style={{ + width: '180px', + margin: '16px', + textAlign: 'left', + }} + > + {t('someNetworksMayPoseSecurity')}{' '} + <a + key="zendesk_page_link" + href="https://metamask.zendesk.com/hc/en-us/articles/4417500466971" + rel="noreferrer" + target="_blank" + style={{ color: 'var(--color-primary-default)' }} + > + {t('learnMoreUpperCase')} + </a> + </div> + ), + }, + children: [ + { + element: 'i', + key: 'info-circle', + props: { + className: 'fas fa-info-circle', + style: { + marginLeft: '4px', + color: 'var(--color-icon-default)', + }, + }, + }, + ], + }, + ], }, { element: 'MetaMaskTranslation', @@ -164,7 +249,7 @@ function getValues(pendingApproval, t, actions) { variant: TYPOGRAPHY.H7, align: 'center', boxProps: { - margin: 0, + margin: originIsMetaMask ? [0, 8] : 0, }, }, }, @@ -205,7 +290,7 @@ function getValues(pendingApproval, t, actions) { pendingApproval.id, ethErrors.provider.userRejectedRequest().serialize(), ), - networkDisplay: true, + networkDisplay: !originIsMetaMask, }; } diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index cd728b85b..470cc6d78 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -26,9 +26,7 @@ import { TYPOGRAPHY, FONT_WEIGHT, DISPLAY, - ///: BEGIN:ONLY_INCLUDE_IN(flask) COLORS, - ///: END:ONLY_INCLUDE_IN } from '../../helpers/constants/design-system'; import { @@ -143,6 +141,9 @@ export default class Home extends PureComponent { closeNotificationPopup: PropTypes.func.isRequired, newTokensImported: PropTypes.string, setNewTokensImported: PropTypes.func.isRequired, + newCustomNetworkAdded: PropTypes.object, + setNewCustomNetworkAdded: PropTypes.func, + setRpcTarget: PropTypes.func, }; state = { @@ -280,6 +281,9 @@ export default class Home extends PureComponent { setNewCollectibleAddedMessage, newTokensImported, setNewTokensImported, + newCustomNetworkAdded, + setNewCustomNetworkAdded, + setRpcTarget, } = this.props; return ( <MultipleNotifications> @@ -479,6 +483,53 @@ export default class Home extends PureComponent { key="home-infuraBlockedNotification" /> ) : null} + {Object.keys(newCustomNetworkAdded).length !== 0 && ( + <Popover className="home__new-network-added"> + <i className="fa fa-check-circle fa-2x home__new-network-added__check-circle" /> + <Typography + variant={TYPOGRAPHY.H4} + margin={[5, 9, 0, 9]} + fontWeight={FONT_WEIGHT.BOLD} + > + {t('networkAddedSuccessfully')} + </Typography> + <Box margin={[8, 8, 5, 8]}> + <Button + type="primary" + className="home__new-network-added__switch-to-button" + onClick={() => { + setRpcTarget( + newCustomNetworkAdded.rpcUrl, + newCustomNetworkAdded.chainId, + newCustomNetworkAdded.ticker, + newCustomNetworkAdded.chainName, + ); + setNewCustomNetworkAdded(); + }} + > + <Typography + variant={TYPOGRAPHY.H6} + fontWeight={FONT_WEIGHT.NORMAL} + color={COLORS.PRIMARY_INVERSE} + > + {t('switchToNetwork', [newCustomNetworkAdded.chainName])} + </Typography> + </Button> + <Button + type="secondary" + onClick={() => setNewCustomNetworkAdded()} + > + <Typography + variant={TYPOGRAPHY.H6} + fontWeight={FONT_WEIGHT.NORMAL} + color={COLORS.PRIMARY_DEFAULT} + > + {t('dismiss')} + </Typography> + </Button> + </Box> + </Popover> + )} </MultipleNotifications> ); } diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 09254e101..1c7f5dfed 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -37,11 +37,16 @@ import { setNewNetworkAdded, setNewCollectibleAddedMessage, setNewTokensImported, + setRpcTarget, ///: BEGIN:ONLY_INCLUDE_IN(flask) removeSnapError, ///: END:ONLY_INCLUDE_IN } from '../../store/actions'; -import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app'; +import { + setThreeBoxLastUpdated, + hideWhatsNewPopup, + setNewCustomNetworkAdded, +} from '../../ducks/app/app'; import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask'; import { getSwapsFeatureIsLive } from '../../ducks/swaps/swaps'; import { getEnvironmentType } from '../../../app/scripts/lib/util'; @@ -138,6 +143,7 @@ const mapStateToProps = (state) => { isSigningQRHardwareTransaction, newCollectibleAddedMessage: getNewCollectibleAddedMessage(state), newTokensImported: getNewTokensImported(state), + newCustomNetworkAdded: appState.newCustomNetworkAdded, }; }; @@ -180,6 +186,12 @@ const mapDispatchToProps = (dispatch) => ({ setNewTokensImported: (newTokens) => { dispatch(setNewTokensImported(newTokens)); }, + setNewCustomNetworkAdded: () => { + dispatch(setNewCustomNetworkAdded({})); + }, + setRpcTarget: (rpcUrl, chainId, ticker, nickname) => { + dispatch(setRpcTarget(rpcUrl, chainId, ticker, nickname)); + }, }); export default compose( diff --git a/ui/pages/home/index.scss b/ui/pages/home/index.scss index dbecff153..11154b246 100644 --- a/ui/pages/home/index.scss +++ b/ui/pages/home/index.scss @@ -207,4 +207,18 @@ margin-inline-start: 32px; } } + + &__new-network-added { + border-radius: 10px; + text-align: center; + + &__check-circle { + color: var(--color-success-default); + margin-top: 20px; + } + + &__switch-to-button { + margin-bottom: 16px; + } + } } 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 fa2dbbfef..b0f791560 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 @@ -32,6 +32,7 @@ export default class AddRecipient extends Component { error: PropTypes.string, warning: PropTypes.string, }), + updateRecipientUserInput: PropTypes.func, }; constructor(props) { @@ -70,6 +71,7 @@ export default class AddRecipient extends Component { `sendFlow - User clicked recipient from ${type}. address: ${address}, nickname ${nickname}`, ); this.props.updateRecipient({ address, nickname }); + this.props.updateRecipientUserInput(address); }; searchForContacts = () => { 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 index a163a13a0..45b85d799 100644 --- 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 @@ -3,9 +3,13 @@ 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 { AMOUNT_MODES, SEND_STATUSES } from '../../../../../ducks/send'; import { renderWithProvider } from '../../../../../../test/jest'; import { GAS_ESTIMATE_TYPES } from '../../../../../../shared/constants/gas'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../../../../test/jest/mocks'; import AmountMaxButton from './amount-max-button'; const middleware = [thunk]; @@ -22,7 +26,7 @@ describe('AmountMaxButton Component', () => { EIPS: {}, }, }, - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }), ); expect(getByText('Max')).toBeTruthy(); @@ -36,12 +40,14 @@ describe('AmountMaxButton Component', () => { EIPS: {}, }, }, - send: { ...initialState, status: SEND_STATUSES.VALID }, + send: getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.VALID, + }), }); const { getByText } = renderWithProvider(<AmountMaxButton />, store); const expectedActions = [ - { type: 'send/updateAmountMode', payload: 'MAX' }, + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX }, ]; fireEvent.click(getByText('Max'), { bubbles: true }); @@ -58,9 +64,10 @@ describe('AmountMaxButton Component', () => { }, }, send: { - ...initialState, - status: SEND_STATUSES.VALID, - amount: { ...initialState.amount, mode: 'MAX' }, + ...getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.VALID, + }), + amountMode: AMOUNT_MODES.MAX, }, }); const { getByText } = renderWithProvider(<AmountMaxButton />, store); diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 3b54b4a78..a856624d1 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import PageContainerContent from '../../../components/ui/page-container/page-container-content.component'; import Dialog from '../../../components/ui/dialog'; +import ActionableMessage from '../../../components/ui/actionable-message'; import NicknamePopovers from '../../../components/app/modals/nickname-popovers'; import { ETH_GAS_PRICE_FETCH_WARNING_KEY, @@ -10,6 +11,7 @@ import { INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY, } from '../../../helpers/constants/error-keys'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; +import { CONTRACT_ADDRESS_LINK } from '../../../helpers/constants/common'; import SendAmountRow from './send-amount-row'; import SendHexDataRow from './send-hex-data-row'; import SendAssetRow from './send-asset-row'; @@ -38,6 +40,9 @@ export default class SendContent extends Component { asset: PropTypes.object, to: PropTypes.string, assetError: PropTypes.string, + recipient: PropTypes.object, + acknowledgeRecipientWarning: PropTypes.func, + recipientWarningAcknowledged: PropTypes.bool, }; render() { @@ -51,6 +56,8 @@ export default class SendContent extends Component { getIsBalanceInsufficient, asset, assetError, + recipient, + recipientWarningAcknowledged, } = this.props; let gasError; @@ -66,6 +73,10 @@ export default class SendContent extends Component { asset.type !== ASSET_TYPES.TOKEN && asset.type !== ASSET_TYPES.COLLECTIBLE; + const showKnownRecipientWarning = + recipient.warning === 'knownAddressRecipient'; + const hideAddContactDialog = recipient.warning === 'loading'; + return ( <PageContainerContent> <div className="send-v2__form"> @@ -76,7 +87,12 @@ export default class SendContent extends Component { : null} {error ? this.renderError(error) : null} {warning ? this.renderWarning() : null} - {this.maybeRenderAddContact()} + {showKnownRecipientWarning && !recipientWarningAcknowledged + ? this.renderRecipientWarning() + : null} + {showKnownRecipientWarning || hideAddContactDialog + ? null + : this.maybeRenderAddContact()} <SendAssetRow /> <SendAmountRow /> {networkOrAccountNotSupports1559 ? <SendGasRow /> : null} @@ -104,6 +120,7 @@ export default class SendContent extends Component { > {t('newAccountDetectedDialogMessage')} </Dialog> + {showNicknamePopovers ? ( <NicknamePopovers onClose={() => this.setState({ showNicknamePopovers: false })} @@ -124,6 +141,36 @@ export default class SendContent extends Component { ); } + renderRecipientWarning() { + const { acknowledgeRecipientWarning } = this.props; + const { t } = this.context; + return ( + <div className="send__warning-container"> + <ActionableMessage + type="danger" + useIcon + iconFillColor="#d73a49" + primaryActionV2={{ + label: t('tooltipApproveButton'), + onClick: acknowledgeRecipientWarning, + }} + message={t('sendingToTokenContractWarning', [ + <a + key="contractWarningSupport" + target="_blank" + rel="noopener noreferrer" + className="send__warning-container__link" + href={CONTRACT_ADDRESS_LINK} + > + {t('learnMoreUpperCase')} + </a>, + ])} + roundedButtons + /> + </div> + ); + } + renderError(error) { const { t } = this.context; return ( diff --git a/ui/pages/send/send-content/send-content.component.test.js b/ui/pages/send/send-content/send-content.component.test.js index 7b06b50e3..ec8ed3ecf 100644 --- a/ui/pages/send/send-content/send-content.component.test.js +++ b/ui/pages/send/send-content/send-content.component.test.js @@ -16,6 +16,32 @@ describe('SendContent Component', () => { gasIsExcessive: false, networkAndAccountSupports1559: true, asset: { type: 'NATIVE' }, + recipient: { + mode: 'CONTACT_LIST', + userInput: '0x31A2764925BD47CCBd57b2F277702dB46e9C5F66', + address: '0x31A2764925BD47CCBd57b2F277702dB46e9C5F66', + nickname: 'John Doe', + error: null, + warning: null, + }, + tokenAddressList: { + '0x32e6c34cd57087abbd59b5a4aecc4cb495924356': { + name: 'BitBase', + symbol: 'BTBS', + decimals: 18, + address: '0x32E6C34Cd57087aBBD59B5A4AECC4cB495924356', + iconUrl: 'BTBS.svg', + occurrences: null, + }, + '0x3fa400483487a489ec9b1db29c4129063eec4654': { + name: 'Cryptokek.com', + symbol: 'KEK', + decimals: 18, + address: '0x3fa400483487A489EC9b1dB29C4129063EEC4654', + iconUrl: 'cryptokek.svg', + occurrences: null, + }, + }, }; beforeEach(() => { @@ -150,7 +176,7 @@ describe('SendContent Component', () => { true, ); expect( - PageContainerContentChild.childAt(1).find( + PageContainerContentChild.childAt(2).find( 'send-v2__asset-dropdown__single-asset', ), ).toHaveLength(0); diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index d3e508e9f..53fca7530 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -11,6 +11,9 @@ import { getSendTo, getSendAsset, getAssetError, + getRecipient, + acknowledgeRecipientWarning, + getRecipientWarningAcknowledgement, } from '../../../ducks/send'; import SendContent from './send-content.component'; @@ -18,6 +21,10 @@ import SendContent from './send-content.component'; function mapStateToProps(state) { const ownedAccounts = accountsWithSendEtherInfoSelector(state); const to = getSendTo(state); + const recipient = getRecipient(state); + const recipientWarningAcknowledged = getRecipientWarningAcknowledgement( + state, + ); return { isOwnedAccount: Boolean( ownedAccounts.find( @@ -34,7 +41,15 @@ function mapStateToProps(state) { getIsBalanceInsufficient: getIsBalanceInsufficient(state), asset: getSendAsset(state), assetError: getAssetError(state), + recipient, + recipientWarningAcknowledged, }; } -export default connect(mapStateToProps)(SendContent); +function mapDispatchToProps(dispatch) { + return { + acknowledgeRecipientWarning: () => dispatch(acknowledgeRecipientWarning()), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SendContent); diff --git a/ui/pages/send/send-header/send-header.component.js b/ui/pages/send/send-header/send-header.component.js index f4528a6c4..d71b6ef99 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -5,6 +5,7 @@ import PageContainerHeader from '../../../components/ui/page-container/page-cont import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { + getDraftTransactionExists, getSendAsset, getSendStage, resetSendState, @@ -19,15 +20,18 @@ export default function SendHeader() { const stage = useSelector(getSendStage); const asset = useSelector(getSendAsset); const t = useI18nContext(); - + const draftTransactionExists = useSelector(getDraftTransactionExists); const onClose = () => { dispatch(resetSendState()); history.push(mostRecentOverviewPage); }; - let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); + let title = asset?.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); - if (stage === SEND_STAGES.ADD_RECIPIENT || stage === SEND_STAGES.INACTIVE) { + if ( + draftTransactionExists === false || + [SEND_STAGES.ADD_RECIPIENT, SEND_STAGES.INACTIVE].includes(stage) + ) { title = t('sendTo'); } else if (stage === SEND_STAGES.EDIT) { title = t('edit'); 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 5ec8bdc69..a6eaa19d9 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -3,9 +3,13 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { fireEvent } from '@testing-library/react'; -import { initialState, SEND_STAGES } from '../../../ducks/send'; +import { SEND_STAGES } from '../../../ducks/send'; import { renderWithProvider } from '../../../../test/jest'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../../test/jest/mocks'; import SendHeader from './send-header.component'; const middleware = [thunk]; @@ -26,7 +30,7 @@ describe('SendHeader Component', () => { const { getByText, rerender } = renderWithProvider( <SendHeader />, configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -35,7 +39,10 @@ describe('SendHeader Component', () => { rerender( <SendHeader />, configureMockStore(middleware)({ - send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.ADD_RECIPIENT, + }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -48,9 +55,12 @@ describe('SendHeader Component', () => { <SendHeader />, configureMockStore(middleware)({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.DRAFT, - asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE }, + asset: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT.asset, + type: ASSET_TYPES.NATIVE, + }, }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, @@ -64,9 +74,12 @@ describe('SendHeader Component', () => { <SendHeader />, configureMockStore(middleware)({ send: { - ...initialState, + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + }, + }), stage: SEND_STAGES.DRAFT, - asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN }, }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, @@ -80,7 +93,7 @@ describe('SendHeader Component', () => { <SendHeader />, configureMockStore(middleware)({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, }, gas: { basicEstimateStatus: 'LOADING' }, @@ -96,7 +109,7 @@ describe('SendHeader Component', () => { const { getByText } = renderWithProvider( <SendHeader />, configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -108,7 +121,10 @@ describe('SendHeader Component', () => { const { getByText } = renderWithProvider( <SendHeader />, configureMockStore(middleware)({ - send: { ...initialState, stage: SEND_STAGES.EDIT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.EDIT, + }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -118,7 +134,7 @@ describe('SendHeader Component', () => { it('resets send state when clicked', () => { const store = configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js index 79980b25c..ad10615a7 100644 --- a/ui/pages/send/send.js +++ b/ui/pages/send/send.js @@ -1,24 +1,26 @@ -import React, { useEffect, useCallback, useContext } from 'react'; +import React, { useEffect, useCallback, useContext, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { addHistoryEntry, + getDraftTransactionExists, getIsUsingMyAccountForRecipientSearch, getRecipient, getRecipientUserInput, getSendStage, - initializeSendState, resetRecipientInput, resetSendState, SEND_STAGES, + startNewDraftTransaction, updateRecipient, updateRecipientUserInput, } from '../../ducks/send'; -import { getCurrentChainId, isCustomPriceExcessive } from '../../selectors'; +import { isCustomPriceExcessive } from '../../selectors'; import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask'; import { showQrScanner } from '../../store/actions'; import { MetaMetricsContext } from '../../contexts/metametrics'; import { EVENT } from '../../../shared/constants/metametrics'; +import { ASSET_TYPES } from '../../../shared/constants/transaction'; import SendHeader from './send-header'; import AddRecipient from './send-content/add-recipient'; import SendContent from './send-content'; @@ -30,7 +32,7 @@ const sendSliceIsCustomPriceExcessive = (state) => export default function SendTransactionScreen() { const history = useHistory(); - const chainId = useSelector(getCurrentChainId); + const startedNewDraftTransaction = useRef(false); const stage = useSelector(getSendStage); const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive); const isUsingMyAccountsForRecipientSearch = useSelector( @@ -39,6 +41,7 @@ export default function SendTransactionScreen() { const recipient = useSelector(getRecipient); const showHexData = useSelector(getSendHexDataFeatureFlagState); const userInput = useSelector(getRecipientUserInput); + const draftTransactionExists = useSelector(getDraftTransactionExists); const location = useLocation(); const trackEvent = useContext(MetaMetricsContext); @@ -48,12 +51,26 @@ export default function SendTransactionScreen() { dispatch(resetSendState()); }, [dispatch]); + /** + * It is possible to route to this page directly, either by typing in the url + * or by clicking the browser back button after progressing to the confirm + * screen. In the case where a draft transaction does not yet exist, this + * hook is responsible for creating it. We will assume that this is a native + * asset send. + */ useEffect(() => { - if (chainId !== undefined) { - dispatch(initializeSendState()); - window.addEventListener('beforeunload', cleanup); + if ( + draftTransactionExists === false && + startedNewDraftTransaction.current === false + ) { + startedNewDraftTransaction.current = true; + dispatch(startNewDraftTransaction({ type: ASSET_TYPES.NATIVE })); } - }, [chainId, dispatch, cleanup]); + }, [draftTransactionExists, dispatch]); + + useEffect(() => { + window.addEventListener('beforeunload', cleanup); + }, [cleanup]); useEffect(() => { if (location.search === '?scan=true') { @@ -75,7 +92,10 @@ export default function SendTransactionScreen() { let content; - if ([SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage)) { + if ( + draftTransactionExists && + [SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage) + ) { content = ( <> <SendContent @@ -96,10 +116,11 @@ export default function SendTransactionScreen() { userInput={userInput} className="send__to-row" onChange={(address) => dispatch(updateRecipientUserInput(address))} - onValidAddressTyped={(address) => { + onValidAddressTyped={async (address) => { dispatch( addHistoryEntry(`sendFlow - Valid address typed ${address}`), ); + await dispatch(updateRecipientUserInput(address)); dispatch(updateRecipient({ address, nickname: '' })); }} internalSearch={isUsingMyAccountsForRecipientSearch} @@ -111,7 +132,6 @@ export default function SendTransactionScreen() { `sendFlow - User pasted ${text} into address field`, ), ); - return dispatch(updateRecipient({ address: text, nickname: '' })); }} onReset={() => dispatch(resetRecipientInput())} scanQrCode={() => { diff --git a/ui/pages/send/send.scss b/ui/pages/send/send.scss index 8d45dfcb7..2735ba89a 100644 --- a/ui/pages/send/send.scss +++ b/ui/pages/send/send.scss @@ -35,6 +35,15 @@ margin: 1rem; } + &__warning-container { + padding-left: 16px; + padding-right: 16px; + + &__link { + color: var(--primary-1); + } + } + &__to-row { margin: 0; padding: 0.5rem; diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js index e2d16be9f..aa4b57f5f 100644 --- a/ui/pages/send/send.test.js +++ b/ui/pages/send/send.test.js @@ -3,15 +3,30 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { useLocation } from 'react-router-dom'; -import { initialState, SEND_STAGES } from '../../ducks/send'; +import { SEND_STAGES, startNewDraftTransaction } from '../../ducks/send'; import { ensInitialState } from '../../ducks/ens'; import { renderWithProvider } from '../../../test/jest'; import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; +import { INITIAL_SEND_STATE_FOR_EXISTING_DRAFT } from '../../../test/jest/mocks'; import Send from './send'; const middleware = [thunk]; +jest.mock('../../ducks/send/send', () => { + const original = jest.requireActual('../../ducks/send/send'); + return { + ...original, + // We don't really need to start a draft transaction, and the mock store + // does not update as a result of action calls so instead we just ensure + // that the action WOULD be called. + startNewDraftTransaction: jest.fn(() => ({ + type: 'TEST_START_NEW_DRAFT', + payload: null, + })), + }; +}); + jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); return { @@ -34,7 +49,7 @@ jest.mock( ); const baseStore = { - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, ENS: ensInitialState, gas: { customData: { limit: null, price: null }, @@ -79,6 +94,25 @@ const baseStore = { '0x0': { balance: '0x0', address: '0x0' }, }, identities: { '0x0': { address: '0x0' } }, + tokenAddress: '0x32e6c34cd57087abbd59b5a4aecc4cb495924356', + tokenList: { + '0x32e6c34cd57087abbd59b5a4aecc4cb495924356': { + name: 'BitBase', + symbol: 'BTBS', + decimals: 18, + address: '0x32E6C34Cd57087aBBD59B5A4AECC4cB495924356', + iconUrl: 'BTBS.svg', + occurrences: null, + }, + '0x3fa400483487a489ec9b1db29c4129063eec4654': { + name: 'Cryptokek.com', + symbol: 'KEK', + decimals: 18, + address: '0x3fa400483487A489EC9b1dB29C4129063EEC4654', + iconUrl: 'cryptokek.svg', + occurrences: null, + }, + }, }, appState: { sendInputCurrencySwitched: false, @@ -87,7 +121,7 @@ const baseStore = { describe('Send Page', () => { describe('Send Flow Initialization', () => { - it('should initialize the send, ENS, and gas slices on render', () => { + it('should initialize the ENS slice on render', () => { const store = configureMockStore(middleware)(baseStore); renderWithProvider(<Send />, store); const actions = store.getActions(); @@ -96,9 +130,6 @@ describe('Send Page', () => { expect.objectContaining({ type: 'ENS/enableEnsLookup', }), - expect.objectContaining({ - type: 'send/initializeSendState/pending', - }), ]), ); }); @@ -113,9 +144,6 @@ describe('Send Page', () => { expect.objectContaining({ type: 'ENS/enableEnsLookup', }), - expect.objectContaining({ - type: 'send/initializeSendState/pending', - }), expect.objectContaining({ type: 'UI_MODAL_OPEN', payload: { name: 'QR_SCANNER' }, @@ -146,6 +174,25 @@ describe('Send Page', () => { const { queryByText } = renderWithProvider(<Send />, store); expect(queryByText('Next')).toBeNull(); }); + + it('should render correctly even when a draftTransaction does not exist', () => { + const modifiedStore = { + ...baseStore, + send: { + ...baseStore.send, + currentTransactionUUID: null, + }, + }; + const store = configureMockStore(middleware)(modifiedStore); + const { getByPlaceholderText } = renderWithProvider(<Send />, store); + // Ensure that the send flow renders on the add recipient screen when + // there is no draft transaction. + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + // Ensure we start a new draft transaction when its missing. + expect(startNewDraftTransaction).toHaveBeenCalledTimes(1); + }); }); describe('Send and Edit Flow (draft)', () => { diff --git a/ui/pages/settings/experimental-tab/experimental-tab.component.js b/ui/pages/settings/experimental-tab/experimental-tab.component.js index dc30b54b2..741445a31 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.component.js +++ b/ui/pages/settings/experimental-tab/experimental-tab.component.js @@ -26,6 +26,8 @@ export default class ExperimentalTab extends PureComponent { setEIP1559V2Enabled: PropTypes.func, theme: PropTypes.string, setTheme: PropTypes.func, + customNetworkListEnabled: PropTypes.bool, + setCustomNetworkListEnabled: PropTypes.func, }; settingsRefs = Array( @@ -284,6 +286,45 @@ export default class ExperimentalTab extends PureComponent { ); } + renderCustomNetworkListToggle() { + const { t } = this.context; + const { + customNetworkListEnabled, + setCustomNetworkListEnabled, + } = this.props; + + return ( + <div ref={this.settingsRefs[5]} className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{t('showCustomNetworkList')}</span> + <div className="settings-page__content-description"> + {t('showCustomNetworkListDescription')} + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={customNetworkListEnabled} + onToggle={(value) => { + this.context.trackEvent({ + category: EVENT.CATEGORIES.SETTINGS, + event: 'Enabled/Disable CustomNetworkList', + properties: { + action: 'Enabled/Disable CustomNetworkList', + legacy_event: true, + }, + }); + setCustomNetworkListEnabled(!value); + }} + offLabel={t('off')} + onLabel={t('on')} + /> + </div> + </div> + </div> + ); + } + render() { return ( <div className="settings-page__body"> @@ -295,6 +336,8 @@ export default class ExperimentalTab extends PureComponent { {this.renderCollectibleDetectionToggle()} {this.renderEIP1559V2EnabledToggle()} {this.renderTheme()} + {process.env.ADD_POPULAR_NETWORKS && + this.renderCustomNetworkListToggle()} </div> ); } diff --git a/ui/pages/settings/experimental-tab/experimental-tab.container.js b/ui/pages/settings/experimental-tab/experimental-tab.container.js index 1fb4124ee..244de6198 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.container.js +++ b/ui/pages/settings/experimental-tab/experimental-tab.container.js @@ -7,6 +7,7 @@ import { setOpenSeaEnabled, setEIP1559V2Enabled, setTheme, + setCustomNetworkListEnabled, } from '../../../store/actions'; import { getUseTokenDetection, @@ -14,6 +15,7 @@ import { getOpenSeaEnabled, getEIP1559V2Enabled, getTheme, + getIsCustomNetworkListEnabled, } from '../../../selectors'; import ExperimentalTab from './experimental-tab.component'; @@ -26,6 +28,7 @@ const mapStateToProps = (state) => { openSeaEnabled: getOpenSeaEnabled(state), eip1559V2Enabled: getEIP1559V2Enabled(state), theme: getTheme(state), + customNetworkListEnabled: getIsCustomNetworkListEnabled(state), }; }; @@ -40,6 +43,8 @@ const mapDispatchToProps = (dispatch) => { setOpenSeaEnabled: (val) => dispatch(setOpenSeaEnabled(val)), setEIP1559V2Enabled: (val) => dispatch(setEIP1559V2Enabled(val)), setTheme: (val) => dispatch(setTheme(val)), + setCustomNetworkListEnabled: (val) => + dispatch(setCustomNetworkListEnabled(val)), }; }; diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.js b/ui/pages/settings/networks-tab/networks-form/networks-form.js index ef846c638..22c28a170 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.js @@ -522,7 +522,6 @@ const NetworksForm = ({ onConfirm: () => { resetForm(); dispatch(setSelectedSettingsRpcUrl('')); - history.push(NETWORKS_ROUTE); }, }), ); diff --git a/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.js b/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.js index 1420e3082..1fad5fdc4 100644 --- a/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.js +++ b/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.js @@ -1,18 +1,31 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useHistory } from 'react-router-dom'; +import { useSelector } from 'react-redux'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { ADD_NETWORK_ROUTE } from '../../../../helpers/constants/routes'; +import { + ADD_NETWORK_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, +} from '../../../../helpers/constants/routes'; import Button from '../../../../components/ui/button'; +import { getIsCustomNetworkListEnabled } from '../../../../selectors'; const NetworksFormSubheader = ({ addNewNetwork }) => { const t = useI18nContext(); const history = useHistory(); + const addPopularNetworkFeatureToggledOn = useSelector( + getIsCustomNetworkListEnabled, + ); + return addNewNetwork ? ( <div className="networks-tab__subheader"> <span className="networks-tab__sub-header-text">{t('networks')}</span> + <span className="networks-tab__sub-header-text">{' > '}</span> + <div className="networks-tab__sub-header-text">{t('addANetwork')}</div> <span>{' > '}</span> - <div className="networks-tab__subheader--break">{t('addANetwork')}</div> + <div className="networks-tab__subheader--break"> + {t('addANetworkManually')} + </div> </div> ) : ( <div className="settings-page__sub-header"> @@ -22,7 +35,9 @@ const NetworksFormSubheader = ({ addNewNetwork }) => { type="primary" onClick={(event) => { event.preventDefault(); - history.push(ADD_NETWORK_ROUTE); + addPopularNetworkFeatureToggledOn + ? history.push(ADD_POPULAR_CUSTOM_NETWORK) + : history.push(ADD_NETWORK_ROUTE); }} > {t('addANetwork')} diff --git a/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.test.js b/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.test.js index 099261049..44b5b1768 100644 --- a/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.test.js +++ b/ui/pages/settings/networks-tab/networks-tab-subheader/networks-tab-subheader.test.js @@ -1,5 +1,6 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; +import { waitFor } from '@testing-library/react'; import { renderWithProvider } from '../../../../../test/jest/rendering'; import NetworksTabSubheader from '.'; @@ -36,11 +37,11 @@ describe('NetworksTabSubheader Component', () => { expect(getByRole('button', { text: 'Add a network' })).toBeDefined(); }); it('should render add network form subheader correctly', () => { - const { queryByText } = renderComponent({ + const { queryByText, getAllByText } = renderComponent({ addNewNetwork: true, }); expect(queryByText('Networks')).toBeInTheDocument(); - expect(queryByText('>')).toBeInTheDocument(); + waitFor(() => expect(getAllByText('>')).toBeInTheDocument()); expect(queryByText('Add a network')).toBeInTheDocument(); }); }); diff --git a/ui/pages/settings/networks-tab/networks-tab.js b/ui/pages/settings/networks-tab/networks-tab.js index c73787566..35814d419 100644 --- a/ui/pages/settings/networks-tab/networks-tab.js +++ b/ui/pages/settings/networks-tab/networks-tab.js @@ -1,11 +1,12 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { ADD_NETWORK_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, NETWORKS_FORM_ROUTE, } from '../../../helpers/constants/routes'; import { setSelectedSettingsRpcUrl } from '../../../store/actions'; @@ -14,6 +15,7 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; import { getFrequentRpcListDetail, + getIsCustomNetworkListEnabled, getNetworksTabSelectedRpcUrl, getProvider, } from '../../../selectors'; @@ -36,6 +38,7 @@ const NetworksTab = ({ addNewNetwork }) => { const t = useI18nContext(); const dispatch = useDispatch(); const { pathname } = useLocation(); + const history = useHistory(); const environmentType = getEnvironmentType(); const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; @@ -45,6 +48,9 @@ const NetworksTab = ({ addNewNetwork }) => { const frequentRpcListDetail = useSelector(getFrequentRpcListDetail); const provider = useSelector(getProvider); const networksTabSelectedRpcUrl = useSelector(getNetworksTabSelectedRpcUrl); + const addPopularNetworkFeatureToggledOn = useSelector( + getIsCustomNetworkListEnabled, + ); const frequentRpcNetworkListDetails = frequentRpcListDetail.map((rpc) => { return { @@ -118,9 +124,16 @@ const NetworksTab = ({ addNewNetwork }) => { <div className="networks-tab__networks-list-popup-footer"> <Button type="primary" - onClick={(event) => { - event.preventDefault(); - global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE); + onClick={() => { + if (addPopularNetworkFeatureToggledOn) { + history.push(ADD_POPULAR_CUSTOM_NETWORK); + } else { + isFullScreen + ? history.push(ADD_NETWORK_ROUTE) + : global.platform.openExtensionInBrowser( + ADD_NETWORK_ROUTE, + ); + } }} > {t('addNetwork')} diff --git a/ui/pages/settings/settings.component.js b/ui/pages/settings/settings.component.js index c3f76d53f..64995b15c 100644 --- a/ui/pages/settings/settings.component.js +++ b/ui/pages/settings/settings.component.js @@ -23,9 +23,11 @@ import { CONTACT_VIEW_ROUTE, EXPERIMENTAL_ROUTE, ADD_NETWORK_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, } from '../../helpers/constants/routes'; import { getSettingsRoutes } from '../../helpers/utils/settings-search'; +import AddNetwork from '../../components/app/add-network/add-network'; import SettingsTab from './settings-tab'; import AlertsTab from './alerts-tab'; import NetworksTab from './networks-tab'; @@ -124,7 +126,6 @@ class SettingsPage extends PureComponent { )} {this.renderTitle()} - <div className="settings-page__header__title-container__close-button" onClick={() => { @@ -343,9 +344,15 @@ class SettingsPage extends PureComponent { render={() => <NetworksTab addNewNetwork />} /> <Route + exact path={NETWORKS_ROUTE} render={() => <NetworksTab addNewNetwork={false} />} /> + <Route + exact + path={ADD_POPULAR_CUSTOM_NETWORK} + render={() => <AddNetwork />} + /> <Route exact path={SECURITY_ROUTE} component={SecurityTab} /> <Route exact path={EXPERIMENTAL_ROUTE} component={ExperimentalTab} /> <Route exact path={CONTACT_LIST_ROUTE} component={ContactListTab} /> diff --git a/ui/pages/settings/settings.container.js b/ui/pages/settings/settings.container.js index 78a470b94..735e66507 100644 --- a/ui/pages/settings/settings.container.js +++ b/ui/pages/settings/settings.container.js @@ -27,6 +27,7 @@ import { ADD_NETWORK_ROUTE, SNAPS_LIST_ROUTE, SNAPS_VIEW_ROUTE, + ADD_POPULAR_CUSTOM_NETWORK, } from '../../helpers/constants/routes'; import Settings from './settings.component'; @@ -46,6 +47,7 @@ const ROUTES_TO_I18N_KEYS = { [ADD_NETWORK_ROUTE]: 'networks', [SECURITY_ROUTE]: 'securityAndPrivacy', [EXPERIMENTAL_ROUTE]: 'experimental', + [ADD_POPULAR_CUSTOM_NETWORK]: 'addNetwork', }; const mapStateToProps = (state, ownProps) => { @@ -64,6 +66,9 @@ const mapStateToProps = (state, ownProps) => { Boolean(pathname.match(NETWORKS_FORM_ROUTE)) || Boolean(pathname.match(ADD_NETWORK_ROUTE)); const addNewNetwork = Boolean(pathname.match(ADD_NETWORK_ROUTE)); + const isAddPopularCustomNetwork = Boolean( + pathname.match(ADD_POPULAR_CUSTOM_NETWORK), + ); const isPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP; const pathnameI18nKey = ROUTES_TO_I18N_KEYS[pathname]; @@ -77,6 +82,8 @@ const mapStateToProps = (state, ownProps) => { backRoute = NETWORKS_ROUTE; } else if (isSnapViewPage) { backRoute = SNAPS_LIST_ROUTE; + } else if (isAddPopularCustomNetwork) { + backRoute = NETWORKS_ROUTE; } let initialBreadCrumbRoute; diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index ea72bf9ae..3edc9b20b 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -8,7 +8,7 @@ import { decEthToConvertedCurrency as ethTotalToConvertedCurrency } from '../hel import { formatETHFee } from '../helpers/utils/formatters'; import { calcGasTotal } from '../pages/send/send.utils'; -import { getGasPrice } from '../ducks/send'; +import { getGasLimit, getGasPrice } from '../ducks/send'; import { GAS_ESTIMATE_TYPES as GAS_FEE_CONTROLLER_ESTIMATE_TYPES, GAS_LIMITS, @@ -321,8 +321,9 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { return []; } const showFiat = getShouldShowFiat(state); + const gasLimit = - state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; + getGasLimit(state) ?? getCustomGasLimit(state) ?? GAS_LIMITS.SIMPLE; const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); const gasFeeEstimates = getGasFeeEstimates(state); diff --git a/ui/selectors/custom-gas.test.js b/ui/selectors/custom-gas.test.js index d41ec27c3..047335282 100644 --- a/ui/selectors/custom-gas.test.js +++ b/ui/selectors/custom-gas.test.js @@ -1,4 +1,5 @@ import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../shared/constants/gas'; +import { getInitialSendStateWithExistingTxState } from '../../test/jest/mocks'; import { getCustomGasLimit, getCustomGasPrice, @@ -11,7 +12,9 @@ import { describe('custom-gas selectors', () => { describe('getCustomGasPrice()', () => { it('should return gas.customData.price', () => { - const mockState = { gas: { customData: { price: 'mockPrice' } } }; + const mockState = { + gas: { customData: { price: 'mockPrice' } }, + }; expect(getCustomGasPrice(mockState)).toStrictEqual('mockPrice'); }); }); @@ -200,11 +203,11 @@ describe('custom-gas selectors', () => { EIPS: {}, }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: '0x28bed0160', }, - }, + }), gas: { customData: { price: null }, }, @@ -222,11 +225,11 @@ describe('custom-gas selectors', () => { EIPS: {}, }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: '0x30e4f9b400', }, - }, + }), gas: { customData: { price: null }, }, @@ -330,11 +333,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -379,11 +382,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -428,11 +431,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -477,11 +480,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, ]; @@ -542,11 +545,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -591,11 +594,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -640,11 +643,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -689,11 +692,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -738,11 +741,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, ]; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 76ed8a004..5256c3756 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -1062,3 +1062,13 @@ export function getDetectedTokensInCurrentNetwork(state) { export function getNewTokensImported(state) { return state.appState.newTokensImported; } + +/** + * To get the `customNetworkListEnabled` value which determines whether we use the custom network list + * + * @param {*} state + * @returns Boolean + */ +export function getIsCustomNetworkListEnabled(state) { + return state.metamask.customNetworkListEnabled; +} diff --git a/ui/store/actionConstants.js b/ui/store/actionConstants.js index 2a5528e72..c9e673afa 100644 --- a/ui/store/actionConstants.js +++ b/ui/store/actionConstants.js @@ -92,6 +92,7 @@ export const SET_SELECTED_SETTINGS_RPC_URL = 'SET_SELECTED_SETTINGS_RPC_URL'; export const SET_NEW_NETWORK_ADDED = 'SET_NEW_NETWORK_ADDED'; export const SET_NEW_COLLECTIBLE_ADDED_MESSAGE = 'SET_NEW_COLLECTIBLE_ADDED_MESSAGE'; +export const SET_NEW_CUSTOM_NETWORK_ADDED = 'SET_NEW_CUSTOM_NETWORK_ADDED'; export const LOADING_METHOD_DATA_STARTED = 'LOADING_METHOD_DATA_STARTED'; export const LOADING_METHOD_DATA_FINISHED = 'LOADING_METHOD_DATA_FINISHED'; diff --git a/ui/store/actions.js b/ui/store/actions.js index b8f738882..c177c1e0c 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -27,7 +27,11 @@ import { getNotifications, ///: END:ONLY_INCLUDE_IN } from '../selectors'; -import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; +import { + computeEstimatedGasLimit, + initializeSendState, + resetSendState, +} from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; @@ -42,6 +46,7 @@ import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; ///: BEGIN:ONLY_INCLUDE_IN(flask) import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notifications'; ///: END:ONLY_INCLUDE_IN +import { setNewCustomNetworkAdded } from '../ducks/app/app'; import * as actionConstants from './actionConstants'; let background = null; @@ -740,7 +745,7 @@ export function updateEditableParams(txId, editableParams) { log.error(error.message); throw error; } - + await forceUpdateMetamaskState(dispatch); return updatedTransaction; }; } @@ -1442,6 +1447,11 @@ export function updateMetamaskState(newState) { type: actionConstants.CHAIN_CHANGED, payload: newProvider.chainId, }); + // We dispatch this action to ensure that the send state stays up to date + // after the chain changes. This async thunk will fail gracefully in the + // event that we are not yet on the send flow with a draftTransaction in + // progress. + dispatch(initializeSendState({ chainHasChanged: true })); } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE, @@ -3728,6 +3738,18 @@ export function setEnableEIP1559V2NoticeDismissed() { return promisifiedBackground.setEnableEIP1559V2NoticeDismissed(true); } +export function setCustomNetworkListEnabled(customNetworkListEnabled) { + return async () => { + try { + await promisifiedBackground.setCustomNetworkListEnabled( + customNetworkListEnabled, + ); + } catch (error) { + log.error(error); + } + }; +} + // QR Hardware Wallets export async function submitQRHardwareCryptoHDKey(cbor) { await promisifiedBackground.submitQRHardwareCryptoHDKey(cbor); @@ -3754,3 +3776,29 @@ export function cancelQRHardwareSignRequest() { await promisifiedBackground.cancelQRHardwareSignRequest(); }; } + +export function addCustomNetwork(customRpc) { + return async (dispatch) => { + try { + dispatch(setNewCustomNetworkAdded(customRpc)); + await promisifiedBackground.addCustomNetwork(customRpc); + } catch (error) { + log.error(error); + dispatch(displayWarning('Had a problem changing networks!')); + } + }; +} + +export function requestUserApproval(customRpc, originIsMetaMask) { + return async (dispatch) => { + try { + await promisifiedBackground.requestUserApproval( + customRpc, + originIsMetaMask, + ); + } catch (error) { + log.error(error); + dispatch(displayWarning('Had a problem changing networks!')); + } + }; +} diff --git a/yarn.lock b/yarn.lock index 64be75773..59206be9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2895,9 +2895,9 @@ web3-provider-engine "^16.0.3" "@metamask/design-tokens@^1.6.0", "@metamask/design-tokens@^1.6.5": - version "1.6.5" - resolved "https://registry.yarnpkg.com/@metamask/design-tokens/-/design-tokens-1.6.5.tgz#e585b67f73ce301e0218d98ba89e079f7e81c412" - integrity sha512-5eCrUHXrIivXX1xx6kwNtM9s/ejhrPYSATSniFc7YKS9z+TkCK4/n52owOBnDIbrL8W3XxQIiaaqQAM+NQad4w== + version "1.7.0" + resolved "https://registry.yarnpkg.com/@metamask/design-tokens/-/design-tokens-1.7.0.tgz#fab069c0101da9e25d35ae051df2ff6bb5ff7a38" + integrity sha512-ejakgcsnTlLQmMrKb8XixXgExsYuMjlv71lkqJXeT0wa2oe4skVhB2dZul7Y9W4vYvQzTkwsW2NLfaj273eeEw== "@metamask/eslint-config-jest@^9.0.0": version "9.0.0" @@ -8017,10 +8017,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^102.0.0: - version "102.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-102.0.0.tgz#02844c39ee33d1e88ac8c48fbe28cb8423e970a4" - integrity sha512-xer/0g1Oarkjc2e+4nyoLgZT4kJHYhcj3PcxD1nEoGJQYEllTjprN1uDpSb4BkgMGo0ydfIS1VDkszrr/J9OOg== +chromedriver@^103.0.0: + version "103.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-103.0.0.tgz#2ef086d62076e3ff6df6cfb84895d11d2c18d9cf" + integrity sha512-7BHf6HWt0PeOHCzWO8qlnD13sARzr5AKTtG8Csn+czsuAsajwPxdLNtry5GPh8HYFyl+i0M+yg3bT43AGfgU9w== dependencies: "@testim/chrome-version" "^1.1.2" axios "^0.27.2"