diff --git a/.circleci/config.yml b/.circleci/config.yml index ec7b51d1c..c6ca6e21e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ executors: NODE_OPTIONS: --max_old_space_size=2048 shellcheck: docker: - - image: koalaman/shellcheck-alpine@sha256:35882cba254810c7de458528011e935ba2c4f3ebcb224275dfa7ebfa930ef294 + - image: koalaman/shellcheck-alpine@sha256:dfaf08fab58c158549d3be64fb101c626abc5f16f341b569092577ae207db199 workflows: test_and_release: @@ -25,10 +25,15 @@ workflows: only: - /^Version-v(\d+)[.](\d+)[.](\d+)/ - prep-deps - - test-deps-audit + - test-deps-audit: + requires: + - prep-deps - test-deps-depcheck: requires: - prep-deps + - test-yarn-dedupe: + requires: + - prep-deps - validate-lavamoat-config: filters: branches: @@ -51,9 +56,12 @@ workflows: - prep-build-test-metrics: requires: - prep-deps - - prep-build-storybook: + - test-storybook: requires: - prep-deps + - prep-build-storybook: + requires: + - test-storybook - test-lint: requires: - prep-deps @@ -330,6 +338,26 @@ jobs: root: . paths: - storybook-build + + test-storybook: + executor: node-browsers + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Test Storybook + command: yarn storybook:test + + test-yarn-dedupe: + executor: node-browsers + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Detect yarn lock deduplications + command: yarn yarn-deduplicate && git diff --exit-code yarn.lock test-lint: executor: node-browsers diff --git a/.circleci/scripts/chrome-install.sh b/.circleci/scripts/chrome-install.sh index ccf458111..3ba70ddbe 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 -CHROME_VERSION='95.0.4638.69-1' +CHROME_VERSION='96.0.4664.45-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='f07d16ec0a41120c40064d030e9e5240ed740b9b24c50eaede7b9bfd9a9678821c0252b40bfcd57e933a708b08d761482c3be5b3006eee605c41f5dc9e21f456' +CHROME_BINARY_SHA512SUM='98433b003d43627e221faad212cba3df42d7f3d6e31894b1e14a9058069cbcd2bd3c83b3c59ecc1733dab11e36b181fa1d89e33b841a21fd53a8e82bbddc39aa' wget -O "${CHROME_BINARY}" -t 5 "${CHROME_BINARY_URL}" diff --git a/.circleci/scripts/yarn-audit.sh b/.circleci/scripts/yarn-audit.sh index 48bed3ce6..0f2de7123 100755 --- a/.circleci/scripts/yarn-audit.sh +++ b/.circleci/scripts/yarn-audit.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash +set -e set -u +set -x set -o pipefail # use `improved-yarn-audit` since that allows for exclude diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3a21d36..8ed943b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.8.0] +### Added +- [#12881](https://github.com/MetaMask/metamask-extension/pull/12881): Feature: Transaction Insights + - Users can now see much greater details on contract interaction transactions + - These transaction details are integrated with new popups for viewing and editing address book information + - These details are also viewable within the transaction details popup +- [#12627](https://github.com/MetaMask/metamask-extension/pull/12627): Add support for eip-1559 on Trezor Model T and Trezor Model One +- [#12065](https://github.com/MetaMask/metamask-extension/pull/12065): Support QR code based hardware wallet signing + - Includes specific support for the Keystone hardware wallet + +### Changed +- [#12842](https://github.com/MetaMask/metamask-extension/pull/12842): Improve performance of the swaps feature +- [#12776](https://github.com/MetaMask/metamask-extension/pull/12776): Hide the token detection announcement when the token detection is ON +- [#12828](https://github.com/MetaMask/metamask-extension/pull/12828): Clear the clipboard after the seed phrase is pasted into the import flow +- [#12576](https://github.com/MetaMask/metamask-extension/pull/12576): Show warning message when gas estimation estimates fail + +### Fixed +- [#12802](https://github.com/MetaMask/metamask-extension/pull/12802): Fix bug causing occasional swaps failures for token pairs with highly precise exchange rates +- [#12679](https://github.com/MetaMask/metamask-extension/pull/12679): Ensure eth_sign callback fires even when data submitted is invalid + ## [10.7.1] ### Fixed - [#13005](https://github.com/MetaMask/metamask-extension/pull/13005): Fix connection to RPC urls with query strings. ## [10.7.0] ### Changed -- [#12643](https://github.com/MetaMask/metamask-extension/pull/12643): Reject popup confirmations on close - [#12566](https://github.com/MetaMask/metamask-extension/pull/12566): Enable LavaMoat for the webapp background - [#12399](https://github.com/MetaMask/metamask-extension/pull/12399): Update the "Account Details" view - Change "Close" icon color from gray -> black @@ -2633,7 +2652,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.7.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.8.0...HEAD +[10.8.0]: https://github.com/MetaMask/metamask-extension/compare/v10.7.1...v10.8.0 [10.7.1]: https://github.com/MetaMask/metamask-extension/compare/v10.7.0...v10.7.1 [10.7.0]: https://github.com/MetaMask/metamask-extension/compare/v10.6.4...v10.7.0 [10.6.4]: https://github.com/MetaMask/metamask-extension/compare/v10.6.3...v10.6.4 diff --git a/README.md b/README.md index 4045873cf..76ce67bda 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # MetaMask Browser Extension -Hey! We are hiring JavaScript Engineers! [Apply here](https://boards.greenhouse.io/consensys/jobs/2572388)! ---- - You can find the latest version of MetaMask on [our official website](https://metamask.io/). For help using MetaMask, visit our [User Support Site](https://metamask.zendesk.com/hc/en-us). For [general questions](https://community.metamask.io/c/learn/26), [feature requests](https://community.metamask.io/c/feature-requests-ideas/13), or [developer questions](https://community.metamask.io/c/developer-questions/11), visit our [Community Forum](https://community.metamask.io/). diff --git a/app/_locales/am/messages.json b/app/_locales/am/messages.json index aa341612d..1d962c06e 100644 --- a/app/_locales/am/messages.json +++ b/app/_locales/am/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "ተቀድቷል" }, - "copiedTransactionId": { - "message": "የተቀዳ የግብይት መለያ ቁጥር" - }, "copyAddress": { "message": "አድራሻን ወደ ቅንጥብ ሰሌዳ ቅዳ" }, diff --git a/app/_locales/ar/messages.json b/app/_locales/ar/messages.json index aa6a76276..1966aab0b 100644 --- a/app/_locales/ar/messages.json +++ b/app/_locales/ar/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "تم النسخ." }, - "copiedTransactionId": { - "message": "تم نسخ معرف المعاملة" - }, "copyAddress": { "message": "نسخ العنوان إلى الحافظة" }, diff --git a/app/_locales/bg/messages.json b/app/_locales/bg/messages.json index f9cabde5d..ab7c09e24 100644 --- a/app/_locales/bg/messages.json +++ b/app/_locales/bg/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Копирано!" }, - "copiedTransactionId": { - "message": "Копиран идентификационен номер на транзакцията" - }, "copyAddress": { "message": "Копирайте адреса в клипборда" }, diff --git a/app/_locales/bn/messages.json b/app/_locales/bn/messages.json index 5d36aabe9..ff4fe49a6 100644 --- a/app/_locales/bn/messages.json +++ b/app/_locales/bn/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "কপি করা হয়েছে!" }, - "copiedTransactionId": { - "message": "কপি করা লেনদেনের আইডি" - }, "copyAddress": { "message": "ক্লিপবোর্ডে ঠিকানা কপি করুন" }, diff --git a/app/_locales/ca/messages.json b/app/_locales/ca/messages.json index b29188344..beea7d58a 100644 --- a/app/_locales/ca/messages.json +++ b/app/_locales/ca/messages.json @@ -215,9 +215,6 @@ "copiedExclamation": { "message": "S'ha copiat!" }, - "copiedTransactionId": { - "message": "ID de transacció copiada" - }, "copyAddress": { "message": "Copiar adreça al porta-retalls" }, diff --git a/app/_locales/da/messages.json b/app/_locales/da/messages.json index 8d288c33f..72fd08ac5 100644 --- a/app/_locales/da/messages.json +++ b/app/_locales/da/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kopieret!" }, - "copiedTransactionId": { - "message": "Kopieret transaktions-id" - }, "copyAddress": { "message": "Kopier adresse til udklipsholder" }, diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index bca6b887a..88a3b99c7 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -209,9 +209,6 @@ "copiedExclamation": { "message": "Kopiert!" }, - "copiedTransactionId": { - "message": "Transaktions-ID kopiert" - }, "copyAddress": { "message": "Adresse in die Zwischenablage kopieren" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 1b880074b..c74b44875 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -215,9 +215,6 @@ "copiedExclamation": { "message": "Έγινε αντιγραφή!" }, - "copiedTransactionId": { - "message": "Αντιγράφηκε το Αναγνωριστικό Συναλλαγής" - }, "copyAddress": { "message": "Αντιγράψτε τη διεύθυνση στο πρόχειρο" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b3cbda168..5fd049c23 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1,4 +1,49 @@ { + "QRHardwareInvalidTransactionTitle": { + "message": "Error" + }, + "QRHardwareMismatchedSignId": { + "message": "Incongruent transaction data. Please check the transaction details." + }, + "QRHardwarePubkeyAccountOutOfRange": { + "message": "No more accounts. If you would like to access another account unlisted below, please reconnect your hardware wallet and select it." + }, + "QRHardwareScanInstructions": { + "message": "Place the QR code in front of your camera. The screen is blurred, but it will not affect the reading." + }, + "QRHardwareSignRequestCancel": { + "message": "Reject" + }, + "QRHardwareSignRequestDescription": { + "message": "After you’ve signed with your wallet, click on 'Get Signature' to receive the signature" + }, + "QRHardwareSignRequestGetSignature": { + "message": "Get Signature" + }, + "QRHardwareSignRequestSubtitle": { + "message": "Scan the QR code with your wallet" + }, + "QRHardwareSignRequestTitle": { + "message": "Request Signature" + }, + "QRHardwareUnknownQRCodeTitle": { + "message": "Error" + }, + "QRHardwareUnknownWalletQRCode": { + "message": "Invalid QR code. Please scan the sync QR code of the hardware wallet." + }, + "QRHardwareWalletImporterTitle": { + "message": "Scan QR Code" + }, + "QRHardwareWalletSteps1Description": { + "message": "Connect an airgapped hardware wallet that communicates through QR-codes. Officially supported airgapped hardware wallets include:" + }, + "QRHardwareWalletSteps1Title": { + "message": "QR-based HW Wallet" + }, + "QRHardwareWalletSteps2Description": { + "message": "AirGap Vault & Ngrave (Coming Soon)" + }, "about": { "message": "About" }, @@ -41,7 +86,7 @@ "message": "Activity" }, "activityLog": { - "message": "activity log" + "message": "Activity log" }, "add": { "message": "Add" @@ -49,6 +94,9 @@ "addANetwork": { "message": "Add a network" }, + "addANickname": { + "message": "Add a nickname" + }, "addAcquiredTokens": { "message": "Add the tokens you've acquired using MetaMask" }, @@ -85,9 +133,15 @@ "addFriendsAndAddresses": { "message": "Add friends and addresses you trust" }, + "addMemo": { + "message": "Add memo" + }, "addNFT": { "message": "Add NFT" }, + "addNFTLowerCase": { + "message": "add NFT" + }, "addNetwork": { "message": "Add Network" }, @@ -112,12 +166,21 @@ "advanced": { "message": "Advanced" }, + "advancedBaseGasFeeToolTip": { + "message": "Any difference between your max base fee and the current base fee will be refunded after completion." + }, + "advancedGasFeeModalTitle": { + "message": "Advanced gas fee" + }, "advancedGasPriceTitle": { "message": "Gas price" }, "advancedOptions": { "message": "Advanced Options" }, + "advancedPriorityFeeToolTip": { + "message": "Priority fee (aka “miner tip”) goes directly to miners and incentivizes them to prioritize your transaction." + }, "advancedSettingsDescription": { "message": "Access developer features, download State Logs, Reset Account, setup test networks and custom RPC" }, @@ -291,10 +354,6 @@ "message": "Swap", "description": "This is used with viewOnEtherscan e.g View Swap on Etherscan" }, - "blockExplorerTransactionAction": { - "message": "Transaction", - "description": "This is used with viewOnCustomBlockExplorer and viewOnEtherscan e.g View Transaction on Etherscan" - }, "blockExplorerUrl": { "message": "Block Explorer URL" }, @@ -317,6 +376,9 @@ "builtAroundTheWorld": { "message": "MetaMask is designed and built around the world." }, + "busy": { + "message": "Busy" + }, "buy": { "message": "Buy" }, @@ -347,6 +409,9 @@ "cancelPopoverTitle": { "message": "Cancel transaction" }, + "cancelSpeedUp": { + "message": "cancel or speed up a tranaction." + }, "cancellationGasFee": { "message": "Cancellation Gas Fee" }, @@ -512,15 +577,15 @@ "copiedExclamation": { "message": "Copied!" }, - "copiedTransactionId": { - "message": "Copied Transaction ID" - }, "copyAddress": { "message": "Copy address to clipboard" }, "copyPrivateKey": { "message": "This is your private key (click to copy)" }, + "copyRawTransactionData": { + "message": "Copy raw transaction data" + }, "copyToClipboard": { "message": "Copy to clipboard" }, @@ -560,6 +625,9 @@ "currentLanguage": { "message": "Current Language" }, + "currentTitle": { + "message": "Current:" + }, "currentlyUnavailable": { "message": "Unavailable on this network" }, @@ -581,6 +649,9 @@ "dappSuggested": { "message": "Site suggested" }, + "dappSuggestedShortLabel": { + "message": "Site" + }, "dappSuggestedTooltip": { "message": "$1 has recommended this price.", "description": "$1 represents the Dapp's origin" @@ -591,6 +662,9 @@ "dataBackupFoundInfo": { "message": "Some of your account data was backed up during a previous installation of MetaMask. This could include your settings, contacts, and tokens. Would you like to restore this data now?" }, + "dataHex": { + "message": "Hex" + }, "decimal": { "message": "Token Decimal" }, @@ -689,6 +763,12 @@ "edit": { "message": "Edit" }, + "editANickname": { + "message": "Edit nickname" + }, + "editAddressNickname": { + "message": "Edit address nickname" + }, "editContact": { "message": "Edit Contact" }, @@ -710,6 +790,9 @@ "editGasEducationModalTitle": { "message": "How to choose?" }, + "editGasFeeModalTitle": { + "message": "Edit gas fee" + }, "editGasHigh": { "message": "High" }, @@ -755,21 +838,12 @@ "editGasPriceTooltip": { "message": "This network requires a “Gas price” field when submitting a transaction. Gas price is the amount you will pay pay per unit of gas." }, - "editGasSubTextAmount": { - "message": "$1 $2", - "description": "$1 will be passed the editGasSubTextAmountLabel and $2 will be passed the amount in either cryptocurrency or fiat" - }, "editGasSubTextAmountLabel": { "message": "Max amount:", "description": "This is meant to be used as the $1 substitution editGasSubTextAmount" }, - "editGasSubTextFee": { - "message": "$1 $2", - "description": "$1 will be passed the editGasSubTextFeeLabel and $2 will be passed the fee amount in either cryptocurrency or fiat" - }, "editGasSubTextFeeLabel": { - "message": "Max fee:", - "description": "$1 represents a dollar amount" + "message": "Max fee:" }, "editGasTitle": { "message": "Edit priority" @@ -783,6 +857,12 @@ "editGasTooLowWarningTooltip": { "message": "This lowers your maximum fee but if network traffic increases your transaction may be delayed or fail." }, + "editInGwei": { + "message": "Edit in GWEI" + }, + "editInMultiplier": { + "message": "Edit in multiplier" + }, "editNonceField": { "message": "Edit Nonce" }, @@ -795,6 +875,16 @@ "enableFromSettings": { "message": " Enable it from Settings." }, + "enableOpenSeaAPI": { + "message": "Enable OpenSea API" + }, + "enableOpenSeaAPIDescription": { + "message": "Use OpenSea's API to fetch NFT data. NFT auto-detection relies on OpenSea's API, and will not be available when this is turned off." + }, + "enableToken": { + "message": "enable $1", + "description": "$1 is a token symbol, e.g. ETH" + }, "encryptionPublicKeyNotice": { "message": "$1 would like your public encryption key. By consenting, this site will be able to compose encrypted messages to you.", "description": "$1 is the web3 site name" @@ -966,6 +1056,21 @@ "message": "File import not working? Click here!", "description": "Helps user import their account from a JSON file" }, + "flaskExperimentalText1": { + "message": "Using Flask can greatly increase your risk of fund loss:" + }, + "flaskExperimentalText2": { + "message": "if you use it to install non-trustworthy Snaps" + }, + "flaskExperimentalText3": { + "message": "if you do not review confirmations before approving changes" + }, + "flaskExperimentalText4": { + "message": "if you interact with unfamiliar smart contracts" + }, + "flaskExperimentalText5": { + "message": "Using Flask gives you much greater discretion in using the power of MetaMask, and that discretion is yours. Do you accept these risks as well as extra responsibility for your wallet's safety?" + }, "followUsOnTwitter": { "message": "Follow us on Twitter" }, @@ -1011,6 +1116,9 @@ "message": "Gas limit must be at least $1", "description": "$1 is the custom gas limit, in decimal." }, + "gasOption": { + "message": "Gas option" + }, "gasPrice": { "message": "Gas Price (GWEI)" }, @@ -1029,13 +1137,18 @@ "gasPriceInfoTooltipContent": { "message": "Gas price specifies the amount of Ether you are willing to pay for each unit of gas." }, - "gasPriceLabel": { - "message": "Gas price" + "gasTimingHoursShort": { + "message": "$1 hrs", + "description": "$1 represents a number of hours" }, "gasTimingMinutes": { "message": "$1 minutes", "description": "$1 represents a number of minutes" }, + "gasTimingMinutesShort": { + "message": "$1 min", + "description": "$1 represents a number of minutes" + }, "gasTimingNegative": { "message": "Maybe in $1", "description": "$1 represents an amount of time" @@ -1048,6 +1161,10 @@ "message": "$1 seconds", "description": "$1 represents a number of seconds" }, + "gasTimingSecondsShort": { + "message": "$1 sec", + "description": "$1 represents a number of seconds" + }, "gasTimingVeryPositive": { "message": "Very likely in < $1", "description": "$1 represents an amount of time" @@ -1276,6 +1393,12 @@ "message": "JSON File", "description": "format for importing an account" }, + "keystone": { + "message": "Keystone" + }, + "keystoneTutorial": { + "message": " (Tutorials)" + }, "knownAddressRecipient": { "message": "Known contract address." }, @@ -1291,8 +1414,15 @@ "layer1Fees": { "message": "Layer 1 fees" }, + "learmMoreAboutGas": { + "message": "Want to $1 about gas?" + }, + "learnCancelSpeeedup": { + "message": "Learn how to $1", + "description": "$1 is link to cancel or speed up transactions" + }, "learnMore": { - "message": "Learn more" + "message": "learn more" }, "learnScamRisk": { "message": "scams and security risks." @@ -1496,6 +1626,9 @@ "mobileSyncWarning": { "message": "The 'Sync with extension' feature is temporarily disabled. If you want to use your extension wallet on MetaMask mobile, then on your mobile app: go back to the wallet setup options and select the 'Import with Secret Recovery Phrase' option. Use your extension wallet's secret phrase to then import your wallet into mobile." }, + "multiplier": { + "message": "multiplier" + }, "mustSelectOne": { "message": "Must select at least 1 token." }, @@ -1558,6 +1691,9 @@ "networkSettingsDescription": { "message": "Add and edit custom RPC networks" }, + "networkStatus": { + "message": "Network status" + }, "networkURL": { "message": "Network URL" }, @@ -1580,12 +1716,24 @@ "message": "Account $1", "description": "Default name of next account to be created on create account screen" }, + "newCollectibleAddFailed": { + "message": "Collectible was not added because: $1" + }, + "newCollectibleAddedMessage": { + "message": "Collectible was successfully added!" + }, "newContact": { "message": "New Contact" }, "newContract": { "message": "New Contract" }, + "newNFTsDetected": { + "message": "New NFTs detected" + }, + "newNFTsDetectedInfo": { + "message": "One or more new NFTs were detected in your wallet." + }, "newNetworkAdded": { "message": "“$1” was successfully added!" }, @@ -1614,6 +1762,9 @@ "nfts": { "message": "NFTs" }, + "nickname": { + "message": "Nickname" + }, "noAccountsFound": { "message": "No accounts found for the given search query" }, @@ -1653,6 +1804,9 @@ "nonceFieldHeading": { "message": "Custom Nonce" }, + "notBusy": { + "message": "Not busy" + }, "notCurrentAccount": { "message": "Is this the correct account? It's different from the currently selected account in your wallet" }, @@ -1739,6 +1893,15 @@ "message": "Ledger connection improvement", "description": "Title for a notification in the 'See What's New' popup. Notifies ledger users that there is an improvement in how they can connect their device." }, + "notifications9DescriptionOne": { + "message": "We now provide you with more insights on the 'Data' tab when confirming smart contract transactions." + }, + "notifications9DescriptionTwo": { + "message": "You can now get a better understanding of your transaction’s details before confirming, and more easily add transaction addresses to your address book, helping you make safe and informed decisions." + }, + "notifications9Title": { + "message": "👓 We are making transactions easier to read." + }, "ofTextNofM": { "message": "of" }, @@ -1861,6 +2024,16 @@ "pending": { "message": "Pending" }, + "pendingTransactionInfo": { + "message": "This transaction will not process until that one is complete." + }, + "pendingTransactionMultiple": { + "message": "You have ($1) pending transactions." + }, + "pendingTransactionSingle": { + "message": "You have (1) pending transaction.", + "description": "$1 is count of pending transactions" + }, "permissionCheckedIconDescription": { "message": "You have approved this permission" }, @@ -1893,6 +2066,9 @@ "primaryCurrencySettingDescription": { "message": "Select native to prioritize displaying values in the native currency of the chain (e.g. ETH). Select Fiat to prioritize displaying values in your selected fiat currency." }, + "priorityFee": { + "message": "Priority Fee" + }, "privacyMsg": { "message": "Privacy Policy" }, @@ -1906,6 +2082,9 @@ "privateNetwork": { "message": "Private Network" }, + "proceedWithTransaction": { + "message": "I want to proceed anyway" + }, "proposedApprovalLimit": { "message": "Proposed Approval Limit" }, @@ -2180,6 +2359,9 @@ "selectHdPath": { "message": "Select HD Path" }, + "selectNFTPrivacyPreference": { + "message": "Select NFT privacy preference" + }, "selectPathHelp": { "message": "If you don't see the accounts you expect, try switching the HD path." }, @@ -2287,6 +2469,12 @@ "signed": { "message": "Signed" }, + "simulationErrorMessage": { + "message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended." + }, + "simulationErrorMessageV2": { + "message": "We were not able to estimate gas. There might be an error in the contract and this transaction may fail." + }, "skip": { "message": "Skip" }, @@ -2339,6 +2527,9 @@ "spendLimitTooLarge": { "message": "Spend limit too large" }, + "stable": { + "message": "Stable" + }, "stateLogError": { "message": "Error in retrieving state logs." }, @@ -2427,8 +2618,9 @@ "message": "You need $1 more $2 to complete this swap", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "A better quote is available" + "swapBestOfNQuotes": { + "message": "Best of $1 quotes.", + "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" }, "swapBuildQuotePlaceHolderText": { "message": "No tokens available matching $1", @@ -2459,12 +2651,9 @@ "message": "This is required and gives MetaMask permission to swap your $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Estimated network fee" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "The “$1” is what we expect the actual fee to be. The exact amount depends on network conditions.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" + "swapEnableTokenForSwapping": { + "message": "This will $1 for swapping", + "description": "$1 is for the 'enableToken' key, e.g. 'enable ETH'" }, "swapEstimatedNetworkFees": { "message": "Estimated network fees" @@ -2511,16 +2700,13 @@ "swapHighSlippageWarning": { "message": "Slippage amount is very high." }, + "swapIncludesMMFee": { + "message": "Includes a $1% MetaMask fee.", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapLowSlippageError": { "message": "Transaction may fail, max slippage too low." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” is the most you’ll spend. When the network is volatile this can be a large amount.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Max network fee" - }, "swapMaxSlippage": { "message": "Max slippage" }, @@ -2531,13 +2717,10 @@ "message": "We find the best price from the top liquidity sources, every time. A fee of $1% is automatically factored into this quote.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 quotes", + "swapNQuotesWithDot": { + "message": "$1 quotes.", "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" }, - "swapNetworkFeeSummary": { - "message": "The network fee covers the cost of processing your swap and storing it on the $1 network. MetaMask does not profit from this fee." - }, "swapNewQuoteIn": { "message": "New quotes in $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -2572,10 +2755,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "If the price changes between the time your order is placed and confirmed it’s called \"slippage\". Your Swap will automatically cancel if slippage exceeds your \"slippage tolerance\" setting." }, - "swapQuoteIncludesRate": { - "message": "Quote includes a $1% MetaMask fee", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Quote $1 of $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -2583,9 +2762,6 @@ "swapQuoteSource": { "message": "Quote source" }, - "swapQuotesAreRefreshed": { - "message": "Quotes are refreshed often to reflect current market conditions." - }, "swapQuotesExpiredErrorDescription": { "message": "Please request new quotes to get the latest rates." }, @@ -2646,9 +2822,6 @@ "swapSwapTo": { "message": "Swap to" }, - "swapThisWillAllowApprove": { - "message": "This will allow $1 to be swapped." - }, "swapToConfirmWithHwWallet": { "message": "to confirm with your hardware wallet" }, @@ -2691,9 +2864,6 @@ "swapUnknown": { "message": "Unknown" }, - "swapUsingBestQuote": { - "message": "Using the best quote" - }, "swapVerifyTokenExplanation": { "message": "Multiple tokens can use the same name and symbol. Check $1 to verify this is the token you're looking for.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." @@ -2793,6 +2963,9 @@ "thisWillCreate": { "message": "This will create a new wallet and Secret Recovery Phrase" }, + "time": { + "message": "Time" + }, "tips": { "message": "Tips" }, @@ -2846,6 +3019,18 @@ "transactionCreated": { "message": "Transaction created with a value of $1 at $2." }, + "transactionData": { + "message": "Transaction data" + }, + "transactionDecodingAccreditationDecoded": { + "message": "Decoded by Truffle" + }, + "transactionDecodingAccreditationVerified": { + "message": "Verified contract on $1" + }, + "transactionDecodingUnsupportedNetworkError": { + "message": "Transaction decoding is not available for chainId $1" + }, "transactionDetailDappGasMoreInfo": { "message": "Site suggested" }, @@ -2947,9 +3132,18 @@ "tryAgain": { "message": "Try again" }, + "tryAnywayOption": { + "message": "I will try anyway" + }, "turnOnTokenDetection": { "message": "Turn on enhanced token detection" }, + "twelveHrTitle": { + "message": "12hr:" + }, + "txInsightsNotSupported": { + "message": "Transaction insights not supported for this contract at this time." + }, "typePassword": { "message": "Type your MetaMask password" }, @@ -3008,6 +3202,12 @@ "urlExistsErrorMsg": { "message": "This URL is currently used by the $1 network." }, + "useCollectibleDetection": { + "message": "Autodetect NFTs" + }, + "useCollectibleDetectionDescription": { + "message": "Displaying NFTs media & data may expose your IP address to centralized servers. Third-party APIs (like OpenSea) are used to detect NFTs in your wallet. This exposes your account address with those services. Leave this disabled if you don’t want the app to pull data from those those services." + }, "usePhishingDetection": { "message": "Use Phishing Detection" }, @@ -3023,6 +3223,9 @@ "usedByClients": { "message": "Used by a variety of different clients" }, + "userAccepts": { + "message": "I accept" + }, "userName": { "message": "Username" }, @@ -3053,6 +3256,9 @@ "viewMore": { "message": "View More" }, + "viewOnBlockExplorer": { + "message": "View on block explorer" + }, "viewOnCustomBlockExplorer": { "message": "View $1 at $2", "description": "$1 is the action type. e.g (Account, Transaction, Swap) and $2 is the Custom Block Exporer URL" diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 8418ba8e6..195bff8c7 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "¡Copiado!" }, - "copiedTransactionId": { - "message": "Id. de transacción copiado" - }, "copyAddress": { "message": "Copiar dirección al Portapapeles" }, @@ -1816,9 +1813,6 @@ "message": "Necesita $1 más $2 para completar este canje", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Hay una mejor cotización disponible" - }, "swapBuildQuotePlaceHolderText": { "message": "No hay tokens disponibles que coincidan con $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Esta acción es obligatoria y le da permiso a MetaMask para canjear su $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Cuota de red estimada" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” es la cuota real que esperamos que sea. El monto exacto depende de las condiciones de la red.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Cuotas de red estimadas" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Es posible que la transacción tenga errores, el desfase máximo es demasiado bajo." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” es el máximo que gastará. Cuando la red es volátil, puede ser un monto grande.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Cuota máxima de red" - }, "swapMaxSlippage": { "message": "Desfase máximo" }, @@ -1904,13 +1884,6 @@ "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 cotizaciones", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "La cuota de red cubre el costo de procesamiento del canje y su almacenamiento en la red de $1. MetaMask no se beneficia de esta cuota." - }, "swapNewQuoteIn": { "message": "Cotizaciones nuevas en $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"." }, - "swapQuoteIncludesRate": { - "message": "La cotización incluye una cuota de MetaMask de $1 %", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Cotización $1 de $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Fuente de la cotización" }, - "swapQuotesAreRefreshed": { - "message": "Las cotizaciones se actualizan con frecuencia para reflejar las condiciones actuales del mercado." - }, "swapQuotesExpiredErrorDescription": { "message": "Solicite cotizaciones nuevas para tener los costos más recientes." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Canjear a" }, - "swapThisWillAllowApprove": { - "message": "Esto permitirá canjear $1." - }, "swapToConfirmWithHwWallet": { "message": "confirmar con la cartera de hardware" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Desconocido" }, - "swapUsingBestQuote": { - "message": "Uso de la mejor cotización" - }, "swapVerifyTokenExplanation": { "message": "Varios tokens pueden usar el mismo nombre y símbolo. Revise $1 para comprobar que este es el token que busca.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 8418ba8e6..195bff8c7 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "¡Copiado!" }, - "copiedTransactionId": { - "message": "Id. de transacción copiado" - }, "copyAddress": { "message": "Copiar dirección al Portapapeles" }, @@ -1816,9 +1813,6 @@ "message": "Necesita $1 más $2 para completar este canje", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Hay una mejor cotización disponible" - }, "swapBuildQuotePlaceHolderText": { "message": "No hay tokens disponibles que coincidan con $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Esta acción es obligatoria y le da permiso a MetaMask para canjear su $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Cuota de red estimada" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” es la cuota real que esperamos que sea. El monto exacto depende de las condiciones de la red.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Cuotas de red estimadas" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Es posible que la transacción tenga errores, el desfase máximo es demasiado bajo." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” es el máximo que gastará. Cuando la red es volátil, puede ser un monto grande.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Cuota máxima de red" - }, "swapMaxSlippage": { "message": "Desfase máximo" }, @@ -1904,13 +1884,6 @@ "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 cotizaciones", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "La cuota de red cubre el costo de procesamiento del canje y su almacenamiento en la red de $1. MetaMask no se beneficia de esta cuota." - }, "swapNewQuoteIn": { "message": "Cotizaciones nuevas en $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"." }, - "swapQuoteIncludesRate": { - "message": "La cotización incluye una cuota de MetaMask de $1 %", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Cotización $1 de $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Fuente de la cotización" }, - "swapQuotesAreRefreshed": { - "message": "Las cotizaciones se actualizan con frecuencia para reflejar las condiciones actuales del mercado." - }, "swapQuotesExpiredErrorDescription": { "message": "Solicite cotizaciones nuevas para tener los costos más recientes." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Canjear a" }, - "swapThisWillAllowApprove": { - "message": "Esto permitirá canjear $1." - }, "swapToConfirmWithHwWallet": { "message": "confirmar con la cartera de hardware" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Desconocido" }, - "swapUsingBestQuote": { - "message": "Uso de la mejor cotización" - }, "swapVerifyTokenExplanation": { "message": "Varios tokens pueden usar el mismo nombre y símbolo. Revise $1 para comprobar que este es el token que busca.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/et/messages.json b/app/_locales/et/messages.json index 66ce15ec5..a3297815c 100644 --- a/app/_locales/et/messages.json +++ b/app/_locales/et/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kopeeritud!" }, - "copiedTransactionId": { - "message": "Kopeeritud tehingu ID" - }, "copyAddress": { "message": "Kopeeri aadress lõikelauale" }, diff --git a/app/_locales/fa/messages.json b/app/_locales/fa/messages.json index f6fd7e476..d76b2f99c 100644 --- a/app/_locales/fa/messages.json +++ b/app/_locales/fa/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "کپی شد!" }, - "copiedTransactionId": { - "message": "آی دی معامله کاپی شده" - }, "copyAddress": { "message": "کاپی آدرس به کلیپ بورد" }, diff --git a/app/_locales/fi/messages.json b/app/_locales/fi/messages.json index 8260f9ad4..1f453fb70 100644 --- a/app/_locales/fi/messages.json +++ b/app/_locales/fi/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kopioitu" }, - "copiedTransactionId": { - "message": "Kopioitu tapahtuman tunnus" - }, "copyAddress": { "message": "Kopioi osoite leikepöydälle" }, diff --git a/app/_locales/fil/messages.json b/app/_locales/fil/messages.json index 23001c136..2a12119cf 100644 --- a/app/_locales/fil/messages.json +++ b/app/_locales/fil/messages.json @@ -197,9 +197,6 @@ "copiedExclamation": { "message": "Nakopya!" }, - "copiedTransactionId": { - "message": "Nakopya ang Transaction ID" - }, "copyAddress": { "message": "Kopyahin ang address sa clipboard" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 7ed3dbcdd..8fd682d8c 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -209,9 +209,6 @@ "copiedExclamation": { "message": "Copié!" }, - "copiedTransactionId": { - "message": "ID de transaction copié" - }, "copyAddress": { "message": "Copier l'addresse dans le presse-papier" }, diff --git a/app/_locales/he/messages.json b/app/_locales/he/messages.json index 905020755..1e49f42db 100644 --- a/app/_locales/he/messages.json +++ b/app/_locales/he/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "הועתק!" }, - "copiedTransactionId": { - "message": "מזהה עסקה הועתק" - }, "copyAddress": { "message": "העתק כתובת ללוח" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 65f6640c1..29d19a07a 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "कॉपी किया गया!" }, - "copiedTransactionId": { - "message": "कॉपी की गई लेनदेन ID" - }, "copyAddress": { "message": "क्लिपबोर्ड पर पता कॉपी करें" }, @@ -1816,9 +1813,6 @@ "message": "इस स्वैप को पूरा करने के लिए आपको $1 और $2 की आवश्यकता होगी", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "एक बेहतर उद्धरण उपलब्ध है" - }, "swapBuildQuotePlaceHolderText": { "message": "$1 के मिलान वाले कोई भी टोकन उपलब्ध नहीं हैं", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "यह आवश्यक है और MetaMask को आपके $1 को स्वैप करने की अनुमति देता है।", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "अनुमानित नेटवर्क शुल्क" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” वह शुल्क है, जिसकी हम वास्तविक रूप से उम्मीद करते हैं। सटीक राशि नेटवर्क की स्थितियों पर निर्भर करती है।", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "अनुमानित नेटवर्क शुल्क" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "लेनदेन विफल हो सकता है, अधिकतम स्लिपेज बहुत कम हो सकता है।" }, - "swapMaxNetworkFeeInfo": { - "message": "आप सबसे अधिक “$1” खर्च करते हैं। जब नेटवर्क अस्थिर होता है, तो यह एक बड़ी राशि हो सकती है।", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "अधिकतम नेटवर्क शुल्क" - }, "swapMaxSlippage": { "message": "अधिकतम स्लिपेज" }, @@ -1904,13 +1884,6 @@ "message": "हम हर बार शीर्ष चलनिधि स्रोतों से सबसे अच्छे मूल्य पाते हैं। इस उद्धरण में $1% का शुल्क स्वतः ही शामिल हो जाता है।", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 उद्धरण", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "नेटवर्क शुल्क आपके स्वैप को संसाधित करने के शुल्क और उसे $1 नेटवर्क पर संग्रह करने को कवर करता है। MetaMask इस शुल्क से लाभ नहीं कमाता है।" - }, "swapNewQuoteIn": { "message": "$1 में नए उद्धरण", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "यदि आपके ऑर्डर किए जाने और पुष्टि किए जाने के समय के बीच मूल्य में परिवर्तन होता है, तो इसे \"स्लिपेज\" कहा जाता है। यदि स्लिपेज आपकी \"स्लिपेज टॉलरेंस\" सेटिंग से अधिक हो जाता है, तो आपका स्वैप स्वतः रद्द हो जाएगा।" }, - "swapQuoteIncludesRate": { - "message": "उद्धरण में $1% का MetaMask शुल्क शामिल है", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "$2 में से $1 उद्धरण", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "उद्धरण का स्रोत" }, - "swapQuotesAreRefreshed": { - "message": "वर्तमान बाजार की स्थितियों को प्रतिबिंबित करने के लिए उद्धरण अक्सर ताज़ा होते रहते हैं।" - }, "swapQuotesExpiredErrorDescription": { "message": "कृपया नवीनतम दरों को प्राप्त करने के लिए नए उद्धरणों का अनुरोध करें।" }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "इसमें स्वैप करें" }, - "swapThisWillAllowApprove": { - "message": "यह $1 को स्वैप करने की अनुमति देगा।" - }, "swapToConfirmWithHwWallet": { "message": "अपने हार्डवेयर वॉलेट से पुष्टि करने के लिए" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "अज्ञात" }, - "swapUsingBestQuote": { - "message": "सर्वोत्तम उद्धरण का उपयोग करना" - }, "swapVerifyTokenExplanation": { "message": "एकाधिक टोकन एक ही नाम और प्रतीक का उपयोग कर सकते हैं। यह सत्यापित करने के लिए $1 की जाँच करें कि यह वही टोकन है, जिसकी आप तलाश कर रहे हैं।", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/hr/messages.json b/app/_locales/hr/messages.json index 7a3a94c8f..39d3f0f0e 100644 --- a/app/_locales/hr/messages.json +++ b/app/_locales/hr/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kopirano!" }, - "copiedTransactionId": { - "message": "Kopirana identifikacijska oznaka transakcije" - }, "copyAddress": { "message": "Kopiraj adresu u međuspremnik" }, diff --git a/app/_locales/hu/messages.json b/app/_locales/hu/messages.json index 18f2cf317..9a36bbaa4 100644 --- a/app/_locales/hu/messages.json +++ b/app/_locales/hu/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kimásolva!" }, - "copiedTransactionId": { - "message": "Másolt tranzakció-azonosító" - }, "copyAddress": { "message": "Másolja a címet a vágólapra" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 9f0744379..eeee31329 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "Disalin!" }, - "copiedTransactionId": { - "message": "ID Transaksi yang Disalin" - }, "copyAddress": { "message": "Salin alamat ke clipboard" }, @@ -1816,9 +1813,6 @@ "message": "Anda memerlukan $1 lagi $2 untuk menyelesaikan penukaran ini", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Kuota yang lebih baik tersedia" - }, "swapBuildQuotePlaceHolderText": { "message": "Tidak ada token yang cocok yang tersedia $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Ini wajib dan memberikan MetaMask izin untuk menukar $1 Anda.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Biaya jaringan yang diperkirakan" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” adalah yang kami harapkan untuk biaya yang seharusnya. Jumlah yang tepat tergantung pada kondisi jaringan.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Biaya jaringan yang diperkirakan" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Transaksi bisa gagal, slippage maks. terlalu rendah." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” adalah yang paling banyak yang akan Anda gunakan. Bila jaringan tidak stabil ini bisa menjadi jumlah yang besar.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Biaya jaringan maks." - }, "swapMaxSlippage": { "message": "Maks. slippage" }, @@ -1904,13 +1884,6 @@ "message": "Kami menemukan harga terbaik dari sumber likuiditas teratas, setiap waktu. Biaya sebesar $1% otomatis diperhitungkan ke kuota ini.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 kuota", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "Biaya jaringan mencakup biaya pemrosesan penukaran Anda dan menyimpannya di jaringan $1. MetaMask tidak mendapatkan keuntungan dari biaya ini." - }, "swapNewQuoteIn": { "message": "Kuota baru di $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Jika harga berubah antara waktu pesanan Anda ditempatkan dan dikonfirmasi, ini disebut \"slippage\". Penukaran Anda akan otomatis dibatalkan jika slippage melebihi pengaturan \"toleransi slippage\"." }, - "swapQuoteIncludesRate": { - "message": "Kuota mencakup biaya MetaMask $1%", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Kuota $1 dari $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Sumber kuota" }, - "swapQuotesAreRefreshed": { - "message": "Kuota disegarkan sering kali untuk menerapkan kondisi pasar terkini." - }, "swapQuotesExpiredErrorDescription": { "message": "Silakan minta kuota baru untuk mendapatkan tarif terbaru." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Tukar untuk" }, - "swapThisWillAllowApprove": { - "message": "Ini akan memungkinkan $1 untuk ditukar." - }, "swapToConfirmWithHwWallet": { "message": "untuk mengonfirmasikan dengan dompet perangkat keras Anda" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Tidak diketahui" }, - "swapUsingBestQuote": { - "message": "Menggunakan kuota terbaik" - }, "swapVerifyTokenExplanation": { "message": "Beberapa token dapat menggunakan simbol dan nama yang sama. Periksa $1 untuk memverifikasi inilah token yang Anda cari.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index 1e8c000de..2e3c5720b 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -356,9 +356,6 @@ "copiedExclamation": { "message": "Copiato!" }, - "copiedTransactionId": { - "message": "ID Transazione Copiato" - }, "copyAddress": { "message": "Copia l'indirizzo" }, @@ -1478,9 +1475,6 @@ "message": "Devi avere $1 $2 in più per completare lo scambio", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "È disponibile una quotazione migliore" - }, "swapBuildQuotePlaceHolderText": { "message": "Non ci sono token disponibile con questo nome $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1498,13 +1492,6 @@ "message": "Questo è richiesto e darà a MetaMask il permesso di scambiare $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Tassa di rete stimata" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "La “$1” è ciò che ci aspettiamo possa essere la tassa reale. L'importo esatto dipende dalle condizioni della rete.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Tasse di rete stimate" }, @@ -1526,13 +1513,6 @@ "swapLowSlippageError": { "message": "La transazione può fallire, il massimo slippage è troppo basso." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” è il massimo che spenderai. Quando la rete è volatile può essere un importo alto.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Tassa di rete massima" - }, "swapMaxSlippage": { "message": "Slippage massimo" }, @@ -1543,13 +1523,6 @@ "message": "Troviamo i migliori prezzi dalle sorgenti di liquidità migliori, sempre. Una tassa del $1% è automaticamente aggiunta ad ogni quotazione, ciò serve a supportare lo sviluppo in modo da rendere MetaMask sempre migliore.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 quotazioni", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "La tassa di rete copre il costo di processamento dello scambio e della memorizzazione nella rete $1. MetaMask non trae profitto da questa tassa." - }, "swapNewQuoteIn": { "message": "Nuove quotazioni in $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1575,10 +1548,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Si chiama \"slippage\" la differenza tra il prezzo quando il tuo ordine viene inserito e quando viene confermato. Lo scambio sarà annullato automaticamente se lo slippage supera il \"massimo slippage\" impostato." }, - "swapQuoteIncludesRate": { - "message": "La quotazione include la tassa del $1% di MetaMask", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Quotazione $1 di $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1586,9 +1555,6 @@ "swapQuoteSource": { "message": "Sorgente della quota" }, - "swapQuotesAreRefreshed": { - "message": "Le quotazioni sono aggiornate spesso per riflettere le condizioni di mercato correnti." - }, "swapQuotesExpiredErrorDescription": { "message": "Richiedi nuove quotazioni per ottenere le ultime tariffe." }, @@ -1646,9 +1612,6 @@ "swapSwapTo": { "message": "Scambia a" }, - "swapThisWillAllowApprove": { - "message": "Questo permetterà di scambiare $1." - }, "swapTokenAvailable": { "message": "I $1 sono stati aggiunti al tuo account.", "description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol." @@ -1674,9 +1637,6 @@ "swapUnknown": { "message": "Sconosciuto" }, - "swapUsingBestQuote": { - "message": "Quotazione migliore" - }, "swapVerifyTokenExplanation": { "message": "Più token possono usare lo stesso nome e simbolo. Verifica su $1 che questo sia il token che stai cercando.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index cea2eda8c..7c74d8f5f 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "コピーされました!" }, - "copiedTransactionId": { - "message": "コピーされたトランザクション ID" - }, "copyAddress": { "message": "アドレスをクリップボードにコピー" }, @@ -1816,9 +1813,6 @@ "message": "このスワップを完了するには、さらに $1 個の $2 が必要です。", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "より適切な見積もりが利用可能です" - }, "swapBuildQuotePlaceHolderText": { "message": "$1 と一致するトークンがありません", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "これは必須であり、$1 をスワップするための MetaMask 許可を付与します。", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "推定のネットワーク手数料" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” は予定する実際の手数料です。正確な額はネットワークの状態によって異なります。", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "推定のネットワーク手数料" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "トランザクションが失敗する可能性があります。最大スリッページが低すぎます。" }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” は使用する最大量です。ネットワークが不安定なときは、これは大きな量になることがあります。", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "最大ネットワーク手数料" - }, "swapMaxSlippage": { "message": "最大スリッページ" }, @@ -1904,13 +1884,6 @@ "message": "当社は毎回最上位の流動性のソースから最良の価格を見つけます。$1 の手数料が自動的にこの見積もりに含まれます。", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 の見積もり", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "ネットワーク手数料には、スワップを処理して $1 ネットワーク上に保管する費用も含まれています。MetaMask は手数料から利益を得ません。" - }, "swapNewQuoteIn": { "message": "$1 での新規の見積もり", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "注文した時点と注文が承認された時点で価格が変わることを \"スリッページ\" と呼びます。スリッページが \"最大スリッページ\" 設定を超える場合、スワップは自動的にキャンセルされます。" }, - "swapQuoteIncludesRate": { - "message": "見積もりには $1% の MetaMask 手数料が含まれています", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "$2 個中の $1 個の見積もり", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "見積もりのソース" }, - "swapQuotesAreRefreshed": { - "message": "現在のマーケット状態を反映するために、見積もりはたびたび更新されます。" - }, "swapQuotesExpiredErrorDescription": { "message": "最新のレートを取得するには、新しい見積もりを要求してください。" }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "スワップ先" }, - "swapThisWillAllowApprove": { - "message": "これにより、$1 のスワップが可能になります。" - }, "swapToConfirmWithHwWallet": { "message": "ハードウェア ウォレットで確認する" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "不明です" }, - "swapUsingBestQuote": { - "message": "最良の見積もりを使用する" - }, "swapVerifyTokenExplanation": { "message": "複数のトークンが同じ名前とシンボルを使用できます。$1 をチェックして、これが探しているトークンであることを確認します。", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/kn/messages.json b/app/_locales/kn/messages.json index bf17d9ce0..25f870717 100644 --- a/app/_locales/kn/messages.json +++ b/app/_locales/kn/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "ನಕಲಿಸಲಾಗಿದೆ!" }, - "copiedTransactionId": { - "message": "ವ್ಯವಹಾರ ID ಅನ್ನು ನಕಲಿಸಲಾಗಿದೆ" - }, "copyAddress": { "message": "ವಿಳಾಸವನ್ನು ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index f4c30767d..167e3f48d 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "복사 완료!" }, - "copiedTransactionId": { - "message": "거래 ID 복사됨" - }, "copyAddress": { "message": "주소를 클립보드에 복사" }, @@ -1816,9 +1813,6 @@ "message": "이 스왑을 완료하려면 $1개의 추가 $2이(가) 필요합니다.", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "더 나은 견적이 있습니다." - }, "swapBuildQuotePlaceHolderText": { "message": "$1와(과) 일치하는 토큰이 없습니다.", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "필수이며 MetaMask에게 $1을(를) 스왑할 권한을 부여합니다.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "예상 네트워크 수수료" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "'$1'은(는) 당사가 예상하는 실제 수수료입니다. 정확한 금액은 네트워크 상태에 따라 달라집니다.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "예상 네트워크 수수료" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "거래가 실패할 수도 있습니다. 최대 슬리패지가 너무 낮습니다." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1”이(가) 최대 지출 금액입니다. 네트워크가 불안정한 경우 금액이 증가할 수 있습니다.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "최대 네트워크 수수료" - }, "swapMaxSlippage": { "message": "최대 슬리패지" }, @@ -1904,13 +1884,6 @@ "message": "당사는 매번 최상의 유동성 소스에서 최적의 가격을 찾습니다. 이 견적에는 $1%의 수수료가 자동으로 반영됩니다.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 견적", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "네트워크 수수료에는 스왑을 처리하고 $1 네트워크에 보관하는 비용이 포함됩니다. MetaMask는 이 수수료로 수익을 얻지 않습니다." - }, "swapNewQuoteIn": { "message": "$1의 새 견적", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "주문 시점과 확인 시점 사이에 가격이 변동되는 현상을 \"슬리패지\"라고 합니다. 슬리패지가 \"최대 슬리패지\" 설정을 초과하면 스왑이 자동으로 취소됩니다." }, - "swapQuoteIncludesRate": { - "message": "견적에는 $1% MetaMask 요금이 포함됩니다.", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "$2의 $1 견적", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "견적 소스" }, - "swapQuotesAreRefreshed": { - "message": "견적은 현재 시장 상황을 반영하도록 자주 갱신됩니다." - }, "swapQuotesExpiredErrorDescription": { "message": "새 견적을 요청해 최신 요율을 확인하세요." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "다음으로 스왑" }, - "swapThisWillAllowApprove": { - "message": "$1이(가) 스왑될 수 있도록 허용합니다." - }, "swapToConfirmWithHwWallet": { "message": "하드웨어 지갑으로 확인하기 위해" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "알 수 없음" }, - "swapUsingBestQuote": { - "message": "최고 견적을 사용 중" - }, "swapVerifyTokenExplanation": { "message": "여러 토큰이 같은 이름과 기호를 사용할 수 있습니다. $1을(를) 확인하여 원하는 토큰인지 확인하세요.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/lt/messages.json b/app/_locales/lt/messages.json index 80536f454..702c4784d 100644 --- a/app/_locales/lt/messages.json +++ b/app/_locales/lt/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Nukopijuota!" }, - "copiedTransactionId": { - "message": "Nukopijuotas operacijos ID" - }, "copyAddress": { "message": "Kopijuoti adresą į iškarpinę" }, diff --git a/app/_locales/lv/messages.json b/app/_locales/lv/messages.json index 4531820a4..4de401ac7 100644 --- a/app/_locales/lv/messages.json +++ b/app/_locales/lv/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Nokopēts!" }, - "copiedTransactionId": { - "message": "Nokopētais darījuma ID" - }, "copyAddress": { "message": "Iekopēt adresi starpliktuvē" }, diff --git a/app/_locales/ms/messages.json b/app/_locales/ms/messages.json index 85d021235..10ea16960 100644 --- a/app/_locales/ms/messages.json +++ b/app/_locales/ms/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Disalin!" }, - "copiedTransactionId": { - "message": "ID Transaksi yang Disalin" - }, "copyAddress": { "message": "Salin alamat kepada papan klip" }, diff --git a/app/_locales/no/messages.json b/app/_locales/no/messages.json index 249ea5d10..a567c8762 100644 --- a/app/_locales/no/messages.json +++ b/app/_locales/no/messages.json @@ -215,9 +215,6 @@ "copiedExclamation": { "message": "Kopiert!" }, - "copiedTransactionId": { - "message": "Kopiert transaksjonsidentifikasjon" - }, "copyAddress": { "message": "Kopier adresse til utklippstavlen " }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index 571178bc1..d1d5110aa 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "Nakopya na!" }, - "copiedTransactionId": { - "message": "Nakopya ang Transaction ID" - }, "copyAddress": { "message": "Kopyahin ang address sa clipboard" }, @@ -1816,9 +1813,6 @@ "message": "Kailangan mo ng $1 pa $2 para makumpleto ang pag-swap na ito", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "May available na mas magandang quote" - }, "swapBuildQuotePlaceHolderText": { "message": "Walang available na token na tumutugma sa $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Kinakailangan ito at nagbibigay ito ng pahintulot sa MetaMask na i-swap ang iyong $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Tinatayang bayarin sa network" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "Ang “$1” ay ang inaasahan naming magiging aktuwal na bayarin. Ang eksaktong halaga ay nakadepende sa mga kundisyon ng network.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Mga tinatayang bayarin sa network" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Posibleng hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Max na bayarin sa network" - }, "swapMaxSlippage": { "message": "Max na slippage" }, @@ -1904,13 +1884,6 @@ "message": "Hinahanap namin ang pinakasulit na presyo mula sa mga nangungunang pinagkukunan ng liquidity, sa lahat ng pagkakataon. Awtomatikong fina-factor ang bayaring $1% sa quote na ito.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 quote", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "Kasama sa bayarin sa network ang gastusin sa pagproseso ng iyong pag-swap at pag-store nito sa $1 network. Hindi kumikita ang MetaMask mula sa bayaring ito." - }, "swapNewQuoteIn": { "message": "Mga bagong quote sa $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Kung magbabago ang presyo sa pagitan ng oras ng pag-order mo at sa oras na nakumpirma ito, tinatawag itong \"slippage\". Awtomatikong makakansela ang iyong Pag-swap kung lalampas ang slippage sa iyong setting na \"tolerance ng slippage.\"" }, - "swapQuoteIncludesRate": { - "message": "Kasama sa quote ang $1% bayarin sa MetaMask", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Quote $1 ng $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Pinagkunan ng quote" }, - "swapQuotesAreRefreshed": { - "message": "Madalas na nire-refresh ang mga quote para maipakita ang mga kasalukuyang kundisyon ng market." - }, "swapQuotesExpiredErrorDescription": { "message": "Mag-request ng mga bagong quote para makuha ang mga pinakabagong rate." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "I-swap sa" }, - "swapThisWillAllowApprove": { - "message": "Mabibigyang-daan nito ang $1 na ma-swap." - }, "swapToConfirmWithHwWallet": { "message": "para kumpirmahin ang iyong hardware wallet" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Hindi Alam" }, - "swapUsingBestQuote": { - "message": "Gamit ang pinakamagandang quote" - }, "swapVerifyTokenExplanation": { "message": "Maaaring gamitin ng maraming token ang iisang pangalan at simbolo. Suriin ang $1 para ma-verify na ito ang token na hinahanap mo.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/pl/messages.json b/app/_locales/pl/messages.json index 0d0f59571..c58abc6f2 100644 --- a/app/_locales/pl/messages.json +++ b/app/_locales/pl/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Skopiowane!" }, - "copiedTransactionId": { - "message": "Skopiowano identyfikator transakcji" - }, "copyAddress": { "message": "Skopiuj adres do schowka" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index bd2e54f12..7300e2ee7 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "Copiado!" }, - "copiedTransactionId": { - "message": "ID da transação copiado" - }, "copyAddress": { "message": "Copiar endereço para área de transferência" }, @@ -1816,9 +1813,6 @@ "message": "Você precisa de $1 mais $2 para concluir este swap", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Uma cotação melhor está disponível" - }, "swapBuildQuotePlaceHolderText": { "message": "Nenhum token disponível correspondente a $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Isso é obrigatório e dá ao MetaMask permissão para fazer o swap do seu $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Taxa de rede estimada" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "O “$1” é o que esperamos que a taxa real seja. O valor exato depende das condições de rede.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Taxas de rede estimadas" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "A transação pode falhar; slippage máximo baixo demais." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” é o máximo que você gastará. Quando a rede for volátil, esse poderá ser um grande valor.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Taxa máxima de rede" - }, "swapMaxSlippage": { "message": "Slippage máximo" }, @@ -1904,13 +1884,6 @@ "message": "Encontramos o melhor preço das principais fontes de liquidez – todas as vezes. Uma taxa de $1% é automaticamente fatorada nesta cotação.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 cotações", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "A taxa de rede abrange o custo de processamento do seu swap e o armazenamento dele na rede $1. O MetaMask não lucra com essa taxa." - }, "swapNewQuoteIn": { "message": "Novas cotações em $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Quando o preço varia entre o momento em que seu pedido é feito e o momento em que é confirmado, isso recebe o nome de \"slippage\". Seu swap será automaticamente cancelado se o slippage for superior à configuração \"tolerância de slippage\"." }, - "swapQuoteIncludesRate": { - "message": "A cotação inclui uma taxa de $1% do MetaMask", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Cotação $1 de $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Fonte da cotação" }, - "swapQuotesAreRefreshed": { - "message": "As cotações são atualizadas com frequência para refletir as condições atuais do mercado." - }, "swapQuotesExpiredErrorDescription": { "message": "Solicite novas cotações para obter as tarifas mais recentes." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Swap para" }, - "swapThisWillAllowApprove": { - "message": "isso permitirá o swap de $1." - }, "swapToConfirmWithHwWallet": { "message": "para confirmar com sua carteira de hardware" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Desconhecido" }, - "swapUsingBestQuote": { - "message": "Usando a melhor cotação" - }, "swapVerifyTokenExplanation": { "message": "Vários tokens podem usar o mesmo nome e símbolo. Confira $1 para verificar se esse é o token que você está buscando.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/ro/messages.json b/app/_locales/ro/messages.json index 167fb1c26..0aa456017 100644 --- a/app/_locales/ro/messages.json +++ b/app/_locales/ro/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Copiat!" }, - "copiedTransactionId": { - "message": "ID-ul tranzacției a fost copiat" - }, "copyAddress": { "message": "Copiere adresă în clipboard" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 40ccc7d45..ad5817696 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "Скопировано!" }, - "copiedTransactionId": { - "message": "Скопированный идентификатор транзакции" - }, "copyAddress": { "message": "Скопировать адрес в буфер обмена" }, @@ -1816,9 +1813,6 @@ "message": "Вам нужно еще $1 $2 для завершения этого свопа", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Есть лучшая котировка" - }, "swapBuildQuotePlaceHolderText": { "message": "Нет доступных токенов соответствующих $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Это необходимо и дает MetaMask разрешение на своп вашего $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Ориентировочная комиссия сети" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "«$1» — это ожидаемая нами реальная комиссия. Точная сумма зависит от условий сети.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Ориентировочные комиссии сети" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Транзакции могут завершиться неудачей, максимальное проскальзывание слишком мало." }, - "swapMaxNetworkFeeInfo": { - "message": "«$1» — это максимальная сумма, которую вы потратите. Когда сеть нестабильна, это может быть большая сумма.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Максимальная комиссия за сеть" - }, "swapMaxSlippage": { "message": "Максимальное проскальзывание" }, @@ -1904,13 +1884,6 @@ "message": "Мы всегда находим лучшую цену из лучших источников ликвидности. В эту котировку автоматически включается комиссия в размере $1%.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "Котировок: $1", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "Сетевая комиссия покрывает стоимость обработки вашего свопа и его хранения в сети $1. MetaMask не получает прибыли от этой комиссии." - }, "swapNewQuoteIn": { "message": "Новые котировки в $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Изменение цены в период между размещением заказа и подтверждением называется проскальзыванием. Своп будет автоматически отменен, если фактическое проскальзывание превысит установленное допустимое значение." }, - "swapQuoteIncludesRate": { - "message": "Котировка включает $1% MetaMask fee", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Котировка $1 из $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Источник котировки" }, - "swapQuotesAreRefreshed": { - "message": "Котировки часто обновляются, чтобы отражать текущие рыночные условия." - }, "swapQuotesExpiredErrorDescription": { "message": "Запрашивайте новые котировки, чтобы узнать последние цены." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Своп на" }, - "swapThisWillAllowApprove": { - "message": "Это позволит обмен $1." - }, "swapToConfirmWithHwWallet": { "message": "подтвердить с помощью аппаратного кошелька" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Неизвестный" }, - "swapUsingBestQuote": { - "message": "Используется лучшая котировка" - }, "swapVerifyTokenExplanation": { "message": "Несколько токенов могут использовать одно и то же имя и символ. Убедитесь, что это именно тот токен, который вы ищете, на $1.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/sk/messages.json b/app/_locales/sk/messages.json index 47097b3d1..c4d6facaa 100644 --- a/app/_locales/sk/messages.json +++ b/app/_locales/sk/messages.json @@ -212,9 +212,6 @@ "copiedExclamation": { "message": "Zkopírováno!" }, - "copiedTransactionId": { - "message": "Kopírované ID transakcie" - }, "copyAddress": { "message": "Kopírovať adresu do schránky" }, diff --git a/app/_locales/sl/messages.json b/app/_locales/sl/messages.json index e5b7b9e44..6d6bef812 100644 --- a/app/_locales/sl/messages.json +++ b/app/_locales/sl/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Kopirano!" }, - "copiedTransactionId": { - "message": "ID transakcije skopirana" - }, "copyAddress": { "message": "Kopiraj naslov v odložišče" }, diff --git a/app/_locales/sr/messages.json b/app/_locales/sr/messages.json index 08eed9bf0..ce980b88e 100644 --- a/app/_locales/sr/messages.json +++ b/app/_locales/sr/messages.json @@ -215,9 +215,6 @@ "copiedExclamation": { "message": "Kopirano!" }, - "copiedTransactionId": { - "message": "Kopiran identifikator transakcije" - }, "copyAddress": { "message": "Kopirajte adresu u ostavu" }, diff --git a/app/_locales/sv/messages.json b/app/_locales/sv/messages.json index 1c07f7dfc..f3e651032 100644 --- a/app/_locales/sv/messages.json +++ b/app/_locales/sv/messages.json @@ -212,9 +212,6 @@ "copiedExclamation": { "message": "Kopierades!" }, - "copiedTransactionId": { - "message": "Kopierade transaktions-ID" - }, "copyAddress": { "message": "Kopiera adress till urklipp" }, diff --git a/app/_locales/sw/messages.json b/app/_locales/sw/messages.json index 05436654c..ec712cc65 100644 --- a/app/_locales/sw/messages.json +++ b/app/_locales/sw/messages.json @@ -212,9 +212,6 @@ "copiedExclamation": { "message": "Imenakiliwa!" }, - "copiedTransactionId": { - "message": "Imenakili Utambulisho wa Muamala" - }, "copyAddress": { "message": "Nakili anwani kwenye ubao wa kunakilia" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index cbd9275ce..498379837 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -344,9 +344,6 @@ "copiedExclamation": { "message": "Nakopya na!" }, - "copiedTransactionId": { - "message": "Nakopya ang ID ng Transaksyon" - }, "copyAddress": { "message": "Kopyahin ang address sa clipboard" }, @@ -1486,13 +1483,6 @@ "message": "Kinakailangan ito at nagbibigay ito ng pahintulot sa MetaMask na i-swap ang iyong $1.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Tinatayang bayarin sa network" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "Ang “$1” ay ang inaasahan naming magiging aktuwal na bayarin. Ang eksaktong halaga ay nakadepende sa mga kundisyon ng network.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Mga tinatayang bayarin sa network" }, @@ -1514,13 +1504,6 @@ "swapLowSlippageError": { "message": "Maaaring hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage." }, - "swapMaxNetworkFeeInfo": { - "message": "Aang “$1” ay ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Max na bayarin sa network" - }, "swapMaxSlippage": { "message": "Max na slippage" }, @@ -1531,9 +1514,6 @@ "message": "Hinahanap namin ang pinakasulit na presyo mula sa mga nangungunang pinagkukunan ng liquidity, sa lahat ng pagkakataon. Ang bayarin na $1% ay awtomatikong fina-factor sa bawat quote, na sumusuporta sa kasalukuyang development para mas mapahusay ang MetaMask.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNetworkFeeSummary": { - "message": "Kasama sa bayarin sa network ang gastusin sa pagproseso ng iyong pag-swap at pag-store nito sa $1 network. Hindi kumikita ang MetaMask mula sa bayaring ito." - }, "swapNewQuoteIn": { "message": "Mga bagong quote sa $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1551,10 +1531,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Kung magbabago ang presyo sa pagitan ng oras ng pag-order mo at sa oras na nakumpirma ito, tinatawag itong \"slippage\". Awtomatikong makakansela ang iyong Pag-swap kung lalampas ang slippage sa iyong setting na \"max slippage\"." }, - "swapQuoteIncludesRate": { - "message": "Kasama sa quote ang $1% bayarin sa MetaMask", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Quote $1 ng $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1562,9 +1538,6 @@ "swapQuoteSource": { "message": "Pinagkunan ng quote" }, - "swapQuotesAreRefreshed": { - "message": "Madalas na nire-refresh ang mga quote para maipakita ang mga kasalukuyang kundisyon ng market." - }, "swapQuotesExpiredErrorDescription": { "message": "Mag-request ng mga bagong quote para makuha ang mga pinakabagong rate." }, @@ -1619,9 +1592,6 @@ "swapSwapTo": { "message": "Palitan ng" }, - "swapThisWillAllowApprove": { - "message": "Mabibigyang-daan nito ang $1 na ma-swap." - }, "swapTokenAvailable": { "message": "Naidagdag na ang $1 sa iyong account.", "description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol." diff --git a/app/_locales/uk/messages.json b/app/_locales/uk/messages.json index 8968e2f2e..c18062e0b 100644 --- a/app/_locales/uk/messages.json +++ b/app/_locales/uk/messages.json @@ -218,9 +218,6 @@ "copiedExclamation": { "message": "Скопійовано!" }, - "copiedTransactionId": { - "message": "ID Скопійованої транзакції" - }, "copyAddress": { "message": "Копіювати адресу в буфер обміну" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 584c892c3..bb6bda25d 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -416,9 +416,6 @@ "copiedExclamation": { "message": "Đã sao chép!" }, - "copiedTransactionId": { - "message": "Đã sao chép mã giao dịch" - }, "copyAddress": { "message": "Sao chép địa chỉ vào khay nhớ tạm" }, @@ -1816,9 +1813,6 @@ "message": "Bạn cần $1 $2 nữa để hoàn tất giao dịch hoán đổi này", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "Có một báo giá tốt hơn" - }, "swapBuildQuotePlaceHolderText": { "message": "Không có token nào khớp với $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1845,13 +1839,6 @@ "message": "Thao tác này là bắt buộc và cấp cho MetaMask quyền hoán đổi $1 của bạn.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "Phí mạng ước tính" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1” là giá trị mà chúng tôi dự kiến sẽ là khoản phí thực sự. Số tiền chính xác phụ thuộc vào tình trạng mạng.", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "Phí mạng ước tính" }, @@ -1887,13 +1874,6 @@ "swapLowSlippageError": { "message": "Giao dịch có thể không thành công, mức trượt giá tối đa quá thấp." }, - "swapMaxNetworkFeeInfo": { - "message": "“$1” là mức chi tiêu cao nhất của bạn. Khi mạng không ổn định, đây có thể là số tiền lớn.", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "Phí mạng tối đa" - }, "swapMaxSlippage": { "message": "Mức trượt giá tối đa" }, @@ -1904,13 +1884,6 @@ "message": "Chúng tôi luôn tìm giá tốt nhất từ các nguồn thanh khoản hàng đầu. Phí $1% được tự động tính vào báo giá này.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 báo giá", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "Phí mạng dùng để chi trả chi phí xử lý giao dịch hoán đổi của bạn và lưu trữ giao dịch đó trên mạng $1. MetaMask không thu lợi từ khoản phí này." - }, "swapNewQuoteIn": { "message": "Báo giá mới sẽ có sau $1", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1945,10 +1918,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "Khi giá giữa thời điểm đặt lệnh và thời điểm xác nhận lệnh thay đổi, hiện tượng này được gọi là \"trượt giá\". Giao dịch hoán đổi của bạn sẽ tự động hủy nếu mức trượt giá vượt quá \"mức trượt giá cho phép\" đã đặt." }, - "swapQuoteIncludesRate": { - "message": "Báo giá có bao gồm khoản phí $1% cho MetaMask", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "Báo giá $1/$2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1956,9 +1925,6 @@ "swapQuoteSource": { "message": "Nguồn báo giá" }, - "swapQuotesAreRefreshed": { - "message": "Báo giá được làm mới thường xuyên để thể hiện tình trạng hiện tại của thị trường." - }, "swapQuotesExpiredErrorDescription": { "message": "Vui lòng yêu cầu báo giá mới để biết các mức tỷ lệ mới nhất." }, @@ -2019,9 +1985,6 @@ "swapSwapTo": { "message": "Hoán đổi sang" }, - "swapThisWillAllowApprove": { - "message": "Thao tác này sẽ cho phép hoán đổi $1." - }, "swapToConfirmWithHwWallet": { "message": "để xác nhận ví cứng của bạn" }, @@ -2060,9 +2023,6 @@ "swapUnknown": { "message": "Không xác định" }, - "swapUsingBestQuote": { - "message": "Sử dụng báo giá tốt nhất" - }, "swapVerifyTokenExplanation": { "message": "Nhiều token có thể dùng cùng một tên và ký hiệu. Hãy kiểm tra $1 để xác minh xem đây có phải là token bạn đang tìm kiếm không.", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 8555b89a4..539a4c29d 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1,4 +1,46 @@ { + "QRHardwareInvalidTransactionTitle": { + "message": "非法交易" + }, + "QRHardwareMismatchedSignId": { + "message": "扫描的签名二维码不属于当前交易,请检查交易详情后重试。" + }, + "QRHardwarePubkeyAccountOutOfRange": { + "message": "暂无更多账户,若想切换到其他账户,请在硬件钱包中选择想要的账户重新同步。" + }, + "QRHardwareScanInstructions": { + "message": "为了保护您的隐私,屏幕是模糊的,但不影响对二维码的读取。" + }, + "QRHardwareSignRequestCancel": { + "message": "拒绝该交易" + }, + "QRHardwareSignRequestDescription": { + "message": "硬件钱包扫描上方二维码完成签名后,点击“获取签名”按钮扫描已签名的二维码" + }, + "QRHardwareSignRequestGetSignature": { + "message": "获取签名" + }, + "QRHardwareSignRequestSubtitle": { + "message": "用硬件钱包扫描二维码" + }, + "QRHardwareSignRequestTitle": { + "message": "获取签名" + }, + "QRHardwareUnknownQRCodeTitle": { + "message": "非法二维码" + }, + "QRHardwareUnknownWalletQRCode": { + "message": "请扫描硬件钱包的同步二维码。" + }, + "QRHardwareWalletImporterTitle": { + "message": "扫描二维码" + }, + "QRHardwareWalletSteps1Description": { + "message": "该类硬件钱包通过二维码实现通讯交互,做到完全脱网。官方支持的钱包有:" + }, + "QRHardwareWalletSteps2Description": { + "message": "AirGap Vault & Ngrave (即将上线)" + }, "about": { "message": "关于" }, @@ -353,9 +395,6 @@ "copiedExclamation": { "message": "已复制" }, - "copiedTransactionId": { - "message": "交易 ID 复制成功" - }, "copyAddress": { "message": "复制地址到剪贴板" }, @@ -835,6 +874,12 @@ "message": "JSON 文件", "description": "format for importing an account" }, + "keystone": { + "message": "铠石钱包" + }, + "keystoneTutorial": { + "message": " (使用教程)" + }, "knownAddressRecipient": { "message": "已知接收方地址。" }, @@ -1472,9 +1517,6 @@ "message": "您还需 $1 $2 来完成这笔兑换", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, - "swapBetterQuoteAvailable": { - "message": "有一个可用的更优报价" - }, "swapBuildQuotePlaceHolderText": { "message": "没有匹配的代币符合 $1", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" @@ -1492,13 +1534,6 @@ "message": "这是必须的,并且允许 MetaMask 兑换您的 $1。", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, - "swapEstimatedNetworkFee": { - "message": "预计网络手续费" - }, - "swapEstimatedNetworkFeeSummary": { - "message": "“$1”是我们预计的实际产生费用。具体数额视网络情况而定。", - "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" - }, "swapEstimatedNetworkFees": { "message": "预计网络手续费" }, @@ -1520,13 +1555,6 @@ "swapLowSlippageError": { "message": "交易可能失败,最大滑点过低。" }, - "swapMaxNetworkFeeInfo": { - "message": "“$1”是您最多所话费的数量,当网络不稳定时,这可能是一个大的数额。", - "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" - }, - "swapMaxNetworkFees": { - "message": "最大网络手续费" - }, "swapMaxSlippage": { "message": "最大滑点" }, @@ -1537,13 +1565,6 @@ "message": "我们每次都能从顶级流动性资源中找到最好的价格。每次报价都会自动收取1%的手续费用,以支持 MetaMask 的持续发展,使其更加完善。", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, - "swapNQuotes": { - "message": "$1 个报价", - "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" - }, - "swapNetworkFeeSummary": { - "message": "网络手续费包括处理您的兑换和在以太坊($1)网络上存储的成本。MetaMask 不从这笔费用中获利。" - }, "swapNewQuoteIn": { "message": "$1 后更新报价", "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" @@ -1569,10 +1590,6 @@ "swapQuoteDetailsSlippageInfo": { "message": "如果在您下订单和确认订单之间的价格发生了变化,这就叫做\"滑点\"。如果滑点超过您的\"最大滑点\"设置,您的兑换将自动取消。" }, - "swapQuoteIncludesRate": { - "message": "报价包含 $1% MetaMask 手续费", - "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." - }, "swapQuoteNofN": { "message": "报价 $1 / $2", "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." @@ -1580,9 +1597,6 @@ "swapQuoteSource": { "message": "报价来源" }, - "swapQuotesAreRefreshed": { - "message": "报价会经常刷新,以反映当前的市场状况。" - }, "swapQuotesExpiredErrorDescription": { "message": "请请求新的报价,以获得最新的价格。" }, @@ -1637,9 +1651,6 @@ "swapSwapTo": { "message": "兑换到" }, - "swapThisWillAllowApprove": { - "message": "这样将允许 $1 用于兑换。" - }, "swapTokenAvailable": { "message": "您的 $1 已添加到您的账户。", "description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol." @@ -1654,9 +1665,6 @@ "swapUnknown": { "message": "未知的" }, - "swapUsingBestQuote": { - "message": "使用最好的报价" - }, "swapVerifyTokenExplanation": { "message": "多个代币可以使用相同的名称和符号。检查 $1(以太坊浏览器)以确认这是您正在寻找的代币。", "description": "This appears in a tooltip next to the verifyThisTokenOn message. It gives the user more information about why they should check the token on a block explorer. $1 will be the name or url of the block explorer, which will be the translation of 'etherscan' or a block explorer url specified for a custom network." diff --git a/app/_locales/zh_TW/messages.json b/app/_locales/zh_TW/messages.json index 91a527f48..966e0d924 100644 --- a/app/_locales/zh_TW/messages.json +++ b/app/_locales/zh_TW/messages.json @@ -227,9 +227,6 @@ "copiedExclamation": { "message": "已複製!" }, - "copiedTransactionId": { - "message": "已複製的交易 ID" - }, "copyAddress": { "message": "複製到剪貼簿" }, diff --git a/app/images/high-arrow.svg b/app/images/high-arrow.svg new file mode 100644 index 000000000..76449fe0d --- /dev/null +++ b/app/images/high-arrow.svg @@ -0,0 +1 @@ + diff --git a/app/images/icons/collapse.svg b/app/images/icons/collapse.svg new file mode 100644 index 000000000..74d6207c9 --- /dev/null +++ b/app/images/icons/collapse.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/icons/expand.svg b/app/images/icons/expand.svg new file mode 100644 index 000000000..6a6146840 --- /dev/null +++ b/app/images/icons/expand.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/low-arrow.svg b/app/images/low-arrow.svg new file mode 100644 index 000000000..da51d636f --- /dev/null +++ b/app/images/low-arrow.svg @@ -0,0 +1 @@ + diff --git a/app/images/qrcode-wallet-demo.svg b/app/images/qrcode-wallet-demo.svg new file mode 100644 index 000000000..61ed69eee --- /dev/null +++ b/app/images/qrcode-wallet-demo.svg @@ -0,0 +1,56 @@ + + + png-chahua + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/images/qrcode-wallet-logo.svg b/app/images/qrcode-wallet-logo.svg new file mode 100644 index 000000000..a88a7635e --- /dev/null +++ b/app/images/qrcode-wallet-logo.svg @@ -0,0 +1,11 @@ + + + qr-logo的副本2 + + + + + + + + \ No newline at end of file diff --git a/app/images/txinsights.png b/app/images/txinsights.png new file mode 100644 index 000000000..bbe848289 Binary files /dev/null and b/app/images/txinsights.png differ diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index f68579bad..9bea8f193 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -16,6 +16,7 @@ export default class AppStateController extends EventEmitter { onInactiveTimeout, showUnlockRequest, preferencesStore, + qrHardwareStore, } = opts; super(); @@ -31,7 +32,9 @@ export default class AppStateController extends EventEmitter { recoveryPhraseReminderHasBeenShown: false, recoveryPhraseReminderLastShown: new Date().getTime(), showTestnetMessageInDropdown: true, + trezorModel: null, ...initState, + qrHardware: {}, }); this.timer = null; @@ -48,6 +51,10 @@ export default class AppStateController extends EventEmitter { } }); + qrHardwareStore.subscribe((state) => { + this.store.updateState({ qrHardware: state }); + }); + const { preferences } = preferencesStore.getState(); this._setInactiveTimeout(preferences.autoLockTimeLimit); } @@ -237,4 +244,12 @@ export default class AppStateController extends EventEmitter { setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) { this.store.updateState({ showTestnetMessageInDropdown }); } + + /** + * Sets a property indicating the model of the user's Trezor hardware wallet + * @returns {void} + */ + setTrezorModel(trezorModel) { + this.store.updateState({ trezorModel }); + } } diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index a53cf8993..738432e73 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -37,6 +37,8 @@ export default class PreferencesController { // set to true means the dynamic list from the API is being used // set to false will be using the static list from contract-metadata useTokenDetection: false, + useCollectibleDetection: false, + openSeaEnabled: false, advancedGasFee: null, // WARNING: Do not use feature flags for security-sensitive things. @@ -130,6 +132,35 @@ export default class PreferencesController { this.store.updateState({ useTokenDetection: val }); } + /** + * Setter for the `useCollectibleDetection` property + * + * @param {boolean} val - Whether or not the user prefers to autodetect collectibles. + * + */ + setUseCollectibleDetection(val) { + const { openSeaEnabled } = this.store.getState(); + if (val && !openSeaEnabled) { + throw new Error( + 'useCollectibleDetection cannot be enabled if openSeaEnabled is false', + ); + } + this.store.updateState({ useCollectibleDetection: val }); + } + + /** + * Setter for the `openSeaEnabled` property + * + * @param {boolean} val - Whether or not the user prefers to use the OpenSea API for collectibles data. + * + */ + setOpenSeaEnabled(val) { + this.store.updateState({ openSeaEnabled: val }); + if (!val) { + this.store.updateState({ useCollectibleDetection: false }); + } + } + /** * Setter for the `advancedGasFee` property * diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index bce64e1a9..3afaba4ea 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -267,6 +267,42 @@ describe('preferences controller', function () { }); }); + describe('setUseCollectibleDetection', function () { + it('should default to false', function () { + const state = preferencesController.store.getState(); + assert.equal(state.useCollectibleDetection, false); + }); + + it('should set the useCollectibleDetection property in state', function () { + assert.equal( + preferencesController.store.getState().useCollectibleDetection, + false, + ); + preferencesController.setOpenSeaEnabled(true); + preferencesController.setUseCollectibleDetection(true); + assert.equal( + preferencesController.store.getState().useCollectibleDetection, + true, + ); + }); + }); + + describe('setOpenSeaEnabled', function () { + it('should default to false', function () { + const state = preferencesController.store.getState(); + assert.equal(state.openSeaEnabled, false); + }); + + it('should set the openSeaEnabled property in state', function () { + assert.equal( + preferencesController.store.getState().openSeaEnabled, + false, + ); + preferencesController.setOpenSeaEnabled(true); + assert.equal(preferencesController.store.getState().openSeaEnabled, true); + }); + }); + describe('setAdvancedGasFee', function () { it('should default to null', function () { const state = preferencesController.store.getState(); diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js index 397883b72..fc55b54a9 100644 --- a/app/scripts/controllers/swaps.js +++ b/app/scripts/controllers/swaps.js @@ -79,7 +79,6 @@ const initialState = { topAggId: null, routeState: '', swapsFeatureIsLive: true, - useNewSwapsApi: false, saveFetchedQuotes: false, swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, swapsQuotePrefetchingRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, @@ -123,9 +122,9 @@ export default class SwapsController { }); } - async fetchSwapsRefreshRates(chainId, useNewSwapsApi) { + async fetchSwapsRefreshRates(chainId) { const response = await fetchWithCache( - getBaseApi('network', chainId, useNewSwapsApi), + getBaseApi('network', chainId), { method: 'GET' }, { cacheRefreshTime: 600000 }, ); @@ -149,13 +148,9 @@ export default class SwapsController { // Sets the refresh rate for quote updates from the MetaSwap API async _setSwapsRefreshRates() { const chainId = this._getCurrentChainId(); - const { swapsState } = this.store.getState(); let swapsRefreshRates; try { - swapsRefreshRates = await this.fetchSwapsRefreshRates( - chainId, - swapsState.useNewSwapsApi, - ); + swapsRefreshRates = await this.fetchSwapsRefreshRates(chainId); } catch (e) { console.error('Request for swaps quote refresh time failed: ', e); } @@ -210,11 +205,7 @@ export default class SwapsController { ) { const { chainId } = fetchParamsMetaData; const { - swapsState: { - useNewSwapsApi, - quotesPollingLimitEnabled, - saveFetchedQuotes, - }, + swapsState: { quotesPollingLimitEnabled, saveFetchedQuotes }, } = this.store.getState(); if (!fetchParams) { @@ -242,7 +233,6 @@ export default class SwapsController { let [newQuotes] = await Promise.all([ this._fetchTradesInfo(fetchParams, { ...fetchParamsMetaData, - useNewSwapsApi, }), this._setSwapsRefreshRates(), ]); @@ -574,9 +564,9 @@ export default class SwapsController { setSwapsLiveness(swapsLiveness) { const { swapsState } = this.store.getState(); - const { swapsFeatureIsLive, useNewSwapsApi } = swapsLiveness; + const { swapsFeatureIsLive } = swapsLiveness; this.store.updateState({ - swapsState: { ...swapsState, swapsFeatureIsLive, useNewSwapsApi }, + swapsState: { ...swapsState, swapsFeatureIsLive }, }); } @@ -588,7 +578,6 @@ export default class SwapsController { tokens: swapsState.tokens, fetchParams: swapsState.fetchParams, swapsFeatureIsLive: swapsState.swapsFeatureIsLive, - useNewSwapsApi: swapsState.useNewSwapsApi, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuotePrefetchingRefreshTime: swapsState.swapsQuotePrefetchingRefreshTime, diff --git a/app/scripts/controllers/swaps.test.js b/app/scripts/controllers/swaps.test.js index d0f4556ab..3bc5b4e0d 100644 --- a/app/scripts/controllers/swaps.test.js +++ b/app/scripts/controllers/swaps.test.js @@ -131,7 +131,6 @@ const EMPTY_INIT_STATE = { topAggId: null, routeState: '', swapsFeatureIsLive: true, - useNewSwapsApi: false, swapsQuoteRefreshTime: 60000, swapsQuotePrefetchingRefreshTime: 60000, swapsUserFeeLevel: '', @@ -707,7 +706,6 @@ describe('SwapsController', function () { assert.strictEqual( fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS, { ...MOCK_FETCH_METADATA, - useNewSwapsApi: false, }), true, ); @@ -885,7 +883,6 @@ describe('SwapsController', function () { const tokens = 'test'; const fetchParams = 'test'; const swapsFeatureIsLive = false; - const useNewSwapsApi = false; const swapsQuoteRefreshTime = 0; const swapsQuotePrefetchingRefreshTime = 0; swapsController.store.updateState({ @@ -893,7 +890,6 @@ describe('SwapsController', function () { tokens, fetchParams, swapsFeatureIsLive, - useNewSwapsApi, swapsQuoteRefreshTime, swapsQuotePrefetchingRefreshTime, }, diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 0f1d15961..c2655a6d7 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -33,6 +33,7 @@ import { GAS_ESTIMATE_TYPES, GAS_RECOMMENDATIONS, CUSTOM_GAS_ESTIMATE, + PRIORITY_LEVELS, } from '../../../../shared/constants/gas'; import { decGWEIToHexWEI } from '../../../../shared/modules/conversion.utils'; import { @@ -438,7 +439,11 @@ export default class TransactionController extends EventEmitter { ) { txMeta.txParams.maxFeePerGas = txMeta.txParams.gasPrice; txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.gasPrice; - txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE; + if (process.env.EIP_1559_V2) { + txMeta.userFeeLevel = PRIORITY_LEVELS.DAPP_SUGGESTED; + } else { + txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE; + } } else { if ( (defaultMaxFeePerGas && @@ -448,6 +453,8 @@ export default class TransactionController extends EventEmitter { txMeta.origin === 'metamask' ) { txMeta.userFeeLevel = GAS_RECOMMENDATIONS.MEDIUM; + } else if (process.env.EIP_1559_V2) { + txMeta.userFeeLevel = PRIORITY_LEVELS.DAPP_SUGGESTED; } else { txMeta.userFeeLevel = CUSTOM_GAS_ESTIMATE; } diff --git a/app/scripts/lib/message-manager.js b/app/scripts/lib/message-manager.js index 0a83a2214..ba349da67 100644 --- a/app/scripts/lib/message-manager.js +++ b/app/scripts/lib/message-manager.js @@ -79,9 +79,9 @@ export default class MessageManager extends EventEmitter { * @returns {promise} after signature has been * */ - addUnapprovedMessageAsync(msgParams, req) { - return new Promise((resolve, reject) => { - const msgId = this.addUnapprovedMessage(msgParams, req); + async addUnapprovedMessageAsync(msgParams, req) { + const msgId = this.addUnapprovedMessage(msgParams, req); + return await new Promise((resolve, reject) => { // await finished this.once(`${msgId}:finished`, (data) => { switch (data.status) { @@ -93,6 +93,10 @@ export default class MessageManager extends EventEmitter { 'MetaMask Message Signature: User denied message signature.', ), ); + case 'errored': + return reject( + new Error(`MetaMask Message Signature: ${data.error}`), + ); default: return reject( new Error( @@ -233,6 +237,19 @@ export default class MessageManager extends EventEmitter { this._setMsgStatus(msgId, 'rejected'); } + /** + * Sets a Message status to 'errored' via a call to this._setMsgStatus. + * + * @param {number} msgId - The id of the Message to error + * + */ + errorMessage(msgId, error) { + const msg = this.getMsg(msgId); + msg.error = error; + this._updateMsg(msg); + this._setMsgStatus(msgId, 'errored'); + } + /** * Clears all unapproved messages from memory. */ @@ -304,7 +321,7 @@ export default class MessageManager extends EventEmitter { * @returns {string} A hex string conversion of the buffer data * */ -function normalizeMsgData(data) { +export function normalizeMsgData(data) { if (data.slice(0, 2) === '0x') { // data is already hex return data; diff --git a/app/scripts/lib/personal-message-manager.js b/app/scripts/lib/personal-message-manager.js index 907f3ee5a..e1f94a156 100644 --- a/app/scripts/lib/personal-message-manager.js +++ b/app/scripts/lib/personal-message-manager.js @@ -107,6 +107,9 @@ export default class PersonalMessageManager extends EventEmitter { ), ); return; + case 'errored': + reject(new Error(`MetaMask Message Signature: ${data.error}`)); + return; default: reject( new Error( @@ -254,6 +257,19 @@ export default class PersonalMessageManager extends EventEmitter { this._setMsgStatus(msgId, 'rejected'); } + /** + * Sets a Message status to 'errored' via a call to this._setMsgStatus. + * + * @param {number} msgId - The id of the Message to error + * + */ + errorMessage(msgId, error) { + const msg = this.getMsg(msgId); + msg.error = error; + this._updateMsg(msg); + this._setMsgStatus(msgId, 'errored'); + } + /** * Clears all unapproved messages from memory. */ diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e92640540..d1c17c240 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -15,8 +15,10 @@ import log from 'loglevel'; import TrezorKeyring from 'eth-trezor-keyring'; import LedgerBridgeKeyring from '@metamask/eth-ledger-bridge-keyring'; import LatticeKeyring from 'eth-lattice-keyring'; +import { MetaMaskKeyring as QRHardwareKeyring } from '@keystonehq/metamask-airgapped-keyring'; import EthQuery from 'eth-query'; import nanoid from 'nanoid'; +import { ethErrors } from 'eth-rpc-errors'; import { captureException } from '@sentry/browser'; import { AddressBookController, @@ -29,6 +31,9 @@ import { TokenListController, TokensController, TokenRatesController, + CollectiblesController, + AssetsContractController, + CollectibleDetectionController, } from '@metamask/controllers'; import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; import { @@ -37,7 +42,10 @@ import { SWAPS_CLIENT_ID, } from '../../shared/constants/swaps'; import { MAINNET_CHAIN_ID } from '../../shared/constants/network'; -import { KEYRING_TYPES } from '../../shared/constants/hardware-wallets'; +import { + DEVICE_NAMES, + KEYRING_TYPES, +} from '../../shared/constants/hardware-wallets'; import { UI_NOTIFICATIONS } from '../../shared/notifications'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { MILLISECOND } from '../../shared/constants/time'; @@ -61,7 +69,7 @@ import AlertController from './controllers/alert'; import OnboardingController from './controllers/onboarding'; import ThreeBoxController from './controllers/threebox'; import IncomingTransactionsController from './controllers/incoming-transactions'; -import MessageManager from './lib/message-manager'; +import MessageManager, { normalizeMsgData } from './lib/message-manager'; import DecryptMessageManager from './lib/decrypt-message-manager'; import EncryptionPublicKeyManager from './lib/encryption-public-key-manager'; import PersonalMessageManager from './lib/personal-message-manager'; @@ -175,6 +183,59 @@ export default class MetamaskController extends EventEmitter { state: initState.TokensController, }); + this.assetsContractController = new AssetsContractController({ + provider: this.provider, + }); + + this.collectiblesController = new CollectiblesController({ + onPreferencesStateChange: this.preferencesController.store.subscribe.bind( + this.preferencesController.store, + ), + onNetworkStateChange: this.networkController.store.subscribe.bind( + this.networkController.store, + ), + getAssetName: this.assetsContractController.getAssetName.bind( + this.assetsContractController, + ), + getAssetSymbol: this.assetsContractController.getAssetSymbol.bind( + this.assetsContractController, + ), + getCollectibleTokenURI: this.assetsContractController.getCollectibleTokenURI.bind( + this.assetsContractController, + ), + getOwnerOf: this.assetsContractController.getOwnerOf.bind( + this.assetsContractController, + ), + balanceOfERC1155Collectible: this.assetsContractController.balanceOfERC1155Collectible.bind( + this.assetsContractController, + ), + uriERC1155Collectible: this.assetsContractController.uriERC1155Collectible.bind( + this.assetsContractController, + ), + }); + + process.env.COLLECTIBLES_V1 && + (this.collectibleDetectionController = new CollectibleDetectionController( + { + onCollectiblesStateChange: (listener) => + this.collectiblesController.subscribe(listener), + onPreferencesStateChange: this.preferencesController.store.subscribe.bind( + this.preferencesController.store, + ), + onNetworkStateChange: this.networkController.store.subscribe.bind( + this.networkController.store, + ), + getOpenSeaApiKey: () => this.collectiblesController.openSeaApiKey, + getBalancesInSingleCall: this.assetsContractController.getBalancesInSingleCall.bind( + this.assetsContractController, + ), + addCollectible: this.collectiblesController.addCollectible.bind( + this.collectiblesController, + ), + getCollectiblesState: () => this.collectiblesController.state, + }, + )); + this.metaMetricsController = new MetaMetricsController({ segment, preferencesStore: this.preferencesController.store, @@ -231,6 +292,8 @@ export default class MetamaskController extends EventEmitter { }, }); + this.qrHardwareKeyring = new QRHardwareKeyring(); + this.appStateController = new AppStateController({ addUnlockListener: this.on.bind(this, 'unlock'), isUnlocked: this.isUnlocked.bind(this), @@ -238,6 +301,7 @@ export default class MetamaskController extends EventEmitter { onInactiveTimeout: () => this.setLocked(), showUnlockRequest: opts.showUserConfirmation, preferencesStore: this.preferencesController.store, + qrHardwareStore: this.qrHardwareKeyring.getMemStore(), }); const currencyRateMessenger = this.controllerMessenger.getRestricted({ @@ -377,6 +441,7 @@ export default class MetamaskController extends EventEmitter { TrezorKeyring, LedgerBridgeKeyring, LatticeKeyring, + QRHardwareKeyring, ]; this.keyringController = new KeyringController({ keyringTypes: additionalKeyrings, @@ -525,6 +590,7 @@ export default class MetamaskController extends EventEmitter { console.error(error); } }); + this.networkController.lookupNetwork(); this.messageManager = new MessageManager({ metricsEvent: this.metaMetricsController.trackEvent.bind( @@ -611,6 +677,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + CollectiblesController: this.collectiblesController, }); this.memStore = new ComposableObservableStore({ @@ -645,6 +712,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + CollectiblesController: this.collectiblesController, }, controllerMessenger: this.controllerMessenger, }); @@ -826,6 +894,7 @@ export default class MetamaskController extends EventEmitter { threeBoxController, txController, tokensController, + collectiblesController, } = this; return { @@ -843,6 +912,14 @@ export default class MetamaskController extends EventEmitter { this.preferencesController.setUseTokenDetection, this.preferencesController, ), + setUseCollectibleDetection: nodeify( + this.preferencesController.setUseCollectibleDetection, + this.preferencesController, + ), + setOpenSeaEnabled: nodeify( + this.preferencesController.setOpenSeaEnabled, + this.preferencesController, + ), setIpfsGateway: this.setIpfsGateway.bind(this), setParticipateInMetaMetrics: this.setParticipateInMetaMetrics.bind(this), setCurrentLocale: this.setCurrentLocale.bind(this), @@ -880,6 +957,28 @@ export default class MetamaskController extends EventEmitter { this, ), + // qr hardware devices + submitQRHardwareCryptoHDKey: nodeify( + this.qrHardwareKeyring.submitCryptoHDKey, + this.qrHardwareKeyring, + ), + submitQRHardwareCryptoAccount: nodeify( + this.qrHardwareKeyring.submitCryptoAccount, + this.qrHardwareKeyring, + ), + cancelSyncQRHardware: nodeify( + this.qrHardwareKeyring.cancelSync, + this.qrHardwareKeyring, + ), + submitQRHardwareSignature: nodeify( + this.qrHardwareKeyring.submitSignature, + this.qrHardwareKeyring, + ), + cancelQRHardwareSignRequest: nodeify( + this.qrHardwareKeyring.cancelSignRequest, + this.qrHardwareKeyring, + ), + // mobile fetchInfoToSync: nodeify(this.fetchInfoToSync, this), @@ -948,6 +1047,27 @@ export default class MetamaskController extends EventEmitter { preferencesController, ), + // CollectiblesController + addCollectible: nodeify( + collectiblesController.addCollectible, + collectiblesController, + ), + + addCollectibleVerifyOwnership: nodeify( + collectiblesController.addCollectibleVerifyOwnership, + collectiblesController, + ), + + removeAndIgnoreCollectible: nodeify( + collectiblesController.removeAndIgnoreCollectible, + collectiblesController, + ), + + removeCollectible: nodeify( + collectiblesController.removeCollectible, + collectiblesController, + ), + // AddressController setAddressBook: nodeify( this.addressBookController.set, @@ -1008,9 +1128,7 @@ export default class MetamaskController extends EventEmitter { ), createCancelTransaction: nodeify(this.createCancelTransaction, this), createSpeedUpTransaction: nodeify(this.createSpeedUpTransaction, this), - isNonceTaken: nodeify(txController.isNonceTaken, txController), estimateGas: nodeify(this.estimateGas, this), - getPendingNonce: nodeify(this.getPendingNonce, this), getNextNonce: nodeify(this.getNextNonce, this), addUnapprovedTransaction: nodeify( txController.addUnapprovedTransaction, @@ -1094,13 +1212,6 @@ export default class MetamaskController extends EventEmitter { permissionsController.approvePermissionsRequest, permissionsController, ), - clearPermissions: permissionsController.clearPermissions.bind( - permissionsController, - ), - getApprovedAccounts: nodeify( - permissionsController.getAccounts, - permissionsController, - ), rejectPermissionsRequest: nodeify( permissionsController.rejectPermissionsRequest, permissionsController, @@ -1255,6 +1366,14 @@ export default class MetamaskController extends EventEmitter { this.detectTokensController.detectNewTokens, this.detectTokensController, ), + + // DetectCollectibleController + detectCollectibles: process.env.COLLECTIBLES_V1 + ? nodeify( + this.collectibleDetectionController.detectCollectibles, + this.collectibleDetectionController, + ) + : null, }; } @@ -1574,13 +1693,16 @@ export default class MetamaskController extends EventEmitter { async getKeyringForDevice(deviceName, hdPath = null) { let keyringName = null; switch (deviceName) { - case 'trezor': + case DEVICE_NAMES.TREZOR: keyringName = TrezorKeyring.type; break; - case 'ledger': + case DEVICE_NAMES.LEDGER: keyringName = LedgerBridgeKeyring.type; break; - case 'lattice': + case DEVICE_NAMES.QR: + keyringName = QRHardwareKeyring.type; + break; + case DEVICE_NAMES.LATTICE: keyringName = LatticeKeyring.type; break; default: @@ -1597,9 +1719,14 @@ export default class MetamaskController extends EventEmitter { if (hdPath && keyring.setHdPath) { keyring.setHdPath(hdPath); } - if (deviceName === 'lattice') { + if (deviceName === DEVICE_NAMES.LATTICE) { keyring.appName = 'MetaMask'; } + if (deviceName === 'trezor') { + const model = keyring.getModel(); + this.appStateController.setTrezorModel(model); + } + keyring.network = this.networkController.getProviderConfig().type; return keyring; @@ -1667,6 +1794,18 @@ export default class MetamaskController extends EventEmitter { return true; } + /** + * get hardware account label + * + * @return string label + * */ + + getAccountLabel(name, index, hdPathDescription) { + return `${name[0].toUpperCase()}${name.slice(1)} ${ + parseInt(index, 10) + 1 + } ${hdPathDescription || ''}`.trim(); + } + /** * Imports an account from a Trezor or Ledger device. * @@ -1687,10 +1826,12 @@ export default class MetamaskController extends EventEmitter { this.preferencesController.setAddresses(newAccounts); newAccounts.forEach((address) => { if (!oldAccounts.includes(address)) { - const label = `${deviceName[0].toUpperCase()}${deviceName.slice(1)} ${ - parseInt(index, 10) + 1 - } ${hdPathDescription || ''}`.trim(); - // Set the account label to Trezor 1 / Ledger 1, etc + const label = this.getAccountLabel( + deviceName === DEVICE_NAMES.QR ? keyring.getName() : deviceName, + index, + hdPathDescription, + ); + // Set the account label to Trezor 1 / Ledger 1 / QR Hardware 1, etc this.preferencesController.setAccountLabel(address, label); // Select the account this.preferencesController.setSelectedAddress(address); @@ -1852,14 +1993,22 @@ export default class MetamaskController extends EventEmitter { * @param {Object} msgParams - The params passed to eth_sign. * @param {Function} cb - The callback function called with the signature. */ - newUnsignedMessage(msgParams, req) { - const promise = this.messageManager.addUnapprovedMessageAsync( - msgParams, - req, - ); - this.sendUpdate(); - this.opts.showUserConfirmation(); - return promise; + async newUnsignedMessage(msgParams, req) { + const data = normalizeMsgData(msgParams.data); + let promise; + // 64 hex + "0x" at the beginning + // This is needed because Ethereum's EcSign works only on 32 byte numbers + // For 67 length see: https://github.com/MetaMask/metamask-extension/pull/12679/files#r749479607 + if (data.length === 66 || data.length === 67) { + promise = this.messageManager.addUnapprovedMessageAsync(msgParams, req); + this.sendUpdate(); + this.opts.showUserConfirmation(); + } else { + throw ethErrors.rpc.invalidParams( + 'eth_sign requires 32 byte message hash', + ); + } + return await promise; } /** @@ -1868,24 +2017,23 @@ export default class MetamaskController extends EventEmitter { * @param {Object} msgParams - The params passed to eth_call. * @returns {Promise} Full state update. */ - signMessage(msgParams) { + async signMessage(msgParams) { log.info('MetaMaskController - signMessage'); const msgId = msgParams.metamaskId; - - // sets the status op the message to 'approved' - // and removes the metamaskId for signing - return this.messageManager - .approveMessage(msgParams) - .then((cleanMsgParams) => { - // signs the message - return this.keyringController.signMessage(cleanMsgParams); - }) - .then((rawSig) => { - // tells the listener that the message has been signed - // and can be returned to the dapp - this.messageManager.setMsgStatusSigned(msgId, rawSig); - return this.getState(); - }); + try { + // sets the status op the message to 'approved' + // and removes the metamaskId for signing + const cleanMsgParams = await this.messageManager.approveMessage( + msgParams, + ); + const rawSig = await this.keyringController.signMessage(cleanMsgParams); + this.messageManager.setMsgStatusSigned(msgId, rawSig); + return this.getState(); + } catch (error) { + log.info('MetaMaskController - eth_sign failed', error); + this.messageManager.errorMessage(msgId, error); + throw error; + } } /** @@ -1932,23 +2080,27 @@ export default class MetamaskController extends EventEmitter { * @param {Object} msgParams - The params of the message to sign & return to the Dapp. * @returns {Promise} A full state update. */ - signPersonalMessage(msgParams) { + async signPersonalMessage(msgParams) { log.info('MetaMaskController - signPersonalMessage'); const msgId = msgParams.metamaskId; // sets the status op the message to 'approved' // and removes the metamaskId for signing - return this.personalMessageManager - .approveMessage(msgParams) - .then((cleanMsgParams) => { - // signs the message - return this.keyringController.signPersonalMessage(cleanMsgParams); - }) - .then((rawSig) => { - // tells the listener that the message has been signed - // and can be returned to the dapp - this.personalMessageManager.setMsgStatusSigned(msgId, rawSig); - return this.getState(); - }); + try { + const cleanMsgParams = await this.personalMessageManager.approveMessage( + msgParams, + ); + const rawSig = await this.keyringController.signPersonalMessage( + cleanMsgParams, + ); + // tells the listener that the message has been signed + // and can be returned to the dapp + this.personalMessageManager.setMsgStatusSigned(msgId, rawSig); + return this.getState(); + } catch (error) { + log.info('MetaMaskController - eth_personalSign failed', error); + this.personalMessageManager.errorMessage(msgId, error); + throw error; + } } /** @@ -2095,6 +2247,12 @@ export default class MetamaskController extends EventEmitter { }); } + case KEYRING_TYPES.QR: { + return Promise.reject( + new Error('QR hardware does not support eth_getEncryptionPublicKey.'), + ); + } + default: { const promise = this.encryptionPublicKeyManager.addUnapprovedMessageAsync( msgParams, @@ -2227,17 +2385,10 @@ export default class MetamaskController extends EventEmitter { } /** - * Method to return a boolean if the keyring for the currently selected - * account is a ledger or trezor keyring. - * TODO: remove this method when Ledger and Trezor release their supported - * client utilities for EIP-1559 * @returns {boolean} true if the keyring type supports EIP-1559 */ - async getCurrentAccountEIP1559Compatibility(fromAddress) { - const address = - fromAddress || this.preferencesController.getSelectedAddress(); - const keyring = await this.keyringController.getKeyringForAccount(address); - return keyring.type !== KEYRING_TYPES.TREZOR; + async getCurrentAccountEIP1559Compatibility() { + return true; } //============================================================================= @@ -3187,6 +3338,12 @@ export default class MetamaskController extends EventEmitter { * Locks MetaMask */ setLocked() { + const [trezorKeyring] = this.keyringController.getKeyringsByType( + KEYRING_TYPES.TREZOR, + ); + if (trezorKeyring) { + trezorKeyring.dispose(); + } return this.keyringController.setLocked(); } } diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index f91176858..12b91fd8b 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -607,12 +607,12 @@ describe('MetaMaskController', function () { sinon.spy(metamaskController.preferencesController, 'setSelectedAddress'); sinon.spy(metamaskController.preferencesController, 'setAccountLabel'); await metamaskController - .connectHardware('trezor', 0, `m/44/0'/0'`) + .connectHardware('trezor', 0, `m/44'/1'/0'/0`) .catch(() => null); await metamaskController.unlockHardwareWalletAccount( accountToUnlock, 'trezor', - `m/44/0'/0'`, + `m/44'/1'/0'/0`, ); }); @@ -835,7 +835,8 @@ describe('MetaMaskController', function () { let msgParams, metamaskMsgs, messages, msgId; const address = '0xc42edfcc21ed14dda456aa0756c153f7985d8813'; - const data = '0x43727970746f6b697474696573'; + const data = + '0x0000000000000000000000000000000000000043727970746f6b697474696573'; beforeEach(async function () { sandbox.stub(metamaskController, 'getBalance'); @@ -885,6 +886,19 @@ describe('MetaMaskController', function () { assert.equal(messages[0].status, TRANSACTION_STATUSES.REJECTED); }); + it('checks message length', async function () { + msgParams = { + from: address, + data: '0xDEADBEEF', + }; + + try { + await metamaskController.newUnsignedMessage(msgParams); + } catch (error) { + assert.equal(error.message, 'eth_sign requires 32 byte message hash'); + } + }); + it('errors when signing a message', async function () { try { await metamaskController.signMessage(messages[0].msgParams); diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index c2f931b49..722d96625 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -118,13 +118,14 @@ export default class ExtensionPlatform { ) { let extensionURL = extension.runtime.getURL('home.html'); + if (route) { + extensionURL += `#${route}`; + } + if (queryString) { extensionURL += `?${queryString}`; } - if (route) { - extensionURL += `#${route}`; - } this.openTab({ url: extensionURL }); if ( getEnvironmentType() !== ENVIRONMENT_TYPE_BACKGROUND && diff --git a/development/build/transforms/remove-fenced-code.js b/development/build/transforms/remove-fenced-code.js index 200233fe8..632660ec2 100644 --- a/development/build/transforms/remove-fenced-code.js +++ b/development/build/transforms/remove-fenced-code.js @@ -41,11 +41,16 @@ class RemoveFencedCodeTransform extends Transform { // stream, immediately before the "end" event is emitted. // It applies the transform to the concatenated file contents. _flush(end) { - const [fileContent, didModify] = removeFencedCode( - this.filePath, - this.buildType, - Buffer.concat(this._fileBuffers).toString('utf8'), - ); + let fileContent, didModify; + try { + [fileContent, didModify] = removeFencedCode( + this.filePath, + this.buildType, + Buffer.concat(this._fileBuffers).toString('utf8'), + ); + } catch (error) { + return end(error); + } const pushAndEnd = () => { this.push(fileContent); @@ -53,12 +58,11 @@ class RemoveFencedCodeTransform extends Transform { }; if (this.shouldLintTransformedFiles && didModify) { - lintTransformedFile(fileContent, this.filePath) + return lintTransformedFile(fileContent, this.filePath) .then(pushAndEnd) .catch((error) => end(error)); - } else { - pushAndEnd(); } + return pushAndEnd(); } } diff --git a/development/build/transforms/remove-fenced-code.test.js b/development/build/transforms/remove-fenced-code.test.js index 5eebc1135..4397b4bf6 100644 --- a/development/build/transforms/remove-fenced-code.test.js +++ b/development/build/transforms/remove-fenced-code.test.js @@ -161,6 +161,28 @@ describe('build/transforms/remove-fenced-code', () => { }); }); + it('handles error during code fence removal or parsing', async () => { + const fileContent = getMinimalFencedCode().concat( + '///: END:ONLY_INCLUDE_IN', + ); + + const stream = createRemoveFencedCodeTransform('main')(mockJsFileName); + + await new Promise((resolve) => { + stream.on('error', (error) => { + expect(error.message).toStrictEqual( + expect.stringContaining( + 'A valid fence consists of two fence lines, but the file contains an uneven number, "3", of fence lines.', + ), + ); + expect(lintTransformedFileMock).toHaveBeenCalledTimes(0); + resolve(); + }); + + stream.end(fileContent); + }); + }); + it('handles transformed file lint failure', async () => { lintTransformedFileMock.mockImplementationOnce(() => Promise.reject(new Error('lint failure')), diff --git a/development/jest.config.js b/development/jest.config.js index 4eeaa048c..e4218c21f 100644 --- a/development/jest.config.js +++ b/development/jest.config.js @@ -2,7 +2,7 @@ module.exports = { displayName: '/development', collectCoverageFrom: ['/**/*.js'], coverageDirectory: '../jest-coverage/development/', - coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageReporters: ['html', 'text-summary'], coverageThreshold: { './development/build/transforms/**/*.js': { branches: 100, diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md index 8f66ba213..bf232a09c 100644 --- a/docs/trezor-emulator.md +++ b/docs/trezor-emulator.md @@ -13,7 +13,7 @@ Follow this instructions: https://github.com/trezor/trezor-core/blob/master/docs ## 3 - Restart the bridge with emulator support (Mac OSx instructions) -` +``` # stop any existing instance of trezord killall trezord @@ -22,4 +22,4 @@ Follow this instructions: https://github.com/trezor/trezor-core/blob/master/docs # launch the emulator ~/trezor-core/emu.sh -` +```` diff --git a/jest.config.js b/jest.config.js index 9f87eb4d9..186afbfe9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { collectCoverageFrom: ['/ui/**/*.js', '/shared/**/*.js'], coverageDirectory: './jest-coverage/main', coveragePathIgnorePatterns: ['.stories.js', '.snap'], - coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageReporters: ['html', 'text-summary'], coverageThreshold: { global: { branches: 35, @@ -23,4 +23,8 @@ module.exports = { '/app/scripts/platforms/*.test.js', ], testTimeout: 2500, + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + '^.+\\.mdx$': '@storybook/addon-docs/jest-transform-mdx', + }, }; diff --git a/jest.stories.config.js b/jest.stories.config.js new file mode 100644 index 000000000..fa66332a3 --- /dev/null +++ b/jest.stories.config.js @@ -0,0 +1,16 @@ +/* eslint-disable import/unambiguous */ +module.exports = { + coverageDirectory: './jest-coverage/storybook', + coverageReporters: ['html', 'text-summary'], + // TODO: enable resetMocks + // resetMocks: true, + restoreMocks: true, + setupFiles: ['/test/setup.js', '/test/env.js'], + setupFilesAfterEnv: ['/test/jest/setup.js'], + testMatch: ['/ui/**/*stories.test.js'], + testTimeout: 2500, + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + '^.+\\.mdx$': '@storybook/addon-docs/jest-transform-mdx', + }, +}; diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 3cfd712fa..7c0b7ce6d 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -412,6 +412,47 @@ "Intl.getCanonicalLocales": true } }, + "@keystonehq/base-eth-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/bc-ur-registry-eth": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/bc-ur-registry": { + "globals": { + "define": true + }, + "packages": { + "@ngraveio/bc-ur": true, + "bs58check": true, + "buffer": true + } + }, + "@keystonehq/bc-ur-registry-eth": { + "packages": { + "@keystonehq/bc-ur-registry": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/metamask-airgapped-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/base-eth-keyring": true, + "@keystonehq/bc-ur-registry-eth": true, + "@metamask/obs-store": true, + "buffer": true, + "events": true, + "rlp": true, + "uuid": true + } + }, "@material-ui/core": { "globals": { "Image": true, @@ -526,6 +567,7 @@ "ethjs-util": true, "events": true, "human-standard-collectible-abi": true, + "human-standard-multi-collectible-abi": true, "human-standard-token-abi": true, "immer": true, "isomorphic-fetch": true, @@ -618,6 +660,18 @@ "events": true } }, + "@ngraveio/bc-ur": { + "packages": { + "@apocentre/alias-sampling": true, + "assert": true, + "bignumber.js": true, + "buffer": true, + "cbor-sync": true, + "crc": true, + "jsbi": true, + "sha.js": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -737,6 +791,83 @@ "util": true } }, + "@truffle/abi-utils": { + "packages": { + "change-case": true, + "faker": true, + "fast-check": true + } + }, + "@truffle/code-utils": { + "packages": { + "buffer": true, + "cbor": true + } + }, + "@truffle/codec": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/compile-common": true, + "big.js": true, + "bn.js": true, + "buffer": true, + "cbor": true, + "debug": true, + "lodash.clonedeep": true, + "lodash.escaperegexp": true, + "lodash.partition": true, + "lodash.sum": true, + "semver": true, + "utf8": true, + "util": true, + "web3-utils": true + } + }, + "@truffle/compile-common": { + "packages": { + "@truffle/error": true, + "colors": true, + "path-browserify": true + } + }, + "@truffle/decoder": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/codec": true, + "@truffle/compile-common": true, + "@truffle/source-map-utils": true, + "bn.js": true, + "debug": true, + "web3-utils": true + } + }, + "@truffle/source-map-utils": { + "packages": { + "@truffle/code-utils": true, + "@truffle/codec": true, + "debug": true, + "json-pointer": true, + "node-interval-tree": true, + "web3-utils": true + } + }, + "@zxing/browser": { + "globals": { + "HTMLElement": true, + "HTMLImageElement": true, + "HTMLVideoElement": true, + "URL.createObjectURL": true, + "clearTimeout": true, + "console.error": true, + "console.warn": true, + "document": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "@zxing/library": true + } + }, "@zxing/library": { "globals": { "TextDecoder": true, @@ -922,6 +1053,11 @@ "buffer": true } }, + "big.js": { + "globals": { + "define": true + } + }, "bignumber.js": { "globals": { "crypto": true, @@ -974,6 +1110,9 @@ } }, "bn.js": { + "globals": { + "Buffer": true + }, "packages": { "browser-resolve": true } @@ -1098,6 +1237,62 @@ "buffer": true } }, + "call-bind": { + "packages": { + "function-bind": true, + "get-intrinsic": true + } + }, + "camel-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, + "cbor": { + "globals": { + "TextDecoder": true + }, + "packages": { + "bignumber.js": true, + "buffer": true, + "is-buffer": true, + "nofilter": true, + "stream-browserify": true, + "url": true, + "util": true + } + }, + "cbor-sync": { + "globals": { + "define": true + }, + "packages": { + "buffer": true + } + }, + "change-case": { + "packages": { + "camel-case": true, + "constant-case": true, + "dot-case": true, + "header-case": true, + "is-lower-case": true, + "is-upper-case": true, + "lower-case": true, + "lower-case-first": true, + "no-case": true, + "param-case": true, + "pascal-case": true, + "path-case": true, + "sentence-case": true, + "snake-case": true, + "swap-case": true, + "title-case": true, + "upper-case": true, + "upper-case-first": true + } + }, "cids": { "packages": { "buffer": true, @@ -1152,6 +1347,22 @@ "color-name": true } }, + "colors": { + "globals": { + "console.log": true + }, + "packages": { + "os-browserify": true, + "process": true, + "util": true + } + }, + "constant-case": { + "packages": { + "snake-case": true, + "upper-case": true + } + }, "cookiejar": { "globals": { "console.warn": true @@ -1159,7 +1370,7 @@ }, "copy-to-clipboard": { "globals": { - "clipboardData.setData": true, + "clipboardData": true, "console.error": true, "console.warn": true, "document.body.appendChild": true, @@ -1190,6 +1401,11 @@ "is-buffer": true } }, + "crc": { + "packages": { + "buffer": true + } + }, "crc-32": { "globals": { "DO_NOT_EXPORT_CRC": true, @@ -1406,6 +1622,11 @@ "@babel/runtime": true } }, + "dot-case": { + "packages": { + "no-case": true + } + }, "drbg.js": { "packages": { "buffer": true, @@ -1489,12 +1710,6 @@ "prr": true } }, - "es-abstract": { - "packages": { - "function-bind": true, - "has-symbols": true - } - }, "eth-block-tracker": { "globals": { "clearTimeout": true, @@ -1662,6 +1877,11 @@ "trezor-connect": true } }, + "ethereum-bloom-filters": { + "packages": { + "js-sha3": true + } + }, "ethereum-cryptography": { "packages": { "assert": true, @@ -1886,6 +2106,24 @@ "chrome": true } }, + "faker": { + "globals": { + "console.error": true, + "console.log": true, + "dbg": "write" + } + }, + "fast-check": { + "globals": { + "clearTimeout": true, + "console.log": true, + "setTimeout": true + }, + "packages": { + "buffer": true, + "pure-rand": true + } + }, "fast-json-patch": { "globals": { "addEventListener": true, @@ -1933,6 +2171,18 @@ "webkitRTCSessionDescription": true } }, + "get-intrinsic": { + "globals": { + "AggregateError": true, + "FinalizationRegistry": true, + "WeakRef": true + }, + "packages": { + "function-bind": true, + "has": true, + "has-symbols": true + } + }, "get-params": { "globals": { "GetParams": "write" @@ -1975,6 +2225,11 @@ "sparse-array": true } }, + "has": { + "packages": { + "function-bind": true + } + }, "has-binary2": { "globals": { "Blob": true, @@ -2006,12 +2261,19 @@ "hdkey": { "packages": { "assert": true, + "bs58check": true, "coinstring": true, "crypto-browserify": true, "safe-buffer": true, "secp256k1": true } }, + "header-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "heap": { "globals": { "define": true @@ -2495,11 +2757,22 @@ "multihashes": true } }, + "is-lower-case": { + "packages": { + "lower-case": true + } + }, "is-regex": { "packages": { + "call-bind": true, "has-symbols": true } }, + "is-upper-case": { + "packages": { + "upper-case": true + } + }, "iso-random-stream": { "globals": { "crypto": true, @@ -2558,6 +2831,16 @@ "console.warn": true } }, + "jsbi": { + "globals": { + "define": true + } + }, + "json-pointer": { + "packages": { + "foreach": true + } + }, "json-rpc-engine": { "packages": { "@metamask/safe-event-emitter": true, @@ -2645,6 +2928,7 @@ "packages": { "buffer": true, "inherits": true, + "readable-stream": true, "safe-buffer": true, "stream-browserify": true } @@ -3099,12 +3383,22 @@ "util": true } }, + "lower-case-first": { + "packages": { + "lower-case": true + } + }, "lru": { "packages": { "events": true, "inherits": true } }, + "lru-cache": { + "packages": { + "yallist": true + } + }, "ltgt": { "packages": { "is-buffer": true @@ -3274,6 +3568,11 @@ "crypto-browserify": true } }, + "no-case": { + "packages": { + "lower-case": true + } + }, "node-forge": { "globals": { "Blob": true, @@ -3298,6 +3597,11 @@ "timers-browserify": true } }, + "node-interval-tree": { + "packages": { + "shallowequal": true + } + }, "nodeify": { "globals": { "setTimeout": true @@ -3309,6 +3613,13 @@ "timers-browserify": true } }, + "nofilter": { + "packages": { + "buffer": true, + "stream-browserify": true, + "util": true + } + }, "nonce-tracker": { "packages": { "assert": true, @@ -3504,6 +3815,11 @@ "p-map": true } }, + "param-case": { + "packages": { + "no-case": true + } + }, "parse-asn1": { "packages": { "asn1.js": true, @@ -3513,11 +3829,22 @@ "pbkdf2": true } }, + "pascal-case": { + "packages": { + "camel-case": true, + "upper-case-first": true + } + }, "path-browserify": { "packages": { "process": true } }, + "path-case": { + "packages": { + "no-case": true + } + }, "path-to-regexp": { "packages": { "isarray": true @@ -3768,6 +4095,17 @@ "define": true } }, + "qrcode.react": { + "globals": { + "Path2D": true, + "devicePixelRatio": true + }, + "packages": { + "prop-types": true, + "qr.js": true, + "react": true + } + }, "rabin-wasm": { "globals": { "Blob": true, @@ -4074,8 +4412,7 @@ "console": true }, "packages": { - "@babel/runtime": true, - "symbol-observable": true + "@babel/runtime": true } }, "redux-devtools-core": { @@ -4114,8 +4451,8 @@ }, "regexp.prototype.flags": { "packages": { - "define-properties": true, - "es-abstract": true + "call-bind": true, + "define-properties": true } }, "relative-url": { @@ -4260,9 +4597,16 @@ "console": true }, "packages": { + "lru-cache": true, "process": true } }, + "sentence-case": { + "packages": { + "no-case": true, + "upper-case-first": true + } + }, "set-immediate-shim": { "globals": { "setTimeout.apply": true @@ -4307,6 +4651,11 @@ "readable-stream": true } }, + "snake-case": { + "packages": { + "no-case": true + } + }, "socket.io-client": { "globals": { "clearTimeout": true, @@ -4457,6 +4806,12 @@ "component-emitter": true } }, + "swap-case": { + "packages": { + "lower-case": true, + "upper-case": true + } + }, "textarea-caret": { "globals": { "document.body.appendChild": true, @@ -4503,6 +4858,12 @@ "console": true } }, + "title-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "toggle-selection": { "globals": { "document.activeElement": true, @@ -4597,6 +4958,11 @@ "buffer": true } }, + "upper-case-first": { + "packages": { + "upper-case": true + } + }, "url": { "packages": { "punycode": true, @@ -4711,6 +5077,21 @@ "uuid": true } }, + "web3-utils": { + "globals": { + "setTimeout": true + }, + "packages": { + "bn.js": true, + "eth-lib": true, + "ethereum-bloom-filters": true, + "ethjs-unit": true, + "is-buffer": true, + "number-to-bn": true, + "randombytes": true, + "utf8": true + } + }, "webrtcsupport": { "globals": { "AudioContext": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 3cfd712fa..7c0b7ce6d 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -412,6 +412,47 @@ "Intl.getCanonicalLocales": true } }, + "@keystonehq/base-eth-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/bc-ur-registry-eth": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/bc-ur-registry": { + "globals": { + "define": true + }, + "packages": { + "@ngraveio/bc-ur": true, + "bs58check": true, + "buffer": true + } + }, + "@keystonehq/bc-ur-registry-eth": { + "packages": { + "@keystonehq/bc-ur-registry": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/metamask-airgapped-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/base-eth-keyring": true, + "@keystonehq/bc-ur-registry-eth": true, + "@metamask/obs-store": true, + "buffer": true, + "events": true, + "rlp": true, + "uuid": true + } + }, "@material-ui/core": { "globals": { "Image": true, @@ -526,6 +567,7 @@ "ethjs-util": true, "events": true, "human-standard-collectible-abi": true, + "human-standard-multi-collectible-abi": true, "human-standard-token-abi": true, "immer": true, "isomorphic-fetch": true, @@ -618,6 +660,18 @@ "events": true } }, + "@ngraveio/bc-ur": { + "packages": { + "@apocentre/alias-sampling": true, + "assert": true, + "bignumber.js": true, + "buffer": true, + "cbor-sync": true, + "crc": true, + "jsbi": true, + "sha.js": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -737,6 +791,83 @@ "util": true } }, + "@truffle/abi-utils": { + "packages": { + "change-case": true, + "faker": true, + "fast-check": true + } + }, + "@truffle/code-utils": { + "packages": { + "buffer": true, + "cbor": true + } + }, + "@truffle/codec": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/compile-common": true, + "big.js": true, + "bn.js": true, + "buffer": true, + "cbor": true, + "debug": true, + "lodash.clonedeep": true, + "lodash.escaperegexp": true, + "lodash.partition": true, + "lodash.sum": true, + "semver": true, + "utf8": true, + "util": true, + "web3-utils": true + } + }, + "@truffle/compile-common": { + "packages": { + "@truffle/error": true, + "colors": true, + "path-browserify": true + } + }, + "@truffle/decoder": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/codec": true, + "@truffle/compile-common": true, + "@truffle/source-map-utils": true, + "bn.js": true, + "debug": true, + "web3-utils": true + } + }, + "@truffle/source-map-utils": { + "packages": { + "@truffle/code-utils": true, + "@truffle/codec": true, + "debug": true, + "json-pointer": true, + "node-interval-tree": true, + "web3-utils": true + } + }, + "@zxing/browser": { + "globals": { + "HTMLElement": true, + "HTMLImageElement": true, + "HTMLVideoElement": true, + "URL.createObjectURL": true, + "clearTimeout": true, + "console.error": true, + "console.warn": true, + "document": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "@zxing/library": true + } + }, "@zxing/library": { "globals": { "TextDecoder": true, @@ -922,6 +1053,11 @@ "buffer": true } }, + "big.js": { + "globals": { + "define": true + } + }, "bignumber.js": { "globals": { "crypto": true, @@ -974,6 +1110,9 @@ } }, "bn.js": { + "globals": { + "Buffer": true + }, "packages": { "browser-resolve": true } @@ -1098,6 +1237,62 @@ "buffer": true } }, + "call-bind": { + "packages": { + "function-bind": true, + "get-intrinsic": true + } + }, + "camel-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, + "cbor": { + "globals": { + "TextDecoder": true + }, + "packages": { + "bignumber.js": true, + "buffer": true, + "is-buffer": true, + "nofilter": true, + "stream-browserify": true, + "url": true, + "util": true + } + }, + "cbor-sync": { + "globals": { + "define": true + }, + "packages": { + "buffer": true + } + }, + "change-case": { + "packages": { + "camel-case": true, + "constant-case": true, + "dot-case": true, + "header-case": true, + "is-lower-case": true, + "is-upper-case": true, + "lower-case": true, + "lower-case-first": true, + "no-case": true, + "param-case": true, + "pascal-case": true, + "path-case": true, + "sentence-case": true, + "snake-case": true, + "swap-case": true, + "title-case": true, + "upper-case": true, + "upper-case-first": true + } + }, "cids": { "packages": { "buffer": true, @@ -1152,6 +1347,22 @@ "color-name": true } }, + "colors": { + "globals": { + "console.log": true + }, + "packages": { + "os-browserify": true, + "process": true, + "util": true + } + }, + "constant-case": { + "packages": { + "snake-case": true, + "upper-case": true + } + }, "cookiejar": { "globals": { "console.warn": true @@ -1159,7 +1370,7 @@ }, "copy-to-clipboard": { "globals": { - "clipboardData.setData": true, + "clipboardData": true, "console.error": true, "console.warn": true, "document.body.appendChild": true, @@ -1190,6 +1401,11 @@ "is-buffer": true } }, + "crc": { + "packages": { + "buffer": true + } + }, "crc-32": { "globals": { "DO_NOT_EXPORT_CRC": true, @@ -1406,6 +1622,11 @@ "@babel/runtime": true } }, + "dot-case": { + "packages": { + "no-case": true + } + }, "drbg.js": { "packages": { "buffer": true, @@ -1489,12 +1710,6 @@ "prr": true } }, - "es-abstract": { - "packages": { - "function-bind": true, - "has-symbols": true - } - }, "eth-block-tracker": { "globals": { "clearTimeout": true, @@ -1662,6 +1877,11 @@ "trezor-connect": true } }, + "ethereum-bloom-filters": { + "packages": { + "js-sha3": true + } + }, "ethereum-cryptography": { "packages": { "assert": true, @@ -1886,6 +2106,24 @@ "chrome": true } }, + "faker": { + "globals": { + "console.error": true, + "console.log": true, + "dbg": "write" + } + }, + "fast-check": { + "globals": { + "clearTimeout": true, + "console.log": true, + "setTimeout": true + }, + "packages": { + "buffer": true, + "pure-rand": true + } + }, "fast-json-patch": { "globals": { "addEventListener": true, @@ -1933,6 +2171,18 @@ "webkitRTCSessionDescription": true } }, + "get-intrinsic": { + "globals": { + "AggregateError": true, + "FinalizationRegistry": true, + "WeakRef": true + }, + "packages": { + "function-bind": true, + "has": true, + "has-symbols": true + } + }, "get-params": { "globals": { "GetParams": "write" @@ -1975,6 +2225,11 @@ "sparse-array": true } }, + "has": { + "packages": { + "function-bind": true + } + }, "has-binary2": { "globals": { "Blob": true, @@ -2006,12 +2261,19 @@ "hdkey": { "packages": { "assert": true, + "bs58check": true, "coinstring": true, "crypto-browserify": true, "safe-buffer": true, "secp256k1": true } }, + "header-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "heap": { "globals": { "define": true @@ -2495,11 +2757,22 @@ "multihashes": true } }, + "is-lower-case": { + "packages": { + "lower-case": true + } + }, "is-regex": { "packages": { + "call-bind": true, "has-symbols": true } }, + "is-upper-case": { + "packages": { + "upper-case": true + } + }, "iso-random-stream": { "globals": { "crypto": true, @@ -2558,6 +2831,16 @@ "console.warn": true } }, + "jsbi": { + "globals": { + "define": true + } + }, + "json-pointer": { + "packages": { + "foreach": true + } + }, "json-rpc-engine": { "packages": { "@metamask/safe-event-emitter": true, @@ -2645,6 +2928,7 @@ "packages": { "buffer": true, "inherits": true, + "readable-stream": true, "safe-buffer": true, "stream-browserify": true } @@ -3099,12 +3383,22 @@ "util": true } }, + "lower-case-first": { + "packages": { + "lower-case": true + } + }, "lru": { "packages": { "events": true, "inherits": true } }, + "lru-cache": { + "packages": { + "yallist": true + } + }, "ltgt": { "packages": { "is-buffer": true @@ -3274,6 +3568,11 @@ "crypto-browserify": true } }, + "no-case": { + "packages": { + "lower-case": true + } + }, "node-forge": { "globals": { "Blob": true, @@ -3298,6 +3597,11 @@ "timers-browserify": true } }, + "node-interval-tree": { + "packages": { + "shallowequal": true + } + }, "nodeify": { "globals": { "setTimeout": true @@ -3309,6 +3613,13 @@ "timers-browserify": true } }, + "nofilter": { + "packages": { + "buffer": true, + "stream-browserify": true, + "util": true + } + }, "nonce-tracker": { "packages": { "assert": true, @@ -3504,6 +3815,11 @@ "p-map": true } }, + "param-case": { + "packages": { + "no-case": true + } + }, "parse-asn1": { "packages": { "asn1.js": true, @@ -3513,11 +3829,22 @@ "pbkdf2": true } }, + "pascal-case": { + "packages": { + "camel-case": true, + "upper-case-first": true + } + }, "path-browserify": { "packages": { "process": true } }, + "path-case": { + "packages": { + "no-case": true + } + }, "path-to-regexp": { "packages": { "isarray": true @@ -3768,6 +4095,17 @@ "define": true } }, + "qrcode.react": { + "globals": { + "Path2D": true, + "devicePixelRatio": true + }, + "packages": { + "prop-types": true, + "qr.js": true, + "react": true + } + }, "rabin-wasm": { "globals": { "Blob": true, @@ -4074,8 +4412,7 @@ "console": true }, "packages": { - "@babel/runtime": true, - "symbol-observable": true + "@babel/runtime": true } }, "redux-devtools-core": { @@ -4114,8 +4451,8 @@ }, "regexp.prototype.flags": { "packages": { - "define-properties": true, - "es-abstract": true + "call-bind": true, + "define-properties": true } }, "relative-url": { @@ -4260,9 +4597,16 @@ "console": true }, "packages": { + "lru-cache": true, "process": true } }, + "sentence-case": { + "packages": { + "no-case": true, + "upper-case-first": true + } + }, "set-immediate-shim": { "globals": { "setTimeout.apply": true @@ -4307,6 +4651,11 @@ "readable-stream": true } }, + "snake-case": { + "packages": { + "no-case": true + } + }, "socket.io-client": { "globals": { "clearTimeout": true, @@ -4457,6 +4806,12 @@ "component-emitter": true } }, + "swap-case": { + "packages": { + "lower-case": true, + "upper-case": true + } + }, "textarea-caret": { "globals": { "document.body.appendChild": true, @@ -4503,6 +4858,12 @@ "console": true } }, + "title-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "toggle-selection": { "globals": { "document.activeElement": true, @@ -4597,6 +4958,11 @@ "buffer": true } }, + "upper-case-first": { + "packages": { + "upper-case": true + } + }, "url": { "packages": { "punycode": true, @@ -4711,6 +5077,21 @@ "uuid": true } }, + "web3-utils": { + "globals": { + "setTimeout": true + }, + "packages": { + "bn.js": true, + "eth-lib": true, + "ethereum-bloom-filters": true, + "ethjs-unit": true, + "is-buffer": true, + "number-to-bn": true, + "randombytes": true, + "utf8": true + } + }, "webrtcsupport": { "globals": { "AudioContext": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 3cfd712fa..7c0b7ce6d 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -412,6 +412,47 @@ "Intl.getCanonicalLocales": true } }, + "@keystonehq/base-eth-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/bc-ur-registry-eth": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/bc-ur-registry": { + "globals": { + "define": true + }, + "packages": { + "@ngraveio/bc-ur": true, + "bs58check": true, + "buffer": true + } + }, + "@keystonehq/bc-ur-registry-eth": { + "packages": { + "@keystonehq/bc-ur-registry": true, + "buffer": true, + "ethereumjs-util": true, + "hdkey": true, + "uuid": true + } + }, + "@keystonehq/metamask-airgapped-keyring": { + "packages": { + "@ethereumjs/tx": true, + "@keystonehq/base-eth-keyring": true, + "@keystonehq/bc-ur-registry-eth": true, + "@metamask/obs-store": true, + "buffer": true, + "events": true, + "rlp": true, + "uuid": true + } + }, "@material-ui/core": { "globals": { "Image": true, @@ -526,6 +567,7 @@ "ethjs-util": true, "events": true, "human-standard-collectible-abi": true, + "human-standard-multi-collectible-abi": true, "human-standard-token-abi": true, "immer": true, "isomorphic-fetch": true, @@ -618,6 +660,18 @@ "events": true } }, + "@ngraveio/bc-ur": { + "packages": { + "@apocentre/alias-sampling": true, + "assert": true, + "bignumber.js": true, + "buffer": true, + "cbor-sync": true, + "crc": true, + "jsbi": true, + "sha.js": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -737,6 +791,83 @@ "util": true } }, + "@truffle/abi-utils": { + "packages": { + "change-case": true, + "faker": true, + "fast-check": true + } + }, + "@truffle/code-utils": { + "packages": { + "buffer": true, + "cbor": true + } + }, + "@truffle/codec": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/compile-common": true, + "big.js": true, + "bn.js": true, + "buffer": true, + "cbor": true, + "debug": true, + "lodash.clonedeep": true, + "lodash.escaperegexp": true, + "lodash.partition": true, + "lodash.sum": true, + "semver": true, + "utf8": true, + "util": true, + "web3-utils": true + } + }, + "@truffle/compile-common": { + "packages": { + "@truffle/error": true, + "colors": true, + "path-browserify": true + } + }, + "@truffle/decoder": { + "packages": { + "@truffle/abi-utils": true, + "@truffle/codec": true, + "@truffle/compile-common": true, + "@truffle/source-map-utils": true, + "bn.js": true, + "debug": true, + "web3-utils": true + } + }, + "@truffle/source-map-utils": { + "packages": { + "@truffle/code-utils": true, + "@truffle/codec": true, + "debug": true, + "json-pointer": true, + "node-interval-tree": true, + "web3-utils": true + } + }, + "@zxing/browser": { + "globals": { + "HTMLElement": true, + "HTMLImageElement": true, + "HTMLVideoElement": true, + "URL.createObjectURL": true, + "clearTimeout": true, + "console.error": true, + "console.warn": true, + "document": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "@zxing/library": true + } + }, "@zxing/library": { "globals": { "TextDecoder": true, @@ -922,6 +1053,11 @@ "buffer": true } }, + "big.js": { + "globals": { + "define": true + } + }, "bignumber.js": { "globals": { "crypto": true, @@ -974,6 +1110,9 @@ } }, "bn.js": { + "globals": { + "Buffer": true + }, "packages": { "browser-resolve": true } @@ -1098,6 +1237,62 @@ "buffer": true } }, + "call-bind": { + "packages": { + "function-bind": true, + "get-intrinsic": true + } + }, + "camel-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, + "cbor": { + "globals": { + "TextDecoder": true + }, + "packages": { + "bignumber.js": true, + "buffer": true, + "is-buffer": true, + "nofilter": true, + "stream-browserify": true, + "url": true, + "util": true + } + }, + "cbor-sync": { + "globals": { + "define": true + }, + "packages": { + "buffer": true + } + }, + "change-case": { + "packages": { + "camel-case": true, + "constant-case": true, + "dot-case": true, + "header-case": true, + "is-lower-case": true, + "is-upper-case": true, + "lower-case": true, + "lower-case-first": true, + "no-case": true, + "param-case": true, + "pascal-case": true, + "path-case": true, + "sentence-case": true, + "snake-case": true, + "swap-case": true, + "title-case": true, + "upper-case": true, + "upper-case-first": true + } + }, "cids": { "packages": { "buffer": true, @@ -1152,6 +1347,22 @@ "color-name": true } }, + "colors": { + "globals": { + "console.log": true + }, + "packages": { + "os-browserify": true, + "process": true, + "util": true + } + }, + "constant-case": { + "packages": { + "snake-case": true, + "upper-case": true + } + }, "cookiejar": { "globals": { "console.warn": true @@ -1159,7 +1370,7 @@ }, "copy-to-clipboard": { "globals": { - "clipboardData.setData": true, + "clipboardData": true, "console.error": true, "console.warn": true, "document.body.appendChild": true, @@ -1190,6 +1401,11 @@ "is-buffer": true } }, + "crc": { + "packages": { + "buffer": true + } + }, "crc-32": { "globals": { "DO_NOT_EXPORT_CRC": true, @@ -1406,6 +1622,11 @@ "@babel/runtime": true } }, + "dot-case": { + "packages": { + "no-case": true + } + }, "drbg.js": { "packages": { "buffer": true, @@ -1489,12 +1710,6 @@ "prr": true } }, - "es-abstract": { - "packages": { - "function-bind": true, - "has-symbols": true - } - }, "eth-block-tracker": { "globals": { "clearTimeout": true, @@ -1662,6 +1877,11 @@ "trezor-connect": true } }, + "ethereum-bloom-filters": { + "packages": { + "js-sha3": true + } + }, "ethereum-cryptography": { "packages": { "assert": true, @@ -1886,6 +2106,24 @@ "chrome": true } }, + "faker": { + "globals": { + "console.error": true, + "console.log": true, + "dbg": "write" + } + }, + "fast-check": { + "globals": { + "clearTimeout": true, + "console.log": true, + "setTimeout": true + }, + "packages": { + "buffer": true, + "pure-rand": true + } + }, "fast-json-patch": { "globals": { "addEventListener": true, @@ -1933,6 +2171,18 @@ "webkitRTCSessionDescription": true } }, + "get-intrinsic": { + "globals": { + "AggregateError": true, + "FinalizationRegistry": true, + "WeakRef": true + }, + "packages": { + "function-bind": true, + "has": true, + "has-symbols": true + } + }, "get-params": { "globals": { "GetParams": "write" @@ -1975,6 +2225,11 @@ "sparse-array": true } }, + "has": { + "packages": { + "function-bind": true + } + }, "has-binary2": { "globals": { "Blob": true, @@ -2006,12 +2261,19 @@ "hdkey": { "packages": { "assert": true, + "bs58check": true, "coinstring": true, "crypto-browserify": true, "safe-buffer": true, "secp256k1": true } }, + "header-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "heap": { "globals": { "define": true @@ -2495,11 +2757,22 @@ "multihashes": true } }, + "is-lower-case": { + "packages": { + "lower-case": true + } + }, "is-regex": { "packages": { + "call-bind": true, "has-symbols": true } }, + "is-upper-case": { + "packages": { + "upper-case": true + } + }, "iso-random-stream": { "globals": { "crypto": true, @@ -2558,6 +2831,16 @@ "console.warn": true } }, + "jsbi": { + "globals": { + "define": true + } + }, + "json-pointer": { + "packages": { + "foreach": true + } + }, "json-rpc-engine": { "packages": { "@metamask/safe-event-emitter": true, @@ -2645,6 +2928,7 @@ "packages": { "buffer": true, "inherits": true, + "readable-stream": true, "safe-buffer": true, "stream-browserify": true } @@ -3099,12 +3383,22 @@ "util": true } }, + "lower-case-first": { + "packages": { + "lower-case": true + } + }, "lru": { "packages": { "events": true, "inherits": true } }, + "lru-cache": { + "packages": { + "yallist": true + } + }, "ltgt": { "packages": { "is-buffer": true @@ -3274,6 +3568,11 @@ "crypto-browserify": true } }, + "no-case": { + "packages": { + "lower-case": true + } + }, "node-forge": { "globals": { "Blob": true, @@ -3298,6 +3597,11 @@ "timers-browserify": true } }, + "node-interval-tree": { + "packages": { + "shallowequal": true + } + }, "nodeify": { "globals": { "setTimeout": true @@ -3309,6 +3613,13 @@ "timers-browserify": true } }, + "nofilter": { + "packages": { + "buffer": true, + "stream-browserify": true, + "util": true + } + }, "nonce-tracker": { "packages": { "assert": true, @@ -3504,6 +3815,11 @@ "p-map": true } }, + "param-case": { + "packages": { + "no-case": true + } + }, "parse-asn1": { "packages": { "asn1.js": true, @@ -3513,11 +3829,22 @@ "pbkdf2": true } }, + "pascal-case": { + "packages": { + "camel-case": true, + "upper-case-first": true + } + }, "path-browserify": { "packages": { "process": true } }, + "path-case": { + "packages": { + "no-case": true + } + }, "path-to-regexp": { "packages": { "isarray": true @@ -3768,6 +4095,17 @@ "define": true } }, + "qrcode.react": { + "globals": { + "Path2D": true, + "devicePixelRatio": true + }, + "packages": { + "prop-types": true, + "qr.js": true, + "react": true + } + }, "rabin-wasm": { "globals": { "Blob": true, @@ -4074,8 +4412,7 @@ "console": true }, "packages": { - "@babel/runtime": true, - "symbol-observable": true + "@babel/runtime": true } }, "redux-devtools-core": { @@ -4114,8 +4451,8 @@ }, "regexp.prototype.flags": { "packages": { - "define-properties": true, - "es-abstract": true + "call-bind": true, + "define-properties": true } }, "relative-url": { @@ -4260,9 +4597,16 @@ "console": true }, "packages": { + "lru-cache": true, "process": true } }, + "sentence-case": { + "packages": { + "no-case": true, + "upper-case-first": true + } + }, "set-immediate-shim": { "globals": { "setTimeout.apply": true @@ -4307,6 +4651,11 @@ "readable-stream": true } }, + "snake-case": { + "packages": { + "no-case": true + } + }, "socket.io-client": { "globals": { "clearTimeout": true, @@ -4457,6 +4806,12 @@ "component-emitter": true } }, + "swap-case": { + "packages": { + "lower-case": true, + "upper-case": true + } + }, "textarea-caret": { "globals": { "document.body.appendChild": true, @@ -4503,6 +4858,12 @@ "console": true } }, + "title-case": { + "packages": { + "no-case": true, + "upper-case": true + } + }, "toggle-selection": { "globals": { "document.activeElement": true, @@ -4597,6 +4958,11 @@ "buffer": true } }, + "upper-case-first": { + "packages": { + "upper-case": true + } + }, "url": { "packages": { "punycode": true, @@ -4711,6 +5077,21 @@ "uuid": true } }, + "web3-utils": { + "globals": { + "setTimeout": true + }, + "packages": { + "bn.js": true, + "eth-lib": true, + "ethereum-bloom-filters": true, + "ethjs-unit": true, + "is-buffer": true, + "number-to-bn": true, + "randombytes": true, + "utf8": true + } + }, "webrtcsupport": { "globals": { "AudioContext": true, diff --git a/lavamoat/build-system/policy-override.json b/lavamoat/build-system/policy-override.json index eaa6b9b26..6e23d3347 100644 --- a/lavamoat/build-system/policy-override.json +++ b/lavamoat/build-system/policy-override.json @@ -29,7 +29,8 @@ }, "eslint-module-utils": { "packages": { - "eslint-import-resolver-node": true + "eslint-import-resolver-node": true, + "@babel/eslint-parser": true } }, "node-sass": { diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index a9073f4cd..e7cf27f5e 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -12,19 +12,23 @@ "@babel/core": { "builtin": { "fs": true, + "module": true, "path": true, - "url": true + "url": true, + "v8": true }, "globals": { "console.log": true, "process.cwd": true, "process.env.BABEL_ENV": true, "process.env.BABEL_SHOW_CONFIG_FOR": true, - "process.env.NODE_ENV": true + "process.env.NODE_ENV": true, + "process.versions.node": true }, "packages": { "@babel/code-frame": true, "@babel/generator": true, + "@babel/helper-compilation-targets": true, "@babel/helper-module-transforms": true, "@babel/helpers": true, "@babel/parser": true, @@ -35,8 +39,6 @@ "debug": true, "gensync": true, "json5": true, - "lodash": true, - "resolve": true, "semver": true, "source-map": true } @@ -76,22 +78,9 @@ "@babel/types": true } }, - "@babel/helper-builder-react-jsx": { - "packages": { - "@babel/helper-annotate-as-pure": true, - "@babel/types": true - } - }, - "@babel/helper-builder-react-jsx-experimental": { - "packages": { - "@babel/helper-annotate-as-pure": true, - "@babel/helper-module-imports": true, - "@babel/types": true - } - }, "@babel/helper-compilation-targets": { "globals": { - "console.log": true, + "console.warn": true, "process.versions.node": true }, "packages": { @@ -107,6 +96,7 @@ }, "packages": { "@babel/core": true, + "@babel/helper-annotate-as-pure": true, "@babel/helper-function-name": true, "@babel/helper-member-expression-to-functions": true, "@babel/helper-optimise-call-expression": true, @@ -121,11 +111,22 @@ "regexpu-core": true } }, - "@babel/helper-define-map": { + "@babel/helper-define-polyfill-provider": { + "builtin": { + "path": true + }, + "globals": { + "console.log": true, + "console.warn": true, + "process.exitCode": "write", + "process.versions.node": true + }, "packages": { - "@babel/helper-function-name": true, - "@babel/types": true, - "lodash": true + "@babel/core": true, + "@babel/helper-compilation-targets": true, + "@babel/helper-plugin-utils": true, + "lodash.debounce": true, + "resolve": true } }, "@babel/helper-explode-assignable-expression": { @@ -177,8 +178,7 @@ "@babel/helper-validator-identifier": true, "@babel/template": true, "@babel/traverse": true, - "@babel/types": true, - "lodash": true + "@babel/types": true } }, "@babel/helper-optimise-call-expression": { @@ -237,6 +237,14 @@ "js-tokens": true } }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "packages": { + "@babel/core": true, + "@babel/helper-plugin-utils": true, + "@babel/helper-skip-transparent-expression-wrappers": true, + "@babel/plugin-proposal-optional-chaining": true + } + }, "@babel/plugin-proposal-async-generator-functions": { "packages": { "@babel/core": true, @@ -251,6 +259,13 @@ "@babel/helper-plugin-utils": true } }, + "@babel/plugin-proposal-class-static-block": { + "packages": { + "@babel/helper-create-class-features-plugin": true, + "@babel/helper-plugin-utils": true, + "@babel/plugin-syntax-class-static-block": true + } + }, "@babel/plugin-proposal-dynamic-import": { "packages": { "@babel/helper-plugin-utils": true, @@ -292,7 +307,9 @@ }, "@babel/plugin-proposal-object-rest-spread": { "packages": { + "@babel/compat-data": true, "@babel/core": true, + "@babel/helper-compilation-targets": true, "@babel/helper-plugin-utils": true, "@babel/plugin-syntax-object-rest-spread": true, "@babel/plugin-transform-parameters": true @@ -318,6 +335,14 @@ "@babel/helper-plugin-utils": true } }, + "@babel/plugin-proposal-private-property-in-object": { + "packages": { + "@babel/helper-annotate-as-pure": true, + "@babel/helper-create-class-features-plugin": true, + "@babel/helper-plugin-utils": true, + "@babel/plugin-syntax-private-property-in-object": true + } + }, "@babel/plugin-proposal-unicode-property-regex": { "packages": { "@babel/helper-create-regexp-features-plugin": true, @@ -334,6 +359,11 @@ "@babel/helper-plugin-utils": true } }, + "@babel/plugin-syntax-class-static-block": { + "packages": { + "@babel/helper-plugin-utils": true + } + }, "@babel/plugin-syntax-dynamic-import": { "packages": { "@babel/helper-plugin-utils": true @@ -384,6 +414,11 @@ "@babel/helper-plugin-utils": true } }, + "@babel/plugin-syntax-private-property-in-object": { + "packages": { + "@babel/helper-plugin-utils": true + } + }, "@babel/plugin-syntax-top-level-await": { "packages": { "@babel/helper-plugin-utils": true @@ -418,7 +453,6 @@ "packages": { "@babel/core": true, "@babel/helper-annotate-as-pure": true, - "@babel/helper-define-map": true, "@babel/helper-function-name": true, "@babel/helper-optimise-call-expression": true, "@babel/helper-plugin-utils": true, @@ -554,7 +588,9 @@ }, "@babel/plugin-transform-react-display-name": { "builtin": { - "path": true + "path.basename": true, + "path.dirname": true, + "path.extname": true }, "packages": { "@babel/core": true, @@ -564,30 +600,15 @@ "@babel/plugin-transform-react-jsx": { "packages": { "@babel/core": true, - "@babel/helper-builder-react-jsx": true, - "@babel/helper-builder-react-jsx-experimental": true, + "@babel/helper-annotate-as-pure": true, + "@babel/helper-module-imports": true, "@babel/helper-plugin-utils": true, "@babel/plugin-syntax-jsx": true } }, "@babel/plugin-transform-react-jsx-development": { "packages": { - "@babel/core": true, - "@babel/helper-builder-react-jsx-experimental": true, - "@babel/helper-plugin-utils": true, - "@babel/plugin-syntax-jsx": true - } - }, - "@babel/plugin-transform-react-jsx-self": { - "packages": { - "@babel/core": true, - "@babel/helper-plugin-utils": true - } - }, - "@babel/plugin-transform-react-jsx-source": { - "packages": { - "@babel/core": true, - "@babel/helper-plugin-utils": true + "@babel/plugin-transform-react-jsx": true } }, "@babel/plugin-transform-react-pure-annotations": { @@ -673,11 +694,12 @@ "packages": { "@babel/compat-data": true, "@babel/helper-compilation-targets": true, - "@babel/helper-module-imports": true, "@babel/helper-plugin-utils": true, "@babel/helper-validator-option": true, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": true, "@babel/plugin-proposal-async-generator-functions": true, "@babel/plugin-proposal-class-properties": true, + "@babel/plugin-proposal-class-static-block": true, "@babel/plugin-proposal-dynamic-import": true, "@babel/plugin-proposal-export-namespace-from": true, "@babel/plugin-proposal-json-strings": true, @@ -688,9 +710,11 @@ "@babel/plugin-proposal-optional-catch-binding": true, "@babel/plugin-proposal-optional-chaining": true, "@babel/plugin-proposal-private-methods": true, + "@babel/plugin-proposal-private-property-in-object": true, "@babel/plugin-proposal-unicode-property-regex": true, "@babel/plugin-syntax-async-generators": true, "@babel/plugin-syntax-class-properties": true, + "@babel/plugin-syntax-class-static-block": true, "@babel/plugin-syntax-dynamic-import": true, "@babel/plugin-syntax-export-namespace-from": true, "@babel/plugin-syntax-json-strings": true, @@ -700,6 +724,7 @@ "@babel/plugin-syntax-object-rest-spread": true, "@babel/plugin-syntax-optional-catch-binding": true, "@babel/plugin-syntax-optional-chaining": true, + "@babel/plugin-syntax-private-property-in-object": true, "@babel/plugin-syntax-top-level-await": true, "@babel/plugin-transform-arrow-functions": true, "@babel/plugin-transform-async-to-generator": true, @@ -735,6 +760,9 @@ "@babel/plugin-transform-unicode-regex": true, "@babel/preset-modules": true, "@babel/types": true, + "babel-plugin-polyfill-corejs2": true, + "babel-plugin-polyfill-corejs3": true, + "babel-plugin-polyfill-regenerator": true, "core-js-compat": true, "semver": true } @@ -742,11 +770,10 @@ "@babel/preset-react": { "packages": { "@babel/helper-plugin-utils": true, + "@babel/helper-validator-option": true, "@babel/plugin-transform-react-display-name": true, "@babel/plugin-transform-react-jsx": true, "@babel/plugin-transform-react-jsx-development": true, - "@babel/plugin-transform-react-jsx-self": true, - "@babel/plugin-transform-react-jsx-source": true, "@babel/plugin-transform-react-pure-annotations": true } }, @@ -760,13 +787,13 @@ "@babel/traverse": { "globals": { "console.log": true, - "console.trace": true, - "process.env.NODE_ENV": true + "console.trace": true }, "packages": { "@babel/code-frame": true, "@babel/generator": true, "@babel/helper-function-name": true, + "@babel/helper-hoist-variables": true, "@babel/helper-split-export-declaration": true, "@babel/parser": true, "@babel/types": true, @@ -781,7 +808,6 @@ }, "packages": { "@babel/helper-validator-identifier": true, - "lodash": true, "to-fast-properties": true } }, @@ -945,11 +971,6 @@ "define": true } }, - "acorn-dynamic-import": { - "packages": { - "acorn": true - } - }, "acorn-jsx": { "packages": { "acorn": true @@ -958,7 +979,6 @@ "acorn-node": { "packages": { "acorn": true, - "acorn-dynamic-import": true, "acorn-walk": true, "xtend": true } @@ -1117,13 +1137,33 @@ "packages": { "browserslist": true, "caniuse-lite": true, - "colorette": true, "normalize-range": true, "num2fraction": true, + "picocolors": true, "postcss": true, "postcss-value-parser": true } }, + "babel-plugin-polyfill-corejs2": { + "packages": { + "@babel/compat-data": true, + "@babel/core": true, + "@babel/helper-define-polyfill-provider": true, + "semver": true + } + }, + "babel-plugin-polyfill-corejs3": { + "packages": { + "@babel/core": true, + "@babel/helper-define-polyfill-provider": true, + "core-js-compat": true + } + }, + "babel-plugin-polyfill-regenerator": { + "packages": { + "@babel/helper-define-polyfill-provider": true + } + }, "babelify": { "builtin": { "path.extname": true, @@ -1675,7 +1715,7 @@ }, "deps-sort": { "packages": { - "shasum": true, + "shasum-object": true, "through2": true } }, @@ -1773,15 +1813,9 @@ } }, "es-abstract": { - "globals": { - "AggregateError": true, - "FinalizationRegistry": true, - "WeakRef": true - }, "packages": { "call-bind": true, "es-to-primitive": true, - "function-bind": true, "get-intrinsic": true, "has": true, "has-symbols": true, @@ -3069,7 +3103,6 @@ "labeled-stream-splicer": { "packages": { "inherits": true, - "isarray": true, "stream-splicer": true } }, @@ -3196,6 +3229,12 @@ "define": true } }, + "lodash.debounce": { + "globals": { + "clearTimeout": true, + "setTimeout": true + } + }, "log-symbols": { "globals": { "process.env.CI": true, @@ -3489,7 +3528,6 @@ "packages": { "call-bind": true, "define-properties": true, - "es-abstract": true, "has-symbols": true, "object-keys": true } @@ -3534,14 +3572,6 @@ "make-iterator": true } }, - "object.values": { - "packages": { - "call-bind": true, - "define-properties": true, - "es-abstract": true, - "has": true - } - }, "once": { "packages": { "wrappy": true @@ -3670,6 +3700,16 @@ "through": true } }, + "picocolors": { + "builtin": { + "tty.isatty": true + }, + "globals": { + "process.argv.includes": true, + "process.env": true, + "process.platform": true + } + }, "picomatch": { "builtin": { "path.basename": true, @@ -3727,6 +3767,7 @@ }, "packages": { "chalk": true, + "picocolors": true, "source-map": true, "supports-color": true } @@ -3767,13 +3808,9 @@ } }, "postcss-selector-parser": { - "builtin": { - "util.deprecate": true - }, "packages": { "cssesc": true, - "indexes-of": true, - "uniq": true + "util-deprecate": true } }, "postcss-syntax": { @@ -3861,6 +3898,11 @@ "pump": true } }, + "qs": { + "packages": { + "side-channel": true + } + }, "quote-stream": { "globals": { "Buffer": true @@ -4272,6 +4314,17 @@ "json-stable-stringify": true } }, + "shasum-object": { + "builtin": { + "crypto.createHash": true + }, + "globals": { + "Buffer.isBuffer": true + }, + "packages": { + "fast-safe-stringify": true + } + }, "shebang-command": { "packages": { "shebang-regex": true @@ -5200,8 +5253,8 @@ "yaml": { "globals": { "Buffer": true, - "_YAML_SILENCE_DEPRECATION_WARNINGS": true, - "_YAML_SILENCE_WARNINGS": true, + "YAML_SILENCE_DEPRECATION_WARNINGS": true, + "YAML_SILENCE_WARNINGS": true, "atob": true, "btoa": true, "console.warn": true, diff --git a/package.json b/package.json index 04de91a1f..cccdd9768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "10.7.1", + "version": "10.8.0", "private": true, "repository": { "type": "git", @@ -60,6 +60,7 @@ "start:dev": "concurrently -k -n build,react,redux yarn:start yarn:devtools:react yarn:devtools:redux", "announce": "node development/announcer.js", "storybook": "start-storybook -p 6006 -c .storybook -s ./app,./.storybook/images", + "storybook:test": "jest --config=./jest.stories.config.js", "storybook:build": "build-storybook -c .storybook -o storybook-build -s ./app,./.storybook/images", "storybook:deploy": "storybook-to-ghpages --existing-output-dir storybook-build --remote storybook --branch master", "update-changelog": "auto-changelog update", @@ -106,9 +107,11 @@ "@ethereumjs/tx": "^3.2.1", "@formatjs/intl-relativetimeformat": "^5.2.6", "@fortawesome/fontawesome-free": "^5.13.0", + "@keystonehq/bc-ur-registry-eth": "^0.6.8", + "@keystonehq/metamask-airgapped-keyring": "0.2.1", "@material-ui/core": "^4.11.0", "@metamask/contract-metadata": "^1.28.0", - "@metamask/controllers": "^17.0.0", + "@metamask/controllers": "^20.1.0", "@metamask/eth-ledger-bridge-keyring": "^0.10.0", "@metamask/eth-token-tracker": "^3.0.1", "@metamask/etherscan-link": "^2.1.0", @@ -117,11 +120,15 @@ "@metamask/obs-store": "^5.0.0", "@metamask/post-message-stream": "^4.0.0", "@metamask/providers": "^8.1.1", + "@ngraveio/bc-ur": "^1.1.6", "@popperjs/core": "^2.4.0", "@reduxjs/toolkit": "^1.6.2", "@sentry/browser": "^6.0.0", "@sentry/integrations": "^6.0.0", - "@zxing/library": "^0.8.0", + "@truffle/codec": "^0.11.18", + "@truffle/decoder": "^5.1.0", + "@zxing/browser": "^0.0.10", + "@zxing/library": "0.8.0", "analytics-node": "^3.4.0-beta.3", "await-semaphore": "^0.1.1", "base32-encode": "^1.2.0", @@ -145,7 +152,7 @@ "eth-query": "^2.1.2", "eth-rpc-errors": "^4.0.2", "eth-sig-util": "^3.0.0", - "eth-trezor-keyring": "^0.7.0", + "eth-trezor-keyring": "^0.9.1", "ethereum-ens-network-map": "^1.0.2", "ethereumjs-abi": "^0.6.4", "ethereumjs-util": "^7.0.10", @@ -181,6 +188,7 @@ "pump": "^3.0.0", "punycode": "^2.1.1", "qrcode-generator": "1.4.1", + "qrcode.react": "^1.0.1", "react": "^16.12.0", "react-dnd": "^3.0.2", "react-dnd-html5-backend": "^7.4.4", @@ -206,6 +214,7 @@ "swappable-obj-proxy": "^1.1.0", "textarea-caret": "^3.0.1", "unicode-confusables": "^0.1.1", + "uuid": "^8.3.2", "valid-url": "^1.0.9", "web3": "^0.20.7", "web3-stream-provider": "^4.0.0" @@ -253,9 +262,10 @@ "babelify": "^10.0.0", "bify-module-groups": "^1.0.0", "brfs": "^2.0.2", + "browser-util-inspect": "^0.2.0", "browserify": "^16.5.1", "chalk": "^3.0.0", - "chromedriver": "^95.0.0", + "chromedriver": "^96.0.0", "concurrently": "^5.2.0", "copy-webpack-plugin": "^6.0.3", "cross-spawn": "^7.0.3", @@ -299,7 +309,7 @@ "jest": "^26.6.3", "jsdom": "^11.2.0", "koa": "^2.7.0", - "lavamoat": "^5.3.4", + "lavamoat": "^5.3.5", "lavamoat-browserify": "^14.0.3", "lavamoat-viz": "^6.0.9", "lockfile-lint": "^4.0.0", @@ -373,7 +383,9 @@ "@lavamoat/preinstall-always-fail": false, "fsevents": false, "node-hid": false, - "usb": false + "usb": false, + "blake-hash": false, + "protobufjs": false } } } diff --git a/patches/@babel+runtime+7.15.4.patch b/patches/@babel+runtime+7.15.4.patch new file mode 100644 index 000000000..2213f4cb0 --- /dev/null +++ b/patches/@babel+runtime+7.15.4.patch @@ -0,0 +1,62 @@ +diff --git a/node_modules/@babel/runtime/helpers/extends.js b/node_modules/@babel/runtime/helpers/extends.js +index eaf9547..d0474f5 100644 +--- a/node_modules/@babel/runtime/helpers/extends.js ++++ b/node_modules/@babel/runtime/helpers/extends.js +@@ -1,20 +1,5 @@ + function _extends() { +- module.exports = _extends = Object.assign || function (target) { +- for (var i = 1; i < arguments.length; i++) { +- var source = arguments[i]; +- +- for (var key in source) { +- if (Object.prototype.hasOwnProperty.call(source, key)) { +- target[key] = source[key]; +- } +- } +- } +- +- return target; +- }; +- +- module.exports["default"] = module.exports, module.exports.__esModule = true; +- return _extends.apply(this, arguments); ++ return Object.assign(...arguments) + } + + module.exports = _extends; +diff --git a/node_modules/@babel/runtime/helpers/getPrototypeOf.js b/node_modules/@babel/runtime/helpers/getPrototypeOf.js +index a6916eb..e01b2d6 100644 +--- a/node_modules/@babel/runtime/helpers/getPrototypeOf.js ++++ b/node_modules/@babel/runtime/helpers/getPrototypeOf.js +@@ -1,9 +1,5 @@ + function _getPrototypeOf(o) { +- module.exports = _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { +- return o.__proto__ || Object.getPrototypeOf(o); +- }; +- module.exports["default"] = module.exports, module.exports.__esModule = true; +- return _getPrototypeOf(o); ++ return Object.getPrototypeOf(o); + } + + module.exports = _getPrototypeOf; +diff --git a/node_modules/@babel/runtime/helpers/setPrototypeOf.js b/node_modules/@babel/runtime/helpers/setPrototypeOf.js +index 415797b..63312f2 100644 +--- a/node_modules/@babel/runtime/helpers/setPrototypeOf.js ++++ b/node_modules/@babel/runtime/helpers/setPrototypeOf.js +@@ -1,12 +1,7 @@ + function _setPrototypeOf(o, p) { +- module.exports = _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { +- o.__proto__ = p; +- return o; +- }; +- +- module.exports["default"] = module.exports, module.exports.__esModule = true; +- return _setPrototypeOf(o, p); +-} ++ o.__proto__ = p; ++ return o; ++}; + + module.exports = _setPrototypeOf; + module.exports["default"] = module.exports, module.exports.__esModule = true; +\ No newline at end of file diff --git a/patches/acorn+7.4.1.patch b/patches/acorn+7.4.1.patch new file mode 100644 index 000000000..061aa1281 --- /dev/null +++ b/patches/acorn+7.4.1.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/acorn/dist/acorn.js b/node_modules/acorn/dist/acorn.js +index 0523f0e..9d3d5c2 100644 +--- a/node_modules/acorn/dist/acorn.js ++++ b/node_modules/acorn/dist/acorn.js +@@ -1835,7 +1835,7 @@ + if (checkClashes) { + if (has(checkClashes, expr.name)) + { this.raiseRecoverable(expr.start, "Argument name clash"); } +- checkClashes[expr.name] = true; ++ Object.defineProperty(checkClashes, expr.name, { value: true, writable: true, enumerable: true, configurable: true }) + } + if (bindingType !== BIND_NONE && bindingType !== BIND_OUTSIDE) { this.declareName(expr.name, bindingType, expr.start); } + break diff --git a/patches/colors+1.4.0.patch b/patches/colors+1.4.0.patch new file mode 100644 index 000000000..9ea49d833 --- /dev/null +++ b/patches/colors+1.4.0.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/colors/lib/extendStringPrototype.js b/node_modules/colors/lib/extendStringPrototype.js +index 46fd386..c7d0fc5 100644 +--- a/node_modules/colors/lib/extendStringPrototype.js ++++ b/node_modules/colors/lib/extendStringPrototype.js +@@ -5,7 +5,8 @@ module['exports'] = function() { + // Extends prototype of native string object to allow for "foo".red syntax + // + var addProperty = function(color, func) { +- String.prototype.__defineGetter__(color, func); ++ // remove prototype mutation so this plays well with LavaMoat ++ // String.prototype.__defineGetter__(color, func); + }; + + addProperty('strip', function() { diff --git a/patches/object.values+1.1.1.patch b/patches/object.values+1.1.3.patch similarity index 79% rename from patches/object.values+1.1.1.patch rename to patches/object.values+1.1.3.patch index f0323c184..2664d97c6 100644 --- a/patches/object.values+1.1.1.patch +++ b/patches/object.values+1.1.3.patch @@ -1,17 +1,18 @@ diff --git a/node_modules/object.values/index.js b/node_modules/object.values/index.js -index b8ba091..2dc8083 100644 +index abf0449..2dc8083 100644 --- a/node_modules/object.values/index.js +++ b/node_modules/object.values/index.js -@@ -1,17 +1,3 @@ +@@ -1,18 +1,3 @@ 'use strict'; -var define = require('define-properties'); +-var callBind = require('call-bind'); - -var implementation = require('./implementation'); -var getPolyfill = require('./polyfill'); -var shim = require('./shim'); - --var polyfill = getPolyfill(); +-var polyfill = callBind(getPolyfill(), Object); - -define(polyfill, { - getPolyfill: getPolyfill, diff --git a/patches/sass+1.32.4.patch b/patches/sass+1.35.2.patch similarity index 57% rename from patches/sass+1.32.4.patch rename to patches/sass+1.35.2.patch index 6939fd235..b4cc16cc1 100644 --- a/patches/sass+1.32.4.patch +++ b/patches/sass+1.35.2.patch @@ -1,25 +1,29 @@ diff --git a/node_modules/sass/sass.dart.js b/node_modules/sass/sass.dart.js -index fedd867..fef6a8f 100644 +index 512d612..1374f5e 100644 --- a/node_modules/sass/sass.dart.js +++ b/node_modules/sass/sass.dart.js -@@ -16,6 +16,9 @@ self.scheduleImmediate = self.setImmediate +@@ -16,6 +16,10 @@ self.scheduleImmediate = typeof setImmediate !== "undefined" // CommonJS globals. self.exports = exports; ++// realm bridge utility functions +exports.bridgeJson = (target) => JSON.parse(JSON.stringify(target)) +exports.bridgeFn = (target) => ((...args) => target(...args)) + // Node.js specific exports, check to see if they exist & or polyfilled if (typeof process !== "undefined") { -@@ -3616,10 +3619,6 @@ self.fs = require("fs"); +@@ -3700,13 +3704,6 @@ self.fs = require("fs"); return C.PlainJavaScriptObject_methods; if (proto === Object.prototype) return C.PlainJavaScriptObject_methods; - if (typeof $constructor == "function") { -- Object.defineProperty($constructor, J.JS_INTEROP_INTERCEPTOR_TAG(), {value: C.UnknownJavaScriptObject_methods, enumerable: false, writable: true, configurable: true}); +- t1 = $._JS_INTEROP_INTERCEPTOR_TAG; +- if (t1 == null) +- t1 = $._JS_INTEROP_INTERCEPTOR_TAG = init.getIsolateTag("_$dart_js"); +- Object.defineProperty($constructor, t1, {value: C.UnknownJavaScriptObject_methods, enumerable: false, writable: true, configurable: true}); - return C.UnknownJavaScriptObject_methods; - } return C.UnknownJavaScriptObject_methods; }, - JS_INTEROP_INTERCEPTOR_TAG: function() { + JSArray_JSArray$fixed: function($length, $E) { diff --git a/shared/constants/gas.js b/shared/constants/gas.js index 1c0bc8ae7..01d490960 100644 --- a/shared/constants/gas.js +++ b/shared/constants/gas.js @@ -30,6 +30,17 @@ export const GAS_RECOMMENDATIONS = { HIGH: 'high', }; +/** + * These represent types of gas estimation + */ +export const PRIORITY_LEVELS = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CUSTOM: 'custom', + DAPP_SUGGESTED: 'dappSuggested', +}; + /** * Represents the user customizing their gas preference */ diff --git a/shared/constants/hardware-wallets.js b/shared/constants/hardware-wallets.js index f32306472..105287800 100644 --- a/shared/constants/hardware-wallets.js +++ b/shared/constants/hardware-wallets.js @@ -1,5 +1,5 @@ /** - * Accounts can be instantiated from simple, HD or the two hardware wallet + * Accounts can be instantiated from simple, HD or the multiple hardware wallet * keyring types. Both simple and HD are treated as default but we do special * case accounts managed by a hardware wallet. */ @@ -7,6 +7,14 @@ export const KEYRING_TYPES = { LEDGER: 'Ledger Hardware', TREZOR: 'Trezor Hardware', LATTICE: 'Lattice Hardware', + QR: 'QR Hardware Wallet Device', +}; + +export const DEVICE_NAMES = { + LEDGER: 'ledger', + TREZOR: 'trezor', + QR: 'QR Hardware', + LATTICE: 'lattice', }; /** diff --git a/shared/constants/swaps.js b/shared/constants/swaps.js index 2b1c9cbbf..2518a7079 100644 --- a/shared/constants/swaps.js +++ b/shared/constants/swaps.js @@ -84,12 +84,7 @@ export const WBNB_CONTRACT_ADDRESS = export const WMATIC_CONTRACT_ADDRESS = '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270'; -const METASWAP_ETH_API_HOST = 'https://api.metaswap.codefi.network'; - -const METASWAP_BSC_API_HOST = 'https://bsc-api.metaswap.codefi.network'; - const SWAPS_TESTNET_CHAIN_ID = '0x539'; -const SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network'; export const SWAPS_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network'; export const SWAPS_DEV_API_V2_BASE_URL = @@ -111,14 +106,6 @@ export const ALLOWED_SWAPS_CHAIN_IDS = { [RINKEBY_CHAIN_ID]: true, }; -// This is mapping for v1 URLs and will be removed once we migrate to v2. -export const METASWAP_CHAINID_API_HOST_MAP = { - [MAINNET_CHAIN_ID]: METASWAP_ETH_API_HOST, - [SWAPS_TESTNET_CHAIN_ID]: `${SWAPS_API_V2_BASE_URL}/networks/1`, - [BSC_CHAIN_ID]: METASWAP_BSC_API_HOST, - [RINKEBY_CHAIN_ID]: SWAPS_TESTNET_HOST, -}; - export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = { [MAINNET_CHAIN_ID]: MAINNET_CONTRACT_ADDRESS, [SWAPS_TESTNET_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS, diff --git a/shared/modules/conversion.utils.js b/shared/modules/conversion.utils.js index 5c139bbbc..eba33fc8e 100644 --- a/shared/modules/conversion.utils.js +++ b/shared/modules/conversion.utils.js @@ -229,6 +229,21 @@ const multiplyCurrencies = (a, b, options = {}) => { }); }; +const divideCurrencies = (a, b, options = {}) => { + const { dividendBase, divisorBase, ...conversionOptions } = options; + + if (!isValidBase(dividendBase) || !isValidBase(divisorBase)) { + throw new Error('Must specify valid dividendBase and divisorBase'); + } + + const value = getBigNumber(a, dividendBase).div(getBigNumber(b, divisorBase)); + + return converter({ + value, + ...conversionOptions, + }); +}; + const conversionGreaterThan = ({ ...firstProps }, { ...secondProps }) => { const firstValue = converter({ ...firstProps }); const secondValue = converter({ ...secondProps }); @@ -291,4 +306,5 @@ export { decGWEIToHexWEI, toBigNumber, toNormalizedDenomination, + divideCurrencies, }; diff --git a/shared/modules/conversion.utils.test.js b/shared/modules/conversion.utils.test.js index d483a9a78..735d0edf2 100644 --- a/shared/modules/conversion.utils.test.js +++ b/shared/modules/conversion.utils.test.js @@ -1,5 +1,9 @@ import BigNumber from 'bignumber.js'; -import { addCurrencies, conversionUtil } from './conversion.utils'; +import { + addCurrencies, + conversionUtil, + divideCurrencies, +} from './conversion.utils'; describe('conversion utils', () => { describe('addCurrencies()', () => { @@ -163,4 +167,39 @@ describe('conversion utils', () => { ).toStrictEqual('1.5'); }); }); + + describe('divideCurrencies()', () => { + it('should correctly divide decimal values', () => { + const result = divideCurrencies(9, 3, { + dividendBase: 10, + divisorBase: 10, + }); + expect(result.toNumber()).toStrictEqual(3); + }); + + it('should correctly divide hexadecimal values', () => { + const result = divideCurrencies(1000, 0xa, { + dividendBase: 16, + divisorBase: 16, + }); + expect(result.toNumber()).toStrictEqual(0x100); + }); + + it('should correctly divide hexadecimal value from decimal value', () => { + const result = divideCurrencies(0x3e8, 0xa, { + dividendBase: 16, + divisorBase: 16, + }); + expect(result.toNumber()).toStrictEqual(0x100); + }); + + it('should throw error for wrong base value', () => { + expect(() => { + divideCurrencies(0x3e8, 0xa, { + dividendBase: 10.5, + divisorBase: 7, + }); + }).toThrow('Must specify valid dividendBase and divisorBase'); + }); + }); }); diff --git a/shared/modules/fetch-with-timeout.test.js b/shared/modules/fetch-with-timeout.test.js index d7b1f2624..8d7ae5785 100644 --- a/shared/modules/fetch-with-timeout.test.js +++ b/shared/modules/fetch-with-timeout.test.js @@ -2,8 +2,8 @@ import nock from 'nock'; import { MILLISECOND, SECOND } from '../constants/time'; import getFetchWithTimeout from './fetch-with-timeout'; -describe('getFetchWithTimeout', function () { - it('fetches a url', async function () { +describe('getFetchWithTimeout', () => { + it('fetches a url', async () => { nock('https://api.infura.io').get('/money').reply(200, '{"hodl": false}'); const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); @@ -15,7 +15,7 @@ describe('getFetchWithTimeout', function () { }); }); - it('throws when the request hits a custom timeout', async function () { + it('throws when the request hits a custom timeout', async () => { nock('https://api.infura.io') .get('/moon') .delay(SECOND * 2) @@ -23,19 +23,14 @@ describe('getFetchWithTimeout', function () { const fetchWithTimeout = getFetchWithTimeout(MILLISECOND * 123); - const fetchWithTimeoutThrowsError = async () => { + await expect(async () => { await fetchWithTimeout('https://api.infura.io/moon').then((r) => r.json(), ); - throw new Error('Request should throw'); - }; - - await expect(fetchWithTimeoutThrowsError()).rejects.toThrow( - 'The user aborted a request.', - ); + }).rejects.toThrow('The user aborted a request.'); }); - it('should abort the request when the custom timeout is hit', async function () { + it('should abort the request when the custom timeout is hit', async () => { nock('https://api.infura.io') .get('/moon') .delay(SECOND * 2) @@ -43,29 +38,24 @@ describe('getFetchWithTimeout', function () { const fetchWithTimeout = getFetchWithTimeout(MILLISECOND * 123); - const fetchWithTimeoutThrowsError = async () => { + await expect(async () => { await fetchWithTimeout('https://api.infura.io/moon').then((r) => r.json(), ); - throw new Error('Request should be aborted'); - }; - - await expect(fetchWithTimeoutThrowsError()).rejects.toThrow( - 'The user aborted a request.', - ); + }).rejects.toThrow('The user aborted a request.'); }); - it('throws on invalid timeout', async function () { - expect(() => getFetchWithTimeout()).toThrow( + it('throws on invalid timeout', async () => { + await expect(() => getFetchWithTimeout()).toThrow( 'Must specify positive integer timeout.', ); - expect(() => getFetchWithTimeout(-1)).toThrow( + await expect(() => getFetchWithTimeout(-1)).toThrow( 'Must specify positive integer timeout.', ); - expect(() => getFetchWithTimeout({})).toThrow( + await expect(() => getFetchWithTimeout({})).toThrow( 'Must specify positive integer timeout.', ); - expect(() => getFetchWithTimeout(true)).toThrow( + await expect(() => getFetchWithTimeout(true)).toThrow( 'Must specify positive integer timeout.', ); }); diff --git a/shared/modules/hexstring-utils.test.js b/shared/modules/hexstring-utils.test.js index bf2d80116..74e444437 100644 --- a/shared/modules/hexstring-utils.test.js +++ b/shared/modules/hexstring-utils.test.js @@ -6,51 +6,51 @@ describe('hexstring utils', function () { it('should allow 40-char non-prefixed hex', function () { const address = 'fdea65c8e26263f6d9a1b5de9555d2931a33b825'; const result = isValidHexAddress(address); - expect(result).toBe(true); + expect(result).toStrictEqual(true); }); it('should allow 42-char prefixed hex', function () { const address = '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825'; const result = isValidHexAddress(address); - expect(result).toBe(true); + expect(result).toStrictEqual(true); }); it('should NOT allow 40-char non-prefixed hex when allowNonPrefixed is false', function () { const address = 'fdea65c8e26263f6d9a1b5de9555d2931a33b825'; const result = isValidHexAddress(address, { allowNonPrefixed: false }); - expect(result).toBe(false); + expect(result).toStrictEqual(false); }); it('should NOT allow any length of non hex-prefixed string', function () { const address = 'fdea65c8e26263f6d9a1b5de9555d2931a33b85'; const result = isValidHexAddress(address); - expect(result).toBe(false); + expect(result).toStrictEqual(false); }); it('should NOT allow less than 42 character hex-prefixed string', function () { const address = '0xfdea65ce26263f6d9a1b5de9555d2931a33b85'; const result = isValidHexAddress(address); - expect(result).toBe(false); + expect(result).toStrictEqual(false); }); it('should recognize correct capitalized checksum', function () { const address = '0xFDEa65C8e26263F6d9A1B5de9555D2931A33b825'; const result = isValidHexAddress(address, { mixedCaseUseChecksum: true }); - expect(result).toBe(true); + expect(result).toStrictEqual(true); }); it('should recognize incorrect capitalized checksum', function () { const address = '0xFDea65C8e26263F6d9A1B5de9555D2931A33b825'; const result = isValidHexAddress(address, { mixedCaseUseChecksum: true }); - expect(result).toBe(false); + expect(result).toStrictEqual(false); }); it('should recognize this sample hashed address', function () { const address = '0x5Fda30Bb72B8Dfe20e48A00dFc108d0915BE9Bb0'; const result = isValidHexAddress(address, { mixedCaseUseChecksum: true }); const hashed = toChecksumAddress(address.toLowerCase()); - expect(hashed).toBe(address); - expect(result).toBe(true); + expect(hashed).toStrictEqual(address); + expect(result).toStrictEqual(true); }); }); }); diff --git a/shared/notifications/index.js b/shared/notifications/index.js index bad25a791..38ae68c0a 100644 --- a/shared/notifications/index.js +++ b/shared/notifications/index.js @@ -38,6 +38,14 @@ export const UI_NOTIFICATIONS = { id: 8, date: '2021-11-01', }, + 9: { + id: 9, + date: '2021-12-07', + image: { + src: 'images/txinsights.png', + width: '80%', + }, + }, }; export const getTranslatedUINoficiations = (t, locale) => { @@ -113,5 +121,16 @@ export const getTranslatedUINoficiations = (t, locale) => { ), actionText: t('notifications8ActionText'), }, + 9: { + ...UI_NOTIFICATIONS[9], + title: t('notifications9Title'), + description: [ + t('notifications9DescriptionOne'), + t('notifications9DescriptionTwo'), + ], + date: new Intl.DateTimeFormat(formattedLocale).format( + new Date(UI_NOTIFICATIONS[9].date), + ), + }, }; }; diff --git a/test/data/mock-estimates.json b/test/data/mock-estimates.json new file mode 100644 index 000000000..8f7587f92 --- /dev/null +++ b/test/data/mock-estimates.json @@ -0,0 +1,27 @@ +{ + "fee-market": { + "gasEstimateType": "fee-market", + "gasFeeEstimates": { + "low": { + "minWaitTimeEstimate": 180000, + "maxWaitTimeEstimate": 300000, + "suggestedMaxPriorityFeePerGas": "3", + "suggestedMaxFeePerGas": "53" + }, + "medium": { + "minWaitTimeEstimate": 15000, + "maxWaitTimeEstimate": 60000, + "suggestedMaxPriorityFeePerGas": "7", + "suggestedMaxFeePerGas": "70" + }, + "high": { + "minWaitTimeEstimate": 0, + "maxWaitTimeEstimate": 15000, + "suggestedMaxPriorityFeePerGas": "10", + "suggestedMaxFeePerGas": "100" + }, + "estimatedBaseFee": "50" + }, + "estimatedGasFeeTimeBounds": {} + } +} diff --git a/test/e2e/metamask-ui.spec.js b/test/e2e/metamask-ui.spec.js index f12b8a090..e6ec801d5 100644 --- a/test/e2e/metamask-ui.spec.js +++ b/test/e2e/metamask-ui.spec.js @@ -349,14 +349,14 @@ describe('MetaMask', function () { }); it('displays the token transfer data', async function () { - await driver.clickElement({ text: 'Data', tag: 'button' }); + await driver.clickElement({ text: 'Hex', tag: 'button' }); await driver.delay(regularDelayMs); const functionType = await driver.findElement( '.confirm-page-container-content__function-type', ); const functionTypeText = await functionType.getText(); - assert.equal(functionTypeText, 'Transfer'); + assert(functionTypeText.match('Transfer')); const tokenAmount = await driver.findElement( '.confirm-page-container-summary__title-text', diff --git a/test/e2e/tests/address-book.spec.js b/test/e2e/tests/address-book.spec.js index 4f047fe1b..1468c5734 100644 --- a/test/e2e/tests/address-book.spec.js +++ b/test/e2e/tests/address-book.spec.js @@ -34,13 +34,16 @@ describe('Address Book', function () { await driver.clickElement('.dialog.send__dialog.dialog--message'); // wait for address book modal to be visible - const addressModal = await driver.findElement('span .modal'); + const addressModal = await driver.findElement('.nickname-popover'); - await driver.findElement('.add-to-address-book-modal'); - await driver.fill('.add-to-address-book-modal__input', 'Test Name 1'); - await driver.clickElement( - '.add-to-address-book-modal__footer .btn-primary', + await driver.clickElement('.nickname-popover__footer-button'); + await driver.findElement('.update-nickname__wrapper'); + + await driver.fill( + '.update-nickname__content__text-field input', + 'Test Name 1', ); + await driver.clickElement('.update-nickname__save'); // wait for address book modal to be removed from DOM await addressModal.waitForElementState('hidden'); diff --git a/test/e2e/tests/from-import-ui.spec.js b/test/e2e/tests/from-import-ui.spec.js index a83a9bbbb..cf3becf05 100644 --- a/test/e2e/tests/from-import-ui.spec.js +++ b/test/e2e/tests/from-import-ui.spec.js @@ -1,5 +1,5 @@ const { strict: assert } = require('assert'); -const { withFixtures, regularDelayMs } = require('../helpers'); +const { withFixtures, regularDelayMs, largeDelayMs } = require('../helpers'); const enLocaleMessages = require('../../../app/_locales/en/messages.json'); describe('Metamask Import UI', function () { @@ -318,6 +318,7 @@ describe('Metamask Import UI', function () { // should open the TREZOR Connect popup await driver.clickElement('.hw-connect__btn:nth-of-type(2)'); + await driver.delay(largeDelayMs * 2); await driver.clickElement({ text: 'Continue', tag: 'button' }); await driver.waitUntilXWindowHandles(2); const allWindows = await driver.getAllWindowHandles(); diff --git a/test/e2e/tests/send-edit.spec.js b/test/e2e/tests/send-edit.spec.js index 81772df7b..94000b4a8 100644 --- a/test/e2e/tests/send-edit.spec.js +++ b/test/e2e/tests/send-edit.spec.js @@ -62,7 +62,7 @@ describe('Editing Confirm Transaction', function () { // has correct updated value on the confirm screen the transaction const editedTransactionAmounts = await driver.findElements( - '.transaction-detail-item__row .transaction-detail-item__detail-text .currency-display-component__text', + '.transaction-detail-item__row .transaction-detail-item__detail-values .currency-display-component__text:last-of-type', ); const editedTransactionAmount = editedTransactionAmounts[0]; assert.equal(await editedTransactionAmount.getText(), '0.0008'); diff --git a/test/jest/constants.js b/test/jest/constants.js index da5c54bdf..8015a8a65 100644 --- a/test/jest/constants.js +++ b/test/jest/constants.js @@ -1,2 +1,2 @@ -export const METASWAP_BASE_URL = 'https://api.metaswap.codefi.network'; -export const METASWAP_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network'; +export const METASWAP_BASE_URL = 'https://api2.metaswap.codefi.network'; +export const GAS_API_URL = 'https://gas-api.metaswap.codefi.network'; diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 3d397a52c..3039cc1a4 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -221,7 +221,6 @@ export const createSwapsMockStore = () => { topAggId: 'TEST_AGG_BEST', routeState: '', swapsFeatureIsLive: false, - useNewSwapsApi: false, }, useTokenDetection: true, tokenList: { diff --git a/ui/components/app/account-menu/account-menu.component.js b/ui/components/app/account-menu/account-menu.component.js index f72077777..131d63336 100644 --- a/ui/components/app/account-menu/account-menu.component.js +++ b/ui/components/app/account-menu/account-menu.component.js @@ -239,6 +239,7 @@ export default class AccountMenu extends Component { case KEYRING_TYPES.TREZOR: case KEYRING_TYPES.LEDGER: case KEYRING_TYPES.LATTICE: + case KEYRING_TYPES.QR: label = t('hardware'); break; case 'Simple Key Pair': diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js new file mode 100644 index 000000000..40c003237 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Box from '../../../ui/box'; +import I18nValue from '../../../ui/i18n-value'; + +const AdvancedGasFeeInputSubtext = ({ latest, historical }) => { + return ( + + + + + + {latest} + + + + + + + {historical} + + + ); +}; + +AdvancedGasFeeInputSubtext.propTypes = { + latest: PropTypes.string, + historical: PropTypes.string, +}; + +export default AdvancedGasFeeInputSubtext; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js new file mode 100644 index 000000000..f466a08f0 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/advanced-gas-fee-input-subtext.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import AdvancedGasFeeInputSubtext from './advanced-gas-fee-input-subtext'; + +describe('AdvancedGasFeeInputSubtext', () => { + it('should renders latest and historical values passed', () => { + render( + , + ); + + expect(screen.queryByText('Latest Value')).toBeInTheDocument(); + expect(screen.queryByText('Historical value')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.js new file mode 100644 index 000000000..13e6bb844 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-fee-input-subtext'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss new file mode 100644 index 000000000..45763668c --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index.scss @@ -0,0 +1,17 @@ +.advanced-gas-fee-input-subtext { + display: flex; + align-items: center; + margin-top: 2px; + color: $ui-4; + font-size: $font-size-h8; + + &__label { + font-weight: bold; + margin-right: 4px; + } + + img { + height: 16px; + margin-right: 8px; + } +} diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js new file mode 100644 index 000000000..c191d36f6 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/advanced-gas-fee-inputs.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import Box from '../../../ui/box'; +import BaseFeeInput from './base-fee-input'; +import PriorityFeeInput from './priority-fee-input'; + +const AdvancedGasFeeInputs = () => { + return ( + + +
+ + + ); +}; + +export default AdvancedGasFeeInputs; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js new file mode 100644 index 000000000..1e7739bf3 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/base-fee-input.js @@ -0,0 +1,155 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { PRIORITY_LEVELS } from '../../../../../../shared/constants/gas'; +import { + divideCurrencies, + multiplyCurrencies, +} from '../../../../../../shared/modules/conversion.utils'; +import { PRIMARY, SECONDARY } from '../../../../../helpers/constants/common'; +import { decGWEIToHexWEI } from '../../../../../helpers/utils/conversions.util'; +import { getAdvancedGasFeeValues } from '../../../../../selectors'; +import { useGasFeeContext } from '../../../../../contexts/gasFee'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useUserPreferencedCurrency } from '../../../../../hooks/useUserPreferencedCurrency'; +import { useCurrencyDisplay } from '../../../../../hooks/useCurrencyDisplay'; +import Button from '../../../../ui/button'; +import Box from '../../../../ui/box'; +import FormField from '../../../../ui/form-field'; +import I18nValue from '../../../../ui/i18n-value'; + +import { useAdvanceGasFeePopoverContext } from '../../context'; +import AdvancedGasFeeInputSubtext from '../../advanced-gas-fee-input-subtext'; + +const divideCurrencyValues = (value, baseFee) => { + if (baseFee === 0) { + return 0; + } + return divideCurrencies(value, baseFee, { + numberOfDecimals: 2, + dividendBase: 10, + divisorBase: 10, + }).toNumber(); +}; + +const multiplyCurrencyValues = (baseFee, value, numberOfDecimals) => + multiplyCurrencies(baseFee, value, { + numberOfDecimals, + multiplicandBase: 10, + multiplierBase: 10, + }).toNumber(); + +const BaseFeeInput = () => { + const t = useI18nContext(); + const { gasFeeEstimates, estimateUsed, maxFeePerGas } = useGasFeeContext(); + const { setDirty, setMaxFeePerGas } = useAdvanceGasFeePopoverContext(); + const { estimatedBaseFee } = gasFeeEstimates; + const { + numberOfDecimals: numberOfDecimalsPrimary, + } = useUserPreferencedCurrency(PRIMARY); + const { + currency, + numberOfDecimals: numberOfDecimalsFiat, + } = useUserPreferencedCurrency(SECONDARY); + + const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues); + + const [editingInGwei, setEditingInGwei] = useState(false); + + const [maxBaseFeeGWEI, setMaxBaseFeeGWEI] = useState(() => { + if ( + estimateUsed !== PRIORITY_LEVELS.CUSTOM && + advancedGasFeeValues?.maxBaseFee + ) { + return multiplyCurrencyValues( + estimatedBaseFee, + advancedGasFeeValues.maxBaseFee, + numberOfDecimalsPrimary, + ); + } + return maxFeePerGas; + }); + + const [maxBaseFeeMultiplier, setMaxBaseFeeMultiplier] = useState(() => { + if ( + estimateUsed !== PRIORITY_LEVELS.CUSTOM && + advancedGasFeeValues?.maxBaseFee + ) { + return advancedGasFeeValues.maxBaseFee; + } + return divideCurrencyValues(maxFeePerGas, estimatedBaseFee); + }); + + const [, { value: baseFeeInFiat }] = useCurrencyDisplay( + decGWEIToHexWEI(maxBaseFeeGWEI), + { currency, numberOfDecimalsFiat }, + ); + + const updateBaseFee = useCallback( + (value) => { + let baseFeeInGWEI; + let baseFeeMultiplierValue; + if (editingInGwei) { + baseFeeInGWEI = value; + baseFeeMultiplierValue = divideCurrencyValues(value, estimatedBaseFee); + } else { + baseFeeInGWEI = multiplyCurrencyValues( + estimatedBaseFee, + value, + numberOfDecimalsPrimary, + ); + baseFeeMultiplierValue = value; + } + setMaxBaseFeeGWEI(baseFeeInGWEI); + setMaxBaseFeeMultiplier(baseFeeMultiplierValue); + setDirty(true); + }, + [ + editingInGwei, + estimatedBaseFee, + numberOfDecimalsPrimary, + setMaxBaseFeeGWEI, + setMaxBaseFeeMultiplier, + setDirty, + ], + ); + + useEffect(() => { + setMaxFeePerGas(maxBaseFeeGWEI); + }, [maxBaseFeeGWEI, setMaxFeePerGas]); + + return ( + + setEditingInGwei(!editingInGwei)} + > + + + } + value={editingInGwei ? maxBaseFeeGWEI : maxBaseFeeMultiplier} + detailText={ + editingInGwei + ? `${maxBaseFeeMultiplier}x ${`≈ ${baseFeeInFiat}`}` + : `${maxBaseFeeGWEI} GWEI ${`≈ ${baseFeeInFiat}`}` + } + numeric + /> + + + ); +}; + +export default BaseFeeInput; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/basefee-input.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/basefee-input.test.js new file mode 100644 index 000000000..7cef8ac15 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/basefee-input.test.js @@ -0,0 +1,118 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +import { GAS_ESTIMATE_TYPES } from '../../../../../../shared/constants/gas'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers'; +import mockEstimates from '../../../../../../test/data/mock-estimates.json'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { GasFeeContextProvider } from '../../../../../contexts/gasFee'; +import configureStore from '../../../../../store/store'; + +import { AdvanceGasFeePopoverContextProvider } from '../../context'; +import BaseFeeInput from './base-fee-input'; + +jest.mock('../../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + removePollingTokenFromAppState: jest.fn(), +})); + +const render = (txProps) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + advancedGasFee: { maxBaseFee: 2 }, + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: + mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, + }, + }); + + return renderWithProvider( + + + + + , + store, + ); +}; + +describe('BaseFeeInput', () => { + it('should renders advancedGasFee.baseFee value if current estimate used is not custom', () => { + render({ + userFeeLevel: 'high', + }); + expect(document.getElementsByTagName('input')[0]).toHaveValue(2); + }); + + it('should renders baseFee values from transaction if current estimate used is custom', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + expect(document.getElementsByTagName('input')[0]).toHaveValue(2); + }); + + it('should show GWEI value in input when Edit in GWEI link is clicked', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + fireEvent.click(screen.queryByText('Edit in GWEI')); + expect(document.getElementsByTagName('input')[0]).toHaveValue(100); + }); + + it('should correctly update GWEI value if multiplier is changed', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 4 }, + }); + fireEvent.click(screen.queryByText('Edit in GWEI')); + expect(document.getElementsByTagName('input')[0]).toHaveValue(200); + }); + + it('should correctly update multiplier value if GWEI is changed', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + expect(document.getElementsByTagName('input')[0]).toHaveValue(2); + fireEvent.click(screen.queryByText('Edit in GWEI')); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 200 }, + }); + fireEvent.click(screen.queryByText('Edit in multiplier')); + expect(document.getElementsByTagName('input')[0]).toHaveValue(4); + }); + + it('should show current value of estimatedBaseFee in subtext', () => { + render({ + txParams: { + maxFeePerGas: '0x174876E800', + }, + }); + expect(screen.queryByText('50')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.js new file mode 100644 index 000000000..61d1de89b --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.js @@ -0,0 +1 @@ +export { default } from './base-fee-input'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.scss b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.scss new file mode 100644 index 000000000..871c43df9 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index.scss @@ -0,0 +1,8 @@ +.base-fee-input { + a.base-fee-input__edit-link { + display: inline; + font-size: $font-size-h7; + padding: 0; + white-space: nowrap; + } +} diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.js new file mode 100644 index 000000000..787f2a6d8 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-fee-inputs'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.scss b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.scss new file mode 100644 index 000000000..55a27eafb --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/index.scss @@ -0,0 +1,14 @@ +.advanced-gas-fee-inputs { + .form-field { + margin-bottom: 4px; + } + + .form-field__heading-title > h6 { + font-size: $font-size-h7; + } + + &__separator { + border-top: 1px solid $ui-grey; + margin: 24px 0 16px 0; + } +} diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/index.js new file mode 100644 index 000000000..0fddc8c30 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/index.js @@ -0,0 +1 @@ +export { default } from './priority-fee-input'; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js new file mode 100644 index 000000000..e1d717fd8 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.js @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { PRIORITY_LEVELS } from '../../../../../../shared/constants/gas'; +import { SECONDARY } from '../../../../../helpers/constants/common'; +import { decGWEIToHexWEI } from '../../../../../helpers/utils/conversions.util'; +import { getAdvancedGasFeeValues } from '../../../../../selectors'; +import { useCurrencyDisplay } from '../../../../../hooks/useCurrencyDisplay'; +import { useGasFeeContext } from '../../../../../contexts/gasFee'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useUserPreferencedCurrency } from '../../../../../hooks/useUserPreferencedCurrency'; +import FormField from '../../../../ui/form-field'; + +import { useAdvanceGasFeePopoverContext } from '../../context'; +import AdvancedGasFeeInputSubtext from '../../advanced-gas-fee-input-subtext'; + +const PriorityFeeInput = () => { + const t = useI18nContext(); + const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues); + const { + setDirty, + setMaxPriorityFeePerGas, + } = useAdvanceGasFeePopoverContext(); + const { estimateUsed, maxPriorityFeePerGas } = useGasFeeContext(); + + const [priorityFee, setPriorityFee] = useState(() => { + if ( + estimateUsed !== PRIORITY_LEVELS.CUSTOM && + advancedGasFeeValues?.priorityFee + ) + return advancedGasFeeValues.priorityFee; + return maxPriorityFeePerGas; + }); + + const { currency, numberOfDecimals } = useUserPreferencedCurrency(SECONDARY); + + const [, { value: priorityFeeInFiat }] = useCurrencyDisplay( + decGWEIToHexWEI(priorityFee), + { currency, numberOfDecimals }, + ); + + const updatePriorityFee = (value) => { + setPriorityFee(value); + setDirty(true); + }; + + useEffect(() => { + setMaxPriorityFeePerGas(priorityFee); + }, [priorityFee, setMaxPriorityFeePerGas]); + + return ( + <> + + + + ); +}; + +export default PriorityFeeInput; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.test.js new file mode 100644 index 000000000..1a8733ba5 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-inputs/priority-fee-input/priority-fee-input.test.js @@ -0,0 +1,70 @@ +import React from 'react'; + +import { GAS_ESTIMATE_TYPES } from '../../../../../../shared/constants/gas'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers'; +import mockEstimates from '../../../../../../test/data/mock-estimates.json'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { GasFeeContextProvider } from '../../../../../contexts/gasFee'; +import configureStore from '../../../../../store/store'; + +import { AdvanceGasFeePopoverContextProvider } from '../../context'; +import PriorityfeeInput from './priority-fee-input'; + +jest.mock('../../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + removePollingTokenFromAppState: jest.fn(), +})); + +const render = (txProps) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + advancedGasFee: { priorityFee: 100 }, + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: + mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, + }, + }); + + return renderWithProvider( + + + + + , + store, + ); +}; + +describe('PriorityfeeInput', () => { + it('should renders advancedGasFee.priorityfee value if current estimate used is not custom', () => { + render({ + userFeeLevel: 'high', + }); + expect(document.getElementsByTagName('input')[0]).toHaveValue(100); + }); + + it('should renders priorityfee value from transaction if current estimate used is custom', () => { + render({ + txParams: { + maxPriorityFeePerGas: '0x77359400', + }, + }); + expect(document.getElementsByTagName('input')[0]).toHaveValue(2); + }); +}); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js new file mode 100644 index 000000000..cda544c81 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.js @@ -0,0 +1,39 @@ +import React from 'react'; + +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useTransactionModalContext } from '../../../contexts/transaction-modal'; +import Box from '../../ui/box'; +import Popover from '../../ui/popover'; + +import { AdvanceGasFeePopoverContextProvider } from './context'; +import AdvancedGasFeeInputs from './advanced-gas-fee-inputs'; +import AdvancedGasFeeSaveButton from './advanced-gas-fee-save'; + +const AdvancedGasFeePopover = () => { + const t = useI18nContext(); + const { + closeModal, + closeAllModals, + currentModal, + } = useTransactionModalContext(); + + if (currentModal !== 'advancedGasFee') return null; + + return ( + + closeModal('advancedGasFee')} + onClose={closeAllModals} + footer={} + > + + + + + + ); +}; + +export default AdvancedGasFeePopover; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js new file mode 100644 index 000000000..dd7f0f7a7 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-popover.test.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +import { GAS_ESTIMATE_TYPES } from '../../../../shared/constants/gas'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import mockEstimates from '../../../../test/data/mock-estimates.json'; +import mockState from '../../../../test/data/mock-state.json'; +import { GasFeeContextProvider } from '../../../contexts/gasFee'; +import configureStore from '../../../store/store'; + +import AdvancedGasFeePopover from './advanced-gas-fee-popover'; + +jest.mock('../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + removePollingTokenFromAppState: jest.fn(), +})); + +jest.mock('../../../contexts/transaction-modal', () => ({ + useTransactionModalContext: () => ({ + closeModal: () => undefined, + currentModal: 'advancedGasFee', + }), +})); + +const render = () => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + advancedGasFee: { priorityFee: 100 }, + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: + mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET].gasFeeEstimates, + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('AdvancedGasFeePopover', () => { + it('should renders save button disabled by default', () => { + render(); + expect(screen.queryByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('should enable save button as input value is changed', () => { + render(); + fireEvent.change(document.getElementsByTagName('input')[0], { + target: { value: 4 }, + }); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeDisabled(); + }); +}); diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js new file mode 100644 index 000000000..d8efd7c7c --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/advanced-gas-fee-save.js @@ -0,0 +1,37 @@ +import React from 'react'; + +import { PRIORITY_LEVELS } from '../../../../../shared/constants/gas'; +import { useTransactionModalContext } from '../../../../contexts/transaction-modal'; +import { useGasFeeContext } from '../../../../contexts/gasFee'; +import Button from '../../../ui/button'; +import I18nValue from '../../../ui/i18n-value'; + +import { useAdvanceGasFeePopoverContext } from '../context'; +import { decGWEIToHexWEI } from '../../../../../shared/modules/conversion.utils'; + +const AdvancedGasFeeSaveButton = () => { + const { closeModal } = useTransactionModalContext(); + const { updateTransaction } = useGasFeeContext(); + const { + isDirty, + maxFeePerGas, + maxPriorityFeePerGas, + } = useAdvanceGasFeePopoverContext(); + + const onSave = () => { + updateTransaction( + PRIORITY_LEVELS.CUSTOM, + decGWEIToHexWEI(maxFeePerGas), + decGWEIToHexWEI(maxPriorityFeePerGas), + ); + closeModal('advancedGasFee'); + }; + + return ( + + ); +}; + +export default AdvancedGasFeeSaveButton; diff --git a/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/index.js b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/index.js new file mode 100644 index 000000000..082c24190 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/advanced-gas-fee-save/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-fee-save'; diff --git a/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js b/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js new file mode 100644 index 000000000..a6bca0a33 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/context/advanceGasFeePopover.js @@ -0,0 +1,33 @@ +import React, { createContext, useContext, useState } from 'react'; +import PropTypes from 'prop-types'; + +export const AdvanceGasFeePopoverContext = createContext({}); + +export const AdvanceGasFeePopoverContextProvider = ({ children }) => { + const [maxFeePerGas, setMaxFeePerGas] = useState(); + const [maxPriorityFeePerGas, setMaxPriorityFeePerGas] = useState(); + const [isDirty, setDirty] = useState(); + + return ( + + {children} + + ); +}; + +export function useAdvanceGasFeePopoverContext() { + return useContext(AdvanceGasFeePopoverContext); +} + +AdvanceGasFeePopoverContextProvider.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/ui/components/app/advanced-gas-fee-popover/context/index.js b/ui/components/app/advanced-gas-fee-popover/context/index.js new file mode 100644 index 000000000..f9181147b --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/context/index.js @@ -0,0 +1 @@ +export * from './advanceGasFeePopover'; diff --git a/ui/components/app/advanced-gas-fee-popover/index.js b/ui/components/app/advanced-gas-fee-popover/index.js new file mode 100644 index 000000000..224b2237e --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-fee-popover'; diff --git a/ui/components/app/advanced-gas-fee-popover/index.scss b/ui/components/app/advanced-gas-fee-popover/index.scss new file mode 100644 index 000000000..604a983e5 --- /dev/null +++ b/ui/components/app/advanced-gas-fee-popover/index.scss @@ -0,0 +1,10 @@ +.advanced-gas-fee-popover { + &__wrapper { + border-top: 1px solid $ui-grey; + } + + &__separator { + border-top: 1px solid $ui-grey; + margin: 24px 0 16px 0; + } +} diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index d76c2998f..26d0d4fa1 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -13,6 +13,10 @@ @import 'connected-status-indicator/index'; @import 'edit-gas-display/index'; @import 'edit-gas-display-education/index'; +@import 'edit-gas-fee-popover/index'; +@import 'edit-gas-fee-popover/edit-gas-item/index'; +@import 'edit-gas-fee-popover/network-status/index'; +@import 'edit-gas-fee-popover/network-status/status-slider/index'; @import 'gas-customization/gas-modal-page-container/index'; @import 'gas-customization/gas-price-button-group/index'; @import 'gas-customization/index'; @@ -46,4 +50,10 @@ @import 'transaction-total-banner/index'; @import 'wallet-overview/index'; @import 'whats-new-popup/index'; -@import 'loading-network-screen/index' +@import 'loading-network-screen/index'; +@import 'flask/experimental-area/index'; +@import 'advanced-gas-fee-popover/index'; +@import 'advanced-gas-fee-popover/advanced-gas-fee-inputs/index'; +@import 'advanced-gas-fee-popover/advanced-gas-fee-inputs/base-fee-input/index'; +@import 'advanced-gas-fee-popover/advanced-gas-fee-input-subtext/index'; +@import 'transaction-decoding/index'; diff --git a/ui/components/app/collectibles-items/collectibles-items.component.js b/ui/components/app/collectibles-items/collectibles-items.component.js new file mode 100644 index 000000000..887653658 --- /dev/null +++ b/ui/components/app/collectibles-items/collectibles-items.component.js @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import Box from '../../ui/box'; +import Button from '../../ui/button'; +import Typography from '../../ui/typography/typography'; +import { + COLORS, + TYPOGRAPHY, + TEXT_ALIGN, + JUSTIFY_CONTENT, + FLEX_DIRECTION, + ALIGN_ITEMS, + DISPLAY, + BLOCK_SIZES, + SIZES, + FLEX_WRAP, +} from '../../../helpers/constants/design-system'; +import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; + +export default function CollectiblesItems({ onAddNFT, onRefreshList }) { + const t = useI18nContext(); + const collections = {}; + const defaultDropdownState = {}; + + Object.keys(collections).forEach((key) => { + defaultDropdownState[key] = true; + }); + + const [dropdownState, setDropdownState] = useState(defaultDropdownState); + const width = + getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + ? BLOCK_SIZES.ONE_THIRD + : BLOCK_SIZES.ONE_SIXTH; + return ( +
+ + <> + {Object.keys(collections).map((key, index) => { + const { icon, collectibles } = collections[key]; + const isExpanded = dropdownState[key]; + + return ( +
+ + + + + {`${key} (${collectibles.length})`} + + + + { + setDropdownState((_dropdownState) => ({ + ..._dropdownState, + [key]: !isExpanded, + })); + }} + /> + + + {isExpanded ? ( + + {collectibles.map((collectible, i) => { + return ( + + + + + + ); + })} + + ) : null} +
+ ); + })} + + + {t('missingNFT')} + + + + + + + {t('or')} + + + + + + + +
+
+ ); +} + +CollectiblesItems.propTypes = { + onAddNFT: PropTypes.func.isRequired, + onRefreshList: PropTypes.func.isRequired, +}; diff --git a/ui/components/app/collectibles-items/index.js b/ui/components/app/collectibles-items/index.js new file mode 100644 index 000000000..b8b286c20 --- /dev/null +++ b/ui/components/app/collectibles-items/index.js @@ -0,0 +1 @@ +export { default } from './collectibles-items.component'; diff --git a/ui/components/app/collectibles-list/index.js b/ui/components/app/collectibles-list/index.js deleted file mode 100644 index ab3fb9b56..000000000 --- a/ui/components/app/collectibles-list/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './collectibles-list.component'; diff --git a/ui/components/app/collectibles-list/collectibles-list.component.js b/ui/components/app/collectibles-tab/collectibles-tab.component.js similarity index 79% rename from ui/components/app/collectibles-list/collectibles-list.component.js rename to ui/components/app/collectibles-tab/collectibles-tab.component.js index 4fde98023..42c85d17a 100644 --- a/ui/components/app/collectibles-list/collectibles-list.component.js +++ b/ui/components/app/collectibles-tab/collectibles-tab.component.js @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import Box from '../../ui/box'; import Button from '../../ui/button'; import Typography from '../../ui/typography/typography'; +import NewCollectiblesNotice from '../new-collectibles-notice'; +import CollectiblesItems from '../collectibles-items'; import { COLORS, TYPOGRAPHY, @@ -13,16 +15,23 @@ import { } from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; -export default function CollectiblesList({ onAddNFT }) { +export default function CollectiblesTab({ onAddNFT }) { const collectibles = []; + const newNFTsDetected = false; const t = useI18nContext(); return ( -
+
{collectibles.length > 0 ? ( - {JSON.stringify(collectibles)} + { + console.log('refreshing collectibles'); + }} + /> ) : ( - + + {newNFTsDetected ? : null} @@ -76,6 +85,6 @@ export default function CollectiblesList({ onAddNFT }) { ); } -CollectiblesList.propTypes = { +CollectiblesTab.propTypes = { onAddNFT: PropTypes.func.isRequired, }; diff --git a/ui/components/app/collectibles-tab/index.js b/ui/components/app/collectibles-tab/index.js new file mode 100644 index 000000000..a69b09579 --- /dev/null +++ b/ui/components/app/collectibles-tab/index.js @@ -0,0 +1 @@ +export { default } from './collectibles-tab.component'; diff --git a/ui/components/app/confirm-page-container/confirm-page-container-container.test.js b/ui/components/app/confirm-page-container/confirm-page-container-container.test.js index fc0a5f669..757e86670 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-container.test.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-container.test.js @@ -58,7 +58,6 @@ describe('Confirm Page Container Container Test', () => { handleCloseEditGas: sinon.spy(), // Gas Popover currentTransaction: {}, - showAddToAddressBookModal: sinon.spy(), contact: undefined, isOwnedAccount: false, }; @@ -118,12 +117,6 @@ describe('Confirm Page Container Container Test', () => { ); }); - it('should simulate click on Dialog', () => { - const DialogWrapper = wrapper.find(Dialog); - DialogWrapper.first().simulate('click'); - expect(props.showAddToAddressBookModal.calledOnce).toStrictEqual(true); - }); - it('should not show add to address dialog if contact is not undefined', () => { props.contact = { address: '0x7a1A4Ad9cc746a70ee58568466f7996dD0aCE4E8', diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index 574967420..45a9a6daf 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Tabs, Tab } from '../../../ui/tabs'; import ErrorMessage from '../../../ui/error-message'; +import ActionableMessage from '../../../ui/actionable-message/actionable-message'; import { PageContainerFooter } from '../../../ui/page-container'; import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.'; @@ -14,9 +15,11 @@ export default class ConfirmPageContainerContent extends Component { static propTypes = { action: PropTypes.string, dataComponent: PropTypes.node, + dataHexComponent: PropTypes.node, detailsComponent: PropTypes.node, errorKey: PropTypes.string, errorMessage: PropTypes.string, + hasSimulationError: PropTypes.bool, hideSubtitle: PropTypes.bool, identiconAddress: PropTypes.string, nonce: PropTypes.string, @@ -31,11 +34,14 @@ export default class ConfirmPageContainerContent extends Component { onCancel: PropTypes.func, cancelText: PropTypes.string, onSubmit: PropTypes.func, + setUserAcknowledgedGasMissing: PropTypes.func, submitText: PropTypes.string, disabled: PropTypes.bool, + hideUserAcknowledgedGasMissing: PropTypes.bool, unapprovedTxCount: PropTypes.number, rejectNText: PropTypes.string, - hideTitle: PropTypes.boolean, + hideTitle: PropTypes.bool, + supportsEIP1559V2: PropTypes.bool, }; renderContent() { @@ -49,7 +55,7 @@ export default class ConfirmPageContainerContent extends Component { renderTabs() { const { t } = this.context; - const { detailsComponent, dataComponent } = this.props; + const { detailsComponent, dataComponent, dataHexComponent } = this.props; return ( @@ -62,6 +68,14 @@ export default class ConfirmPageContainerContent extends Component { {dataComponent} + {dataHexComponent && ( + + {dataHexComponent} + + )} ); } @@ -71,6 +85,7 @@ export default class ConfirmPageContainerContent extends Component { action, errorKey, errorMessage, + hasSimulationError, title, titleComponent, subtitleComponent, @@ -91,14 +106,33 @@ export default class ConfirmPageContainerContent extends Component { origin, ethGasPriceWarning, hideTitle, + setUserAcknowledgedGasMissing, + hideUserAcknowledgedGasMissing, + supportsEIP1559V2, } = this.props; + const primaryAction = hideUserAcknowledgedGasMissing + ? null + : { + label: this.context.t('tryAnywayOption'), + onClick: setUserAcknowledgedGasMissing, + }; + return (
{warning ? : null} {ethGasPriceWarning && ( )} + {hasSimulationError && ( +
+ +
+ )} {this.renderContent()} - {(errorKey || errorMessage) && ( -
- -
- )} + {!supportsEIP1559V2 && + !hasSimulationError && + (errorKey || errorMessage) && ( +
+ +
+ )} { + const mockStore = { + metamask: { + provider: { + type: 'test', + }, + }, + }; + + const store = configureMockStore()(mockStore); + + let props = {}; + + beforeEach(() => { + const mockOnCancel = jest.fn(); + const mockOnCancelAll = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockSetUserAcknowledgedGasMissing = jest.fn(); + props = { + action: ' Withdraw Stake', + errorMessage: null, + errorKey: null, + hasSimulationError: true, + onCancelAll: mockOnCancelAll, + onCancel: mockOnCancel, + cancelText: 'Reject', + onSubmit: mockOnSubmit, + setUserAcknowledgedGasMissing: mockSetUserAcknowledgedGasMissing, + submitText: 'Confirm', + disabled: true, + origin: 'http://localhost:4200', + hideTitle: false, + }; + }); + + it('render ConfirmPageContainer component with simulation error', async () => { + process.env.EIP_1559_V2 = false; + + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText('Transaction Error. Exception thrown in contract code.'), + ).not.toBeInTheDocument(); + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).toBeInTheDocument(); + expect(queryByText('I will try anyway')).toBeInTheDocument(); + + const confirmButton = getByText('Confirm'); + expect(getByText('Confirm').closest('button')).toBeDisabled(); + fireEvent.click(confirmButton); + expect(props.onSubmit).toHaveBeenCalledTimes(0); + + const iWillTryButton = getByText('I will try anyway'); + fireEvent.click(iWillTryButton); + expect(props.setUserAcknowledgedGasMissing).toHaveBeenCalledTimes(1); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + it('render ConfirmPageContainer component with another error', async () => { + props.hasSimulationError = false; + props.disabled = true; + props.errorKey = TRANSACTION_ERROR_KEY; + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).not.toBeInTheDocument(); + expect(queryByText('I will try anyway')).not.toBeInTheDocument(); + expect(getByText('Confirm').closest('button')).toBeDisabled(); + expect( + getByText('Transaction Error. Exception thrown in contract code.'), + ).toBeInTheDocument(); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + it('render ConfirmPageContainer component with no errors', async () => { + props.hasSimulationError = false; + props.disabled = false; + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).not.toBeInTheDocument(); + expect( + queryByText('Transaction Error. Exception thrown in contract code.'), + ).not.toBeInTheDocument(); + expect(queryByText('I will try anyway')).not.toBeInTheDocument(); + + const confirmButton = getByText('Confirm'); + fireEvent.click(confirmButton); + expect(props.onSubmit).toHaveBeenCalledTimes(1); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); +}); 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 1ae028115..05a5964fd 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 @@ -3,6 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import Identicon from '../../../../ui/identicon'; +import { useGasFeeContext } from '../../../../../contexts/gasFee'; const ConfirmPageContainerSummary = (props) => { const { @@ -18,6 +19,8 @@ const ConfirmPageContainerSummary = (props) => { hideTitle, } = props; + const { supportsEIP1559V2 } = useGasFeeContext(); + return (
{origin === 'metamask' ? null : ( @@ -45,7 +48,7 @@ const ConfirmPageContainerSummary = (props) => {
) : null}
- {hideSubtitle || ( + {!hideSubtitle && !supportsEIP1559V2 && (
{subtitleComponent}
diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss index c7655165b..2783e08d4 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -20,6 +20,10 @@ &__data { padding: 16px; color: $oslo-gray; + + & > .disclosure { + margin-top: 0; + } } &__data-box { @@ -27,9 +31,7 @@ background-color: #f9fafa; padding: 12px; - margin-bottom: 16px; word-wrap: break-word; - max-height: 200px; overflow-y: auto; &-label { diff --git a/ui/components/app/confirm-page-container/confirm-page-container.component.js b/ui/components/app/confirm-page-container/confirm-page-container.component.js index 117318e3d..351a6e37d 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container.component.js @@ -1,13 +1,21 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import SenderToRecipient from '../../ui/sender-to-recipient'; -import { PageContainerFooter } from '../../ui/page-container'; -import EditGasPopover from '../edit-gas-popover'; + import { EDIT_GAS_MODES } from '../../../../shared/constants/gas'; import { GasFeeContextProvider } from '../../../contexts/gasFee'; -import ErrorMessage from '../../ui/error-message'; import { TRANSACTION_TYPES } from '../../../../shared/constants/transaction'; + +import { PageContainerFooter } from '../../ui/page-container'; import Dialog from '../../ui/dialog'; +import ErrorMessage from '../../ui/error-message'; +import SenderToRecipient from '../../ui/sender-to-recipient'; + +import NicknamePopovers from '../modals/nickname-popovers'; + +import AdvancedGasFeePopover from '../advanced-gas-fee-popover'; +import EditGasFeePopover from '../edit-gas-fee-popover/edit-gas-fee-popover'; +import EditGasPopover from '../edit-gas-popover'; + import { ConfirmPageContainerHeader, ConfirmPageContainerContent, @@ -15,6 +23,10 @@ import { } from '.'; export default class ConfirmPageContainer extends Component { + state = { + showNicknamePopovers: false, + }; + static contextTypes = { t: PropTypes.func, }; @@ -42,6 +54,7 @@ export default class ConfirmPageContainer extends Component { errorKey: PropTypes.string, errorMessage: PropTypes.string, dataComponent: PropTypes.node, + dataHexComponent: PropTypes.node, detailsComponent: PropTypes.node, identiconAddress: PropTypes.string, nonce: PropTypes.string, @@ -69,9 +82,9 @@ export default class ConfirmPageContainer extends Component { handleCloseEditGas: PropTypes.func, // Gas Popover currentTransaction: PropTypes.object.isRequired, - showAddToAddressBookModal: PropTypes.func, contact: PropTypes.object, isOwnedAccount: PropTypes.bool, + supportsEIP1559V2: PropTypes.bool, }; render() { @@ -95,6 +108,7 @@ export default class ConfirmPageContainer extends Component { hideSubtitle, detailsComponent, dataComponent, + dataHexComponent, onCancelAll, onCancel, onSubmit, @@ -119,9 +133,9 @@ export default class ConfirmPageContainer extends Component { editingGas, handleCloseEditGas, currentTransaction, - showAddToAddressBookModal, contact = {}, isOwnedAccount, + supportsEIP1559V2, } = this.props; const showAddToAddressDialog = @@ -169,13 +183,23 @@ export default class ConfirmPageContainer extends Component {
{showAddToAddressDialog && ( - showAddToAddressBookModal()} - > - {this.context.t('newAccountDetectedDialogMessage')} - + <> + this.setState({ showNicknamePopovers: true })} + > + {this.context.t('newAccountDetectedDialogMessage')} + + {this.state.showNicknamePopovers ? ( + + this.setState({ showNicknamePopovers: false }) + } + address={toAddress} + /> + ) : null} + )}
{contentComponent || ( @@ -187,6 +211,7 @@ export default class ConfirmPageContainer extends Component { hideSubtitle={hideSubtitle} detailsComponent={detailsComponent} dataComponent={dataComponent} + dataHexComponent={dataHexComponent} errorMessage={errorMessage} errorKey={errorKey} identiconAddress={identiconAddress} @@ -203,6 +228,7 @@ export default class ConfirmPageContainer extends Component { origin={origin} ethGasPriceWarning={ethGasPriceWarning} hideTitle={hideTitle} + supportsEIP1559V2={supportsEIP1559V2} /> )} {shouldDisplayWarning && ( @@ -225,13 +251,15 @@ export default class ConfirmPageContainer extends Component { )} )} - {editingGas && ( + {editingGas && !supportsEIP1559V2 && ( )} + +
); diff --git a/ui/components/app/confirm-page-container/confirm-page-container.container.js b/ui/components/app/confirm-page-container/confirm-page-container.container.js index c5bc0396c..ec5ba84c0 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container.container.js +++ b/ui/components/app/confirm-page-container/confirm-page-container.container.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; import { getAccountsWithLabels, getAddressBookEntry } from '../../../selectors'; -import * as actions from '../../../store/actions'; import ConfirmPageContainer from './confirm-page-container.component'; function mapStateToProps(state, ownProps) { @@ -17,30 +16,4 @@ function mapStateToProps(state, ownProps) { }; } -function mapDispatchToProps(dispatch) { - return { - showAddToAddressBookModal: (recipient) => - dispatch( - actions.showModal({ - name: 'ADD_TO_ADDRESSBOOK', - recipient, - }), - ), - }; -} - -function mergeProps(stateProps, dispatchProps, ownProps) { - const { to, ...restStateProps } = stateProps; - return { - ...ownProps, - ...restStateProps, - showAddToAddressBookModal: () => - dispatchProps.showAddToAddressBookModal(to), - }; -} - -export default connect( - mapStateToProps, - mapDispatchToProps, - mergeProps, -)(ConfirmPageContainer); +export default connect(mapStateToProps)(ConfirmPageContainer); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js new file mode 100644 index 000000000..1f6037c26 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.js @@ -0,0 +1,83 @@ +import React from 'react'; + +import { PRIORITY_LEVELS } from '../../../../shared/constants/gas'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useTransactionModalContext } from '../../../contexts/transaction-modal'; +import ErrorMessage from '../../ui/error-message'; +import I18nValue from '../../ui/i18n-value'; +import LoadingHeartBeat from '../../ui/loading-heartbeat'; +import Popover from '../../ui/popover'; +import Typography from '../../ui/typography/typography'; + +import { COLORS } from '../../../helpers/constants/design-system'; +import { INSUFFICIENT_FUNDS_ERROR_KEY } from '../../../helpers/constants/error-keys'; +import { useGasFeeContext } from '../../../contexts/gasFee'; +import EditGasItem from './edit-gas-item'; +import NetworkStatus from './network-status'; + +const EditGasFeePopover = () => { + const { balanceError } = useGasFeeContext(); + const t = useI18nContext(); + const { closeModal, currentModal } = useTransactionModalContext(); + + if (currentModal !== 'editGasFee') return null; + + return ( + closeModal('editGasFee')} + className="edit-gas-fee-popover" + > + <> + {process.env.IN_TEST === 'true' ? null : } +
+
+ {balanceError && ( + + )} +
+ + + + + + + + + +
+ + + +
+ + + + + + + , + ]} + /> + +
+
+ + + ); +}; + +export default EditGasFeePopover; diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.test.js b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.test.js new file mode 100644 index 000000000..df06ae62c --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-fee-popover.test.js @@ -0,0 +1,114 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import { ETH } from '../../../helpers/constants/common'; +import configureStore from '../../../store/store'; +import { GasFeeContextProvider } from '../../../contexts/gasFee'; + +import EditGasFeePopover from './edit-gas-fee-popover'; + +jest.mock('../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), +})); + +jest.mock('../../../contexts/transaction-modal', () => ({ + useTransactionModalContext: () => ({ + closeModal: () => undefined, + currentModal: 'editGasFee', + }), +})); + +const MOCK_FEE_ESTIMATE = { + low: { + minWaitTimeEstimate: 360000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 30000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', +}; + +const render = (txProps) => { + const store = configureStore({ + metamask: { + nativeCurrency: ETH, + provider: {}, + cachedBalances: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x1F4', + }, + }, + selectedAddress: '0xAddress', + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: MOCK_FEE_ESTIMATE, + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('EditGasFeePopover', () => { + it('should renders low / medium / high options', () => { + render(); + + expect(screen.queryByText('🐢')).toBeInTheDocument(); + expect(screen.queryByText('🦊')).toBeInTheDocument(); + expect(screen.queryByText('🦍')).toBeInTheDocument(); + expect(screen.queryByText('🌐')).toBeInTheDocument(); + expect(screen.queryByText('⚙')).toBeInTheDocument(); + expect(screen.queryByText('Low')).toBeInTheDocument(); + expect(screen.queryByText('Market')).toBeInTheDocument(); + expect(screen.queryByText('Aggressive')).toBeInTheDocument(); + expect(screen.queryByText('Site')).toBeInTheDocument(); + expect(screen.queryByText('Advanced')).toBeInTheDocument(); + }); + + it('should show time estimates', () => { + render(); + expect(screen.queryAllByText('5 min')).toHaveLength(2); + expect(screen.queryByText('15 sec')).toBeInTheDocument(); + }); + + it('should show gas fee estimates', () => { + render(); + expect(screen.queryByTitle('0.001113 ETH')).toBeInTheDocument(); + expect(screen.queryByTitle('0.00147 ETH')).toBeInTheDocument(); + expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument(); + }); + + it('should not show insufficient balance message if transaction value is less than balance', () => { + render({ userFeeLevel: 'high', txParams: { value: '0x64' } }); + expect(screen.queryByText('Insufficient funds.')).not.toBeInTheDocument(); + }); + + it('should show insufficient balance message if transaction value is more than balance', () => { + render({ userFeeLevel: 'high', txParams: { value: '0x5208' } }); + expect(screen.queryByText('Insufficient funds.')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js new file mode 100644 index 000000000..7cd7aa281 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.js @@ -0,0 +1,158 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; + +import { getMaximumGasTotalInHexWei } from '../../../../../shared/modules/gas.utils'; +import { PRIORITY_LEVELS } from '../../../../../shared/constants/gas'; +import { PRIORITY_LEVEL_ICON_MAP } from '../../../../helpers/constants/gas'; +import { PRIMARY } from '../../../../helpers/constants/common'; +import { + decGWEIToHexWEI, + decimalToHex, + hexWEIToDecGWEI, +} from '../../../../helpers/utils/conversions.util'; +import { getAdvancedGasFeeValues } from '../../../../selectors'; +import { toHumanReadableTime } from '../../../../helpers/utils/util'; +import { useGasFeeContext } from '../../../../contexts/gasFee'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { useTransactionModalContext } from '../../../../contexts/transaction-modal'; +import I18nValue from '../../../ui/i18n-value'; +import InfoTooltip from '../../../ui/info-tooltip'; +import UserPreferencedCurrencyDisplay from '../../user-preferenced-currency-display'; + +import { useCustomTimeEstimate } from './useCustomTimeEstimate'; + +const EditGasItem = ({ priorityLevel }) => { + const { + estimateUsed, + gasFeeEstimates, + gasLimit, + maxFeePerGas: maxFeePerGasValue, + maxPriorityFeePerGas: maxPriorityFeePerGasValue, + updateTransactionUsingGasFeeEstimates, + transaction: { dappSuggestedGasFees }, + } = useGasFeeContext(); + const t = useI18nContext(); + const advancedGasFeeValues = useSelector(getAdvancedGasFeeValues); + const { closeModal, openModal } = useTransactionModalContext(); + + let maxFeePerGas; + let maxPriorityFeePerGas; + let minWaitTime; + + if (gasFeeEstimates?.[priorityLevel]) { + maxFeePerGas = gasFeeEstimates[priorityLevel].suggestedMaxFeePerGas; + } else if ( + priorityLevel === PRIORITY_LEVELS.DAPP_SUGGESTED && + dappSuggestedGasFees + ) { + maxFeePerGas = hexWEIToDecGWEI(dappSuggestedGasFees.maxFeePerGas); + maxPriorityFeePerGas = hexWEIToDecGWEI( + dappSuggestedGasFees.maxPriorityFeePerGas, + ); + } else if (priorityLevel === PRIORITY_LEVELS.CUSTOM) { + if (estimateUsed === PRIORITY_LEVELS.CUSTOM) { + maxFeePerGas = maxFeePerGasValue; + maxPriorityFeePerGas = maxPriorityFeePerGasValue; + } else if (advancedGasFeeValues) { + maxFeePerGas = + gasFeeEstimates.estimatedBaseFee * + parseFloat(advancedGasFeeValues.maxBaseFee); + maxPriorityFeePerGas = advancedGasFeeValues.priorityFee; + } + } + + const { waitTimeEstimate } = useCustomTimeEstimate({ + gasFeeEstimates, + maxFeePerGas, + maxPriorityFeePerGas, + }); + + if (gasFeeEstimates[priorityLevel]) { + minWaitTime = + priorityLevel === PRIORITY_LEVELS.HIGH + ? gasFeeEstimates?.high.minWaitTimeEstimate + : gasFeeEstimates?.low.maxWaitTimeEstimate; + } else { + minWaitTime = waitTimeEstimate; + } + + const hexMaximumTransactionFee = maxFeePerGas + ? getMaximumGasTotalInHexWei({ + gasLimit: decimalToHex(gasLimit), + maxFeePerGas: decGWEIToHexWEI(maxFeePerGas), + }) + : null; + + const onOptionSelect = () => { + if (priorityLevel === PRIORITY_LEVELS.CUSTOM) { + openModal('advancedGasFee'); + } else { + updateTransactionUsingGasFeeEstimates(priorityLevel); + closeModal('editGasFee'); + } + }; + + return ( + + ); +}; + +EditGasItem.propTypes = { + priorityLevel: PropTypes.string, +}; + +export default EditGasItem; diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js new file mode 100644 index 000000000..cd95b654f --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/edit-gas-item.test.js @@ -0,0 +1,148 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import { ETH } from '../../../../helpers/constants/common'; +import configureStore from '../../../../store/store'; +import { GasFeeContextProvider } from '../../../../contexts/gasFee'; + +import EditGasItem from './edit-gas-item'; + +jest.mock('../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + getGasFeeTimeEstimate: jest + .fn() + .mockImplementation(() => Promise.resolve('unknown')), +})); + +const MOCK_FEE_ESTIMATE = { + low: { + minWaitTimeEstimate: 360000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 30000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', +}; + +const DAPP_SUGGESTED_ESTIMATE = { + maxFeePerGas: '0x59682f10', + maxPriorityFeePerGas: '0x59682f00', +}; + +const renderComponent = (componentProps, transactionProps) => { + const store = configureStore({ + metamask: { + nativeCurrency: ETH, + provider: {}, + cachedBalances: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x176e5b6f173ebe66', + }, + }, + selectedAddress: '0xAddress', + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: MOCK_FEE_ESTIMATE, + advancedGasFee: { + maxBaseFee: '1.5', + priorityFee: '2', + }, + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('EditGasItem', () => { + it('should renders low gas estimate option for priorityLevel low', () => { + renderComponent({ priorityLevel: 'low' }); + expect(screen.queryByRole('button', { name: 'low' })).toBeInTheDocument(); + expect(screen.queryByText('🐢')).toBeInTheDocument(); + expect(screen.queryByText('Low')).toBeInTheDocument(); + expect(screen.queryByText('5 min')).toBeInTheDocument(); + expect(screen.queryByTitle('0.001113 ETH')).toBeInTheDocument(); + }); + + it('should renders market gas estimate option for priorityLevel medium', () => { + renderComponent({ priorityLevel: 'medium' }); + expect( + screen.queryByRole('button', { name: 'medium' }), + ).toBeInTheDocument(); + expect(screen.queryByText('🦊')).toBeInTheDocument(); + expect(screen.queryByText('Market')).toBeInTheDocument(); + expect(screen.queryByText('5 min')).toBeInTheDocument(); + expect(screen.queryByTitle('0.00147 ETH')).toBeInTheDocument(); + }); + + it('should renders aggressive gas estimate option for priorityLevel high', () => { + renderComponent({ priorityLevel: 'high' }); + expect(screen.queryByRole('button', { name: 'high' })).toBeInTheDocument(); + expect(screen.queryByText('🦍')).toBeInTheDocument(); + expect(screen.queryByText('Aggressive')).toBeInTheDocument(); + expect(screen.queryByText('15 sec')).toBeInTheDocument(); + expect(screen.queryByTitle('0.0021 ETH')).toBeInTheDocument(); + }); + + it('should highlight option is priorityLevel is currently selected', () => { + renderComponent({ priorityLevel: 'high' }, { userFeeLevel: 'high' }); + expect( + document.getElementsByClassName('edit-gas-item-selected'), + ).toHaveLength(1); + }); + + it('should renders site gas estimate option for priorityLevel dappSuggested', () => { + renderComponent( + { priorityLevel: 'dappSuggested' }, + { dappSuggestedGasFees: DAPP_SUGGESTED_ESTIMATE }, + ); + expect( + screen.queryByRole('button', { name: 'dappSuggested' }), + ).toBeInTheDocument(); + expect(screen.queryByText('🌐')).toBeInTheDocument(); + expect(screen.queryByText('Site')).toBeInTheDocument(); + expect(screen.queryByTitle('0.0000315 ETH')).toBeInTheDocument(); + }); + + it('should disable site gas estimate option for is transaction does not have dappSuggestedGasFees', async () => { + renderComponent({ priorityLevel: 'dappSuggested' }); + expect( + document.getElementsByClassName('edit-gas-item-disabled'), + ).toHaveLength(1); + }); + + it('should renders advance gas estimate option for priorityLevel custom', () => { + renderComponent({ priorityLevel: 'custom' }, { userFeeLevel: 'high' }); + expect( + screen.queryByRole('button', { name: 'custom' }), + ).toBeInTheDocument(); + expect(screen.queryByText('⚙')).toBeInTheDocument(); + expect(screen.queryByText('Advanced')).toBeInTheDocument(); + // below value of custom gas fee estimate is default obtained from state.metamask.advancedGasFee + expect(screen.queryByTitle('0.001575 ETH')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.js new file mode 100644 index 000000000..3ba916857 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.js @@ -0,0 +1 @@ +export { default } from './edit-gas-item'; diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss new file mode 100644 index 000000000..f29b80fce --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/index.scss @@ -0,0 +1,70 @@ +.edit-gas-item { + border-radius: 24px; + background: white; + color: $ui-4; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + margin: 12px 0; + padding: 4px 12px; + height: 32px; + width: 100%; + + &-selected { + background-color: $ui-1; + } + + &-disabled { + cursor: default; + } + + &__name { + display: inline-flex; + align-items: center; + color: $ui-black; + font-size: 12px; + font-weight: bold; + width: 40%; + } + + &__icon { + margin-right: 4px; + + &-custom { + font-size: 20px; + line-height: 0; + } + } + + &__time-estimate { + display: inline-block; + width: 20%; + } + + &__fee-estimate { + display: inline-block; + width: 30%; + white-space: nowrap; + } + + &__tooltip { + display: inline-block; + text-align: right; + width: 10%; + + .info-tooltip { + display: inline-block; + } + } + + &__time-estimate-low, + &__fee-estimate-high { + color: $secondary-1; + } + + &__time-estimate-medium, + &__time-estimate-high { + color: $success-3; + } +} diff --git a/ui/components/app/edit-gas-fee-popover/edit-gas-item/useCustomTimeEstimate.js b/ui/components/app/edit-gas-fee-popover/edit-gas-item/useCustomTimeEstimate.js new file mode 100644 index 000000000..41d6c59ed --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/edit-gas-item/useCustomTimeEstimate.js @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; + +import { GAS_ESTIMATE_TYPES } from '../../../../../shared/constants/gas'; +import { + getGasEstimateType, + getIsGasEstimatesLoading, +} from '../../../../ducks/metamask/metamask'; +import { getGasFeeTimeEstimate } from '../../../../store/actions'; + +export const useCustomTimeEstimate = ({ + gasFeeEstimates, + maxFeePerGas, + maxPriorityFeePerGas, +}) => { + const gasEstimateType = useSelector(getGasEstimateType); + const isGasEstimatesLoading = useSelector(getIsGasEstimatesLoading); + + const [customEstimatedTime, setCustomEstimatedTime] = useState(null); + + const returnNoEstimates = + isGasEstimatesLoading || + gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET || + !maxPriorityFeePerGas; + + // If the user has chosen a value lower than the low gas fee estimate, + // We'll need to use the useEffect hook below to make a call to calculate + // the time to show + const isUnknownLow = + gasFeeEstimates?.low && + Number(maxPriorityFeePerGas) < + Number(gasFeeEstimates.low.suggestedMaxPriorityFeePerGas); + + useEffect(() => { + if ( + isGasEstimatesLoading || + gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET || + !maxPriorityFeePerGas + ) + return; + if (isUnknownLow) { + // getGasFeeTimeEstimate requires parameters in string format + getGasFeeTimeEstimate( + new BigNumber(maxPriorityFeePerGas, 10).toString(10), + new BigNumber(maxFeePerGas, 10).toString(10), + ).then((result) => { + setCustomEstimatedTime(result); + }); + } + }, [ + gasEstimateType, + isUnknownLow, + isGasEstimatesLoading, + maxFeePerGas, + maxPriorityFeePerGas, + returnNoEstimates, + ]); + + if (returnNoEstimates) { + return {}; + } + + const { low = {}, medium = {}, high = {} } = gasFeeEstimates; + let waitTimeEstimate = ''; + + if ( + isUnknownLow && + customEstimatedTime && + customEstimatedTime !== 'unknown' && + customEstimatedTime?.upperTimeBound !== 'unknown' + ) { + waitTimeEstimate = Number(customEstimatedTime?.upperTimeBound); + } else if ( + Number(maxPriorityFeePerGas) >= Number(medium.suggestedMaxPriorityFeePerGas) + ) { + waitTimeEstimate = high.minWaitTimeEstimate; + } else { + waitTimeEstimate = low.maxWaitTimeEstimate; + } + + return { waitTimeEstimate }; +}; diff --git a/ui/components/app/edit-gas-fee-popover/index.js b/ui/components/app/edit-gas-fee-popover/index.js new file mode 100644 index 000000000..d2e6862b9 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/index.js @@ -0,0 +1 @@ +export { default } from './edit-gas-fee-popover'; diff --git a/ui/components/app/edit-gas-fee-popover/index.scss b/ui/components/app/edit-gas-fee-popover/index.scss new file mode 100644 index 000000000..581cb5f6b --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/index.scss @@ -0,0 +1,51 @@ +.edit-gas-fee-popover { + height: 500px; + + &__wrapper { + border-top: 1px solid $ui-grey; + } + + &__content { + padding: 16px 12px; + + & .error-message { + margin-top: 0; + margin-bottom: 12px; + } + + &__header { + color: $ui-4; + font-size: 10px; + font-weight: 700; + margin: 0 12px; + + &-option { + display: inline-block; + width: 40%; + } + + &-time { + display: inline-block; + width: 20%; + } + + &-max-fee { + display: inline-block; + width: 30%; + } + } + + &__separator { + border-top: 1px solid $ui-grey; + margin: 8px 12px; + } + } + + &__network-status { + margin-top: 36px; + } + + &__know-more a { + color: $primary-1; + } +} diff --git a/ui/components/app/edit-gas-fee-popover/network-status/index.js b/ui/components/app/edit-gas-fee-popover/network-status/index.js new file mode 100644 index 000000000..0ba1f18f3 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/index.js @@ -0,0 +1 @@ +export { default } from './network-status'; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/index.scss b/ui/components/app/edit-gas-fee-popover/network-status/index.scss new file mode 100644 index 000000000..db6d37299 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/index.scss @@ -0,0 +1,41 @@ +.network-status { + margin: 24px 0 12px; + + &__info { + border-top: 1px solid $ui-2; + border-bottom: 1px solid $ui-2; + height: 56px; + display: flex; + align-items: center; + + &__separator { + border-left: 1px solid $ui-2; + height: 65%; + } + + &__field { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 30%; + + &--priority-fee { + width: 40%; + } + + &-data { + color: $ui-4; + font-size: 12px; + text-align: center; + } + + &-label { + color: $Black-100; + font-size: 10px; + font-weight: bold; + margin-top: 4px; + } + } + } +} diff --git a/ui/components/app/edit-gas-fee-popover/network-status/network-status.js b/ui/components/app/edit-gas-fee-popover/network-status/network-status.js new file mode 100644 index 000000000..bd102f81e --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/network-status.js @@ -0,0 +1,52 @@ +import React from 'react'; + +import { COLORS } from '../../../../helpers/constants/design-system'; +import { useGasFeeContext } from '../../../../contexts/gasFee'; +import I18nValue from '../../../ui/i18n-value'; +import Typography from '../../../ui/typography/typography'; + +import StatusSlider from './status-slider'; + +const NetworkStatus = () => { + const { gasFeeEstimates } = useGasFeeContext(); + + return ( +
+ + + +
+
+ + {gasFeeEstimates?.estimatedBaseFee && + `${gasFeeEstimates?.estimatedBaseFee} GWEI`} + + Base fee +
+
+
+ + 0.5 - 22 GWEI + + + Priority fee + +
+
+
+ +
+
+
+ ); +}; + +NetworkStatus.propTypes = {}; + +export default NetworkStatus; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js b/ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js new file mode 100644 index 000000000..7981b5a72 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/network-status.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { renderWithProvider } from '../../../../../test/jest'; +import { ETH } from '../../../../helpers/constants/common'; +import { GasFeeContextProvider } from '../../../../contexts/gasFee'; +import configureStore from '../../../../store/store'; + +import NetworkStatus from './network-status'; + +jest.mock('../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + getGasFeeTimeEstimate: jest + .fn() + .mockImplementation(() => Promise.resolve('unknown')), +})); + +const MOCK_FEE_ESTIMATE = { + estimatedBaseFee: '50.0112', +}; + +const renderComponent = (props) => { + const store = configureStore({ + metamask: { + nativeCurrency: ETH, + provider: {}, + cachedBalances: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x176e5b6f173ebe66', + }, + }, + selectedAddress: '0xAddress', + featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: MOCK_FEE_ESTIMATE, + ...props, + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('NetworkStatus', () => { + it('should renders labels', () => { + renderComponent(); + expect(screen.queryByText('Base fee')).toBeInTheDocument(); + expect(screen.queryByText('Priority fee')).toBeInTheDocument(); + }); + + it('should renders current base fee value', () => { + renderComponent(); + expect( + screen.queryByText(`${MOCK_FEE_ESTIMATE.estimatedBaseFee} GWEI`), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.js b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.js new file mode 100644 index 000000000..a9105f5f7 --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.js @@ -0,0 +1 @@ +export { default } from './status-slider'; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss new file mode 100644 index 000000000..2ae4daf0c --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/index.scss @@ -0,0 +1,42 @@ +.status-slider { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 55%; + + &__line { + background-image: linear-gradient(to right, #037dd6, #d73a49); + height: 4px; + width: 100%; + border-radius: 100px; + display: block; + } + + &__label { + font-size: 10px; + font-weight: bold; + margin-top: 4px; + } + + &__arrow-border { + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid white; + position: relative; + margin-bottom: -2px; + } + + &__arrow { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid black; + position: absolute; + bottom: 3px; + left: -5px; + } +} diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js new file mode 100644 index 000000000..8271cac6a --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.js @@ -0,0 +1,53 @@ +import React from 'react'; + +import I18nValue from '../../../../ui/i18n-value'; + +const GRADIENT_COLORS = [ + '#037DD6', + '#1876C8', + '#2D70BA', + '#4369AB', + '#57629E', + '#6A5D92', + '#805683', + '#9A4D71', + '#B44561', + '#C54055', +]; + +const StatusSlider = () => { + // todo: value below to be replaced with dynamic values from api once it is available + // corresponding test cases also to be added + const statusValue = 0.5; + const sliderValueNumeric = Math.round(statusValue * 10); + + let statusLabel = 'stable'; + if (statusValue <= 0.33) { + statusLabel = 'notBusy'; + } else if (statusValue > 0.66) { + statusLabel = 'busy'; + } + + return ( +
+
+
+
+
+
+ +
+
+ ); +}; + +export default StatusSlider; diff --git a/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js new file mode 100644 index 000000000..a10ed82bb --- /dev/null +++ b/ui/components/app/edit-gas-fee-popover/network-status/status-slider/status-slider.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { renderWithProvider } from '../../../../../../test/jest'; +import { ETH } from '../../../../../helpers/constants/common'; +import { GasFeeContextProvider } from '../../../../../contexts/gasFee'; +import configureStore from '../../../../../store/store'; + +import StatusSlider from './status-slider'; + +jest.mock('../../../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), + getGasFeeTimeEstimate: jest + .fn() + .mockImplementation(() => Promise.resolve('unknown')), +})); + +const renderComponent = () => { + const store = configureStore({ + metamask: { + nativeCurrency: ETH, + provider: {}, + cachedBalances: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x176e5b6f173ebe66', + }, + }, + selectedAddress: '0xAddress', + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('NetworkStatus', () => { + it('should renders stable for statusValue > 0.33 and <= 0.66', () => { + renderComponent(); + expect(screen.queryByText('Stable')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/flask/experimental-area/experimental-area.js b/ui/components/app/flask/experimental-area/experimental-area.js new file mode 100644 index 000000000..49fa52ca8 --- /dev/null +++ b/ui/components/app/flask/experimental-area/experimental-area.js @@ -0,0 +1,97 @@ +import React, { useContext } from 'react'; +import { useHistory } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { I18nContext } from '../../../../contexts/i18n'; +import Button from '../../../ui/button'; + +function lineBreaksToBr(source) { + return source.split('\n').map((value) => { + return ( + <> + {value} +
+ + ); + }); +} + +const METAMASK_LOGO = lineBreaksToBr(`MMm*mmMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmm*mMM +MM*./***mMMMMMMMMMMMMMMMMMMMMMMMMMMm***/.*MM +MM/...///*mMMMMMMMMMMMMMMMMMMMMMMm*///.../MM +Mm.....//../*mMMMMMMMMMMMMMMMMm*/..//.....mM +M*....../*....*mMMMMMMMMMMMMm*....*/......*M +M/........*.....*//////////*...../......../M +m..........*/...//........//.../*..........m +M/..........//.../......../...//........../M +M/.........../*/./.......//./*/.........../M +M*.............////......////.............*M +Mm...............**......**...............mM +Mm/...............*/..../*.............../mM +MM/............../*/..../*/............../MM +Mm..............//./...././/..............mM +MM*............*/../..../../*............*MM +MM/........../*..../..../....*/........../MM +MMm.........//...../..../.....//.........mMM +MMm......//**....../..../......**//......mMM +MMM/..////.*......./..../......././///../MMM +MMMm*//..../......./..../......./....//*mMMM +MMMm......*////////*....*////////*......mMMM +MMM*......*////////*....*////////*......*MMM +MMM/....../*......./..../.......*/....../MMM +MMm........**/./m*./..../.**/..**........mMM +MM*........//*mMMM///..///mMMm*//........*MM +MM/........././*mM*//..//*Mm*/./........./MM +Mm..........//.../**/../**/...//..........mM +M*...........*..../*/../*/..../...........*M +M*///////////*/.../m/../m/.../*///////////*M +M*.........../*/...m/../m.../*/...........*M +Mm.........../..//.*....*./*../...........mM +MM/........../...//******//.../........../MM +MM*........../....*MMMMMM*..../..........*MM +MMm........../....*MMMMMM*..../..........mMM +MMm/........//....*MMMMMM*....//......../mMM +MMM/....../*mm*...*mmmmmm*...*mm*/....../MMM +MMM*../*mmMMMMMm///......//*mMMMMMmm*/..*MMM +MMMm*mMMMMMMMMMMm**......**mMMMMMMMMMMm*mMMM +MMMMMMMMMMMMMMMMMm/....../mMMMMMMMMMMMMMMMMM +MMMMMMMMMMMMMMMMMMmmmmmmmmMMMMMMMMMMMMMMMMMM`); + +/* eslint-disable no-irregular-whitespace */ +const EXPERIMENTAL_AREA = lineBreaksToBr(`█▄█ █▀█ █░█ ▀ █▀█ █▀▀   █▀▀ █▄░█ ▀█▀ █▀▀ █▀█ █ █▄░█ █▀▀   ▄▀█ █▄░█ +░█░ █▄█ █▄█ ░ █▀▄ ██▄   ██▄ █░▀█ ░█░ ██▄ █▀▄ █ █░▀█ █▄█   █▀█ █░▀█ + +█▀▀ ▀▄▀ █▀█ █▀▀ █▀█ █ █▀▄▀█ █▀▀ █▄░█ ▀█▀ ▄▀█ █░░   ▄▀█ █▀█ █▀▀ ▄▀█ +██▄ █░█ █▀▀ ██▄ █▀▄ █ █░▀░█ ██▄ █░▀█ ░█░ █▀█ █▄▄   █▀█ █▀▄ ██▄ █▀█`); +/* eslint-enable no-irregular-whitespace */ + +export default function ExperimentalArea({ redirectTo }) { + const t = useContext(I18nContext); + const history = useHistory(); + + const onClick = () => { + history.push(redirectTo); + }; + + return ( +
+
{METAMASK_LOGO}
+
{EXPERIMENTAL_AREA}
+
+ {t('flaskExperimentalText1')} +
    +
  • {t('flaskExperimentalText2')}
  • +
  • {t('flaskExperimentalText3')}
  • +
  • {t('flaskExperimentalText4')}
  • +
+ {t('flaskExperimentalText5')} +
+ +
+ ); +} + +ExperimentalArea.propTypes = { + redirectTo: PropTypes.string, +}; diff --git a/ui/components/app/flask/experimental-area/experimental-area.stories.js b/ui/components/app/flask/experimental-area/experimental-area.stories.js new file mode 100644 index 000000000..55e993049 --- /dev/null +++ b/ui/components/app/flask/experimental-area/experimental-area.stories.js @@ -0,0 +1,13 @@ +import React from 'react'; + +import ExperimentalArea from '.'; + +export default { + title: 'Components/App/Flask/ExperimentalArea', + id: __filename, + component: ExperimentalArea, +}; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/flask/experimental-area/index.js b/ui/components/app/flask/experimental-area/index.js new file mode 100644 index 000000000..716ab972d --- /dev/null +++ b/ui/components/app/flask/experimental-area/index.js @@ -0,0 +1 @@ +export { default } from './experimental-area'; diff --git a/ui/components/app/flask/experimental-area/index.scss b/ui/components/app/flask/experimental-area/index.scss new file mode 100644 index 000000000..c743033da --- /dev/null +++ b/ui/components/app/flask/experimental-area/index.scss @@ -0,0 +1,54 @@ +.experimental-area { + color: $flask-purple; + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .logo { + padding: 16px 8px 0; + line-height: 0.625em; + font-family: monospace; + font-size: $font-size-h8; + margin-bottom: 32px; + } + + .experimental-text { + padding: 16px 8px 0; + font-family: monospace; + font-size: $font-size-h5; + margin: auto; + line-height: 0.875em; + margin-bottom: 32px; + } + + .text { + @include H5; + + padding: 16px; + max-width: 670px; + } + + ul { + padding: 16px 0; + } + + li { + list-style-type: disc; + padding-left: 8px; + list-style-position: inside; + margin-bottom: 8px; + + &:nth-last-child(1) { + margin-bottom: 0; + } + } + + button { + background-color: $flask-purple !important; + border: 0 !important; + color: $ui-1; + width: 200px; + } +} diff --git a/ui/components/app/gas-timing/gas-timing.component.js b/ui/components/app/gas-timing/gas-timing.component.js index 41fc6bed5..49ba84075 100644 --- a/ui/components/app/gas-timing/gas-timing.component.js +++ b/ui/components/app/gas-timing/gas-timing.component.js @@ -28,8 +28,6 @@ import { useGasFeeContext } from '../../../contexts/gasFee'; // Once we reach this second threshold, we switch to minutes as a unit const SECOND_CUTOFF = 90; -// eslint-disable-next-line prefer-destructuring -const EIP_1559_V2 = process.env.EIP_1559_V2; // Shows "seconds" as unit of time if under SECOND_CUTOFF, otherwise "minutes" const toHumanReadableTime = (milliseconds = 1, t) => { @@ -50,7 +48,7 @@ export default function GasTiming({ const [customEstimatedTime, setCustomEstimatedTime] = useState(null); const t = useContext(I18nContext); - const { estimateToUse } = useGasFeeContext(); + const { estimateUsed, supportsEIP1559V2 } = useGasFeeContext(); // If the user has chosen a value lower than the low gas fee estimate, // We'll need to use the useEffect hook below to make a call to calculate @@ -97,7 +95,7 @@ export default function GasTiming({ ]); let unknownProcessingTimeText; - if (EIP_1559_V2) { + if (supportsEIP1559V2) { unknownProcessingTimeText = t('editGasTooLow'); } else { unknownProcessingTimeText = ( @@ -155,7 +153,7 @@ export default function GasTiming({ ]); } } else { - if (!EIP_1559_V2 || estimateToUse === 'low') { + if (!supportsEIP1559V2 || estimateUsed === 'low') { attitude = 'negative'; } // If the user has chosen a value less than our low estimate, @@ -176,7 +174,7 @@ export default function GasTiming({ } } // code below needs to cleaned-up once EIP_1559_V2 flag is removed - else if (EIP_1559_V2) { + else if (supportsEIP1559V2) { text = t('gasTimingNegative', [ toHumanReadableTime(low.maxWaitTimeEstimate, t), ]); @@ -199,8 +197,8 @@ export default function GasTiming({ {text} diff --git a/ui/components/app/modals/nickname-popovers/index.js b/ui/components/app/modals/nickname-popovers/index.js new file mode 100644 index 000000000..b18a3fcb7 --- /dev/null +++ b/ui/components/app/modals/nickname-popovers/index.js @@ -0,0 +1 @@ +export { default } from './nickname-popovers.component'; diff --git a/ui/components/app/modals/nickname-popovers/nickname-popovers.component.js b/ui/components/app/modals/nickname-popovers/nickname-popovers.component.js new file mode 100644 index 000000000..664358a66 --- /dev/null +++ b/ui/components/app/modals/nickname-popovers/nickname-popovers.component.js @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { getAccountLink } from '@metamask/etherscan-link'; +import { addToAddressBook } from '../../../../store/actions'; +import { + getRpcPrefsForCurrentProvider, + getCurrentChainId, + getAddressBook, +} from '../../../../selectors'; +import NicknamePopover from '../../../ui/nickname-popover'; +import UpdateNicknamePopover from '../../../ui/update-nickname-popover/update-nickname-popover'; + +const SHOW_NICKNAME_POPOVER = 'SHOW_NICKNAME_POPOVER'; +const ADD_NICKNAME_POPOVER = 'ADD_NICKNAME_POPOVER'; + +const NicknamePopovers = ({ address, onClose }) => { + const dispatch = useDispatch(); + + const [popoverToDisplay, setPopoverToDisplay] = useState( + SHOW_NICKNAME_POPOVER, + ); + + const addressBook = useSelector(getAddressBook); + const chainId = useSelector(getCurrentChainId); + + const addressBookEntryObject = addressBook.find( + (entry) => entry.address === address, + ); + + const recipientNickname = addressBookEntryObject?.name; + const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); + + const explorerLink = getAccountLink( + address, + chainId, + { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null }, + null, + ); + + if (popoverToDisplay === ADD_NICKNAME_POPOVER) { + return ( + setPopoverToDisplay(SHOW_NICKNAME_POPOVER)} + onAdd={(recipient, nickname, memo) => + dispatch(addToAddressBook(recipient, nickname, memo)) + } + /> + ); + } + + // SHOW_NICKNAME_POPOVER case + return ( + setPopoverToDisplay(ADD_NICKNAME_POPOVER)} + explorerLink={explorerLink} + /> + ); +}; + +NicknamePopovers.propTypes = { + address: PropTypes.string, + onClose: PropTypes.func, +}; + +export default NicknamePopovers; diff --git a/ui/components/app/new-collectibles-notice/index.js b/ui/components/app/new-collectibles-notice/index.js new file mode 100644 index 000000000..2f54229ac --- /dev/null +++ b/ui/components/app/new-collectibles-notice/index.js @@ -0,0 +1 @@ +export { default } from './new-collectibles-notice.component'; diff --git a/ui/components/app/new-collectibles-notice/new-collectibles-notice.component.js b/ui/components/app/new-collectibles-notice/new-collectibles-notice.component.js new file mode 100644 index 000000000..0d77128ca --- /dev/null +++ b/ui/components/app/new-collectibles-notice/new-collectibles-notice.component.js @@ -0,0 +1,56 @@ +import React from 'react'; +import Box from '../../ui/box'; +import Dialog from '../../ui/dialog'; +import Typography from '../../ui/typography/typography'; +import { + COLORS, + TYPOGRAPHY, + TEXT_ALIGN, + FONT_WEIGHT, + DISPLAY, +} from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export default function NewCollectiblesNotice() { + const t = useI18nContext(); + + return ( + + + + + + + + + {t('newNFTsDetected')} + + + {t('newNFTsDetectedInfo')} + + { + e.preventDefault(); + console.log('show preference popover'); + }} + style={{ fontSize: '.9rem' }} + > + {t('selectNFTPrivacyPreference')} + + + + + + ); +} diff --git a/ui/components/app/qr-hardware-popover/base-reader.js b/ui/components/app/qr-hardware-popover/base-reader.js new file mode 100644 index 000000000..5743dc3c4 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/base-reader.js @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from 'react'; +import log from 'loglevel'; +import { URDecoder } from '@ngraveio/bc-ur'; +import PropTypes from 'prop-types'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; +import WebcamUtils from '../../../helpers/utils/webcam-utils'; +import PageContainerFooter from '../../ui/page-container/page-container-footer/page-container-footer.component'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { SECOND } from '../../../../shared/constants/time'; +import EnhancedReader from './enhanced-reader'; + +const READY_STATE = { + ACCESSING_CAMERA: 'ACCESSING_CAMERA', + NEED_TO_ALLOW_ACCESS: 'NEED_TO_ALLOW_ACCESS', + READY: 'READY', +}; + +const BaseReader = ({ + isReadingWallet, + handleCancel, + handleSuccess, + setErrorTitle, +}) => { + const t = useI18nContext(); + const [ready, setReady] = useState(READY_STATE.ACCESSING_CAMERA); + const [error, setError] = useState(null); + const [urDecoder, setURDecoder] = useState(new URDecoder()); + + let permissionChecker = null; + const mounted = useRef(false); + + const reset = () => { + setReady(READY_STATE.ACCESSING_CAMERA); + setError(null); + setURDecoder(new URDecoder()); + }; + + const checkEnvironment = async () => { + try { + const { environmentReady } = await WebcamUtils.checkStatus(); + if ( + !environmentReady && + getEnvironmentType() !== ENVIRONMENT_TYPE_FULLSCREEN + ) { + const currentUrl = new URL(window.location.href); + const currentHash = currentUrl.hash; + const currentRoute = currentHash ? currentHash.substring(1) : null; + global.platform.openExtensionInBrowser(currentRoute); + } + } catch (e) { + if (mounted.current) { + setError(e); + } + } + // initial attempt is required to trigger permission prompt + // eslint-disable-next-line no-use-before-define + return initCamera(); + }; + + const checkPermissions = async () => { + try { + const { permissions } = await WebcamUtils.checkStatus(); + if (permissions) { + // Let the video stream load first... + await new Promise((resolve) => setTimeout(resolve, SECOND * 2)); + if (!mounted.current) { + return; + } + setReady(READY_STATE.READY); + } else if (mounted.current) { + // Keep checking for permissions + permissionChecker = setTimeout(checkPermissions, SECOND); + setReady(READY_STATE.NEED_TO_ALLOW_ACCESS); + } + } catch (e) { + if (mounted.current) { + setError(e); + } + } + }; + + const handleScan = (data) => { + try { + if (!data) { + return; + } + urDecoder.receivePart(data); + if (urDecoder.isComplete()) { + const result = urDecoder.resultUR(); + handleSuccess(result).catch(setError); + } + } catch (e) { + if (isReadingWallet) { + setErrorTitle(t('QRHardwareUnknownQRCodeTitle')); + } else { + setErrorTitle(t('QRHardwareInvalidTransactionTitle')); + } + setError(new Error(t('unknownQrCode'))); + } + }; + + const initCamera = () => { + try { + checkPermissions(); + } catch (e) { + if (!mounted.current) { + return; + } + if (e.name === 'NotAllowedError') { + log.info(`Permission denied: '${e}'`); + setReady(READY_STATE.NEED_TO_ALLOW_ACCESS); + } else { + setError(e); + } + } + }; + + useEffect(() => { + mounted.current = true; + checkEnvironment(); + return () => { + mounted.current = false; + clearTimeout(permissionChecker); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (ready === READY_STATE.READY) { + initCamera(); + } else if (ready === READY_STATE.NEED_TO_ALLOW_ACCESS) { + checkPermissions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready]); + + const tryAgain = () => { + clearTimeout(permissionChecker); + reset(); + checkEnvironment(); + }; + + const renderError = () => { + let title, msg; + if (error.type === 'NO_WEBCAM_FOUND') { + title = t('noWebcamFoundTitle'); + msg = t('noWebcamFound'); + } else if (error.message === t('unknownQrCode')) { + if (isReadingWallet) { + msg = t('QRHardwareUnknownWalletQRCode'); + } else { + msg = t('unknownQrCode'); + } + } else if (error.message === t('QRHardwareMismatchedSignId')) { + msg = t('QRHardwareMismatchedSignId'); + } else { + title = t('unknownCameraErrorTitle'); + msg = t('unknownCameraError'); + } + + return ( + <> +
+ +
+ {title ?
{title}
: null} +
{msg}
+ { + setErrorTitle(''); + handleCancel(); + }} + onSubmit={() => { + setErrorTitle(''); + tryAgain(); + }} + cancelText={t('cancel')} + submitText={t('tryAgain')} + submitButtonType="confirm" + /> + + ); + }; + + const renderVideo = () => { + let message; + if (ready === READY_STATE.ACCESSING_CAMERA) { + message = t('accessingYourCamera'); + } else if (ready === READY_STATE.READY) { + message = t('QRHardwareScanInstructions'); + } else if (ready === READY_STATE.NEED_TO_ALLOW_ACCESS) { + message = t('youNeedToAllowCameraAccess'); + } + return ( + <> +
+ +
+ {message &&
{message}
} + + ); + }; + + return ( +
{error ? renderError() : renderVideo()}
+ ); +}; + +BaseReader.propTypes = { + isReadingWallet: PropTypes.bool.isRequired, + handleCancel: PropTypes.func.isRequired, + handleSuccess: PropTypes.func.isRequired, + setErrorTitle: PropTypes.func.isRequired, +}; + +export default BaseReader; diff --git a/ui/components/app/qr-hardware-popover/enhanced-reader.js b/ui/components/app/qr-hardware-popover/enhanced-reader.js new file mode 100644 index 000000000..d1d696717 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/enhanced-reader.js @@ -0,0 +1,67 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { BarcodeFormat, DecodeHintType } from '@zxing/library'; +import { BrowserQRCodeReader } from '@zxing/browser'; +import log from 'loglevel'; +import PropTypes from 'prop-types'; +import { MILLISECOND } from '../../../../shared/constants/time'; +import Spinner from '../../ui/spinner'; + +const EnhancedReader = ({ handleScan }) => { + const [canplay, setCanplay] = useState(false); + const codeReader = useMemo(() => { + const hint = new Map(); + hint.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.QR_CODE]); + return new BrowserQRCodeReader(hint, { + delayBetweenScanAttempts: MILLISECOND * 100, + delayBetweenScanSuccess: MILLISECOND * 100, + }); + }, []); + + useEffect(() => { + const videoElem = document.getElementById('video'); + const canplayListener = () => { + setCanplay(true); + }; + videoElem.addEventListener('canplay', canplayListener); + const promise = codeReader.decodeFromVideoDevice( + undefined, + 'video', + (result) => { + if (result) { + handleScan(result.getText()); + } + }, + ); + return () => { + videoElem.removeEventListener('canplay', canplayListener); + promise + .then((controls) => { + if (controls) { + controls.stop(); + } + }) + .catch(log.info); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ ); +}; + +EnhancedReader.propTypes = { + handleScan: PropTypes.func.isRequired, +}; + +export default EnhancedReader; diff --git a/ui/components/app/qr-hardware-popover/index.js b/ui/components/app/qr-hardware-popover/index.js new file mode 100644 index 000000000..2ccbb2100 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/index.js @@ -0,0 +1,3 @@ +import QRHardwarePopover from './qr-hardware-popover'; + +export default QRHardwarePopover; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-popover.js b/ui/components/app/qr-hardware-popover/qr-hardware-popover.js new file mode 100644 index 000000000..c3875c4e8 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-popover.js @@ -0,0 +1,102 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getCurrentQRHardwareState } from '../../../selectors'; +import Popover from '../../ui/popover'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + cancelSyncQRHardware as cancelSyncQRHardwareAction, + cancelQRHardwareSignRequest as cancelQRHardwareSignRequestAction, + cancelTx, + cancelPersonalMsg, + cancelMsg, + cancelTypedMsg, +} from '../../../store/actions'; +import { MESSAGE_TYPE } from '../../../../shared/constants/app'; +import QRHardwareWalletImporter from './qr-hardware-wallet-importer'; +import QRHardwareSignRequest from './qr-hardware-sign-request'; + +const QRHardwarePopover = () => { + const t = useI18nContext(); + + const qrHardware = useSelector(getCurrentQRHardwareState); + const { sync, sign } = qrHardware; + const showWalletImporter = sync?.reading; + const showSignRequest = sign?.request; + const showPopover = showWalletImporter || showSignRequest; + const [errorTitle, setErrorTitle] = useState(''); + + const { txData } = useSelector((state) => { + return state.confirmTransaction; + }); + // the confirmTransaction's life cycle is not consistent with QR hardware wallet; + // the confirmTransaction will change after the previous tx is confirmed or cancel, + // we want to block the changing by sign request id; + const _txData = useMemo(() => { + return txData; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sign?.request?.requestId]); + + const dispatch = useDispatch(); + const walletImporterCancel = useCallback( + () => dispatch(cancelSyncQRHardwareAction()), + [dispatch], + ); + + const signRequestCancel = useCallback(() => { + let action = cancelTx; + switch (_txData.type) { + case MESSAGE_TYPE.PERSONAL_SIGN: { + action = cancelPersonalMsg; + break; + } + case MESSAGE_TYPE.ETH_SIGN: { + action = cancelMsg; + break; + } + case MESSAGE_TYPE.ETH_SIGN_TYPED_DATA: { + action = cancelTypedMsg; + break; + } + default: { + action = cancelTx; + } + } + dispatch(action(_txData)); + dispatch(cancelQRHardwareSignRequestAction()); + }, [dispatch, _txData]); + + const title = useMemo(() => { + let _title = ''; + if (showSignRequest) { + _title = t('QRHardwareSignRequestTitle'); + } else if (showWalletImporter) { + _title = t('QRHardwareWalletImporterTitle'); + } + if (errorTitle !== '') { + _title = errorTitle; + } + return _title; + }, [showSignRequest, showWalletImporter, t, errorTitle]); + return showPopover ? ( + + {showWalletImporter && ( + + )} + {showSignRequest && ( + + )} + + ) : null; +}; + +export default QRHardwarePopover; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/index.js b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/index.js new file mode 100644 index 000000000..9a59357a1 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/index.js @@ -0,0 +1,3 @@ +import QRHardwareSignRequest from './qr-hardware-sign-request.component'; + +export default QRHardwareSignRequest; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/player.js b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/player.js new file mode 100644 index 000000000..c79dec547 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/player.js @@ -0,0 +1,71 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import QRCode from 'qrcode.react'; +import { UR, UREncoder } from '@ngraveio/bc-ur'; +import PropTypes from 'prop-types'; +import Typography from '../../../ui/typography'; +import Box from '../../../ui/box'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + ALIGN_ITEMS, + DISPLAY, + FLEX_DIRECTION, + TEXT_ALIGN, +} from '../../../../helpers/constants/design-system'; +import { PageContainerFooter } from '../../../ui/page-container'; + +const Player = ({ type, cbor, cancelQRHardwareSignRequest, toRead }) => { + const t = useI18nContext(); + const urEncoder = useMemo( + () => new UREncoder(new UR(Buffer.from(cbor, 'hex'), type), 400), + [cbor, type], + ); + const [currentQRCode, setCurrentQRCode] = useState(urEncoder.nextPart()); + useEffect(() => { + const id = setInterval(() => { + setCurrentQRCode(urEncoder.nextPart()); + }, 100); + return () => { + clearInterval(id); + }; + }, [urEncoder]); + + return ( + <> + + + {t('QRHardwareSignRequestSubtitle')} + + + + + + + + {t('QRHardwareSignRequestDescription')} + + + + + ); +}; + +Player.propTypes = { + type: PropTypes.string.isRequired, + cbor: PropTypes.string.isRequired, + cancelQRHardwareSignRequest: PropTypes.func.isRequired, + toRead: PropTypes.func.isRequired, +}; + +export default Player; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/qr-hardware-sign-request.component.js b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/qr-hardware-sign-request.component.js new file mode 100644 index 000000000..efbc40729 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/qr-hardware-sign-request.component.js @@ -0,0 +1,45 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { submitQRHardwareSignature } from '../../../../store/actions'; +import Player from './player'; +import Reader from './reader'; + +const QRHardwareSignRequest = ({ request, handleCancel, setErrorTitle }) => { + const [status, setStatus] = useState('play'); + + const toRead = useCallback(() => setStatus('read'), []); + + const renderPlayer = () => { + const { payload } = request; + return ( + + ); + }; + + const renderReader = () => { + return ( + + ); + }; + + if (status === 'play') return renderPlayer(); + return renderReader(); +}; + +QRHardwareSignRequest.propTypes = { + request: PropTypes.object.isRequired, + handleCancel: PropTypes.func.isRequired, + setErrorTitle: PropTypes.func.isRequired, +}; + +export default QRHardwareSignRequest; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/reader.js b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/reader.js new file mode 100644 index 000000000..2a5804a71 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-sign-request/reader.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { ETHSignature } from '@keystonehq/bc-ur-registry-eth'; +import * as uuid from 'uuid'; +import PropTypes from 'prop-types'; +import BaseReader from '../base-reader'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; + +const Reader = ({ + submitQRHardwareSignature, + cancelQRHardwareSignRequest, + requestId, + setErrorTitle, +}) => { + const t = useI18nContext(); + const cancel = () => { + cancelQRHardwareSignRequest(); + }; + + const handleSuccess = async (ur) => { + if (ur.type === 'eth-signature') { + const ethSignature = ETHSignature.fromCBOR(ur.cbor); + const buffer = ethSignature.getRequestId(); + const signId = uuid.stringify(buffer); + if (signId === requestId) { + return await submitQRHardwareSignature(signId, ur.cbor.toString('hex')); + } + setErrorTitle(t('QRHardwareInvalidTransactionTitle')); + throw new Error(t('QRHardwareMismatchedSignId')); + } else { + setErrorTitle(t('QRHardwareInvalidTransactionTitle')); + throw new Error(t('unknownQrCode')); + } + }; + + return ( + + ); +}; + +Reader.propTypes = { + submitQRHardwareSignature: PropTypes.func.isRequired, + cancelQRHardwareSignRequest: PropTypes.func.isRequired, + requestId: PropTypes.string.isRequired, + setErrorTitle: PropTypes.func.isRequired, +}; + +export default Reader; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/index.js b/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/index.js new file mode 100644 index 000000000..55c7b34e2 --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/index.js @@ -0,0 +1,3 @@ +import QRHardwareWalletImporter from './qr-hardware-wallet-importer.component'; + +export default QRHardwareWalletImporter; diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/qr-hardware-wallet-importer.component.js b/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/qr-hardware-wallet-importer.component.js new file mode 100644 index 000000000..0c5a6355b --- /dev/null +++ b/ui/components/app/qr-hardware-popover/qr-hardware-wallet-importer/qr-hardware-wallet-importer.component.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + submitQRHardwareCryptoAccount, + submitQRHardwareCryptoHDKey, +} from '../../../../store/actions'; +import BaseReader from '../base-reader'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; + +const QRHardwareWalletImporter = ({ handleCancel, setErrorTitle }) => { + const t = useI18nContext(); + const handleSuccess = async (ur) => { + if (ur.type === 'crypto-hdkey') { + return await submitQRHardwareCryptoHDKey(ur.cbor.toString('hex')); + } else if (ur.type === 'crypto-account') { + return await submitQRHardwareCryptoAccount(ur.cbor.toString('hex')); + } + setErrorTitle(t('QRHardwareUnknownQRCodeTitle')); + throw new Error(t('unknownQrCode')); + }; + + return ( + + ); +}; + +QRHardwareWalletImporter.propTypes = { + handleCancel: PropTypes.func.isRequired, + setErrorTitle: PropTypes.func.isRequired, +}; + +export default QRHardwareWalletImporter; diff --git a/ui/components/app/transaction-breakdown/index.scss b/ui/components/app/transaction-breakdown/index.scss index 7df517d1b..87aa3c72e 100644 --- a/ui/components/app/transaction-breakdown/index.scss +++ b/ui/components/app/transaction-breakdown/index.scss @@ -2,8 +2,11 @@ .transaction-breakdown { &__title { - border-bottom: 1px solid #d8d8d8; padding-bottom: 4px; + padding-top: 8px; + font-size: 14px; + color: $Black-100; + font-weight: bold; text-transform: capitalize; } @@ -15,12 +18,17 @@ display: flex; justify-content: flex-end; text-align: end; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; &--eth-total { - font-weight: 500; + font-weight: bold; + color: $Black-100; + } + + &--amount { + font-weight: bold; + color: $Black-100; } } } diff --git a/ui/components/app/transaction-breakdown/transaction-breakdown-row/index.scss b/ui/components/app/transaction-breakdown/transaction-breakdown-row/index.scss index 372a40e4b..ac6111af2 100644 --- a/ui/components/app/transaction-breakdown/transaction-breakdown-row/index.scss +++ b/ui/components/app/transaction-breakdown/transaction-breakdown-row/index.scss @@ -1,20 +1,22 @@ .transaction-breakdown-row { @include H7; - color: $scorpion; + color: $Grey-500; display: flex; justify-content: space-between; padding: 8px 0; - &:not(:last-child) { + &--with-bottom-border { border-bottom: 1px solid #d8d8d8; } &__title { padding-right: 8px; + min-width: 40%; } &__value { min-width: 0; + word-break: break-word; } } diff --git a/ui/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js b/ui/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js index 99c2b1e30..e4c7c56bc 100644 --- a/ui/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js +++ b/ui/components/app/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js @@ -7,14 +7,19 @@ export default class TransactionBreakdownRow extends PureComponent { title: PropTypes.string, children: PropTypes.node, className: PropTypes.string, + divider: PropTypes.bool, }; render() { - const { title, children, className } = this.props; + const { title, children, className, divider = false } = this.props; return (
{t('transaction')}
- + {typeof nonce === 'undefined' ? null : ( - + {primaryCurrency} @@ -178,7 +178,10 @@ export default class TransactionBreakdown extends PureComponent { )} {isEIP1559Transaction && ( - + { + const t = useI18nContext(); + const [showNicknamePopovers, setShowNicknamePopovers] = useState(false); + + const addressBook = useSelector(getAddressBook); + const addressBookEntryObject = addressBook.find( + (entry) => entry.address === checksummedRecipientAddress, + ); + const recipientNickname = addressBookEntryObject?.name; + + const recipientToRender = addressOnly + ? recipientNickname || + recipientEns || + shortenAddress(checksummedRecipientAddress) + : recipientNickname || recipientEns || recipientName || t('newContract'); + + return ( +
{ + copyToClipboard(checksummedRecipientAddress); + if (onRecipientClick) { + onRecipientClick(); + } + }} + > +
+ +
+ +
setShowNicknamePopovers(true)} + > + {recipientToRender} +
+ {showNicknamePopovers ? ( + setShowNicknamePopovers(false)} + address={checksummedRecipientAddress} + /> + ) : null} +
+ ); +}; + +Address.propTypes = { + checksummedRecipientAddress: PropTypes.string, + recipientName: PropTypes.string, + recipientEns: PropTypes.string, + addressOnly: PropTypes.bool, + onRecipientClick: PropTypes.func, +}; + +export default Address; diff --git a/ui/components/app/transaction-decoding/components/decoding/address/index.js b/ui/components/app/transaction-decoding/components/decoding/address/index.js new file mode 100644 index 000000000..6909aec4e --- /dev/null +++ b/ui/components/app/transaction-decoding/components/decoding/address/index.js @@ -0,0 +1 @@ +export { default } from './address.component'; diff --git a/ui/components/app/transaction-decoding/components/decoding/address/index.scss b/ui/components/app/transaction-decoding/components/decoding/address/index.scss new file mode 100644 index 000000000..ca821dae8 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/decoding/address/index.scss @@ -0,0 +1,12 @@ +.tx-insight-content { + .tx-insight-component-address { + display: flex; + align-items: center; + cursor: pointer; + overflow: visible; + + &__sender-icon { + padding-right: 5px; + } + } +} diff --git a/ui/components/app/transaction-decoding/components/ui/accreditation/accreditation.component.js b/ui/components/app/transaction-decoding/components/ui/accreditation/accreditation.component.js new file mode 100644 index 000000000..fd1d75cd8 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/accreditation/accreditation.component.js @@ -0,0 +1,72 @@ +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { getAccountLink } from '@metamask/etherscan-link'; +import { + getCurrentChainId, + getRpcPrefsForCurrentProvider, +} from '../../../../../../selectors'; +import { I18nContext } from '../../../../../../contexts/i18n'; + +import { TYPOGRAPHY } from '../../../../../../helpers/constants/design-system'; + +import Button from '../../../../../ui/button'; +import Typography from '../../../../../ui/typography'; + +const Accreditation = ({ fetchVia, address }) => { + const t = useContext(I18nContext); + const chainId = useSelector(getCurrentChainId); + const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); + const addressLink = getAccountLink(address, chainId, rpcPrefs); + + const AccreditationLink = () => { + return ( + <> + + {t('transactionDecodingAccreditationVerified', [ + , + ])} + + + {t('transactionDecodingAccreditationDecoded')} + + + ); + }; + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +Accreditation.propTypes = { + fetchVia: PropTypes.string.isRequired, + address: PropTypes.string.isRequired, +}; + +export default Accreditation; diff --git a/ui/components/app/transaction-decoding/components/ui/accreditation/index.js b/ui/components/app/transaction-decoding/components/ui/accreditation/index.js new file mode 100644 index 000000000..138aad790 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/accreditation/index.js @@ -0,0 +1 @@ +export { default } from './accreditation.component'; diff --git a/ui/components/app/transaction-decoding/components/ui/accreditation/index.scss b/ui/components/app/transaction-decoding/components/ui/accreditation/index.scss new file mode 100644 index 000000000..efc4af751 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/accreditation/index.scss @@ -0,0 +1,24 @@ +.accreditation { + display: flex; + align-items: center; + margin-top: 8px; + + &__icon { + margin-right: 8px; + } + + &__info { + color: $ui-black; + display: flex; + flex-flow: column; + flex-wrap: wrap; + } + + &__link.btn-link { + @include H7; + + display: inherit; + padding: 0 4px; + } +} + diff --git a/ui/components/app/transaction-decoding/components/ui/copy-raw-data/copy-raw-data.component.js b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/copy-raw-data.component.js new file mode 100644 index 000000000..8e9361e70 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/copy-raw-data.component.js @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from '../../../../../ui/tooltip/tooltip'; +import CopyIcon from '../../../../../ui/icon/copy-icon.component'; +import { I18nContext } from '../../../../../../contexts/i18n'; + +import { useCopyToClipboard } from '../../../../../../hooks/useCopyToClipboard'; + +const CopyRawData = ({ data }) => { + const t = useContext(I18nContext); + const [copied, handleCopy] = useCopyToClipboard(); + + return ( +
+ + + +
+ ); +}; + +CopyRawData.propTypes = { + data: PropTypes.string.isRequired, +}; + +export default CopyRawData; diff --git a/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.js b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.js new file mode 100644 index 000000000..c586d6272 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.js @@ -0,0 +1 @@ +export { default } from './copy-raw-data.component'; diff --git a/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.scss b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.scss new file mode 100644 index 000000000..4deb4c525 --- /dev/null +++ b/ui/components/app/transaction-decoding/components/ui/copy-raw-data/index.scss @@ -0,0 +1,30 @@ +.copy-raw-data { + display: flex; + align-items: center; + + &__button { + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + margin-top: 5px; + background-color: transparent; + + &:hover { + background-color: $Grey-000; + } + + &:active { + background-color: #ededed; + } + } + + &__icon { + padding-inline-end: 6px; + } + + &__label { + color: #6a737d; + } +} diff --git a/ui/components/app/transaction-decoding/constants.js b/ui/components/app/transaction-decoding/constants.js new file mode 100644 index 000000000..06840193f --- /dev/null +++ b/ui/components/app/transaction-decoding/constants.js @@ -0,0 +1,4 @@ +const TX_INSIGHTS_BASE_URI = 'https://tx-insights.metaswap.codefi.network'; + +export const FETCH_PROJECT_INFO_URI = `${TX_INSIGHTS_BASE_URI}/fetch-project`; +export const FETCH_SUPPORTED_NETWORKS_URI = `${TX_INSIGHTS_BASE_URI}/networks`; diff --git a/ui/components/app/transaction-decoding/index.js b/ui/components/app/transaction-decoding/index.js new file mode 100644 index 000000000..005447c73 --- /dev/null +++ b/ui/components/app/transaction-decoding/index.js @@ -0,0 +1 @@ +export { default } from './transaction-decoding.component'; diff --git a/ui/components/app/transaction-decoding/index.scss b/ui/components/app/transaction-decoding/index.scss new file mode 100644 index 000000000..98992c7aa --- /dev/null +++ b/ui/components/app/transaction-decoding/index.scss @@ -0,0 +1,172 @@ +//styling for ui components +@import './components/ui/copy-raw-data/index'; +@import './components/ui/accreditation/index'; + +//styling for decoding components +@import './components/decoding/address/index'; + +.tx-insight { + overflow-x: hidden; + + &-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 100px; + + .spinner { + width: 30px; + } + } + + & > details { + margin: 20px 0; + } + + & > details > summary { + &.tx-insight-title { + font-size: 14px; + position: relative; + margin: 12px 0 4px; + padding-inline-start: 22px; + cursor: pointer; + + & + .tx-insight-content { + padding-inline-start: 14px; + padding-top: 5px; + } + } + } + + &-error { + cursor: auto; + } + + .tx-insight-content { + &__tree-component { + line-height: 175%; + + & > ol { + padding-left: 0; + } + + .eth-tx-params { + padding: 8px; + width: 343px; + background: white; + text-align: left; + display: flex; + flex-direction: column; + align-items: left; + font-family: Euclid Circular B, sans-serif; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 172%; + } + + ol ol { + padding-left: 22px; + margin-left: 4px; + } + + details > summary { + position: relative; + cursor: pointer; + padding-bottom: 5px; + } + + .eth-tx-params .solidity-func-name { + width: fit-content; + padding: 4px; + text-transform: uppercase; + border: 1px solid rgb(149, 149, 149); + color: rgb(149, 149, 149); + border-radius: 4px; + } + + .eth-tx-params .sol-value { + display: flex; + flex-flow: row wrap; + } + + .eth-tx-params .sol-value > { + overflow: ellipses; + } + + .eth-tx-params ol { + list-style: none; + padding: 0; + max-width: inherit; + } + + .eth-tx-params > ol { + margin-left: 16px; + } + + /* + Use this parameter to change the indentation!! */ + .eth-tx-params ol ol:not(:first-child) { + padding-left: 8px; + } + + pre.solidity-raw { + text-align: left; + } + + .solidity-address { + display: flex; + flex-direction: row; + } + + .solidity-address :first-child { + width: 16px; + height: 16px; + margin: 0 8px; + } + + .solidity-item .param-name { + padding-right: 4px; + } + + .solidity-value { + color: #6a737d; + overflow-x: hidden; + padding-bottom: 5px; + + & > div { + display: flex; + flex-wrap: wrap; + word-break: break-all; + } + } + + .solidity-error { + display: flex; + align-items: center; + + & > .error-message__icon { + width: 14px; + margin-right: 5px; + } + } + + .eth-tx-params details > summary { + color: black; + font-family: sans-serif; + } + + .eth-tx-params footer { + text-align: center; + color: #8d959e; + } + + .eth-tx-params footer a { + text-align: center; + color: #8d959e; + } + } + } +} + + diff --git a/ui/components/app/transaction-decoding/transaction-decoding.component.js b/ui/components/app/transaction-decoding/transaction-decoding.component.js new file mode 100644 index 000000000..75ed7af4f --- /dev/null +++ b/ui/components/app/transaction-decoding/transaction-decoding.component.js @@ -0,0 +1,244 @@ +import React, { useContext, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import inspect from 'browser-util-inspect'; +import { forAddress } from '@truffle/decoder'; +import { useSelector } from 'react-redux'; +import * as Codec from '@truffle/codec'; +import Spinner from '../../ui/spinner'; +import ErrorMessage from '../../ui/error-message'; +import fetchWithCache from '../../../helpers/utils/fetch-with-cache'; +import { getSelectedAccount, getCurrentChainId } from '../../../selectors'; +import { hexToDecimal } from '../../../helpers/utils/conversions.util'; +import { I18nContext } from '../../../contexts/i18n'; +import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; +import { transformTxDecoding } from './transaction-decoding.util'; +import { + FETCH_PROJECT_INFO_URI, + FETCH_SUPPORTED_NETWORKS_URI, +} from './constants'; + +import Address from './components/decoding/address'; +import CopyRawData from './components/ui/copy-raw-data'; +import Accreditation from './components/ui/accreditation'; + +export default function TransactionDecoding({ to = '', inputData: data = '' }) { + const t = useContext(I18nContext); + const [tx, setTx] = useState([]); + const [sourceAddress, setSourceAddress] = useState(''); + const [sourceFetchedVia, setSourceFetchedVia] = useState(''); + + const { address: from } = useSelector(getSelectedAccount); + const network = hexToDecimal(useSelector(getCurrentChainId)); + + const [loading, setLoading] = useState(false); + const [hasError, setError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const networks = await fetchWithCache(FETCH_SUPPORTED_NETWORKS_URI, { + method: 'GET', + }); + + if ( + !networks.some( + (n) => n.active && Number(n.chainId) === Number(network), + ) + ) { + throw new Error( + t('transactionDecodingUnsupportedNetworkError', [network]), + ); + } + + const requestUrl = `${FETCH_PROJECT_INFO_URI}?${new URLSearchParams({ + to, + 'network-id': network, + })}`; + + const response = await fetchWithCache(requestUrl, { method: 'GET' }); + + const { info: projectInfo, fetchedVia, address } = response; + + // update source information + if (address) { + setSourceAddress(address); + } + + if (fetchedVia) { + setSourceFetchedVia(fetchedVia); + } + + // creating instance of the truffle decoder + const decoder = await forAddress(to, { + provider: global.ethereumProvider, + projectInfo, + }); + + // decode tx input data + const decoding = await decoder.decodeTransaction({ + from, + to, + input: data, + blockNumber: null, + }); + + // transform tx decoding arguments into tree data + const params = transformTxDecoding(decoding?.arguments); + setTx(params); + + setLoading(false); + } catch (error) { + setLoading(false); + setError(true); + if (error?.message.match('400')) { + setErrorMessage(t('txInsightsNotSupported')); + } else { + setErrorMessage(error?.message); + } + } + })(); + }, [t, from, to, network, data]); + + // *********************************************************** + // component rendering methods + // *********************************************************** + const renderLeaf = ({ name, kind, typeClass, value }) => { + switch (kind) { + case 'error': + return ( + + Malformed data + + ); + + default: + switch (typeClass) { + case 'int': + return ( + + {[value.asBN || value.asString].toString()} + + ); + + case 'uint': + return ( + + {[value.asBN || value.asString].toString()} + + ); + + case 'bytes': + return ( + {value.asHex} + ); + + case 'array': + return ( +
+ {name}: +
    + {value.map((itemValue, index) => { + return ( +
  1. + {renderLeaf({ + typeClass: itemValue.type?.typeClass, + value: itemValue.value, + kind: itemValue.kind, + })} +
  2. + ); + })} +
+
+ ); + + case 'address': { + const address = value?.asAddress; + return ( +
+ ); + } + default: + return ( +
+                {inspect(new Codec.Format.Utils.Inspect.ResultInspector(value))}
+              
+ ); + } + } + }; + + const renderTree = ( + { name, kind, typeClass, type, value, children }, + index, + ) => { + return children ? ( +
  • +
    + {name}: +
      {children.map(renderTree)}
    +
    +
  • + ) : ( +
  • +
    + {typeClass !== 'array' && !Array.isArray(value) ? ( + {name}: + ) : null} + + {renderLeaf({ name, typeClass, type, value, kind })} + +
    +
  • + ); + }; + + const renderTransactionDecoding = () => { + if (loading) { + return ( +
    + +
    + ); + } + + if (hasError) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +
      {tx.map(renderTree)}
    +
    +
    + +
    + {sourceFetchedVia && sourceAddress ? ( +
    + +
    + ) : null} +
    + ); + }; + + return
    {renderTransactionDecoding()}
    ; +} + +TransactionDecoding.propTypes = { + to: PropTypes.string.isRequired, + inputData: PropTypes.string.isRequired, +}; diff --git a/ui/components/app/transaction-decoding/transaction-decoding.util.js b/ui/components/app/transaction-decoding/transaction-decoding.util.js new file mode 100644 index 000000000..fa351a565 --- /dev/null +++ b/ui/components/app/transaction-decoding/transaction-decoding.util.js @@ -0,0 +1,30 @@ +// ********************************************* +// data transformation utils +// ********************************************* +export const transformTxDecoding = (params) => { + return params.map((node) => { + const nodeName = node.name; + const nodeValue = node.value; + const nodeKind = nodeValue.kind; + const nodeTypeClass = nodeValue.type.typeClass; + + const treeItem = { + name: nodeName, + kind: nodeKind, + typeClass: nodeTypeClass, + type: nodeValue.type, + }; + + if (nodeTypeClass === 'struct') { + return { + ...treeItem, + children: transformTxDecoding(nodeValue.value), + }; + } + + return { + ...treeItem, + value: nodeValue.value ? nodeValue.value : nodeValue, + }; + }); +}; diff --git a/ui/components/app/transaction-detail-item/index.scss b/ui/components/app/transaction-detail-item/index.scss index 5fecb94e0..a19f4316e 100644 --- a/ui/components/app/transaction-detail-item/index.scss +++ b/ui/components/app/transaction-detail-item/index.scss @@ -5,19 +5,24 @@ &__row { display: flex; + justify-content: space-between; grid-gap: 5px; - } - &__title { - flex-grow: 1; - word-break: break-word; + &-subText { + display: flex; + align-items: center; + + .currency-display-component { + margin-left: 4px; + } + } } &__detail-values { display: flex; flex-wrap: wrap; justify-content: end; - width: 55%; + width: 52%; &--flex-width { width: auto; @@ -33,19 +38,6 @@ } } - &__total { - font-weight: bold; - color: $ui-black; - } - - &__subtitle { - flex-grow: 1; - } - - &__subtext { - text-align: end; - } - .currency-display-component { display: inline; } diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js index 84689bc95..cbd01182c 100644 --- a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js @@ -7,6 +7,9 @@ import { COLORS, FONT_WEIGHT, TYPOGRAPHY, + DISPLAY, + FLEX_WRAP, + ALIGN_ITEMS, } from '../../../helpers/constants/design-system'; export default function TransactionDetailItem({ @@ -26,44 +29,39 @@ export default function TransactionDetailItem({ color={detailTitleColor} fontWeight={boldHeadings ? FONT_WEIGHT.BOLD : FONT_WEIGHT.NORMAL} variant={TYPOGRAPHY.H6} - className="transaction-detail-item__title" + boxProps={{ + display: DISPLAY.FLEX, + flexWrap: FLEX_WRAP.NO_WRAP, + alignItems: ALIGN_ITEMS.CENTER, + }} > {detailTitle} - {detailText && ( -
    - +
    + {detailText && ( + {detailText} -
    - )} - - {detailTotal} - + )} + + {detailTotal} + +
    {React.isValidElement(subTitle) ? ( -
    {subTitle}
    +
    {subTitle}
    ) : ( - + {subTitle} )} @@ -71,7 +69,8 @@ export default function TransactionDetailItem({ {subText} diff --git a/ui/components/app/transaction-detail/index.scss b/ui/components/app/transaction-detail/index.scss index 35bcf27c9..a24bd4ede 100644 --- a/ui/components/app/transaction-detail/index.scss +++ b/ui/components/app/transaction-detail/index.scss @@ -17,7 +17,6 @@ } &-edit-V2 { - margin-bottom: 10px; display: flex; align-items: baseline; justify-content: flex-end; @@ -66,4 +65,8 @@ } } } + + &-rows { + margin-top: 10px; + } } diff --git a/ui/components/app/transaction-detail/transaction-detail.component.js b/ui/components/app/transaction-detail/transaction-detail.component.js index b06d43af8..919d76dac 100644 --- a/ui/components/app/transaction-detail/transaction-detail.component.js +++ b/ui/components/app/transaction-detail/transaction-detail.component.js @@ -1,48 +1,43 @@ -import React, { useContext } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { I18nContext } from '../../../contexts/i18n'; import { useGasFeeContext } from '../../../contexts/gasFee'; +import { useTransactionModalContext } from '../../../contexts/transaction-modal'; import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; import Typography from '../../ui/typography/typography'; import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component'; import { COLORS } from '../../../helpers/constants/design-system'; +import { PRIORITY_LEVEL_ICON_MAP } from '../../../helpers/constants/gas'; +import { useI18nContext } from '../../../hooks/useI18nContext'; -const GasLevelIconMap = { - low: '🐢', - medium: '🦊', - high: '🦍', - dappSuggested: '🌐', - custom: '⚙', -}; - -export default function TransactionDetail({ rows = [], onEdit }) { - // eslint-disable-next-line prefer-destructuring - const EIP_1559_V2 = process.env.EIP_1559_V2; - - const t = useContext(I18nContext); +export default function TransactionDetail({ + rows = [], + onEdit, + userAcknowledgedGasMissing, +}) { + const t = useI18nContext(); const { - estimateToUse, gasLimit, - gasPrice, - isUsingDappSuggestedGasFees, + hasSimulationError, + estimateUsed, maxFeePerGas, maxPriorityFeePerGas, + supportsEIP1559V2, transaction, - supportsEIP1559, } = useGasFeeContext(); - const estimateUsed = isUsingDappSuggestedGasFees - ? 'dappSuggested' - : estimateToUse; + const { openModal } = useTransactionModalContext(); + + if (supportsEIP1559V2 && estimateUsed) { + const editEnabled = !hasSimulationError || userAcknowledgedGasMissing; + if (!editEnabled) return null; - if (EIP_1559_V2 && estimateUsed) { return (
    - {estimateUsed === 'custom' && onEdit && ( - + )} {estimateUsed === 'dappSuggested' && ( {t('dappSuggestedTooltip', [transaction.origin])} - {supportsEIP1559 ? ( - <> - - {t('maxBaseFee')} - {maxFeePerGas} - - - {t('maxPriorityFee')} - {maxPriorityFeePerGas} - - - ) : ( - - {t('gasPriceLabel')} - {gasPrice} - - )} + + {t('maxBaseFee')} + {maxFeePerGas} + + + {t('maxPriorityFee')} + {maxPriorityFeePerGas} + {t('gasLimit')} {gasLimit} @@ -106,4 +94,5 @@ export default function TransactionDetail({ rows = [], onEdit }) { TransactionDetail.propTypes = { rows: PropTypes.arrayOf(TransactionDetailItem).isRequired, onEdit: PropTypes.func, + userAcknowledgedGasMissing: PropTypes.bool.isRequired, }; diff --git a/ui/components/app/transaction-detail/transaction-detail.component.test.js b/ui/components/app/transaction-detail/transaction-detail.component.test.js index a95cf1565..7209c872e 100644 --- a/ui/components/app/transaction-detail/transaction-detail.component.test.js +++ b/ui/components/app/transaction-detail/transaction-detail.component.test.js @@ -1,9 +1,16 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { ETH } from '../../../helpers/constants/common'; +import { + GAS_ESTIMATE_TYPES, + PRIORITY_LEVELS, +} from '../../../../shared/constants/gas'; +import { TRANSACTION_ENVELOPE_TYPES } from '../../../../shared/constants/transaction'; + import { GasFeeContextProvider } from '../../../contexts/gasFee'; import { renderWithProvider } from '../../../../test/jest'; +import mockEstimates from '../../../../test/data/mock-estimates.json'; +import mockState from '../../../../test/data/mock-state.json'; import configureStore from '../../../store/store'; import TransactionDetail from './transaction-detail.component'; @@ -16,34 +23,29 @@ jest.mock('../../../store/actions', () => ({ addPollingTokenToAppState: jest.fn(), })); -const render = (props) => { +const render = ({ componentProps, contextProps } = {}) => { const store = configureStore({ metamask: { - nativeCurrency: ETH, - preferences: { - useNativeCurrencyAsPrimaryCurrency: true, - }, - provider: {}, - cachedBalances: {}, + ...mockState.metamask, accounts: { - '0xAddress': { - address: '0xAddress', - balance: '0x176e5b6f173ebe66', + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', }, }, - selectedAddress: '0xAddress', - featureFlags: { advancedInlineGas: true }, + gasFeeEstimates: mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET], }, }); return renderWithProvider( - + { console.log('on edit'); }} rows={[]} - {...props} + userAcknowledgedGasMissing + {...componentProps} /> , store, @@ -54,41 +56,93 @@ describe('TransactionDetail', () => { beforeEach(() => { process.env.EIP_1559_V2 = true; }); + afterEach(() => { process.env.EIP_1559_V2 = false; }); + it('should render edit link with text low if low gas estimates are selected', () => { - render({ transaction: { userFeeLevel: 'low' } }); + render({ contextProps: { transaction: { userFeeLevel: 'low' } } }); expect(screen.queryByText('🐢')).toBeInTheDocument(); expect(screen.queryByText('Low')).toBeInTheDocument(); }); + it('should render edit link with text markey if medium gas estimates are selected', () => { - render({ transaction: { userFeeLevel: 'medium' } }); + render({ contextProps: { transaction: { userFeeLevel: 'medium' } } }); expect(screen.queryByText('🦊')).toBeInTheDocument(); expect(screen.queryByText('Market')).toBeInTheDocument(); }); + it('should render edit link with text agressive if high gas estimates are selected', () => { - render({ transaction: { userFeeLevel: 'high' } }); + render({ contextProps: { transaction: { userFeeLevel: 'high' } } }); expect(screen.queryByText('🦍')).toBeInTheDocument(); expect(screen.queryByText('Aggressive')).toBeInTheDocument(); }); + it('should render edit link with text Site suggested if site suggested estimated are used', () => { render({ - transaction: { - dappSuggestedGasFees: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, - txParams: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, + contextProps: { + transaction: { + userFeeLevel: PRIORITY_LEVELS.DAPP_SUGGESTED, + dappSuggestedGasFees: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, + txParams: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, + }, }, }); expect(screen.queryByText('🌐')).toBeInTheDocument(); expect(screen.queryByText('Site suggested')).toBeInTheDocument(); expect(document.getElementsByClassName('info-tooltip')).toHaveLength(1); }); + it('should render edit link with text advance if custom gas estimates are used', () => { render({ - defaultEstimateToUse: 'custom', + contextProps: { + defaultEstimateToUse: 'custom', + }, }); expect(screen.queryByText('⚙')).toBeInTheDocument(); expect(screen.queryByText('Advanced')).toBeInTheDocument(); expect(screen.queryByText('Edit')).toBeInTheDocument(); }); + + it('should not render edit link if transaction has simulation error and prop userAcknowledgedGasMissing is false', () => { + render({ + contextProps: { + transaction: { + simulationFails: true, + userFeeLevel: 'low', + }, + }, + componentProps: { userAcknowledgedGasMissing: false }, + }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); + }); + + it('should render edit link if userAcknowledgedGasMissing is true even if transaction has simulation error', () => { + render({ + contextProps: { + transaction: { + simulationFails: true, + userFeeLevel: 'low', + }, + }, + componentProps: { userAcknowledgedGasMissing: true }, + }); + expect(screen.queryByRole('button')).toBeInTheDocument(); + expect(screen.queryByText('Low')).toBeInTheDocument(); + }); + + it('should render edit link with text edit for legacy transactions', () => { + render({ + contextProps: { + transaction: { + userFeeLevel: 'low', + txParams: { type: TRANSACTION_ENVELOPE_TYPES.LEGACY }, + }, + }, + }); + expect(screen.queryByText('🐢')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit')).toBeInTheDocument(); + }); }); diff --git a/ui/components/app/transaction-list-item-details/index.scss b/ui/components/app/transaction-list-item-details/index.scss index a0f95f470..2df231e14 100644 --- a/ui/components/app/transaction-list-item-details/index.scss +++ b/ui/components/app/transaction-list-item-details/index.scss @@ -1,11 +1,82 @@ .transaction-list-item-details { + div.disclosure + div.disclosure { + margin-top: 0; + } + + .sender-to-recipient--flat .sender-to-recipient__party.sender-to-recipient__party--sender { + padding: 0; + justify-content: flex-start; + } + + .sender-to-recipient--flat .sender-to-recipient__party.sender-to-recipient__party--recipient { + padding: 0; + justify-content: flex-end; + } + + &__sender-to-recipient-header { + display: flex; + font-size: 14px; + color: $Black-100; + font-weight: bold; + padding-bottom: 7px; + + & > div:first-child { + flex: 1; + } + } + + &__tx-status { + display: flex; + flex-direction: column; + align-items: flex-start; + height: 44px; + justify-content: space-between; + + & > div:first-child { + font-size: 14px; + color: $Black-100; + font-weight: bold; + } + + & > div:last-child { + font-weight: bold; + } + } + + &__tx-hash { + display: flex; + flex-direction: column; + align-items: flex-end; + + .btn-link { + font-size: 12px; + line-height: 100%; + padding: 0; + } + + & > div:first-child { + padding-bottom: 16px; + } + } + + &__operations { + margin: 0 0 16px 16px; + display: flex; + justify-content: end; + } + &__header { + font-size: 12px; margin: 8px 16px; display: flex; justify-content: space-between; align-items: center; } + &__body { + padding: 8px 16px; + } + &__header-buttons { display: flex; flex-direction: row; @@ -31,12 +102,25 @@ &__sender-to-recipient-container { margin-bottom: 8px; + + .sender-to-recipient { + .sender-to-recipient__party { + border: none; + + &--sender { + padding-left: 0; + } + + &--recipient { + padding-right: 0; + } + } + } } &__cards-container { display: flex; flex-direction: column; - padding: 8px 16px; } &__transaction-breakdown { @@ -49,5 +133,13 @@ &__transaction-activity-log { flex: 2; min-width: 0; + + .transaction-activity-log__activities-container { + padding-top: 0; + } + + .transaction-activity-log__title { + display: none; + } } } 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 335859aa3..6fe04de97 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 @@ -4,16 +4,17 @@ import copyToClipboard from 'copy-to-clipboard'; import { getBlockExplorerLink } from '@metamask/etherscan-link'; import SenderToRecipient from '../../ui/sender-to-recipient'; import { DEFAULT_VARIANT } from '../../ui/sender-to-recipient/sender-to-recipient.constants'; +import Disclosure from '../../ui/disclosure'; import TransactionActivityLog from '../transaction-activity-log'; import TransactionBreakdown from '../transaction-breakdown'; import Button from '../../ui/button'; import Tooltip from '../../ui/tooltip'; -import Copy from '../../ui/icon/copy-icon.component'; import CancelButton from '../cancel-button'; import Popover from '../../ui/popover'; import { SECOND } from '../../../../shared/constants/time'; import { TRANSACTION_TYPES } from '../../../../shared/constants/transaction'; import { getURLHostName } from '../../../helpers/utils/util'; +import TransactionDecoding from '../transaction-decoding'; export default class TransactionListItemDetails extends PureComponent { static contextTypes = { @@ -44,6 +45,7 @@ export default class TransactionListItemDetails extends PureComponent { tryReverseResolveAddress: PropTypes.func.isRequired, senderNickname: PropTypes.string.isRequired, recipientNickname: PropTypes.string, + transactionStatus: PropTypes.func, }; state = { @@ -124,7 +126,6 @@ export default class TransactionListItemDetails extends PureComponent { showRetry, recipientEns, recipientAddress, - rpcPrefs: { blockExplorerUrl } = {}, senderAddress, isEarliestNonce, senderNickname, @@ -132,6 +133,7 @@ export default class TransactionListItemDetails extends PureComponent { onClose, recipientNickname, showCancel, + transactionStatus: TransactionStatus, } = this.props; const { primaryTransaction: transaction, @@ -142,8 +144,7 @@ export default class TransactionListItemDetails extends PureComponent { return (
    -
    -
    {t('details')}
    +
    {showSpeedUp && ( - - - - {showRetry && (
    +
    +
    +
    Status
    +
    + +
    +
    +
    +
    + +
    +
    + + + +
    +
    +
    +
    +
    {t('from')}
    +
    {t('to')}
    +
    - + {transactionGroup.initialTransaction.type !== + TRANSACTION_TYPES.INCOMING && ( + + + + )} + {transactionGroup.initialTransaction?.txParams?.data ? ( + + + + ) : null}
    diff --git a/ui/components/app/transaction-list-item/transaction-list-item.component.js b/ui/components/app/transaction-list-item/transaction-list-item.component.js index 88227e7d0..b5f215c15 100644 --- a/ui/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/components/app/transaction-list-item/transaction-list-item.component.js @@ -212,6 +212,16 @@ export default function TransactionListItem({ isEarliestNonce={isEarliestNonce} onCancel={cancelTransaction} showCancel={isPending && !hasCancelled} + transactionStatus={() => ( + + )} /> )} {showRetryEditGasPopover && ( diff --git a/ui/components/app/transaction-status/index.scss b/ui/components/app/transaction-status/index.scss index 0896aa7f8..18af1f0df 100644 --- a/ui/components/app/transaction-status/index.scss +++ b/ui/components/app/transaction-status/index.scss @@ -1,6 +1,10 @@ .transaction-status { display: inline; + &--confirmed { + color: $success-3; + } + &--unapproved { color: $Orange-500; } diff --git a/ui/components/app/transaction-status/transaction-status.component.js b/ui/components/app/transaction-status/transaction-status.component.js index f8f5f8018..584989adb 100644 --- a/ui/components/app/transaction-status/transaction-status.component.js +++ b/ui/components/app/transaction-status/transaction-status.component.js @@ -43,6 +43,7 @@ export default function TransactionStatus({ error, isEarliestNonce, className, + statusOnly, }) { const t = useI18nContext(); const tooltipText = error?.rpc?.message || error?.message; @@ -54,7 +55,9 @@ export default function TransactionStatus({ } const statusText = - statusKey === TRANSACTION_STATUSES.CONFIRMED ? date : t(statusKey); + statusKey === TRANSACTION_STATUSES.CONFIRMED && !statusOnly + ? date + : t(statusKey); return ( { +export const DefaultStory = () => { return ( { /> ); }; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/transaction-total-banner/transaction-total-banner.stories.test.js b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.test.js new file mode 100644 index 000000000..dbf59496a --- /dev/null +++ b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.test.js @@ -0,0 +1,11 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import '@testing-library/jest-dom/extend-expect'; +import { DefaultStory } from './transaction-total-banner.stories'; + +it('renders transaction total banner stories with Base state', () => { + render(); + expect(screen.findByTestId('#popover-content')).toBeDefined(); +}); diff --git a/ui/components/ui/actionable-message/actionable-message.js b/ui/components/ui/actionable-message/actionable-message.js index 8af6646c7..ccae2b676 100644 --- a/ui/components/ui/actionable-message/actionable-message.js +++ b/ui/components/ui/actionable-message/actionable-message.js @@ -19,6 +19,7 @@ const typeHash = { export default function ActionableMessage({ message = '', primaryAction = null, + primaryActionV2 = null, secondaryAction = null, className = '', infoTooltipText = '', @@ -26,6 +27,7 @@ export default function ActionableMessage({ type = 'default', useIcon = false, iconFillColor = '', + roundedButtons, }) { const actionableMessageClassName = classnames( 'actionable-message', @@ -35,6 +37,9 @@ export default function ActionableMessage({ { 'actionable-message--with-icon': useIcon }, ); + const onlyOneAction = + (primaryAction && !secondaryAction) || (secondaryAction && !primaryAction); + return (
    {useIcon ? : null} @@ -46,13 +51,29 @@ export default function ActionableMessage({ /> )}
    {message}
    + {primaryActionV2 && ( + + )} {(primaryAction || secondaryAction) && ( -
    +
    {primaryAction && ( + + + +``` + + + + + +### With Radio Button + +Rendering radio type button instead + +```jsx + + + + + +``` + + + + + +### No Active Button + +Rendering button group without active button + +```jsx + + + + + +``` + + + + diff --git a/ui/components/ui/button-group/button-group.component.js b/ui/components/ui/button-group/button-group.component.js index 9cd5d9896..65f005ac2 100644 --- a/ui/components/ui/button-group/button-group.component.js +++ b/ui/components/ui/button-group/button-group.component.js @@ -4,13 +4,37 @@ import classnames from 'classnames'; export default class ButtonGroup extends PureComponent { static propTypes = { + /** + * change button active order + */ defaultActiveButtonIndex: PropTypes.number, + /** + * no button are active before clicked by the user + */ noButtonActiveByDefault: PropTypes.bool, + /** + * disabling every button inside button group + */ disabled: PropTypes.bool, + /** + * Children must be an array of button components + */ children: PropTypes.array, + /** + * Adds a className to the root div of the of the ButtonGroup component + */ className: PropTypes.string, + /** + * adding style for button group component + */ style: PropTypes.object, + /** + * updating value of active button in button group component + */ newActiveButtonIndex: PropTypes.number, + /** + * options for rendering type of button, consist of 'default' and 'radiogroup' + */ variant: PropTypes.oneOf(['radiogroup', 'default']), }; diff --git a/ui/components/ui/button-group/button-group.stories.js b/ui/components/ui/button-group/button-group.stories.js index d71172bd0..46c4b2781 100644 --- a/ui/components/ui/button-group/button-group.stories.js +++ b/ui/components/ui/button-group/button-group.stories.js @@ -1,51 +1,94 @@ import React from 'react'; -import { action } from '@storybook/addon-actions'; -import classnames from 'classnames'; -import { text, boolean } from '@storybook/addon-knobs'; + import Button from '../button'; +import README from './README.mdx'; import ButtonGroup from '.'; export default { - title: 'ButtonGroup', + title: 'Components/UI/ButtonGroup', id: __filename, + component: ButtonGroup, + parameters: { docs: { page: README } }, + argTypes: { + defaultActiveButtonIndex: { control: 'number' }, + noButtonActiveByDefault: { control: 'boolean' }, + disabled: { control: 'boolean' }, + children: { control: 'array' }, + className: { control: 'text' }, + style: { control: 'object' }, + newActiveButtonIndex: { control: 'number' }, + variant: { + options: ['radiogroup', 'default'], + control: { type: 'select' }, + }, + }, }; -export const withButtons = () => ( - - - - - +export const DefaultStory = (args) => ( + {args.children} ); -export const withDisabledButton = () => ( - - - - +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + defaultActiveButtonIndex: 1, + noButtonActiveByDefault: false, + disabled: false, + children: ['cheap', 'average', 'fast'].map((label, index) => ( + + )), + className: '', + style: { width: '300px' }, + newActiveButtonIndex: 0, + variant: 'default', +}; + +export const WithDisabledButton = (args) => ( + {args.children} ); -export const radioButtons = () => ( - - - - - +WithDisabledButton.args = { + defaultActiveButtonIndex: 1, + noButtonActiveByDefault: false, + disabled: true, + children: ['cheap', 'average', 'fast'].map((label, index) => ( + + )), + className: '', + style: { width: '300px' }, + newActiveButtonIndex: 0, + variant: 'default', +}; + +export const WithRadioButton = (args) => ( + {args.children} ); + +WithRadioButton.args = { + defaultActiveButtonIndex: 1, + noButtonActiveByDefault: false, + disabled: false, + children: ['cheap', 'average', 'fast'].map((label, index) => ( + + )), + className: '', + style: { width: '300px' }, + newActiveButtonIndex: 0, + variant: 'radiogroup', +}; + +export const NoActiveButton = (args) => ( + {args.children} +); + +NoActiveButton.args = { + defaultActiveButtonIndex: 1, + noButtonActiveByDefault: true, + disabled: false, + children: ['cheap', 'average', 'fast'].map((label, index) => ( + + )), + className: '', + style: { width: '300px' }, + newActiveButtonIndex: 0, + variant: 'default', +}; diff --git a/ui/components/ui/button/button.stories.test.js b/ui/components/ui/button/button.stories.test.js new file mode 100644 index 000000000..391285270 --- /dev/null +++ b/ui/components/ui/button/button.stories.test.js @@ -0,0 +1,12 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import '@testing-library/jest-dom/extend-expect'; +import { DefaultStory } from './button.stories'; + +it('renders the button in the primary state', () => { + render(); + expect(screen.getByRole('button')).toHaveTextContent('Default'); +}); diff --git a/ui/components/ui/check-box/README.mdx b/ui/components/ui/check-box/README.mdx new file mode 100644 index 000000000..b667c2f11 --- /dev/null +++ b/ui/components/ui/check-box/README.mdx @@ -0,0 +1,15 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import CheckBox from '.'; + +# Checkbox + +A small interactive box that can be toggled by the user to indicate an affirmative or negative choice. + + + + + +## Component API + + diff --git a/ui/components/ui/check-box/check-box.component.js b/ui/components/ui/check-box/check-box.component.js index 8d8d4a617..734c60238 100644 --- a/ui/components/ui/check-box/check-box.component.js +++ b/ui/components/ui/check-box/check-box.component.js @@ -58,13 +58,34 @@ const CheckBox = ({ }; CheckBox.propTypes = { + /** + * Add custom classname css + */ className: PropTypes.string, + /** + * Check if checkbox disabled or not + */ disabled: PropTypes.bool, + /** + * Checkbox ID + */ id: PropTypes.string, + /** + * Click handler + */ onClick: PropTypes.func, + /** + * Check if the checkbox are checked or not + */ checked: PropTypes.oneOf([...Object.keys(CHECKBOX_STATE), true, false]) .isRequired, + /** + * Show title + */ title: PropTypes.string, + /** + * Data test ID for checkbox Component + */ dataTestId: PropTypes.string, }; diff --git a/ui/components/ui/check-box/check-box.stories.js b/ui/components/ui/check-box/check-box.stories.js index 6894e46d1..5e3dcb5a1 100644 --- a/ui/components/ui/check-box/check-box.stories.js +++ b/ui/components/ui/check-box/check-box.stories.js @@ -1,17 +1,11 @@ import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { boolean, select, text } from '@storybook/addon-knobs'; +import README from './README.mdx'; import CheckBox, { CHECKED, INDETERMINATE, UNCHECKED, } from './check-box.component'; -export default { - title: 'Check Box', - id: __filename, -}; - const checkboxOptions = { [CHECKED]: CHECKED, [INDETERMINATE]: INDETERMINATE, @@ -20,11 +14,36 @@ const checkboxOptions = { False: false, }; -export const primaryType = () => ( - +export default { + title: 'Components/UI/Check Box', + id: __filename, + component: CheckBox, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + className: { control: 'text' }, + disabled: { control: 'boolean' }, + id: { control: 'text' }, + onClick: { action: 'clicked' }, + checked: { + options: ['CHECKED', 'INDETERMINATE', 'UNCHECKED', 'True', 'False'], + control: 'select', + }, + title: { control: 'text' }, + dataTestId: { control: 'text' }, + }, +}; + +export const DefaultStory = (args) => ( + ); + +DefaultStory.storyName = 'Default'; +DefaultStory.args = { + checked: UNCHECKED, + disabled: false, + id: 'checkboxID', +}; diff --git a/ui/components/ui/circle-icon/README.mdx b/ui/components/ui/circle-icon/README.mdx new file mode 100644 index 000000000..ee4a95b7d --- /dev/null +++ b/ui/components/ui/circle-icon/README.mdx @@ -0,0 +1,16 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import CircleIcon from '.'; + +# Circle Icon + +Add circle border to the image component + + + + + +## Component API + + + diff --git a/ui/components/ui/circle-icon/circle-icon.component.js b/ui/components/ui/circle-icon/circle-icon.component.js index 522953286..c772941c4 100644 --- a/ui/components/ui/circle-icon/circle-icon.component.js +++ b/ui/components/ui/circle-icon/circle-icon.component.js @@ -3,9 +3,21 @@ import PropTypes from 'prop-types'; export default class CircleIcon extends PureComponent { static propTypes = { + /** + * add size (px) for the image container + */ size: PropTypes.string, + /** + * add css classname for the component based on the parent css + */ circleClass: PropTypes.string, + /** + * image source path + */ iconSource: PropTypes.string.isRequired, + /** + * add size (px) for the image + */ iconSize: PropTypes.string, }; diff --git a/ui/components/ui/circle-icon/circle-icon.stories.js b/ui/components/ui/circle-icon/circle-icon.stories.js index 786efaa1a..5fb969737 100644 --- a/ui/components/ui/circle-icon/circle-icon.stories.js +++ b/ui/components/ui/circle-icon/circle-icon.stories.js @@ -1,17 +1,37 @@ import React from 'react'; +import README from './README.mdx'; import CircleIcon from './circle-icon.component'; export default { - title: 'CircleIcon', + title: 'Components/UI/Circle Icon', id: __filename, + component: CircleIcon, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + size: { control: 'text' }, + circleClass: { control: 'text' }, + iconSource: { control: 'text' }, + iconSize: { control: 'text' }, + }, }; -export const basicCircleIcon = () => ( +export const DefaultStory = (args) => ( ); + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + iconSize: '42px', + iconSource: 'images/eth_logo.svg', +}; diff --git a/ui/components/ui/disclosure/disclosure.js b/ui/components/ui/disclosure/disclosure.js new file mode 100644 index 000000000..9e3d56ebc --- /dev/null +++ b/ui/components/ui/disclosure/disclosure.js @@ -0,0 +1,51 @@ +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +const Disclosure = ({ children, title, size }) => { + const disclosureFooterEl = useRef(null); + const [open, setOpen] = useState(false); + + const scrollToBottom = () => { + disclosureFooterEl && + disclosureFooterEl.current && + disclosureFooterEl.current.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + if (open) { + scrollToBottom(); + } + }, [open]); + + return ( +
    setOpen((state) => !state)}> + {title ? ( +
    + + {title}:{' '} + +
    + {children} +
    +
    +
    + ) : ( + children + )} +
    + ); +}; + +Disclosure.propTypes = { + children: PropTypes.node.isRequired, + title: PropTypes.string, + size: PropTypes.string, +}; + +Disclosure.defaultProps = { + size: 'normal', + title: null, +}; + +export default Disclosure; diff --git a/ui/components/ui/disclosure/disclosure.scss b/ui/components/ui/disclosure/disclosure.scss new file mode 100644 index 000000000..8a0fe8b4b --- /dev/null +++ b/ui/components/ui/disclosure/disclosure.scss @@ -0,0 +1,46 @@ +.disclosure { + margin: 12px 0; + cursor: pointer; + font-size: 14px; + + & > details[open] > summary { + &::before { + background-image: url("images/icons/collapse.svg"); + } + } + + &__summary { + position: relative; + padding-left: 24px; + padding-bottom: 10px; + + &::-webkit-details-marker, + &::marker { + display: none; + content: ""; + } + + &::before { + position: absolute; + content: " "; + flex: 0 0 auto; + height: 16px; + width: 16px; + background-image: url("images/icons/expand.svg"); + background-size: contain; + background-repeat: no-repeat; + cursor: pointer; + left: 0; + top: 2px; + } + } + + &__content { + margin-left: 12px; + font-size: 14px; + + &.small { + font-size: 12px; + } + } +} diff --git a/ui/components/ui/disclosure/index.js b/ui/components/ui/disclosure/index.js new file mode 100644 index 000000000..cd9bc820d --- /dev/null +++ b/ui/components/ui/disclosure/index.js @@ -0,0 +1 @@ +export { default } from './disclosure'; diff --git a/ui/components/ui/form-field/index.scss b/ui/components/ui/form-field/index.scss index d7d4cb0b7..ed8859141 100644 --- a/ui/components/ui/form-field/index.scss +++ b/ui/components/ui/form-field/index.scss @@ -3,6 +3,7 @@ &__heading { display: flex; + align-items: center; margin-top: 4px; } diff --git a/ui/components/ui/icon-border/icon-border.scss b/ui/components/ui/icon-border/icon-border.scss index 5028617e6..1ac7e3a4b 100644 --- a/ui/components/ui/icon-border/icon-border.scss +++ b/ui/components/ui/icon-border/icon-border.scss @@ -1,8 +1,9 @@ .icon-border { border-radius: 50%; - border: 1px solid #f2f3f4; - background: #ececf0; + border: 1px solid $ui-1; + background: $ui-1; display: flex; justify-content: center; align-items: center; + overflow: hidden; } diff --git a/ui/components/ui/icon-with-fallback/README.mdx b/ui/components/ui/icon-with-fallback/README.mdx new file mode 100644 index 000000000..10ce346a1 --- /dev/null +++ b/ui/components/ui/icon-with-fallback/README.mdx @@ -0,0 +1,27 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import IconWithFallback from '.'; + +# IconWithFallback + +Icon component that takes an image src and uses `onError` to fallback to the first letter of the icon `name` + + + + + +## Component API + + + +## Usage + +The following describes the props and example usage for this component. + +### Fallback + +If the image src errors `onError` the image tag will be replace with a span and the first letter of the `name` prop + + + + diff --git a/ui/components/ui/icon-with-fallback/icon-with-fallback.component.js b/ui/components/ui/icon-with-fallback/icon-with-fallback.component.js index fec573cd0..62c1ba69f 100644 --- a/ui/components/ui/icon-with-fallback/icon-with-fallback.component.js +++ b/ui/components/ui/icon-with-fallback/icon-with-fallback.component.js @@ -1,46 +1,61 @@ -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -export default class IconWithFallback extends PureComponent { - static propTypes = { - icon: PropTypes.string, - name: PropTypes.string, - size: PropTypes.number, - className: PropTypes.string, - fallbackClassName: PropTypes.string, - }; +const IconWithFallback = ({ + name = '', + icon = null, + size, + className, + fallbackClassName, + ...props +}) => { + const [iconError, setIconError] = useState(false); + const style = size ? { height: `${size}px`, width: `${size}px` } : {}; - static defaultProps = { - name: '', - icon: null, + const handleOnError = () => { + setIconError(true); }; - state = { - iconError: false, - }; + return !iconError && icon ? ( + {name.length + ) : ( + + {name.length ? name.charAt(0).toUpperCase() : ''} + + ); +}; - render() { - const { icon, name, size, className, fallbackClassName } = this.props; - const style = size ? { height: `${size}px`, width: `${size}px` } : {}; +IconWithFallback.propTypes = { + /** + * The img src of the icon + */ + icon: PropTypes.string, + /** + * The name of the icon also used for the alt attribute of the image + */ + name: PropTypes.string, + /** + * The size of the icon. Recommended sizes adhere to 8px grid: 16, 24, 32, 40 + */ + size: PropTypes.number, + /** + * className to apply to the image tag + */ + className: PropTypes.string, + /** + * Additional className to apply to the fallback span tag + */ + fallbackClassName: PropTypes.string, +}; - return !this.state.iconError && icon ? ( - this.setState({ iconError: true })} - src={icon} - style={style} - className={className} - alt="" - /> - ) : ( - - {name.length ? name.charAt(0).toUpperCase() : ''} - - ); - } -} +export default IconWithFallback; diff --git a/ui/components/ui/icon-with-fallback/icon-with-fallback.scss b/ui/components/ui/icon-with-fallback/icon-with-fallback.scss index a0cb7cd42..c31671677 100644 --- a/ui/components/ui/icon-with-fallback/icon-with-fallback.scss +++ b/ui/components/ui/icon-with-fallback/icon-with-fallback.scss @@ -1,5 +1,5 @@ .icon-with-fallback { &__fallback { - color: black; + color: $ui-black; } } diff --git a/ui/components/ui/icon-with-fallback/icon-with-fallback.stories.js b/ui/components/ui/icon-with-fallback/icon-with-fallback.stories.js new file mode 100644 index 000000000..aa6087ec0 --- /dev/null +++ b/ui/components/ui/icon-with-fallback/icon-with-fallback.stories.js @@ -0,0 +1,49 @@ +import React from 'react'; + +import README from './README.mdx'; +import IconWithFallback from '.'; + +export default { + title: 'Components/UI/IconWithFallback', + id: __filename, + component: IconWithFallback, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + icon: { + control: 'text', + }, + name: { + control: 'text', + }, + size: { + control: 'number', + }, + className: { + control: 'text', + }, + fallbackClassName: { + control: 'text', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + name: 'ast', + icon: './AST.png', + size: 24, +}; + +export const Fallback = (args) => ; + +Fallback.args = { + name: 'ast', + size: 24, +}; diff --git a/ui/components/ui/icon-with-fallback/icon-with-fallback.test.js b/ui/components/ui/icon-with-fallback/icon-with-fallback.test.js new file mode 100644 index 000000000..5cff033cb --- /dev/null +++ b/ui/components/ui/icon-with-fallback/icon-with-fallback.test.js @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import IconWithFallback from '.'; + +describe('IconWithFallback', () => { + const args = { + name: 'Snap name', + icon: './AST.png', + className: 'classname-test', + fallbackClassName: 'fallback-classname-test', + }; + + it('should render without crashing', () => { + const { container } = render(); + expect(container.querySelector('span')).toBeDefined(); + }); + + it('should render an icon image', () => { + const { getByAltText } = render(); + const image = getByAltText(args.name); + expect(image).toBeDefined(); + expect(image).toHaveAttribute('src', args.icon); + }); + + it('should render with a fallback letter from the name prop', () => { + const { getByText } = render(); + expect(getByText('S')).toBeDefined(); + }); + + it('should render with a classname', () => { + const { getByAltText } = render(); + expect(getByAltText(args.name)).toHaveClass(args.className); + }); + + it('should render with a fallback classname', () => { + const { getByText } = render(); + expect(getByText('S')).toHaveClass(args.fallbackClassName); + }); +}); diff --git a/ui/components/ui/nickname-popover/index.js b/ui/components/ui/nickname-popover/index.js new file mode 100644 index 000000000..f5303aa1e --- /dev/null +++ b/ui/components/ui/nickname-popover/index.js @@ -0,0 +1 @@ +export { default } from './nickname-popover.component'; diff --git a/ui/components/ui/nickname-popover/index.scss b/ui/components/ui/nickname-popover/index.scss new file mode 100644 index 000000000..c7b91d246 --- /dev/null +++ b/ui/components/ui/nickname-popover/index.scss @@ -0,0 +1,74 @@ +.nickname-popover { + &__popover-wrap { + height: 232px; + border-radius: 4px; + background: $ui-white; + display: flex; + justify-content: center; + width: auto; + + .popover-header { + padding: 16px 16px 0 0; + } + + .popover-content { + margin-top: -15px; + align-items: center; + } + } + + &__address { + @include H4; + + font-weight: bold; + display: flex; + align-items: center; + color: $Black-100; + padding-top: 8px; + } + + &__public-address { + @include H7; + + display: flex; + flex-direction: row; + align-items: center; + min-height: 25px; + background: $Grey-000; + border-radius: 40px; + padding-left: 8px; + padding-right: 2px; + margin-top: 8px; + + button { + background: none; + } + + &__constant { + @include H8; + + color: $Grey-500; + } + } + + &__view-on-block-explorer { + @include H7; + + color: $primary-1; + margin-top: 12px; + } + + &__etherscan-link { + @include H7; + + padding: 0; + } + + &__footer-button { + margin-top: 16px; + width: 152px; + height: 40px; + border-radius: 100px; + background: $primary-1; + } +} diff --git a/ui/components/ui/nickname-popover/nickname-popover.component.js b/ui/components/ui/nickname-popover/nickname-popover.component.js new file mode 100644 index 000000000..0ffea24e4 --- /dev/null +++ b/ui/components/ui/nickname-popover/nickname-popover.component.js @@ -0,0 +1,95 @@ +import React, { useCallback, useContext } from 'react'; +import PropTypes from 'prop-types'; +import { I18nContext } from '../../../contexts/i18n'; +import Tooltip from '../tooltip'; +import Popover from '../popover'; +import Button from '../button'; +import Identicon from '../identicon/identicon.component'; +import { shortenAddress } from '../../../helpers/utils/util'; +import CopyIcon from '../icon/copy-icon.component'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; + +const NicknamePopover = ({ + address, + nickname, + onClose = null, + onAdd = null, + explorerLink, +}) => { + const t = useContext(I18nContext); + + const onAddClick = useCallback(() => { + onAdd(); + }, [onAdd]); + + const [copied, handleCopy] = useCopyToClipboard(); + + return ( +
    + + +
    + {nickname || shortenAddress(address)} +
    +
    +
    + {address} +
    + + + + +
    + +
    + +
    + +
    +
    + ); +}; + +NicknamePopover.propTypes = { + address: PropTypes.string, + nickname: PropTypes.string, + onClose: PropTypes.func, + onAdd: PropTypes.func, + explorerLink: PropTypes.string, +}; + +export default NicknamePopover; diff --git a/ui/components/ui/nickname-popover/nickname-popover.stories.js b/ui/components/ui/nickname-popover/nickname-popover.stories.js new file mode 100644 index 000000000..a1097e32c --- /dev/null +++ b/ui/components/ui/nickname-popover/nickname-popover.stories.js @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { text } from '@storybook/addon-knobs'; +import Button from '../button'; +import NicknamePopover from '.'; + +export default { + title: 'NicknamePopover', + id: __filename, +}; + +export const Default = () => { + const [showNicknamePopover, setShowNicknamePopover] = useState(false); + + return ( +
    + + {showNicknamePopover && ( + setShowNicknamePopover(false)} + onAdd={action('add NicknamePopover')} + /> + )} +
    + ); +}; diff --git a/ui/components/ui/sender-to-recipient/sender-to-recipient.component.js b/ui/components/ui/sender-to-recipient/sender-to-recipient.component.js index 4f92a3016..ef229d84c 100644 --- a/ui/components/ui/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/components/ui/sender-to-recipient/sender-to-recipient.component.js @@ -8,6 +8,7 @@ import { shortenAddress } from '../../../helpers/utils/util'; import AccountMismatchWarning from '../account-mismatch-warning/account-mismatch-warning.component'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; +import NicknamePopovers from '../../app/modals/nickname-popovers'; import { DEFAULT_VARIANT, CARDS_VARIANT, @@ -94,7 +95,7 @@ SenderAddress.propTypes = { warnUserOnAccountMismatch: PropTypes.bool, }; -function RecipientWithAddress({ +export function RecipientWithAddress({ checksummedRecipientAddress, onRecipientClick, addressOnly, @@ -103,44 +104,22 @@ function RecipientWithAddress({ recipientName, }) { const t = useI18nContext(); - const [addressCopied, setAddressCopied] = useState(false); + const [showNicknamePopovers, setShowNicknamePopovers] = useState(false); - let tooltipHtml =

    {t('copiedExclamation')}

    ; - if (!addressCopied) { - if (addressOnly && !recipientNickname && !recipientEns) { - tooltipHtml =

    {t('copyAddress')}

    ; - } else { - tooltipHtml = ( -

    - {shortenAddress(checksummedRecipientAddress)} -
    - {t('copyAddress')} -

    - ); - } - } return ( -
    { - setAddressCopied(true); - copyToClipboard(checksummedRecipientAddress); - if (onRecipientClick) { - onRecipientClick(); - } - }} - > -
    - -
    - setAddressCopied(false)} + <> +
    { + setShowNicknamePopovers(true); + if (onRecipientClick) { + onRecipientClick(); + } + }} > +
    + +
    {addressOnly ? recipientNickname || @@ -151,8 +130,14 @@ function RecipientWithAddress({ recipientName || t('newContract')}
    - -
    +
    + {showNicknamePopovers ? ( + setShowNicknamePopovers(false)} + address={checksummedRecipientAddress} + /> + ) : null} + ); } diff --git a/ui/components/ui/site-icon/README.mdx b/ui/components/ui/site-icon/README.mdx new file mode 100644 index 000000000..6ef847958 --- /dev/null +++ b/ui/components/ui/site-icon/README.mdx @@ -0,0 +1,27 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import SiteIcon from '.'; + +# SiteIcon + +SiteIcon uses the `IconBorder` and `IconWithFallback` components to create an icon within a gray ellipse + + + + + +## Component API + + + +## Usage + +The following describes the props and example usage for this component. + +### Fallback + +`SiteIcon` wraps the `IconWithFallback` component which has a fallback `onError` and will display the first letter of the `name` prop + + + + diff --git a/ui/components/ui/site-icon/site-icon.js b/ui/components/ui/site-icon/site-icon.js index 4101b7b30..f165ff311 100644 --- a/ui/components/ui/site-icon/site-icon.js +++ b/ui/components/ui/site-icon/site-icon.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import IconBorder from '../icon-border'; import IconWithFallback from '../icon-with-fallback'; -export default function SiteIcon({ icon, name, size }) { +export default function SiteIcon({ icon = null, name = '', size }) { const iconSize = Math.floor(size * 0.75); return ( @@ -13,12 +13,19 @@ export default function SiteIcon({ icon, name, size }) { } SiteIcon.propTypes = { + /** + * The img src of the icon. + * Used in IconWithFallback + */ icon: PropTypes.string, + /** + * The name of the icon also used for the alt tag of the image and fallback letter. + * Used in IconWithFallback + */ name: PropTypes.string, + /** + * The size of the icon. + * Used in IconWithFallback + */ size: PropTypes.number.isRequired, }; - -SiteIcon.defaultProps = { - icon: undefined, - name: undefined, -}; diff --git a/ui/components/ui/site-icon/site-icon.stories.js b/ui/components/ui/site-icon/site-icon.stories.js new file mode 100644 index 000000000..8d0bdba71 --- /dev/null +++ b/ui/components/ui/site-icon/site-icon.stories.js @@ -0,0 +1,43 @@ +import React from 'react'; + +import README from './README.mdx'; +import SiteIcon from '.'; + +export default { + title: 'Components/UI/SiteIcon', + id: __filename, + component: SiteIcon, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + icon: { + control: 'text', + }, + name: { + control: 'text', + }, + size: { + control: 'number', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + name: 'eth', + icon: './images/eth_logo.svg', + size: 24, +}; + +export const Fallback = (args) => ; + +Fallback.args = { + name: 'eth', + size: 24, +}; diff --git a/ui/components/ui/site-icon/site-icon.test.js b/ui/components/ui/site-icon/site-icon.test.js new file mode 100644 index 000000000..5c4efa156 --- /dev/null +++ b/ui/components/ui/site-icon/site-icon.test.js @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import SiteIcon from '.'; + +describe('SiteIcon', () => { + const args = { + name: 'Snap name', + icon: './images/eth_logo.svg', + className: 'classname-test', + fallbackClassName: 'fallback-classname-test', + }; + + it('should render without crashing', () => { + const { getByText } = render(); + expect(getByText('S')).toBeDefined(); + }); + + it('should render an icon image', () => { + const { getByAltText } = render(); + const image = getByAltText(args.name); + expect(image).toBeDefined(); + expect(image).toHaveAttribute('src', args.icon); + }); +}); diff --git a/ui/components/ui/text-field/text-field.component.js b/ui/components/ui/text-field/text-field.component.js index a9a212d16..ba9fa28b0 100644 --- a/ui/components/ui/text-field/text-field.component.js +++ b/ui/components/ui/text-field/text-field.component.js @@ -203,6 +203,7 @@ const TextField = ({ min, max, autoComplete, + onPaste, ...textFieldProps }) => { const inputProps = themeToInputProps[theme]({ @@ -215,6 +216,16 @@ const TextField = ({ autoComplete, }); + if (onPaste) { + if (!inputProps.InputProps) { + inputProps.InputProps = {}; + } + if (!inputProps.InputProps.inputProps) { + inputProps.InputProps.inputProps = {}; + } + inputProps.InputProps.inputProps.onPaste = onPaste; + } + return ( { - const { value, onToggle, offLabel, onLabel, disabled } = props; + const { value, onToggle, offLabel, onLabel, disabled, className } = props; const modifier = value ? 'on' : 'off'; @@ -59,9 +59,14 @@ const ToggleButton = (props) => { onToggle(value); } }} - className={classnames('toggle-button', `toggle-button--${modifier}`, { - 'toggle-button--disabled': disabled, - })} + className={classnames( + 'toggle-button', + `toggle-button--${modifier}`, + { + 'toggle-button--disabled': disabled, + }, + className, + )} > { + it('should render the Typography without crashing', () => { + const { getByText } = render(Test type); + expect(getByText('Test type')).toBeDefined(); + }); + it('should render the Typography with correct html tags', () => { + const { getByText, container } = render( + <> + p tag + h1 tag + h2 tag + h3 tag + h4 tag + h5 tag + h6 tag + span tag + strong tag + em tag + li tag + div tag + dt tag + dd tag + , + ); + expect(container.querySelector('p')).toBeDefined(); + expect(getByText('p tag')).toBeDefined(); + expect(container.querySelector('h1')).toBeDefined(); + expect(getByText('h1 tag')).toBeDefined(); + expect(container.querySelector('h2')).toBeDefined(); + expect(getByText('h2 tag')).toBeDefined(); + expect(container.querySelector('h3')).toBeDefined(); + expect(getByText('h3 tag')).toBeDefined(); + expect(container.querySelector('h4')).toBeDefined(); + expect(getByText('h4 tag')).toBeDefined(); + expect(container.querySelector('h5')).toBeDefined(); + expect(getByText('h5 tag')).toBeDefined(); + expect(container.querySelector('h6')).toBeDefined(); + expect(getByText('h6 tag')).toBeDefined(); + expect(container.querySelector('span')).toBeDefined(); + expect(getByText('span tag')).toBeDefined(); + expect(container.querySelector('strong')).toBeDefined(); + expect(getByText('strong tag')).toBeDefined(); + expect(container.querySelector('em')).toBeDefined(); + expect(getByText('em tag')).toBeDefined(); + expect(container.querySelector('li')).toBeDefined(); + expect(getByText('li tag')).toBeDefined(); + expect(container.querySelector('div')).toBeDefined(); + expect(getByText('div tag')).toBeDefined(); + expect(container.querySelector('dt')).toBeDefined(); + expect(getByText('dt tag')).toBeDefined(); + expect(container.querySelector('dd')).toBeDefined(); + expect(getByText('dd tag')).toBeDefined(); + }); +}); diff --git a/ui/components/ui/ui-components.scss b/ui/components/ui/ui-components.scss index 6200bb21f..34c435acd 100644 --- a/ui/components/ui/ui-components.scss +++ b/ui/components/ui/ui-components.scss @@ -35,6 +35,7 @@ @import 'loading-screen/index'; @import 'menu/menu'; @import 'numeric-input/numeric-input'; +@import 'nickname-popover/index'; @import 'form-field/index'; @import 'page-container/index'; @import 'popover/index'; @@ -53,3 +54,5 @@ @import 'typography/typography'; @import 'unit-input/index'; @import 'url-icon/index'; +@import 'update-nickname-popover/index'; +@import 'disclosure/disclosure'; diff --git a/ui/components/ui/update-nickname-popover/index.js b/ui/components/ui/update-nickname-popover/index.js new file mode 100644 index 000000000..010441206 --- /dev/null +++ b/ui/components/ui/update-nickname-popover/index.js @@ -0,0 +1 @@ +export { default } from './update-nickname-popover'; diff --git a/ui/components/ui/update-nickname-popover/index.scss b/ui/components/ui/update-nickname-popover/index.scss new file mode 100644 index 000000000..650edf11d --- /dev/null +++ b/ui/components/ui/update-nickname-popover/index.scss @@ -0,0 +1,64 @@ + +.update-nickname { + &__wrapper { + height: 620px; + width: 360px; + border-radius: 10px; + max-height: 100vh; + width: auto; + + .popover-header { + border-bottom: 1px solid #d2d8dd; + margin-bottom: 16px; + border-radius: 10px; + } + + @media screen and (max-width: $break-small) { + width: 96%; + height: 96%; + } + } + + &__cancel { + margin-right: 16px; + } + + &__content { + padding: 0 20px 20px 20px; + position: relative; + + &__indenticon { + margin-bottom: 16px; + } + + &__text-area-wrapper { + height: 96px !important; + } + + &__text-area { + line-height: initial !important; + } + + &__address { + margin-top: 8px; + font-size: 13px; + color: #bbc0c5; + margin-bottom: 16px; + overflow-wrap: break-word; + } + + &__label, + &__label--capitalized { + text-transform: capitalize; + margin-top: 16px; + color: #24292e; + font-size: 14px; + } + + &__nickname-label { + margin-bottom: 8px; + color: #24292e; + font-size: 14px; + } + } +} diff --git a/ui/components/ui/update-nickname-popover/update-nickname-popover.js b/ui/components/ui/update-nickname-popover/update-nickname-popover.js new file mode 100644 index 000000000..2713d7b70 --- /dev/null +++ b/ui/components/ui/update-nickname-popover/update-nickname-popover.js @@ -0,0 +1,119 @@ +import React, { useCallback, useContext, useState } from 'react'; +import PropTypes from 'prop-types'; + +import Popover from '../popover'; +import Button from '../button'; +import TextField from '../text-field'; + +import { I18nContext } from '../../../contexts/i18n'; + +import Identicon from '../identicon/identicon.component'; + +export default function UpdateNicknamePopover({ + nickname, + address, + onAdd, + memo, + onClose, +}) { + const t = useContext(I18nContext); + + const [nicknameInput, setNicknameInput] = useState(nickname); + const [memoInput, setMemoInput] = useState(memo); + + const handleNicknameChange = (event) => { + setNicknameInput(event.target.value); + }; + + const handleMemoChange = (event) => { + setMemoInput(event.target.value); + }; + + const closePopover = useCallback(() => { + onClose(); + }, [onClose]); + + const onCancel = () => { + onClose(); + }; + + const onSubmit = () => { + onAdd(address, nicknameInput, memoInput); + onClose(); + }; + + return ( + + + + + } + > +
    + + +
    {address}
    +
    + {t('nickname')} +
    + +
    + {t('memo')} +
    + +
    +
    + ); +} + +UpdateNicknamePopover.propTypes = { + nickname: PropTypes.string, + address: PropTypes.string, + memo: PropTypes.string, + onAdd: PropTypes.func, + onClose: PropTypes.func, +}; diff --git a/ui/components/ui/update-nickname-popover/update-nickname-popover.stories.js b/ui/components/ui/update-nickname-popover/update-nickname-popover.stories.js new file mode 100644 index 000000000..ce9c34aca --- /dev/null +++ b/ui/components/ui/update-nickname-popover/update-nickname-popover.stories.js @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { text } from '@storybook/addon-knobs'; +import Button from '../button'; +import UpdateNicknamePopover from '.'; + +export default { + title: 'UpdateNickname', + id: __filename, +}; + +export const AddNickname = () => { + const [showPopover, setShowPopover] = useState(false); + return ( +
    + + {showPopover && ( + action(`Close Update Nickname Popover`)()} + /> + )} +
    + ); +}; + +export const UpdateNickname = () => { + const [showPopover, setShowPopover] = useState(false); + return ( +
    + + {showPopover && ( + action(`Close Update Nickname Popover`)()} + /> + )} +
    + ); +}; diff --git a/ui/contexts/gasFee.js b/ui/contexts/gasFee.js index c7fe7f092..65242fe94 100644 --- a/ui/contexts/gasFee.js +++ b/ui/contexts/gasFee.js @@ -31,7 +31,7 @@ export function useGasFeeContext() { GasFeeContextProvider.propTypes = { children: PropTypes.node.isRequired, defaultEstimateToUse: PropTypes.string, - transaction: PropTypes.object.isRequired, + transaction: PropTypes.object, minimumGasLimit: PropTypes.string, editGasMode: PropTypes.string, }; diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index bb23afef8..3e2b9786a 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -3,6 +3,7 @@ import React, { createContext, useEffect, useCallback, + useContext, useState, } from 'react'; import { useSelector } from 'react-redux'; @@ -124,6 +125,10 @@ export function MetaMetricsProvider({ children }) { MetaMetricsProvider.propTypes = { children: PropTypes.node }; +export function useMetaMetricsContext() { + return useContext(MetaMetricsContext); +} + export class LegacyMetaMetricsProvider extends Component { static propTypes = { children: PropTypes.node, diff --git a/ui/contexts/transaction-modal.js b/ui/contexts/transaction-modal.js new file mode 100644 index 000000000..bf16ecf09 --- /dev/null +++ b/ui/contexts/transaction-modal.js @@ -0,0 +1,80 @@ +import React, { createContext, useContext, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { TRANSACTION_TYPES } from '../../shared/constants/transaction'; +import { getMethodName } from '../helpers/utils/metrics'; +import { useGasFeeContext } from './gasFee'; +import { useMetaMetricsContext } from './metametrics'; + +export const TransactionModalContext = createContext({}); + +export const TransactionModalContextProvider = ({ + actionKey, + children, + methodData, +}) => { + const [openModals, setOpenModals] = useState([]); + const metricsEvent = useMetaMetricsContext(); + const { transaction: { origin } = {} } = useGasFeeContext(); + + const captureEvent = () => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'User clicks "Edit" on gas', + }, + customVariables: { + recipientKnown: null, + functionType: + actionKey || + getMethodName(methodData.name) || + TRANSACTION_TYPES.CONTRACT_INTERACTION, + origin, + }, + }); + }; + + const closeModal = (modalName) => { + const index = openModals.indexOf(modalName); + if (openModals < 0) return; + const modals = [...openModals]; + modals.splice(index, 1); + setOpenModals(modals); + }; + + const closeAllModals = () => { + setOpenModals([]); + }; + + const openModal = (modalName) => { + if (openModals.includes(modalName)) return; + captureEvent(); + const modals = [...openModals]; + modals.push(modalName); + setOpenModals(modals); + }; + + return ( + + {children} + + ); +}; + +export function useTransactionModalContext() { + return useContext(TransactionModalContext); +} + +TransactionModalContextProvider.propTypes = { + actionKey: PropTypes.string, + children: PropTypes.node.isRequired, + methodData: PropTypes.object, +}; diff --git a/ui/css/design-system/attributes.scss b/ui/css/design-system/attributes.scss index 8b920b7eb..4a187a949 100644 --- a/ui/css/design-system/attributes.scss +++ b/ui/css/design-system/attributes.scss @@ -82,4 +82,4 @@ $display: block, grid, flex, inline-block, inline-grid, inline-flex, list-item; $text-align: left, right, center, justify, end; $font-weight: bold, normal, 100, 200, 300, 400, 500, 600, 700, 800, 900; $font-style: normal, italic, oblique; -$font-size: 12px; +$font-size: 10px, 12px; diff --git a/ui/css/design-system/colors.scss b/ui/css/design-system/colors.scss index d76de5ef6..b66c07690 100644 --- a/ui/css/design-system/colors.scss +++ b/ui/css/design-system/colors.scss @@ -1,6 +1,5 @@ // These are the colors of the MetaMask design system // Only design system colors should be added, no superfluous variables -// See https://bit.ly/32mnoja (link to figma design system) $Blue-000: #eaf6ff; $Blue-100: #a7d9fe; $Blue-200: #75c4fd; @@ -112,6 +111,8 @@ $rinkeby: #f6c343; $goerli: #3099f2; $localhost: #29b6af; +$flask-purple: #8b45b6; + $color-map: ( 'ui-1': $ui-1, 'ui-2': $ui-2, @@ -143,4 +144,5 @@ $color-map: ( 'goerli': $goerli, 'localhost': $localhost, 'transparent': transparent, + 'flask-purple': $flask-purple ); diff --git a/ui/ducks/app/app.js b/ui/ducks/app/app.js index 2baa2f53d..47c440e00 100644 --- a/ui/ducks/app/app.js +++ b/ui/ducks/app/app.js @@ -55,6 +55,7 @@ export default function reduceApp(state = {}, action) { ledgerWebHidConnectedStatus: WEBHID_CONNECTED_STATUSES.UNKNOWN, ledgerTransportStatus: TRANSPORT_STATES.NONE, newNetworkAdded: '', + newCollectibleAddedMessage: '', ...state, }; @@ -290,6 +291,12 @@ export default function reduceApp(state = {}, action) { newNetworkAdded: action.value, }; + case actionConstants.SET_NEW_COLLECTIBLE_ADDED_MESSAGE: + return { + ...appState, + newCollectibleAddedMessage: action.value, + }; + case actionConstants.LOADING_METHOD_DATA_STARTED: return { ...appState, diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index 4acfcad50..8923574fb 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -85,6 +85,10 @@ const initialState = { balanceError: false, fetchingQuotes: false, fromToken: null, + fromTokenInputValue: '', + fromTokenError: null, + isFeatureFlagLoaded: false, + maxSlippage: 3, quotesFetchStartTime: null, reviewSwapClickedTimestamp: null, topAssets: {}, @@ -128,6 +132,18 @@ const slice = createSlice({ setFromToken: (state, action) => { state.fromToken = action.payload; }, + setFromTokenInputValue: (state, action) => { + state.fromTokenInputValue = action.payload; + }, + setFromTokenError: (state, action) => { + state.fromTokenError = action.payload; + }, + setIsFeatureFlagLoaded: (state, action) => { + state.isFeatureFlagLoaded = action.payload; + }, + setMaxSlippage: (state, action) => { + state.maxSlippage = action.payload; + }, setQuotesFetchStartTime: (state, action) => { state.quotesFetchStartTime = action.payload; }, @@ -178,6 +194,16 @@ export const getBalanceError = (state) => state.swaps.balanceError; export const getFromToken = (state) => state.swaps.fromToken; +export const getFromTokenError = (state) => state.swaps.fromTokenError; + +export const getFromTokenInputValue = (state) => + state.swaps.fromTokenInputValue; + +export const getIsFeatureFlagLoaded = (state) => + state.swaps.isFeatureFlagLoaded; + +export const getMaxSlippage = (state) => state.swaps.maxSlippage; + export const getTopAssets = (state) => state.swaps.topAssets; export const getToToken = (state) => state.swaps.toToken; @@ -237,9 +263,6 @@ const getSwapsState = (state) => state.metamask.swapsState; export const getSwapsFeatureIsLive = (state) => state.metamask.swapsState.swapsFeatureIsLive; -export const getUseNewSwapsApi = (state) => - state.metamask.swapsState.useNewSwapsApi; - export const getSwapsQuoteRefreshTime = (state) => state.metamask.swapsState.swapsQuoteRefreshTime; @@ -332,6 +355,10 @@ const { setBalanceError, setFetchingQuotes, setFromToken, + setFromTokenError, + setFromTokenInputValue, + setIsFeatureFlagLoaded, + setMaxSlippage, setQuotesFetchStartTime, setReviewSwapClickedTimestamp, setTopAssets, @@ -348,6 +375,10 @@ export { setBalanceError, setFetchingQuotes, setFromToken as setSwapsFromToken, + setFromTokenError, + setFromTokenInputValue, + setIsFeatureFlagLoaded, + setMaxSlippage, setQuotesFetchStartTime as setSwapQuotesFetchStartTime, setReviewSwapClickedTimestamp, setTopAssets, @@ -403,7 +434,6 @@ export const fetchSwapsLiveness = () => { return async (dispatch, getState) => { let swapsLivenessForNetwork = { swapsFeatureIsLive: false, - useNewSwapsApi: false, }; try { const swapsFeatureFlags = await fetchSwapsFeatureFlags(); @@ -415,6 +445,7 @@ export const fetchSwapsLiveness = () => { log.error('Failed to fetch Swaps liveness, defaulting to false.', error); } await dispatch(setSwapsLiveness(swapsLivenessForNetwork)); + dispatch(setIsFeatureFlagLoaded(true)); return swapsLivenessForNetwork; }; }; @@ -431,7 +462,6 @@ export const fetchQuotesAndSetQuoteState = ( const chainId = getCurrentChainId(state); let swapsLivenessForNetwork = { swapsFeatureIsLive: false, - useNewSwapsApi: false, }; try { const swapsFeatureFlags = await fetchSwapsFeatureFlags(); @@ -655,7 +685,6 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { ); let swapsLivenessForNetwork = { swapsFeatureIsLive: false, - useNewSwapsApi: false, }; try { const swapsFeatureFlags = await fetchSwapsFeatureFlags(); @@ -913,13 +942,12 @@ export function fetchMetaSwapsGasPriceEstimates() { return async (dispatch, getState) => { const state = getState(); const chainId = getCurrentChainId(state); - const useNewSwapsApi = getUseNewSwapsApi(state); dispatch(swapGasPriceEstimatesFetchStarted()); let priceEstimates; try { - priceEstimates = await fetchSwapsGasPrices(chainId, useNewSwapsApi); + priceEstimates = await fetchSwapsGasPrices(chainId); } catch (e) { log.warn('Fetching swaps gas prices failed:', e); diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js index 192e1bb9d..e766e484e 100644 --- a/ui/ducks/swaps/swaps.test.js +++ b/ui/ducks/swaps/swaps.test.js @@ -61,7 +61,6 @@ describe('Ducks - Swaps', () => { const mockDispatch = jest.fn(); const expectedSwapsLiveness = { swapsFeatureIsLive: true, - useNewSwapsApi: true, }; const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); const featureFlagApiNock = mockFeatureFlagsApiResponse({ @@ -72,7 +71,7 @@ describe('Ducks - Swaps', () => { createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); @@ -81,7 +80,6 @@ describe('Ducks - Swaps', () => { const mockDispatch = jest.fn(); const expectedSwapsLiveness = { swapsFeatureIsLive: true, - useNewSwapsApi: false, }; const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); featureFlagsResponse.ethereum.extension_active = false; @@ -93,7 +91,7 @@ describe('Ducks - Swaps', () => { createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); @@ -102,7 +100,6 @@ describe('Ducks - Swaps', () => { const mockDispatch = jest.fn(); const expectedSwapsLiveness = { swapsFeatureIsLive: false, - useNewSwapsApi: false, }; const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); featureFlagsResponse.ethereum.extension_active = false; @@ -115,7 +112,7 @@ describe('Ducks - Swaps', () => { createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); @@ -124,7 +121,6 @@ describe('Ducks - Swaps', () => { const mockDispatch = jest.fn(); const expectedSwapsLiveness = { swapsFeatureIsLive: false, - useNewSwapsApi: false, }; const featureFlagApiNock = mockFeatureFlagsApiResponse({ replyWithError: true, @@ -134,7 +130,7 @@ describe('Ducks - Swaps', () => { createGetState(), ); expect(featureFlagApiNock.isDone()).toBe(true); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); @@ -143,7 +139,6 @@ describe('Ducks - Swaps', () => { const mockDispatch = jest.fn(); const expectedSwapsLiveness = { swapsFeatureIsLive: true, - useNewSwapsApi: true, }; const featureFlagsResponse = MOCKS.createFeatureFlagsResponse(); const featureFlagApiNock = mockFeatureFlagsApiResponse({ @@ -159,7 +154,7 @@ describe('Ducks - Swaps', () => { createGetState(), ); expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead. - expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledTimes(4); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); @@ -210,22 +205,6 @@ describe('Ducks - Swaps', () => { }); }); - describe('getUseNewSwapsApi', () => { - it('returns true for "useNewSwapsApi"', () => { - const state = createSwapsMockStore(); - const useNewSwapsApi = true; - state.metamask.swapsState.useNewSwapsApi = useNewSwapsApi; - expect(swaps.getUseNewSwapsApi(state)).toBe(useNewSwapsApi); - }); - - it('returns false for "useNewSwapsApi"', () => { - const state = createSwapsMockStore(); - const useNewSwapsApi = false; - state.metamask.swapsState.useNewSwapsApi = useNewSwapsApi; - expect(swaps.getUseNewSwapsApi(state)).toBe(useNewSwapsApi); - }); - }); - describe('getUsedQuote', () => { it('returns selected quote', () => { const state = createSwapsMockStore(); diff --git a/ui/helpers/constants/gas.js b/ui/helpers/constants/gas.js index 4f2d8612b..726057342 100644 --- a/ui/helpers/constants/gas.js +++ b/ui/helpers/constants/gas.js @@ -31,3 +31,11 @@ export function getGasFormErrorText(type, t, { minimumGasLimit } = {}) { return ''; } } + +export const PRIORITY_LEVEL_ICON_MAP = { + low: '🐢', + medium: '🦊', + high: '🦍', + dappSuggested: '🌐', + custom: '⚙', +}; diff --git a/ui/helpers/constants/routes.js b/ui/helpers/constants/routes.js index d1f1dcf3d..541dad5c1 100644 --- a/ui/helpers/constants/routes.js +++ b/ui/helpers/constants/routes.js @@ -70,6 +70,11 @@ const ONBOARDING_PIN_EXTENSION_ROUTE = '/onboarding/pin-extension'; const ONBOARDING_WELCOME_ROUTE = '/onboarding/welcome'; const ONBOARDING_METAMETRICS = '/onboarding/metametrics'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +const INITIALIZE_EXPERIMENTAL_AREA = '/initialize/experimental-area'; +const ONBOARDING_EXPERIMENTAL_AREA = '/onboarding/experimental-area'; +///: END:ONLY_INCLUDE_IN + const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction'; const CONFIRM_SEND_ETHER_PATH = '/send-ether'; const CONFIRM_SEND_TOKEN_PATH = '/send-token'; @@ -234,4 +239,8 @@ export { ONBOARDING_PIN_EXTENSION_ROUTE, ONBOARDING_WELCOME_ROUTE, ONBOARDING_METAMETRICS, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + INITIALIZE_EXPERIMENTAL_AREA, + ONBOARDING_EXPERIMENTAL_AREA, + ///: END:ONLY_INCLUDE_IN }; diff --git a/ui/helpers/utils/metric.test.js b/ui/helpers/utils/metric.test.js new file mode 100644 index 000000000..b3a4981d5 --- /dev/null +++ b/ui/helpers/utils/metric.test.js @@ -0,0 +1,13 @@ +import { getMethodName } from './metrics'; + +describe('getMethodName', () => { + it('should get correct method names', () => { + expect(getMethodName(undefined)).toStrictEqual(''); + expect(getMethodName({})).toStrictEqual(''); + expect(getMethodName('confirm')).toStrictEqual('confirm'); + expect(getMethodName('balanceOf')).toStrictEqual('balance Of'); + expect(getMethodName('ethToTokenSwapInput')).toStrictEqual( + 'eth To Token Swap Input', + ); + }); +}); diff --git a/ui/helpers/utils/metrics.js b/ui/helpers/utils/metrics.js new file mode 100644 index 000000000..c085545f2 --- /dev/null +++ b/ui/helpers/utils/metrics.js @@ -0,0 +1,10 @@ +export function getMethodName(camelCase) { + if (!camelCase || typeof camelCase !== 'string') { + return ''; + } + + return camelCase + .replace(/([a-z])([A-Z])/gu, '$1 $2') + .replace(/([A-Z])([a-z])/gu, ' $1$2') + .replace(/ +/gu, ' '); +} diff --git a/ui/helpers/utils/optimism/buildUnserializedTransaction.test.js b/ui/helpers/utils/optimism/buildUnserializedTransaction.test.js index 1beaac13c..5789e0f17 100644 --- a/ui/helpers/utils/optimism/buildUnserializedTransaction.test.js +++ b/ui/helpers/utils/optimism/buildUnserializedTransaction.test.js @@ -1,5 +1,4 @@ import { BN } from 'ethereumjs-util'; -import { times } from 'lodash'; import buildUnserializedTransaction from './buildUnserializedTransaction'; describe('buildUnserializedTransaction', () => { @@ -14,15 +13,13 @@ describe('buildUnserializedTransaction', () => { data: '0x0', }, }); - expect(unserializedTransaction).toMatchObject({ - nonce: new BN('00', 16), - gasPrice: new BN('64', 16), - gasLimit: new BN('5208', 16), - to: expect.objectContaining({ - buf: Buffer.from(times(20, 0)), - }), - value: new BN('09184e72a000', 16), - data: Buffer.from([0]), + expect(unserializedTransaction.toJSON()).toMatchObject({ + nonce: '0x0', + gasPrice: '0x64', + gasLimit: '0x5208', + to: '0x0000000000000000000000000000000000000000', + value: '0x9184e72a000', + data: '0x00', }); }); }); diff --git a/ui/helpers/utils/util.js b/ui/helpers/utils/util.js index b20e69559..f7d839d08 100644 --- a/ui/helpers/utils/util.js +++ b/ui/helpers/utils/util.js @@ -408,3 +408,23 @@ export function getURLHost(url) { export function getURLHostName(url) { return getURL(url)?.hostname || ''; } + +// Once we reach this threshold, we switch to higher unit +const MINUTE_CUTOFF = 90 * 60; +const SECOND_CUTOFF = 90; + +export const toHumanReadableTime = (t, milliseconds) => { + if (milliseconds === undefined || milliseconds === null) return ''; + const seconds = Math.ceil(milliseconds / 1000); + if (seconds <= SECOND_CUTOFF) { + return t('gasTimingSecondsShort', [seconds]); + } + if (seconds <= MINUTE_CUTOFF) { + return t('gasTimingMinutesShort', [Math.ceil(seconds / 60)]); + } + return t('gasTimingHoursShort', [Math.ceil(seconds / 3600)]); +}; + +export function clearClipboard() { + window.navigator.clipboard.writeText(''); +} diff --git a/ui/helpers/utils/util.test.js b/ui/helpers/utils/util.test.js index 7038dd79e..1f9084865 100644 --- a/ui/helpers/utils/util.test.js +++ b/ui/helpers/utils/util.test.js @@ -301,4 +301,43 @@ describe('util', () => { }); }); }); + + describe('toHumanReadableTime()', () => { + const t = (key, number) => { + switch (key) { + case 'gasTimingSecondsShort': + return `${number} sec`; + case 'gasTimingMinutesShort': + return `${number} min`; + case 'gasTimingHoursShort': + return `${number} hrs`; + default: + return ''; + } + }; + it('should return empty string if milliseconds passed is undefined', () => { + expect(util.toHumanReadableTime(t)).toStrictEqual(''); + }); + it('should return rounded value for time', () => { + expect(util.toHumanReadableTime(t, 6300)).toStrictEqual('7 sec'); + }); + it('should return value in seconds for milliseconds passed is < 9000', () => { + expect(util.toHumanReadableTime(t, 6000)).toStrictEqual('6 sec'); + }); + it('should return value in seconds for milliseconds passed is > 6000 and <= 9000', () => { + expect(util.toHumanReadableTime(t, 9000)).toStrictEqual('9 sec'); + }); + it('should return value in minutes for milliseconds passed is > 90000', () => { + expect(util.toHumanReadableTime(t, 90001)).toStrictEqual('2 min'); + }); + it('should return value in minutes for milliseconds passed is > 90000 and <= 5400000', () => { + expect(util.toHumanReadableTime(t, 5400000)).toStrictEqual('90 min'); + }); + it('should return value in hours for milliseconds passed is > 5400000', () => { + expect(util.toHumanReadableTime(t, 5400001)).toStrictEqual('2 hrs'); + }); + it('should return value in hours for milliseconds passed very high above 5400000', () => { + expect(util.toHumanReadableTime(t, 7200000)).toStrictEqual('2 hrs'); + }); + }); }); diff --git a/ui/hooks/gasFeeInput/useGasFeeErrors.js b/ui/hooks/gasFeeInput/useGasFeeErrors.js index 0300feec0..213b66b2d 100644 --- a/ui/hooks/gasFeeInput/useGasFeeErrors.js +++ b/ui/hooks/gasFeeInput/useGasFeeErrors.js @@ -262,5 +262,6 @@ export function useGasFeeErrors({ gasWarnings, balanceError, estimatesUnavailableWarning, + hasSimulationError: Boolean(transaction?.simulationFails), }; } diff --git a/ui/hooks/gasFeeInput/useGasFeeErrors.test.js b/ui/hooks/gasFeeInput/useGasFeeErrors.test.js index 44573dd9f..b40ebc918 100644 --- a/ui/hooks/gasFeeInput/useGasFeeErrors.test.js +++ b/ui/hooks/gasFeeInput/useGasFeeErrors.test.js @@ -280,6 +280,21 @@ describe('useGasFeeErrors', () => { }); }); + describe('Simulation Error', () => { + it('is false if transaction has falsy values for simulationFails', () => { + configureEIP1559(); + const { result } = renderUseGasFeeErrorsHook(); + expect(result.current.hasSimulationError).toBe(false); + }); + it('is true if transaction.simulationFails is true', () => { + configureEIP1559(); + const { result } = renderUseGasFeeErrorsHook({ + transaction: { simulationFails: true }, + }); + expect(result.current.hasSimulationError).toBe(true); + }); + }); + describe('estimatesUnavailableWarning', () => { it('is false if supportsEIP1559 and gasEstimateType is fee-market', () => { configureEIP1559(); diff --git a/ui/hooks/gasFeeInput/useGasFeeInputs.js b/ui/hooks/gasFeeInput/useGasFeeInputs.js index 51021cc42..49864193b 100644 --- a/ui/hooks/gasFeeInput/useGasFeeInputs.js +++ b/ui/hooks/gasFeeInput/useGasFeeInputs.js @@ -1,13 +1,13 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { CUSTOM_GAS_ESTIMATE, GAS_RECOMMENDATIONS, EDIT_GAS_MODES, + PRIORITY_LEVELS, } from '../../../shared/constants/gas'; import { GAS_FORM_ERRORS } from '../../helpers/constants/gas'; -import { areDappSuggestedAndTxParamGasFeesTheSame } from '../../helpers/utils/confirm-tx.util'; import { checkNetworkAndAccountSupports1559, getAdvancedInlineGasShown, @@ -21,6 +21,10 @@ import { useGasPriceInput } from './useGasPriceInput'; import { useMaxFeePerGasInput } from './useMaxFeePerGasInput'; import { useMaxPriorityFeePerGasInput } from './useMaxPriorityFeePerGasInput'; import { useGasEstimates } from './useGasEstimates'; +import { useTransactionFunctions } from './useTransactionFunctions'; + +// eslint-disable-next-line prefer-destructuring +const EIP_1559_V2 = process.env.EIP_1559_V2; /** * @typedef {Object} GasFeeInputReturnType @@ -73,6 +77,10 @@ export function useGasFeeInputs( minimumGasLimit = '0x5208', editGasMode = EDIT_GAS_MODES.MODIFY_IN_PLACE, ) { + // eslint-disable-next-line prefer-destructuring + const EIP_1559_V2_ENABLED = + process.env.EIP_1559_V2 === true || process.env.EIP_1559_V2 === 'true'; + const supportsEIP1559 = useSelector(checkNetworkAndAccountSupports1559) && !isLegacyTransaction(transaction?.txParams); @@ -100,14 +108,31 @@ export function useGasFeeInputs( return defaultEstimateToUse; }); - const [ - isUsingDappSuggestedGasFees, - setIsUsingDappSuggestedGasFees, - ] = useState(() => - Boolean(areDappSuggestedAndTxParamGasFeesTheSame(transaction)), - ); + const [estimateUsed, setEstimateUsed] = useState(() => { + if (estimateToUse) { + return estimateToUse; + } + return PRIORITY_LEVELS.CUSTOM; + }); + + /** + * In EIP-1559 V2 designs change to gas estimate is always updated to transaction + * Thus callback setEstimateToUse can be deprecate in favour of this useEffect + * so that transaction is source of truth whenever possible. + */ + useEffect(() => { + if (EIP_1559_V2 && transaction?.userFeeLevel) { + setEstimateUsed(transaction?.userFeeLevel); + setInternalEstimateToUse(transaction?.userFeeLevel); + } + }, [ + setEstimateUsed, + setInternalEstimateToUse, + transaction, + userPrefersAdvancedGas, + ]); - const [gasLimit, setGasLimit] = useState( + const [gasLimit, setGasLimit] = useState(() => Number(hexToDecimal(transaction?.txParams?.gas ?? '0x0')), ); @@ -127,6 +152,7 @@ export function useGasFeeInputs( maxFeePerGasFiat, setMaxFeePerGas, } = useMaxFeePerGasInput({ + EIP_1559_V2, estimateToUse, gasEstimateType, gasFeeEstimates, @@ -140,6 +166,7 @@ export function useGasFeeInputs( maxPriorityFeePerGasFiat, setMaxPriorityFeePerGas, } = useMaxPriorityFeePerGasInput({ + EIP_1559_V2, estimateToUse, gasEstimateType, gasFeeEstimates, @@ -172,6 +199,7 @@ export function useGasFeeInputs( gasErrors, gasWarnings, hasGasErrors, + hasSimulationError, } = useGasFeeErrors({ gasEstimateType, gasFeeEstimates, @@ -197,6 +225,16 @@ export function useGasFeeInputs( } }, [minimumGasLimit, gasErrors.gasLimit, transaction]); + const { + updateTransaction, + updateTransactionUsingGasFeeEstimates, + } = useTransactionFunctions({ + defaultEstimateToUse, + gasFeeEstimates, + gasLimit, + transaction, + }); + // When a user selects an estimate level, it will wipe out what they have // previously put in the inputs. This returns the inputs to the estimated // values at the level specified. @@ -208,7 +246,7 @@ export function useGasFeeInputs( setMaxPriorityFeePerGas(null); setGasPrice(null); setGasPriceHasBeenManuallySet(false); - setIsUsingDappSuggestedGasFees(false); + setEstimateUsed(estimateLevel); }, [ setInternalEstimateToUse, @@ -217,7 +255,7 @@ export function useGasFeeInputs( setMaxPriorityFeePerGas, setGasPrice, setGasPriceHasBeenManuallySet, - setIsUsingDappSuggestedGasFees, + setEstimateUsed, ], ); @@ -230,6 +268,7 @@ export function useGasFeeInputs( setMaxFeePerGas(maxFeePerGas); setMaxPriorityFeePerGas(maxPriorityFeePerGas); setGasPriceHasBeenManuallySet(true); + setEstimateUsed('custom'); }, [ setInternalEstimateToUse, handleGasLimitOutOfBoundError, @@ -263,7 +302,7 @@ export function useGasFeeInputs( estimatedMaximumNative, estimatedMinimumNative, isGasEstimatesLoading, - isUsingDappSuggestedGasFees, + estimateUsed, gasFeeEstimates, gasEstimateType, estimatedGasFeeTimeBounds, @@ -275,6 +314,25 @@ export function useGasFeeInputs( gasErrors, gasWarnings, hasGasErrors, + hasSimulationError, supportsEIP1559, + supportsEIP1559V2: supportsEIP1559 && EIP_1559_V2_ENABLED, + updateTransaction, + updateTransactionUsingGasFeeEstimates, }; } + +/** + * In EIP_1559_V2 implementation as used by useGasfeeInputContext() the use of this hook is evolved. + * It is no longer used to keep transient state of advance gas fee inputs. + * Transient state of inputs is maintained locally in /ui/components/app/advance-gas-fee-popover component. + * + * This hook is used now as source of shared data about transaction, it shares details of gas fee in transaction, + * estimate used, is EIP-1559 supported and other details. It also have methods to update transaction. + * + * Transaction is used as single source of truth and as transaction is updated the fields shared by hook are + * also updated using useEffect hook. + * + * It will be useful to plan a task to create a new hook of this shared information from this hook. + * Methods like setEstimateToUse, onManualChange are deprecated in context of EIP_1559_V2 implementation. + */ diff --git a/ui/hooks/gasFeeInput/useGasFeeInputs.test.js b/ui/hooks/gasFeeInput/useGasFeeInputs.test.js index 5127c61b7..f4bf4f908 100644 --- a/ui/hooks/gasFeeInput/useGasFeeInputs.test.js +++ b/ui/hooks/gasFeeInput/useGasFeeInputs.test.js @@ -37,6 +37,7 @@ jest.mock('react-redux', () => { return { ...actual, useSelector: jest.fn(), + useDispatch: () => jest.fn(), }; }); @@ -315,4 +316,38 @@ describe('useGasFeeInputs', () => { expect(result.current.estimatedMinimumFiat).toBe(''); }); }); + + describe('supportsEIP1559V2', () => { + beforeEach(() => { + configureEIP1559(); + useSelector.mockImplementation( + generateUseSelectorRouter({ + checkNetworkAndAccountSupports1559Response: true, + }), + ); + process.env.EIP_1559_V2 = true; + }); + + afterEach(() => { + process.env.EIP_1559_V2 = false; + }); + + it('return true for fee_market transaction type', () => { + const { result } = renderHook(() => + useGasFeeInputs(null, { + txParams: { type: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET }, + }), + ); + expect(result.current.supportsEIP1559V2).toBe(true); + }); + + it('return false for legacy transaction type', () => { + const { result } = renderHook(() => + useGasFeeInputs(null, { + txParams: { type: TRANSACTION_ENVELOPE_TYPES.LEGACY }, + }), + ); + expect(result.current.supportsEIP1559V2).toBe(false); + }); + }); }); diff --git a/ui/hooks/gasFeeInput/useMaxFeePerGasInput.js b/ui/hooks/gasFeeInput/useMaxFeePerGasInput.js index ab3d10122..452c0611e 100644 --- a/ui/hooks/gasFeeInput/useMaxFeePerGasInput.js +++ b/ui/hooks/gasFeeInput/useMaxFeePerGasInput.js @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; @@ -34,6 +34,7 @@ const getMaxFeePerGasFromTransaction = (transaction) => { * method to update the setMaxFeePerGas. */ export function useMaxFeePerGasInput({ + EIP_1559_V2, estimateToUse, gasEstimateType, gasFeeEstimates, @@ -65,6 +66,12 @@ export function useMaxFeePerGasInput({ return null; }); + useEffect(() => { + if (EIP_1559_V2) { + setMaxFeePerGas(maxFeePerGasFromTransaction); + } + }, [EIP_1559_V2, maxFeePerGasFromTransaction, setMaxFeePerGas]); + let gasSettings = { gasLimit: decimalToHex(gasLimit), }; diff --git a/ui/hooks/gasFeeInput/useMaxPriorityFeePerGasInput.js b/ui/hooks/gasFeeInput/useMaxPriorityFeePerGasInput.js index 1e9a13690..54aeb1093 100644 --- a/ui/hooks/gasFeeInput/useMaxPriorityFeePerGasInput.js +++ b/ui/hooks/gasFeeInput/useMaxPriorityFeePerGasInput.js @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { addHexPrefix } from 'ethereumjs-util'; @@ -34,6 +34,7 @@ const getMaxPriorityFeePerGasFromTransaction = (transaction) => { * method to update the maxPriorityFeePerGas. */ export function useMaxPriorityFeePerGasInput({ + EIP_1559_V2, estimateToUse, gasEstimateType, gasFeeEstimates, @@ -61,6 +62,16 @@ export function useMaxPriorityFeePerGasInput({ return null; }); + useEffect(() => { + if (EIP_1559_V2) { + setMaxPriorityFeePerGas(maxPriorityFeePerGasFromTransaction); + } + }, [ + EIP_1559_V2, + maxPriorityFeePerGasFromTransaction, + setMaxPriorityFeePerGas, + ]); + const maxPriorityFeePerGasToUse = maxPriorityFeePerGas ?? getGasFeeEstimate( diff --git a/ui/hooks/gasFeeInput/useTransactionFunctions.js b/ui/hooks/gasFeeInput/useTransactionFunctions.js new file mode 100644 index 000000000..ab3830266 --- /dev/null +++ b/ui/hooks/gasFeeInput/useTransactionFunctions.js @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { PRIORITY_LEVELS } from '../../../shared/constants/gas'; +import { + decimalToHex, + decGWEIToHexWEI, +} from '../../helpers/utils/conversions.util'; +import { updateTransaction as updateTransactionFn } from '../../store/actions'; + +export const useTransactionFunctions = ({ + defaultEstimateToUse, + gasFeeEstimates, + gasLimit, + transaction, +}) => { + const dispatch = useDispatch(); + + const updateTransaction = useCallback( + (estimateUsed, maxFeePerGas, maxPriorityFeePerGas) => { + const newGasSettings = { + gas: decimalToHex(gasLimit), + gasLimit: decimalToHex(gasLimit), + estimateSuggested: defaultEstimateToUse, + estimateUsed, + }; + if (maxFeePerGas) { + newGasSettings.maxFeePerGas = maxFeePerGas; + } + if (maxPriorityFeePerGas) { + newGasSettings.maxPriorityFeePerGas = maxPriorityFeePerGas; + } + + const updatedTxMeta = { + ...transaction, + userFeeLevel: estimateUsed || 'custom', + txParams: { + ...transaction.txParams, + ...newGasSettings, + }, + }; + + dispatch(updateTransactionFn(updatedTxMeta)); + }, + [defaultEstimateToUse, dispatch, gasLimit, transaction], + ); + + const updateTransactionUsingGasFeeEstimates = useCallback( + (gasFeeEstimateToUse) => { + if (gasFeeEstimateToUse === PRIORITY_LEVELS.DAPP_SUGGESTED) { + const { + maxFeePerGas, + maxPriorityFeePerGas, + } = transaction?.dappSuggestedGasFees; + updateTransaction( + PRIORITY_LEVELS.DAPP_SUGGESTED, + maxFeePerGas, + maxPriorityFeePerGas, + ); + } else { + const { + suggestedMaxFeePerGas, + suggestedMaxPriorityFeePerGas, + } = gasFeeEstimates[gasFeeEstimateToUse]; + updateTransaction( + gasFeeEstimateToUse, + decGWEIToHexWEI(suggestedMaxFeePerGas), + decGWEIToHexWEI(suggestedMaxPriorityFeePerGas), + ); + } + }, + [gasFeeEstimates, transaction?.dappSuggestedGasFees, updateTransaction], + ); + + return { updateTransaction, updateTransactionUsingGasFeeEstimates }; +}; diff --git a/ui/hooks/useGasFeeEstimates.js b/ui/hooks/useGasFeeEstimates.js index 7bbdd4fc0..92067e050 100644 --- a/ui/hooks/useGasFeeEstimates.js +++ b/ui/hooks/useGasFeeEstimates.js @@ -1,4 +1,5 @@ -import { useSelector } from 'react-redux'; +import isEqual from 'lodash/isEqual'; +import { shallowEqual, useSelector } from 'react-redux'; import { getEstimatedGasFeeTimeBounds, getGasEstimateType, @@ -31,8 +32,11 @@ import { useSafeGasEstimatePolling } from './useSafeGasEstimatePolling'; */ export function useGasFeeEstimates() { const gasEstimateType = useSelector(getGasEstimateType); - const gasFeeEstimates = useSelector(getGasFeeEstimates); - const estimatedGasFeeTimeBounds = useSelector(getEstimatedGasFeeTimeBounds); + const gasFeeEstimates = useSelector(getGasFeeEstimates, isEqual); + const estimatedGasFeeTimeBounds = useSelector( + getEstimatedGasFeeTimeBounds, + shallowEqual, + ); const isGasEstimatesLoading = useSelector(getIsGasEstimatesLoading); useSafeGasEstimatePolling(); diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index dc52a979a..8fe0cfc7c 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { getTokenExchangeRates, getCurrentCurrency, @@ -29,7 +29,10 @@ export function useTokenFiatAmount( overrides = {}, hideCurrencySymbol, ) { - const contractExchangeRates = useSelector(getTokenExchangeRates); + const contractExchangeRates = useSelector( + getTokenExchangeRates, + shallowEqual, + ); const conversionRate = useSelector(getConversionRate); const currentCurrency = useSelector(getCurrentCurrency); const userPrefersShownFiat = useSelector(getShouldShowFiat); diff --git a/ui/hooks/useTokenTracker.js b/ui/hooks/useTokenTracker.js index 5ad2fb5af..01b56f615 100644 --- a/ui/hooks/useTokenTracker.js +++ b/ui/hooks/useTokenTracker.js @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import TokenTracker from '@metamask/eth-token-tracker'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { getCurrentChainId, getSelectedAddress } from '../selectors'; import { SECOND } from '../../shared/constants/time'; import { isEqualCaseInsensitive } from '../helpers/utils/util'; @@ -12,7 +12,7 @@ export function useTokenTracker( hideZeroBalanceTokens = false, ) { const chainId = useSelector(getCurrentChainId); - const userAddress = useSelector(getSelectedAddress); + const userAddress = useSelector(getSelectedAddress, shallowEqual); const [loading, setLoading] = useState(() => tokens?.length >= 0); const [tokensWithBalances, setTokensWithBalances] = useState([]); const [error, setError] = useState(null); diff --git a/ui/hooks/useTokensToSearch.js b/ui/hooks/useTokensToSearch.js index 9efba5114..7140737a4 100644 --- a/ui/hooks/useTokensToSearch.js +++ b/ui/hooks/useTokensToSearch.js @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import contractMap from '@metamask/contract-metadata'; import BigNumber from 'bignumber.js'; import { isEqual, shuffle, uniqBy } from 'lodash'; @@ -94,8 +94,8 @@ export function useTokensToSearch({ const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const conversionRate = useSelector(getConversionRate); const currentCurrency = useSelector(getCurrentCurrency); - const defaultSwapsToken = useSelector(getSwapsDefaultToken); - const tokenList = useSelector(getTokenList); + const defaultSwapsToken = useSelector(getSwapsDefaultToken, shallowEqual); + const tokenList = useSelector(getTokenList, isEqual); const useTokenDetection = useSelector(getUseTokenDetection); // token from dynamic api list is fetched when useTokenDetection is true const shuffledTokenList = useTokenDetection @@ -115,7 +115,7 @@ export function useTokensToSearch({ ); const memoizedDefaultToken = useEqualityCheck(defaultToken); - const swapsTokens = useSelector(getSwapsTokens) || []; + const swapsTokens = useSelector(getSwapsTokens, isEqual) || []; const tokensToSearch = swapsTokens.length ? swapsTokens @@ -154,15 +154,15 @@ export function useTokensToSearch({ tokenList, useTokenDetection, ); - if ( + if (memoizedTopTokens[token.address.toLowerCase()]) { + tokensToSearchBuckets.top[ + memoizedTopTokens[token.address.toLowerCase()].index + ] = renderableDataToken; + } else if ( isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) || usersTokensAddressMap[token.address.toLowerCase()] ) { tokensToSearchBuckets.owned.push(renderableDataToken); - } else if (memoizedTopTokens[token.address.toLowerCase()]) { - tokensToSearchBuckets.top[ - memoizedTopTokens[token.address.toLowerCase()].index - ] = renderableDataToken; } else { tokensToSearchBuckets.others.push(renderableDataToken); } diff --git a/ui/pages/add-collectible/add-collectible.component.js b/ui/pages/add-collectible/add-collectible.component.js index 4bf0ecdb0..a0fc8639f 100644 --- a/ui/pages/add-collectible/add-collectible.component.js +++ b/ui/pages/add-collectible/add-collectible.component.js @@ -1,27 +1,43 @@ import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; import { useI18nContext } from '../../hooks/useI18nContext'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; import Box from '../../components/ui/box'; import TextField from '../../components/ui/text-field'; import PageContainer from '../../components/ui/page-container'; +import { + addCollectibleVerifyOwnership, + setNewCollectibleAddedMessage, +} from '../../store/actions'; export default function AddCollectible() { const t = useI18nContext(); const history = useHistory(); + const dispatch = useDispatch(); const [address, setAddress] = useState(''); const [tokenId, setTokenId] = useState(''); + const handleAddCollectible = async () => { + try { + await dispatch(addCollectibleVerifyOwnership(address, tokenId)); + } catch (error) { + const { message } = error; + dispatch(setNewCollectibleAddedMessage(message)); + history.push(DEFAULT_ROUTE); + return; + } + dispatch(setNewCollectibleAddedMessage('success')); + history.push(DEFAULT_ROUTE); + }; + return ( { - console.log( - `Adding collectible with ID: ${tokenId} and address ${address}`, - ); - history.push(DEFAULT_ROUTE); + handleAddCollectible(); }} submitText={t('add')} onCancel={() => { 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 fc2d7e29a..342d79f39 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ConfirmPageContainer from '../../components/app/confirm-page-container'; +import TransactionDecoding from '../../components/app/transaction-decoding'; import { isBalanceSufficient } from '../send/send.utils'; import { addHexes, @@ -13,21 +14,29 @@ import { } from '../../helpers/constants/routes'; import { INSUFFICIENT_FUNDS_ERROR_KEY, - TRANSACTION_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, ETH_GAS_PRICE_FETCH_WARNING_KEY, GAS_PRICE_FETCH_FAILURE_ERROR_KEY, } from '../../helpers/constants/error-keys'; import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display'; +import CopyRawData from '../../components/app/transaction-decoding/components/ui/copy-raw-data'; + import { PRIMARY, SECONDARY } from '../../helpers/constants/common'; import TextField from '../../components/ui/text-field'; +import ActionableMessage from '../../components/ui/actionable-message'; +import Disclosure from '../../components/ui/disclosure'; import { TRANSACTION_TYPES, TRANSACTION_STATUSES, } from '../../../shared/constants/transaction'; -import { getTransactionTypeTitle } from '../../helpers/utils/transactions.util'; +import { getMethodName } from '../../helpers/utils/metrics'; +import { + getTransactionTypeTitle, + isLegacyTransaction, +} from '../../helpers/utils/transactions.util'; import { toBuffer } from '../../../shared/modules/buffer-utils'; +import { TransactionModalContextProvider } from '../../contexts/transaction-modal'; import TransactionDetail from '../../components/app/transaction-detail/transaction-detail.component'; import TransactionDetailItem from '../../components/app/transaction-detail-item/transaction-detail-item.component'; import InfoTooltip from '../../components/ui/info-tooltip/info-tooltip'; @@ -52,10 +61,11 @@ import Typography from '../../components/ui/typography/typography'; import { MIN_GAS_LIMIT_DEC } from '../send/send.constants'; import GasDetailsItem from './gas-details-item'; -import LowPriorityMessage from './low-priority-message'; +import TransactionAlerts from './transaction-alerts'; // eslint-disable-next-line prefer-destructuring -const EIP_1559_V2 = process.env.EIP_1559_V2; +const EIP_1559_V2_ENABLED = + process.env.EIP_1559_V2 === true || process.env.EIP_1559_V2 === 'true'; const renderHeartBeatIfNotInTest = () => process.env.IN_TEST === 'true' ? null : ; @@ -104,6 +114,7 @@ export default class ConfirmTransactionBase extends Component { actionKey: PropTypes.string, contentComponent: PropTypes.node, dataComponent: PropTypes.node, + dataHexComponent: PropTypes.node, hideData: PropTypes.bool, hideSubtitle: PropTypes.bool, identiconAddress: PropTypes.string, @@ -143,6 +154,7 @@ export default class ConfirmTransactionBase extends Component { submitWarning: '', ethGasPriceWarning: '', editingGas: false, + userAcknowledgedGasMissing: false, }; componentDidUpdate(prevProps) { @@ -216,7 +228,7 @@ export default class ConfirmTransactionBase extends Component { balance, conversionRate, hexMaximumTransactionFee, - txData: { simulationFails, txParams: { value: amount } = {} } = {}, + txData: { txParams: { value: amount } = {} } = {}, customGas, noGasPrice, gasFeeIsCustom, @@ -245,15 +257,6 @@ export default class ConfirmTransactionBase extends Component { }; } - if (simulationFails) { - return { - valid: true, - errorKey: simulationFails.errorKey - ? simulationFails.errorKey - : TRANSACTION_ERROR_KEY, - }; - } - if (noGasPrice && !gasFeeIsCustom) { return { valid: false, @@ -296,6 +299,10 @@ export default class ConfirmTransactionBase extends Component { this.setState({ editingGas: false }); } + setUserAcknowledgedGasMissing() { + this.setState({ userAcknowledgedGasMissing: true }); + } + renderDetails() { const { primaryTotalTextOverride, @@ -320,6 +327,16 @@ export default class ConfirmTransactionBase extends Component { nativeCurrency, } = this.props; const { t } = this.context; + const { userAcknowledgedGasMissing } = this.state; + + const { valid } = this.getErrorKey(); + const isDisabled = () => { + return userAcknowledgedGasMissing ? false : !valid; + }; + + const hasSimulationError = Boolean(txData.simulationFails); + const renderSimulationFailureWarning = + hasSimulationError && !userAcknowledgedGasMissing; const renderTotalMaxAmount = () => { if ( @@ -411,147 +428,179 @@ export default class ConfirmTransactionBase extends Component {
    ) : null; - return ( -
    - {EIP_1559_V2 && } - this.handleEditGas()} - rows={[ - EIP_1559_V2 ? ( - + const renderGasDetailsItem = () => { + return EIP_1559_V2_ENABLED && + supportsEIP1559 && + !isLegacyTransaction(txData) ? ( + + ) : ( + + {isMultiLayerFeeNetwork + ? t('transactionDetailLayer2GasHeading') + : t('transactionDetailGasHeading')} + + + + ) : ( - - {isMultiLayerFeeNetwork - ? t('transactionDetailLayer2GasHeading') - : t('transactionDetailGasHeading')} - - - - - ) : ( + <> + {isMultiLayerFeeNetwork + ? t('transactionDetailLayer2GasHeading') + : t('transactionDetailGasHeading')} + - {isMultiLayerFeeNetwork - ? t('transactionDetailLayer2GasHeading') - : t('transactionDetailGasHeading')} - -

    - {t('transactionDetailGasTooltipIntro', [ - isMainnet ? t('networkNameEthereum') : '', - ])} -

    -

    {t('transactionDetailGasTooltipExplanation')}

    -

    - - {t('transactionDetailGasTooltipConversion')} - -

    - - } - position="top" - > - -
    +

    + {t('transactionDetailGasTooltipIntro', [ + isMainnet ? t('networkNameEthereum') : '', + ])} +

    +

    {t('transactionDetailGasTooltipExplanation')}

    +

    + + {t('transactionDetailGasTooltipConversion')} + +

    - ) - } - detailTitleColor={COLORS.BLACK} - detailText={ - !isMultiLayerFeeNetwork && ( -
    - {renderHeartBeatIfNotInTest()} - -
    - ) - } - detailTotal={ -
    - {renderHeartBeatIfNotInTest()} - -
    - } - subText={ - !isMultiLayerFeeNetwork && - t('editGasSubTextFee', [ - - {t('editGasSubTextFeeLabel')} - , -
    - {renderHeartBeatIfNotInTest()} - -
    , - ]) - } - subTitle={ - <> - {txData.dappSuggestedGasFees ? ( - - {t('transactionDetailDappGasMoreInfo')} - - ) : ( - '' - )} - {supportsEIP1559 && ( - - )} - - } + } + position="top" + > + +
    + + ) + } + detailTitleColor={COLORS.BLACK} + detailText={ + !isMultiLayerFeeNetwork && ( +
    + {renderHeartBeatIfNotInTest()} + +
    + ) + } + detailTotal={ +
    + {renderHeartBeatIfNotInTest()} + - ), - isMultiLayerFeeNetwork && ( +
    + } + subText={ + !isMultiLayerFeeNetwork && ( + <> + + {t('editGasSubTextFeeLabel')} + +
    + {renderHeartBeatIfNotInTest()} + +
    + + ) + } + subTitle={ + <> + {txData.dappSuggestedGasFees ? ( + + {t('transactionDetailDappGasMoreInfo')} + + ) : ( + '' + )} + {supportsEIP1559 && ( + + )} + + } + /> + ); + }; + + const simulationFailureWarning = () => ( +
    + this.setUserAcknowledgedGasMissing(), + }} + message={this.context.t('simulationErrorMessage')} + roundedButtons + /> +
    + ); + + return ( +
    + + this.setUserAcknowledgedGasMissing() + } + userAcknowledgedGasMissing={userAcknowledgedGasMissing} + /> + this.handleEditGas() + } + rows={[ + renderSimulationFailureWarning && simulationFailureWarning(), + !renderSimulationFailureWarning && renderGasDetailsItem(), + !renderSimulationFailureWarning && isMultiLayerFeeNetwork && ( - {t('editGasSubTextAmountLabel')} - , - renderTotalMaxAmount(), - ])} + subText={ + <> + + {t('editGasSubTextAmountLabel')} + + {renderTotalMaxAmount()} + + } /> ), ]} @@ -588,7 +639,7 @@ export default class ConfirmTransactionBase extends Component { renderData(functionType) { const { t } = this.context; const { - txData: { txParams: { data } = {} } = {}, + txData: { txParams } = {}, methodData: { params } = {}, hideData, dataComponent, @@ -598,13 +649,51 @@ export default class ConfirmTransactionBase extends Component { return null; } + const functionParams = params?.length + ? `(${params.map(({ type }) => type).join(', ')})` + : ''; + return ( dataComponent || (
    {`${t('functionType')}:`} - {functionType} + {`${functionType} ${functionParams}`} + +
    + + + +
    + ) + ); + } + + renderDataHex(functionType) { + const { t } = this.context; + const { + txData: { txParams } = {}, + methodData: { params } = {}, + hideData, + dataHexComponent, + } = this.props; + + if (hideData || !txParams.to) { + return null; + } + + const functionParams = params?.length + ? `(${params.map(({ type }) => type).join(', ')})` + : ''; + + return ( + dataHexComponent || ( +
    +
    + {`${t('functionType')}:`} + + {`${functionType} ${functionParams}`}
    {params && ( @@ -618,9 +707,12 @@ export default class ConfirmTransactionBase extends Component {
    )}
    - {`${t('hexData')}: ${toBuffer(data).length} bytes`} + {`${t('hexData')}: ${toBuffer(txParams?.data).length} bytes`} +
    +
    + {txParams?.data}
    -
    {data}
    +
    ) ); @@ -888,6 +980,7 @@ export default class ConfirmTransactionBase extends Component { render() { const { t } = this.context; const { + actionKey, fromName, fromAddress, toName, @@ -911,6 +1004,7 @@ export default class ConfirmTransactionBase extends Component { gasFeeIsCustom, nativeCurrency, hardwareWalletRequiresConnection, + supportsEIP1559, } = this.props; const { submitting, @@ -918,10 +1012,14 @@ export default class ConfirmTransactionBase extends Component { submitWarning, ethGasPriceWarning, editingGas, + userAcknowledgedGasMissing, } = this.state; const { name } = methodData; const { valid, errorKey } = this.getErrorKey(); + const hasSimulationError = Boolean(txData.simulationFails); + const renderSimulationFailureWarning = + hasSimulationError && !userAcknowledgedGasMissing; const { totalTx, positionOfCurrentTx, @@ -934,6 +1032,10 @@ export default class ConfirmTransactionBase extends Component { requestsWaitingText, } = this.getNavigateTxData(); + const isDisabled = () => { + return userAcknowledgedGasMissing ? false : !valid; + }; + let functionType = getMethodName(name); if (!functionType) { if (type) { @@ -943,67 +1045,71 @@ export default class ConfirmTransactionBase extends Component { } } return ( - this.handleNextTx(txId)} - firstTx={firstTx} - lastTx={lastTx} - ofText={ofText} - requestsWaitingText={requestsWaitingText} - disabled={ - !valid || - submitting || - hardwareWalletRequiresConnection || - (gasIsLoading && !gasFeeIsCustom) - } - onEdit={() => this.handleEdit()} - onCancelAll={() => this.handleCancelAll()} - onCancel={() => this.handleCancel()} - onSubmit={() => this.handleSubmit()} - hideSenderToRecipient={hideSenderToRecipient} - origin={txData.origin} - ethGasPriceWarning={ethGasPriceWarning} - editingGas={editingGas} - handleCloseEditGas={() => this.handleCloseEditGas()} - currentTransaction={txData} - /> + + this.handleNextTx(txId)} + firstTx={firstTx} + lastTx={lastTx} + ofText={ofText} + requestsWaitingText={requestsWaitingText} + hideUserAcknowledgedGasMissing={!isDisabled()} + disabled={ + renderSimulationFailureWarning || + !valid || + submitting || + hardwareWalletRequiresConnection || + (gasIsLoading && !gasFeeIsCustom) + } + onEdit={() => this.handleEdit()} + onCancelAll={() => this.handleCancelAll()} + onCancel={() => this.handleCancel()} + onSubmit={() => this.handleSubmit()} + setUserAcknowledgedGasMissing={this.setUserAcknowledgedGasMissing} + hideSenderToRecipient={hideSenderToRecipient} + origin={txData.origin} + ethGasPriceWarning={ethGasPriceWarning} + editingGas={editingGas} + handleCloseEditGas={() => this.handleCloseEditGas()} + currentTransaction={txData} + supportsEIP1559V2={ + EIP_1559_V2_ENABLED && + supportsEIP1559 && + !isLegacyTransaction(txData) + } + /> + ); } } - -export function getMethodName(camelCase) { - if (!camelCase || typeof camelCase !== 'string') { - return ''; - } - - return camelCase - .replace(/([a-z])([A-Z])/gu, '$1 $2') - .replace(/([A-Z])([a-z])/gu, ' $1$2') - .replace(/ +/gu, ' '); -} diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.test.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.test.js deleted file mode 100644 index b4d9caa37..000000000 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { getMethodName } from './confirm-transaction-base.component'; - -describe('ConfirmTransactionBase Component', () => { - describe('getMethodName', () => { - it('should get correct method names', () => { - expect(getMethodName(undefined)).toStrictEqual(''); - expect(getMethodName({})).toStrictEqual(''); - expect(getMethodName('confirm')).toStrictEqual('confirm'); - expect(getMethodName('balanceOf')).toStrictEqual('balance Of'); - expect(getMethodName('ethToTokenSwapInput')).toStrictEqual( - 'eth To Token Swap Input', - ); - }); - }); -}); 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 1185c81d1..7361ad8aa 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -120,7 +120,10 @@ const mapStateToProps = (state, ownProps) => { shortenAddress(toChecksumHexAddress(toAddress)); const checksummedAddress = toChecksumHexAddress(toAddress); - const addressBookObject = addressBook[checksummedAddress]; + const addressBookObject = + addressBook && + addressBook[chainId] && + addressBook[chainId][checksummedAddress]; const toEns = ensResolutionsByAddress[checksummedAddress] || ''; const toNickname = addressBookObject ? addressBookObject.name : ''; const transactionStatus = transaction ? transaction.status : ''; @@ -231,6 +234,7 @@ const mapStateToProps = (state, ownProps) => { nativeCurrency, hardwareWalletRequiresConnection, isMultiLayerFeeNetwork, + chainId, }; }; diff --git a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.js b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.js index 6ac1e0be0..72f9a780e 100644 --- a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.js +++ b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.js @@ -26,12 +26,13 @@ const GasDetailsItem = ({ isMainnet, maxFeePerGas, maxPriorityFeePerGas, - supportsEIP1559, - txData, + userAcknowledgedGasMissing, useNativeCurrencyAsPrimaryCurrency, }) => { const t = useI18nContext(); - const { estimateToUse } = useGasFeeContext(); + const { estimateUsed, hasSimulationError, transaction } = useGasFeeContext(); + + if (hasSimulationError && !userAcknowledgedGasMissing) return null; return (
    } - subText={t('editGasSubTextFee', [ - - - - {estimateToUse === 'high' && '⚠ '} - - - -
    + - - -
    -
    , - ])} + + + {estimateUsed === 'high' && '⚠ '} + + + +
    + + +
    + + + } subTitle={ - supportsEIP1559 && ( - - ) + } /> ); @@ -141,8 +142,7 @@ GasDetailsItem.propTypes = { isMainnet: PropTypes.bool, maxFeePerGas: PropTypes.string, maxPriorityFeePerGas: PropTypes.string, - supportsEIP1559: PropTypes.bool, - txData: PropTypes.object, + userAcknowledgedGasMissing: PropTypes.bool.isRequired, useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, }; diff --git a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.scss b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.scss index e65e3f362..2f1dff574 100644 --- a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.scss +++ b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.scss @@ -11,10 +11,6 @@ color: $secondary-1; } - &__gasfee-label { - font-weight: bold; - } - &__currency-container { position: relative; } diff --git a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.test.js b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.test.js index 2d8c97fab..d149a6398 100644 --- a/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.test.js +++ b/ui/pages/confirm-transaction-base/gas-details-item/gas-details-item.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { ETH } from '../../../helpers/constants/common'; import { GasFeeContextProvider } from '../../../contexts/gasFee'; @@ -14,9 +14,10 @@ jest.mock('../../../store/actions', () => ({ .fn() .mockImplementation(() => Promise.resolve()), addPollingTokenToAppState: jest.fn(), + getGasFeeTimeEstimate: jest.fn().mockImplementation(() => Promise.resolve()), })); -const render = (props) => { +const render = ({ componentProps, contextProps } = {}) => { const store = configureStore({ metamask: { nativeCurrency: ETH, @@ -36,29 +37,73 @@ const render = (props) => { }); return renderWithProvider( - - + + , store, ); }; describe('GasDetailsItem', () => { - it('should render label', () => { + it('should render label', async () => { render(); - expect(screen.queryByText('Gas')).toBeInTheDocument(); - expect(screen.queryByText('(estimated)')).toBeInTheDocument(); - expect(screen.queryByText('Max fee:')).toBeInTheDocument(); - expect(screen.queryByText('ETH')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('Gas')).toBeInTheDocument(); + expect(screen.queryByText('(estimated)')).toBeInTheDocument(); + expect(screen.queryByText('Max fee:')).toBeInTheDocument(); + expect(screen.queryByText('ETH')).toBeInTheDocument(); + }); }); - it('should show warning icon if estimates are high', () => { - render({ defaultEstimateToUse: 'high' }); - expect(screen.queryByText('⚠ Max fee:')).toBeInTheDocument(); + it('should show warning icon if estimates are high', async () => { + render({ + contextProps: { transaction: { txParams: {}, userFeeLevel: 'high' } }, + }); + await waitFor(() => { + expect(screen.queryByText('⚠ Max fee:')).toBeInTheDocument(); + }); }); - it('should not show warning icon if estimates are not high', () => { - render({ defaultEstimateToUse: 'low' }); - expect(screen.queryByText('Max fee:')).toBeInTheDocument(); + it('should not show warning icon if estimates are not high', async () => { + render({ + contextProps: { transaction: { txParams: {}, userFeeLevel: 'low' } }, + }); + await waitFor(() => { + expect(screen.queryByText('Max fee:')).toBeInTheDocument(); + }); + }); + + it('should return null if there is simulationError and user has not acknowledged gasMissing warning', () => { + const { container } = render({ + contextProps: { + transaction: { + txParams: {}, + simulationFails: true, + userFeeLevel: 'low', + }, + }, + }); + expect(container.innerHTML).toHaveLength(0); + }); + + it('should not return null even if there is simulationError if user acknowledged gasMissing warning', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('Gas')).toBeInTheDocument(); + }); + }); + + it('should should render gas fee details', async () => { + render({ + componentProps: { + hexMinimumTransactionFee: '0x1ca62a4f7800', + hexMaximumTransactionFee: '0x290ee75e3d900', + }, + }); + await waitFor(() => { + expect(screen.queryByTitle('0.0000315 ETH')).toBeInTheDocument(); + expect(screen.queryByText('ETH')).toBeInTheDocument(); + expect(screen.queryByTitle('0.0007223')).toBeInTheDocument(); + }); }); }); diff --git a/ui/pages/confirm-transaction-base/low-priority-message/index.js b/ui/pages/confirm-transaction-base/low-priority-message/index.js deleted file mode 100644 index 7b2838d48..000000000 --- a/ui/pages/confirm-transaction-base/low-priority-message/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './low-priority-message'; diff --git a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.js b/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.js deleted file mode 100644 index 41bb5d132..000000000 --- a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; -import { useGasFeeContext } from '../../../contexts/gasFee'; -import { useI18nContext } from '../../../hooks/useI18nContext'; - -const LowPriorityMessage = () => { - const { estimateToUse } = useGasFeeContext(); - const t = useI18nContext(); - - if (estimateToUse !== 'low') return null; - return ( -
    - -
    - ); -}; - -export default LowPriorityMessage; diff --git a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.scss b/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.scss deleted file mode 100644 index 1a99a03af..000000000 --- a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.scss +++ /dev/null @@ -1,3 +0,0 @@ -.low-priority-message { - margin-top: 20px; -} diff --git a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.test.js b/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.test.js deleted file mode 100644 index 9d8e233f3..000000000 --- a/ui/pages/confirm-transaction-base/low-priority-message/low-priority-message.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; - -import { renderWithProvider } from '../../../../test/lib/render-helpers'; -import { ETH } from '../../../helpers/constants/common'; -import { GasFeeContextProvider } from '../../../contexts/gasFee'; -import configureStore from '../../../store/store'; - -import LowPriorityMessage from './low-priority-message'; - -jest.mock('../../../store/actions', () => ({ - disconnectGasFeeEstimatePoller: jest.fn(), - getGasFeeEstimatesAndStartPolling: jest - .fn() - .mockImplementation(() => Promise.resolve()), - addPollingTokenToAppState: jest.fn(), -})); - -const render = (props) => { - const store = configureStore({ - metamask: { - nativeCurrency: ETH, - preferences: { - useNativeCurrencyAsPrimaryCurrency: true, - }, - provider: {}, - cachedBalances: {}, - accounts: { - '0xAddress': { - address: '0xAddress', - balance: '0x176e5b6f173ebe66', - }, - }, - selectedAddress: '0xAddress', - }, - }); - - return renderWithProvider( - - - , - store, - ); -}; - -describe('LowPriorityMessage', () => { - it('should returning warning message for low gas estimate', () => { - render({ transaction: { userFeeLevel: 'low' } }); - expect( - document.getElementsByClassName('actionable-message--warning'), - ).toHaveLength(1); - }); - - it('should return null for gas estimate other than low', () => { - render({ transaction: { userFeeLevel: 'high' } }); - expect( - document.getElementsByClassName('actionable-message--warning'), - ).toHaveLength(0); - }); -}); diff --git a/ui/pages/confirm-transaction-base/transaction-alerts/index.js b/ui/pages/confirm-transaction-base/transaction-alerts/index.js new file mode 100644 index 000000000..48b2d29af --- /dev/null +++ b/ui/pages/confirm-transaction-base/transaction-alerts/index.js @@ -0,0 +1 @@ +export { default } from './transaction-alerts'; diff --git a/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.js b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.js new file mode 100644 index 000000000..fe7d6a6ae --- /dev/null +++ b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.js @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +import { PRIORITY_LEVELS } from '../../../../shared/constants/gas'; +import { INSUFFICIENT_FUNDS_ERROR_KEY } from '../../../helpers/constants/error-keys'; +import { submittedPendingTransactionsSelector } from '../../../selectors/transactions'; +import { useGasFeeContext } from '../../../contexts/gasFee'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; +import ErrorMessage from '../../../components/ui/error-message'; +import I18nValue from '../../../components/ui/i18n-value'; +import Typography from '../../../components/ui/typography'; + +const TransactionAlerts = ({ + userAcknowledgedGasMissing, + setUserAcknowledgedGasMissing, +}) => { + const { + balanceError, + estimateUsed, + hasSimulationError, + supportsEIP1559V2, + } = useGasFeeContext(); + const pendingTransactions = useSelector(submittedPendingTransactionsSelector); + const t = useI18nContext(); + + if (!supportsEIP1559V2) return null; + + return ( +
    + {hasSimulationError && ( + } + useIcon + iconFillColor="#d73a49" + type="danger" + primaryActionV2={ + userAcknowledgedGasMissing === true + ? undefined + : { + label: t('proceedWithTransaction'), + onClick: setUserAcknowledgedGasMissing, + } + } + /> + )} + {pendingTransactions?.length > 0 && ( + + + + {' '} + {' '} + + + , + ]} + /> + + } + useIcon + iconFillColor="#f8c000" + type="warning" + /> + )} + {balanceError && } + {estimateUsed === PRIORITY_LEVELS.LOW && ( + + + + } + useIcon + iconFillColor="#f8c000" + type="warning" + /> + )} +
    + ); +}; + +TransactionAlerts.propTypes = { + userAcknowledgedGasMissing: PropTypes.bool, + setUserAcknowledgedGasMissing: PropTypes.func, +}; + +export default TransactionAlerts; diff --git a/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.scss b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.scss new file mode 100644 index 000000000..df6974e99 --- /dev/null +++ b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.scss @@ -0,0 +1,21 @@ +.transaction-alerts { + text-align: left; + + & > *:first-of-type { + margin-top: 20px; + } + + & > *:not(:first-of-type) { + margin-top: 12px; + } + + & strong { + font-weight: bold; + } + + &__pending-transactions { + & a { + color: $primary-1; + } + } +} diff --git a/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.test.js b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.test.js new file mode 100644 index 000000000..555a091a4 --- /dev/null +++ b/ui/pages/confirm-transaction-base/transaction-alerts/transaction-alerts.test.js @@ -0,0 +1,172 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; + +import { GAS_ESTIMATE_TYPES } from '../../../../shared/constants/gas'; +import { + TRANSACTION_ENVELOPE_TYPES, + TRANSACTION_STATUSES, +} from '../../../../shared/constants/transaction'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import mockEstimates from '../../../../test/data/mock-estimates.json'; +import mockState from '../../../../test/data/mock-state.json'; +import { GasFeeContextProvider } from '../../../contexts/gasFee'; +import configureStore from '../../../store/store'; + +import TransactionAlerts from './transaction-alerts'; + +jest.mock('../../../store/actions', () => ({ + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest + .fn() + .mockImplementation(() => Promise.resolve()), + addPollingTokenToAppState: jest.fn(), +})); + +const render = ({ componentProps, transactionProps, state }) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + accounts: { + [mockState.metamask.selectedAddress]: { + address: mockState.metamask.selectedAddress, + balance: '0x1F4', + }, + }, + gasFeeEstimates: mockEstimates[GAS_ESTIMATE_TYPES.FEE_MARKET], + ...state, + }, + }); + + return renderWithProvider( + + + , + store, + ); +}; + +describe('TransactionAlerts', () => { + beforeEach(() => { + process.env.EIP_1559_V2 = true; + }); + + afterEach(() => { + process.env.EIP_1559_V2 = false; + }); + + it('should returning warning message for low gas estimate', () => { + render({ transactionProps: { userFeeLevel: 'low' } }); + expect( + document.getElementsByClassName('actionable-message--warning'), + ).toHaveLength(1); + }); + + it('should return null for gas estimate other than low', () => { + render({ transactionProps: { userFeeLevel: 'high' } }); + expect( + document.getElementsByClassName('actionable-message--warning'), + ).toHaveLength(0); + }); + + it('should not show insufficient balance message if transaction value is less than balance', () => { + render({ + transactionProps: { + userFeeLevel: 'high', + txParams: { value: '0x64' }, + }, + }); + expect(screen.queryByText('Insufficient funds.')).not.toBeInTheDocument(); + }); + + it('should show insufficient balance message if transaction value is more than balance', () => { + render({ + transactionProps: { + userFeeLevel: 'high', + txParams: { value: '0x5208' }, + }, + }); + expect(screen.queryByText('Insufficient funds.')).toBeInTheDocument(); + }); + + it('should show pending transaction message if there are >= 1 pending transactions', () => { + render({ + state: { + currentNetworkTxList: [ + { + id: 0, + time: 0, + txParams: { + from: mockState.metamask.selectedAddress, + to: '0xRecipient', + }, + status: TRANSACTION_STATUSES.SUBMITTED, + }, + ], + }, + }); + expect( + screen.queryByText('You have (1) pending transaction.'), + ).toBeInTheDocument(); + }); + + describe('SimulationError Message', () => { + it('should show simulation error message along with option to proceed anyway if transaction.simulationFails is true', () => { + render({ transactionProps: { simulationFails: true } }); + expect( + screen.queryByText( + 'We were not able to estimate gas. There might be an error in the contract and this transaction may fail.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText('I want to proceed anyway'), + ).toBeInTheDocument(); + }); + + it('should not show options to acknowledge gas-missing warning if component prop userAcknowledgedGasMissing is already true', () => { + render({ + componentProps: { + userAcknowledgedGasMissing: true, + }, + transactionProps: { simulationFails: true }, + }); + expect( + screen.queryByText( + 'We were not able to estimate gas. There might be an error in the contract and this transaction may fail.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText('I want to proceed anyway'), + ).not.toBeInTheDocument(); + }); + + it('should call prop setUserAcknowledgedGasMissing if option to acknowledge gas-missing warning is clicked', () => { + const setUserAcknowledgedGasMissing = jest.fn(); + render({ + componentProps: { + setUserAcknowledgedGasMissing, + }, + transactionProps: { simulationFails: true }, + }); + fireEvent.click(screen.queryByText('I want to proceed anyway')); + expect(setUserAcknowledgedGasMissing).toHaveBeenCalledTimes(1); + }); + + it('should return null for legacy transactions', () => { + const { container } = render({ + transactionProps: { + txParams: { + type: TRANSACTION_ENVELOPE_TYPES.LEGACY, + }, + }, + }); + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/ui/pages/create-account/connect-hardware/account-list.js b/ui/pages/create-account/connect-hardware/account-list.js index caf7ff580..3d4c4522a 100644 --- a/ui/pages/create-account/connect-hardware/account-list.js +++ b/ui/pages/create-account/connect-hardware/account-list.js @@ -61,8 +61,9 @@ class AccountList extends Component { renderHeader() { const { device } = this.props; - const shouldShowHDPaths = - device.toLowerCase() === 'ledger' || device.toLowerCase() === 'lattice'; + const shouldShowHDPaths = ['ledger', 'lattice', 'trezor'].includes( + device.toLowerCase(), + ); return (

    diff --git a/ui/pages/create-account/connect-hardware/index.js b/ui/pages/create-account/connect-hardware/index.js index 6b6c3bab5..0e784c7c5 100644 --- a/ui/pages/create-account/connect-hardware/index.js +++ b/ui/pages/create-account/connect-hardware/index.js @@ -11,7 +11,10 @@ import { import { formatBalance } from '../../../helpers/utils/util'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { SECOND } from '../../../../shared/constants/time'; -import { LEDGER_TRANSPORT_TYPES } from '../../../../shared/constants/hardware-wallets'; +import { + DEVICE_NAMES, + LEDGER_TRANSPORT_TYPES, +} from '../../../../shared/constants/hardware-wallets'; import SelectHardware from './select-hardware'; import AccountList from './account-list'; @@ -41,9 +44,16 @@ const LATTICE_HD_PATHS = [ { name: `Ledger Legacy (${LATTICE_MEW_PATH})`, value: LATTICE_MEW_PATH }, ]; +const TREZOR_TESTNET_PATH = `m/44'/1'/0'/0`; +const TREZOR_HD_PATHS = [ + { name: `BIP44 Standard (e.g. MetaMask, Trezor)`, value: BIP44_PATH }, + { name: `Trezor Testnets`, value: TREZOR_TESTNET_PATH }, +]; + const HD_PATHS = { ledger: LEDGER_HD_PATHS, lattice: LATTICE_HD_PATHS, + trezor: TREZOR_HD_PATHS, }; class ConnectHardwareForm extends Component { @@ -76,7 +86,11 @@ class ConnectHardwareForm extends Component { } async checkIfUnlocked() { - for (const device of ['trezor', 'ledger', 'lattice']) { + for (const device of [ + DEVICE_NAMES.TREZOR, + DEVICE_NAMES.LEDGER, + DEVICE_NAMES.LATTICE, + ]) { const path = this.props.defaultHdPaths[device]; const unlocked = await this.props.checkHardwareStatus(device, path); if (unlocked) { @@ -176,9 +190,22 @@ class ConnectHardwareForm extends Component { this.setState({ error: this.context.t('ledgerTimeout'), }); + } else if ( + errorMessage + .toLowerCase() + .includes( + 'KeystoneError#pubkey_account.no_expected_account'.toLowerCase(), + ) + ) { + this.setState({ + error: this.context.t('QRHardwarePubkeyAccountOutOfRange'), + }); } else if ( errorMessage !== 'Window closed' && - errorMessage !== 'Popup closed' + errorMessage !== 'Popup closed' && + errorMessage + .toLowerCase() + .includes('KeystoneError#sync_cancel'.toLowerCase()) === false ) { this.setState({ error: errorMessage, diff --git a/ui/pages/create-account/connect-hardware/index.scss b/ui/pages/create-account/connect-hardware/index.scss index b796812cf..977b4af96 100644 --- a/ui/pages/create-account/connect-hardware/index.scss +++ b/ui/pages/create-account/connect-hardware/index.scss @@ -52,6 +52,7 @@ justify-content: center; border-radius: 5px; padding: 0; + margin-right: 15px; &__img { width: 95px; @@ -64,7 +65,6 @@ } &__btn:first-child { - margin-right: 15px; margin-left: 20px; } diff --git a/ui/pages/create-account/connect-hardware/select-hardware.js b/ui/pages/create-account/connect-hardware/select-hardware.js index 3f7e6cbe8..375b33f23 100644 --- a/ui/pages/create-account/connect-hardware/select-hardware.js +++ b/ui/pages/create-account/connect-hardware/select-hardware.js @@ -2,7 +2,10 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Button from '../../../components/ui/button'; -import { LEDGER_TRANSPORT_TYPES } from '../../../../shared/constants/hardware-wallets'; +import { + DEVICE_NAMES, + LEDGER_TRANSPORT_TYPES, +} from '../../../../shared/constants/hardware-wallets'; export default class SelectHardware extends Component { static contextTypes = { @@ -30,9 +33,9 @@ export default class SelectHardware extends Component { return ( + ); + } + renderButtons() { return ( <> @@ -89,6 +109,7 @@ export default class SelectHardware extends Component { style={{ margin: '10px 0 0 0' }} > {this.renderConnectToLatticeButton()} + {this.renderConnectToQRButton()}

    ); @@ -149,12 +170,14 @@ export default class SelectHardware extends Component { renderTutorialsteps() { switch (this.state.selectedDevice) { - case 'ledger': + case DEVICE_NAMES.LEDGER: return this.renderLedgerTutorialSteps(); - case 'trezor': + case DEVICE_NAMES.TREZOR: return this.renderTrezorTutorialSteps(); - case 'lattice': + case DEVICE_NAMES.LATTICE: return this.renderLatticeTutorialSteps(); + case DEVICE_NAMES.QR: + return this.renderQRHardwareWalletSteps(); default: return ''; } @@ -296,6 +319,65 @@ export default class SelectHardware extends Component { ); } + renderQRHardwareWalletSteps() { + const steps = []; + steps.push( + { + title: this.context.t('QRHardwareWalletSteps1Title'), + message: this.context.t('QRHardwareWalletSteps1Description'), + }, + { + message: ( + <> + + {this.context.t('keystone')} + + + {this.context.t('keystoneTutorial')} + + + ), + }, + { + message: this.context.t('QRHardwareWalletSteps2Description'), + }, + { + asset: 'qrcode-wallet-demo', + dimensions: { width: '225px', height: '75px' }, + }, + ); + return ( +
    + {steps.map((step, index) => ( +
    + {step.title &&

    {step.title}

    } +

    {step.message}

    + {step.asset && ( + + )} +
    + ))} +
    + ); + } + renderConnectScreen() { return (
    diff --git a/ui/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js b/ui/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js index 7522899c6..a7259bf9b 100644 --- a/ui/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js +++ b/ui/pages/first-time-flow/create-password/import-with-seed-phrase/import-with-seed-phrase.component.js @@ -7,6 +7,7 @@ import { INITIALIZE_SELECT_ACTION_ROUTE, INITIALIZE_END_OF_FLOW_ROUTE, } from '../../../../helpers/constants/routes'; +import { clearClipboard } from '../../../../helpers/utils/util'; const { isValidMnemonic } = ethers.utils; @@ -244,6 +245,7 @@ export default class ImportWithSeedPhrase extends PureComponent {