From 8b3e3c8a588f4cfcd6efb100623be18dbf2ad60d Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Thu, 15 Jun 2023 20:17:21 +0200 Subject: [PATCH] Swaps UI update (#19169) --- app/_locales/de/messages.json | 36 +- app/_locales/el/messages.json | 36 +- app/_locales/en/messages.json | 176 ++- app/_locales/es/messages.json | 36 +- app/_locales/es_419/messages.json | 8 +- app/_locales/fr/messages.json | 36 +- app/_locales/hi/messages.json | 36 +- app/_locales/id/messages.json | 36 +- app/_locales/it/messages.json | 15 +- app/_locales/ja/messages.json | 36 +- app/_locales/ko/messages.json | 36 +- app/_locales/ph/messages.json | 8 +- app/_locales/pt/messages.json | 36 +- app/_locales/pt_BR/messages.json | 8 +- app/_locales/ru/messages.json | 36 +- app/_locales/tl/messages.json | 36 +- app/_locales/tr/messages.json | 36 +- app/_locales/vi/messages.json | 36 +- app/_locales/zh_CN/messages.json | 36 +- app/images/swaps-redesign.svg | 9 + app/scripts/controllers/swaps.js | 38 +- app/scripts/metamask-controller.js | 38 +- shared/constants/swaps.ts | 4 + shared/notifications/index.js | 19 + test/e2e/fixture-builder.js | 5 + test/e2e/swaps/shared.js | 215 +-- test/e2e/swaps/swap-eth.spec.js | 20 +- test/e2e/swaps/swaps-notifications.spec.js | 155 +- test/jest/mock-store.js | 5 + .../confirm-gas-display.test.js.snap | 1 + .../confirm-legacy-gas-display.test.js.snap | 1 + .../transaction-breakdown.component.js | 5 +- ...transaction-list-item-details.component.js | 5 +- .../app/wallet-overview/eth-overview.js | 1 + .../app/whats-new-popup/whats-new-popup.js | 7 +- .../__snapshots__/banner-alert.test.js.snap | 1 + .../__snapshots__/banner-base.test.js.snap | 1 + .../banner-base/banner-base.js | 1 + .../__snapshots__/banner-tip.test.js.snap | 1 + .../text-field/text-field.js | 6 + .../compliance-details.test.js.snap | 2 + ui/components/ui/info-tooltip/info-tooltip.js | 2 +- .../list-item.component.test.js.snap | 3 + .../ui/list-item/list-item.component.js | 11 +- ui/components/ui/typography/typography.js | 6 + ui/ducks/swaps/swaps.js | 76 +- ui/ducks/swaps/swaps.test.js | 19 + ui/helpers/constants/routes.ts | 6 + .../confirm-send-ether.test.js.snap | 2 + .../confirm-transaction-base.test.js.snap | 1 + .../add-network-modal.test.js.snap | 1 + .../send-content.component.test.js.snap | 1 + .../__snapshots__/awaiting-swap.test.js.snap | 2 + ui/pages/swaps/awaiting-swap/awaiting-swap.js | 15 +- ui/pages/swaps/build-quote/build-quote.js | 105 +- .../swaps/build-quote/build-quote.test.js | 1 - .../swaps/countdown-timer/countdown-timer.js | 2 - ui/pages/swaps/countdown-timer/index.scss | 1 + .../dropdown-search-list.test.js.snap | 5 +- .../dropdown-search-list.js | 9 +- .../swaps/dropdown-search-list/index.scss | 4 +- .../exchange-rate-display.test.js.snap | 60 +- .../exchange-rate-display.js | 90 +- .../exchange-rate-display.test.js | 7 +- .../swaps/exchange-rate-display/index.scss | 29 +- .../__snapshots__/fee-card.test.js.snap | 1 + ui/pages/swaps/index.js | 199 ++- ui/pages/swaps/index.scss | 43 +- ui/pages/swaps/index.test.js | 4 +- .../list-with-search.test.js.snap | 22 + ui/pages/swaps/list-with-search/index.scss | 23 + .../list-with-search/list-with-search.js | 179 +++ .../list-with-search/list-with-search.test.js | 68 + ...swaps-quotes-stories-metadata.test.js.snap | 2 +- .../background-animation.js | 10 +- .../background-animation.test.js | 11 +- .../main-quote-summary.test.js.snap | 60 +- .../mascot-background-animation/index.scss | 60 + .../mascot-background-animation.js | 229 +++ .../mascot-background-animation.test.js | 19 + ui/pages/swaps/notification-page/index.scss | 13 + .../notification-page/notification-page.js | 79 + .../notification-page.test.js | 48 + .../popover-custom-background/index.scss | 6 + .../popover-custom-background.js | 14 + .../prepare-swap-page.test.js.snap | 3 + ui/pages/swaps/prepare-swap-page/index.scss | 404 ++++++ .../prepare-swap-page/prepare-swap-page.js | 1098 ++++++++++++++ .../prepare-swap-page.test.js | 188 +++ .../quotes-loading-animation.js | 63 + .../swaps/prepare-swap-page/review-quote.js | 1281 +++++++++++++++++ .../prepare-swap-page/review-quote.test.js | 104 ++ .../smart-transactions-popover.js | 121 ++ .../view-quote-price-difference.js | 102 ++ .../view-quote-price-difference.test.js | 150 ++ .../searchable-item-list.test.js.snap | 1 + .../swaps/searchable-item-list/index.scss | 1 - .../item-list.component.test.js.snap | 1 + .../item-list/item-list.component.js | 14 +- .../swaps/select-quote-popover/index.scss | 2 +- .../quote-details/index.scss | 2 +- .../__snapshots__/sort-list.test.js.snap | 2 + .../__snapshots__/selected-token.test.js.snap | 74 + .../swaps/selected-token/selected-token.js | 87 ++ .../selected-token/selected-token.test.js | 45 + .../slippage-buttons/slippage-buttons.js | 2 +- .../slippage-buttons/slippage-buttons.test.js | 4 +- .../smart-transaction-status.js | 23 +- .../smart-transaction-status.test.js | 2 +- .../swaps-banner-alert/swaps-banner-alert.js | 170 +++ .../swaps-banner-alert.test.js | 171 +++ ui/pages/swaps/swaps-footer/index.scss | 5 +- ui/pages/swaps/swaps.util.ts | 21 +- .../transaction-settings.test.js.snap | 67 + .../swaps/transaction-settings/index.scss | 82 ++ .../transaction-settings.js | 380 +++++ .../transaction-settings.stories.js | 15 + .../transaction-settings.test.js | 140 ++ .../__snapshots__/view-quote.test.js.snap | 120 +- ui/pages/swaps/view-quote/index.scss | 12 - ui/selectors/selectors.js | 1 + ui/store/actions.ts | 1 - 122 files changed, 6610 insertions(+), 1156 deletions(-) create mode 100644 app/images/swaps-redesign.svg create mode 100644 ui/pages/swaps/list-with-search/__snapshots__/list-with-search.test.js.snap create mode 100644 ui/pages/swaps/list-with-search/index.scss create mode 100644 ui/pages/swaps/list-with-search/list-with-search.js create mode 100644 ui/pages/swaps/list-with-search/list-with-search.test.js create mode 100644 ui/pages/swaps/mascot-background-animation/index.scss create mode 100644 ui/pages/swaps/mascot-background-animation/mascot-background-animation.js create mode 100644 ui/pages/swaps/mascot-background-animation/mascot-background-animation.test.js create mode 100644 ui/pages/swaps/notification-page/index.scss create mode 100644 ui/pages/swaps/notification-page/notification-page.js create mode 100644 ui/pages/swaps/notification-page/notification-page.test.js create mode 100644 ui/pages/swaps/popover-custom-background/index.scss create mode 100644 ui/pages/swaps/popover-custom-background/popover-custom-background.js create mode 100644 ui/pages/swaps/prepare-swap-page/__snapshots__/prepare-swap-page.test.js.snap create mode 100644 ui/pages/swaps/prepare-swap-page/index.scss create mode 100644 ui/pages/swaps/prepare-swap-page/prepare-swap-page.js create mode 100644 ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js create mode 100644 ui/pages/swaps/prepare-swap-page/quotes-loading-animation.js create mode 100644 ui/pages/swaps/prepare-swap-page/review-quote.js create mode 100644 ui/pages/swaps/prepare-swap-page/review-quote.test.js create mode 100644 ui/pages/swaps/prepare-swap-page/smart-transactions-popover.js create mode 100644 ui/pages/swaps/prepare-swap-page/view-quote-price-difference.js create mode 100644 ui/pages/swaps/prepare-swap-page/view-quote-price-difference.test.js create mode 100644 ui/pages/swaps/selected-token/__snapshots__/selected-token.test.js.snap create mode 100644 ui/pages/swaps/selected-token/selected-token.js create mode 100644 ui/pages/swaps/selected-token/selected-token.test.js create mode 100644 ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.js create mode 100644 ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.test.js create mode 100644 ui/pages/swaps/transaction-settings/__snapshots__/transaction-settings.test.js.snap create mode 100644 ui/pages/swaps/transaction-settings/index.scss create mode 100644 ui/pages/swaps/transaction-settings/transaction-settings.js create mode 100644 ui/pages/swaps/transaction-settings/transaction-settings.stories.js create mode 100644 ui/pages/swaps/transaction-settings/transaction-settings.test.js diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 8b1b4aad2..db6d09d94 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Kunden-Token hinzufügen" }, - "addCustomTokenByContractAddress": { - "message": "Sie können kein Token finden? Sie können ein beliebiges Token manuell hinzufügen, indem Sie seine Adresse eingeben. Token-Vertragsadressen finden Sie auf $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Dadurch kann dieses Netzwerk innerhalb MetaMask verwendet werden." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Token hinzufügen" }, + "addTokenByContractAddress": { + "message": "Sie können kein Token finden? Sie können ein beliebiges Token manuell hinzufügen, indem Sie seine Adresse eingeben. Token-Vertragsadressen finden Sie auf $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Adresse" }, @@ -548,13 +548,6 @@ "message": "Für eine Transaktion im Wert von $1 muss die Gasgebühr um mindestens 10 % erhöht werden, damit sie vom Netz erkannt wird.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Tausch für ~$1 abbrechen", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Tausch kostenlos abbrechen" - }, "cancelled": { "message": "Abgebrochen" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Verwenden Sie die OpenSea's API, um NFT-Daten abzurufen. Die NFT-Auto-Erkennung basiert auf der OpenSea's API und wird nicht verfügbar sein, wenn diese deaktiviert ist." }, - "enableSmartTransactions": { - "message": "Intelligente Transaktionen ermöglichen" - }, "enableToken": { "message": "$1 aktivieren", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Mir ist klar, dass ich meine Konten und alle dazugehörigen Vermögenswerte verlieren kann, solange ich keine Sicherungskopie meiner Geheimen Wiederherstellungsphrase erstelle." }, - "smartTransaction": { - "message": "Intelligente Transaktionen" - }, "snapContent": { "message": "Diese Inhalte stammen von $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Stark" }, - "stxAreHere": { - "message": "Intelligente Transaktionen sind da!" - }, "stxBenefit1": { "message": "Transaktionskosten minimieren" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Versuchen Sie Ihren Swap erneut. Wir werden hier sein, um Sie beim nächsten Mal vor ähnlichen Risiken zu schützen." }, - "stxDescription": { - "message": "MetaMask-Swap ist jetzt viel intelligenter geworden! Wenn Sie intelligente Transaktionen aktivieren, kann MetaMask Ihren Swap programmgesteuert optimieren, um Ihnen zu helfen:" - }, - "stxErrorNotEnoughFunds": { - "message": "Nicht genügend Mittel für eine intelligente Transaktion." - }, - "stxErrorUnavailable": { - "message": "Intelligente Transaktionen sind vorübergehend nicht verfügbar." - }, "stxFailure": { "message": "Swap fehlgeschlagen" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Ihr Swap wird öffentlich eingereicht ..." }, - "stxSubDescription": { - "message": "* Intelligente Transaktionen versuchen mehrmals, Ihre Transaktion privat zu übermitteln. Wenn alle Versuche fehlschlagen, wird die Transaktion öffentlich übertragen, um sicherzustellen, dass Ihr Swap erfolgreich durchgeführt wird." - }, "stxSuccess": { "message": "Swap abgeschlossen!" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index eff68ba30..9021a3098 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Προσθήκη Προσαρμοσμένου Token" }, - "addCustomTokenByContractAddress": { - "message": "Αδυναμία εύρεσης token; Μπορείτε να προσθέσετε χειροκίνητα οποιοδήποτε διακριτικό επικολλώντας τη διεύθυνσή του. Οι διευθύνσεις συμβολαίων Token μπορούν να βρεθούν στο $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Αυτό θα επιτρέψει σε αυτό το δίκτυο να χρησιμοποιηθεί στο MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Προσθήκη Token" }, + "addTokenByContractAddress": { + "message": "Αδυναμία εύρεσης token; Μπορείτε να προσθέσετε χειροκίνητα οποιοδήποτε διακριτικό επικολλώντας τη διεύθυνσή του. Οι διευθύνσεις συμβολαίων Token μπορούν να βρεθούν στο $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Διεύθυνση" }, @@ -548,13 +548,6 @@ "message": "Για να $1 τη συναλλαγή, τα τέλη συναλλαγής πρέπει να αυξηθούν κατά τουλάχιστον 10% ώστε να αναγνωριστούν από το δίκτυο.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Ακυρώστε τη συναλλαγή για ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Ακυρώστε τη συναλλαγή δωρεάν" - }, "cancelled": { "message": "Ακυρώθηκε" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Χρησιμοποιήστε το API OpenSea για λήψη δεδομένων NFT. Η αυτόματη ανίχνευση NFT βασίζεται στο API του OpenSea, και δεν θα είναι διαθέσιμη όταν αυτό είναι απενεργοποιημένο." }, - "enableSmartTransactions": { - "message": "Ενεργοποίηση Έξυπνων Συναλλαγών" - }, "enableToken": { "message": "ενεργοποίηση $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3236,9 +3226,6 @@ "skipAccountSecurityDetails": { "message": "Καταλαβαίνω ότι μέχρι να δημιουργήσω αντίγραφα ασφαλείας για τη Μυστική Φράση Ανάκτησής μου, μπορεί να χάσω τους λογαριασμούς μου και όλα τα περιουσιακά στοιχεία τους." }, - "smartTransaction": { - "message": "Έξυπνη Συναλλαγή" - }, "snapContent": { "message": "Αυτό το περιεχόμενο προέρχεται από το $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3422,9 +3409,6 @@ "strong": { "message": "Ισχυρός" }, - "stxAreHere": { - "message": "Οι Έξυπνες Συναλλαγές είναι εδώ!" - }, "stxBenefit1": { "message": "Ελαχιστοποίηση του κόστους συναλλαγής" }, @@ -3446,15 +3430,6 @@ "stxCancelledSubDescription": { "message": "Προσπαθήστε ξανά να κάνετε ανταλλαγή. Θα είμαστε εδώ για να σας προστατεύσουμε από παρόμοιους κινδύνους και την επόμενη φορά." }, - "stxDescription": { - "message": "Οι Ανταλλαγές MetaMask μόλις έγιναν πολύ πιο έξυπνες! Η ενεργοποίηση των Έξυπνων Συναλλαγών θα επιτρέψει στο MetaMask να βελτιώσει προγραμματικά τις Ανταλλαγές σας ώστε να απολαμβάνετε:" - }, - "stxErrorNotEnoughFunds": { - "message": "Δεν υπάρχουν αρκετοί πόροι για αυτή την έξυπνη συναλλαγή." - }, - "stxErrorUnavailable": { - "message": "Οι Έξυπνες Συναλλαγές είναι προσωρινά μη διαθέσιμες." - }, "stxFailure": { "message": "Η Ανταλλαγή απέτυχε" }, @@ -3468,9 +3443,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Δημόσια υποβολή της Ανταλλαγής σας..." }, - "stxSubDescription": { - "message": "* Οι Έξυπνες Συναλλαγές θα προσπαθήσουν να υποβάλουν τη συναλλαγή σας ιδιωτικά, πολλές φορές. Εάν όλες οι προσπάθειες αποτύχουν, η συναλλαγή θα μεταδοθεί δημόσια για να διασφαλιστεί η επιτυχής πραγματοποίηση της ανταλλαγής σας." - }, "stxSuccess": { "message": "Η ανταλλαγή ολοκληρώθηκε!" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 4ce75852b..9bb43c4cc 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -204,10 +204,6 @@ "addCustomToken": { "message": "Add custom token" }, - "addCustomTokenByContractAddress": { - "message": "Can’t find a token? You can manually add any token by pasting its address. Token contract addresses can be found on $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "This will allow this network to be used within MetaMask." }, @@ -275,6 +271,10 @@ "addToken": { "message": "Add token" }, + "addTokenByContractAddress": { + "message": "Can’t find a token? You can manually add any token by pasting its address. Token contract addresses can be found on $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addingCustomNetwork": { "message": "Adding Network" }, @@ -438,6 +438,10 @@ "attemptSendingAssets": { "message": "If you attempt to send assets directly from one network to another, this may result in permanent asset loss. Make sure to use a bridge." }, + "attemptToCancelSwap": { + "message": "Attempt to cancel swap for ~$1", + "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Swap" + }, "attemptingConnect": { "message": "Attempting to connect to blockchain." }, @@ -600,13 +604,6 @@ "message": "To $1 a transaction the gas fee must be increased by at least 10% for it to be recognized by the network.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Cancel swap for ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Cancel swap for free" - }, "cancelled": { "message": "Cancelled" }, @@ -1378,8 +1375,8 @@ "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." }, - "enableSmartTransactions": { - "message": "Enable smart transactions" + "enableSmartSwaps": { + "message": "Enable smart swaps" }, "enableSnap": { "message": "Enable" @@ -1438,6 +1435,9 @@ "enterPasswordContinue": { "message": "Enter password to continue" }, + "enterTokenNameOrAddress": { + "message": "Enter token name or paste address" + }, "enterYourPassword": { "message": "Enter your password" }, @@ -2535,6 +2535,9 @@ "notCurrentAccount": { "message": "Is this the correct account? It's different from the currently selected account in your wallet" }, + "notEnoughBalance": { + "message": "Insufficient balance" + }, "notEnoughGas": { "message": "Not enough gas" }, @@ -2661,6 +2664,16 @@ "message": "Ledger and Firefox Users Experiencing Connection Issues", "description": "Title for a notification in the 'See What's New' popup. Tells users that latest firefox users using U2F may experience connection issues." }, + "notifications21ActionText": { + "message": "Try it out" + }, + "notifications21Description": { + "message": "We've updated Swaps in the MetaMask extension to be easier and faster to use.", + "description": "Description of a notification in the 'See What's New' popup. Describes NFT autodetection feature." + }, + "notifications21Title": { + "message": "Introducing new and refreshed Swaps!" + }, "notifications3ActionText": { "message": "Read more", "description": "The 'call to action' on the button, or link, of the 'Stay secure' notification. Upon clicking, users will be taken to a page about security on the metamask support website." @@ -3004,6 +3017,9 @@ "message": "You have (1) pending transaction.", "description": "$1 is count of pending transactions" }, + "percentage": { + "message": "$1%" + }, "permissionRequest": { "message": "Permission request" }, @@ -3261,6 +3277,9 @@ "queued": { "message": "Queued" }, + "quoteRate": { + "message": "Quote rate" + }, "reAddAccounts": { "message": "re-add any other accounts" }, @@ -3776,8 +3795,23 @@ "skipAccountSecurityDetails": { "message": "I understand that until I back up my Secret Recovery Phrase, I may lose my accounts and all of their assets." }, - "smartTransaction": { - "message": "Smart transaction" + "smartSwap": { + "message": "Smart swap" + }, + "smartSwapsAreHere": { + "message": "Smart Swaps are here!" + }, + "smartSwapsDescription": { + "message": "MetaMask Swaps just got a whole lot smarter! Enabling Smart Swaps will allow MetaMask to programmatically optimize your Swap to help:" + }, + "smartSwapsErrorNotEnoughFunds": { + "message": "Not enough funds for a smart swap." + }, + "smartSwapsErrorUnavailable": { + "message": "Smart Swaps are temporarily unavailable." + }, + "smartSwapsSubDescription": { + "message": "* Smart Swaps will attempt to submit your transaction privately, multiple times. If all attempts fail, the transaction will be broadcast publicly to ensure your Swap successfully goes through." }, "snapConnectionWarning": { "message": "$1 wants to connect to $2. Only continue if you trust this website.", @@ -4036,9 +4070,6 @@ "strong": { "message": "Strong" }, - "stxAreHere": { - "message": "Smart Transactions are here!" - }, "stxBenefit1": { "message": "Minimize transaction costs" }, @@ -4060,15 +4091,6 @@ "stxCancelledSubDescription": { "message": "Try your swap again. We’ll be here to protect you against similar risks next time." }, - "stxDescription": { - "message": "MetaMask Swaps just got a whole lot smarter! Enabling Smart Transactions will allow MetaMask to programmatically optimize your Swap to help:" - }, - "stxErrorNotEnoughFunds": { - "message": "Not enough funds for a smart transaction." - }, - "stxErrorUnavailable": { - "message": "Smart Transactions are temporarily unavailable." - }, "stxFailure": { "message": "Swap failed" }, @@ -4082,9 +4104,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Publicly submitting your Swap..." }, - "stxSubDescription": { - "message": "* Smart Transactions will attempt to submit your transaction privately, multiple times. If all attempts fail, the transaction will be broadcast publicly to ensure your Swap successfully goes through." - }, "stxSuccess": { "message": "Swap complete!" }, @@ -4145,6 +4164,9 @@ "swapAmountReceivedInfo": { "message": "This is the minimum amount you will receive. You may receive more depending on slippage." }, + "swapAnyway": { + "message": "Swap anyway" + }, "swapApproval": { "message": "Approve $1 for swaps", "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be swapped.. $1 is the symbol of a token that has been approved." @@ -4153,6 +4175,12 @@ "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." }, + "swapAreYouStillThere": { + "message": "Are you still there?" + }, + "swapAreYouStillThereDescription": { + "message": "We’re ready to show you the latest quotes when you want to continue" + }, "swapBuildQuotePlaceHolderText": { "message": "No tokens available matching $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" @@ -4160,6 +4188,9 @@ "swapConfirmWithHwWallet": { "message": "Confirm with your hardware wallet" }, + "swapContinueSwapping": { + "message": "Continue swapping" + }, "swapContractDataDisabledErrorDescription": { "message": "In the Ethereum app on your Ledger, go to \"Settings\" and allow contract data. Then, try your swap again." }, @@ -4178,6 +4209,9 @@ "swapEditLimit": { "message": "Edit limit" }, + "swapEditTransactionSettings": { + "message": "Edit transaction settings" + }, "swapEnableDescription": { "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." @@ -4186,6 +4220,9 @@ "message": "This will $1 for swapping", "description": "$1 is for the 'enableToken' key, e.g. 'enable ETH'" }, + "swapEnterAmount": { + "message": "Enter an amount" + }, "swapEstimatedNetworkFees": { "message": "Estimated network fees" }, @@ -4199,6 +4236,9 @@ "swapFailedErrorTitle": { "message": "Swap failed" }, + "swapFetchingQuote": { + "message": "Fetching quote" + }, "swapFetchingQuoteNofN": { "message": "Fetching quote $1 of $2", "description": "A count of possible 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 resources that we check for quotes. Keep in mind that not all resources will have a quote for a particular swap." @@ -4239,6 +4279,9 @@ "message": "Includes a $1% MetaMask fee.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, + "swapLearnMore": { + "message": "Learn more about Swaps" + }, "swapLowSlippageError": { "message": "Transaction may fail, max slippage too low." }, @@ -4260,6 +4303,10 @@ "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" }, + "swapNoTokensAvailable": { + "message": "No tokens available matching $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" + }, "swapOnceTransactionHasProcess": { "message": "Your $1 will be added to your account once this transaction has processed.", "description": "This message communicates the token that is being transferred. It is shown on the awaiting swap screen. The $1 will be a token symbol." @@ -4287,6 +4334,10 @@ "swapQuoteDetails": { "message": "Quote details" }, + "swapQuoteNofM": { + "message": "$1 of $2", + "description": "A count of possible 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 resources that we check for quotes. Keep in mind that not all resources will have a quote for a particular swap." + }, "swapQuoteSource": { "message": "Quote source" }, @@ -4296,6 +4347,9 @@ "swapQuotesExpiredErrorTitle": { "message": "Quotes timeout" }, + "swapQuotesNotAvailableDescription": { + "message": "Reduce the size of your trade or use a different token." + }, "swapQuotesNotAvailableErrorDescription": { "message": "Try adjusting the amount or slippage settings and try again." }, @@ -4332,16 +4386,52 @@ "swapSelectQuotePopoverDescription": { "message": "Below are all the quotes gathered from multiple liquidity sources." }, + "swapSelectToken": { + "message": "Select token" + }, + "swapShowLatestQuotes": { + "message": "Show latest quotes" + }, "swapSlippageNegative": { "message": "Slippage must be greater or equal to zero" }, + "swapSlippageNegativeDescription": { + "message": "Slippage must be greater or equal to zero" + }, + "swapSlippageNegativeTitle": { + "message": "Increase slippage to continue" + }, + "swapSlippageOverLimitDescription": { + "message": "Slippage tolerance must be 15% or less. Anything higher will result in a bad rate." + }, + "swapSlippageOverLimitTitle": { + "message": "Reduce slippage to continue" + }, "swapSlippagePercent": { "message": "$1%", "description": "$1 is the amount of % for slippage" }, + "swapSlippageTooLowDescription": { + "message": "Max slippage is too low which may cause your transaction to fail." + }, + "swapSlippageTooLowTitle": { + "message": "Increase slippage to avoid transaction failure" + }, "swapSlippageTooltip": { "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." }, + "swapSlippageVeryHighDescription": { + "message": "The slippage entered is considered very high and may result in a bad rate" + }, + "swapSlippageVeryHighTitle": { + "message": "Very high slippage" + }, + "swapSlippageZeroDescription": { + "message": "There are fewer zero-slippage quote providers which will result in a less competitive quote." + }, + "swapSlippageZeroTitle": { + "message": "Sourcing zero-slippage providers" + }, "swapSource": { "message": "Liquidity source" }, @@ -4358,7 +4448,7 @@ "message": "Swap from" }, "swapSwapSwitch": { - "message": "Switch from and to tokens" + "message": "Switch token order" }, "swapSwapTo": { "message": "Swap to" @@ -4366,6 +4456,13 @@ "swapToConfirmWithHwWallet": { "message": "to confirm with your hardware wallet" }, + "swapTokenAddedManuallyDescription": { + "message": "Verify this token on $1 and make sure it is the token you want to trade.", + "description": "$1 points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, + "swapTokenAddedManuallyTitle": { + "message": "Token added manually" + }, "swapTokenAvailable": { "message": "Your $1 has been added to your 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." @@ -4392,6 +4489,13 @@ "message": "Verified on $1 sources.", "description": "Indicates the number of token information sources that recognize the symbol + address. $1 is a decimal number." }, + "swapTokenVerifiedOn1SourceDescription": { + "message": "$1 is only verified on 1 source. Consider verifying it on $2 before proceeding.", + "description": "$1 is a token name, $2 points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, + "swapTokenVerifiedOn1SourceTitle": { + "message": "Potentially inauthentic token" + }, "swapTooManyDecimalsError": { "message": "$1 allows up to $2 decimals", "description": "$1 is a token symbol and $2 is the max. number of decimals allowed for the token" @@ -4429,9 +4533,16 @@ "message": "Not enough $1 to complete this transaction", "description": "Tells the user that they don't have enough of a token for a proposed swap. $1 is a token symbol" }, + "swapsNotEnoughToken": { + "message": "Not enough $1", + "description": "Tells the user that they don't have enough of a token for a proposed swap. $1 is a token symbol" + }, "swapsViewInActivity": { "message": "View in activity" }, + "switch": { + "message": "Switch" + }, "switchEthereumChainConfirmationDescription": { "message": "This will switch the selected network within MetaMask to a previously added network:" }, @@ -4734,6 +4845,9 @@ "transactionSecurityCheckDescription": { "message": "We use third-party APIs to detect and display risks involved in unsigned transaction and signature requests before you sign them. These services will have access to your unsigned transaction and signature requests, your account address, and your preferred language." }, + "transactionSettings": { + "message": "Transaction settings" + }, "transactionSubmitted": { "message": "Transaction submitted with estimated gas fee of $1 at $2." }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 17a2744e1..fb2d332bc 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Añadir token personalizado" }, - "addCustomTokenByContractAddress": { - "message": "¿No encuentra un token? Puede agregar cualquier token si copia su dirección. Puede encontrar la dirección de contrato del token en $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Esto permitirá que la red se utilice en MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Agregar token" }, + "addTokenByContractAddress": { + "message": "¿No encuentra un token? Puede agregar cualquier token si copia su dirección. Puede encontrar la dirección de contrato del token en $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Dirección" }, @@ -548,13 +548,6 @@ "message": "Para $1 una transacción, la tarifa de gas debe aumentar al menos un 10% para que sea reconocida por la red.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Cancelar el swap por ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Cancelar el swap gratuitamente" - }, "cancelled": { "message": "Cancelado" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Utilice la API de OpenSea para obtener los datos de NFT. La autodetección de NFT depende de la API de OpenSea y no estará disponible si la API está desactivada." }, - "enableSmartTransactions": { - "message": "Habilitar transacciones inteligentes" - }, "enableToken": { "message": "activar $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Entiendo que hasta que no haga una copia de seguridad de mi frase secreta de recuperación, puedo perder mis cuentas y todos los activos asociados." }, - "smartTransaction": { - "message": "Transacción inteligente" - }, "snapContent": { "message": "Este contenido proviene de $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Fuerte" }, - "stxAreHere": { - "message": "¡Las transacciones inteligentes están aquí!" - }, "stxBenefit1": { "message": "Minimizar los costos de transacción" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Intente su swap nuevamente. Estaremos aquí para protegerlo contra riesgos similares la próxima vez." }, - "stxDescription": { - "message": "¡MetaMask Swaps ahora es mucho más inteligente! Habilitar transacciones inteligentes permitirá que MetaMask optimice mediante programación su swap para ayudar:" - }, - "stxErrorNotEnoughFunds": { - "message": "No hay suficientes fondos para una transacción inteligente." - }, - "stxErrorUnavailable": { - "message": "Las transacciones inteligentes no están disponibles temporalmente." - }, "stxFailure": { "message": "Error al canjear" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Enviando su swap de forma pública..." }, - "stxSubDescription": { - "message": "* Transacciones inteligentes intentará enviar su transacción de forma privada varias veces. Si todos los intentos fallan, la transacción se transmitirá públicamente para garantizar que su swap se realice con éxito." - }, "stxSuccess": { "message": "¡Swap finalizado!" }, diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 782dbd2ea..bec397e5b 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -103,10 +103,6 @@ "addCustomToken": { "message": "Añadir token personalizado" }, - "addCustomTokenByContractAddress": { - "message": "¿No encuentra un token? Para agregar un token, copie su dirección. Puede encontrar la dirección de contrato del token en $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Esto permitirá que la red se utilice en MetaMask." }, @@ -139,6 +135,10 @@ "addToken": { "message": "Agregar token" }, + "addTokenByContractAddress": { + "message": "¿No encuentra un token? Para agregar un token, copie su dirección. Puede encontrar la dirección de contrato del token en $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Dirección" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 5101c57f4..6aa370f4f 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Ajouter un jeton personnalisé" }, - "addCustomTokenByContractAddress": { - "message": "Vous ne trouvez pas de jeton ? Vous pouvez ajouter manuellement n’importe quel jeton avec son adresse par copier-coller. Les adresses des contrats de jetons sont disponibles sur $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Cela permettra d’utiliser ce réseau dans MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Ajouter le jeton" }, + "addTokenByContractAddress": { + "message": "Vous ne trouvez pas de jeton ? Vous pouvez ajouter manuellement n’importe quel jeton avec son adresse par copier-coller. Les adresses des contrats de jetons sont disponibles sur $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Adresse" }, @@ -548,13 +548,6 @@ "message": "Pour $1 la transaction, les gas fees doivent être augmentés d’au moins 10 % pour être reconnus par le réseau.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Annuler le swap pour ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Annuler le swap gratuitement" - }, "cancelled": { "message": "Annulé" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Utilisez l’API OpenSea pour récupérer les données de NFT. La détection automatique de NFT repose sur l’API OpenSea et ne sera pas disponible si elle est désactivée." }, - "enableSmartTransactions": { - "message": "Activer les transactions intelligentes" - }, "enableToken": { "message": "activer $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Je suis conscient(e) que tant que je n’aurai pas sauvegardé ma phrase secrète de récupération, je risque de perdre mes comptes et tous leurs actifs." }, - "smartTransaction": { - "message": "Transaction intelligente" - }, "snapContent": { "message": "Ce contenu provient de $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Robuste" }, - "stxAreHere": { - "message": "Les transactions intelligentes sont là !" - }, "stxBenefit1": { "message": "Minimise les frais de transaction" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Réessayez le swap. Nous serons là pour vous protéger contre des risques similaires la prochaine fois." }, - "stxDescription": { - "message": "MetaMask Swaps vient de devenir beaucoup plus intelligent ! Si vous activez les transactions intelligentes, MetaMask pourra optimiser programmatiquement votre swap pour vous aider à :" - }, - "stxErrorNotEnoughFunds": { - "message": "Fonds insuffisants pour une transaction intelligente." - }, - "stxErrorUnavailable": { - "message": "Les transactions intelligentes sont temporairement indisponibles." - }, "stxFailure": { "message": "Échec du swap" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Soumission publique de votre Swap..." }, - "stxSubDescription": { - "message": "* Avec les transactions intelligentes, votre transaction sera soumise plusieurs fois en privé. Si toutes les tentatives échouent, la transaction sera diffusée publiquement pour s’assurer de la réussite de votre swap." - }, "stxSuccess": { "message": "Swap terminé !" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 4fca23854..55e28531b 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "कस्टम टोकन जोड़ें" }, - "addCustomTokenByContractAddress": { - "message": "टोकन नहीं मिल रहा है? आप किसी भी टोकन का पता पेस्ट करके उसे मैन्युअल रूप से भी जोड़ सकते हैं। टोकन अनुबंध पते $1 पर मिल सकते हैं।", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "इससे इस नेटवर्क को MetaMask के अंदर उपयोग करने की अनुमति मिलेगी।" }, @@ -263,6 +259,10 @@ "addToken": { "message": "टोकन जोड़ें" }, + "addTokenByContractAddress": { + "message": "टोकन नहीं मिल रहा है? आप किसी भी टोकन का पता पेस्ट करके उसे मैन्युअल रूप से भी जोड़ सकते हैं। टोकन अनुबंध पते $1 पर मिल सकते हैं।", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "पता" }, @@ -548,13 +548,6 @@ "message": "किसी लेनदेन को $1 करने के लिए गैस शुल्क में कम से कम 10% की वृद्धि की जानी चाहिए ताकि उसे नेटवर्क द्वारा मान्यता मिल सके।", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "~$1 में स्वैप रद्द करें", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "मुफ्त में स्वैप रद्द करें" - }, "cancelled": { "message": "रद्द किया गया" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "NFT डेटा लाने के लिए OpenSea के API का उपयोग करें। NFT ऑटो-डिटेक्शन OpenSea के API पर निर्भर करता है, और इसके बंद होने पर उपलब्ध नहीं होगा।" }, - "enableSmartTransactions": { - "message": "स्मार्ट लेनदेन को सक्षम करें" - }, "enableToken": { "message": "$1 इनेबल करें", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "मैं समझता हूं कि जब तक मैं अपने सीक्रेट रिकवरी फ्रेज का बैकअप नहीं लेता, मैं अपने खाते और उनकी सभी संपत्ति खो सकता हूं।" }, - "smartTransaction": { - "message": "स्मार्ट लेनदेन" - }, "snapContent": { "message": "यह सामग्री $1 से आ रही है", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "मजबूत" }, - "stxAreHere": { - "message": "स्मार्ट लेनदेन यहां पर हैं!" - }, "stxBenefit1": { "message": "लेनदेन लागतें मिनिमाइज़ करें" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "अपना स्वैप फिर से कोशिश करें। अगली बार भी इस तरह के जोखिमों से आपको बचाने के लिए हम यहां होंगे।" }, - "stxDescription": { - "message": "MetaMask के स्वैप अब और अधिक स्मार्ट हो गए हैं! इन हेतु सहयता के लिए स्मार्ट लेनदेन को सक्षम करने से MetaMask आपके स्वैप को प्रोग्रामेटिक रूप से ऑप्टिमाइज़ कर पाएगा:" - }, - "stxErrorNotEnoughFunds": { - "message": "एक स्मार्ट लेनदेन के लिए पर्याप्त फंड नहीं है।" - }, - "stxErrorUnavailable": { - "message": "स्मार्ट लेनदेन अस्थाई तौर पर अनुपबल्ध हैं।" - }, "stxFailure": { "message": "स्वैप विफल हुआ" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "आपका स्वैप सार्वजनिक रूप से सबमिट किया जा रहा है..." }, - "stxSubDescription": { - "message": "* स्मार्ट लेनदेन आपके लेनदेन को निजी तौर पर, अनेक बार जमा करने का प्रयास करेंगे। यदि सभी प्रयास विफल हो जाते हैं, तो लेनदेन को सार्वजनिक रूप से प्रसारित किया जाएगा ताकि यह सुनिश्चित हो सके कि आपका स्वैप सफलतापूर्वक पूरा हो।" - }, "stxSuccess": { "message": "स्वैप पूरा हुआ!" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index d4babd8b2..baec167e3 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Tambahkan token kustom" }, - "addCustomTokenByContractAddress": { - "message": "Tidak dapat menemukan token? Tambahkan token secara manual dengan menempelkan alamatnya. Alamat kontrak token dapat ditemukan di $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Tindakan ini akan membantu jaringan ini agar dapat digunakan dengan MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Tambahkan token" }, + "addTokenByContractAddress": { + "message": "Tidak dapat menemukan token? Tambahkan token secara manual dengan menempelkan alamatnya. Alamat kontrak token dapat ditemukan di $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Alamat" }, @@ -548,13 +548,6 @@ "message": "Untuk $1 suatu transaksi, biaya gas harus dinaikkan minimal 10% agar dapat dikenali oleh jaringan.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Batalkan swap untuk ~$", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Batalkan swap gratis" - }, "cancelled": { "message": "Dibatalkan" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Gunakan API OpenSea untuk mengambil data NFT. Deteksi otomatis NFT bergantung pada API OpenSea, dan tidak akan tersedia saat API ditutup." }, - "enableSmartTransactions": { - "message": "Aktifkan transaksi pintar" - }, "enableToken": { "message": "aktifkan $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Saya memahami bahwa sampai saya mencadangkan Frasa Pemulihan Rahasia, saya dapat kehilangan akun saya dan semua aset yang ada." }, - "smartTransaction": { - "message": "Transaksi pintar" - }, "snapContent": { "message": "Konten ini berasal dari $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Kuat" }, - "stxAreHere": { - "message": "Transaksi Pintar hadir di sini!" - }, "stxBenefit1": { "message": "Meminimalkan biaya transaksi" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Cobalah untuk menukar lagi. Kami akan selalu hadir untuk melindungi Anda dari risiko serupa di lain waktu." }, - "stxDescription": { - "message": "Pertukaran MetaMask menjadi semakin pintar! Mengaktifkan Transaksi Pintar akan memungkinkan MetaMask mengoptimalkan Pertukaran Anda secara terprogram untuk membantu:" - }, - "stxErrorNotEnoughFunds": { - "message": "Dana tidak cukup untuk mengaktifkan transaksi pintar." - }, - "stxErrorUnavailable": { - "message": "Transaksi Pintar tidak tersedia untuk sementara waktu." - }, "stxFailure": { "message": "Pertukaran gagal" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Kirimkan Swap Anda secara publik..." }, - "stxSubDescription": { - "message": "* Transaksi Pintar akan mencoba mengirimkan transaksi Anda secara pribadi, beberapa kali. Jika semua upaya gagal, transaksi akan disiarkan secara publik untuk memastikan Pertukaran telah berhasil dilakukan." - }, "stxSuccess": { "message": "Pertukaran selesai!" }, diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index bf069ec0a..9d3c2fd5a 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -158,10 +158,6 @@ "addCustomToken": { "message": "Aggiungi token personalizzato" }, - "addCustomTokenByContractAddress": { - "message": "Non trovi un token? Puoi aggiungere qualsiasi token incollando il suo indirizzo. L'indirizzo del contratto del Token può essere trovato su $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Ciò consentirà a questa rete di essere utilizzata all'interno di MetaMask." }, @@ -204,6 +200,10 @@ "addToken": { "message": "Aggiungi Token" }, + "addTokenByContractAddress": { + "message": "Non trovi un token? Puoi aggiungere qualsiasi token incollando il suo indirizzo. L'indirizzo del contratto del Token può essere trovato su $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Indirizzo" }, @@ -419,13 +419,6 @@ "message": "Per $1 una transazione la commissione di gas deve crescere almeno del 10% per essere riconosciuto dalla rete.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Annulla scambio per ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Annulla scambio gratuitamente" - }, "cancelled": { "message": "Annullata" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 0830623e7..611955387 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "カスタムトークンを追加" }, - "addCustomTokenByContractAddress": { - "message": "トークンが見つからない場合、アドレスをペーストして手動でトークンを追加できます。トークンコントラクトアドレスは$1にあります。", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "これにより、このネットワークはMetaMask内で使用できるようになります。" }, @@ -263,6 +259,10 @@ "addToken": { "message": "トークンを追加" }, + "addTokenByContractAddress": { + "message": "トークンが見つからない場合、アドレスをペーストして手動でトークンを追加できます。トークンコントラクトアドレスは$1にあります", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "アドレス" }, @@ -548,13 +548,6 @@ "message": "トランザクションを$1するには、ネットワークに認識されるようにガス代を 10% 以上増額する必要があります。", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "$1 以下でスワップをキャンセル", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "無料でスワップをキャンセル" - }, "cancelled": { "message": "キャンセル済み" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "OpenSea APIを使用してNFTデータを取得します。NFT自動検出はOpenSea APIを使用するため、この設定をオフにすると利用できなくなります。" }, - "enableSmartTransactions": { - "message": "スマートトランザクションを有効にする" - }, "enableToken": { "message": "$1を有効にする", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "私は、シークレットリカバリーフレーズをバックアップするまで、アカウントとそのアセットのすべてを失う可能性があることを理解しています。" }, - "smartTransaction": { - "message": "スマートトランザクション" - }, "snapContent": { "message": "このコンテンツは $1 からのものです", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "強" }, - "stxAreHere": { - "message": "スマートトランザクションが利用可能になりました!" - }, "stxBenefit1": { "message": "トランザクションコストを最小化" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "もう一度スワップをお試しください。次回は同様のリスクを避けられるようサポートします。" }, - "stxDescription": { - "message": "MetaMask Swaps がはるかに賢くなりました!スマートトランザクションを有効にすると、MetaMask がプログラムに従ってスワップを最適化できるようになるため、以下のようなメリットがあります。" - }, - "stxErrorNotEnoughFunds": { - "message": "スマートトランザクションに十分な資金がありません。" - }, - "stxErrorUnavailable": { - "message": "スマートトランザクションは一時的に利用できません。" - }, "stxFailure": { "message": "スワップに失敗しました" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "スワップを公開で送信中..." }, - "stxSubDescription": { - "message": "* スマートトランザクションは、非公開でトランザクションのの送信を数回試みます。すべての試みが失敗した場合、スワップが成功するようトランザクションが公開されます。" - }, "stxSuccess": { "message": "スワップ完了!" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index 3cf3cb19c..ab2ebe88b 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "커스텀 토큰 추가" }, - "addCustomTokenByContractAddress": { - "message": "이 토큰을 찾을 수 없으신가요? 토큰 주소를 붙여넣으면 토큰을 직접 추가할 수 있습니다. 토큰의 계약 주소는 $1에서 찾을 수 있습니다.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "이렇게 하면 MetaMask 내에서 이 네트워크를 사용할 수 있습니다." }, @@ -263,6 +259,10 @@ "addToken": { "message": "토큰 추가" }, + "addTokenByContractAddress": { + "message": "이 토큰을 찾을 수 없으신가요? 토큰 주소를 붙여넣으면 토큰을 직접 추가할 수 있습니다. 토큰의 계약 주소는 $1에서 찾을 수 있습니다", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "주소" }, @@ -548,13 +548,6 @@ "message": "거래를 $1하려면 가스비를 최소 10%를 인상해야 네트워크에서 인식될 수 있습니다.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "~$1 비용으로 스왑 취소", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "무료로 스왑 취소" - }, "cancelled": { "message": "취소됨" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "OpenSea의 API를 사용하여 NFT 데이터를 가져옵니다. NFT 자동 감지는 OpenSea의 API에 의존하며 이 API가 꺼져 있으면 사용할 수 없습니다." }, - "enableSmartTransactions": { - "message": "스마트 트랜잭션 활성화" - }, "enableToken": { "message": "$1 활성화", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "본인은 본인의 비밀 복구 구문을 백업하지 않는 한 본인의 계정과 모든 자산을 잃을 수 있다는 사실을 이해합니다." }, - "smartTransaction": { - "message": "스마트 트랜잭션" - }, "snapContent": { "message": "콘텐츠 출처: $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "강함" }, - "stxAreHere": { - "message": "스마트 거래가 가능합니다!" - }, "stxBenefit1": { "message": "거래 비용 최소화하기" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "스왑을 다시 진행하세요. 다음에도 유사한 위험이 발생한다면 보호해 드리겠습니다." }, - "stxDescription": { - "message": "MetaMask 스왑이 더욱 스마트해졌습니다! 스마트 거래를 활성화하면 MetaMask가 프로그램을 통해 스왑을 최적화하여 다음을 도울 수 있습니다." - }, - "stxErrorNotEnoughFunds": { - "message": "스마트 거래 자금 부족" - }, - "stxErrorUnavailable": { - "message": "스마트 거래를 잠시 사용할 수 없습니다." - }, "stxFailure": { "message": "스왑 실패" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "스왑을 공개로 제출하는 중..." }, - "stxSubDescription": { - "message": "*스마트 거래는 비공개로 거래를 제출하기 위해 여러 번 시도할 것입니다. 모든 시도가 실패하면 성공적인 스왑을 위해 거래는 공개적으로 브로드캐스트될 것입니다." - }, "stxSuccess": { "message": "스왑 완료!" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index a9787f21c..0aa23709a 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -42,10 +42,6 @@ "addContact": { "message": "Magdagdag ng contact" }, - "addCustomTokenByContractAddress": { - "message": "Walang makitang token? Puwede kang manual na magdagdag ng anumang token sa pamamagitan ng pag-paste ng address nito. Makikita ang mga address ng kontrata ng token sa $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Bibigyang-daan nito na magamit ang network na ito sa MetaMask." }, @@ -75,6 +71,10 @@ "addToken": { "message": "Magdagdag ng Token" }, + "addTokenByContractAddress": { + "message": "Walang makitang token? Puwede kang manual na magdagdag ng anumang token sa pamamagitan ng pag-paste ng address nito. Makikita ang mga address ng kontrata ng token sa $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "advanced": { "message": "Advanced" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 3daf84053..6289de75b 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Adicionar token personalizado" }, - "addCustomTokenByContractAddress": { - "message": "Não consegue encontrar um token? Cole o endereço para adicionar manualmente qualquer token. Os endereços de contrato do token se encontram em $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Isso permitirá que essa rede seja usada dentro da MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Adicionar token" }, + "addTokenByContractAddress": { + "message": "Não consegue encontrar um token? Cole o endereço para adicionar manualmente qualquer token. Os endereços de contrato do token se encontram em $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Endereço" }, @@ -548,13 +548,6 @@ "message": "Para $1 uma transação, a taxa de gás deve ser aumentada em pelo menos 10% para que seja reconhecida pela rede.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Cancelar swap por ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Cancelar swap gratuitamente" - }, "cancelled": { "message": "Cancelada" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Use a API OpenSea para recuperar dados de NFTs. A detecção automática de NFTs depende da API OpenSea e não estará disponível quando essa opção estiver desativada." }, - "enableSmartTransactions": { - "message": "Ativar transações inteligentes" - }, "enableToken": { "message": "ativar $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Compreendo que, até fazer o backup da minha Frase de Recuperação Secreta, poderei perder minhas contas e todos os ativos contidos nela." }, - "smartTransaction": { - "message": "Transação inteligente" - }, "snapContent": { "message": "Esse conteúdo vem de $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Forte" }, - "stxAreHere": { - "message": "As transações inteligentes chegaram!" - }, "stxBenefit1": { "message": "Minimize os custos das transações" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Tente fazer sua swap novamente. Estaremos aqui para te proteger contra riscos semelhantes no futuro." }, - "stxDescription": { - "message": "As swaps na MetaMask ficaram muito mais inteligentes! A ativação de Transações Inteligentes permite que o MetaMask otimize programaticamente suas swaps para evitar:" - }, - "stxErrorNotEnoughFunds": { - "message": "Insuficiência de fundos para fazer uma transação inteligente." - }, - "stxErrorUnavailable": { - "message": "Indisponibilidade temporária de Transações Inteligentes." - }, "stxFailure": { "message": "Falha na troca" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Enviando seu swap de forma pública..." }, - "stxSubDescription": { - "message": "* A função de Transações Inteligentes tentará enviar a sua transação várias vezes de forma privada. Se todas as tentativas falharem, a transação será transmitida publicamente para garantir que sua Swap seja realizada com sucesso." - }, "stxSuccess": { "message": "Swap concluído!" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index 33f3e50e8..f85a1ccd2 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -103,10 +103,6 @@ "addCustomToken": { "message": "Adicionar token personalizado" }, - "addCustomTokenByContractAddress": { - "message": "Não consegue encontrar um token? Cole o endereço para adicionar manualmente qualquer token. Os endereços de contrato do token se encontram em $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Isso permitirá que essa rede seja usada dentro da MetaMask." }, @@ -139,6 +135,10 @@ "addToken": { "message": "Adicionar token" }, + "addTokenByContractAddress": { + "message": "Não consegue encontrar um token? Cole o endereço para adicionar manualmente qualquer token. Os endereços de contrato do token se encontram em $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Endereço" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 5a77736dd..98143fa7a 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Добавить пользовательский токен" }, - "addCustomTokenByContractAddress": { - "message": "Не можете найти токен? Можно вручную добавить любой токен, вставив его адрес. Адреса контракта токена можно найти на $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Это позволит использовать эту сеть в MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Добавить токен" }, + "addTokenByContractAddress": { + "message": "Не можете найти токен? Можно вручную добавить любой токен, вставив его адрес. Адреса контракта токена можно найти на $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Адрес" }, @@ -548,13 +548,6 @@ "message": "Чтобы $1 транзакции плата за газ должна быть увеличена как минимум на 10%. Это позволит обеспечить прием транзакции сетью.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Отменить обмен на ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Отменить обмен бесплатно" - }, "cancelled": { "message": "Отменено" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Используйте API OpenSea для получения данных NFT. Для автоматического обнаружения NFT используется API OpenSea, и такое обнаружение будет недоступно, если этот API отключен." }, - "enableSmartTransactions": { - "message": "Включить смарт-транзакции" - }, "enableToken": { "message": "активирует для $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Я понимаю, что, если я не создам резервную копию своей секретной фразы для восстановления, я могу потерять доступ ко всем своим счетам и всем средствам на них." }, - "smartTransaction": { - "message": "Смарт-транзакция" - }, "snapContent": { "message": "Этот контент поступает от $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Сильный" }, - "stxAreHere": { - "message": "Появились смарт-транзакции!" - }, "stxBenefit1": { "message": "Минимизируйте транзакционные издержки" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Попробуйте обменять еще раз. Мы готовы защитить вас от подобных рисков в следующий раз." }, - "stxDescription": { - "message": "Функция обмена в MetaMask стала намного умнее! Включение смарт-транзакций позволит MetaMask программно оптимизировать ваш обмен, чтобы помочь:" - }, - "stxErrorNotEnoughFunds": { - "message": "Недостаточно средств для смарт-транзакции." - }, - "stxErrorUnavailable": { - "message": "Смарт-транзакции временно недоступны." - }, "stxFailure": { "message": "Обмен не удался" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Публичная отправка вашей операции обмена..." }, - "stxSubDescription": { - "message": "* Смарт-транзакции попытаются несколько раз отправить вашу транзакцию в конфиденциальном порядке. Если все попытки не увенчаются успехом, транзакция будет показана публично, чтобы гарантировать успешное завершение вашего обмена." - }, "stxSuccess": { "message": "Обмен завершен!" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index a7fdb7005..df0b61a19 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Magdagdag ng Custom na Token" }, - "addCustomTokenByContractAddress": { - "message": "Hindi makahanap ng token? Maaari kang manu-manong magdagdag ng anumang token sa pamamagitan ng pag-paste ng address nito. Ang mga address ng token contract ay matatagpuan sa $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Magpapahintulot ito sa network na ito na gamitin sa loob ng MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Magdagdag ng Token" }, + "addTokenByContractAddress": { + "message": "Hindi makahanap ng token? Maaari kang manu-manong magdagdag ng anumang token sa pamamagitan ng pag-paste ng address nito. Ang mga address ng token contract ay matatagpuan sa $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Address" }, @@ -548,13 +548,6 @@ "message": "Sa $1 na transaksyon ang singil sa gas ay dapat tumaas nang hindi bababa sa 10% para ito ay makilala ng network.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Kanselahin ang swap sa halagang ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Kanselahin ang swap nang libre" - }, "cancelled": { "message": "Nakansela" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Gamitin ang API ng Opensea upang kunin ang NFT data. ang NFT auto-detection ay umaasa sa API ng OpenSea, at hindi magiging available kapag ito ay isinara." }, - "enableSmartTransactions": { - "message": "Payagan ang mga smart transaction" - }, "enableToken": { "message": "paganahin ang $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Nauunawaan ko na hanggang sa i-back up ko ang aking Secret Recovery Phrase, maaari kong maiwala ang aking mga account at lahat ng kanilang mga asset." }, - "smartTransaction": { - "message": "Smart Transaction" - }, "snapContent": { "message": "Ang nilalamang ito ay nagmumula sa $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Mahirap" }, - "stxAreHere": { - "message": "Narito na ang mga Smart Transaction!" - }, "stxBenefit1": { "message": "Bawasan ang mga gastos sa transaksyon" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Subukan muli ang iyong pagpapalit. Narito kami para protektahan ka sa mga katulad na panganib sa susunod." }, - "stxDescription": { - "message": "Mas humusay pa ang mga Pagpapalit sa MetaMask! Papayagan ng Pagpapagana sa mga Smart Transaction ang MetaMask na pahusayin ang iyong Pagpapalit gamit ang program para makatulong sa: " - }, - "stxErrorNotEnoughFunds": { - "message": "Hindi sapat na pondo para sa smart transaction." - }, - "stxErrorUnavailable": { - "message": "Pansamantalang hindi available ang mga Smart Transaction." - }, "stxFailure": { "message": "Hindi matagumpay ang pag-swap" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Pampublikong isinusumite ang iyong Swap..." }, - "stxSubDescription": { - "message": "* Susubukan ng mga Smart Transaction na isumite nang pribado ang iyong transaksyon, maraming beses. Kapag nabigo ang lahat ng pagsubok, ipapakita sa publiko ang transaksyon upang matiyak na ang Pagpapalit ay naging matagupay." - }, "stxSuccess": { "message": "Nakumpleto ang pagpapalit!" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 1c30a8383..d629b046c 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Özel token ekle" }, - "addCustomTokenByContractAddress": { - "message": "Bir tokeni bulamadınız mı? Adresini yapıştırarak dilediğiniz tokeni manuel olarak ekleyebilirsiniz. Token sözleşme adreslerini $1 alanında bulabilirsiniz.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Bu, bu ağın MetaMas dahilinde kullanılmasına olanak tanıyacaktır." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Token ekle" }, + "addTokenByContractAddress": { + "message": "Bir tokeni bulamadınız mı? Adresini yapıştırarak dilediğiniz tokeni manuel olarak ekleyebilirsiniz. Token sözleşme adreslerini $1 alanında bulabilirsiniz", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Adres" }, @@ -548,13 +548,6 @@ "message": "İşlemi $1 için, gaz ücretinin ağ tarafından tanınması amacıyla en az %10 oranında artırılması gerekir.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "~$1 için swap işlemini iptal edin", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Swap işlemini ücretsiz iptal edin" - }, "cancelled": { "message": "İptal edildi" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "NFT verilerini almak için OpenSea API'sini kullanın. NFT otomatik algılama OpenSea API'ye dayalıdır ve bu kapatılırsa mevcut olmayacaktır." }, - "enableSmartTransactions": { - "message": "Akıllı işlemleri etkinleştir" - }, "enableToken": { "message": "şunu etkinleştir: $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Gizli Kurtarma İfademi yedekleyene kadar hesaplarımı ve tüm varlıkları kaybedebileceğimi anlıyorum." }, - "smartTransaction": { - "message": "Akıllı işlem" - }, "snapContent": { "message": "Bu içerik $1 kaynaklıdır", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Güçlü" }, - "stxAreHere": { - "message": "Akıllı İşlemler burada!" - }, "stxBenefit1": { "message": "İşlem maliyetlerini en aza indir" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Takasını tekrar dene. Bir dahaki sefere seni benzer risklere karşı korumak için burada olacağız." }, - "stxDescription": { - "message": "MetaMask Swapları artık çok daha akıllı! Akıllı İşlemleri, MetaMask'in renklerine yardımcı olmak için Swap'ını programlı olarak optimize etme bölümünden yararlanmak:" - }, - "stxErrorNotEnoughFunds": { - "message": "Akıllı işlem için yeterli para yok." - }, - "stxErrorUnavailable": { - "message": "Akıllı İşlemler geçici olarak kullanılamıyor." - }, "stxFailure": { "message": "Takas başarısız oldu" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Swap işlemin herkese açık olarak gönderiliyor..." }, - "stxSubDescription": { - "message": "* Akıllı İşlemler, işlemini birden çok kez özel olarak göndermeye çalışır. Tüm denemeler başarısız olursa Takas'ının başarılı bir şekilde gerçekleşmesini sağlamak için işlem herkese açık olarak yayınlanacaktır." - }, "stxSuccess": { "message": "Takas tamamlandı!" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index c5af50467..270272b6c 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "Thêm token tùy chỉnh" }, - "addCustomTokenByContractAddress": { - "message": "Bạn không tìm thấy token? Bạn có thể dán địa chỉ của bất kỳ token nào để thêm token đó theo cách thủ công. Bạn có thể tìm thấy địa chỉ hợp đồng token trên $1.", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "Thao tác này sẽ cho phép sử dụng mạng này trong MetaMask." }, @@ -263,6 +259,10 @@ "addToken": { "message": "Thêm token" }, + "addTokenByContractAddress": { + "message": "Bạn không tìm thấy token? Bạn có thể dán địa chỉ của bất kỳ token nào để thêm token đó theo cách thủ công. Bạn có thể tìm thấy địa chỉ hợp đồng token trên $1", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "Địa chỉ" }, @@ -548,13 +548,6 @@ "message": "Để $1 một giao dịch, phí gas phải tăng tối thiểu 10% để mạng nhận ra giao dịch này.", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "Hủy hoán đổi với giá ~$1", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "Hủy hoán đổi miễn phí" - }, "cancelled": { "message": "Đã hủy" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "Sử dụng API của OpenSea để tìm nạp dữ liệu NFT. Tính năng tự động phát hiện NFT dựa vào API của OpenSea và sẽ không khả dụng nếu tính năng này bị tắt." }, - "enableSmartTransactions": { - "message": "Bật giao dịch thông minh" - }, "enableToken": { "message": "bật $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "Tôi hiểu rằng nếu chưa sao lưu Cụm Mật Khẩu Khôi Phục Bí Mật của mình, tôi có thể bị mất tài khoản và toàn bộ tài sản bên trong." }, - "smartTransaction": { - "message": "Giao dịch thông minh" - }, "snapContent": { "message": "Nội dung này đến từ $1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "Mạnh" }, - "stxAreHere": { - "message": "Giao dịch thông minh đã ra mắt!" - }, "stxBenefit1": { "message": "Giảm thiểu chi phí giao dịch" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "Hãy thử hoán đổi lại. Chúng tôi ở đây để bảo vệ bạn trước những rủi ro tương tự trong lần tới." }, - "stxDescription": { - "message": "Tính năng Hoán đổi của MetaMask nay đã thông minh hơn rất nhiều! Kích hoạt Giao dịch thông minh sẽ cho phép MetaMask tối ưu quy trình Hoán đổi để giúp bạn:" - }, - "stxErrorNotEnoughFunds": { - "message": "Không có đủ tiền để thực hiện giao dịch thông minh." - }, - "stxErrorUnavailable": { - "message": "Giao dịch thông minh tạm thời không khả dụng." - }, "stxFailure": { "message": "Hoán đổi không thành công" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "Đang công khai gửi yêu cầu Hoán đổi của bạn..." }, - "stxSubDescription": { - "message": "* Giao dịch thông minh sẽ cố gắng gửi giao dịch của bạn nhiều lần theo cách riêng tư. Nếu tất cả các lần thử đều không thành công, giao dịch sẽ được phát công khai để đảm bảo Hoán đổi của bạn được thực hiện thành công." - }, "stxSuccess": { "message": "Hoán đổi hoàn tất!" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 94d2dcf83..192838fc4 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -195,10 +195,6 @@ "addCustomToken": { "message": "添加自定义代币" }, - "addCustomTokenByContractAddress": { - "message": "找不到代币?您可以通过粘贴其地址手动添加任何代币。代币合约地址可以在 $1 上找到。", - "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" - }, "addEthereumChainConfirmationDescription": { "message": "这将允许在 MetaMask 中使用此网络。" }, @@ -263,6 +259,10 @@ "addToken": { "message": "添加代币" }, + "addTokenByContractAddress": { + "message": "找不到代币?您可以通过粘贴其地址手动添加任何代币。代币合约地址可以在 $1 上找到", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "address": { "message": "地址" }, @@ -548,13 +548,6 @@ "message": "若要$1交易,燃料费用必须增加至少10%才能被网络认可。", "description": "$1 is string 'cancel' or 'speed up'" }, - "cancelSwapForFee": { - "message": "以~$1取消兑换", - "description": "$1 could be e.g. $2.98, it is a cost for cancelling a Smart Transaction" - }, - "cancelSwapForFree": { - "message": "免费取消兑换" - }, "cancelled": { "message": "已取消" }, @@ -1200,9 +1193,6 @@ "enableOpenSeaAPIDescription": { "message": "使用 OpenSea 的 API 获取 NFT 数据。NFT 自动检测依赖于 OpenSea 的 API,在后者关闭时自动检测将不可用。" }, - "enableSmartTransactions": { - "message": "启用智能交易" - }, "enableToken": { "message": "启用 $1", "description": "$1 is a token symbol, e.g. ETH" @@ -3239,9 +3229,6 @@ "skipAccountSecurityDetails": { "message": "我明白,在我备份我的账户助记词之前,我可能会丢失我的账户及其所有资产。" }, - "smartTransaction": { - "message": "智能交易" - }, "snapContent": { "message": "此内容来自$1", "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." @@ -3425,9 +3412,6 @@ "strong": { "message": "强" }, - "stxAreHere": { - "message": "智能交易已推出!" - }, "stxBenefit1": { "message": "将交易成本减至最低" }, @@ -3449,15 +3433,6 @@ "stxCancelledSubDescription": { "message": "再次尝试进行交换。下次我们会在这里保护您免受类似风险。 " }, - "stxDescription": { - "message": "MetaMask Swap变得更加智能!启用智能交易将允许MetaMask从编程上优化您的交换,以帮助:" - }, - "stxErrorNotEnoughFunds": { - "message": "没有足够的资金进行智能交易。" - }, - "stxErrorUnavailable": { - "message": "智能交易暂时不可用。" - }, "stxFailure": { "message": "交换失败" }, @@ -3471,9 +3446,6 @@ "stxPendingPubliclySubmittingSwap": { "message": "正在公开提交您的Swap..." }, - "stxSubDescription": { - "message": "*智能交易将尝试多次隐秘提交您的交易。如果所有尝试都失败,交易将会公开广播,以确保您的交换能成功进行。" - }, "stxSuccess": { "message": "交换完成!" }, diff --git a/app/images/swaps-redesign.svg b/app/images/swaps-redesign.svg new file mode 100644 index 000000000..08bf92a87 --- /dev/null +++ b/app/images/swaps-redesign.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js index b44e66a21..505b696d7 100644 --- a/app/scripts/controllers/swaps.js +++ b/app/scripts/controllers/swaps.js @@ -104,23 +104,34 @@ const initialState = { }; export default class SwapsController { - constructor({ - getBufferedGasLimit, - networkController, - provider, - getProviderConfig, - getTokenRatesState, - fetchTradesInfo = defaultFetchTradesInfo, - getCurrentChainId, - getEIP1559GasFeeEstimates, - onNetworkStateChange, - }) { + constructor( + { + getBufferedGasLimit, + networkController, + provider, + getProviderConfig, + getTokenRatesState, + fetchTradesInfo = defaultFetchTradesInfo, + getCurrentChainId, + getEIP1559GasFeeEstimates, + onNetworkStateChange, + }, + state, + ) { this.store = new ObservableStore({ - swapsState: { ...initialState.swapsState }, + swapsState: { + ...initialState.swapsState, + swapsFeatureFlags: state?.swapsState?.swapsFeatureFlags || {}, + }, }); this.resetState = () => { - this.store.updateState({ swapsState: { ...initialState.swapsState } }); + this.store.updateState({ + swapsState: { + ...initialState.swapsState, + swapsFeatureFlags: state?.swapsState?.swapsFeatureFlags, + }, + }); }; this._fetchTradesInfo = fetchTradesInfo; @@ -662,6 +673,7 @@ export default class SwapsController { swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuotePrefetchingRefreshTime: swapsState.swapsQuotePrefetchingRefreshTime, + swapsFeatureFlags: swapsState.swapsFeatureFlags, }, }); clearTimeout(this.pollingTimeout); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index eb1464f3f..0af0d6c7d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1282,22 +1282,28 @@ export default class MetamaskController extends EventEmitter { }, ); - this.swapsController = new SwapsController({ - getBufferedGasLimit: this.txController.txGasUtil.getBufferedGasLimit.bind( - this.txController.txGasUtil, - ), - networkController: this.networkController, - onNetworkStateChange: (listener) => - this.networkController.store.subscribe(listener), - provider: this.provider, - getProviderConfig: () => - this.networkController.store.getState().providerConfig, - getTokenRatesState: () => this.tokenRatesController.state, - getCurrentChainId: () => - this.networkController.store.getState().providerConfig.chainId, - getEIP1559GasFeeEstimates: - this.gasFeeController.fetchGasFeeEstimates.bind(this.gasFeeController), - }); + this.swapsController = new SwapsController( + { + getBufferedGasLimit: + this.txController.txGasUtil.getBufferedGasLimit.bind( + this.txController.txGasUtil, + ), + networkController: this.networkController, + onNetworkStateChange: (listener) => + this.networkController.store.subscribe(listener), + provider: this.provider, + getProviderConfig: () => + this.networkController.store.getState().providerConfig, + getTokenRatesState: () => this.tokenRatesController.state, + getCurrentChainId: () => + this.networkController.store.getState().providerConfig.chainId, + getEIP1559GasFeeEstimates: + this.gasFeeController.fetchGasFeeEstimates.bind( + this.gasFeeController, + ), + }, + initState.SwapsController, + ); this.smartTransactionsController = new SmartTransactionsController( { onNetworkStateChange: (cb) => { diff --git a/shared/constants/swaps.ts b/shared/constants/swaps.ts index b1686be9b..093ef470b 100644 --- a/shared/constants/swaps.ts +++ b/shared/constants/swaps.ts @@ -15,6 +15,10 @@ export const QUOTES_NOT_AVAILABLE_ERROR = 'quotes-not-avilable'; export const CONTRACT_DATA_DISABLED_ERROR = 'contract-data-disabled'; export const OFFLINE_FOR_MAINTENANCE = 'offline-for-maintenance'; export const SWAPS_FETCH_ORDER_CONFLICT = 'swaps-fetch-order-conflict'; +export const SLIPPAGE_OVER_LIMIT_ERROR = 'slippage-over-limit'; +export const SLIPPAGE_VERY_HIGH_ERROR = 'slippage-very-high'; +export const SLIPPAGE_TOO_LOW_ERROR = 'slippage-too-low'; +export const SLIPPAGE_NEGATIVE_ERROR = 'slippage-negative'; // An address that the metaswap-api recognizes as the default token for the current network, // in place of the token address that ERC-20 tokens have diff --git a/shared/notifications/index.js b/shared/notifications/index.js index 9bf285957..a5a689f3a 100644 --- a/shared/notifications/index.js +++ b/shared/notifications/index.js @@ -106,6 +106,14 @@ export const UI_NOTIFICATIONS = { id: 20, date: null, }, + 21: { + id: 21, + date: null, + image: { + src: 'images/swaps-redesign.svg', + width: '100%', + }, + }, }; export const getTranslatedUINotifications = (t, locale) => { @@ -294,5 +302,16 @@ export const getTranslatedUINotifications = (t, locale) => { ) : '', }, + 21: { + ...UI_NOTIFICATIONS[21], + title: t('notifications21Title'), + description: t('notifications21Description'), + actionText: t('notifications21ActionText'), + date: UI_NOTIFICATIONS[21].date + ? new Intl.DateTimeFormat(formattedLocale).format( + new Date(UI_NOTIFICATIONS[21].date), + ) + : '', + }, }; }; diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 28609f2f1..80e25d6cf 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -136,6 +136,11 @@ function defaultFixture() { id: 19, isShown: true, }, + 21: { + date: null, + id: 21, + isShown: true, + }, }, }, AppStateController: { diff --git a/test/e2e/swaps/shared.js b/test/e2e/swaps/shared.js index ef7eb8278..12fbe4ccb 100644 --- a/test/e2e/swaps/shared.js +++ b/test/e2e/swaps/shared.js @@ -24,24 +24,25 @@ const loadExtension = async (driver) => { }; const buildQuote = async (driver, options) => { - await driver.clickElement( - '.wallet-overview__buttons .icon-button:nth-child(3)', - ); - await driver.fill('input[placeholder*="0"]', options.amount); - await driver.delay(veryLargeDelayMs); // Need an extra delay after typing an amount. - await driver.waitForSelector( - '[class="dropdown-input-pair dropdown-input-pair__to"]', - ); - await driver.clickElement('.dropdown-input-pair__to'); + await driver.clickElement('[data-testid="token-overview-button-swap"]'); await driver.fill( - 'input[data-testid="search-list-items"]', + 'input[data-testid="prepare-swap-page-from-token-amount"]', + options.amount, + ); + await driver.delay(veryLargeDelayMs); // Need an extra delay after typing an amount. + await driver.clickElement('[data-testid="prepare-swap-page-swap-to"]'); + await driver.waitForSelector('[id="list-with-search__text-search"]'); + + await driver.fill( + 'input[id="list-with-search__text-search"]', options.swapTo || options.swapToContractAddress, ); + await driver.delay(veryLargeDelayMs); // Need an extra delay after typing an amount. if (options.swapTo) { await driver.wait(async () => { const tokenNames = await driver.findElements( - '.searchable-item-list__primary-label', + '[data-testid="searchable-item-list-primary-label"]', ); if (tokenNames.length === 0) { return false; @@ -51,118 +52,140 @@ const buildQuote = async (driver, options) => { }); } if (options.swapToContractAddress) { - await driver.waitForSelector({ - css: '.searchable-item-list__item button.btn-primary', - text: 'Import', - }); + await driver.waitForSelector( + '[data-testid="searchable-item-list-import-button"]', + ); } - await driver.clickElement('.searchable-item-list__primary-label'); + await driver.clickElement( + '[data-testid="searchable-item-list-primary-label"]', + ); }; const reviewQuote = async (driver, options) => { - await driver.clickElement({ text: 'Review swap', tag: 'button' }); - await driver.waitForSelector({ text: 'Swap', tag: 'button' }); - await driver.waitForSelector({ - css: '[class*="box--align-items-center"]', - text: 'Estimated gas fee', - }); - const sourceValue = await driver.waitForSelector( - '.main-quote-summary__source-row-value', + const summary = await driver.waitForSelector( + '[data-testid="exchange-rate-display-quote-rate"]', ); + const summaryText = await summary.getText(); + assert.equal(summaryText.includes(options.swapFrom), true); + assert.equal(summaryText.includes(options.swapTo), true); + const quote = summaryText.split(`\n`); + + const elementSwapToAmount = await driver.findElement( + '[data-testid="prepare-swap-page-receive-amount"]', + ); + const swapToAmount = await elementSwapToAmount.getText(); + const expectedAmount = parseFloat(quote[3]) * options.amount; + const dotIndex = swapToAmount.indexOf('.'); + const decimals = dotIndex === -1 ? 0 : swapToAmount.length - dotIndex - 1; assert.equal( - await sourceValue.getText(), - options.amount, - 'Error: Quote has wrong amount', + swapToAmount, + expectedAmount.toFixed(decimals), + `Expecting ${expectedAmount.toFixed( + decimals, + )} but got ${swapToAmount} instead`, ); - const sourceSymbol = await driver.waitForSelector( - '.main-quote-summary__source-row-symbol', - ); - assert.equal( - await sourceSymbol.getText(), - options.swapFrom, - 'Error: SwapFrom has wrong symbol', - ); - const swapToSymbol = await driver.waitForSelector( - '.main-quote-summary__destination-row > span', - ); - assert.equal( - await swapToSymbol.getText(), - options.swapTo, - 'Error: SwapTo has wrong symbol', - ); - await driver.waitForSelector( - '[class="exchange-rate-display main-quote-summary__exchange-rate-display"]', - ); - await driver.waitForSelector('[class="fee-card__info-tooltip-container"]'); - await driver.waitForSelector({ - css: '[class="countdown-timer__time"]', - text: '0:23', - }); + + await driver.findElement('[data-testid="review-quote-gas-fee-in-fiat"]'); + + await driver.findElement('[data-testid="info-tooltip"]'); + + if (!options.skipCounter) { + await driver.waitForSelector({ + css: '[data-testid="countdown-timer__timer-container"]', + text: '0:25', + }); + } }; -const waitForTransactionToComplete = async (driver, tokenName) => { - const sucessfulTransactionMessage = await driver.waitForSelector( +const waitForTransactionToComplete = async (driver, options) => { + await driver.waitForSelector({ + css: '[data-testid="awaiting-swap-header"]', + text: 'Processing', + }); + + await driver.waitForSelector( { - css: '[class="awaiting-swap__header"]', + css: '[data-testid="awaiting-swap-header"]', text: 'Transaction complete', }, { timeout: 30000 }, ); - assert.equal( - await sucessfulTransactionMessage.getText(), - 'Transaction complete', - 'Incorrect transaction message', - ); - const sucessfulTransactionToken = await driver.waitForSelector({ - css: '[class="awaiting-swap__amount-and-symbol"]', - text: tokenName, + + await driver.findElement({ + css: '[data-testid="awaiting-swap-main-description"]', + text: `${options.tokenName}`, }); - assert.equal( - await sucessfulTransactionToken.getText(), - tokenName, - 'Incorrect token name', - ); + await driver.clickElement({ text: 'Close', tag: 'button' }); await driver.waitForSelector('[data-testid="home__asset-tab"]'); }; const checkActivityTransaction = async (driver, options) => { await driver.clickElement('[data-testid="home__activity-tab"]'); - const itemsText = await driver.findElements('.list-item__title'); + await driver.waitForSelector('[data-testid="list-item-title"]'); + + const transactionList = await driver.findElements( + '[data-testid="list-item-title"]', + ); + const transactionText = await transactionList[options.index].getText(); assert.equal( - await itemsText[options.index].getText(), + transactionText, `Swap ${options.swapFrom} to ${options.swapTo}`, - 'Title is incorrect', + 'Transaction not found', ); - const amountValues = await driver.findElements( - '.transaction-list-item__primary-currency', - ); - assert.equal( - await amountValues[options.index].getText(), - `-${options.amount} ${options.swapFrom}`, - 'Transaction amount is incorrect', - ); - await itemsText[options.index].click(); + + await driver.findElement({ + css: '[data-testid="list-item-right-content"]', + text: `-${options.amount} ${options.swapFrom}`, + }); + + await transactionList[options.index].click(); await driver.delay(regularDelayMs); - const txStatus = await driver.findElement( - '.transaction-list-item-details__tx-status >div > div:last-child', - ); - assert.equal( - await txStatus.getText(), - `Confirmed`, - `Transaction status is not 'Confirmed'`, - ); - const txAmount = await driver.findElement( - '.transaction-breakdown__value--amount', - ); - assert.equal( - await txAmount.getText(), - `-${options.amount} ${options.swapFrom}`, - 'Transaction breakdown is incorrect', - ); + + await driver.findElement({ + css: '[data-testid="transaction-list-item-details-tx-status"]', + text: `Confirmed`, + }); + + await driver.findElement({ + css: '[data-testid="transaction-breakdown-value-amount"]', + text: `-${options.amount} ${options.swapFrom}`, + }); + await driver.clickElement('[data-testid="popover-close"]'); }; +const checkNotification = async (driver, options) => { + const boxTitle = await driver.findElement( + '[data-testid="mm-banner-base-title"]', + ); + assert.equal(await boxTitle.getText(), options.title, 'Invalid box title'); + const boxContent = await driver.findElement( + '[data-testid="mm-banner-alert-notification-text"]', + ); + const bodyText = await boxContent.getText(); + console.log(`test: ${bodyText}`); + assert.equal( + bodyText.includes(options.text), + true, + 'Invalid box text content', + ); +}; + +const changeExchangeRate = async (driver) => { + await driver.clickElement( + '[data-testid="exchange-rate-display-base-symbol"]', + ); + await driver.waitForSelector({ text: 'Quote details', tag: 'h2' }); + + const networkFees = await driver.findElements( + '[data-testid*="select-quote-popover-row"]', + ); + const random = Math.floor(Math.random() * networkFees.length); + await networkFees[random].click(); + await driver.clickElement({ text: 'Select', tag: 'button' }); +}; + module.exports = { withFixturesOptions, loadExtension, @@ -170,4 +193,6 @@ module.exports = { reviewQuote, waitForTransactionToComplete, checkActivityTransaction, + checkNotification, + changeExchangeRate, }; diff --git a/test/e2e/swaps/swap-eth.spec.js b/test/e2e/swaps/swap-eth.spec.js index 6506fa262..380ce2965 100644 --- a/test/e2e/swaps/swap-eth.spec.js +++ b/test/e2e/swaps/swap-eth.spec.js @@ -6,6 +6,7 @@ const { reviewQuote, waitForTransactionToComplete, checkActivityTransaction, + changeExchangeRate, } = require('./shared'); describe('Swap Eth for another Token', function () { @@ -25,7 +26,7 @@ describe('Swap Eth for another Token', function () { swapTo: 'USDC', }); await reviewQuote(driver, { - amount: '0.001', + amount: 0.001, swapFrom: 'TESTETH', swapTo: 'USDC', }); @@ -36,12 +37,12 @@ describe('Swap Eth for another Token', function () { swapTo: 'DAI', }); await reviewQuote(driver, { - amount: '0.003', + amount: 0.003, swapFrom: 'TESTETH', swapTo: 'DAI', }); await driver.clickElement({ text: 'Swap', tag: 'button' }); - await waitForTransactionToComplete(driver, 'DAI'); + await waitForTransactionToComplete(driver, { tokenName: 'DAI' }); await checkActivityTransaction(driver, { index: 0, amount: '0.003', @@ -57,7 +58,7 @@ describe('Swap Eth for another Token', function () { }, ); }); - it('Completes a Swap between Eth and Dai', async function () { + it('Completes a Swap between ETH and DAI after changing initial rate', async function () { await withFixtures( { ...withFixturesOptions, @@ -70,12 +71,19 @@ describe('Swap Eth for another Token', function () { swapTo: 'DAI', }); await reviewQuote(driver, { - amount: '2', + amount: 2, swapFrom: 'TESTETH', swapTo: 'DAI', }); + await changeExchangeRate(driver); + await reviewQuote(driver, { + amount: 2, + swapFrom: 'TESTETH', + swapTo: 'DAI', + skipCounter: true, + }); await driver.clickElement({ text: 'Swap', tag: 'button' }); - await waitForTransactionToComplete(driver, 'DAI'); + await waitForTransactionToComplete(driver, { tokenName: 'DAI' }); await checkActivityTransaction(driver, { index: 0, amount: '2', diff --git a/test/e2e/swaps/swaps-notifications.spec.js b/test/e2e/swaps/swaps-notifications.spec.js index 02bfe43dd..4d24bf5fe 100644 --- a/test/e2e/swaps/swaps-notifications.spec.js +++ b/test/e2e/swaps/swaps-notifications.spec.js @@ -1,8 +1,12 @@ const { strict: assert } = require('assert'); - const { withFixtures } = require('../helpers'); - -const { withFixturesOptions, loadExtension, buildQuote } = require('./shared'); +const { + withFixturesOptions, + loadExtension, + buildQuote, + reviewQuote, + checkNotification, +} = require('./shared'); describe('Swaps - notifications', function () { async function mockTradesApiPriceSlippageError(mockServer) { @@ -65,33 +69,34 @@ describe('Swaps - notifications', function () { amount: 2, swapTo: 'INUINU', }); - const reviewSwapButton = await driver.findElement( - '[data-testid="page-container-footer-next"]', - ); - assert.equal(await reviewSwapButton.getText(), 'Review swap'); - assert.equal(await reviewSwapButton.isEnabled(), false); - const continueButton = await driver.findClickableElement( - '.actionable-message__action-warning', - ); - assert.equal(await continueButton.getText(), 'Continue'); - await continueButton.click(); - assert.equal(await reviewSwapButton.isEnabled(), true); - await reviewSwapButton.click(); - await driver.waitForSelector({ - css: '[class*="box--align-items-center"]', - text: 'Estimated gas fee', + await checkNotification(driver, { + title: 'Potentially inauthentic token', + text: 'INUINU is only verified on 1 source. Consider verifying it on Etherscan before proceeding.', + }); + await driver.clickElement({ text: 'Continue swapping', tag: 'button' }); + await driver.waitForSelector({ + text: 'Swap', + tag: 'button', + }); + await checkNotification(driver, { + title: 'Check your rate before proceeding', + text: 'Price impact could not be determined due to lack of market price data.', + }); + await driver.clickElement({ text: 'Swap anyway', tag: 'button' }); + await reviewQuote(driver, { + amount: 2, + swapFrom: 'TESTETH', + swapTo: 'INUINU', + skipCounter: true, + }); + const swapButton = await driver.findElement({ + text: 'Swap', + tag: 'button', }); - const swapButton = await driver.findElement( - '[data-testid="page-container-footer-next"]', - ); - assert.equal(await swapButton.isEnabled(), false); - await driver.clickElement({ text: 'I understand', tag: 'button' }); - assert.equal(await swapButton.getText(), 'Swap'); assert.equal(await swapButton.isEnabled(), true); }, ); }); - it('tests a notification for not enough balance', async function () { await withFixtures( { @@ -104,30 +109,26 @@ describe('Swaps - notifications', function () { amount: 50, swapTo: 'USDC', }); - const reviewSwapButton = await driver.findElement( - '[data-testid="page-container-footer-next"]', - ); - assert.equal(await reviewSwapButton.getText(), 'Review swap'); - assert.equal(await reviewSwapButton.isEnabled(), true); - await reviewSwapButton.click(); - await driver.waitForSelector({ - css: '[class*="box--align-items-center"]', - text: 'Estimated gas fee', + await checkNotification(driver, { + title: 'Insufficient balance', + text: 'You need 50 more TESTETH to complete this swap', }); - await driver.waitForSelector({ - css: '[class*="actionable-message__message"]', - text: 'You need 43.4467 more TESTETH to complete this swap', + await reviewQuote(driver, { + swapFrom: 'TESTETH', + swapTo: 'USDC', + amount: 50, + skipCounter: true, + }); + const swapButton = await driver.waitForSelector({ + text: 'Swap', + tag: 'button', }); - const swapButton = await driver.findElement( - '[data-testid="page-container-footer-next"]', - ); assert.equal(await swapButton.getText(), 'Swap'); assert.equal(await swapButton.isEnabled(), false); }, ); }); - - it('tests notifications for verified token on 0 sources and high slippage', async function () { + it('tests notifications for token import', async function () { await withFixtures( { ...withFixturesOptions, @@ -139,39 +140,51 @@ describe('Swaps - notifications', function () { amount: 2, swapToContractAddress: '0x72c9Fb7ED19D3ce51cea5C56B3e023cd918baaDf', }); - await driver.waitForSelector({ - css: '.popover-header', - text: 'Import token?', - }); await driver.clickElement( '[data-testid="page-container__import-button"]', ); - const reviewSwapButton = await driver.findElement( - '[data-testid="page-container-footer-next"]', - ); - assert.equal(await reviewSwapButton.isEnabled(), false); - const continueButton = await driver.findClickableElement( - '.actionable-message__action-danger', - ); - assert.equal(await continueButton.getText(), 'Continue'); - await continueButton.click(); - assert.equal(await reviewSwapButton.isEnabled(), true); - await driver.clickElement('[class="slippage-buttons__header-text"]'); - await driver.clickElement({ text: 'custom', tag: 'button' }); - await driver.fill( - 'input[data-testid="slippage-buttons__custom-slippage"]', - '20', - ); - await driver.waitForSelector({ - css: '[class*="slippage-buttons__error-text"]', - text: 'Slippage amount is too high and will result in a bad rate. Please reduce your slippage tolerance to a value below 15%.', + await checkNotification(driver, { + title: 'Token added manually', + text: 'Verify this token on Etherscan and make sure it is the token you want to trade.', }); - assert.equal(await reviewSwapButton.isEnabled(), false); - await driver.fill( - 'input[data-testid="slippage-buttons__custom-slippage"]', - '4', - ); - assert.equal(await reviewSwapButton.isEnabled(), true); + }, + ); + }); + it('tests notifications for slippage', async function () { + await withFixtures( + { + ...withFixturesOptions, + title: this.test.title, + }, + async ({ driver }) => { + await loadExtension(driver); + await buildQuote(driver, { + amount: '.0001', + swapTo: 'DAI', + }); + await driver.clickElement('[title="Transaction settings"]'); + await driver.clickElement({ text: 'custom', tag: 'button' }); + await driver.fill('input[data-testid*="slippage"]', '0'); + await checkNotification(driver, { + title: 'Sourcing zero-slippage providers', + text: 'There are fewer zero-slippage quote providers which will result in a less competitive quote.', + }); + await driver.fill('input[data-testid*="slippage"]', '1'); + await checkNotification(driver, { + title: 'Increase slippage to avoid transaction failure', + text: 'Max slippage is too low which may cause your transaction to fail.', + }); + await driver.fill('input[data-testid*="slippage"]', '15'); + await checkNotification(driver, { + title: 'Very high slippage', + text: 'The slippage entered is considered very high and may result in a bad rate', + }); + await driver.fill('input[data-testid*="slippage"]', '20'); + await checkNotification(driver, { + title: 'Reduce slippage to continue', + text: 'Slippage tolerance must be 15% or less. Anything higher will result in a bad rate.', + }); + await driver.fill('input[data-testid*="slippage"]', '4'); }, ); }); diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 80934403b..ef05b7ba3 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -252,6 +252,7 @@ export const createSwapsMockStore = () => { }, }, selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + currentLocale: 'en', keyringTypes: [KeyringType.imported, KeyringType.hdKeyTree], keyrings: [ { @@ -288,6 +289,10 @@ export const createSwapsMockStore = () => { mobileActive: true, extensionActive: true, }, + swapRedesign: { + mobileActive: true, + extensionActive: true, + }, }, quotes: { TEST_AGG_1: { diff --git a/ui/components/app/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap b/ui/components/app/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap index a08846c4d..a993864ba 100644 --- a/ui/components/app/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap +++ b/ui/components/app/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap @@ -28,6 +28,7 @@ exports[`ConfirmGasDisplay should match snapshot 1`] = `
- + {primaryCurrency} 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 7393cd6f4..502f62d94 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 @@ -253,7 +253,10 @@ export default class TransactionListItemDetails extends PureComponent {
-
+
{t('status')}
diff --git a/ui/components/app/wallet-overview/eth-overview.js b/ui/components/app/wallet-overview/eth-overview.js index 613693187..2d026a78c 100644 --- a/ui/components/app/wallet-overview/eth-overview.js +++ b/ui/components/app/wallet-overview/eth-overview.js @@ -302,6 +302,7 @@ const EthOverview = ({ className }) => { } }} label={t('swap')} + data-testid="token-overview-button-swap" tooltipRender={ isSwapsChain ? null diff --git a/ui/components/app/whats-new-popup/whats-new-popup.js b/ui/components/app/whats-new-popup/whats-new-popup.js index 707a2d286..0b505eac3 100644 --- a/ui/components/app/whats-new-popup/whats-new-popup.js +++ b/ui/components/app/whats-new-popup/whats-new-popup.js @@ -15,6 +15,7 @@ import { getTranslatedUINotifications } from '../../../../shared/notifications'; import { getSortedAnnouncementsToShow } from '../../../selectors'; import { BUILD_QUOTE_ROUTE, + PREPARE_SWAP_ROUTE, ADVANCED_ROUTE, EXPERIMENTAL_ROUTE, SECURITY_ROUTE, @@ -85,6 +86,10 @@ function getActionFunctionById(id, history) { url: ZENDESK_URLS.LEDGER_FIREFOX_U2F_GUIDE, }); }, + 21: () => { + updateViewedNotifications({ 21: true }); + history.push(PREPARE_SWAP_ROUTE); + }, }; return actionFunctions[id]; @@ -318,7 +323,7 @@ export default function WhatsNewPopup({ onClose }) { const isLast = index === notifications.length - 1; // Display the swaps notification with full image // Displays the NFTs & OpenSea notifications 18,19 with full image - return index === 0 || id === 1 || id === 18 || id === 19 + return index === 0 || id === 1 || id === 18 || id === 19 || id === 21 ? renderFirstNotification( notification, idRefMap, diff --git a/ui/components/component-library/banner-alert/__snapshots__/banner-alert.test.js.snap b/ui/components/component-library/banner-alert/__snapshots__/banner-alert.test.js.snap index 0ab03644a..ce95af294 100644 --- a/ui/components/component-library/banner-alert/__snapshots__/banner-alert.test.js.snap +++ b/ui/components/component-library/banner-alert/__snapshots__/banner-alert.test.js.snap @@ -13,6 +13,7 @@ exports[`BannerAlert should render BannerAlert element correctly 1`] = `
BannerAlert test
diff --git a/ui/components/component-library/banner-base/__snapshots__/banner-base.test.js.snap b/ui/components/component-library/banner-base/__snapshots__/banner-base.test.js.snap index 12dd091d2..09de86c7f 100644 --- a/ui/components/component-library/banner-base/__snapshots__/banner-base.test.js.snap +++ b/ui/components/component-library/banner-base/__snapshots__/banner-base.test.js.snap @@ -9,6 +9,7 @@ exports[`BannerBase should render bannerbase element correctly 1`] = `
Bannerbase test
diff --git a/ui/components/component-library/banner-base/banner-base.js b/ui/components/component-library/banner-base/banner-base.js index adb28f82d..cc9f5575e 100644 --- a/ui/components/component-library/banner-base/banner-base.js +++ b/ui/components/component-library/banner-base/banner-base.js @@ -46,6 +46,7 @@ export const BannerBase = ({ diff --git a/ui/components/component-library/banner-tip/__snapshots__/banner-tip.test.js.snap b/ui/components/component-library/banner-tip/__snapshots__/banner-tip.test.js.snap index 604ab19bd..d14c06b7f 100644 --- a/ui/components/component-library/banner-tip/__snapshots__/banner-tip.test.js.snap +++ b/ui/components/component-library/banner-tip/__snapshots__/banner-tip.test.js.snap @@ -18,6 +18,7 @@ exports[`BannerTip should render BannerTip element correctly 1`] = `
BannerTip test
diff --git a/ui/components/component-library/text-field/text-field.js b/ui/components/component-library/text-field/text-field.js index d8cc8b9ed..9cccd1bb7 100644 --- a/ui/components/component-library/text-field/text-field.js +++ b/ui/components/component-library/text-field/text-field.js @@ -38,6 +38,7 @@ export const TextField = ({ readOnly, required, size = Size.MD, + testId, type = 'text', truncate = true, value, @@ -116,6 +117,7 @@ export const TextField = ({ autoComplete={autoComplete} autoFocus={autoFocus} backgroundColor={BackgroundColor.transparent} + data-testid={testId} defaultValue={defaultValue} disabled={disabled} focused={focused.toString()} @@ -249,6 +251,10 @@ TextField.propTypes = { * The input value, required for a controlled component. */ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** + * Data test ID for the InputComponent component + */ + testId: PropTypes.string, /** * TextField accepts all the props from Box */ diff --git a/ui/components/institutional/compliance-details/__snapshots__/compliance-details.test.js.snap b/ui/components/institutional/compliance-details/__snapshots__/compliance-details.test.js.snap index 2cd5c29ec..bd4b24a02 100644 --- a/ui/components/institutional/compliance-details/__snapshots__/compliance-details.test.js.snap +++ b/ui/components/institutional/compliance-details/__snapshots__/compliance-details.test.js.snap @@ -32,6 +32,7 @@ exports[`ComplianceDetails should render correctly 1`] = `

+

@@ -55,6 +56,7 @@ exports[`ListItem should match snapshot with props 1`] = ` >

Hello World

@@ -125,6 +127,7 @@ exports[`ListItem should match snapshot with props 1`] = `

Content rendered to the right diff --git a/ui/components/ui/list-item/list-item.component.js b/ui/components/ui/list-item/list-item.component.js index 9a13cbaf1..eb851e9f8 100644 --- a/ui/components/ui/list-item/list-item.component.js +++ b/ui/components/ui/list-item/list-item.component.js @@ -38,7 +38,9 @@ export default function ListItem({ {React.isValidElement(title) ? ( title ) : ( -

{title}

+

+ {title} +

)} {titleIcon && (
{titleIcon}
@@ -52,7 +54,12 @@ export default function ListItem({
{midContent}
) : null} {rightContent ? ( -
{rightContent}
+
+ {rightContent} +
) : null}
); diff --git a/ui/components/ui/typography/typography.js b/ui/components/ui/typography/typography.js index cef5e357d..7e2a5742b 100644 --- a/ui/components/ui/typography/typography.js +++ b/ui/components/ui/typography/typography.js @@ -80,6 +80,7 @@ export default function Typography({ marginLeft, boxProps = {}, className, + testId, children, }) { let Tag = as ?? variant; @@ -123,6 +124,7 @@ export default function Typography({ {children} @@ -188,6 +190,10 @@ Typography.propTypes = { * Title attribute to include on the element. Will show as tooltip on hover. */ title: PropTypes.string, + /** + * Data test ID for the Tag component + */ + testId: PropTypes.string, /** * The text content of the Typography component */ diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index 47730bd2b..1c3501cb2 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -120,6 +120,7 @@ const initialState = { }, currentSmartTransactionsError: '', swapsSTXLoading: false, + transactionSettingsOpened: false, }; const slice = createSlice({ @@ -208,6 +209,9 @@ const slice = createSlice({ setSwapsSTXSubmitLoading: (state, action) => { state.swapsSTXLoading = action.payload || false; }, + setTransactionSettingsOpened: (state, action) => { + state.transactionSettingsOpened = Boolean(action.payload); + }, }, }); @@ -268,6 +272,9 @@ export const getSwapsFallbackGasPrice = (state) => export const getCurrentSmartTransactionsError = (state) => state.swaps.currentSmartTransactionsError; +export const getTransactionSettingsOpened = (state) => + state.swaps.transactionSettingsOpened; + export function shouldShowCustomPriceTooLowWarning(state) { const { average } = getSwapGasPriceEstimateData(state); @@ -325,6 +332,15 @@ export const getCurrentSmartTransactionsEnabled = (state) => { return smartTransactionsEnabled && !currentSmartTransactionsError; }; +export const getSwapRedesignEnabled = (state) => { + const swapRedesign = + state.metamask.swapsState?.swapsFeatureFlags?.swapRedesign; + if (swapRedesign === undefined) { + return true; // By default show the redesign if we don't have feature flags returned yet. + } + return swapRedesign.extensionActive; +}; + export const getSwapsQuoteRefreshTime = (state) => state.metamask.swapsState.swapsQuoteRefreshTime; @@ -478,6 +494,7 @@ const { swapCustomGasModalClosed, setCurrentSmartTransactionsError, setSwapsSTXSubmitLoading, + setTransactionSettingsOpened, } = actions; export { @@ -497,6 +514,7 @@ export { swapCustomGasModalPriceEdited, swapCustomGasModalLimitEdited, swapCustomGasModalClosed, + setTransactionSettingsOpened, }; export const navigateBackToBuildQuote = (history) => { @@ -647,12 +665,7 @@ export const fetchQuotesAndSetQuoteState = ( iconUrl: fromTokenIconUrl, balance: fromTokenBalance, } = selectedFromToken; - const { - address: toTokenAddress, - symbol: toTokenSymbol, - decimals: toTokenDecimals, - iconUrl: toTokenIconUrl, - } = selectedToToken; + const { address: toTokenAddress, symbol: toTokenSymbol } = selectedToToken; // pageRedirectionDisabled is true if quotes prefetching is active (a user is on the Build Quote page). // In that case we just want to silently prefetch quotes without redirecting to the quotes loading page. if (!pageRedirectionDisabled) { @@ -663,24 +676,6 @@ export const fetchQuotesAndSetQuoteState = ( const contractExchangeRates = getTokenExchangeRates(state); - let destinationTokenAddedForSwap = false; - if ( - toTokenAddress && - toTokenSymbol !== swapsDefaultToken.symbol && - contractExchangeRates[toTokenAddress] === undefined && - !isTokenAlreadyAdded(toTokenAddress, getTokens(state)) - ) { - destinationTokenAddedForSwap = true; - await dispatch( - addToken( - toTokenAddress, - toTokenSymbol, - toTokenDecimals, - toTokenIconUrl, - true, - ), - ); - } if ( fromTokenAddress && fromTokenSymbol !== swapsDefaultToken.symbol && @@ -749,7 +744,6 @@ export const fetchQuotesAndSetQuoteState = ( destinationToken: toTokenAddress, value: inputValue, fromAddress: selectedAccount.address, - destinationTokenAddedForSwap, balanceError, sourceDecimals: fromTokenDecimals, }, @@ -837,6 +831,36 @@ export const fetchQuotesAndSetQuoteState = ( }; }; +const addTokenTo = (dispatch, state) => { + const fetchParams = getFetchParams(state); + const swapsDefaultToken = getSwapsDefaultToken(state); + const contractExchangeRates = getTokenExchangeRates(state); + const selectedToToken = + getToToken(state) || fetchParams?.metaData?.destinationTokenInfo || {}; + const { + address: toTokenAddress, + symbol: toTokenSymbol, + decimals: toTokenDecimals, + iconUrl: toTokenIconUrl, + } = selectedToToken; + if ( + toTokenAddress && + toTokenSymbol !== swapsDefaultToken.symbol && + contractExchangeRates[toTokenAddress] === undefined && + !isTokenAlreadyAdded(toTokenAddress, getTokens(state)) + ) { + dispatch( + addToken( + toTokenAddress, + toTokenSymbol, + toTokenDecimals, + toTokenIconUrl, + true, + ), + ); + } +}; + export const signAndSendSwapsSmartTransaction = ({ unsignedTransaction, trackEvent, @@ -936,6 +960,7 @@ export const signAndSendSwapsSmartTransaction = ({ dispatch(setCurrentSmartTransactionsError(StxErrorTypes.unavailable)); return; } + addTokenTo(dispatch, state); if (approveTxParams) { updatedApproveTxParams.gas = `0x${decimalToHex( fees.approvalTxFees?.gasLimit || 0, @@ -1179,6 +1204,7 @@ export const signAndSendTransactions = ( history.push(AWAITING_SIGNATURES_ROUTE); } + addTokenTo(dispatch, state); if (approveTxParams) { if (networkAndAccountSupports1559) { approveTxParams.maxFeePerGas = maxFeePerGas; diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js index a537223d4..6ba7c7677 100644 --- a/ui/ducks/swaps/swaps.test.js +++ b/ui/ducks/swaps/swaps.test.js @@ -956,5 +956,24 @@ describe('Ducks - Swaps', () => { expect(newState.customGas.limit).toBe(null); }); }); + + describe('getSwapRedesignEnabled', () => { + it('returns true if feature flags are not returned from backend yet', () => { + const state = createSwapsMockStore(); + delete state.metamask.swapsState.swapsFeatureFlags.swapRedesign; + expect(swaps.getSwapRedesignEnabled(state)).toBe(true); + }); + + it('returns false if the extension feature flag for swaps redesign is false', () => { + const state = createSwapsMockStore(); + state.metamask.swapsState.swapsFeatureFlags.swapRedesign.extensionActive = false; + expect(swaps.getSwapRedesignEnabled(state)).toBe(false); + }); + + it('returns true if the extension feature flag for swaps redesign is true', () => { + const state = createSwapsMockStore(); + expect(swaps.getSwapRedesignEnabled(state)).toBe(true); + }); + }); }); }); diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index eed593c36..e11b9466d 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -52,6 +52,8 @@ const NOTIFICATIONS_ROUTE = '/notifications'; const CONNECTED_ROUTE = '/connected'; const CONNECTED_ACCOUNTS_ROUTE = '/connected/accounts'; const SWAPS_ROUTE = '/swaps'; +const PREPARE_SWAP_ROUTE = '/swaps/prepare-swap-page'; +const SWAPS_NOTIFICATION_ROUTE = '/swaps/notification-page'; const BUILD_QUOTE_ROUTE = '/swaps/build-quote'; const VIEW_QUOTE_ROUTE = '/swaps/view-quote'; const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes'; @@ -174,6 +176,8 @@ const PATH_NAME_MAP = { [`${CONFIRM_TRANSACTION_ROUTE}/:id${ENCRYPTION_PUBLIC_KEY_REQUEST_PATH}`]: 'Encryption Public Key Request Page', [BUILD_QUOTE_ROUTE]: 'Swaps Build Quote Page', + [PREPARE_SWAP_ROUTE]: 'Prepare Swap Page', + [SWAPS_NOTIFICATION_ROUTE]: 'Swaps Notification Page', [VIEW_QUOTE_ROUTE]: 'Swaps View Quotes Page', [LOADING_QUOTES_ROUTE]: 'Swaps Loading Quotes Page', [AWAITING_SWAP_ROUTE]: 'Swaps Awaiting Swaps Page', @@ -246,6 +250,8 @@ export { CONNECTED_ACCOUNTS_ROUTE, PATH_NAME_MAP, SWAPS_ROUTE, + PREPARE_SWAP_ROUTE, + SWAPS_NOTIFICATION_ROUTE, BUILD_QUOTE_ROUTE, VIEW_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, diff --git a/ui/pages/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap b/ui/pages/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap index 140f637f2..d7d945536 100644 --- a/ui/pages/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap +++ b/ui/pages/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap @@ -358,6 +358,7 @@ exports[`ConfirmSendEther should render correct information for for confirm send
Your USDC diff --git a/ui/pages/swaps/awaiting-swap/awaiting-swap.js b/ui/pages/swaps/awaiting-swap/awaiting-swap.js index 29a91619b..d991452f2 100644 --- a/ui/pages/swaps/awaiting-swap/awaiting-swap.js +++ b/ui/pages/swaps/awaiting-swap/awaiting-swap.js @@ -230,6 +230,7 @@ export default function AwaitingSwap({ {swapMetaData?.token_to} , @@ -278,8 +279,18 @@ export default function AwaitingSwap({ /> )}
{statusImage}
-
{headerText}
-
{descriptionText}
+
+ {headerText} +
+
+ {descriptionText} +
{content}
{!errorKey && swapComplete ? ( diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js index 084969fa3..7c2836703 100644 --- a/ui/pages/swaps/build-quote/build-quote.js +++ b/ui/pages/swaps/build-quote/build-quote.js @@ -18,17 +18,7 @@ import DropdownSearchList from '../dropdown-search-list'; import SlippageButtons from '../slippage-buttons'; import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; import InfoTooltip from '../../../components/ui/info-tooltip'; -import Popover from '../../../components/ui/popover'; -import Button from '../../../components/ui/button'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; -import Box from '../../../components/ui/box'; -import { - TextVariant, - DISPLAY, - FLEX_DIRECTION, - FontWeight, - TextColor, -} from '../../../helpers/constants/design-system'; import { VIEW_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, @@ -94,7 +84,6 @@ import { import { resetSwapsPostFetchState, - ignoreTokens, setBackgroundSwapRouteState, clearSwapsQuotes, stopPollingForQuotes, @@ -111,7 +100,7 @@ import { getValueFromWeiHex, hexToDecimal, } from '../../../../shared/modules/conversion.utils'; -import { Text } from '../../../components/component-library'; +import SmartTransactionsPopover from '../prepare-swap-page/smart-transactions-popover'; const fuseSearchKeys = [ { name: 'name', weight: 0.499 }, @@ -358,22 +347,12 @@ export default function BuildQuote({ ? getURLHostName(blockExplorerTokenLink) : t('etherscan'); - const { destinationTokenAddedForSwap } = fetchParams || {}; - const { address: toAddress } = toToken || {}; const onToSelect = useCallback( (token) => { - if (destinationTokenAddedForSwap && token.address !== toAddress) { - dispatch( - ignoreTokens({ - tokensToIgnore: toAddress, - dontShowLoadingIndicator: true, - }), - ); - } dispatch(setSwapToToken(token)); setVerificationClicked(false); }, - [dispatch, destinationTokenAddedForSwap, toAddress], + [dispatch], ); const hideDropdownItemIf = useCallback( @@ -585,82 +564,12 @@ export default function BuildQuote({
{showSmartTransactionsOptInPopover && ( - - - - - - - - + - - - {t('swapSwapSwitch')} - - - {t('stxDescription')} - - -
  • {t('stxBenefit1')}
  • -
  • {t('stxBenefit2')}
  • -
  • {t('stxBenefit3')}
  • -
  • - {t('stxBenefit4')} - - {' *'} - -
  • -
    - - {t('stxSubDescription')}  - {t('stxYouCanOptOut')}  - -
    -
    + /> )}
    {t('swapSwapFrom')}
    diff --git a/ui/pages/swaps/build-quote/build-quote.test.js b/ui/pages/swaps/build-quote/build-quote.test.js index ea8c2cc70..e09ec1149 100644 --- a/ui/pages/swaps/build-quote/build-quote.test.js +++ b/ui/pages/swaps/build-quote/build-quote.test.js @@ -29,7 +29,6 @@ const createProps = (customProps = {}) => { setBackgroundConnection({ resetPostFetchState: jest.fn(), - ignoreTokens: jest.fn(), setBackgroundSwapRouteState: jest.fn(), clearSwapsQuotes: jest.fn(), stopPollingForQuotes: jest.fn(), diff --git a/ui/pages/swaps/countdown-timer/countdown-timer.js b/ui/pages/swaps/countdown-timer/countdown-timer.js index 87ccf1b4f..153cf093e 100644 --- a/ui/pages/swaps/countdown-timer/countdown-timer.js +++ b/ui/pages/swaps/countdown-timer/countdown-timer.js @@ -10,7 +10,6 @@ import { getSwapsQuotePrefetchingRefreshTime, } from '../../../ducks/swaps/swaps'; import { SECOND } from '../../../../shared/constants/time'; -import TimerIcon from './timer-icon'; // Return the mm:ss start time of the countdown timer. // If time has elapsed between `timeStarted` the time current time, @@ -111,7 +110,6 @@ export default function CountdownTimer({ warningTime && timeBelowWarningTime(timer, warningTime), })} > - {time}
    {!timeOnly && infoTooltipLabelKey ? ( diff --git a/ui/pages/swaps/countdown-timer/index.scss b/ui/pages/swaps/countdown-timer/index.scss index f07a54741..a49b38cfb 100644 --- a/ui/pages/swaps/countdown-timer/index.scss +++ b/ui/pages/swaps/countdown-timer/index.scss @@ -11,6 +11,7 @@ &__timer-container { display: flex; padding-right: 3px; + color: var(--color-text-alternative); > span { display: flex; diff --git a/ui/pages/swaps/dropdown-search-list/__snapshots__/dropdown-search-list.test.js.snap b/ui/pages/swaps/dropdown-search-list/__snapshots__/dropdown-search-list.test.js.snap index ee9809186..be5ea2352 100644 --- a/ui/pages/swaps/dropdown-search-list/__snapshots__/dropdown-search-list.test.js.snap +++ b/ui/pages/swaps/dropdown-search-list/__snapshots__/dropdown-search-list.test.js.snap @@ -36,8 +36,9 @@ exports[`DropdownSearchList renders the component with initial props 1`] = `
    -
    diff --git a/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js b/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js index 955899c2b..940bf41b4 100644 --- a/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js +++ b/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js @@ -13,6 +13,11 @@ import { I18nContext } from '../../../contexts/i18n'; import SearchableItemList from '../searchable-item-list'; import PulseLoader from '../../../components/ui/pulse-loader'; import UrlIcon from '../../../components/ui/url-icon'; +import { + Icon, + IconName, + IconSize, +} from '../../../components/component-library'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; import ImportToken from '../import-token'; import { @@ -210,7 +215,7 @@ export default function DropdownSearchList({
    - +
    )} {isOpen && ( @@ -238,7 +243,7 @@ export default function DropdownSearchList({ key="searchable-item-list-item-last" > { diff --git a/ui/pages/swaps/dropdown-search-list/index.scss b/ui/pages/swaps/dropdown-search-list/index.scss index e08d025e5..7f5305c85 100644 --- a/ui/pages/swaps/dropdown-search-list/index.scss +++ b/ui/pages/swaps/dropdown-search-list/index.scss @@ -51,7 +51,7 @@ &__caret { position: absolute; right: 16px; - color: var(--color-icon-muted); + color: var(--color-icon-default); } &__selector-closed { @@ -71,7 +71,7 @@ } .dropdown-search-list__item-labels { - width: 56%; + width: 100%; } } diff --git a/ui/pages/swaps/exchange-rate-display/__snapshots__/exchange-rate-display.test.js.snap b/ui/pages/swaps/exchange-rate-display/__snapshots__/exchange-rate-display.test.js.snap index ff4edda34..7dc3b1256 100644 --- a/ui/pages/swaps/exchange-rate-display/__snapshots__/exchange-rate-display.test.js.snap +++ b/ui/pages/swaps/exchange-rate-display/__snapshots__/exchange-rate-display.test.js.snap @@ -5,43 +5,37 @@ exports[`ExchangeRateDisplay renders the component with initial props 1`] = `
    - - 1 - - - ETH - - - = - - - 0.1 - - - BAT -
    - + 1 + + - - + ETH + + + = + + + 0.1 + + + BAT +
    +
    `; diff --git a/ui/pages/swaps/exchange-rate-display/exchange-rate-display.js b/ui/pages/swaps/exchange-rate-display/exchange-rate-display.js index 55dcfdc3a..413730c28 100644 --- a/ui/pages/swaps/exchange-rate-display/exchange-rate-display.js +++ b/ui/pages/swaps/exchange-rate-display/exchange-rate-display.js @@ -1,9 +1,19 @@ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import PropTypes from 'prop-types'; import BigNumber from 'bignumber.js'; import classnames from 'classnames'; import { formatSwapsValueForDisplay } from '../swaps.util'; import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; +import Box from '../../../components/ui/box'; +import { + JustifyContent, + DISPLAY, + AlignItems, + IconColor, + TextColor, +} from '../../../helpers/constants/design-system'; +import { Icon, IconName } from '../../../components/component-library'; +import { I18nContext } from '../../../contexts/i18n'; export default function ExchangeRateDisplay({ primaryTokenValue, @@ -12,12 +22,13 @@ export default function ExchangeRateDisplay({ secondaryTokenValue, secondaryTokenDecimals = 18, secondaryTokenSymbol, - arrowColor = 'var(--color-primary-default)', boldSymbols = true, + showIconForSwappingTokens = true, className, + onQuotesClick, }) { const [showPrimaryToSecondary, setShowPrimaryToSecondary] = useState(true); - const [rotating, setRotating] = useState(false); + const t = useContext(I18nContext); const primaryTokenAmount = calcTokenAmount( primaryTokenValue, @@ -63,44 +74,42 @@ export default function ExchangeRateDisplay({ return (
    - 1 - - {baseSymbol} - - {comparisonSymbol} - {rateToDisplay} - - {ratiodSymbol} - -
    { - setShowPrimaryToSecondary(!showPrimaryToSecondary); - setRotating(true); - }} - onAnimationEnd={() => setRotating(false)} - > - 1 + - - -
    + {baseSymbol} + + {comparisonSymbol} + {rateToDisplay} + + {ratiodSymbol} + + + {showIconForSwappingTokens && ( + { + setShowPrimaryToSecondary(!showPrimaryToSecondary); + }} + color={IconColor.iconAlternative} + style={{ cursor: 'pointer' }} + title={t('switch')} + data-testid="exchange-rate-display-switch" + /> + )}
    ); } @@ -125,6 +134,7 @@ ExchangeRateDisplay.propTypes = { ]), secondaryTokenSymbol: PropTypes.string.isRequired, className: PropTypes.string, - arrowColor: PropTypes.string, boldSymbols: PropTypes.bool, + showIconForSwappingTokens: PropTypes.bool, + onQuotesClick: PropTypes.func, }; diff --git a/ui/pages/swaps/exchange-rate-display/exchange-rate-display.test.js b/ui/pages/swaps/exchange-rate-display/exchange-rate-display.test.js index 6f0ddd661..a4433da6a 100644 --- a/ui/pages/swaps/exchange-rate-display/exchange-rate-display.test.js +++ b/ui/pages/swaps/exchange-rate-display/exchange-rate-display.test.js @@ -28,14 +28,15 @@ describe('ExchangeRateDisplay', () => { it('clicks on the switch link', () => { const props = createProps(); + props.showIconForSwappingTokens = true; const { getByTestId } = renderWithProvider( , ); - expect(getByTestId('exchange-rate-display__base-symbol')).toHaveTextContent( + expect(getByTestId('exchange-rate-display-base-symbol')).toHaveTextContent( 'ETH', ); - fireEvent.click(getByTestId('exchange-rate-display__switch-arrows')); - expect(getByTestId('exchange-rate-display__base-symbol')).toHaveTextContent( + fireEvent.click(getByTestId('exchange-rate-display-switch')); + expect(getByTestId('exchange-rate-display-base-symbol')).toHaveTextContent( 'BAT', ); }); diff --git a/ui/pages/swaps/exchange-rate-display/index.scss b/ui/pages/swaps/exchange-rate-display/index.scss index bbd00c9f6..9a397f982 100644 --- a/ui/pages/swaps/exchange-rate-display/index.scss +++ b/ui/pages/swaps/exchange-rate-display/index.scss @@ -16,34 +16,7 @@ font-weight: bold; } - &__switch-arrows { + &__quote-rate { cursor: pointer; - - > svg { - margin-top: 4px; - } - } - - &__switch-arrows-rotate { - -webkit-animation-name: rotate-toggle; - -webkit-animation-duration: 1s; - -webkit-transition: all 500ms cubic-bezier(0.86, 0, 0.07, 1); - -moz-transition: all 500ms cubic-bezier(0.86, 0, 0.07, 1); - -o-transition: all 500ms cubic-bezier(0.86, 0, 0.07, 1); - transition: all 500ms cubic-bezier(0.86, 0, 0.07, 1); - -webkit-transition-timing-function: cubic-bezier(0.86, 0, 0.07, 1); - -moz-transition-timing-function: cubic-bezier(0.86, 0, 0.07, 1); - -o-transition-timing-function: cubic-bezier(0.86, 0, 0.07, 1); - transition-timing-function: cubic-bezier(0.86, 0, 0.07, 1); - } - - @-webkit-keyframes rotate-toggle { - 0% { - -webkit-transform: rotate(0deg); - } - - 100% { - -webkit-transform: rotate(180deg); - } } } diff --git a/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap b/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap index 9693a4043..c46151b5d 100644 --- a/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap +++ b/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap @@ -7,6 +7,7 @@ exports[`FeeCard renders the component with initial props 1`] = `null`; exports[`FeeCard renders the component with initial props 2`] = `
    approveTxId === id); const tradeTxData = tradeTxId && txList.find(({ id }) => tradeTxId === id); @@ -194,35 +209,6 @@ export default function Swap() { swapsErrorKey = SWAP_FAILED_ERROR; } - const clearTemporaryTokenRef = useRef(); - useEffect(() => { - clearTemporaryTokenRef.current = () => { - if ( - destinationTokenAddedForSwap && - (!isAwaitingSwapRoute || conversionError) - ) { - dispatch( - ignoreTokens({ - tokensToIgnore: destinationTokenInfo?.address, - dontShowLoadingIndicator: true, - }), - ); - } - }; - }, [ - conversionError, - dispatch, - destinationTokenAddedForSwap, - destinationTokenInfo, - fetchParams, - isAwaitingSwapRoute, - ]); - useEffect(() => { - return () => { - clearTemporaryTokenRef.current(); - }; - }, []); - // eslint-disable-next-line useEffect(() => { if (!isSwapsChain) { @@ -297,7 +283,6 @@ export default function Swap() { const beforeUnloadEventAddedRef = useRef(); useEffect(() => { const fn = () => { - clearTemporaryTokenRef.current(); if (isLoadingQuotesRoute) { dispatch(prepareToLeaveSwaps()); } @@ -363,35 +348,102 @@ export default function Swap() { return <>; } + const redirectToDefaultRoute = async () => { + dispatch(clearSwapsState()); + await dispatch(resetBackgroundSwapsState()); + history.push(DEFAULT_ROUTE); + }; + return (
    -
    { - await dispatch(navigateBackToBuildQuote(history)); - }} - > - {isViewQuoteRoute && t('edit')} -
    + {!swapRedesignEnabled && ( +
    { + await dispatch(navigateBackToBuildQuote(history)); + }} + > + {isViewQuoteRoute && t('edit')} +
    + )} + {swapRedesignEnabled && ( + { + if (e.key === 'Enter') { + redirectToDefaultRoute(); + } + }} + > + {!isAwaitingSwapRoute && + !isAwaitingSignaturesRoute && + !isSmartTransactionStatusRoute && ( + + )} + + )}
    {t('swap')}
    -
    { - clearTemporaryTokenRef.current(); - dispatch(clearSwapsState()); - await dispatch(resetBackgroundSwapsState()); - history.push(DEFAULT_ROUTE); - }} - > - {!isAwaitingSwapRoute && - !isAwaitingSignaturesRoute && - !isSmartTransactionStatusRoute && - t('cancel')} -
    + {!swapRedesignEnabled && ( +
    { + dispatch(clearSwapsState()); + await dispatch(resetBackgroundSwapsState()); + history.push(DEFAULT_ROUTE); + }} + > + {!isAwaitingSwapRoute && + !isAwaitingSignaturesRoute && + !isSmartTransactionStatusRoute && + t('cancel')} +
    + )} + {swapRedesignEnabled && ( + { + if (e.key === 'Enter') { + dispatch(setTransactionSettingsOpened(true)); + } + }} + > + {isPrepareSwapRoute && ( + { + dispatch(setTransactionSettingsOpened(true)); + }} + style={{ cursor: 'pointer' }} + title={t('transactionSettings')} + /> + )} + + )}
    -
    +
    { + if (swapRedesignEnabled) { + return ; + } if (tradeTxData && !conversionError) { return ; } else if (tradeTxData && routeState) { @@ -416,6 +471,25 @@ export default function Swap() { ); }} /> + { + if (!swapRedesignEnabled) { + return ; + } + + return ( + + ); + }} + /> ); } + if (swapRedesignEnabled) { + return ; + } if (Object.values(quotes).length) { return ( @@ -460,6 +537,16 @@ export default function Swap() { return ; }} /> + { + if (!swapsErrorKey) { + return ; + } + return ; + }} + /> { }); it('renders the component with initial props', async () => { - const store = configureMockStore(middleware)(createSwapsMockStore()); + const swapsMockStore = createSwapsMockStore(); + swapsMockStore.metamask.swapsState.swapsFeatureFlags.swapRedesign.extensionActive = false; + const store = configureMockStore(middleware)(swapsMockStore); const { container, getByText } = renderWithProvider(, store); await waitFor(() => expect(featureFlagsNock.isDone()).toBe(true)); expect(getByText('Swap')).toBeInTheDocument(); diff --git a/ui/pages/swaps/list-with-search/__snapshots__/list-with-search.test.js.snap b/ui/pages/swaps/list-with-search/__snapshots__/list-with-search.test.js.snap new file mode 100644 index 000000000..4fd33e151 --- /dev/null +++ b/ui/pages/swaps/list-with-search/__snapshots__/list-with-search.test.js.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ListWithSearch renders the component with initial props 1`] = ` + +`; diff --git a/ui/pages/swaps/list-with-search/index.scss b/ui/pages/swaps/list-with-search/index.scss new file mode 100644 index 000000000..baaf9a15e --- /dev/null +++ b/ui/pages/swaps/list-with-search/index.scss @@ -0,0 +1,23 @@ +.list-with-search { + .searchable-item-list { + &__list-container { + height: 320px; + overflow-y: auto; + padding-right: 8px; + + @include screen-sm-min { + height: 600px; + } + } + } + + &__text-search { + outline: none; + } + + .mm-button-icon { + .mm-icon--size-lg { + --size: 20px; + } + } +} diff --git a/ui/pages/swaps/list-with-search/list-with-search.js b/ui/pages/swaps/list-with-search/list-with-search.js new file mode 100644 index 000000000..64223da18 --- /dev/null +++ b/ui/pages/swaps/list-with-search/list-with-search.js @@ -0,0 +1,179 @@ +import React, { useState, useEffect, useRef, useContext } from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { filter } from 'lodash'; +import log from 'loglevel'; + +import Box from '../../../components/ui/box'; +import { + DISPLAY, + FLEX_DIRECTION, + JustifyContent, + AlignItems, + TextVariant, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { TextFieldSearch, Text } from '../../../components/component-library'; +import ItemList from '../searchable-item-list/item-list'; +import { isValidHexAddress } from '../../../../shared/modules/hexstring-utils'; +import { I18nContext } from '../../../contexts/i18n'; +import { fetchToken } from '../swaps.util'; +import { getCurrentChainId } from '../../../selectors/selectors'; + +let timeoutIdForSearch; + +export default function ListWithSearch({ + itemsToSearch = [], + listTitle, + maxListItems, + onClickItem, + onOpenImportTokenModalClick, + shouldSearchForImports, + Placeholder, + hideRightLabels, + hideItemIf, + listContainerClassName, + searchQuery, + setSearchQuery, +}) { + const itemListRef = useRef(); + const t = useContext(I18nContext); + + const [items, setItems] = useState(itemsToSearch); + const chainId = useSelector(getCurrentChainId); + + /** + * Search a custom token for import based on a contract address. + * + * @param {string} contractAddress + */ + const handleSearchTokenForImport = async (contractAddress) => { + try { + const token = await fetchToken(contractAddress, chainId); + if (token) { + token.primaryLabel = token.symbol; + token.secondaryLabel = token.name; + token.notImported = true; + setItems([token]); + return; + } + } catch (e) { + log.error('Token not found, show 0 results.', e); + } + setItems([]); // No token for import found. + }; + + const handleSearch = async (newSearchQuery) => { + setSearchQuery(newSearchQuery); + if (timeoutIdForSearch) { + clearTimeout(timeoutIdForSearch); + } + timeoutIdForSearch = setTimeout(async () => { + timeoutIdForSearch = null; + const trimmedNewSearchQuery = newSearchQuery.trim(); + const trimmedNewSearchQueryUpperCase = + trimmedNewSearchQuery.toUpperCase(); + const trimmedNewSearchQueryLowerCase = + trimmedNewSearchQuery.toLowerCase(); + if (!trimmedNewSearchQuery) { + setItems(itemsToSearch); + return; + } + const validHexAddress = isValidHexAddress(trimmedNewSearchQuery); + let filteredItems = []; + if (validHexAddress) { + // E.g. DAI token: 0x6b175474e89094c44da98b954eedeac495271D0f + const foundItem = itemsToSearch.find((item) => { + return item.address === trimmedNewSearchQueryLowerCase; + }); + if (foundItem) { + filteredItems.push(foundItem); + } + } else { + filteredItems = filter(itemsToSearch, function (item) { + return item.symbol.includes(trimmedNewSearchQueryUpperCase); + }); + } + const results = newSearchQuery === '' ? itemsToSearch : filteredItems; + if (shouldSearchForImports && results.length === 0 && validHexAddress) { + await handleSearchTokenForImport(trimmedNewSearchQuery); + return; + } + setItems(results); + }, 350); + }; + + useEffect(() => { + handleSearch(searchQuery); + }, [searchQuery]); + + const handleOnClear = () => { + setSearchQuery(''); + }; + + return ( + + + handleSearch(e.target.value)} + clearButtonOnClick={handleOnClear} + value={searchQuery} + placeholder={t('enterTokenNameOrAddress')} + inputProps={{ marginRight: 0 }} + className="list-with-search__text-search" + autoFocus + tabIndex="0" + /> + + {items?.length > 0 && ( + + )} + {items?.length === 0 && ( + + + {t('swapNoTokensAvailable', [searchQuery])} + + + )} + + ); +} + +ListWithSearch.propTypes = { + itemsToSearch: PropTypes.array, + onClickItem: PropTypes.func, + onOpenImportTokenModalClick: PropTypes.func, + Placeholder: PropTypes.func, + listTitle: PropTypes.string, + maxListItems: PropTypes.number, + hideRightLabels: PropTypes.bool, + shouldSearchForImports: PropTypes.bool, + hideItemIf: PropTypes.func, + listContainerClassName: PropTypes.string, + searchQuery: PropTypes.string, + setSearchQuery: PropTypes.func, +}; diff --git a/ui/pages/swaps/list-with-search/list-with-search.test.js b/ui/pages/swaps/list-with-search/list-with-search.test.js new file mode 100644 index 000000000..98ab4c1f3 --- /dev/null +++ b/ui/pages/swaps/list-with-search/list-with-search.test.js @@ -0,0 +1,68 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; + +import { + renderWithProvider, + createSwapsMockStore, +} from '../../../../test/jest'; +import ListWithSearch from './list-with-search'; + +const createProps = (customProps = {}) => { + return { + itemsToSearch: [ + { + iconUrl: 'iconUrl', + selected: true, + primaryLabel: 'primaryLabel', + secondaryLabel: 'secondaryLabel', + rightPrimaryLabel: 'rightPrimaryLabel', + rightSecondaryLabel: 'rightSecondaryLabel', + }, + ], + onClickItem: jest.fn(), + onOpenImportTokenModalClick: jest.fn(), + setSearchQuery: jest.fn(), + Placeholder: <>, + listTitle: 'listTitle', + ...customProps, + }; +}; + +describe('ListWithSearch', () => { + it('renders the component with initial props', () => { + const store = configureMockStore()(createSwapsMockStore()); + const props = createProps(); + const { getByText, getByPlaceholderText } = renderWithProvider( + , + store, + ); + expect(getByText(props.listTitle)).toBeInTheDocument(); + expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument(); + expect( + getByText(props.itemsToSearch[0].secondaryLabel), + ).toBeInTheDocument(); + expect( + getByText(props.itemsToSearch[0].rightPrimaryLabel), + ).toBeInTheDocument(); + expect( + getByText(props.itemsToSearch[0].rightSecondaryLabel), + ).toBeInTheDocument(); + expect( + getByPlaceholderText('Enter token name or paste address'), + ).toBeInTheDocument(); + expect( + document.querySelector('.list-with-search__text-search'), + ).toMatchSnapshot(); + }); + + it('renders the component with an empty list', () => { + const store = configureMockStore()(createSwapsMockStore()); + const props = createProps(); + props.itemsToSearch = []; + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('No tokens available matching')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/loading-swaps-quotes/__snapshots__/loading-swaps-quotes-stories-metadata.test.js.snap b/ui/pages/swaps/loading-swaps-quotes/__snapshots__/loading-swaps-quotes-stories-metadata.test.js.snap index 6581c8123..66be56d52 100644 --- a/ui/pages/swaps/loading-swaps-quotes/__snapshots__/loading-swaps-quotes-stories-metadata.test.js.snap +++ b/ui/pages/swaps/loading-swaps-quotes/__snapshots__/loading-swaps-quotes-stories-metadata.test.js.snap @@ -27,4 +27,4 @@ exports[`storiesMetadata matches expected values for storiesMetadata 1`] = ` "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASkAAAB5CAYAAABlYNfBAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAlASURBVHgB7d3/eeO2GcDx1336f3UThNcF4g3KLNA4HaDnywC5Xgeo6Q5Q+zpAz+kCd+0AJyUDxNcFTsoCsbtA3gIVFL0CIRGgKMm2vp/nwWPxF0hT4isABCERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADhCJ4LeVLVyf05d+sylZy7dufSjS7OTk5OPgrXcufN/KpfO16zyfqhzmLGvEjOX7mWA9zgcVx3SwrXL916AvtwHa+TShUtT3cwvfxUCGSLuvJy4dLPh/H2Qgfhg4NIXOrw7l9659EJ6CMfVmPx+5vOCrYSgc6dlpi79SbBC50FquuG8+Qt2JAPQ3QUp65NLdY/jIkh1+LUgi/vwXLk/qWDz/6J/+Ov56p+9uCqXrtz2n7li/GvBoppzJvNzs8m5S9eyG/eyfM9KjGT1/V147tIH979duvf5UoB98gEq8c35dt03p5t/qumqzFvBohT1zpyXqUvnLr3U1ZLqIFU+bZekfInlXHoIefkq/9ma99jnnVVypiSFQei8/UmjC6rO3LZOfIiPuuoXLszn0Tn5h1kWX7RbV/mGDFKJfKtEsPo55zNCkMLWwgcwDlBVwfYXmnYqRypcmC+jC/PULIvbjhrZ0q6ClMnflwyvo+PuLAUSpLA194EZRx+8unD7qaaN5UiFC9qe108dy3+QLe06SIV9xMfdWZoiSOX5lSApfMBqM+vGNYhOJFO4CCoz61vzutbCgPcU6LK/Um1m/zNezaXvzPTpIzlX/rj/aqZ9H8RasDWC1Hrn0XTpHZsL83oi7TuDF3KcavPaX9g3iXXemNf+Yv9SHoe4c+fngq0RpNazF8bElaJmkilRiroMvYgnZp4vIQzSD+gR8QHnGzP9MT6vbtr/ic9Vr86S+2SOe2ZmE6QGQJBKCNULG0BupMxKKcpUE22Vz+d/NA3ooap3Ksv/2c94s251l/5tpkePqHo8EwyKIJV2Fk1/J5lSpSjz+n3HfuK8LnTeH8unRrag897yi7wO0V/LFzVeRfM2ndebaNs/CoC56C7NtHDbqdn2tmP5uCOvM13Vq4+VtrtSNLJn2n4M5kPG+mOz/k99q8e6h7t7sjzmW7OfHzKOi7t7HShJpdlq2EwyabsUlXqkY+XOlWzgqom+5DUxsy56Xqg2GPqn9xvZI10+7V8tZslq1Te5maze+fP/95k8fPY9ZTSDARCk0mwg+I/ks21RPhikLkT7wR1lBJ3X0XEV3RVMBM5G9s9X1+LG75wqtA/S9yaPB1vlC4H4PJr9vQBDS1SNcp/DOo+2e5G5XpWRd9ybuZYM4X+Zmu0O8uygtqt67wq2exdVh4pLkrq/zpzTaB+nGcdFda8DJam2KprOLbLnlKJy9pfSRMeRW5pqZH0j/l5oe8QDP+N97uYu/T2ady4PTPgf/XtSmdkTBj4cBkGq26xrhVCyqcyspiC/30iH0MfKVvtq7RhoLXyL23UuS/p6DSiupt0XBHDPX+i2yvd7eSBCSciPyOpLqPaLw0etrwXYBW2PXFBnbDM2608L8z+X/GOz+7nbVPWJqh5FdyiHohtGPCjII354t7hKpLsbquVa04MgNgV5Ud3rwKB3bVXJytp+xq+R3bk0+1o0or9OHJPvj1SZWYccbK82r+M7djn8Nv+SZR8rX5o6l+3Os8/DjxHW59GkdYPeLVzu++4pjoy2+ybVHeuPS0osukVJKmwfN6KfRssfRGN5OJaNIx4U5nPXNx9l+OBHjTaptrihfG2bUfhA1WZWI+XuCtdvZPUYrxLLq/B6JgdoLPc0PeLBRPqz7ViVPozHZBbPGH7lSk+/LRklA/mo7nV7tmFZY17n3tGLqwr/lQK+Ed1doD7wLILTohHd9zuqZLWx/M2BGssXbOdLH7W+36KkYPurLUZGmEh/76WsD9zCLPz9yN07HIRm9pNKrJf1pL62+0nV0oO2B+SLHaSx3Bxf16/BbOungmPZeT+pPpTqXhaqe21xda9as15jXs8k/yHkKpru+23cVY17KQeiyxEPKtmdxzQyArZAkIqEPkk2ULXapMK3nS05fVtQrfo8sb8+ftex/JUcTmrEg13s4xvBk0ebVNpMlg+K1onlTbTujeSrzOtepagQJBtZPQaf7BhV/i5lfcDGXBtE/WgQf5Dt+cDk+1nVYdq3x434WXIcHW3f5h+ZZXFb1FVBvqNo217dA7TdqbMy+dtb9VPd8+ifa9p/sp5/zMz7dWnetEk9blT30uISji0VNNGydaNLptTR9EQKhYurNrN+edwllChsW1Ul6V9d3qXUiAe5z+rluIn29WAekwH2Zl2JJ1GKKioJaftHJKvC7eOOmtM1642j/ZzKnmi74+Ugv0Ic5T+OSh+jjm0oSeHp0cRzctsEGW0HuLEUyt2/zn/mXXcVKDYcn0/xD39mdc0o3EdRlY8ghSdJ24+vvI2nC/O7iLYvuni13b+q6Vj/uuRCHoKmx3+qZEDhwn4WnYsPGdsQpPD06ObOiFVBPnEpqnTc9FF0LP71KGMbW+3qPUZ45jGmRjzYSQlOC38tmCD1uNFwvtm6DpE3uf2iQmCIq3aNlIlHNWgybrv75X820/7xnr/JbtXmdc445n3Foynwa8E4XtpuB1ItG773Ntr2WgroFo31pSWObWiPBu0t9rWo8q2UFDvWpySFp0nn1aY40ExdeqHrG679NhfaHhDttvTC1XY1ryrYNlUF+6QDBw8dYHC7Hvv0QfEmusBrWX98BCk8XWsC1cJYzY9uhunUaI19AtRFlEfxnbLEheA1MiBN33H7UnZI02NEXWWuS5DC06Ttnui5/HalAcpX82ywu5UedFk1mu7qYtD2iAfZIxQMsN/OmwNKkHrUaDgv4Bqr/W3855LfIDxx6Qu/XY/ny3ypyW8zC+kr6cHt1//xA+t9bfL60aW/yAB0ObidmPx31WDe2r3Mf4B1FtK9rB95wZ5Ln0oHG9wVfxwzkxA5EfQSvrFrmT/Q618vRkvwAcA/VjPhwVcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwJH7H5NlZI/0GQ+cAAAAAElFTkSuQmCC", }, } -`; \ No newline at end of file +`; diff --git a/ui/pages/swaps/loading-swaps-quotes/background-animation.js b/ui/pages/swaps/loading-swaps-quotes/background-animation.js index 3820bffd0..6cbbfea1c 100644 --- a/ui/pages/swaps/loading-swaps-quotes/background-animation.js +++ b/ui/pages/swaps/loading-swaps-quotes/background-animation.js @@ -3,7 +3,10 @@ import React from 'react'; export default function BackgroundAnimation() { return ( <> -
    +
    -
    +
    { it('renders the component', () => { - const { container } = renderWithProvider(); - expect(container.firstChild.nodeName).toBe('DIV'); + const { container, getByTestId } = renderWithProvider( + , + ); + expect( + getByTestId('loading-swaps-quotes-background-1'), + ).toBeInTheDocument(); + expect( + getByTestId('loading-swaps-quotes-background-2'), + ).toBeInTheDocument(); expect(container.firstChild.firstChild.nodeName).toBe('svg'); }); }); diff --git a/ui/pages/swaps/main-quote-summary/__snapshots__/main-quote-summary.test.js.snap b/ui/pages/swaps/main-quote-summary/__snapshots__/main-quote-summary.test.js.snap index 078c54117..7b58f794f 100644 --- a/ui/pages/swaps/main-quote-summary/__snapshots__/main-quote-summary.test.js.snap +++ b/ui/pages/swaps/main-quote-summary/__snapshots__/main-quote-summary.test.js.snap @@ -80,43 +80,37 @@ exports[`MainQuoteSummary renders the component with initial props 4`] = `
    - - 1 - - - ETH - - - = - - - 0.1 - - - BAT -
    - + 1 + + - - + ETH + + + = + + + 0.1 + + + BAT +
    +
    `; diff --git a/ui/pages/swaps/mascot-background-animation/index.scss b/ui/pages/swaps/mascot-background-animation/index.scss new file mode 100644 index 000000000..be05be84c --- /dev/null +++ b/ui/pages/swaps/mascot-background-animation/index.scss @@ -0,0 +1,60 @@ +.mascot-background-animation { + display: flex; + flex-flow: column; + align-items: center; + flex: 1; + width: 100%; + + &__background-1, + &__background-2 { + width: 120px; + height: 100px; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + -webkit-animation: spin 38s linear infinite; + -moz-animation: spin 38s linear infinite; + animation: spin 38s linear infinite; + + @-moz-keyframes spin { + 100% { + -moz-transform: rotate(360deg); + } + } + + @-webkit-keyframes spin { + 100% { + -webkit-transform: rotate(360deg); + } + } + + @keyframes spin { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } + } + + &__background-2 { + width: 120px; + height: 128px; + -webkit-animation: spin 42s linear infinite; + -moz-animation: spin 42s linear infinite; + animation: spin 42s linear infinite; + } + + &__mascot-container { + position: relative; + } + + &__animation { + display: flex; + justify-content: center; + align-items: center; + position: relative; + margin-top: 40px; + margin-bottom: 40px; + } +} diff --git a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js new file mode 100644 index 000000000..805c56f7a --- /dev/null +++ b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js @@ -0,0 +1,229 @@ +import EventEmitter from 'events'; +import React, { useRef } from 'react'; + +import Mascot from '../../../components/ui/mascot'; + +export default function MascotBackgroundAnimation() { + const animationEventEmitter = useRef(new EventEmitter()); + + return ( +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + ); +} diff --git a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.test.js b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.test.js new file mode 100644 index 000000000..fcbbc5f94 --- /dev/null +++ b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.test.js @@ -0,0 +1,19 @@ +import React from 'react'; + +import { renderWithProvider } from '../../../../test/jest'; +import MascotBackgroundAnimation from './mascot-background-animation'; + +describe('MascotBackgroundAnimation', () => { + it('renders the component', () => { + const { getByTestId } = renderWithProvider(); + expect( + getByTestId('mascot-background-animation-background-1'), + ).toBeInTheDocument(); + expect( + getByTestId('mascot-background-animation-background-2'), + ).toBeInTheDocument(); + expect( + getByTestId('mascot-background-animation-mascot-container'), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/notification-page/index.scss b/ui/pages/swaps/notification-page/index.scss new file mode 100644 index 000000000..aed09b426 --- /dev/null +++ b/ui/pages/swaps/notification-page/index.scss @@ -0,0 +1,13 @@ +.notification-page { + display: flex; + flex-flow: column; + height: 100%; + + &__content { + flex: 1; + } + + &__warning-icon { + font-size: 54px; + } +} diff --git a/ui/pages/swaps/notification-page/notification-page.js b/ui/pages/swaps/notification-page/notification-page.js new file mode 100644 index 000000000..a9835d4b1 --- /dev/null +++ b/ui/pages/swaps/notification-page/notification-page.js @@ -0,0 +1,79 @@ +import React, { useContext } from 'react'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import { setSwapsErrorKey } from '../../../store/actions'; +import Box from '../../../components/ui/box'; +import { + DISPLAY, + AlignItems, + TextVariant, + FLEX_DIRECTION, + TEXT_ALIGN, + IconColor, +} from '../../../helpers/constants/design-system'; +import { Text, Icon, IconName } from '../../../components/component-library'; +import { PREPARE_SWAP_ROUTE } from '../../../helpers/constants/routes'; +import SwapsFooter from '../swaps-footer'; +import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps'; + +export default function NotificationPage({ notificationKey }) { + const t = useContext(I18nContext); + const history = useHistory(); + const dispatch = useDispatch(); + + // TODO: Either add default values or redirect a user out if a notificationKey value is not supported. + let title = ''; + let description = ''; + let buttonText = ''; + + if (notificationKey === QUOTES_EXPIRED_ERROR) { + title = t('swapAreYouStillThere'); + description = t('swapAreYouStillThereDescription'); + buttonText = t('swapShowLatestQuotes'); + } + + return ( +
    + + + + + + {title} + + + {description} + + + { + await dispatch(setSwapsErrorKey('')); + history.push(PREPARE_SWAP_ROUTE); + }} + submitText={buttonText} + hideCancel + showTermsOfService + /> +
    + ); +} + +NotificationPage.propTypes = { + notificationKey: PropTypes.oneOf([QUOTES_EXPIRED_ERROR]), +}; diff --git a/ui/pages/swaps/notification-page/notification-page.test.js b/ui/pages/swaps/notification-page/notification-page.test.js new file mode 100644 index 000000000..986d03cae --- /dev/null +++ b/ui/pages/swaps/notification-page/notification-page.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { + renderWithProvider, + createSwapsMockStore, +} from '../../../../test/jest'; +import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps'; +import NotificationPage from './notification-page'; + +const middleware = [thunk]; + +describe('NotificationPage', () => { + it('renders the component with the QUOTES_EXPIRED_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Are you still there?')).toBeInTheDocument(); + expect( + getByText( + 'We’re ready to show you the latest quotes when you want to continue', + ), + ).toBeInTheDocument(); + expect(getByText('Show latest quotes')).toBeInTheDocument(); + expect(getByText('Terms of service')).toBeInTheDocument(); + }); + + it('renders the component with an unsupported error key', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText, queryByText } = renderWithProvider( + , + store, + ); + expect(queryByText('Are you still there?')).not.toBeInTheDocument(); + expect( + queryByText( + 'We’re ready to show you the latest quotes when you want to continue', + ), + ).not.toBeInTheDocument(); + expect(queryByText('Show latest quotes')).not.toBeInTheDocument(); + expect(getByText('Terms of service')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/popover-custom-background/index.scss b/ui/pages/swaps/popover-custom-background/index.scss new file mode 100644 index 000000000..07bc852ed --- /dev/null +++ b/ui/pages/swaps/popover-custom-background/index.scss @@ -0,0 +1,6 @@ +.popover-custom-background { + height: 100%; + width: 100%; + background: var(--color-background-alternative); + opacity: 0.6; +} diff --git a/ui/pages/swaps/popover-custom-background/popover-custom-background.js b/ui/pages/swaps/popover-custom-background/popover-custom-background.js new file mode 100644 index 000000000..8e8af648a --- /dev/null +++ b/ui/pages/swaps/popover-custom-background/popover-custom-background.js @@ -0,0 +1,14 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Box from '../../../components/ui/box'; + +const PopoverCustomBackground = ({ onClose }) => { + return ; +}; + +export default PopoverCustomBackground; + +PopoverCustomBackground.propTypes = { + onClose: PropTypes.func, +}; diff --git a/ui/pages/swaps/prepare-swap-page/__snapshots__/prepare-swap-page.test.js.snap b/ui/pages/swaps/prepare-swap-page/__snapshots__/prepare-swap-page.test.js.snap new file mode 100644 index 000000000..8991347ee --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/__snapshots__/prepare-swap-page.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PrepareSwapPage renders the component with initial props 1`] = `null`; diff --git a/ui/pages/swaps/prepare-swap-page/index.scss b/ui/pages/swaps/prepare-swap-page/index.scss new file mode 100644 index 000000000..06e617670 --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/index.scss @@ -0,0 +1,404 @@ +.prepare-swap-page { + display: flex; + flex-flow: column; + flex: 1; + width: 100%; + + &__content { + display: flex; + height: 100%; + flex-direction: column; + margin-left: 16px; + margin-right: 16px; + + @include screen-sm-min { + margin-left: 24px; + margin-right: 24px; + } + } + + &__swap-from-content { + padding: 24px 16px 20px 16px; + border-radius: 6px 6px 0 0; + box-shadow: none; + border: 1px solid var(--color-border-muted); + margin-top: 16px; + position: relative; + + .dropdown-input-pair__input { + input { + text-align: right; + } + } + + .MuiInputBase-root { + padding-right: 0; + } + } + + &__swap-to-content { + padding: 28px 16px 20px 16px; + border-radius: 0 0 6px 6px; + box-shadow: none; + border: 1px solid var(--color-border-muted); + border-top: 0; + } + + &__switch-tokens { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + padding: 7px; + background: var(--color-background-default); + border: 1px solid var(--color-border-muted); + transition: all 0.3s ease-in-out; + cursor: pointer; + + .mm-icon { + color: var(--color-icon-alternative); + transition: all 0.3s ease-in-out; + } + + &:hover { + background: var(--color-background-default-hover); + + .mm-icon { + color: var(--color-icon-default); + } + } + + &:active { + background: var(--color-background-default-pressed); + + .mm-icon { + color: var(--color-icon-default); + } + } + + &--rotate { + transform: rotate(360deg); + } + + &--disabled { + cursor: not-allowed; + } + } + + &__max-balance { + @include H7; + + color: var(--color-primary-default); + cursor: pointer; + padding-left: 4px; + } + + &__balance-message { + @include H7; + + width: 100%; + color: var(--color-text-alternative); + margin-top: 4px; + display: flex; + flex-flow: row; + height: 18px; + + &--error { + div:first-of-type { + font-weight: bold; + color: var(--color-text-default); + } + + .prepare-swap-page__form-error:first-of-type { + font-weight: bold; + color: var(--color-error-default); + } + + div:last-of-type { + font-weight: normal; + color: var(--color-text-alternative); + } + } + } + + .dropdown-search-list { + background-color: var(--color-background-alternative); + border-radius: 100px; + + &__select-default { + color: var(--color-text-default); + } + + &__labels { + flex: auto; + max-width: 110px; + + &--with-icon { + max-width: 95px; + } + } + + &__closed-primary-label { + font-weight: 500; + } + + &__selector-closed-container { + border: 0; + border-radius: 100px; + height: 32px; + max-height: 32px; + max-width: 165px; + width: auto; + } + + &__selector-closed-icon { + width: 24px; + height: 24px; + margin-right: 8px; + } + + &__selector-closed { + height: 32px; + max-width: 140px; + + div { + display: flex; + } + + &__item-labels { + width: 100%; + margin-left: 0; + } + } + } + + .dropdown-input-pair { + height: 32px; + width: auto; + + &__selector--closed { + height: 32px; + border-top-right-radius: 100px; + border-bottom-right-radius: 100px; + } + + .searchable-item-list { + &__item--add-token { + display: none; + } + } + + &__to { + display: flex; + justify-content: space-between; + align-items: center; + + .searchable-item-list { + &__item--add-token { + display: flex; + } + } + } + + &__input { + div { + border: 0; + } + } + + &__two-line-input { + input { + padding-bottom: 0; + } + } + } + + &__token-etherscan-link { + color: var(--color-primary-default); + cursor: pointer; + } + + &__bold { + font-weight: bold; + } + + &__underline { + text-decoration: underline; + } + + &__from-token-amount { + border: 0; + outline: none; + + input { + padding-right: 0; + text-align: right; + font-weight: var(--typography-s-heading-lg-font-weight); + font-size: var(--typography-s-heading-lg-font-size); + overflow: hidden; + text-overflow: ellipsis; + } + + &--lg { + input { + font-weight: var(--typography-s-heading-lg-font-weight); + font-size: var(--typography-s-heading-lg-font-size); + } + } + + &--md { + input { + font-weight: var(--typography-s-heading-md-font-weight); + font-size: var(--typography-s-heading-md-font-size); + } + } + + &--sm { + input { + font-weight: var(--typography-s-heading-sm-font-weight); + font-size: var(--typography-s-heading-sm-font-size); + } + } + } + + &__receive-amount-container { + overflow: hidden; + } + + &__receive-amount { + font-weight: var(--typography-s-heading-lg-font-weight); + font-size: var(--typography-s-heading-lg-font-size); + overflow: hidden; + text-overflow: ellipsis; + + &--lg { + font-weight: var(--typography-s-heading-lg-font-weight); + font-size: var(--typography-s-heading-lg-font-size); + } + + &--md { + font-weight: var(--typography-s-heading-md-font-weight); + font-size: var(--typography-s-heading-md-font-size); + } + + &--sm { + font-weight: var(--typography-s-heading-sm-font-weight); + font-size: var(--typography-s-heading-sm-font-size); + } + } + + footer { + .btn-primary { + width: 100%; + } + } +} + +.review-quote { + display: flex; + flex-flow: column; + height: 100%; + + &__overview { + width: 100%; + } + + .main-quote-summary { + &__exchange-rate-display { + width: auto; + } + } + + &::after { // Hide preloaded images. + position: absolute; + width: 0; + height: 0; + overflow: hidden; + z-index: -1; + content: url('/images/transaction-background-top.svg') url('/images/transaction-background-bottom.svg'); // Preload images for the STX status page. + } + + &__content { + display: flex; + flex-flow: column; + align-items: center; + width: 100%; + flex: 1; + + @include screen-sm-max { + overflow-y: auto; + max-height: 420px; + } + } + + &__bold { + font-weight: bold; + } + + &__countdown-timer-container { + display: flex; + justify-content: center; + margin-top: 8px; + } + + &__thin-swaps-footer { + max-height: 82px; + + @include screen-sm-min { + height: 72px; + } + } + + &__footer { + footer { + padding: 16px 0; + + .btn-primary { + width: 100%; + } + } + } + + &__edit-limit { + white-space: nowrap; + } +} + +@keyframes slide-in { + 100% { transform: translateY(0%); } +} + +.smart-transactions-popover { + transform: translateY(-100%); + animation: slide-in 0.5s forwards; + + &__content { + flex-direction: column; + + ul { + list-style: inside; + } + + a { + color: var(--color-primary-default); + cursor: pointer; + } + } + + &__footer { + flex-direction: column; + flex: 1; + align-items: center; + border-top: 0; + + button { + border-radius: 50px; + } + + a { + font-size: inherit; + padding-bottom: 0; + } + } +} diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js new file mode 100644 index 000000000..e1f0c5f0e --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -0,0 +1,1098 @@ +import React, { useContext, useEffect, useState, useCallback } from 'react'; +import BigNumber from 'bignumber.js'; +import PropTypes from 'prop-types'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { uniqBy, isEqual } from 'lodash'; +import { useHistory } from 'react-router-dom'; +import { getTokenTrackerLink } from '@metamask/etherscan-link'; +import classnames from 'classnames'; + +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + useTokensToSearch, + getRenderableTokenData, +} from '../../../hooks/useTokensToSearch'; +import { useEqualityCheck } from '../../../hooks/useEqualityCheck'; +import { I18nContext } from '../../../contexts/i18n'; +import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; +import Box from '../../../components/ui/box'; +import { + DISPLAY, + TextColor, + JustifyContent, + AlignItems, + SEVERITIES, + Size, + TextVariant, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { + fetchQuotesAndSetQuoteState, + setSwapsFromToken, + setSwapToToken, + getFromToken, + getToToken, + getBalanceError, + getTopAssets, + getFetchParams, + getQuotes, + setBalanceError, + setFromTokenInputValue, + setFromTokenError, + setMaxSlippage, + setReviewSwapClickedTimestamp, + getSmartTransactionsOptInStatus, + getSmartTransactionsEnabled, + getCurrentSmartTransactionsEnabled, + getFromTokenInputValue, + getFromTokenError, + getMaxSlippage, + getIsFeatureFlagLoaded, + getCurrentSmartTransactionsError, + getFetchingQuotes, + getSwapsErrorKey, + getAggregatorMetadata, + getTransactionSettingsOpened, + setTransactionSettingsOpened, +} from '../../../ducks/swaps/swaps'; +import { + getSwapsDefaultToken, + getTokenExchangeRates, + getCurrentCurrency, + getCurrentChainId, + getRpcPrefsForCurrentProvider, + getTokenList, + isHardwareWallet, + getHardwareWalletType, +} from '../../../selectors'; +import { + getValueFromWeiHex, + hexToDecimal, +} from '../../../../shared/modules/conversion.utils'; +import { getURLHostName } from '../../../helpers/utils/util'; +import { usePrevious } from '../../../hooks/usePrevious'; +import { useTokenTracker } from '../../../hooks/useTokenTracker'; +import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; +import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; +import { + isSwapsDefaultTokenAddress, + isSwapsDefaultTokenSymbol, +} from '../../../../shared/modules/swaps.utils'; +import { + MetaMetricsEventCategory, + MetaMetricsEventLinkType, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { + SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, + TokenBucketPriority, + ERROR_FETCHING_QUOTES, + QUOTES_NOT_AVAILABLE_ERROR, + QUOTES_EXPIRED_ERROR, +} from '../../../../shared/constants/swaps'; +import { + resetSwapsPostFetchState, + clearSwapsQuotes, + stopPollingForQuotes, + setSmartTransactionsOptInStatus, + clearSmartTransactionFees, + setSwapsErrorKey, + setBackgroundSwapRouteState, +} from '../../../store/actions'; +import { + countDecimals, + fetchTokenPrice, + formatSwapsValueForDisplay, + getClassNameForCharLength, +} from '../swaps.util'; +import { fetchTokenBalance } from '../../../../shared/lib/token-util.ts'; +import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; +import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; +import { shouldEnableDirectWrapping } from '../../../../shared/lib/swaps-utils'; +import { + Icon, + IconName, + IconSize, + TextField, + ButtonLink, + Text, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, +} from '../../../components/component-library'; +import { BannerAlert } from '../../../components/component-library/banner-alert'; +import { SWAPS_NOTIFICATION_ROUTE } from '../../../helpers/constants/routes'; +import ImportToken from '../import-token'; +import TransactionSettings from '../transaction-settings/transaction-settings'; +import SwapsBannerAlert from '../swaps-banner-alert/swaps-banner-alert'; +import SwapsFooter from '../swaps-footer'; +import SelectedToken from '../selected-token/selected-token'; +import ListWithSearch from '../list-with-search/list-with-search'; +import SmartTransactionsPopover from './smart-transactions-popover'; +import QuotesLoadingAnimation from './quotes-loading-animation'; +import ReviewQuote from './review-quote'; + +const MAX_ALLOWED_SLIPPAGE = 15; + +let timeoutIdForQuotesPrefetching; + +export default function PrepareSwapPage({ + ethBalance, + selectedAccountAddress, + shuffledTokensList, +}) { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const history = useHistory(); + const trackEvent = useContext(MetaMetricsContext); + + const [fetchedTokenExchangeRate, setFetchedTokenExchangeRate] = + useState(undefined); + const [verificationClicked, setVerificationClicked] = useState(false); + const [receiveToAmount, setReceiveToAmount] = useState(); + const [isSwapToOpen, setIsSwapToOpen] = useState(false); + const onSwapToOpen = () => setIsSwapToOpen(true); + const onSwapToClose = () => setIsSwapToOpen(false); + const [isSwapFromOpen, setIsSwapFromOpen] = useState(false); + const onSwapFromOpen = () => setIsSwapFromOpen(true); + const onSwapFromClose = () => setIsSwapFromOpen(false); + const [isImportTokenModalOpen, setIsImportTokenModalOpen] = useState(false); + const [tokenForImport, setTokenForImport] = useState(null); + const [swapFromSearchQuery, setSwapFromSearchQuery] = useState(''); + const [swapToSearchQuery, setSwapToSearchQuery] = useState(''); + const [quoteCount, updateQuoteCount] = useState(0); + const [prefetchingQuotes, setPrefetchingQuotes] = useState(false); + const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false); + + const isFeatureFlagLoaded = useSelector(getIsFeatureFlagLoaded); + const balanceError = useSelector(getBalanceError); + const fetchParams = useSelector(getFetchParams, isEqual); + const { sourceTokenInfo = {}, destinationTokenInfo = {} } = + fetchParams?.metaData || {}; + const tokens = useSelector(getTokens, isEqual); + const topAssets = useSelector(getTopAssets, isEqual); + const fromToken = useSelector(getFromToken, isEqual); + const fromTokenInputValue = useSelector(getFromTokenInputValue); + const fromTokenError = useSelector(getFromTokenError); + const maxSlippage = useSelector(getMaxSlippage); + const toToken = useSelector(getToToken, isEqual) || destinationTokenInfo; + const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual); + const chainId = useSelector(getCurrentChainId); + const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider, shallowEqual); + const tokenList = useSelector(getTokenList, isEqual); + const quotes = useSelector(getQuotes, isEqual); + const numberOfQuotes = Object.keys(quotes).length; + const areQuotesPresent = numberOfQuotes > 0; + const swapsErrorKey = useSelector(getSwapsErrorKey); + const aggregatorMetadata = useSelector(getAggregatorMetadata, shallowEqual); + const transactionSettingsOpened = useSelector( + getTransactionSettingsOpened, + shallowEqual, + ); + const numberOfAggregators = aggregatorMetadata + ? Object.keys(aggregatorMetadata).length + : 0; + + const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); + const conversionRate = useSelector(getConversionRate); + const hardwareWalletUsed = useSelector(isHardwareWallet); + const hardwareWalletType = useSelector(getHardwareWalletType); + const smartTransactionsOptInStatus = useSelector( + getSmartTransactionsOptInStatus, + ); + const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled); + const currentSmartTransactionsEnabled = useSelector( + getCurrentSmartTransactionsEnabled, + ); + const isSmartTransaction = + currentSmartTransactionsEnabled && smartTransactionsOptInStatus; + const smartTransactionsOptInPopoverDisplayed = + smartTransactionsOptInStatus !== undefined; + const currentSmartTransactionsError = useSelector( + getCurrentSmartTransactionsError, + ); + const currentCurrency = useSelector(getCurrentCurrency); + const fetchingQuotes = useSelector(getFetchingQuotes); + const loadingComplete = !fetchingQuotes && areQuotesPresent; + + const showSmartTransactionsOptInPopover = + smartTransactionsEnabled && !smartTransactionsOptInPopoverDisplayed; + + const onCloseSmartTransactionsOptInPopover = (e) => { + e?.preventDefault(); + setSmartTransactionsOptInStatus(false, smartTransactionsOptInStatus); + }; + + const onEnableSmartTransactionsClick = () => + setSmartTransactionsOptInStatus(true, smartTransactionsOptInStatus); + + const fetchParamsFromToken = isSwapsDefaultTokenSymbol( + sourceTokenInfo?.symbol, + chainId, + ) + ? defaultSwapsToken + : sourceTokenInfo; + + const { tokensWithBalances } = useTokenTracker(tokens); + + // If the fromToken was set in a call to `onFromSelect` (see below), and that from token has a balance + // but is not in tokensWithBalances or tokens, then we want to add it to the usersTokens array so that + // the balance of the token can appear in the from token selection dropdown + const fromTokenArray = + !isSwapsDefaultTokenSymbol(fromToken?.symbol, chainId) && fromToken?.balance + ? [fromToken] + : []; + const usersTokens = uniqBy( + [...tokensWithBalances, ...tokens, ...fromTokenArray], + 'address', + ); + const memoizedUsersTokens = useEqualityCheck(usersTokens); + + const selectedFromToken = getRenderableTokenData( + fromToken || fetchParamsFromToken, + tokenConversionRates, + conversionRate, + currentCurrency, + chainId, + tokenList, + ); + + const tokensToSearchSwapFrom = useTokensToSearch({ + usersTokens: memoizedUsersTokens, + topTokens: topAssets, + shuffledTokensList, + tokenBucketPriority: TokenBucketPriority.owned, + }); + const tokensToSearchSwapTo = useTokensToSearch({ + usersTokens: memoizedUsersTokens, + topTokens: topAssets, + shuffledTokensList, + tokenBucketPriority: TokenBucketPriority.top, + }); + const selectedToToken = + tokensToSearchSwapFrom.find(({ address }) => + isEqualCaseInsensitive(address, toToken?.address), + ) || toToken; + const toTokenIsNotDefault = + selectedToToken?.address && + !isSwapsDefaultTokenAddress(selectedToToken?.address, chainId); + const occurrences = Number( + selectedToToken?.occurances || selectedToToken?.occurrences || 0, + ); + const { + address: fromTokenAddress, + symbol: fromTokenSymbol, + string: fromTokenString, + decimals: fromTokenDecimals, + balance: rawFromTokenBalance, + } = selectedFromToken || {}; + const { address: toTokenAddress } = selectedToToken || {}; + + const fromTokenBalance = + rawFromTokenBalance && + calcTokenAmount(rawFromTokenBalance, fromTokenDecimals).toString(10); + + const prevFromTokenBalance = usePrevious(fromTokenBalance); + + const swapFromTokenFiatValue = useTokenFiatAmount( + fromTokenAddress, + fromTokenInputValue || 0, + fromTokenSymbol, + { + showFiat: true, + }, + true, + ); + const swapFromEthFiatValue = useEthFiatAmount( + fromTokenInputValue || 0, + { showFiat: true }, + true, + ); + const swapFromFiatValue = isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) + ? swapFromEthFiatValue + : swapFromTokenFiatValue; + + const onInputChange = useCallback( + (newInputValue, balance) => { + dispatch(setFromTokenInputValue(newInputValue)); + const newBalanceError = new BigNumber(newInputValue || 0).gt( + balance || 0, + ); + // "setBalanceError" is just a warning, a user can still click on the "Review swap" button. + if (balanceError !== newBalanceError) { + dispatch(setBalanceError(newBalanceError)); + } + dispatch( + setFromTokenError( + fromToken && countDecimals(newInputValue) > fromToken.decimals + ? 'tooManyDecimals' + : null, + ), + ); + }, + [dispatch, fromToken, balanceError], + ); + + useEffect(() => { + let timeoutLength; + + if (!prefetchingQuotes) { + updateQuoteCount(0); + return; + } + + const onQuotesLoadingDone = async () => { + await dispatch(setBackgroundSwapRouteState('')); + setPrefetchingQuotes(false); + if ( + swapsErrorKey === ERROR_FETCHING_QUOTES || + swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR + ) { + dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)); + } + }; + + // The below logic simulates a sequential loading of the aggregator quotes, even though we are fetching them all with a single call. + // This is to give the user a sense of progress. The callback passed to `setTimeout` updates the quoteCount and therefore causes + // a new logo to be shown, the fox to look at that logo, the logo bar and aggregator name to update. + + if (loadingComplete) { + // If loading is complete, but the quoteCount is not, we quickly display the remaining logos/names/fox looks. 0.2s each + timeoutLength = 20; + } else { + // If loading is not complete, we display remaining logos/names/fox looks at random intervals between 0.5s and 2s, to simulate the + // sort of loading a user would experience in most async scenarios + timeoutLength = 500 + Math.floor(Math.random() * 1500); + } + const quoteCountTimeout = setTimeout(() => { + if (quoteCount < numberOfAggregators) { + updateQuoteCount(quoteCount + 1); + } else if (quoteCount === numberOfAggregators && loadingComplete) { + onQuotesLoadingDone(); + } + }, timeoutLength); + + // eslint-disable-next-line consistent-return + return function cleanup() { + clearTimeout(quoteCountTimeout); + }; + }, [ + fetchingQuotes, + quoteCount, + loadingComplete, + numberOfQuotes, + dispatch, + history, + swapsErrorKey, + numberOfAggregators, + prefetchingQuotes, + ]); + + const onFromSelect = (token) => { + if ( + token?.address && + !swapFromFiatValue && + fetchedTokenExchangeRate !== null + ) { + fetchTokenPrice(token.address).then((rate) => { + if (rate !== null && rate !== undefined) { + setFetchedTokenExchangeRate(rate); + } + }); + } else { + setFetchedTokenExchangeRate(null); + } + if ( + token?.address && + !memoizedUsersTokens.find((usersToken) => + isEqualCaseInsensitive(usersToken.address, token.address), + ) + ) { + fetchTokenBalance( + token.address, + selectedAccountAddress, + global.ethereumProvider, + ).then((fetchedBalance) => { + if (fetchedBalance?.balance) { + const balanceAsDecString = fetchedBalance.balance.toString(10); + const userTokenBalance = calcTokenAmount( + balanceAsDecString, + token.decimals, + ); + dispatch( + setSwapsFromToken({ + ...token, + string: userTokenBalance.toString(10), + balance: balanceAsDecString, + }), + ); + } + }); + } + dispatch(setSwapsFromToken(token)); + onInputChange(fromTokenInputValue, token.string, token.decimals); + }; + + const blockExplorerTokenLink = getTokenTrackerLink( + selectedToToken.address, + chainId, + null, // no networkId + null, // no holderAddress + { + blockExplorerUrl: + SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, + }, + ); + + const blockExplorerLabel = rpcPrefs.blockExplorerUrl + ? getURLHostName(blockExplorerTokenLink) + : t('etherscan'); + + const onToSelect = useCallback( + (token) => { + dispatch(setSwapToToken(token)); + setVerificationClicked(false); + }, + [dispatch], + ); + + const tokensWithBalancesFromToken = tokensWithBalances.find((token) => + isEqualCaseInsensitive(token.address, fromToken?.address), + ); + const previousTokensWithBalancesFromToken = usePrevious( + tokensWithBalancesFromToken, + ); + + useEffect(() => { + const notDefault = !isSwapsDefaultTokenAddress( + tokensWithBalancesFromToken?.address, + chainId, + ); + const addressesAreTheSame = isEqualCaseInsensitive( + tokensWithBalancesFromToken?.address, + previousTokensWithBalancesFromToken?.address, + ); + const balanceHasChanged = + tokensWithBalancesFromToken?.balance !== + previousTokensWithBalancesFromToken?.balance; + if (notDefault && addressesAreTheSame && balanceHasChanged) { + dispatch( + setSwapsFromToken({ + ...fromToken, + balance: tokensWithBalancesFromToken?.balance, + string: tokensWithBalancesFromToken?.string, + }), + ); + } + }, [ + dispatch, + tokensWithBalancesFromToken, + previousTokensWithBalancesFromToken, + fromToken, + chainId, + ]); + + // If the eth balance changes while on build quote, we update the selected from token + useEffect(() => { + if ( + isSwapsDefaultTokenAddress(fromToken?.address, chainId) && + fromToken?.balance !== hexToDecimal(ethBalance) + ) { + dispatch( + setSwapsFromToken({ + ...fromToken, + balance: hexToDecimal(ethBalance), + string: getValueFromWeiHex({ + value: ethBalance, + numberOfDecimals: 4, + toDenomination: 'ETH', + }), + }), + ); + } + }, [dispatch, fromToken, ethBalance, chainId]); + + useEffect(() => { + if (!fromToken?.symbol && !fetchParamsFromToken?.symbol) { + dispatch(setSwapsFromToken(defaultSwapsToken)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (prevFromTokenBalance !== fromTokenBalance) { + onInputChange(fromTokenInputValue, fromTokenBalance); + } + }, [ + onInputChange, + prevFromTokenBalance, + fromTokenInputValue, + fromTokenBalance, + ]); + + const trackPrepareSwapPageLoadedEvent = useCallback(() => { + trackEvent({ + event: 'Prepare Swap Page Loaded', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + is_hardware_wallet: hardwareWalletUsed, + hardware_wallet_type: hardwareWalletType, + stx_enabled: smartTransactionsEnabled, + current_stx_enabled: currentSmartTransactionsEnabled, + stx_user_opt_in: smartTransactionsOptInStatus, + }, + }); + }, [ + trackEvent, + hardwareWalletUsed, + hardwareWalletType, + smartTransactionsEnabled, + currentSmartTransactionsEnabled, + smartTransactionsOptInStatus, + ]); + + useEffect(() => { + dispatch(resetSwapsPostFetchState()); + dispatch(setReviewSwapClickedTimestamp()); + trackPrepareSwapPageLoadedEvent(); + }, [dispatch, trackPrepareSwapPageLoadedEvent]); + + const BlockExplorerLink = () => { + return ( + { + /* istanbul ignore next */ + trackEvent({ + event: MetaMetricsEventName.ExternalLinkClicked, + category: MetaMetricsEventCategory.Swaps, + properties: { + link_type: MetaMetricsEventLinkType.TokenTracker, + location: 'Swaps Confirmation', + url_domain: getURLHostName(blockExplorerTokenLink), + }, + }); + global.platform.openTab({ + url: blockExplorerTokenLink, + }); + }} + target="_blank" + rel="noopener noreferrer" + > + {blockExplorerLabel} + + ); + }; + + const swapYourTokenBalance = `${t('balance')}: ${fromTokenString || '0'}`; + + const isDirectWrappingEnabled = shouldEnableDirectWrapping( + chainId, + fromTokenAddress, + selectedToToken.address, + ); + const isReviewSwapButtonDisabled = + fromTokenError || + !isFeatureFlagLoaded || + !Number(fromTokenInputValue) || + !selectedToToken?.address || + !fromTokenAddress || + Number(maxSlippage) < 0 || + Number(maxSlippage) > MAX_ALLOWED_SLIPPAGE || + (toTokenIsNotDefault && occurrences < 2 && !verificationClicked); + + // It's triggered every time there is a change in form values (token from, token to, amount and slippage). + useEffect(() => { + dispatch(clearSwapsQuotes()); + dispatch(stopPollingForQuotes()); + const prefetchQuotesWithoutRedirecting = async () => { + setPrefetchingQuotes(true); + const pageRedirectionDisabled = true; + await dispatch( + fetchQuotesAndSetQuoteState( + history, + fromTokenInputValue, + maxSlippage, + trackEvent, + pageRedirectionDisabled, + ), + ); + }; + // Delay fetching quotes until a user is done typing an input value. If they type a new char in less than a second, + // we will cancel previous setTimeout call and start running a new one. + timeoutIdForQuotesPrefetching = setTimeout(() => { + timeoutIdForQuotesPrefetching = null; + if (!isReviewSwapButtonDisabled) { + if (isSmartTransaction) { + clearSmartTransactionFees(); // Clean up STX fees eery time there is a form change. + } + // Only do quotes prefetching if the Review swap button is enabled. + prefetchQuotesWithoutRedirecting(); + } + }, 1000); + return () => clearTimeout(timeoutIdForQuotesPrefetching); + }, [ + dispatch, + history, + maxSlippage, + trackEvent, + isReviewSwapButtonDisabled, + fromTokenInputValue, + fromTokenAddress, + toTokenAddress, + smartTransactionsOptInStatus, + isSmartTransaction, + ]); + + // Set text for the main button based on different conditions. + let mainButtonText; + if (swapsErrorKey && swapsErrorKey === QUOTES_NOT_AVAILABLE_ERROR) { + mainButtonText = t('swapQuotesNotAvailableErrorTitle'); + } else if (!isReviewSwapButtonDisabled) { + mainButtonText = t('swapFetchingQuotes'); + } else if (!selectedToToken?.address || !fromTokenAddress) { + mainButtonText = t('swapSelectToken'); + } else { + mainButtonText = t('swapEnterAmount'); + } + + const onTextFieldChange = (event) => { + event.stopPropagation(); + // Automatically prefix value with 0. if user begins typing . + const valueToUse = event.target.value === '.' ? '0.' : event.target.value; + + // Regex that validates strings with only numbers, 'x.', '.x', and 'x.x' + const regexp = /^(\.\d+|\d+(\.\d+)?|\d+\.)$/u; + // If the value is either empty or contains only numbers and '.' and only has one '.', update input to match + if (valueToUse === '' || regexp.test(valueToUse)) { + onInputChange(valueToUse, fromTokenBalance); + } else { + // otherwise, use the previously set inputValue (effectively denying the user from inputting the last char) + // or an empty string if we do not yet have an inputValue + onInputChange(fromTokenInputValue || '', fromTokenBalance); + } + }; + + const hideSwapToTokenIf = useCallback( + (item) => isEqualCaseInsensitive(item.address, fromTokenAddress), + [fromTokenAddress], + ); + + const hideSwapFromTokenIf = useCallback( + (item) => isEqualCaseInsensitive(item.address, selectedToToken?.address), + [selectedToToken?.address], + ); + + const showReviewQuote = + !swapsErrorKey && !isReviewSwapButtonDisabled && areQuotesPresent; + const showQuotesLoadingAnimation = + !swapsErrorKey && !isReviewSwapButtonDisabled && !areQuotesPresent; + const showNotEnoughTokenMessage = + !fromTokenError && balanceError && fromTokenSymbol; + + const tokenVerifiedOn1Source = occurrences === 1; + + useEffect(() => { + if (swapsErrorKey === QUOTES_EXPIRED_ERROR) { + history.push(SWAPS_NOTIFICATION_ROUTE); + } + }, [swapsErrorKey, history]); + + useEffect(() => { + if (showQuotesLoadingAnimation) { + setReceiveToAmount(''); + } + }, [showQuotesLoadingAnimation]); + + const onOpenImportTokenModalClick = (item) => { + setTokenForImport(item); + setIsImportTokenModalOpen(true); + onSwapToClose(); + setSwapToSearchQuery(''); + }; + + /* istanbul ignore next */ + const onImportTokenClick = () => { + trackEvent({ + event: 'Token Imported', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + symbol: tokenForImport?.symbol, + address: tokenForImport?.address, + chain_id: chainId, + is_hardware_wallet: hardwareWalletUsed, + hardware_wallet_type: hardwareWalletType, + stx_enabled: smartTransactionsEnabled, + current_stx_enabled: currentSmartTransactionsEnabled, + stx_user_opt_in: smartTransactionsOptInStatus, + }, + }); + // Only when a user confirms import of a token, we add it and show it in a dropdown. + onToSelect?.(tokenForImport); + setTokenForImport(null); + }; + + const onImportTokenCloseClick = () => { + setIsImportTokenModalOpen(false); + }; + + const importTokenProps = { + onImportTokenCloseClick, + onImportTokenClick, + setIsImportTokenModalOpen, + tokenForImport, + }; + + let receiveToAmountFormatted; + let receiveToAmountClassName; + let fromTokenAmountClassName; + + // TODO: Do this only when these variables change, not on every re-render. + if (receiveToAmount && !isReviewSwapButtonDisabled) { + receiveToAmountFormatted = formatSwapsValueForDisplay(receiveToAmount); + receiveToAmountClassName = getClassNameForCharLength( + receiveToAmountFormatted, + 'prepare-swap-page__receive-amount', + ); + } + if (fromTokenInputValue) { + fromTokenAmountClassName = getClassNameForCharLength( + fromTokenInputValue, + 'prepare-swap-page__from-token-amount', + ); + } + + const showMaxBalanceLink = + fromTokenSymbol && + !isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && + rawFromTokenBalance > 0; + + return ( +
    +
    + {tokenForImport && isImportTokenModalOpen && ( + + )} + + + + {t('swapSwapTo')} + + { + onToSelect?.(item); + onSwapToClose(); + }} + maxListItems={30} + searchQuery={swapToSearchQuery} + setSearchQuery={setSwapToSearchQuery} + hideItemIf={hideSwapToTokenIf} + shouldSearchForImports + onOpenImportTokenModalClick={onOpenImportTokenModalClick} + /> + + + + + + + + {t('swapSwapFrom')} + + + { + onFromSelect?.(item); + onSwapFromClose(); + }} + maxListItems={30} + searchQuery={swapFromSearchQuery} + setSearchQuery={setSwapFromSearchQuery} + hideItemIf={hideSwapFromTokenIf} + /> + + + + {showSmartTransactionsOptInPopover && ( + + )} +
    + + + + + + + +
    + {fromTokenSymbol && swapYourTokenBalance} + {showMaxBalanceLink && ( +
    + onInputChange(fromTokenBalance || '0', fromTokenBalance) + } + > + {t('max')} +
    + )} +
    + {fromTokenInputValue && swapFromFiatValue && ( + + + {swapFromFiatValue} + + + )} +
    + {showNotEnoughTokenMessage && ( + + + {t('swapsNotEnoughToken', [fromTokenSymbol])} + + + )} + {fromTokenError && ( + + + {t('swapTooManyDecimalsError', [ + fromTokenSymbol, + fromTokenDecimals, + ])} + + + )} + +
    { + // If quotes are being loaded, disable the switch button. + if (!showQuotesLoadingAnimation) { + onToSelect(selectedFromToken); + onFromSelect(selectedToToken); + setRotateSwitchTokens(!rotateSwitchTokens); + } + }} + title={t('swapSwapSwitch')} + > + +
    +
    +
    +
    + + + + + {receiveToAmountFormatted} + + + +
    + {!showReviewQuote && toTokenIsNotDefault && occurrences < 2 && ( + + + + + {tokenVerifiedOn1Source + ? t('swapTokenVerifiedOn1SourceDescription', [ + selectedToToken?.symbol, + , + ]) + : t('swapTokenAddedManuallyDescription', [ + , + ])} + + {!verificationClicked && ( + { + e.preventDefault(); + setVerificationClicked(true); + }} + > + {t('swapContinueSwapping')} + + )} + + + + )} + {swapsErrorKey && ( + + + + )} + {transactionSettingsOpened && + (smartTransactionsEnabled || + (!smartTransactionsEnabled && !isDirectWrappingEnabled)) && ( + { + dispatch(setMaxSlippage(newSlippage)); + }} + maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE} + currentSlippage={maxSlippage} + smartTransactionsEnabled={smartTransactionsEnabled} + smartTransactionsOptInStatus={smartTransactionsOptInStatus} + setSmartTransactionsOptInStatus={setSmartTransactionsOptInStatus} + currentSmartTransactionsError={currentSmartTransactionsError} + isDirectWrappingEnabled={isDirectWrappingEnabled} + onModalClose={() => { + dispatch(setTransactionSettingsOpened(false)); + }} + /> + )} + {showQuotesLoadingAnimation && ( + + )} + {showReviewQuote && ( + + )} +
    + {!areQuotesPresent && ( + + )} +
    + ); +} + +PrepareSwapPage.propTypes = { + ethBalance: PropTypes.string, + selectedAccountAddress: PropTypes.string, + shuffledTokensList: PropTypes.array, +}; diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js new file mode 100644 index 000000000..61255621f --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js @@ -0,0 +1,188 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { + renderWithProvider, + createSwapsMockStore, + setBackgroundConnection, + fireEvent, +} from '../../../../test/jest'; +import { + setSwapsFromToken, + setSwapToToken, + setFromTokenInputValue, +} from '../../../ducks/swaps/swaps'; +import PrepareSwapPage from './prepare-swap-page'; + +const middleware = [thunk]; +const createProps = (customProps = {}) => { + return { + ethBalance: '0x8', + selectedAccountAddress: 'selectedAccountAddress', + shuffledTokensList: [], + ...customProps, + }; +}; + +setBackgroundConnection({ + resetPostFetchState: jest.fn(), + setBackgroundSwapRouteState: jest.fn(), + clearSwapsQuotes: jest.fn(), + stopPollingForQuotes: jest.fn(), + clearSmartTransactionFees: jest.fn(), + setSwapsFromToken: jest.fn(), + setSwapToToken: jest.fn(), + setFromTokenInputValue: jest.fn(), +}); + +jest.mock('../../../../shared/lib/token-util.ts', () => { + const actual = jest.requireActual('../../../../shared/lib/token-util.ts'); + return { + ...actual, + fetchTokenBalance: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('../../../ducks/swaps/swaps', () => { + const actual = jest.requireActual('../../../ducks/swaps/swaps'); + return { + ...actual, + setSwapsFromToken: jest.fn(), + setSwapToToken: jest.fn(), + setFromTokenInputValue: jest.fn(() => { + return { + type: 'MOCK_ACTION', + }; + }), + }; +}); + +jest.mock('../swaps.util', () => { + const actual = jest.requireActual('../swaps.util'); + return { + ...actual, + fetchTokenPrice: jest.fn(() => Promise.resolve()), + }; +}); + +describe('PrepareSwapPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component with initial props', () => { + const store = configureMockStore(middleware)(createSwapsMockStore()); + const props = createProps(); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Select token')).toBeInTheDocument(); + expect( + document.querySelector('.slippage-buttons__button-group'), + ).toMatchSnapshot(); + }); + + it('switches swap from and to tokens', () => { + const setSwapFromTokenMock = jest.fn(() => { + return { + type: 'MOCK_ACTION', + }; + }); + setSwapsFromToken.mockImplementation(setSwapFromTokenMock); + const setSwapToTokenMock = jest.fn(() => { + return { + type: 'MOCK_ACTION', + }; + }); + setSwapToToken.mockImplementation(setSwapToTokenMock); + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const props = createProps(); + const { getByTestId } = renderWithProvider( + , + store, + ); + fireEvent.click(getByTestId('prepare-swap-page-switch-tokens')); + expect(setSwapsFromToken).toHaveBeenCalledWith(mockStore.swaps.toToken); + expect(setSwapToToken).toHaveBeenCalled(); + }); + + it('renders the block explorer link, only 1 verified source', () => { + const mockStore = createSwapsMockStore(); + mockStore.swaps.toToken.occurances = 1; + const store = configureMockStore(middleware)(mockStore); + const props = createProps(); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Potentially inauthentic token')).toBeInTheDocument(); + expect( + getByText('USDC is only verified on 1 source', { exact: false }), + ).toBeInTheDocument(); + expect(getByText('Etherscan')).toBeInTheDocument(); + expect(getByText('Continue swapping')).toBeInTheDocument(); + }); + + it('renders the block explorer link, 0 verified sources', () => { + const mockStore = createSwapsMockStore(); + mockStore.swaps.toToken.occurances = 0; + const store = configureMockStore(middleware)(mockStore); + const props = createProps(); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Token added manually')).toBeInTheDocument(); + expect( + getByText('Verify this token on', { exact: false }), + ).toBeInTheDocument(); + expect(getByText('Etherscan')).toBeInTheDocument(); + expect(getByText('Continue swapping')).toBeInTheDocument(); + }); + + it('clicks on a block explorer link', () => { + global.platform = { openTab: jest.fn() }; + const mockStore = createSwapsMockStore(); + mockStore.swaps.toToken.occurances = 1; + const store = configureMockStore(middleware)(mockStore); + const props = createProps(); + const { getByText } = renderWithProvider( + , + store, + ); + const blockExplorer = getByText('Etherscan'); + expect(blockExplorer).toBeInTheDocument(); + fireEvent.click(blockExplorer); + expect(global.platform.openTab).toHaveBeenCalledWith({ + url: 'https://etherscan.io/token/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + }); + + it('clicks on the "max" link', () => { + const setFromTokenInputValueMock = jest.fn(() => { + return { + type: 'MOCK_ACTION', + }; + }); + setFromTokenInputValue.mockImplementation(setFromTokenInputValueMock); + const mockStore = createSwapsMockStore(); + mockStore.swaps.fromToken = { + symbol: 'DAI', + balance: '0x8', + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + decimals: 6, + }; + const store = configureMockStore(middleware)(mockStore); + const props = createProps(); + const { getByText } = renderWithProvider( + , + store, + ); + const maxLink = getByText('Max'); + fireEvent.click(maxLink); + expect(setFromTokenInputValue).toHaveBeenCalled(); + }); +}); diff --git a/ui/pages/swaps/prepare-swap-page/quotes-loading-animation.js b/ui/pages/swaps/prepare-swap-page/quotes-loading-animation.js new file mode 100644 index 000000000..771e21639 --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/quotes-loading-animation.js @@ -0,0 +1,63 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import Box from '../../../components/ui/box'; +import { + DISPLAY, + FLEX_DIRECTION, + TextColor, + JustifyContent, + AlignItems, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { Text } from '../../../components/component-library'; +import MascotBackgroundAnimation from '../mascot-background-animation/mascot-background-animation'; + +export default function QuotesLoadingAnimation(props) { + const { quoteCount, numberOfAggregators } = props; + + const t = useContext(I18nContext); + + return ( + + + + {t('swapFetchingQuote')} + + + {t('swapQuoteNofM', [ + Math.min(quoteCount + 1, numberOfAggregators), + numberOfAggregators, + ])} + + + + + ); +} + +QuotesLoadingAnimation.propTypes = { + quoteCount: PropTypes.number.isRequired, + numberOfAggregators: PropTypes.number.isRequired, +}; diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.js b/ui/pages/swaps/prepare-swap-page/review-quote.js new file mode 100644 index 000000000..13b9e9f57 --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/review-quote.js @@ -0,0 +1,1281 @@ +import React, { + useState, + useContext, + useMemo, + useEffect, + useRef, + useCallback, +} from 'react'; +import { shallowEqual, useSelector, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import BigNumber from 'bignumber.js'; +import { isEqual } from 'lodash'; +import classnames from 'classnames'; +import { captureException } from '@sentry/browser'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import SelectQuotePopover from '../select-quote-popover'; +import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; +import { useEqualityCheck } from '../../../hooks/useEqualityCheck'; +import { usePrevious } from '../../../hooks/usePrevious'; +import { useGasFeeInputs } from '../../../hooks/gasFeeInput/useGasFeeInputs'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + FALLBACK_GAS_MULTIPLIER, + getQuotes, + getSelectedQuote, + getApproveTxParams, + getFetchParams, + setBalanceError, + getQuotesLastFetched, + getBalanceError, + getCustomSwapsGas, // Gas limit. + getCustomMaxFeePerGas, + getCustomMaxPriorityFeePerGas, + getSwapsUserFeeLevel, + getDestinationTokenInfo, + getUsedSwapsGasPrice, + getTopQuote, + signAndSendTransactions, + getBackgroundSwapRouteState, + swapsQuoteSelected, + getSwapsQuoteRefreshTime, + getReviewSwapClickedTimestamp, + getSmartTransactionsOptInStatus, + signAndSendSwapsSmartTransaction, + getSwapsNetworkConfig, + getSmartTransactionsEnabled, + getSmartTransactionsError, + getCurrentSmartTransactionsError, + getSwapsSTXLoading, + fetchSwapsSmartTransactionFees, + getSmartTransactionFees, + getCurrentSmartTransactionsEnabled, +} from '../../../ducks/swaps/swaps'; +import { + conversionRateSelector, + getSelectedAccount, + getCurrentCurrency, + getTokenExchangeRates, + getSwapsDefaultToken, + getCurrentChainId, + isHardwareWallet, + getHardwareWalletType, + checkNetworkAndAccountSupports1559, + getUSDConversionRate, + getIsMultiLayerFeeNetwork, +} from '../../../selectors'; +import { getNativeCurrency, getTokens } from '../../../ducks/metamask/metamask'; +import { + safeRefetchQuotes, + setCustomApproveTxData, + setSwapsErrorKey, + showModal, + setSwapsQuotesPollingLimitEnabled, +} from '../../../store/actions'; +import { SET_SMART_TRANSACTIONS_ERROR } from '../../../store/actionConstants'; +import { + ASSET_ROUTE, + DEFAULT_ROUTE, + AWAITING_SWAP_ROUTE, + SWAPS_NOTIFICATION_ROUTE, + PREPARE_SWAP_ROUTE, +} from '../../../helpers/constants/routes'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { + addHexes, + decGWEIToHexWEI, + decimalToHex, + decWEIToDecETH, + sumHexes, +} from '../../../../shared/modules/conversion.utils'; +import { getCustomTxParamsData } from '../../confirm-approve/confirm-approve.util'; +import { + quotesToRenderableData, + getRenderableNetworkFeesForQuote, + getFeeForSmartTransaction, + formatSwapsValueForDisplay, +} from '../swaps.util'; +import { useTokenTracker } from '../../../hooks/useTokenTracker'; +import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps'; +import { GasRecommendations } from '../../../../shared/constants/gas'; +import CountdownTimer from '../countdown-timer'; +import SwapsFooter from '../swaps-footer'; +import Box from '../../../components/ui/box'; +import { + TextColor, + JustifyContent, + DISPLAY, + AlignItems, + FLEX_DIRECTION, + SEVERITIES, + TextVariant, + FRACTIONS, + TEXT_ALIGN, + Size, +} from '../../../helpers/constants/design-system'; +import { + BannerAlert, + Text, + ButtonLink, +} from '../../../components/component-library'; +import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; +import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; +import { parseStandardTokenTransactionData } from '../../../../shared/modules/transaction.utils'; +import { getTokenValueParam } from '../../../../shared/lib/metamask-controller-utils'; +import { + calcGasTotal, + calcTokenAmount, + toPrecisionWithoutTrailingZeros, +} from '../../../../shared/lib/transactions-controller-utils'; +import { addHexPrefix } from '../../../../app/scripts/lib/util'; +import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; +import fetchEstimatedL1Fee from '../../../helpers/utils/optimism/fetchEstimatedL1Fee'; +import ExchangeRateDisplay from '../exchange-rate-display'; +import InfoTooltip from '../../../components/ui/info-tooltip'; +import ViewQuotePriceDifference from './view-quote-price-difference'; + +let intervalId; + +const GAS_FEES_LEARN_MORE_URL = + 'https://community.metamask.io/t/what-is-gas-why-do-transactions-take-so-long/3172'; + +export default function ReviewQuote({ setReceiveToAmount }) { + const history = useHistory(); + const dispatch = useDispatch(); + const t = useContext(I18nContext); + const trackEvent = useContext(MetaMetricsContext); + + const [dispatchedSafeRefetch, setDispatchedSafeRefetch] = useState(false); + const [submitClicked, setSubmitClicked] = useState(false); + const [selectQuotePopoverShown, setSelectQuotePopoverShown] = useState(false); + const [warningHidden] = useState(false); // TODO: Check when to use setWarningHidden + const [originalApproveAmount, setOriginalApproveAmount] = useState(null); + const [multiLayerL1FeeTotal, setMultiLayerL1FeeTotal] = useState(null); + const [multiLayerL1ApprovalFeeTotal, setMultiLayerL1ApprovalFeeTotal] = + useState(null); + // We need to have currentTimestamp in state, otherwise it would change with each rerender. + const [currentTimestamp] = useState(Date.now()); + + const [acknowledgedPriceDifference, setAcknowledgedPriceDifference] = + useState(false); + const priceDifferenceRiskyBuckets = [ + GasRecommendations.high, + GasRecommendations.medium, + ]; + + const routeState = useSelector(getBackgroundSwapRouteState); + const quotes = useSelector(getQuotes, isEqual); + useEffect(() => { + if (!Object.values(quotes).length) { + history.push(PREPARE_SWAP_ROUTE); + } else if (routeState === 'awaiting') { + history.push(AWAITING_SWAP_ROUTE); + } + }, [history, quotes, routeState]); + + const quotesLastFetched = useSelector(getQuotesLastFetched); + + // Select necessary data + const gasPrice = useSelector(getUsedSwapsGasPrice); + const customMaxGas = useSelector(getCustomSwapsGas); + const customMaxFeePerGas = useSelector(getCustomMaxFeePerGas); + const customMaxPriorityFeePerGas = useSelector(getCustomMaxPriorityFeePerGas); + const swapsUserFeeLevel = useSelector(getSwapsUserFeeLevel); + const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); + const memoizedTokenConversionRates = useEqualityCheck(tokenConversionRates); + const { balance: ethBalance } = useSelector(getSelectedAccount, shallowEqual); + const conversionRate = useSelector(conversionRateSelector); + const USDConversionRate = useSelector(getUSDConversionRate); + const isMultiLayerFeeNetwork = useSelector(getIsMultiLayerFeeNetwork); + const currentCurrency = useSelector(getCurrentCurrency); + const swapsTokens = useSelector(getTokens, isEqual); + const networkAndAccountSupports1559 = useSelector( + checkNetworkAndAccountSupports1559, + ); + const balanceError = useSelector(getBalanceError); + const fetchParams = useSelector(getFetchParams, isEqual); + const approveTxParams = useSelector(getApproveTxParams, shallowEqual); + const selectedQuote = useSelector(getSelectedQuote, isEqual); + const topQuote = useSelector(getTopQuote, isEqual); + const usedQuote = selectedQuote || topQuote; + const tradeValue = usedQuote?.trade?.value ?? '0x0'; + const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime); + const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual); + const chainId = useSelector(getCurrentChainId); + const nativeCurrencySymbol = useSelector(getNativeCurrency); + const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp); + const smartTransactionsOptInStatus = useSelector( + getSmartTransactionsOptInStatus, + ); + const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled); + const swapsSTXLoading = useSelector(getSwapsSTXLoading); + const currentSmartTransactionsError = useSelector( + getCurrentSmartTransactionsError, + ); + const smartTransactionsError = useSelector(getSmartTransactionsError); + const currentSmartTransactionsEnabled = useSelector( + getCurrentSmartTransactionsEnabled, + ); + const smartTransactionFees = useSelector(getSmartTransactionFees, isEqual); + const swapsNetworkConfig = useSelector(getSwapsNetworkConfig, shallowEqual); + const unsignedTransaction = usedQuote.trade; + const isSmartTransaction = + currentSmartTransactionsEnabled && smartTransactionsOptInStatus; + + /* istanbul ignore next */ + const getTranslatedNetworkName = () => { + switch (chainId) { + case CHAIN_IDS.MAINNET: + return t('networkNameEthereum'); + case CHAIN_IDS.BSC: + return t('networkNameBSC'); + case CHAIN_IDS.POLYGON: + return t('networkNamePolygon'); + case CHAIN_IDS.LOCALHOST: + return t('networkNameTestnet'); + case CHAIN_IDS.GOERLI: + return t('networkNameGoerli'); + case CHAIN_IDS.AVALANCHE: + return t('networkNameAvalanche'); + case CHAIN_IDS.OPTIMISM: + return t('networkNameOptimism'); + case CHAIN_IDS.ARBITRUM: + return t('networkNameArbitrum'); + default: + throw new Error('This network is not supported for token swaps'); + } + }; + + let gasFeeInputs; + if (networkAndAccountSupports1559) { + // For Swaps we want to get 'high' estimations by default. + // eslint-disable-next-line react-hooks/rules-of-hooks + gasFeeInputs = useGasFeeInputs(GasRecommendations.high, { + userFeeLevel: swapsUserFeeLevel || GasRecommendations.high, + }); + } + + const fetchParamsSourceToken = fetchParams?.sourceToken; + + const additionalTrackingParams = { + reg_tx_fee_in_usd: undefined, + reg_tx_fee_in_eth: undefined, + reg_tx_max_fee_in_usd: undefined, + reg_tx_max_fee_in_eth: undefined, + stx_fee_in_usd: undefined, + stx_fee_in_eth: undefined, + stx_max_fee_in_usd: undefined, + stx_max_fee_in_eth: undefined, + }; + + const usedGasLimit = + usedQuote?.gasEstimateWithRefund || + `0x${decimalToHex(usedQuote?.averageGas || 0)}`; + + const gasLimitForMax = usedQuote?.gasEstimate || `0x0`; + + const usedGasLimitWithMultiplier = new BigNumber(gasLimitForMax, 16) + .times(usedQuote?.gasMultiplier || FALLBACK_GAS_MULTIPLIER, 10) + .round(0) + .toString(16); + + const nonCustomMaxGasLimit = usedQuote?.gasEstimate + ? usedGasLimitWithMultiplier + : `0x${decimalToHex(usedQuote?.maxGas || 0)}`; + const maxGasLimit = customMaxGas || nonCustomMaxGasLimit; + + let maxFeePerGas; + let maxPriorityFeePerGas; + let baseAndPriorityFeePerGas; + + // EIP-1559 gas fees. + if (networkAndAccountSupports1559) { + const { + maxFeePerGas: suggestedMaxFeePerGas, + maxPriorityFeePerGas: suggestedMaxPriorityFeePerGas, + gasFeeEstimates: { estimatedBaseFee = '0' }, + } = gasFeeInputs; + maxFeePerGas = customMaxFeePerGas || decGWEIToHexWEI(suggestedMaxFeePerGas); + maxPriorityFeePerGas = + customMaxPriorityFeePerGas || + decGWEIToHexWEI(suggestedMaxPriorityFeePerGas); + baseAndPriorityFeePerGas = addHexes( + decGWEIToHexWEI(estimatedBaseFee), + maxPriorityFeePerGas, + ); + } + let gasTotalInWeiHex = calcGasTotal(maxGasLimit, maxFeePerGas || gasPrice); + if (multiLayerL1FeeTotal !== null) { + gasTotalInWeiHex = sumHexes( + gasTotalInWeiHex || '0x0', + multiLayerL1FeeTotal || '0x0', + ); + } + + const { tokensWithBalances } = useTokenTracker(swapsTokens, true); + const balanceToken = + fetchParamsSourceToken === defaultSwapsToken.address + ? defaultSwapsToken + : tokensWithBalances.find(({ address }) => + isEqualCaseInsensitive(address, fetchParamsSourceToken), + ); + + const selectedFromToken = balanceToken || usedQuote.sourceTokenInfo; + const tokenBalance = + tokensWithBalances?.length && + calcTokenAmount( + selectedFromToken.balance || '0x0', + selectedFromToken.decimals, + ).toFixed(9); + const tokenBalanceUnavailable = + tokensWithBalances && balanceToken === undefined; + + const approveData = parseStandardTokenTransactionData(approveTxParams?.data); + const approveValue = approveData && getTokenValueParam(approveData); + const approveAmount = + approveValue && + selectedFromToken?.decimals !== undefined && + calcTokenAmount(approveValue, selectedFromToken.decimals).toFixed(9); + const approveGas = approveTxParams?.gas; + + const renderablePopoverData = useMemo(() => { + return quotesToRenderableData({ + quotes, + gasPrice: networkAndAccountSupports1559 + ? baseAndPriorityFeePerGas + : gasPrice, + conversionRate, + currentCurrency, + approveGas, + tokenConversionRates: memoizedTokenConversionRates, + chainId, + smartTransactionEstimatedGas: + smartTransactionsEnabled && + smartTransactionsOptInStatus && + smartTransactionFees?.tradeTxFees, + nativeCurrencySymbol, + multiLayerL1ApprovalFeeTotal, + }); + }, [ + quotes, + gasPrice, + baseAndPriorityFeePerGas, + networkAndAccountSupports1559, + conversionRate, + currentCurrency, + approveGas, + memoizedTokenConversionRates, + chainId, + smartTransactionFees?.tradeTxFees, + nativeCurrencySymbol, + smartTransactionsEnabled, + smartTransactionsOptInStatus, + multiLayerL1ApprovalFeeTotal, + ]); + + const renderableDataForUsedQuote = renderablePopoverData.find( + (renderablePopoverDatum) => + renderablePopoverDatum.aggId === usedQuote.aggregator, + ); + + const { + destinationTokenDecimals, + destinationTokenSymbol, + destinationTokenValue, + sourceTokenDecimals, + sourceTokenSymbol, + sourceTokenValue, + } = renderableDataForUsedQuote; + + let { feeInFiat, feeInEth, rawEthFee, feeInUsd } = + getRenderableNetworkFeesForQuote({ + tradeGas: usedGasLimit, + approveGas, + gasPrice: networkAndAccountSupports1559 + ? baseAndPriorityFeePerGas + : gasPrice, + currentCurrency, + conversionRate, + USDConversionRate, + tradeValue, + sourceSymbol: sourceTokenSymbol, + sourceAmount: usedQuote.sourceAmount, + chainId, + nativeCurrencySymbol, + multiLayerL1FeeTotal, + }); + additionalTrackingParams.reg_tx_fee_in_usd = Number(feeInUsd); + additionalTrackingParams.reg_tx_fee_in_eth = Number(rawEthFee); + + const renderableMaxFees = getRenderableNetworkFeesForQuote({ + tradeGas: maxGasLimit, + approveGas, + gasPrice: maxFeePerGas || gasPrice, + currentCurrency, + conversionRate, + USDConversionRate, + tradeValue, + sourceSymbol: sourceTokenSymbol, + sourceAmount: usedQuote.sourceAmount, + chainId, + nativeCurrencySymbol, + multiLayerL1FeeTotal, + }); + let { + feeInFiat: maxFeeInFiat, + feeInEth: maxFeeInEth, + rawEthFee: maxRawEthFee, + feeInUsd: maxFeeInUsd, + } = renderableMaxFees; + additionalTrackingParams.reg_tx_max_fee_in_usd = Number(maxFeeInUsd); + additionalTrackingParams.reg_tx_max_fee_in_eth = Number(maxRawEthFee); + + if (isSmartTransaction && smartTransactionFees?.tradeTxFees) { + const stxEstimatedFeeInWeiDec = + smartTransactionFees?.tradeTxFees.feeEstimate + + (smartTransactionFees?.approvalTxFees?.feeEstimate || 0); + const stxMaxFeeInWeiDec = + stxEstimatedFeeInWeiDec * swapsNetworkConfig.stxMaxFeeMultiplier; + ({ feeInFiat, feeInEth, rawEthFee, feeInUsd } = getFeeForSmartTransaction({ + chainId, + currentCurrency, + conversionRate, + USDConversionRate, + nativeCurrencySymbol, + feeInWeiDec: stxEstimatedFeeInWeiDec, + })); + additionalTrackingParams.stx_fee_in_usd = Number(feeInUsd); + additionalTrackingParams.stx_fee_in_eth = Number(rawEthFee); + additionalTrackingParams.estimated_gas = + smartTransactionFees?.tradeTxFees.gasLimit; + ({ + feeInFiat: maxFeeInFiat, + feeInEth: maxFeeInEth, + rawEthFee: maxRawEthFee, + feeInUsd: maxFeeInUsd, + } = getFeeForSmartTransaction({ + chainId, + currentCurrency, + conversionRate, + USDConversionRate, + nativeCurrencySymbol, + feeInWeiDec: stxMaxFeeInWeiDec, + })); + additionalTrackingParams.stx_max_fee_in_usd = Number(maxFeeInUsd); + additionalTrackingParams.stx_max_fee_in_eth = Number(maxRawEthFee); + } + + const tokenCost = new BigNumber(usedQuote.sourceAmount); + const ethCost = new BigNumber(usedQuote.trade.value || 0, 10).plus( + new BigNumber(gasTotalInWeiHex, 16), + ); + + const insufficientTokens = + (tokensWithBalances?.length || balanceError) && + tokenCost.gt(new BigNumber(selectedFromToken.balance || '0x0')); + + const insufficientEth = ethCost.gt(new BigNumber(ethBalance || '0x0')); + + const tokenBalanceNeeded = insufficientTokens + ? toPrecisionWithoutTrailingZeros( + calcTokenAmount(tokenCost, selectedFromToken.decimals) + .minus(tokenBalance) + .toString(10), + 6, + ) + : null; + + const ethBalanceNeeded = insufficientEth + ? toPrecisionWithoutTrailingZeros( + ethCost + .minus(ethBalance, 16) + .div('1000000000000000000', 10) + .toString(10), + 6, + ) + : null; + + let ethBalanceNeededStx; + if (isSmartTransaction && smartTransactionsError?.balanceNeededWei) { + ethBalanceNeededStx = decWEIToDecETH( + smartTransactionsError.balanceNeededWei - + smartTransactionsError.currentBalanceWei, + ); + } + + const destinationToken = useSelector(getDestinationTokenInfo, isEqual); + useEffect(() => { + if (isSmartTransaction) { + if (insufficientTokens) { + dispatch(setBalanceError(true)); + } else if (balanceError && !insufficientTokens) { + dispatch(setBalanceError(false)); + } + } else if (insufficientTokens || insufficientEth) { + dispatch(setBalanceError(true)); + } else if (balanceError && !insufficientTokens && !insufficientEth) { + dispatch(setBalanceError(false)); + } + // eslint-disable-next-line + }, [insufficientTokens, insufficientEth, dispatch, isSmartTransaction]); + + useEffect(() => { + const currentTime = Date.now(); + const timeSinceLastFetched = currentTime - quotesLastFetched; + if ( + timeSinceLastFetched > swapsQuoteRefreshTime && + !dispatchedSafeRefetch + ) { + setDispatchedSafeRefetch(true); + dispatch(safeRefetchQuotes()); + } else if (timeSinceLastFetched > swapsQuoteRefreshTime) { + dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR)); + history.push(SWAPS_NOTIFICATION_ROUTE); + } + }, [ + quotesLastFetched, + dispatchedSafeRefetch, + dispatch, + history, + swapsQuoteRefreshTime, + ]); + + useEffect(() => { + if (!originalApproveAmount && approveAmount) { + setOriginalApproveAmount(approveAmount); + } + }, [originalApproveAmount, approveAmount]); + + // If it's not a Smart Transaction and ETH balance is needed, we want to show a warning. + const isNotStxAndEthBalanceIsNeeded = !isSmartTransaction && ethBalanceNeeded; + + // If it's a Smart Transaction and ETH balance is needed, we want to show a warning. + const isStxAndEthBalanceIsNeeded = isSmartTransaction && ethBalanceNeededStx; + + // Indicates if we should show to a user a warning about insufficient funds for swapping. + const showInsufficientWarning = + (balanceError || + tokenBalanceNeeded || + isNotStxAndEthBalanceIsNeeded || + isStxAndEthBalanceIsNeeded) && + !warningHidden; + + const hardwareWalletUsed = useSelector(isHardwareWallet); + const hardwareWalletType = useSelector(getHardwareWalletType); + + const numberOfQuotes = Object.values(quotes).length; + const bestQuoteReviewedEventSent = useRef(); + const eventObjectBase = useMemo(() => { + return { + token_from: sourceTokenSymbol, + token_from_amount: sourceTokenValue, + token_to: destinationTokenSymbol, + token_to_amount: destinationTokenValue, + request_type: fetchParams?.balanceError, + slippage: fetchParams?.slippage, + custom_slippage: fetchParams?.slippage !== 2, + response_time: fetchParams?.responseTime, + best_quote_source: topQuote?.aggregator, + available_quotes: numberOfQuotes, + is_hardware_wallet: hardwareWalletUsed, + hardware_wallet_type: hardwareWalletType, + stx_enabled: smartTransactionsEnabled, + current_stx_enabled: currentSmartTransactionsEnabled, + stx_user_opt_in: smartTransactionsOptInStatus, + }; + }, [ + sourceTokenSymbol, + sourceTokenValue, + destinationTokenSymbol, + destinationTokenValue, + fetchParams?.balanceError, + fetchParams?.slippage, + fetchParams?.responseTime, + topQuote?.aggregator, + numberOfQuotes, + hardwareWalletUsed, + hardwareWalletType, + smartTransactionsEnabled, + currentSmartTransactionsEnabled, + smartTransactionsOptInStatus, + ]); + + const trackAllAvailableQuotesOpened = () => { + trackEvent({ + event: 'All Available Quotes Opened', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + ...eventObjectBase, + other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, + other_quote_selected_source: + usedQuote?.aggregator === topQuote?.aggregator + ? null + : usedQuote?.aggregator, + }, + }); + }; + const trackQuoteDetailsOpened = () => { + trackEvent({ + event: 'Quote Details Opened', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + ...eventObjectBase, + other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, + other_quote_selected_source: + usedQuote?.aggregator === topQuote?.aggregator + ? null + : usedQuote?.aggregator, + }, + }); + }; + const trackEditSpendLimitOpened = () => { + trackEvent({ + event: 'Edit Spend Limit Opened', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + ...eventObjectBase, + custom_spend_limit_set: originalApproveAmount === approveAmount, + custom_spend_limit_amount: + originalApproveAmount === approveAmount ? null : approveAmount, + }, + }); + }; + const trackBestQuoteReviewedEvent = useCallback(() => { + trackEvent({ + event: 'Best Quote Reviewed', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + ...eventObjectBase, + network_fees: feeInFiat, + }, + }); + }, [trackEvent, eventObjectBase, feeInFiat]); + const trackViewQuotePageLoadedEvent = useCallback(() => { + trackEvent({ + event: 'Review Quote Component Loaded', + category: MetaMetricsEventCategory.Swaps, + sensitiveProperties: { + ...eventObjectBase, + response_time: currentTimestamp - reviewSwapClickedTimestamp, + }, + }); + }, [ + trackEvent, + eventObjectBase, + currentTimestamp, + reviewSwapClickedTimestamp, + ]); + + useEffect(() => { + if ( + !bestQuoteReviewedEventSent.current && + [ + sourceTokenSymbol, + sourceTokenValue, + destinationTokenSymbol, + destinationTokenValue, + fetchParams, + topQuote, + numberOfQuotes, + feeInFiat, + ].every((dep) => dep !== null && dep !== undefined) + ) { + bestQuoteReviewedEventSent.current = true; + trackBestQuoteReviewedEvent(); + } + }, [ + fetchParams, + topQuote, + numberOfQuotes, + feeInFiat, + destinationTokenSymbol, + destinationTokenValue, + sourceTokenSymbol, + sourceTokenValue, + trackBestQuoteReviewedEvent, + ]); + + const metaMaskFee = usedQuote.fee; + + /* istanbul ignore next */ + const onFeeCardTokenApprovalClick = () => { + trackEditSpendLimitOpened(); + dispatch( + showModal({ + name: 'EDIT_APPROVAL_PERMISSION', + decimals: selectedFromToken.decimals, + origin: 'MetaMask', + setCustomAmount: (newCustomPermissionAmount) => { + const customPermissionAmount = + newCustomPermissionAmount === '' + ? originalApproveAmount + : newCustomPermissionAmount; + const newData = getCustomTxParamsData(approveTxParams.data, { + customPermissionAmount, + decimals: selectedFromToken.decimals, + }); + + if ( + customPermissionAmount?.length && + approveTxParams.data !== newData + ) { + dispatch(setCustomApproveTxData(newData)); + } + }, + tokenAmount: originalApproveAmount, + customTokenAmount: + originalApproveAmount === approveAmount ? null : approveAmount, + tokenBalance, + tokenSymbol: selectedFromToken.symbol, + requiredMinimum: calcTokenAmount( + usedQuote.sourceAmount, + selectedFromToken.decimals, + ), + }), + ); + }; + const actionableBalanceErrorMessage = tokenBalanceUnavailable + ? t('swapTokenBalanceUnavailable', [sourceTokenSymbol]) + : t('swapApproveNeedMoreTokens', [ + + {tokenBalanceNeeded || ethBalanceNeededStx || ethBalanceNeeded} + , + tokenBalanceNeeded && !(sourceTokenSymbol === defaultSwapsToken.symbol) + ? sourceTokenSymbol + : defaultSwapsToken.symbol, + ]); + + // Price difference warning + const priceSlippageBucket = usedQuote?.priceSlippage?.bucket; + const lastPriceDifferenceBucket = usePrevious(priceSlippageBucket); + + // If the user agreed to a different bucket of risk, make them agree again + useEffect(() => { + if ( + acknowledgedPriceDifference && + lastPriceDifferenceBucket === GasRecommendations.medium && + priceSlippageBucket === GasRecommendations.high + ) { + setAcknowledgedPriceDifference(false); + } + }, [ + priceSlippageBucket, + acknowledgedPriceDifference, + lastPriceDifferenceBucket, + ]); + + let viewQuotePriceDifferenceWarning = null; + const priceSlippageFromSource = useEthFiatAmount( + usedQuote?.priceSlippage?.sourceAmountInETH || 0, + { showFiat: true }, + ); + const priceSlippageFromDestination = useEthFiatAmount( + usedQuote?.priceSlippage?.destinationAmountInETH || 0, + { showFiat: true }, + ); + + // We cannot present fiat value if there is a calculation error or no slippage + // from source or destination + const priceSlippageUnknownFiatValue = + !priceSlippageFromSource || + !priceSlippageFromDestination || + Boolean(usedQuote?.priceSlippage?.calculationError); + + let priceDifferencePercentage = 0; + if (usedQuote?.priceSlippage?.ratio) { + priceDifferencePercentage = parseFloat( + new BigNumber(usedQuote.priceSlippage.ratio, 10) + .minus(1, 10) + .times(100, 10) + .toFixed(2), + 10, + ); + } + + const shouldShowPriceDifferenceWarning = + !tokenBalanceUnavailable && + !showInsufficientWarning && + usedQuote && + (priceDifferenceRiskyBuckets.includes(priceSlippageBucket) || + priceSlippageUnknownFiatValue); + + if (shouldShowPriceDifferenceWarning) { + viewQuotePriceDifferenceWarning = ( + { + setAcknowledgedPriceDifference(true); + }} + acknowledged={acknowledgedPriceDifference} + /> + ); + } + + const disableSubmissionDueToPriceWarning = + shouldShowPriceDifferenceWarning && !acknowledgedPriceDifference; + + const isShowingWarning = + showInsufficientWarning || shouldShowPriceDifferenceWarning; + + const isSwapButtonDisabled = Boolean( + submitClicked || + balanceError || + tokenBalanceUnavailable || + disableSubmissionDueToPriceWarning || + (networkAndAccountSupports1559 && + baseAndPriorityFeePerGas === undefined) || + (!networkAndAccountSupports1559 && + (gasPrice === null || gasPrice === undefined)) || + (currentSmartTransactionsEnabled && + (currentSmartTransactionsError || smartTransactionsError)) || + (currentSmartTransactionsEnabled && + smartTransactionsOptInStatus && + !smartTransactionFees?.tradeTxFees), + ); + + useEffect(() => { + if (isSmartTransaction && !insufficientTokens) { + const unsignedTx = { + from: unsignedTransaction.from, + to: unsignedTransaction.to, + value: unsignedTransaction.value, + data: unsignedTransaction.data, + gas: unsignedTransaction.gas, + chainId, + }; + intervalId = setInterval(() => { + if (!swapsSTXLoading) { + dispatch( + fetchSwapsSmartTransactionFees({ + unsignedTransaction: unsignedTx, + approveTxParams, + fallbackOnNotEnoughFunds: false, + }), + ); + } + }, swapsNetworkConfig.stxGetTransactionsRefreshTime); + dispatch( + fetchSwapsSmartTransactionFees({ + unsignedTransaction: unsignedTx, + approveTxParams, + fallbackOnNotEnoughFunds: false, + }), + ); + } else if (intervalId) { + clearInterval(intervalId); + } + return () => clearInterval(intervalId); + // eslint-disable-next-line + }, [ + dispatch, + isSmartTransaction, + unsignedTransaction.data, + unsignedTransaction.from, + unsignedTransaction.value, + unsignedTransaction.gas, + unsignedTransaction.to, + chainId, + swapsNetworkConfig.stxGetTransactionsRefreshTime, + insufficientTokens, + ]); + + useEffect(() => { + // Thanks to the next line we will only do quotes polling 3 times before showing a Quote Timeout modal. + dispatch(setSwapsQuotesPollingLimitEnabled(true)); + if (reviewSwapClickedTimestamp) { + trackViewQuotePageLoadedEvent(); + } + }, [dispatch, trackViewQuotePageLoadedEvent, reviewSwapClickedTimestamp]); + + useEffect(() => { + // if smart transaction error is turned off, reset submit clicked boolean + if ( + !currentSmartTransactionsEnabled && + currentSmartTransactionsError && + submitClicked + ) { + setSubmitClicked(false); + } + }, [ + currentSmartTransactionsEnabled, + currentSmartTransactionsError, + submitClicked, + ]); + + useEffect(() => { + if (!isMultiLayerFeeNetwork || !usedQuote?.multiLayerL1TradeFeeTotal) { + return; + } + const getEstimatedL1Fees = async () => { + try { + let l1ApprovalFeeTotal = '0x0'; + if (approveTxParams) { + l1ApprovalFeeTotal = await fetchEstimatedL1Fee({ + txParams: { + ...approveTxParams, + gasPrice: addHexPrefix(approveTxParams.gasPrice), + value: '0x0', // For approval txs we need to use "0x0" here. + }, + chainId, + }); + setMultiLayerL1ApprovalFeeTotal(l1ApprovalFeeTotal); + } + const l1FeeTotal = sumHexes( + usedQuote.multiLayerL1TradeFeeTotal, + l1ApprovalFeeTotal, + ); + setMultiLayerL1FeeTotal(l1FeeTotal); + } catch (e) { + captureException(e); + setMultiLayerL1FeeTotal(null); + setMultiLayerL1ApprovalFeeTotal(null); + } + }; + getEstimatedL1Fees(); + }, [ + unsignedTransaction, + approveTxParams, + isMultiLayerFeeNetwork, + chainId, + usedQuote, + ]); + + useEffect(() => { + if (isSmartTransaction) { + // Removes a smart transactions error when the component loads. + dispatch({ + type: SET_SMART_TRANSACTIONS_ERROR, + payload: null, + }); + } + }, [isSmartTransaction, dispatch]); + + const destinationValue = calcTokenValue( + destinationTokenValue, + destinationTokenDecimals, + ); + const destinationAmount = calcTokenAmount( + destinationValue, + destinationTokenDecimals, + ); + const amountToDisplay = formatSwapsValueForDisplay(destinationAmount); + const amountDigitLength = amountToDisplay.match(/\d+/gu).join('').length; + let ellipsedAmountToDisplay = amountToDisplay; + + if (amountDigitLength > 20) { + ellipsedAmountToDisplay = `${amountToDisplay.slice(0, 20)}...`; + } + useEffect(() => { + setReceiveToAmount(ellipsedAmountToDisplay); + }, [ellipsedAmountToDisplay, setReceiveToAmount]); + + const hideTokenApprovalRow = + !approveTxParams || (balanceError && !warningHidden); + + // TODO: use the component for this. + const tokenApprovalTextComponent = ( + + {t('enableToken', [sourceTokenSymbol])} + + ); + + return ( +
    +
    + { + /* istanbul ignore next */ + selectQuotePopoverShown && ( + setSelectQuotePopoverShown(false)} + onSubmit={(aggId) => dispatch(swapsQuoteSelected(aggId))} + swapToSymbol={destinationTokenSymbol} + initialAggId={usedQuote.aggregator} + onQuoteDetailsIsOpened={trackQuoteDetailsOpened} + hideEstimatedGasFee={ + smartTransactionsEnabled && smartTransactionsOptInStatus + } + /> + ) + } + {isShowingWarning && ( + <> + {viewQuotePriceDifferenceWarning} + {(showInsufficientWarning || tokenBalanceUnavailable) && ( + + + + {actionableBalanceErrorMessage} + + + + )} + + )} + +
    + +
    + + + + + {t('quoteRate')} + + { + trackAllAvailableQuotesOpened(); + setSelectQuotePopoverShown(true); + } + } + showIconForSwappingTokens={false} + /> + + + + {t('swapMetaMaskFee')} + + + {t('percentage', [metaMaskFee])} + + + + + + {t('transactionDetailGasHeading')} + + +

    + {t('swapGasFeesSummary', [getTranslatedNetworkName()])} +

    +

    + {t('swapGasFeesDetails')} +

    +

    + { + /* istanbul ignore next */ + trackEvent({ + event: 'Clicked "Gas Fees: Learn More" Link', + category: MetaMetricsEventCategory.Swaps, + }); + global.platform.openTab({ + url: GAS_FEES_LEARN_MORE_URL, + }); + }} + target="_blank" + rel="noopener noreferrer" + > + {t('swapGasFeesLearnMore')} + +

    + + } + /> +
    + + + {feeInEth} + + + {` ${feeInFiat}`} + + +
    + {(maxFeeInFiat || maxFeeInEth) && ( + + + + + {`${t('maxFee')}: `} + + + {maxFeeInFiat || maxFeeInEth} + + + + )} + {!hideTokenApprovalRow && ( + + + {t('swapEnableTokenForSwapping', [tokenApprovalTextComponent])} + + + onFeeCardTokenApprovalClick()} + size={Size.inherit} + className="review-quote__edit-limit" + > + {t('swapEditLimit')} + + + + )} +
    +
    + { + setSubmitClicked(true); + if (!balanceError) { + if (isSmartTransaction && smartTransactionFees?.tradeTxFees) { + dispatch( + signAndSendSwapsSmartTransaction({ + unsignedTransaction, + trackEvent, + history, + additionalTrackingParams, + }), + ); + } else { + dispatch( + signAndSendTransactions( + history, + trackEvent, + additionalTrackingParams, + ), + ); + } + } else if (destinationToken.symbol === defaultSwapsToken.symbol) { + history.push(DEFAULT_ROUTE); + } else { + history.push(`${ASSET_ROUTE}/${destinationToken.address}`); + } + } + } + submitText={ + isSmartTransaction && swapsSTXLoading ? t('preparingSwap') : t('swap') + } + hideCancel + disabled={isSwapButtonDisabled} + className={classnames('review-quote__footer', { + 'review-quote__thin-swaps-footer': isShowingWarning, + })} + showTopBorder + showTermsOfService + /> +
    + ); +} + +ReviewQuote.propTypes = { + setReceiveToAmount: PropTypes.func.isRequired, +}; diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.test.js b/ui/pages/swaps/prepare-swap-page/review-quote.test.js new file mode 100644 index 000000000..98183ffae --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/review-quote.test.js @@ -0,0 +1,104 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { + renderWithProvider, + createSwapsMockStore, + setBackgroundConnection, + MOCKS, +} from '../../../../test/jest'; +import ReviewQuote from './review-quote'; + +jest.mock( + '../../../components/ui/info-tooltip/info-tooltip-icon', + () => () => '', +); + +jest.mock('../../../hooks/gasFeeInput/useGasFeeInputs', () => { + return { + useGasFeeInputs: () => { + return { + maxFeePerGas: 16, + maxPriorityFeePerGas: 3, + gasFeeEstimates: MOCKS.createGasFeeEstimatesForFeeMarket(), + }; + }, + }; +}); + +const middleware = [thunk]; +const createProps = (customProps = {}) => { + return { + setReceiveToAmount: jest.fn(), + ...customProps, + }; +}; + +setBackgroundConnection({ + resetPostFetchState: jest.fn(), + safeRefetchQuotes: jest.fn(), + setSwapsErrorKey: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), + updateTransaction: jest.fn(), + getGasFeeTimeEstimate: jest.fn(), + setSwapsQuotesPollingLimitEnabled: jest.fn(), +}); + +describe('ReviewQuote', () => { + it('renders the component with initial props', () => { + const store = configureMockStore(middleware)(createSwapsMockStore()); + const props = createProps(); + const { getByText } = renderWithProvider(, store); + expect(getByText('New quotes in')).toBeInTheDocument(); + expect(getByText('Quote rate')).toBeInTheDocument(); + expect(getByText('MetaMask fee')).toBeInTheDocument(); + expect(getByText('Estimated gas fee')).toBeInTheDocument(); + expect(getByText('0.00008 ETH')).toBeInTheDocument(); + expect(getByText('Max fee:')).toBeInTheDocument(); + expect(getByText('Swap')).toBeInTheDocument(); + }); + + it('renders the component with EIP-1559 enabled', () => { + const state = createSwapsMockStore(); + state.metamask.networkDetails = { + EIPS: { + 1559: true, + }, + }; + const store = configureMockStore(middleware)(state); + const props = createProps(); + const { getByText } = renderWithProvider(, store); + expect(getByText('New quotes in')).toBeInTheDocument(); + expect(getByText('Quote rate')).toBeInTheDocument(); + expect(getByText('MetaMask fee')).toBeInTheDocument(); + expect(getByText('Estimated gas fee')).toBeInTheDocument(); + expect(getByText('0.00008 ETH')).toBeInTheDocument(); + expect(getByText('Max fee:')).toBeInTheDocument(); + expect(getByText('Swap')).toBeInTheDocument(); + }); + + it('renders text for token approval', () => { + const state = createSwapsMockStore(); + state.metamask.swapsState.quotes.TEST_AGG_2.approvalNeeded = { + data: '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00', + to: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + amount: '0', + from: '0x2369267687A84ac7B494daE2f1542C40E37f4455', + gas: '12', + gasPrice: '34', + }; + const store = configureMockStore(middleware)(state); + const props = createProps(); + const { getByText } = renderWithProvider(, store); + expect(getByText('New quotes in')).toBeInTheDocument(); + expect(getByText('Quote rate')).toBeInTheDocument(); + expect(getByText('MetaMask fee')).toBeInTheDocument(); + expect(getByText('Estimated gas fee')).toBeInTheDocument(); + expect(getByText('0.00008 ETH')).toBeInTheDocument(); + expect(getByText('Max fee:')).toBeInTheDocument(); + expect(getByText('enable DAI')).toBeInTheDocument(); + expect(getByText('Edit limit')).toBeInTheDocument(); + expect(getByText('Swap')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/prepare-swap-page/smart-transactions-popover.js b/ui/pages/swaps/prepare-swap-page/smart-transactions-popover.js new file mode 100644 index 000000000..75c4056ac --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/smart-transactions-popover.js @@ -0,0 +1,121 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import Button from '../../../components/ui/button'; +import Box from '../../../components/ui/box'; +import Popover from '../../../components/ui/popover'; +import Typography from '../../../components/ui/typography'; +import { + TypographyVariant, + DISPLAY, + TextVariant, + FLEX_DIRECTION, + FONT_WEIGHT, + TextColor, +} from '../../../helpers/constants/design-system'; +import { Text } from '../../../components/component-library'; +import PopoverCustomBackground from '../popover-custom-background/popover-custom-background'; + +export default function SmartTransactionsPopover({ + onEnableSmartTransactionsClick, + onCloseSmartTransactionsOptInPopover, +}) { + const t = useContext(I18nContext); + return ( + + + + + + + + + } + footerClassName="smart-transactions-popover__footer" + className="smart-transactions-popover" + CustomBackground={() => { + return ( + + ); + }} + > + + + {t('swapSwapSwitch')} + + + {t('smartSwapsDescription')} + + +
  • {t('stxBenefit1')}
  • +
  • {t('stxBenefit2')}
  • +
  • {t('stxBenefit3')}
  • +
  • + {t('stxBenefit4')} + + {' *'} + +
  • +
    + + {t('smartSwapsSubDescription')}  + + {t('stxYouCanOptOut')}  + + +
    +
    + ); +} + +SmartTransactionsPopover.propTypes = { + onEnableSmartTransactionsClick: PropTypes.func.isRequired, + onCloseSmartTransactionsOptInPopover: PropTypes.func.isRequired, +}; diff --git a/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.js b/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.js new file mode 100644 index 000000000..1219fbd73 --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.js @@ -0,0 +1,102 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import Box from '../../../components/ui/box'; +import { + DISPLAY, + AlignItems, + SEVERITIES, + Size, + TextVariant, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { GasRecommendations } from '../../../../shared/constants/gas'; +import { + BannerAlert, + Text, + ButtonLink, +} from '../../../components/component-library'; + +export default function ViewQuotePriceDifference(props) { + const { + usedQuote, + sourceTokenValue, + destinationTokenValue, + onAcknowledgementClick, + acknowledged, + priceSlippageFromSource, + priceSlippageFromDestination, + priceDifferencePercentage, + priceSlippageUnknownFiatValue, + } = props; + + const t = useContext(I18nContext); + + let priceDifferenceTitle = t('swapPriceUnavailableTitle'); + let priceDifferenceMessage = t('swapPriceUnavailableDescription'); + let priceDifferenceClass = GasRecommendations.high; + if (!priceSlippageUnknownFiatValue) { + priceDifferenceTitle = t('swapPriceDifferenceTitle', [ + priceDifferencePercentage, + ]); + priceDifferenceMessage = t('swapPriceDifference', [ + sourceTokenValue, // Number of source token to swap + usedQuote.sourceTokenInfo.symbol, // Source token symbol + priceSlippageFromSource, // Source tokens total value + destinationTokenValue, // Number of destination tokens in return + usedQuote.destinationTokenInfo.symbol, // Destination token symbol, + priceSlippageFromDestination, // Destination tokens total value + ]); + priceDifferenceClass = usedQuote.priceSlippage.bucket; + } + const severity = + priceDifferenceClass === GasRecommendations.high + ? SEVERITIES.DANGER + : SEVERITIES.WARNING; + + return ( + + + + + {priceDifferenceMessage} + + {!acknowledged && ( + + {t('swapAnyway')} + + )} + + + + ); +} + +ViewQuotePriceDifference.propTypes = { + usedQuote: PropTypes.object, + sourceTokenValue: PropTypes.string, + destinationTokenValue: PropTypes.string, + onAcknowledgementClick: PropTypes.func, + acknowledged: PropTypes.bool, + priceSlippageFromSource: PropTypes.string, + priceSlippageFromDestination: PropTypes.string, + priceDifferencePercentage: PropTypes.number, + priceSlippageUnknownFiatValue: PropTypes.bool, +}; diff --git a/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.test.js b/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.test.js new file mode 100644 index 000000000..71c5e72e9 --- /dev/null +++ b/ui/pages/swaps/prepare-swap-page/view-quote-price-difference.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import { NETWORK_TYPES } from '../../../../shared/constants/network'; +import { GasRecommendations } from '../../../../shared/constants/gas'; +import ViewQuotePriceDifference from './view-quote-price-difference'; + +describe('View Price Quote Difference', () => { + const mockState = { + metamask: { + tokens: [], + providerConfig: { type: NETWORK_TYPES.RPC, nickname: '', rpcUrl: '' }, + preferences: { showFiatInTestnets: true }, + currentCurrency: 'usd', + conversionRate: 600.0, + }, + }; + + const mockStore = configureMockStore()(mockState); + + // Sample transaction is 1 ETH to ~42.880915 LINK. + const DEFAULT_PROPS = { + usedQuote: { + trade: { + data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000007756e69737761700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000514910771af9ca656af840dff83e8264ecf986ca0000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000024855454cb32d335f0000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000005fc7b7100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f161421c8e0000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000514910771af9ca656af840dff83e8264ecf986ca', + from: '0xd7440fdcb70a9fba55dfe06942ddbc17679c90ac', + value: '0xde0b6b3a7640000', + gas: '0xbbfd0', + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + }, + sourceAmount: '1000000000000000000', + destinationAmount: '42947749216634160067', + error: null, + sourceToken: '0x0000000000000000000000000000000000000000', + destinationToken: '0x514910771af9ca656af840dff83e8264ecf986ca', + approvalNeeded: null, + maxGas: 770000, + averageGas: 210546, + estimatedRefund: 80000, + fetchTime: 647, + aggregator: 'uniswap', + aggType: 'DEX', + fee: 0.875, + gasMultiplier: 1.5, + priceSlippage: { + ratio: 1.007876641534847, + calculationError: '', + bucket: GasRecommendations.low, + sourceAmountInETH: 1, + destinationAmountInETH: 0.9921849150875727, + }, + slippage: 2, + sourceTokenInfo: { + symbol: 'ETH', + name: 'Ether', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + iconUrl: 'images/black-eth-logo.svg', + }, + destinationTokenInfo: { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurances: 12, + iconUrl: + 'https://cloudflare-ipfs.com/ipfs/QmQhZAdcZvW9T2tPm516yHqbGkfhyZwTZmLixW9MXJudTA', + }, + ethFee: '0.011791', + ethValueOfTokens: '0.99220724791716534441', + overallValueOfQuote: '0.98041624791716534441', + metaMaskFeeInEth: '0.00875844985551091729', + isBestQuote: true, + savings: { + performance: '0.00207907025112527799', + fee: '0.005581', + metaMaskFee: '0.00875844985551091729', + total: '-0.0010983796043856393', + medianMetaMaskFee: '0.00874009740688812165', + }, + }, + sourceTokenValue: '1', + destinationTokenValue: '42.947749', + }; + + it('displays an error when in low bucket', () => { + const { getByText, getByTestId } = renderWithProvider( + , + mockStore, + ); + expect(getByTestId('mm-banner-alert')).toHaveClass( + 'mm-banner-alert--severity-warning', + ); + expect( + getByText('You are about to swap 1 ETH (~) for 42.947749 LINK (~).'), + ).toBeInTheDocument(); + expect(getByText('Swap anyway')).toBeInTheDocument(); + }); + + it('displays an error when in medium bucket', () => { + const props = { ...DEFAULT_PROPS }; + props.usedQuote.priceSlippage.bucket = GasRecommendations.medium; + const { getByText, getByTestId } = renderWithProvider( + , + mockStore, + ); + expect(getByTestId('mm-banner-alert')).toHaveClass( + 'mm-banner-alert--severity-warning', + ); + expect( + getByText('You are about to swap 1 ETH (~) for 42.947749 LINK (~).'), + ).toBeInTheDocument(); + expect(getByText('Swap anyway')).toBeInTheDocument(); + }); + + it('displays an error when in high bucket', () => { + const props = { ...DEFAULT_PROPS }; + props.usedQuote.priceSlippage.bucket = GasRecommendations.high; + const { getByText, getByTestId } = renderWithProvider( + , + mockStore, + ); + expect(getByTestId('mm-banner-alert')).toHaveClass( + 'mm-banner-alert--severity-danger', + ); + expect( + getByText('You are about to swap 1 ETH (~) for 42.947749 LINK (~).'), + ).toBeInTheDocument(); + expect(getByText('Swap anyway')).toBeInTheDocument(); + }); + + it('displays a fiat error when calculationError is present', () => { + const props = { ...DEFAULT_PROPS, priceSlippageUnknownFiatValue: true }; + props.usedQuote.priceSlippage.calculationError = + 'Could not determine price.'; + const { getByText, getByTestId } = renderWithProvider( + , + mockStore, + ); + expect(getByTestId('mm-banner-alert')).toHaveClass( + 'mm-banner-alert--severity-danger', + ); + expect(getByText('Check your rate before proceeding')).toBeInTheDocument(); + expect( + getByText( + 'Price impact could not be determined due to lack of market price data. Please confirm that you are comfortable with the amount of tokens you are about to receive before swapping.', + ), + ).toBeInTheDocument(); + expect(getByText('Swap anyway')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/searchable-item-list/__snapshots__/searchable-item-list.test.js.snap b/ui/pages/swaps/searchable-item-list/__snapshots__/searchable-item-list.test.js.snap index acf652948..f5e215c9f 100644 --- a/ui/pages/swaps/searchable-item-list/__snapshots__/searchable-item-list.test.js.snap +++ b/ui/pages/swaps/searchable-item-list/__snapshots__/searchable-item-list.test.js.snap @@ -59,6 +59,7 @@ exports[`SearchableItemList renders the component with initial props 2`] = ` > primaryLabel diff --git a/ui/pages/swaps/searchable-item-list/index.scss b/ui/pages/swaps/searchable-item-list/index.scss index b1bd2e589..7ddf1ed7f 100644 --- a/ui/pages/swaps/searchable-item-list/index.scss +++ b/ui/pages/swaps/searchable-item-list/index.scss @@ -142,7 +142,6 @@ &__labels { display: flex; justify-content: space-between; - max-width: 237px; flex: 1; -moz-animation: fadein 1s; -webkit-animation: fadein 1s; diff --git a/ui/pages/swaps/searchable-item-list/item-list/__snapshots__/item-list.component.test.js.snap b/ui/pages/swaps/searchable-item-list/item-list/__snapshots__/item-list.component.test.js.snap index 89d3c0343..c002832dd 100644 --- a/ui/pages/swaps/searchable-item-list/item-list/__snapshots__/item-list.component.test.js.snap +++ b/ui/pages/swaps/searchable-item-list/item-list/__snapshots__/item-list.component.test.js.snap @@ -23,6 +23,7 @@ exports[`ItemList renders the component with initial props 1`] = ` > primaryLabel diff --git a/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js b/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js index 074e1f6af..8fd987693 100644 --- a/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js +++ b/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js @@ -59,6 +59,7 @@ export default function ItemList({ listContainerClassName, )} ref={containerRef} + data-testid="searchable-item-list-list-container" > {results.slice(0, maxListItems).map((result, i) => { if (hideItemIf?.(result)) { @@ -107,7 +108,10 @@ export default function ItemList({
    {primaryLabel ? ( - + {primaryLabel} ) : null} @@ -134,7 +138,11 @@ export default function ItemList({ ) : null}
    {result.notImported && ( - )} @@ -148,7 +156,7 @@ export default function ItemList({ key="searchable-item-list-item-last" > { diff --git a/ui/pages/swaps/select-quote-popover/index.scss b/ui/pages/swaps/select-quote-popover/index.scss index 6f7c46439..4bef9c071 100644 --- a/ui/pages/swaps/select-quote-popover/index.scss +++ b/ui/pages/swaps/select-quote-popover/index.scss @@ -113,7 +113,7 @@ color: var(--color-primary-inverse); &:hover { - color: var(--color-text-default); + color: var(--color-icon-alternative); } } diff --git a/ui/pages/swaps/select-quote-popover/quote-details/index.scss b/ui/pages/swaps/select-quote-popover/quote-details/index.scss index 72d65d9c4..01fc01dc1 100644 --- a/ui/pages/swaps/select-quote-popover/quote-details/index.scss +++ b/ui/pages/swaps/select-quote-popover/quote-details/index.scss @@ -48,7 +48,7 @@ } &__light-grey { - color: var(--color-border-muted); + color: var(--color-text-muted); } &__row { diff --git a/ui/pages/swaps/select-quote-popover/sort-list/__snapshots__/sort-list.test.js.snap b/ui/pages/swaps/select-quote-popover/sort-list/__snapshots__/sort-list.test.js.snap index f7686228a..b048f28c9 100644 --- a/ui/pages/swaps/select-quote-popover/sort-list/__snapshots__/sort-list.test.js.snap +++ b/ui/pages/swaps/select-quote-popover/sort-list/__snapshots__/sort-list.test.js.snap @@ -18,6 +18,7 @@ exports[`SortList renders the component with initial props 1`] = `
    + +
    +`; + +exports[`SelectedToken renders the component with no token selected 1`] = ` +
    + +
    +`; diff --git a/ui/pages/swaps/selected-token/selected-token.js b/ui/pages/swaps/selected-token/selected-token.js new file mode 100644 index 000000000..516dbdc24 --- /dev/null +++ b/ui/pages/swaps/selected-token/selected-token.js @@ -0,0 +1,87 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { + Icon, + IconName, + IconSize, +} from '../../../components/component-library'; +import { IconColor } from '../../../helpers/constants/design-system'; +import UrlIcon from '../../../components/ui/url-icon'; +import { I18nContext } from '../../../contexts/i18n'; + +export default function SelectedToken({ + onClick, + onClose, + selectedToken, + testId, +}) { + const t = useContext(I18nContext); + const hasIcon = selectedToken?.iconUrl && selectedToken?.symbol; + + const onKeyUp = (e) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Enter') { + onClick(e); + } + }; + + return ( +
    +
    + {hasIcon && ( + + )} +
    +
    + + {selectedToken?.symbol || t('swapSelectAToken')} + +
    +
    +
    + +
    + ); +} + +SelectedToken.propTypes = { + onClick: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + selectedToken: PropTypes.object.isRequired, + testId: PropTypes.string, +}; diff --git a/ui/pages/swaps/selected-token/selected-token.test.js b/ui/pages/swaps/selected-token/selected-token.test.js new file mode 100644 index 000000000..15cca222a --- /dev/null +++ b/ui/pages/swaps/selected-token/selected-token.test.js @@ -0,0 +1,45 @@ +import React from 'react'; + +import { renderWithProvider, fireEvent } from '../../../../test/jest'; +import SelectedToken from './selected-token'; + +const createProps = (customProps = {}) => { + return { + onClick: jest.fn(), + selectedToken: { + iconUrl: 'iconUrl', + symbol: 'ETH', + }, + ...customProps, + }; +}; + +describe('SelectedToken', () => { + it('renders the component with initial props', () => { + const props = createProps(); + const { container, getByText } = renderWithProvider( + , + ); + expect(container).toMatchSnapshot(); + expect(getByText('ETH')).toBeInTheDocument(); + }); + + it('renders the component with no token selected', () => { + const props = createProps(); + props.selectedToken.symbol = undefined; + const { container, getByText } = renderWithProvider( + , + ); + expect(container).toMatchSnapshot(); + expect(getByText('Select token')).toBeInTheDocument(); + }); + + it('renders the component and opens the list', () => { + const props = createProps(); + const { getByTestId } = renderWithProvider(); + const dropdownSearchList = getByTestId('dropdown-search-list'); + expect(dropdownSearchList).toBeInTheDocument(); + fireEvent.click(dropdownSearchList); + expect(props.onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/pages/swaps/slippage-buttons/slippage-buttons.js b/ui/pages/swaps/slippage-buttons/slippage-buttons.js index fe7e81f80..6bd993688 100644 --- a/ui/pages/swaps/slippage-buttons/slippage-buttons.js +++ b/ui/pages/swaps/slippage-buttons/slippage-buttons.js @@ -213,7 +213,7 @@ export default function SlippageButtons({ paddingRight={2} fontWeight={FontWeight.Bold} > - {t('smartTransaction')} + {t('smartSwap')} {currentSmartTransactionsError ? ( { expect( document.querySelector('.slippage-buttons__button-group'), ).toMatchSnapshot(); - expect(queryByText('Smart transaction')).not.toBeInTheDocument(); + expect(queryByText('Smart swap')).not.toBeInTheDocument(); expect(getByTestId('button-group__button1')).toHaveAttribute( 'aria-checked', 'true', @@ -56,7 +56,7 @@ describe('SlippageButtons', () => { expect( document.querySelector('.slippage-buttons__button-group'), ).toMatchSnapshot(); - expect(getByText('Smart transaction')).toBeInTheDocument(); + expect(getByText('Smart swap')).toBeInTheDocument(); expect(document.querySelector('.toggle-button--off')).toBeInTheDocument(); fireEvent.click(document.querySelector('.toggle-button')); expect(setSmartTransactionsOptInStatus).toHaveBeenCalledWith(true, false); diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js index f926ac30b..9285fcb5e 100644 --- a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js +++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js @@ -277,17 +277,14 @@ export default function SmartTransactionStatusPage() { latestSmartTransaction.cancellable && !cancelSwapLinkClicked; const CancelSwap = () => { - let feeInFiat; - if (cancellationFeeWei > 0) { - ({ feeInFiat } = getFeeForSmartTransaction({ - chainId, - currentCurrency, - conversionRate, - USDConversionRate, - nativeCurrencySymbol, - feeInWeiDec: cancellationFeeWei, - })); - } + const { feeInFiat } = getFeeForSmartTransaction({ + chainId, + currentCurrency, + conversionRate, + USDConversionRate, + nativeCurrencySymbol, + feeInWeiDec: cancellationFeeWei || 0, + }); return ( - {feeInFiat - ? t('cancelSwapForFee', [feeInFiat]) - : t('cancelSwapForFree')} + {t('attemptToCancelSwap', [feeInFiat])} ); diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js index 4d71002bc..57a8212e4 100644 --- a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js +++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js @@ -155,7 +155,7 @@ describe('SmartTransactionStatusLabel', () => { store, ); expect(getByText('Publicly submitting your Swap...')).toBeInTheDocument(); - const cancelLink = getByText('Cancel swap for ~0'); + const cancelLink = getByText('Attempt to cancel swap for ~0'); expect(cancelLink).toBeInTheDocument(); fireEvent.click(cancelLink); expect( diff --git a/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.js b/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.js new file mode 100644 index 000000000..87a4e3ee2 --- /dev/null +++ b/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.js @@ -0,0 +1,170 @@ +import React, { useContext } from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { I18nContext } from '../../../contexts/i18n'; +import { BannerAlert } from '../../../components/component-library/banner-alert'; +import Box from '../../../components/ui/box'; +import { + AlignItems, + SEVERITIES, + Size, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { ButtonLink, Text } from '../../../components/component-library'; +import { + QUOTES_EXPIRED_ERROR, + SWAP_FAILED_ERROR, + ERROR_FETCHING_QUOTES, + QUOTES_NOT_AVAILABLE_ERROR, + CONTRACT_DATA_DISABLED_ERROR, + OFFLINE_FOR_MAINTENANCE, + SLIPPAGE_OVER_LIMIT_ERROR, + SLIPPAGE_VERY_HIGH_ERROR, + SLIPPAGE_TOO_LOW_ERROR, + SLIPPAGE_NEGATIVE_ERROR, +} from '../../../../shared/constants/swaps'; +import { setTransactionSettingsOpened } from '../../../ducks/swaps/swaps'; + +export default function SwapsBannerAlert({ swapsErrorKey }) { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + let severity = SEVERITIES.DANGER; + let title; + let description; + switch (swapsErrorKey) { + case SLIPPAGE_OVER_LIMIT_ERROR: + title = t('swapSlippageOverLimitTitle'); + description = ( + + + {t('swapSlippageOverLimitDescription')} + + { + e.preventDefault(); + dispatch(setTransactionSettingsOpened(true)); + }} + > + {t('swapEditTransactionSettings')} + + + ); + break; + case SLIPPAGE_VERY_HIGH_ERROR: + severity = SEVERITIES.WARNING; + title = t('swapSlippageVeryHighTitle'); + description = ( + + {t('swapSlippageVeryHighDescription')} + + ); + break; + case SLIPPAGE_TOO_LOW_ERROR: + severity = SEVERITIES.WARNING; + title = t('swapSlippageTooLowTitle'); + description = ( + + {t('swapSlippageTooLowDescription')} + + ); + break; + case SLIPPAGE_NEGATIVE_ERROR: + title = t('swapSlippageNegativeTitle'); + description = ( + + + {t('swapSlippageNegativeDescription')} + + { + e.preventDefault(); + dispatch(setTransactionSettingsOpened(true)); + }} + > + {t('swapEditTransactionSettings')} + + + ); + break; + case QUOTES_NOT_AVAILABLE_ERROR: + title = t('swapQuotesNotAvailableErrorTitle'); + description = ( + + + {t('swapQuotesNotAvailableDescription')} + + + {t('swapLearnMore')} + + + ); + break; + case ERROR_FETCHING_QUOTES: + title = t('swapFetchingQuotesErrorTitle'); + description = ( + + {t('swapFetchingQuotesErrorDescription')} + + ); + break; + case CONTRACT_DATA_DISABLED_ERROR: + title = t('swapContractDataDisabledErrorTitle'); + description = ( + + {t('swapContractDataDisabledErrorDescription')} + + ); + break; + case QUOTES_EXPIRED_ERROR: + title = t('swapQuotesExpiredErrorTitle'); + description = ( + + {t('swapQuotesExpiredErrorDescription')} + + ); + break; + case OFFLINE_FOR_MAINTENANCE: + title = t('offlineForMaintenance'); + description = ( + + {t('metamaskSwapsOfflineDescription')} + + ); + break; + case SWAP_FAILED_ERROR: + title = t('swapFailedErrorTitle'); + break; + default: + } + + return ( + + {description} + + ); +} + +SwapsBannerAlert.propTypes = { + swapsErrorKey: PropTypes.string, +}; diff --git a/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.test.js b/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.test.js new file mode 100644 index 000000000..638415ecf --- /dev/null +++ b/ui/pages/swaps/swaps-banner-alert/swaps-banner-alert.test.js @@ -0,0 +1,171 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { + renderWithProvider, + createSwapsMockStore, +} from '../../../../test/jest'; +import { + QUOTES_EXPIRED_ERROR, + SWAP_FAILED_ERROR, + ERROR_FETCHING_QUOTES, + QUOTES_NOT_AVAILABLE_ERROR, + CONTRACT_DATA_DISABLED_ERROR, + OFFLINE_FOR_MAINTENANCE, + SLIPPAGE_OVER_LIMIT_ERROR, + SLIPPAGE_VERY_HIGH_ERROR, + SLIPPAGE_TOO_LOW_ERROR, + SLIPPAGE_NEGATIVE_ERROR, +} from '../../../../shared/constants/swaps'; +import SwapsBannerAlert from './swaps-banner-alert'; + +const middleware = [thunk]; + +describe('SwapsBannerAlert', () => { + it('renders the component with the SLIPPAGE_OVER_LIMIT_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Reduce slippage to continue')).toBeInTheDocument(); + expect( + getByText( + 'Slippage tolerance must be 15% or less. Anything higher will result in a bad rate.', + ), + ).toBeInTheDocument(); + expect(getByText('Edit transaction settings')).toBeInTheDocument(); + }); + + it('renders the component with the SLIPPAGE_VERY_HIGH_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Very high slippage')).toBeInTheDocument(); + expect( + getByText( + 'The slippage entered is considered very high and may result in a bad rate', + ), + ).toBeInTheDocument(); + }); + + it('renders the component with the SLIPPAGE_TOO_LOW_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect( + getByText('Increase slippage to avoid transaction failure'), + ).toBeInTheDocument(); + expect( + getByText( + 'Max slippage is too low which may cause your transaction to fail.', + ), + ).toBeInTheDocument(); + }); + + it('renders the component with the SLIPPAGE_NEGATIVE_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Increase slippage to continue')).toBeInTheDocument(); + expect( + getByText('Slippage must be greater or equal to zero'), + ).toBeInTheDocument(); + expect(getByText('Edit transaction settings')).toBeInTheDocument(); + }); + + it('renders the component with the QUOTES_NOT_AVAILABLE_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('No quotes available')).toBeInTheDocument(); + expect( + getByText('Reduce the size of your trade or use a different token.'), + ).toBeInTheDocument(); + expect(getByText('Learn more about Swaps')).toBeInTheDocument(); + }); + + it('renders the component with the ERROR_FETCHING_QUOTES', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Error fetching quotes')).toBeInTheDocument(); + expect( + getByText( + 'Hmmm... something went wrong. Try again, or if errors persist, contact customer support.', + ), + ).toBeInTheDocument(); + }); + + it('renders the component with the CONTRACT_DATA_DISABLED_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect( + getByText('Contract data is not enabled on your Ledger'), + ).toBeInTheDocument(); + expect( + getByText( + 'In the Ethereum app on your Ledger, go to "Settings" and allow contract data. Then, try your swap again.', + ), + ).toBeInTheDocument(); + }); + + it('renders the component with the QUOTES_EXPIRED_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Quotes timeout')).toBeInTheDocument(); + expect( + getByText('Please request new quotes to get the latest rates.'), + ).toBeInTheDocument(); + }); + + it('renders the component with the OFFLINE_FOR_MAINTENANCE', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Offline for maintenance')).toBeInTheDocument(); + expect( + getByText( + 'MetaMask Swaps is undergoing maintenance. Please check back later.', + ), + ).toBeInTheDocument(); + }); + + it('renders the component with the SWAP_FAILED_ERROR', () => { + const mockStore = createSwapsMockStore(); + const store = configureMockStore(middleware)(mockStore); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Swap failed')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/swaps/swaps-footer/index.scss b/ui/pages/swaps/swaps-footer/index.scss index c06454a83..69dabf9a8 100644 --- a/ui/pages/swaps/swaps-footer/index.scss +++ b/ui/pages/swaps/swaps-footer/index.scss @@ -1,5 +1,9 @@ .swaps-footer { width: 100%; + z-index: 50; + position: sticky; + bottom: 0; + background-color: var(--color-background-default); &--warning { .btn-primary { @@ -8,7 +12,6 @@ } } - @include screen-sm-max { &--border { .swaps-footer__custom-page-container-footer-class { diff --git a/ui/pages/swaps/swaps.util.ts b/ui/pages/swaps/swaps.util.ts index 79b16a3f4..566faa93f 100644 --- a/ui/pages/swaps/swaps.util.ts +++ b/ui/pages/swaps/swaps.util.ts @@ -553,6 +553,21 @@ export function formatSwapsValueForDisplay(destinationAmount: string): string { return amountToDisplay; } +export const getClassNameForCharLength = ( + num: string, + classNamePrefix: string, +): string => { + let modifier; + if (!num || num.length <= 10) { + modifier = 'lg'; + } else if (num.length > 10 && num.length <= 13) { + modifier = 'md'; + } else { + modifier = 'sm'; + } + return `${classNamePrefix}--${modifier}`; +}; + /** * Checks whether a contract address is valid before swapping tokens. * @@ -671,11 +686,11 @@ export const getTranslatedStxErrorMessage = ( switch (errorType) { case StxErrorTypes.unavailable: case StxErrorTypes.regularTxPending: - return t('stxErrorUnavailable'); + return t('smartSwapsErrorUnavailable'); case StxErrorTypes.notEnoughFunds: - return t('stxErrorNotEnoughFunds'); + return t('smartSwapsErrorNotEnoughFunds'); default: - return t('stxErrorUnavailable'); + return t('smartSwapsErrorUnavailable'); } }; diff --git a/ui/pages/swaps/transaction-settings/__snapshots__/transaction-settings.test.js.snap b/ui/pages/swaps/transaction-settings/__snapshots__/transaction-settings.test.js.snap new file mode 100644 index 000000000..3acd3682e --- /dev/null +++ b/ui/pages/swaps/transaction-settings/__snapshots__/transaction-settings.test.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TransactionSettings renders the component with initial props 1`] = ` +
    + + + +
    +`; + +exports[`TransactionSettings renders the component with the smart transaction opt-in button available, opt into STX 1`] = `null`; + +exports[`TransactionSettings renders the component with the smart transaction opt-in button available, opt into STX 2`] = ` +
    + + + +
    +`; diff --git a/ui/pages/swaps/transaction-settings/index.scss b/ui/pages/swaps/transaction-settings/index.scss new file mode 100644 index 000000000..c08b35d98 --- /dev/null +++ b/ui/pages/swaps/transaction-settings/index.scss @@ -0,0 +1,82 @@ +.transaction-settings { + &__content { + @include screen-sm-max { + height: 100%; + } + } + + &__button-group { + & &-custom-button { + cursor: text; + display: flex; + align-items: center; + justify-content: center; + position: relative; + min-width: 72px; + margin-right: 0; + } + } + + &__custom-input { + display: flex; + justify-content: center; + + input { + border: none; + width: 64px; + text-align: center; + background: var(--color-primary-default); + color: var(--color-primary-inverse); + font-weight: inherit; + outline: none; + + &::-webkit-input-placeholder { /* WebKit, Blink, Edge */ + color: var(--color-primary-inverse); + } + + &:-moz-placeholder { /* Mozilla Firefox 4 to 18 */ + color: var(--color-primary-inverse); + opacity: 1; + } + + &::-moz-placeholder { /* Mozilla Firefox 19+ */ + color: var(--color-primary-inverse); + opacity: 1; + } + + &:-ms-input-placeholder { /* Internet Explorer 10-11 */ + color: var(--color-primary-inverse); + } + + &::-ms-input-placeholder { /* Microsoft Edge */ + color: var(--color-primary-inverse); + } + + &::placeholder { /* Most modern browsers support this now. */ + color: var(--color-primary-inverse); + } + } + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ + } + + input[type=number] { + -moz-appearance: textfield; + } + + &--danger { + input { + background: var(--color-error-default); + } + } + } + + &__percentage-suffix { + position: absolute; + right: 5px; + } +} diff --git a/ui/pages/swaps/transaction-settings/transaction-settings.js b/ui/pages/swaps/transaction-settings/transaction-settings.js new file mode 100644 index 000000000..a0d213eac --- /dev/null +++ b/ui/pages/swaps/transaction-settings/transaction-settings.js @@ -0,0 +1,380 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { I18nContext } from '../../../contexts/i18n'; +import ButtonGroup from '../../../components/ui/button-group'; +import Button from '../../../components/ui/button'; +import InfoTooltip from '../../../components/ui/info-tooltip'; +import ToggleButton from '../../../components/ui/toggle-button'; +import Box from '../../../components/ui/box'; +import Typography from '../../../components/ui/typography'; +import { + TypographyVariant, + AlignItems, + JustifyContent, + DISPLAY, + SEVERITIES, + FlexDirection, +} from '../../../helpers/constants/design-system'; +import { getTranslatedStxErrorMessage } from '../swaps.util'; +import { + Slippage, + SLIPPAGE_OVER_LIMIT_ERROR, + SLIPPAGE_NEGATIVE_ERROR, +} from '../../../../shared/constants/swaps'; +import { + BannerAlert, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ButtonPrimary, +} from '../../../components/component-library'; +import { setSwapsErrorKey } from '../../../store/actions'; +import { getSwapsErrorKey } from '../../../ducks/swaps/swaps'; + +export default function TransactionSettings({ + onSelect, + onModalClose, + maxAllowedSlippage, + currentSlippage, + smartTransactionsEnabled, + smartTransactionsOptInStatus, + setSmartTransactionsOptInStatus, + currentSmartTransactionsError, + isDirectWrappingEnabled, +}) { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const swapsErrorKey = useSelector(getSwapsErrorKey); + const [customValue, setCustomValue] = useState(() => { + if ( + typeof currentSlippage === 'number' && + !Object.values(Slippage).includes(currentSlippage) + ) { + return currentSlippage.toString(); + } + return ''; + }); + const [enteringCustomValue, setEnteringCustomValue] = useState(false); + const [activeButtonIndex, setActiveButtonIndex] = useState(() => { + if (currentSlippage === Slippage.high) { + return 1; // 3% slippage. + } else if (currentSlippage === Slippage.default) { + return 0; // 2% slippage. + } else if (typeof currentSlippage === 'number') { + return 2; // Custom slippage. + } + return 0; + }); + const [inputRef, setInputRef] = useState(null); + const [newSlippage, setNewSlippage] = useState(currentSlippage); + const [newSmartTransactionsOptInStatus, setNewSmartTransactionsOptInStatus] = + useState(smartTransactionsOptInStatus); + + const didFormChange = + newSlippage !== currentSlippage || + newSmartTransactionsOptInStatus !== smartTransactionsOptInStatus; + + const updateTransactionSettings = () => { + if (newSlippage !== currentSlippage) { + onSelect(newSlippage); + } + if (newSmartTransactionsOptInStatus !== smartTransactionsOptInStatus) { + setSmartTransactionsOptInStatus(newSmartTransactionsOptInStatus); + } + }; + + let notificationText = ''; + let notificationTitle = ''; + let notificationSeverity = SEVERITIES.INFO; + if (customValue) { + // customValue is a string, e.g. '0' + if (Number(customValue) < 0) { + notificationSeverity = SEVERITIES.DANGER; + notificationText = t('swapSlippageNegativeDescription'); + notificationTitle = t('swapSlippageNegativeTitle'); + dispatch(setSwapsErrorKey(SLIPPAGE_NEGATIVE_ERROR)); + } else if (Number(customValue) > 0 && Number(customValue) <= 1) { + // We will not show this warning for 0% slippage, because we will only + // return non-slippage quotes from off-chain makers. + notificationSeverity = SEVERITIES.WARNING; + notificationText = t('swapSlippageTooLowDescription'); + notificationTitle = t('swapSlippageTooLowTitle'); + } else if ( + Number(customValue) >= 5 && + Number(customValue) <= maxAllowedSlippage + ) { + notificationSeverity = SEVERITIES.WARNING; + notificationText = t('swapSlippageVeryHighDescription'); + notificationTitle = t('swapSlippageVeryHighTitle'); + } else if (Number(customValue) > maxAllowedSlippage) { + notificationSeverity = SEVERITIES.DANGER; + notificationText = t('swapSlippageOverLimitDescription'); + notificationTitle = t('swapSlippageOverLimitTitle'); + dispatch(setSwapsErrorKey(SLIPPAGE_OVER_LIMIT_ERROR)); + } else if (Number(customValue) === 0) { + notificationSeverity = SEVERITIES.INFO; + notificationText = t('swapSlippageZeroDescription'); + notificationTitle = t('swapSlippageZeroTitle'); + } else if (swapsErrorKey) { + dispatch(setSwapsErrorKey('')); + } + } + const isDangerSeverity = notificationSeverity === SEVERITIES.DANGER; + + const customValueText = customValue || t('swapCustom'); + + useEffect(() => { + if ( + inputRef && + enteringCustomValue && + window.document.activeElement !== inputRef + ) { + inputRef.focus(); + } + }, [inputRef, enteringCustomValue]); + + useEffect(() => { + if (activeButtonIndex !== 2) { + // If it's not a custom slippage, remove an error key. + dispatch(setSwapsErrorKey('')); + } + }, [dispatch, activeButtonIndex]); + + return ( + + + + + {t('transactionSettings')} + + + + <> + {smartTransactionsEnabled && ( + + + + {t('smartSwap')} + + {currentSmartTransactionsError ? ( + + ) : ( + + )} + + { + setNewSmartTransactionsOptInStatus(!value, value); + }} + offLabel={t('off')} + onLabel={t('on')} + disabled={Boolean(currentSmartTransactionsError)} + /> + + )} + {!isDirectWrappingEnabled && ( + <> + + + {t('swapsMaxSlippage')} + + {currentSmartTransactionsError ? ( + + ) : ( + + )} + + + + + + + + + + )} + + {notificationText && ( + + + + {notificationText} + + + + )} + + + { + updateTransactionSettings(); + onModalClose(); + }} + block + disabled={!didFormChange} + data-testid="update-transaction-settings-button" + > + {t('update')} + + + + + + ); +} + +TransactionSettings.propTypes = { + onSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + maxAllowedSlippage: PropTypes.number.isRequired, + currentSlippage: PropTypes.number, + smartTransactionsEnabled: PropTypes.bool.isRequired, + smartTransactionsOptInStatus: PropTypes.bool, + setSmartTransactionsOptInStatus: PropTypes.func, + currentSmartTransactionsError: PropTypes.string, + isDirectWrappingEnabled: PropTypes.bool, +}; diff --git a/ui/pages/swaps/transaction-settings/transaction-settings.stories.js b/ui/pages/swaps/transaction-settings/transaction-settings.stories.js new file mode 100644 index 000000000..f94b9e960 --- /dev/null +++ b/ui/pages/swaps/transaction-settings/transaction-settings.stories.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import TransactionSettings from './transaction-settings'; + +export default { + title: 'Pages/Swaps/TransactionSettings', +}; + +export const DefaultStory = () => ( +
    + +
    +); + +DefaultStory.storyName = 'Default'; diff --git a/ui/pages/swaps/transaction-settings/transaction-settings.test.js b/ui/pages/swaps/transaction-settings/transaction-settings.test.js new file mode 100644 index 000000000..aa0255db1 --- /dev/null +++ b/ui/pages/swaps/transaction-settings/transaction-settings.test.js @@ -0,0 +1,140 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +// import { useDispatch } from 'react-redux'; + +import { + renderWithProvider, + fireEvent, + createSwapsMockStore, +} from '../../../../test/jest'; +import { Slippage } from '../../../../shared/constants/swaps'; +import TransactionSettings from './transaction-settings'; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + + return { + ...actual, + useDispatch: () => jest.fn(), + }; +}); + +const createProps = (customProps = {}) => { + return { + onSelect: jest.fn(), + onModalClose: jest.fn(), + maxAllowedSlippage: 15, + currentSlippage: Slippage.high, + smartTransactionsEnabled: false, + ...customProps, + }; +}; + +const middleware = [thunk]; + +describe('TransactionSettings', () => { + let store; + + beforeEach(() => { + const swapsMockStore = createSwapsMockStore(); + store = configureMockStore(middleware)(swapsMockStore); + }); + + it('renders the component with initial props', () => { + const { getByText, queryByText, getByTestId } = renderWithProvider( + , + store, + ); + expect(getByText('2%')).toBeInTheDocument(); + expect(getByText('3%')).toBeInTheDocument(); + expect(getByText('custom')).toBeInTheDocument(); + expect( + document.querySelector('.transaction-settings__button-group'), + ).toMatchSnapshot(); + expect(queryByText('Smart swap')).not.toBeInTheDocument(); + expect(getByTestId('button-group__button1')).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('renders the component with the smart transaction opt-in button available, opt into STX', async () => { + const setSmartTransactionsOptInStatus = jest.fn(); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + expect(getByText('2%')).toBeInTheDocument(); + expect(getByText('3%')).toBeInTheDocument(); + expect(getByText('custom')).toBeInTheDocument(); + expect( + document.querySelector('.transaction-settings__header'), + ).toMatchSnapshot(); + expect( + document.querySelector('.transaction-settings__button-group'), + ).toMatchSnapshot(); + expect(getByText('Smart swap')).toBeInTheDocument(); + expect(document.querySelector('.toggle-button--off')).toBeInTheDocument(); + await fireEvent.click(document.querySelector('.toggle-button')); + await fireEvent.click(getByTestId('update-transaction-settings-button')); + expect(setSmartTransactionsOptInStatus).toHaveBeenCalledWith(true); + }); + + it('renders slippage with a custom value', () => { + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('2.5')).toBeInTheDocument(); + }); + + it('sets a default slippage', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + fireEvent.click(getByTestId('button-group__button0')); + expect(getByTestId('button-group__button0')).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('sets a high slippage', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + fireEvent.click(getByTestId('button-group__button1')); + expect(getByTestId('button-group__button1')).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('sets a custom slippage value', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + fireEvent.click(getByTestId('button-group__button2')); + expect(getByTestId('button-group__button2')).toHaveAttribute( + 'aria-checked', + 'true', + ); + const input = getByTestId('transaction-settings-custom-slippage'); + fireEvent.change(input, { target: { value: 5 } }); + fireEvent.click(document); + expect(input).toHaveAttribute('value', '5'); + }); +}); diff --git a/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap b/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap index de5c4da43..9d8204c51 100644 --- a/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap +++ b/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap @@ -37,43 +37,37 @@ exports[`ViewQuote renders the component with EIP-1559 enabled 2`] = `
    - - 1 - - - DAI - - - = - - - 2.2 - - - USDC -
    - + 1 + + - - + DAI + + + = + + + 2.2 + + + USDC +
    +
    `; @@ -115,43 +109,37 @@ exports[`ViewQuote renders the component with initial props 2`] = `
    - - 1 - - - DAI - - - = - - - 2.2 - - - USDC -
    - + 1 + + - - + DAI + + + = + + + 2.2 + + + USDC +
    +
    `; diff --git a/ui/pages/swaps/view-quote/index.scss b/ui/pages/swaps/view-quote/index.scss index 0f955d0ee..9a1570db0 100644 --- a/ui/pages/swaps/view-quote/index.scss +++ b/ui/pages/swaps/view-quote/index.scss @@ -38,18 +38,6 @@ width: 348px; } - &__new-quote-countdown { - @include H7; - - font-weight: bold; - - &--danger { - span { - color: var(--color-error-default); - } - } - } - &__price-difference-warning { &-wrapper { width: 100%; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 8254391dd..3deab6b68 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -968,6 +968,7 @@ function getAllowedAnnouncementIds(state) { 18: true, 19: true, 20: currentKeyringIsLedger && isFirefox, + 21: true, }; } diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 09d8b256b..235d97e0b 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -3367,7 +3367,6 @@ export function fetchAndSetQuotes( destinationToken: string; value: string; fromAddress: string; - destinationTokenAddedForSwap: string; balanceError: string; sourceDecimals: number; },