From c30cb7d33a6ed0e774580879f872cedf369f763e Mon Sep 17 00:00:00 2001 From: Brad Decker Date: Wed, 23 Jun 2021 16:35:25 -0500 Subject: [PATCH] Refactor send page state management (#10965) --- app/_locales/am/messages.json | 3 - app/_locales/ar/messages.json | 3 - app/_locales/bg/messages.json | 3 - app/_locales/bn/messages.json | 3 - app/_locales/ca/messages.json | 3 - app/_locales/cs/messages.json | 3 - app/_locales/da/messages.json | 3 - app/_locales/de/messages.json | 3 - app/_locales/el/messages.json | 3 - app/_locales/en/messages.json | 12 +- app/_locales/es/messages.json | 3 - app/_locales/es_419/messages.json | 3 - app/_locales/et/messages.json | 3 - app/_locales/fa/messages.json | 3 - app/_locales/fi/messages.json | 3 - app/_locales/fil/messages.json | 3 - app/_locales/fr/messages.json | 3 - app/_locales/he/messages.json | 3 - app/_locales/hi/messages.json | 3 - app/_locales/hn/messages.json | 3 - app/_locales/hr/messages.json | 3 - app/_locales/ht/messages.json | 3 - app/_locales/hu/messages.json | 3 - app/_locales/id/messages.json | 3 - app/_locales/it/messages.json | 3 - app/_locales/ja/messages.json | 3 - app/_locales/kn/messages.json | 3 - app/_locales/ko/messages.json | 3 - app/_locales/lt/messages.json | 3 - app/_locales/lv/messages.json | 3 - app/_locales/ms/messages.json | 3 - app/_locales/nl/messages.json | 3 - app/_locales/no/messages.json | 3 - app/_locales/ph/messages.json | 3 - app/_locales/pl/messages.json | 3 - app/_locales/pt/messages.json | 3 - app/_locales/pt_BR/messages.json | 3 - app/_locales/ro/messages.json | 3 - app/_locales/ru/messages.json | 3 - app/_locales/sk/messages.json | 3 - app/_locales/sl/messages.json | 3 - app/_locales/sr/messages.json | 3 - app/_locales/sv/messages.json | 3 - app/_locales/sw/messages.json | 3 - app/_locales/ta/messages.json | 3 - app/_locales/th/messages.json | 3 - app/_locales/tl/messages.json | 3 - app/_locales/tr/messages.json | 3 - app/_locales/uk/messages.json | 3 - app/_locales/vi/messages.json | 3 - app/_locales/zh_CN/messages.json | 3 - app/_locales/zh_TW/messages.json | 3 - development/build/scripts.js | 2 +- test/e2e/tests/send-eth.spec.js | 3 +- .../app/asset-list-item/asset-list-item.js | 18 +- ...gas-modal-page-container-container.test.js | 54 +- .../gas-modal-page-container.container.js | 74 +- .../app/wallet-overview/token-overview.js | 12 +- .../ui/unit-input/unit-input.component.js | 5 +- ui/contexts/metametrics.js | 14 +- ui/ducks/ens.js | 197 ++ ui/ducks/gas/gas-action-constants.js | 14 + ui/ducks/gas/gas-duck.test.js | 15 +- ui/ducks/gas/gas.duck.js | 16 +- ui/ducks/index.js | 4 +- ui/ducks/send/index.js | 1 + ui/ducks/send/send-duck.test.js | 142 -- ui/ducks/send/send.duck.js | 382 ---- ui/ducks/send/send.js | 1472 ++++++++++++++ ui/ducks/send/send.test.js | 1808 +++++++++++++++++ .../confirm-send-ether.container.js | 20 +- .../confirm-send-token.container.js | 43 +- ui/pages/confirm-transaction/conf-tx.js | 15 +- .../confirm-transaction.component.js | 6 +- .../confirm-transaction.container.js | 5 +- ui/pages/send/index.js | 2 +- .../add-recipient/add-recipient.component.js | 120 +- .../add-recipient.component.test.js | 68 +- .../add-recipient/add-recipient.container.js | 36 +- .../add-recipient.container.test.js | 62 +- .../add-recipient/add-recipient.js | 56 - .../add-recipient/add-recipient.utils.test.js | 115 -- .../add-recipient/ens-input.component.js | 330 +-- .../add-recipient/ens-input.container.js | 26 +- .../amount-max-button.component.js | 81 - .../amount-max-button.component.test.js | 93 - .../amount-max-button.container.js | 42 - .../amount-max-button.container.test.js | 83 - .../amount-max-button/amount-max-button.js | 49 + .../amount-max-button.test.js | 61 + .../amount-max-button.utils.js | 22 - .../amount-max-button.utils.test.js | 26 - .../amount-max-button/index.js | 2 +- .../send-amount-row.component.js | 92 +- .../send-amount-row.component.test.js | 129 +- .../send-amount-row.container.js | 34 +- .../send-amount-row.container.test.js | 58 +- .../send-asset-row.component.js | 45 +- .../send-asset-row.container.js | 16 +- .../send-content/send-content.component.js | 26 +- .../send-content/send-content.container.js | 6 +- .../send-gas-row/send-gas-row.component.js | 106 +- .../send-gas-row.component.test.js | 11 +- .../send-gas-row/send-gas-row.container.js | 100 +- .../send-gas-row.container.test.js | 67 +- .../send-hex-data-row.component.js | 4 +- .../send-hex-data-row.container.js | 4 +- .../send-row-error-message.container.js | 2 +- .../send-row-error-message.container.test.js | 2 +- .../send/send-footer/send-footer.component.js | 86 +- .../send-footer/send-footer.component.test.js | 125 +- .../send/send-footer/send-footer.container.js | 107 +- .../send-footer/send-footer.container.test.js | 135 +- .../send/send-footer/send-footer.utils.js | 96 - .../send-footer/send-footer.utils.test.js | 215 -- ui/pages/send/send-header/index.js | 2 +- .../send/send-header/send-header.component.js | 66 +- .../send-header/send-header.component.test.js | 167 +- .../send/send-header/send-header.container.js | 20 - ui/pages/send/send.component.js | 403 ---- ui/pages/send/send.component.test.js | 467 ----- ui/pages/send/send.constants.js | 16 +- ui/pages/send/send.container.js | 138 -- ui/pages/send/send.container.test.js | 128 -- ui/pages/send/send.js | 112 + ui/pages/send/send.test.js | 173 ++ ui/pages/send/send.utils.js | 206 +- ui/pages/send/send.utils.test.js | 329 --- .../add-contact/add-contact.component.js | 54 +- .../add-contact/add-contact.container.js | 10 +- ui/selectors/custom-gas.js | 10 +- ui/selectors/custom-gas.test.js | 44 +- ui/selectors/index.js | 1 - ui/selectors/send-selectors-test-data.js | 214 -- ui/selectors/send.js | 135 -- ui/selectors/send.test.js | 417 ---- ui/store/actionConstants.js | 4 + ui/store/actionConstants.test.js | 9 +- ui/store/actions.js | 124 +- ui/store/actions.test.js | 134 +- 140 files changed, 4822 insertions(+), 5788 deletions(-) create mode 100644 ui/ducks/ens.js create mode 100644 ui/ducks/gas/gas-action-constants.js create mode 100644 ui/ducks/send/index.js delete mode 100644 ui/ducks/send/send-duck.test.js delete mode 100644 ui/ducks/send/send.duck.js create mode 100644 ui/ducks/send/send.js create mode 100644 ui/ducks/send/send.test.js delete mode 100644 ui/pages/send/send-content/add-recipient/add-recipient.js delete mode 100644 ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js create mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js create mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js delete mode 100644 ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js delete mode 100644 ui/pages/send/send-footer/send-footer.utils.js delete mode 100644 ui/pages/send/send-footer/send-footer.utils.test.js delete mode 100644 ui/pages/send/send-header/send-header.container.js delete mode 100644 ui/pages/send/send.component.js delete mode 100644 ui/pages/send/send.component.test.js delete mode 100644 ui/pages/send/send.container.js delete mode 100644 ui/pages/send/send.container.test.js create mode 100644 ui/pages/send/send.js create mode 100644 ui/pages/send/send.test.js delete mode 100644 ui/selectors/send-selectors-test-data.js delete mode 100644 ui/selectors/send.js delete mode 100644 ui/selectors/send.test.js diff --git a/app/_locales/am/messages.json b/app/_locales/am/messages.json index e16deef2c..18c3b7378 100644 --- a/app/_locales/am/messages.json +++ b/app/_locales/am/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "የቅርብ ጊዜያት" }, - "recipientAddress": { - "message": "የተቀባይ አድራሻ" - }, "recipientAddressPlaceholder": { "message": "ፍለጋ፣ ለሕዝብ ክፍት የሆነ አድራሻ (0x), ወይም ENS" }, diff --git a/app/_locales/ar/messages.json b/app/_locales/ar/messages.json index db0ba6d0a..90f7618c8 100644 --- a/app/_locales/ar/messages.json +++ b/app/_locales/ar/messages.json @@ -744,9 +744,6 @@ "recents": { "message": "الحديث" }, - "recipientAddress": { - "message": "عنوان المستلم" - }, "recipientAddressPlaceholder": { "message": "البحث، العنوان العام (0x)، أو ENS" }, diff --git a/app/_locales/bg/messages.json b/app/_locales/bg/messages.json index a1ace1be6..26cae225a 100644 --- a/app/_locales/bg/messages.json +++ b/app/_locales/bg/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Скорошни" }, - "recipientAddress": { - "message": "Адрес на получателя" - }, "recipientAddressPlaceholder": { "message": "Търсене, публичен адрес (0x) или ENS" }, diff --git a/app/_locales/bn/messages.json b/app/_locales/bn/messages.json index 378a950b8..2b1724128 100644 --- a/app/_locales/bn/messages.json +++ b/app/_locales/bn/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "সাম্প্রতিকগুলি" }, - "recipientAddress": { - "message": "প্রাপকের ঠিকানা" - }, "recipientAddressPlaceholder": { "message": "অনুসন্ধান, সার্বজনীন ঠিকানা (0x), বা ENS" }, diff --git a/app/_locales/ca/messages.json b/app/_locales/ca/messages.json index 0531dbcb8..7571840c8 100644 --- a/app/_locales/ca/messages.json +++ b/app/_locales/ca/messages.json @@ -729,9 +729,6 @@ "readdToken": { "message": "Pots tornar a afegir aquesta fitxa en el futur anant a \"Afegir fitxa\" al menu d'opcions dels teus comptes." }, - "recipientAddress": { - "message": "Adreça del destinatari" - }, "recipientAddressPlaceholder": { "message": "Cerca, adreça pública (0x), o ENS" }, diff --git a/app/_locales/cs/messages.json b/app/_locales/cs/messages.json index ee22b9aec..3e158ca01 100644 --- a/app/_locales/cs/messages.json +++ b/app/_locales/cs/messages.json @@ -305,9 +305,6 @@ "readdToken": { "message": "Tento token můžete v budoucnu přidat zpět s „Přidat token“ v nastavení účtu." }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "reject": { "message": "Odmítnout" }, diff --git a/app/_locales/da/messages.json b/app/_locales/da/messages.json index b5bd7eb02..8138bdc93 100644 --- a/app/_locales/da/messages.json +++ b/app/_locales/da/messages.json @@ -732,9 +732,6 @@ "recents": { "message": "Seneste" }, - "recipientAddress": { - "message": "Modtagerens adresse" - }, "recipientAddressPlaceholder": { "message": "Søg, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 08d79a54e..48cfd91bc 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -720,9 +720,6 @@ "recents": { "message": "Letzte" }, - "recipientAddress": { - "message": "Empfängeradresse" - }, "recipientAddressPlaceholder": { "message": "Suchen, öffentliche Adresse (0x) oder ENS" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index c58385c6a..59ba5729b 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "Πρόσφατα" }, - "recipientAddress": { - "message": "Διεύθυνση Παραλήπτη" - }, "recipientAddressPlaceholder": { "message": "Αναζήτηση, δημόσια διεύθυνση (0x) ή ENS" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 7b53b9456..7feb91856 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -659,12 +659,21 @@ "message": "The endpoint returned a different chain ID: $1", "description": "$1 is the return value of eth_chainId from an RPC endpoint" }, + "ensIllegalCharacter": { + "message": "Illegal Character for ENS." + }, "ensNotFoundOnCurrentNetwork": { "message": "ENS name not found on the current network. Try switching to Ethereum Mainnet." }, + "ensNotSupportedOnNetwork": { + "message": "Network does not support ENS" + }, "ensRegistrationError": { "message": "Error in ENS name registration" }, + "ensUnknownError": { + "message": "ENS Lookup failed." + }, "enterAnAlias": { "message": "Enter an alias" }, @@ -1466,9 +1475,6 @@ "recents": { "message": "Recents" }, - "recipientAddress": { - "message": "Recipient Address" - }, "recipientAddressPlaceholder": { "message": "Search, public address (0x), or ENS" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 8fe19f247..23d44c8af 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 8fe19f247..23d44c8af 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, diff --git a/app/_locales/et/messages.json b/app/_locales/et/messages.json index 4e2a6a0a0..23de7d7e1 100644 --- a/app/_locales/et/messages.json +++ b/app/_locales/et/messages.json @@ -741,9 +741,6 @@ "recents": { "message": "Hiljutised" }, - "recipientAddress": { - "message": "Saaja aadress" - }, "recipientAddressPlaceholder": { "message": "Otsing, avalik aadress (0x) või ENS" }, diff --git a/app/_locales/fa/messages.json b/app/_locales/fa/messages.json index 4a0b6a3dd..d5230d513 100644 --- a/app/_locales/fa/messages.json +++ b/app/_locales/fa/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "واپسین" }, - "recipientAddress": { - "message": "آدرس دریافت کننده" - }, "recipientAddressPlaceholder": { "message": "جستجو، آدرس عمومی (0x)، یا ENS" }, diff --git a/app/_locales/fi/messages.json b/app/_locales/fi/messages.json index 0cb3ea696..f2a96b511 100644 --- a/app/_locales/fi/messages.json +++ b/app/_locales/fi/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "Viimeaikaiset" }, - "recipientAddress": { - "message": "Vastaanottajan osoite" - }, "recipientAddressPlaceholder": { "message": "Haku, julkinen osoite (0x) tai ENS" }, diff --git a/app/_locales/fil/messages.json b/app/_locales/fil/messages.json index 8dded66bb..d7ef62fa2 100644 --- a/app/_locales/fil/messages.json +++ b/app/_locales/fil/messages.json @@ -675,9 +675,6 @@ "recents": { "message": "Kamakailan" }, - "recipientAddress": { - "message": "Address ng Recipient" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 05ae8872d..31358aa71 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -733,9 +733,6 @@ "recents": { "message": "Récents" }, - "recipientAddress": { - "message": "Adresse du destinataire" - }, "recipientAddressPlaceholder": { "message": "Recherche, adresse publique (0x) ou ENS" }, diff --git a/app/_locales/he/messages.json b/app/_locales/he/messages.json index e80aafba6..795a25c53 100644 --- a/app/_locales/he/messages.json +++ b/app/_locales/he/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "אחרונים" }, - "recipientAddress": { - "message": "כתובת הנמען" - }, "recipientAddressPlaceholder": { "message": "חיפוש, כתובת ציבורית (0x), או ENS" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index b975bf714..4bb244a11 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "हाल ही के" }, - "recipientAddress": { - "message": "प्राप्तकर्ता का पता" - }, "recipientAddressPlaceholder": { "message": "खोज, सार्वजनिक पता (0x) या ENS" }, diff --git a/app/_locales/hn/messages.json b/app/_locales/hn/messages.json index 6b5761c40..d8aeceb8e 100644 --- a/app/_locales/hn/messages.json +++ b/app/_locales/hn/messages.json @@ -282,9 +282,6 @@ "readdToken": { "message": "आप अपने खाता विकल्प मेनू में .टोकन जोड़ें. पर जाकर भविष्य में इस टोकन को वापस जोड़ सकते हैं।" }, - "recipientAddress": { - "message": "प्राप्तकर्ता पता" - }, "reject": { "message": "अस्वीकार" }, diff --git a/app/_locales/hr/messages.json b/app/_locales/hr/messages.json index cc7991b96..c56b5d88b 100644 --- a/app/_locales/hr/messages.json +++ b/app/_locales/hr/messages.json @@ -744,9 +744,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Adresa primatelja" - }, "recipientAddressPlaceholder": { "message": "Pretraži, javne adrese (0x) ili ENS" }, diff --git a/app/_locales/ht/messages.json b/app/_locales/ht/messages.json index 4cd74e121..78cc96a2a 100644 --- a/app/_locales/ht/messages.json +++ b/app/_locales/ht/messages.json @@ -447,9 +447,6 @@ "readdToken": { "message": "Ou ka ajoute token sa aprè sa ankò ou prale nan \"Ajoute token\" nan opsyon meni kont ou an." }, - "recipientAddress": { - "message": "Adrès pou resevwa" - }, "reject": { "message": "Rejte" }, diff --git a/app/_locales/hu/messages.json b/app/_locales/hu/messages.json index 085e93c3e..2293d2956 100644 --- a/app/_locales/hu/messages.json +++ b/app/_locales/hu/messages.json @@ -744,9 +744,6 @@ "recents": { "message": "Legutóbbiak" }, - "recipientAddress": { - "message": "Címzett címe" - }, "recipientAddressPlaceholder": { "message": "Keresés, nyilvános cím (0x) vagy ENS" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 7a649946a..d5e42a1b0 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Terkini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat publik (0x), atau ENS" }, diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index 975c8769b..f09f50b96 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -1198,9 +1198,6 @@ "recents": { "message": "Recenti" }, - "recipientAddress": { - "message": "Indirizzo Destinatario" - }, "recipientAddressPlaceholder": { "message": "Ricerca, indirizzo pubblico (0x) o ENS" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 71c9ef90f..bd73411ca 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "受信者のアドレス" - }, "recipientAddressPlaceholder": { "message": "検索、パブリック アドレス (0x)、または ENS" }, diff --git a/app/_locales/kn/messages.json b/app/_locales/kn/messages.json index 964e1d489..9f3b17c66 100644 --- a/app/_locales/kn/messages.json +++ b/app/_locales/kn/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "ಇತ್ತೀಚಿನವುಗಳು" }, - "recipientAddress": { - "message": "ಸ್ವೀಕರಿಸುವವರ ವಿಳಾಸ" - }, "recipientAddressPlaceholder": { "message": "ಸಾರ್ವಜನಿಕ ವಿಳಾಸ (0x) ಅಥವಾ ENS ಹುಡುಕಿ" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index b063ea504..0cd69a0f5 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "최근" }, - "recipientAddress": { - "message": "수신인 주소" - }, "recipientAddressPlaceholder": { "message": "검색, 공개 주소(0x) 또는 ENS" }, diff --git a/app/_locales/lt/messages.json b/app/_locales/lt/messages.json index 1984ba7e6..da2c253b0 100644 --- a/app/_locales/lt/messages.json +++ b/app/_locales/lt/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Naujausi" }, - "recipientAddress": { - "message": "Gavėjo adresas" - }, "recipientAddressPlaceholder": { "message": "Ieška, viešieji adresai (0x) arba ENS" }, diff --git a/app/_locales/lv/messages.json b/app/_locales/lv/messages.json index 974c3abb9..2aa8e03e4 100644 --- a/app/_locales/lv/messages.json +++ b/app/_locales/lv/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Nesenie" }, - "recipientAddress": { - "message": "Saņēmēja adrese" - }, "recipientAddressPlaceholder": { "message": "Meklēšana, publiskā adrese (0x) vai ENS" }, diff --git a/app/_locales/ms/messages.json b/app/_locales/ms/messages.json index b2a48bacc..7ad59e94f 100644 --- a/app/_locales/ms/messages.json +++ b/app/_locales/ms/messages.json @@ -728,9 +728,6 @@ "recents": { "message": "Baru-baru ini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat awam (0x), atau ENS" }, diff --git a/app/_locales/nl/messages.json b/app/_locales/nl/messages.json index de1b19e96..e70f716af 100644 --- a/app/_locales/nl/messages.json +++ b/app/_locales/nl/messages.json @@ -269,9 +269,6 @@ "readdToken": { "message": "U kunt dit token in de toekomst weer toevoegen door naar \"Token toevoegen\" te gaan in het menu met accountopties." }, - "recipientAddress": { - "message": "Geadresseerde adres" - }, "reject": { "message": "Afwijzen" }, diff --git a/app/_locales/no/messages.json b/app/_locales/no/messages.json index 0bfe2ef14..1728b607c 100644 --- a/app/_locales/no/messages.json +++ b/app/_locales/no/messages.json @@ -738,9 +738,6 @@ "recents": { "message": "Nylige" }, - "recipientAddress": { - "message": "Mottakeradresse" - }, "recipientAddressPlaceholder": { "message": "Søk, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index c191a271b..56763f94d 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/pl/messages.json b/app/_locales/pl/messages.json index fbcd93c1d..209b01e35 100644 --- a/app/_locales/pl/messages.json +++ b/app/_locales/pl/messages.json @@ -745,9 +745,6 @@ "recents": { "message": "Ostatnie" }, - "recipientAddress": { - "message": "Adres odbiorcy" - }, "recipientAddressPlaceholder": { "message": "Szukaj, adres publiczny (0x) lub ENS" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index e75fd618c..1d0676ee1 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -279,9 +279,6 @@ "readdToken": { "message": "Pode adicionar este token de novo clicando na opção “Adicionar token” no menu de opções da sua conta." }, - "recipientAddress": { - "message": "Endereço do Destinatário" - }, "reject": { "message": "Rejeitar" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index 9c8136fef..117c704f8 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Recentes" }, - "recipientAddress": { - "message": "Endereço do destinatário" - }, "recipientAddressPlaceholder": { "message": "Busca, endereço público (0x) ou ENS" }, diff --git a/app/_locales/ro/messages.json b/app/_locales/ro/messages.json index 76a0cf529..2ba1a3a60 100644 --- a/app/_locales/ro/messages.json +++ b/app/_locales/ro/messages.json @@ -738,9 +738,6 @@ "recents": { "message": "Recente" }, - "recipientAddress": { - "message": "Adresă destinatar" - }, "recipientAddressPlaceholder": { "message": "Căutare, adresa publică (0x) sau ENS" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index a29fe6c6b..233d76216 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Недавние" }, - "recipientAddress": { - "message": "Адрес получателя" - }, "recipientAddressPlaceholder": { "message": "Поиск, публичный адрес (0x) или ENS" }, diff --git a/app/_locales/sk/messages.json b/app/_locales/sk/messages.json index 360d89dbc..2cf89a50b 100644 --- a/app/_locales/sk/messages.json +++ b/app/_locales/sk/messages.json @@ -720,9 +720,6 @@ "recents": { "message": "Posledné" }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "recipientAddressPlaceholder": { "message": "Vyhľadávať verejnú adresu (0x) alebo ENS" }, diff --git a/app/_locales/sl/messages.json b/app/_locales/sl/messages.json index 3a84787c5..d4de42b88 100644 --- a/app/_locales/sl/messages.json +++ b/app/_locales/sl/messages.json @@ -739,9 +739,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Prejemnikov naslov" - }, "recipientAddressPlaceholder": { "message": "Iskanje, javni naslov (0x) ali ENS" }, diff --git a/app/_locales/sr/messages.json b/app/_locales/sr/messages.json index a6d16b233..8170b146a 100644 --- a/app/_locales/sr/messages.json +++ b/app/_locales/sr/messages.json @@ -742,9 +742,6 @@ "recents": { "message": "Skorašnje" }, - "recipientAddress": { - "message": "Adresa primaoca" - }, "recipientAddressPlaceholder": { "message": "Pretraga, javna adresa (0x) ili ENS" }, diff --git a/app/_locales/sv/messages.json b/app/_locales/sv/messages.json index 2e36636c6..8fbe73469 100644 --- a/app/_locales/sv/messages.json +++ b/app/_locales/sv/messages.json @@ -735,9 +735,6 @@ "recents": { "message": "Senaste" }, - "recipientAddress": { - "message": "Mottagaradress" - }, "recipientAddressPlaceholder": { "message": "Sök, allmän adress (0x) eller ENS" }, diff --git a/app/_locales/sw/messages.json b/app/_locales/sw/messages.json index 6f85051f2..5e493a99f 100644 --- a/app/_locales/sw/messages.json +++ b/app/_locales/sw/messages.json @@ -729,9 +729,6 @@ "recents": { "message": "Za hivi karibuni" }, - "recipientAddress": { - "message": "Anwani ya Mpokeaji" - }, "recipientAddressPlaceholder": { "message": "Tafuta, anwani za umma (0x), au ENS" }, diff --git a/app/_locales/ta/messages.json b/app/_locales/ta/messages.json index 360ae15cd..425b104c1 100644 --- a/app/_locales/ta/messages.json +++ b/app/_locales/ta/messages.json @@ -369,9 +369,6 @@ "readdToken": { "message": "உங்கள் கணக்கு விருப்பங்கள் மெனுவில் \"டோக்கனைச் சேர்\" என்பதன் மூலம் நீங்கள் எதிர்காலத்தில் இந்த டோக்கனை மீண்டும் சேர்க்கலாம்." }, - "recipientAddress": { - "message": "பெறுநர் முகவரி" - }, "reject": { "message": "நிராகரி" }, diff --git a/app/_locales/th/messages.json b/app/_locales/th/messages.json index 037d7b80a..3c193a838 100644 --- a/app/_locales/th/messages.json +++ b/app/_locales/th/messages.json @@ -372,9 +372,6 @@ "readdToken": { "message": "คุณสามารถเพิ่มโทเค็นนี้ในอนาคตได้โดยไปที่ “เพิ่มโทเค็น” ในเมนูตัวเลือกบัญชีของคุณ" }, - "recipientAddress": { - "message": "แอดแดรสผู้รับ" - }, "reject": { "message": "ปฏิเสธ" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index a2ddbee64..af413eadd 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -1189,9 +1189,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 91e60a3a7..3c4bdebf7 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -321,9 +321,6 @@ "readdToken": { "message": "Gelecekte Bu jetonu hesap seçenekleri menüsünde “Jeton ekle”'ye giderek geri ekleyebilirsiniz." }, - "recipientAddress": { - "message": "Alıcı adresi" - }, "reject": { "message": "Reddetmek" }, diff --git a/app/_locales/uk/messages.json b/app/_locales/uk/messages.json index 736200897..290378224 100644 --- a/app/_locales/uk/messages.json +++ b/app/_locales/uk/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Останні" }, - "recipientAddress": { - "message": "Адреса отримувача" - }, "recipientAddressPlaceholder": { "message": "Пошук, публічна адреса (0x), або ENS" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 9625b1d98..ca3ca9c66 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -1448,9 +1448,6 @@ "recents": { "message": "Gần đây" }, - "recipientAddress": { - "message": "Địa chỉ người nhận" - }, "recipientAddressPlaceholder": { "message": "Tìm kiếm, địa chỉ công khai (0x) hoặc ENS" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 0bc804c35..3eb07cf4e 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1192,9 +1192,6 @@ "recents": { "message": "最近记录" }, - "recipientAddress": { - "message": "接收地址" - }, "recipientAddressPlaceholder": { "message": "查找、公用地址 (0x) 或 ENS" }, diff --git a/app/_locales/zh_TW/messages.json b/app/_locales/zh_TW/messages.json index 4df54a9db..245558800 100644 --- a/app/_locales/zh_TW/messages.json +++ b/app/_locales/zh_TW/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "接收位址" - }, "recipientAddressPlaceholder": { "message": "搜尋,公開地址 (0x),或 ENS" }, diff --git a/development/build/scripts.js b/development/build/scripts.js index f0e399d47..f9683f57d 100644 --- a/development/build/scripts.js +++ b/development/build/scripts.js @@ -44,7 +44,7 @@ const materialUIDependencies = ['@material-ui/core']; const reactDepenendencies = dependencies.filter((dep) => dep.match(/react/u)); const externalDependenciesMap = { - background: ['3box'], + background: ['3box', '@ethereumjs/common', 'unicode-confusables'], ui: [...materialUIDependencies, ...reactDepenendencies], }; diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js index b6c5130c0..2c1c1c97e 100644 --- a/test/e2e/tests/send-eth.spec.js +++ b/test/e2e/tests/send-eth.spec.js @@ -1,5 +1,5 @@ const { strict: assert } = require('assert'); -const { withFixtures } = require('../helpers'); +const { withFixtures, regularDelayMs } = require('../helpers'); describe('Send ETH from inside MetaMask using default gas', function () { const ganacheOptions = { @@ -43,6 +43,7 @@ describe('Send ETH from inside MetaMask using default gas', function () { await inputAmount.press(driver.Key.BACK_SPACE); await inputAmount.press(driver.Key.BACK_SPACE); await inputAmount.press(driver.Key.BACK_SPACE); + await driver.delay(regularDelayMs); await driver.assertElementNotPresent('.send-v2__error-amount'); diff --git a/ui/components/app/asset-list-item/asset-list-item.js b/ui/components/app/asset-list-item/asset-list-item.js index 8d7f67965..3c2325d48 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -10,7 +10,7 @@ import InfoIcon from '../../ui/icon/info-icon.component'; import Button from '../../ui/button'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useMetricEvent } from '../../../hooks/useMetricEvent'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; @@ -69,13 +69,17 @@ const AssetListItem = ({ e.stopPropagation(); sendTokenEvent(); dispatch( - updateSendToken({ - address: tokenAddress, - decimals: tokenDecimals, - symbol: tokenSymbol, + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { + address: tokenAddress, + decimals: tokenDecimals, + symbol: tokenSymbol, + }, }), - ); - history.push(SEND_ROUTE); + ).then(() => { + history.push(SEND_ROUTE); + }); }} > {t('sendSpecifiedTokens', [tokenSymbol])} diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js index 6b36af651..d82a8fb0a 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js @@ -9,10 +9,10 @@ import { } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, -} from '../../../../ducks/send/send.duck'; + useCustomGas, + updateGasLimit, + updateGasPrice, +} from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -32,8 +32,6 @@ jest.mock('../../../../selectors', () => ({ `mockRenderableBasicEstimateData:${Object.keys(s).length}`, getDefaultActiveButtonIndex: (a, b) => a + b, getCurrentEthBalance: (state) => state.metamask.balance || '0x0', - getSendToken: () => null, - getTokenBalance: (state) => state.send.tokenBalance || '0x0', getCustomGasPrice: (state) => state.gas.customData.price || '0x0', getCustomGasLimit: (state) => state.gas.customData.limit || '0x0', getCurrentCurrency: jest.fn().mockReturnValue('usd'), @@ -57,11 +55,15 @@ jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - hideGasButtonGroup: jest.fn(), - setGasLimit: jest.fn(), - setGasPrice: jest.fn(), -})); +jest.mock('../../../../ducks/send', () => { + const { ASSET_TYPES } = jest.requireActual('../../../../ducks/send'); + return { + useCustomGas: jest.fn(), + updateGasLimit: jest.fn(), + updateGasPrice: jest.fn(), + getSendAsset: jest.fn(() => ({ type: ASSET_TYPES.NATIVE })), + }; +}); require('./gas-modal-page-container.container'); @@ -79,11 +81,11 @@ describe('gas-modal-page-container container', () => { dispatchSpy.resetHistory(); }); - describe('hideGasButtonGroup()', () => { - it('should dispatch a hideGasButtonGroup action', () => { - mapDispatchToPropsObject.hideGasButtonGroup(); + describe('useCustomGas()', () => { + it('should dispatch a useCustomGas action', () => { + mapDispatchToPropsObject.useCustomGas(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(hideGasButtonGroup).toHaveBeenCalled(); + expect(useCustomGas).toHaveBeenCalled(); }); }); @@ -126,13 +128,13 @@ describe('gas-modal-page-container container', () => { }); describe('setGasData()', () => { - it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => { + it('should dispatch a updateGasPrice and updateGasLimit action with the correct props', () => { mapDispatchToPropsObject.setGasData('ffff', 'aaaa'); expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalledWith('ffff'); - expect(setGasPrice).toHaveBeenCalledWith('aaaa'); + expect(updateGasPrice).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalledWith('ffff'); + expect(updateGasPrice).toHaveBeenCalledWith('aaaa'); }); }); @@ -165,7 +167,7 @@ describe('gas-modal-page-container container', () => { }; dispatchProps = { updateCustomGasPrice: sinon.spy(), - hideGasButtonGroup: sinon.spy(), + useCustomGas: sinon.spy(), setGasData: sinon.spy(), updateConfirmTxGasAndCalculate: sinon.spy(), someOtherDispatchProp: sinon.spy(), @@ -194,7 +196,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(0); result.onSubmit(); @@ -203,7 +205,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(1); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -238,7 +240,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(0); result.onSubmit('mockNewLimit', 'mockNewPrice'); @@ -251,7 +253,7 @@ describe('gas-modal-page-container container', () => { 'mockNewLimit', 'mockNewPrice', ]); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(1); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(1); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -278,7 +280,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.createSpeedUpTransaction.callCount).toStrictEqual(1); diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js index c02eca852..2c7d0aff4 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -14,19 +14,21 @@ import { fetchBasicGasEstimates, } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, - setGasTotal, - updateSendAmount, - updateSendErrors, -} from '../../../../ducks/send/send.duck'; + getSendMaxModeState, + getGasLimit, + getGasPrice, + getSendAmount, + updateGasLimit, + updateGasPrice, + useCustomGas, + getSendAsset, + ASSET_TYPES, +} from '../../../../ducks/send'; import { conversionRateSelector as getConversionRate, getCurrentCurrency, getCurrentEthBalance, getIsMainnet, - getSendToken, getPreferences, getBasicGasEstimateLoadingStatus, getCustomGasLimit, @@ -34,8 +36,6 @@ import { getDefaultActiveButtonIndex, getRenderableBasicEstimateData, isCustomPriceSafe, - getTokenBalance, - getSendMaxModeState, getAveragePriceEstimateInHexWEI, isCustomPriceExcessive, getIsGasEstimatesFetched, @@ -54,16 +54,15 @@ import { isBalanceSufficient, } from '../../../../pages/send/send.utils'; import { MIN_GAS_LIMIT_DEC } from '../../../../pages/send/send.constants'; -import { calcMaxAmount } from '../../../../pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils'; import { TRANSACTION_STATUSES } from '../../../../../shared/constants/transaction'; import { GAS_LIMITS } from '../../../../../shared/constants/gas'; import GasModalPageContainer from './gas-modal-page-container.component'; const mapStateToProps = (state, ownProps) => { - const { - metamask: { currentNetworkTxList }, - send, - } = state; + const gasLimit = getGasLimit(state); + const gasPrice = getGasPrice(state); + const amount = getSendAmount(state); + const { currentNetworkTxList } = state.metamask; const { modalState: { props: modalProps } = {} } = state.appState.modal || {}; const { txData = {} } = modalProps || {}; const { transaction = {}, onSubmit } = ownProps; @@ -71,15 +70,15 @@ const mapStateToProps = (state, ownProps) => { ({ id }) => id === (transaction.id || txData.id), ); const buttonDataLoading = getBasicGasEstimateLoadingStatus(state); - const sendToken = getSendToken(state); + const asset = getSendAsset(state); // a "default" txParams is used during the send flow, since the transaction doesn't exist yet in that case const txParams = selectedTransaction?.txParams ? selectedTransaction.txParams : { - gas: send.gasLimit || GAS_LIMITS.SIMPLE, - gasPrice: send.gasPrice || getAveragePriceEstimateInHexWEI(state, true), - value: sendToken ? '0x0' : send.amount, + gas: gasLimit || GAS_LIMITS.SIMPLE, + gasPrice: gasPrice || getAveragePriceEstimateInHexWEI(state, true), + value: asset.type === ASSET_TYPES.TOKEN ? '0x0' : amount, }; const { gasPrice: currentGasPrice, gas: currentGasLimit } = txParams; @@ -117,15 +116,13 @@ const mapStateToProps = (state, ownProps) => { const isMainnet = getIsMainnet(state); const showFiat = Boolean(isMainnet || showFiatInTestnets); - const isSendTokenSet = Boolean(sendToken); - const newTotalEth = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? sumHexWEIsToRenderableEth([balance, '0x0']) : sumHexWEIsToRenderableEth([value, customGasTotal]); const sendAmount = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) : sumHexWEIsToRenderableEth([value, '0x0']); @@ -179,9 +176,7 @@ const mapStateToProps = (state, ownProps) => { txId: transaction.id, insufficientBalance, isMainnet, - sendToken, balance, - tokenBalance: getTokenBalance(state), conversionRate, value, onSubmit, @@ -198,12 +193,13 @@ const mapDispatchToProps = (dispatch) => { dispatch(hideModal()); }, hideModal: () => dispatch(hideModal()), + useCustomGas: () => dispatch(useCustomGas()), updateCustomGasPrice, updateCustomGasLimit: (newLimit) => dispatch(setCustomGasLimit(addHexPrefix(newLimit))), setGasData: (newLimit, newPrice) => { - dispatch(setGasLimit(newLimit)); - dispatch(setGasPrice(newPrice)); + dispatch(updateGasLimit(newLimit)); + dispatch(updateGasPrice(newPrice)); }, updateConfirmTxGasAndCalculate: (gasLimit, gasPrice, updatedTx) => { updateCustomGasPrice(gasPrice); @@ -216,14 +212,8 @@ const mapDispatchToProps = (dispatch) => { createSpeedUpTransaction: (txId, gasPrice, gasLimit) => { return dispatch(createSpeedUpTransaction(txId, gasPrice, gasLimit)); }, - hideGasButtonGroup: () => dispatch(hideGasButtonGroup()), hideSidebar: () => dispatch(hideSidebar()), fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()), - setGasTotal: (total) => dispatch(setGasTotal(total)), - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, }; }; @@ -236,17 +226,12 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { isSpeedUp, isRetry, insufficientBalance, - maxModeOn, customGasPrice, - customGasTotal, - balance, - sendToken, - tokenBalance, customGasLimit, transaction, } = stateProps; const { - hideGasButtonGroup: dispatchHideGasButtonGroup, + useCustomGas: dispatchUseCustomGas, setGasData: dispatchSetGasData, updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate, createSpeedUpTransaction: dispatchCreateSpeedUpTransaction, @@ -254,7 +239,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { hideSidebar: dispatchHideSidebar, cancelAndClose: dispatchCancelAndClose, hideModal: dispatchHideModal, - setAmountToMax: dispatchSetAmountToMax, ...otherDispatchProps } = dispatchProps; @@ -290,17 +274,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { dispatchCancelAndClose(); } else { dispatchSetGasData(gasLimit, gasPrice); - dispatchHideGasButtonGroup(); + dispatchUseCustomGas(); dispatchCancelAndClose(); } - if (maxModeOn) { - dispatchSetAmountToMax({ - balance, - gasTotal: customGasTotal, - sendToken, - tokenBalance, - }); - } }, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index f34cc3900..e40003067 100644 --- a/ui/components/app/wallet-overview/token-overview.js +++ b/ui/components/app/wallet-overview/token-overview.js @@ -17,7 +17,7 @@ import { } from '../../../hooks/useMetricEvent'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; import { getAssetImages, @@ -85,8 +85,14 @@ const TokenOverview = ({ className, token }) => { className="token-overview__button" onClick={() => { sendTokenEvent(); - dispatch(updateSendToken(token)); - history.push(SEND_ROUTE); + dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: token, + }), + ).then(() => { + history.push(SEND_ROUTE); + }); }} Icon={SendIcon} label={t('send')} diff --git a/ui/components/ui/unit-input/unit-input.component.js b/ui/components/ui/unit-input/unit-input.component.js index 8eeb39e1b..78458cab6 100644 --- a/ui/components/ui/unit-input/unit-input.component.js +++ b/ui/components/ui/unit-input/unit-input.component.js @@ -1,7 +1,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { removeLeadingZeroes } from '../../../pages/send/send.utils'; + +function removeLeadingZeroes(str) { + return str.replace(/^0*(?=\d)/u, ''); +} /** * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index 7a1cafd80..bb23afef8 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -15,10 +15,11 @@ import { getNumberOfAccounts, getNumberOfTokens, } from '../selectors/selectors'; -import { getSendToken } from '../selectors/send'; +import { getSendAsset, ASSET_TYPES } from '../ducks/send'; import { txDataSelector } from '../selectors/confirm-transaction'; import { getEnvironmentType } from '../../app/scripts/lib/util'; import { trackMetaMetricsEvent } from '../store/actions'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; export const MetaMetricsContext = createContext(() => { captureException( @@ -31,7 +32,8 @@ export const MetaMetricsContext = createContext(() => { export function MetaMetricsProvider({ children }) { const txData = useSelector(txDataSelector) || {}; const environmentType = getEnvironmentType(); - const activeCurrency = useSelector(getSendToken)?.symbol; + const activeAsset = useSelector(getSendAsset); + const nativeAssetSymbol = useSelector(getNativeCurrency); const accountType = useSelector(getAccountType); const confirmTransactionOrigin = txData.origin; const numberOfTokens = useSelector(getNumberOfTokens); @@ -72,7 +74,10 @@ export function MetaMetricsProvider({ children }) { action: eventOpts.action, number_of_tokens: numberOfTokens, number_of_accounts: numberOfAccounts, - active_currency: activeCurrency, + active_currency: + activeAsset.type === ASSET_TYPES.NATIVE + ? nativeAssetSymbol + : activeAsset?.details?.symbol, account_type: accountType, is_new_visit: config.is_new_visit, // the properties coming from this key will not match our standards for @@ -102,7 +107,8 @@ export function MetaMetricsProvider({ children }) { accountType, currentPath, confirmTransactionOrigin, - activeCurrency, + activeAsset, + nativeAssetSymbol, numberOfTokens, numberOfAccounts, environmentType, diff --git a/ui/ducks/ens.js b/ui/ducks/ens.js new file mode 100644 index 000000000..de72739e6 --- /dev/null +++ b/ui/ducks/ens.js @@ -0,0 +1,197 @@ +import { createSlice } from '@reduxjs/toolkit'; +import ENS from 'ethjs-ens'; +import log from 'loglevel'; +import networkMap from 'ethereum-ens-network-map'; +import { isConfusing } from 'unicode-confusables'; +import { isHexString } from 'ethereumjs-util'; + +import { getCurrentChainId } from '../selectors'; +import { + CHAIN_ID_TO_NETWORK_ID_MAP, + MAINNET_NETWORK_ID, +} from '../../shared/constants/network'; +import { + CONFUSING_ENS_ERROR, + ENS_ILLEGAL_CHARACTER, + ENS_NOT_FOUND_ON_NETWORK, + ENS_NOT_SUPPORTED_ON_NETWORK, + ENS_NO_ADDRESS_FOR_NAME, + ENS_REGISTRATION_ERROR, + ENS_UNKNOWN_ERROR, +} from '../pages/send/send.constants'; +import { isValidDomainName } from '../helpers/utils/util'; +import { CHAIN_CHANGED } from '../store/actionConstants'; +import { + BURN_ADDRESS, + isBurnAddress, + isValidHexAddress, +} from '../../shared/modules/hexstring-utils'; + +// Local Constants +const ZERO_X_ERROR_ADDRESS = '0x'; + +const initialState = { + stage: 'UNINITIALIZED', + resolution: null, + error: null, + warning: null, + network: null, +}; + +export const ensInitialState = initialState; + +const name = 'ENS'; + +let ens = null; + +const slice = createSlice({ + name, + initialState, + reducers: { + ensLookup: (state, action) => { + // first clear out the previous state + state.resolution = null; + state.error = null; + state.warning = null; + const { address, ensName, error, network } = action.payload; + + if (error) { + if ( + isValidDomainName(ensName) && + error.message === 'ENS name not defined.' + ) { + state.error = + network === MAINNET_NETWORK_ID + ? ENS_NO_ADDRESS_FOR_NAME + : ENS_NOT_FOUND_ON_NETWORK; + } else if (error.message === 'Illegal Character for ENS.') { + state.error = ENS_ILLEGAL_CHARACTER; + } else { + log.error(error); + state.error = ENS_UNKNOWN_ERROR; + } + } else if (address) { + if (address === BURN_ADDRESS) { + state.error = ENS_NO_ADDRESS_FOR_NAME; + } else if (address === ZERO_X_ERROR_ADDRESS) { + state.error = ENS_REGISTRATION_ERROR; + } else { + state.resolution = address; + } + if (isValidDomainName(address) && isConfusing(address)) { + state.warning = CONFUSING_ENS_ERROR; + } + } + }, + enableEnsLookup: (state, action) => { + state.stage = 'INITIALIZED'; + state.error = null; + state.resolution = null; + state.warning = null; + state.network = action.payload; + }, + disableEnsLookup: (state) => { + state.stage = 'NO_NETWORK_SUPPORT'; + state.error = ENS_NOT_SUPPORTED_ON_NETWORK; + state.warning = null; + state.resolution = null; + state.network = null; + }, + resetResolution: (state) => { + state.resolution = null; + state.warning = null; + state.error = + state.stage === 'NO_NETWORK_SUPPORT' + ? ENS_NOT_SUPPORTED_ON_NETWORK + : null; + }, + }, + extraReducers: (builder) => { + builder.addCase(CHAIN_CHANGED, (state, action) => { + if (action.payload !== state.currentChainId) { + state.stage = 'UNINITIALIZED'; + ens = null; + } + }); + }, +}); + +const { reducer, actions } = slice; +export default reducer; + +const { + disableEnsLookup, + ensLookup, + enableEnsLookup, + resetResolution, +} = actions; +export { resetResolution }; + +export function initializeEnsSlice() { + return (dispatch, getState) => { + const state = getState(); + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + const networkIsSupported = Boolean(networkMap[network]); + if (networkIsSupported) { + ens = new ENS({ provider: global.ethereumProvider, network }); + dispatch(enableEnsLookup(network)); + } else { + ens = null; + dispatch(disableEnsLookup()); + } + }; +} + +export function lookupEnsName(ensName) { + return async (dispatch, getState) => { + const trimmedEnsName = ensName.trim(); + let state = getState(); + if (state[name].stage === 'UNINITIALIZED') { + await dispatch(initializeEnsSlice()); + } + state = getState(); + if ( + state[name].stage === 'NO_NETWORK_SUPPORT' && + !( + isBurnAddress(trimmedEnsName) === false && + isValidHexAddress(trimmedEnsName, { mixedCaseUseChecksum: true }) + ) && + !isHexString(trimmedEnsName) + ) { + await dispatch(resetResolution()); + } else { + log.info(`ENS attempting to resolve name: ${trimmedEnsName}`); + let address; + let error; + try { + address = await ens.lookup(trimmedEnsName); + } catch (err) { + error = err; + } + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + await dispatch( + ensLookup({ + ensName: trimmedEnsName, + address, + error, + chainId, + network, + }), + ); + } + }; +} + +export function getEnsResolution(state) { + return state[name].resolution; +} + +export function getEnsError(state) { + return state[name].error; +} + +export function getEnsWarning(state) { + return state[name].warning; +} diff --git a/ui/ducks/gas/gas-action-constants.js b/ui/ducks/gas/gas-action-constants.js new file mode 100644 index 000000000..19cb16ee7 --- /dev/null +++ b/ui/ducks/gas/gas-action-constants.js @@ -0,0 +1,14 @@ +// This file has been separated because it is required in both the gas and send +// slices. This created a circular dependency problem as both slices also +// import from the actions and selectors files. This easiest path for +// untangling is having the constants separate. + +// Actions +export const BASIC_GAS_ESTIMATE_STATUS = + 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; +export const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; +export const SET_BASIC_GAS_ESTIMATE_DATA = + 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; +export const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; +export const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; +export const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; diff --git a/ui/ducks/gas/gas-duck.test.js b/ui/ducks/gas/gas-duck.test.js index d4301f9b3..221e4dbd8 100644 --- a/ui/ducks/gas/gas-duck.test.js +++ b/ui/ducks/gas/gas-duck.test.js @@ -10,6 +10,14 @@ import GasReducer, { fetchBasicGasEstimates, } from './gas.duck'; +import { + BASIC_GAS_ESTIMATE_STATUS, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_PRICE, + SET_CUSTOM_GAS_LIMIT, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; + jest.mock('../../helpers/utils/storage-helpers.js', () => ({ getStorageItem: jest.fn(), setStorageItem: jest.fn(), @@ -61,13 +69,6 @@ describe('Gas Duck', () => { type: 'mainnet', }; - const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; - const SET_BASIC_GAS_ESTIMATE_DATA = - 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; - const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; - const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; - const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - describe('GasReducer()', () => { it('should initialize state', () => { expect(GasReducer(undefined, {})).toStrictEqual(initState); diff --git a/ui/ducks/gas/gas.duck.js b/ui/ducks/gas/gas.duck.js index e991e5e73..a41c313c5 100644 --- a/ui/ducks/gas/gas.duck.js +++ b/ui/ducks/gas/gas.duck.js @@ -10,6 +10,14 @@ import { } from '../../helpers/utils/conversions.util'; import { getIsMainnet, getCurrentChainId } from '../../selectors'; import fetchWithCache from '../../helpers/utils/fetch-with-cache'; +import { + BASIC_GAS_ESTIMATE_STATUS, + RESET_CUSTOM_DATA, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_LIMIT, + SET_CUSTOM_GAS_PRICE, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; export const BASIC_ESTIMATE_STATES = { LOADING: 'LOADING', @@ -22,14 +30,6 @@ export const GAS_SOURCE = { ETHGASPRICE: 'eth_gasprice', }; -// Actions -const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; -const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; -const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; -const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; -const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; -const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - const initState = { customData: { price: null, diff --git a/ui/ducks/index.js b/ui/ducks/index.js index bae560536..11b525e4c 100644 --- a/ui/ducks/index.js +++ b/ui/ducks/index.js @@ -2,7 +2,8 @@ import { combineReducers } from 'redux'; import { ALERT_TYPES } from '../../shared/constants/alerts'; import metamaskReducer from './metamask/metamask'; import localeMessagesReducer from './locale/locale'; -import sendReducer from './send/send.duck'; +import sendReducer from './send/send'; +import ensReducer from './ens'; import appStateReducer from './app/app'; import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck'; import gasReducer from './gas/gas.duck'; @@ -16,6 +17,7 @@ export default combineReducers({ activeTab: (s) => (s === undefined ? null : s), metamask: metamaskReducer, appState: appStateReducer, + ENS: ensReducer, history: historyReducer, send: sendReducer, confirmTransaction: confirmTransactionReducer, diff --git a/ui/ducks/send/index.js b/ui/ducks/send/index.js new file mode 100644 index 000000000..d1ab99c82 --- /dev/null +++ b/ui/ducks/send/index.js @@ -0,0 +1 @@ +export * from './send'; diff --git a/ui/ducks/send/send-duck.test.js b/ui/ducks/send/send-duck.test.js deleted file mode 100644 index 7c05e8689..000000000 --- a/ui/ducks/send/send-duck.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import SendReducer, { - openToDropdown, - closeToDropdown, - updateSendErrors, - showGasButtonGroup, - hideGasButtonGroup, -} from './send.duck'; - -describe('Send Duck', () => { - const mockState = { - mockProp: 123, - }; - const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, - }; - const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; - const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; - const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; - const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; - const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; - const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; - - describe('SendReducer()', () => { - it('should initialize state', () => { - expect(SendReducer(undefined, {})).toStrictEqual(initState); - }); - - it('should return state unchanged if it does not match a dispatched actions type', () => { - expect( - SendReducer(mockState, { - type: 'someOtherAction', - value: 'someValue', - }), - ).toStrictEqual(mockState); - }); - - it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: OPEN_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: true, ...mockState }); - }); - - it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: CLOSE_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: false, ...mockState }); - }); - - it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer( - { ...mockState, gasButtonGroupShown: false }, - { type: SHOW_GAS_BUTTON_GROUP }, - ), - ).toStrictEqual({ gasButtonGroupShown: true, ...mockState }); - }); - - it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer(mockState, { type: HIDE_GAS_BUTTON_GROUP }), - ).toStrictEqual({ gasButtonGroupShown: false, ...mockState }); - }); - - it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => { - const modifiedMockState = { - ...mockState, - errors: { - someError: false, - }, - }; - expect( - SendReducer(modifiedMockState, { - type: UPDATE_SEND_ERRORS, - value: { someOtherError: true }, - }), - ).toStrictEqual({ - ...modifiedMockState, - errors: { - someError: false, - someOtherError: true, - }, - }); - }); - - it('should return the initial state in response to a RESET_SEND_STATE action', () => { - expect( - SendReducer(mockState, { - type: RESET_SEND_STATE, - }), - ).toStrictEqual(initState); - }); - }); - - describe('Send Duck Actions', () => { - it('calls openToDropdown action', () => { - expect(openToDropdown()).toStrictEqual({ type: OPEN_TO_DROPDOWN }); - }); - - it('calls closeToDropdown action', () => { - expect(closeToDropdown()).toStrictEqual({ type: CLOSE_TO_DROPDOWN }); - }); - - it('calls showGasButtonGroup action', () => { - expect(showGasButtonGroup()).toStrictEqual({ - type: SHOW_GAS_BUTTON_GROUP, - }); - }); - - it('calls hideGasButtonGroup action', () => { - expect(hideGasButtonGroup()).toStrictEqual({ - type: HIDE_GAS_BUTTON_GROUP, - }); - }); - - it('calls updateSendErrors action', () => { - expect(updateSendErrors('mockErrorObject')).toStrictEqual({ - type: UPDATE_SEND_ERRORS, - value: 'mockErrorObject', - }); - }); - }); -}); diff --git a/ui/ducks/send/send.duck.js b/ui/ducks/send/send.duck.js deleted file mode 100644 index 82d9b9d82..000000000 --- a/ui/ducks/send/send.duck.js +++ /dev/null @@ -1,382 +0,0 @@ -import log from 'loglevel'; -import { estimateGas } from '../../store/actions'; -import { setCustomGasLimit } from '../gas/gas.duck'; -import { - estimateGasForSend, - calcTokenBalance, -} from '../../pages/send/send.utils'; - -// Actions -const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; -const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; -const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; -const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; -const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; -const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; -const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT'; -const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE'; -const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL'; -const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA'; -const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE'; -const UPDATE_SEND_TO = 'UPDATE_SEND_TO'; -const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT'; -const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE'; -const UPDATE_SEND = 'UPDATE_SEND'; -const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN'; -const CLEAR_SEND = 'CLEAR_SEND'; -const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED'; -const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED'; -const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION'; -const UPDATE_SEND_ENS_RESOLUTION_ERROR = 'UPDATE_SEND_ENS_RESOLUTION_ERROR'; - -const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, -}; - -// Reducer -export default function reducer(state = initState, action) { - switch (action.type) { - case OPEN_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: true, - }; - case CLOSE_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: false, - }; - case UPDATE_SEND_ERRORS: - return { - ...state, - errors: { - ...state.errors, - ...action.value, - }, - }; - case SHOW_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: true, - }; - case HIDE_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: false, - }; - case UPDATE_GAS_LIMIT: - return { - ...state, - gasLimit: action.value, - }; - case UPDATE_GAS_PRICE: - return { - ...state, - gasPrice: action.value, - }; - case RESET_SEND_STATE: - return { ...initState }; - case UPDATE_GAS_TOTAL: - return { - ...state, - gasTotal: action.value, - }; - case UPDATE_SEND_TOKEN_BALANCE: - return { - ...state, - tokenBalance: action.value, - }; - case UPDATE_SEND_HEX_DATA: - return { - ...state, - data: action.value, - }; - case UPDATE_SEND_TO: - return { - ...state, - to: action.value.to, - toNickname: action.value.nickname, - }; - case UPDATE_SEND_AMOUNT: - return { - ...state, - amount: action.value, - }; - case UPDATE_MAX_MODE: - return { - ...state, - maxModeOn: action.value, - }; - case UPDATE_SEND: - return Object.assign(state, action.value); - case UPDATE_SEND_TOKEN: { - const newSend = { - ...state, - token: action.value, - }; - // erase token-related state when switching back to native currency - if (newSend.editingTransactionId && !newSend.token) { - const unapprovedTx = - newSend?.unapprovedTxs?.[newSend.editingTransactionId] || {}; - const txParams = unapprovedTx.txParams || {}; - Object.assign(newSend, { - tokenBalance: null, - balance: '0', - from: unapprovedTx.from || '', - unapprovedTxs: { - ...newSend.unapprovedTxs, - [newSend.editingTransactionId]: { - ...unapprovedTx, - txParams: { - ...txParams, - data: '', - }, - }, - }, - }); - } - return Object.assign(state, newSend); - } - case UPDATE_SEND_ENS_RESOLUTION: - return { - ...state, - ensResolution: action.payload, - ensResolutionError: '', - }; - case UPDATE_SEND_ENS_RESOLUTION_ERROR: - return { - ...state, - ensResolution: null, - ensResolutionError: action.payload, - }; - case CLEAR_SEND: - return { - ...state, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - }; - case GAS_LOADING_STARTED: - return { - ...state, - gasIsLoading: true, - }; - - case GAS_LOADING_FINISHED: - return { - ...state, - gasIsLoading: false, - }; - default: - return state; - } -} - -// Action Creators -export function openToDropdown() { - return { type: OPEN_TO_DROPDOWN }; -} - -export function closeToDropdown() { - return { type: CLOSE_TO_DROPDOWN }; -} - -export function showGasButtonGroup() { - return { type: SHOW_GAS_BUTTON_GROUP }; -} - -export function hideGasButtonGroup() { - return { type: HIDE_GAS_BUTTON_GROUP }; -} - -export function updateSendErrors(errorObject) { - return { - type: UPDATE_SEND_ERRORS, - value: errorObject, - }; -} - -export function resetSendState() { - return { type: RESET_SEND_STATE }; -} - -export function setGasLimit(gasLimit) { - return { - type: UPDATE_GAS_LIMIT, - value: gasLimit, - }; -} - -export function setGasPrice(gasPrice) { - return { - type: UPDATE_GAS_PRICE, - value: gasPrice, - }; -} - -export function setGasTotal(gasTotal) { - return { - type: UPDATE_GAS_TOTAL, - value: gasTotal, - }; -} - -export function updateGasData({ - gasPrice, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - data, -}) { - return (dispatch) => { - dispatch(gasLoadingStarted()); - return estimateGasForSend({ - estimateGasMethod: estimateGas, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - estimateGasPrice: gasPrice, - data, - }) - .then((gas) => { - dispatch(setGasLimit(gas)); - dispatch(setCustomGasLimit(gas)); - dispatch(updateSendErrors({ gasLoadingError: null })); - dispatch(gasLoadingFinished()); - }) - .catch((err) => { - log.error(err); - dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })); - dispatch(gasLoadingFinished()); - }); - }; -} - -export function gasLoadingStarted() { - return { - type: GAS_LOADING_STARTED, - }; -} - -export function gasLoadingFinished() { - return { - type: GAS_LOADING_FINISHED, - }; -} - -export function updateSendTokenBalance({ sendToken, tokenContract, address }) { - return (dispatch) => { - const tokenBalancePromise = tokenContract - ? tokenContract.balanceOf(address) - : Promise.resolve(); - return tokenBalancePromise - .then((usersToken) => { - if (usersToken) { - const newTokenBalance = calcTokenBalance({ sendToken, usersToken }); - dispatch(setSendTokenBalance(newTokenBalance)); - } - }) - .catch((err) => { - log.error(err); - updateSendErrors({ tokenBalance: 'tokenBalanceError' }); - }); - }; -} - -export function setSendTokenBalance(tokenBalance) { - return { - type: UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, - }; -} - -export function updateSendHexData(value) { - return { - type: UPDATE_SEND_HEX_DATA, - value, - }; -} - -export function updateSendTo(to, nickname = '') { - return { - type: UPDATE_SEND_TO, - value: { to, nickname }, - }; -} - -export function updateSendAmount(amount) { - return { - type: UPDATE_SEND_AMOUNT, - value: amount, - }; -} - -export function setMaxModeTo(bool) { - return { - type: UPDATE_MAX_MODE, - value: bool, - }; -} - -export function updateSend(newSend) { - return { - type: UPDATE_SEND, - value: newSend, - }; -} - -export function updateSendToken(token) { - return { - type: UPDATE_SEND_TOKEN, - value: token, - }; -} - -export function clearSend() { - return { - type: CLEAR_SEND, - }; -} - -export function updateSendEnsResolution(ensResolution) { - return { - type: UPDATE_SEND_ENS_RESOLUTION, - payload: ensResolution, - }; -} - -export function updateSendEnsResolutionError(errorMessage) { - return { - type: UPDATE_SEND_ENS_RESOLUTION_ERROR, - payload: errorMessage, - }; -} diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js new file mode 100644 index 000000000..3ed41512b --- /dev/null +++ b/ui/ducks/send/send.js @@ -0,0 +1,1472 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import abi from 'human-standard-token-abi'; +import contractMap from '@metamask/contract-metadata'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; +import { debounce } from 'lodash'; +import { + conversionGreaterThan, + conversionUtil, + multiplyCurrencies, + subtractCurrencies, +} from '../../helpers/utils/conversion-util'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + MIN_GAS_LIMIT_HEX, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; + +import { + addGasBuffer, + calcGasTotal, + generateTokenTransferData, + isBalanceSufficient, + isTokenBalanceSufficient, +} from '../../pages/send/send.utils'; +import { + getAddressBookEntry, + getAdvancedInlineGasShown, + getCurrentChainId, + getGasPriceInHexWei, + getIsMainnet, + getSelectedAddress, + getTargetAccount, +} from '../../selectors'; +import { + displayWarning, + estimateGas, + hideLoadingIndication, + showConfTxPage, + showLoadingIndication, + updateTokenType, + updateTransaction, +} from '../../store/actions'; +import { + fetchBasicGasEstimates, + setCustomGasLimit, + BASIC_ESTIMATE_STATES, +} from '../gas/gas.duck'; +import { + SET_BASIC_GAS_ESTIMATE_DATA, + BASIC_GAS_ESTIMATE_STATUS, +} from '../gas/gas-action-constants'; +import { + QR_CODE_DETECTED, + SELECTED_ACCOUNT_CHANGED, + ACCOUNT_CHANGED, + ADDRESS_BOOK_UPDATED, +} from '../../store/actionConstants'; +import { + calcTokenAmount, + getTokenAddressParam, + getTokenValueParam, +} from '../../helpers/utils/token-util'; +import { + checkExistingAddresses, + isDefaultMetaMaskChain, + isOriginContractAddress, + isValidDomainName, +} from '../../helpers/utils/util'; +import { getTokens, getUnapprovedTxs } from '../metamask/metamask'; +import { resetResolution } from '../ens'; +import { + isBurnAddress, + isValidHexAddress, +} from '../../../shared/modules/hexstring-utils'; + +// typedefs +/** + * @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction + */ + +const name = 'send'; + +/** + * The Stages that the send slice can be in + * 1. UNINITIALIZED - The send state is idle, and hasn't yet fetched required + * data for gasPrice and gasLimit estimations, etc. + * 2. ADD_RECIPIENT - The user is selecting which address to send an asset to + * 3. DRAFT - The send form is shown for a transaction yet to be sent to the + * Transaction Controller. + * 4. EDIT - The send form is shown for a transaction already submitted to the + * Transaction Controller but not yet confirmed. This happens when a + * confirmation is shown for a transaction and the 'edit' button in the header + * is clicked. + */ +export const SEND_STAGES = { + INACTIVE: 'INACTIVE', + ADD_RECIPIENT: 'ADD_RECIPIENT', + DRAFT: 'DRAFT', + EDIT: 'EDIT', +}; + +/** + * The status that the send slice can be in is either + * 1. VALID - the transaction is valid and can be submitted + * 2. INVALID - the transaction is invalid and cannot be submitted + * + * A number of cases would result in an invalid form + * 1. The recipient is not yet defined + * 2. The amount + gasTotal is greater than the user's balance when sending + * native currency + * 3. The gasTotal is greater than the user's *native* balance + * 4. The amount of sent asset is greater than the user's *asset* balance + * 5. Gas price estimates failed to load entirely + * 6. The gasLimit is less than 21000 (0x5208) + */ +export const SEND_STATUSES = { + VALID: 'VALID', + INVALID: 'INVALID', +}; + +/** + * Controls what is displayed in the send-gas-row component. + * 1. BASIC - Shows the basic estimate slow/avg/fast buttons when on mainnet + * and the metaswaps API request is successful. + * 2. INLINE - Shows inline gasLimit/gasPrice fields when on any other network + * or metaswaps API fails and we use eth_gasPrice + * 3. CUSTOM - Shows GasFeeDisplay component that is a read only display of the + * values the user has set in the advanced gas modal (stored in the gas duck + * under the customData key). + */ +export const GAS_INPUT_MODES = { + BASIC: 'BASIC', + INLINE: 'INLINE', + CUSTOM: 'CUSTOM', +}; + +/** + * The types of assets that a user can send + * 1. NATIVE - The native asset for the current network, such as ETH + * 2. TOKEN - An ERC20 token. + */ +export const ASSET_TYPES = { + NATIVE: 'NATIVE', + TOKEN: 'TOKEN', +}; + +/** + * The modes that the amount field can be set by + * 1. INPUT - the user provides the amount by typing in the field + * 2. MAX - The user selects the MAX button and amount is calculated based on + * balance - (amount + gasTotal) + */ +export const AMOUNT_MODES = { + INPUT: 'INPUT', + MAX: 'MAX', +}; + +export const RECIPIENT_SEARCH_MODES = { + MY_ACCOUNTS: 'MY_ACCOUNTS', + CONTACT_LIST: 'CONTACT_LIST', +}; + +async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + ...options +}) { + // blockGasLimit may be a falsy, but defined, value when we receive it from + // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. + const blockGasLimit = options.blockGasLimit || MIN_GAS_LIMIT_HEX; + // The parameters below will be sent to our background process to estimate + // how much gas will be used for a transaction. That background process is + // located in tx-gas-utils.js in the transaction controller folder. + const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; + + if (sendToken) { + if (!to) { + // if no to address is provided, we cannot generate the token transfer + // hexData. hexData in a transaction largely dictates how much gas will + // be consumed by a transaction. We must use our best guess, which is + // represented in the gas shared constants. + return GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + paramsForGasEstimate.value = '0x0'; + // We have to generate the erc20 contract call to transfer tokens in + // order to get a proper estimate for gasLimit. + paramsForGasEstimate.data = generateTokenTransferData({ + toAddress: to, + amount: value, + sendToken, + }); + paramsForGasEstimate.to = sendToken.address; + } else { + if (!data) { + // eth.getCode will return the compiled smart contract code at the + // address. If this returns 0x, 0x0 or a nullish value then the address + // is an externally owned account (NOT a contract account). For these + // types of transactions the gasLimit will always be 21,000 or 0x5208 + const contractCode = Boolean(to) && (await global.eth.getCode(to)); + // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' + const contractCodeIsEmpty = + !contractCode || contractCode === '0x' || contractCode === '0x0'; + if (contractCodeIsEmpty) { + return GAS_LIMITS.SIMPLE; + } + } + + paramsForGasEstimate.data = data; + + if (to) { + paramsForGasEstimate.to = to; + } + + if (!value || value === '0') { + // TODO: Figure out what's going on here. According to eth_estimateGas + // docs this value can be zero, or undefined, yet we are setting it to a + // value here when the value is undefined or zero. For more context: + // https://github.com/MetaMask/metamask-extension/pull/6195 + paramsForGasEstimate.value = '0xff'; + } + } + + // If we do not yet have a gasLimit, we must call into our background + // process to get an estimate for gasLimit based on known parameters. + + paramsForGasEstimate.gas = addHexPrefix( + multiplyCurrencies(blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }), + ); + try { + // call into the background process that will simulate transaction + // execution on the node and return an estimate of gasLimit + const estimatedGasLimit = await estimateGas(paramsForGasEstimate); + const estimateWithBuffer = addGasBuffer( + estimatedGasLimit, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } catch (error) { + const simulationFailed = + error.message.includes('Transaction execution error.') || + error.message.includes( + 'gas required exceeds allowance or always failing transaction', + ); + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer( + paramsForGasEstimate.gas, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } + throw error; + } +} + +export async function getERC20Balance(token, accountAddress) { + const contract = global.eth.contract(abi).at(token.address); + const usersToken = (await contract.balanceOf(accountAddress)) ?? null; + if (!usersToken) { + return '0x0'; + } + const amount = calcTokenAmount( + usersToken.balance.toString(), + token.decimals, + ).toString(16); + return addHexPrefix(amount); +} + +// After modification of specific fields in specific circumstances we must +// recompute the gasLimit estimate to be as accurate as possible. the cases +// that necessitate this logic are listed below: +// 1. when the amount sent changes when sending a token due to the amount being +// part of the hex encoded data property of the transaction. +// 2. when updating the data property while sending NATIVE currency (ex: ETH) +// because the data parameter defines function calls that the EVM will have +// to execute which is where a large chunk of gas is potentially consumed. +// 3. when the recipient changes while sending a token due to the recipient's +// address being included in the hex encoded data property of the +// transaction +// 4. when the asset being sent changes due to the contract address and details +// of the token being included in the hex encoded data property of the +// transaction. If switching to NATIVE currency (ex: ETH), the gasLimit will +// change due to hex data being removed (unless supplied by user). +// This method computes the gasLimit estimate which is written to state in an +// action handler in extraReducers. +export const computeEstimatedGasLimit = createAsyncThunk( + 'send/computeEstimatedGasLimit', + async (_, thunkApi) => { + const { send, metamask } = thunkApi.getState(); + if (send.stage !== SEND_STAGES.EDIT) { + const gasLimit = await estimateGasLimitForSend({ + gasPrice: send.gas.gasPrice, + blockGasLimit: metamask.blockGasLimit, + selectedAddress: metamask.selectedAddress, + sendToken: send.asset.details, + to: send.recipient.address?.toLowerCase(), + value: send.amount.value, + data: send.draftTransaction.userInputHexData, + }); + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + return { + gasLimit, + }; + } + return null; + }, +); + +/** + * Responsible for initializing required state for the send slice. + * This method is dispatched from the send page in the componentDidMount + * method. It is also dispatched anytime the network changes to ensure that + * the slice remains valid with changing token and account balances. To do so + * it keys into state to get necessary values and computes a starting point for + * the send slice. It returns the values that might change from this action and + * those values are written to the slice in the `initializeSendState.fulfilled` + * action handler. + */ +export const initializeSendState = createAsyncThunk( + 'send/initializeSendState', + async (_, thunkApi) => { + const state = thunkApi.getState(); + const { + send: { asset, stage, recipient, amount, draftTransaction }, + metamask, + } = state; + // First determine the correct from address. For new sends this is always + // the currently selected account and switching accounts switches the from + // address. If editing an existing transaction (by clicking 'edit' on the + // send page), the fromAddress is always the address from the txParams. + const fromAddress = + stage === SEND_STAGES.EDIT + ? draftTransaction.txParams.from + : metamask.selectedAddress; + // We need the account's balance which is calculated from cachedBalances in + // the getMetaMaskAccounts selector. getTargetAccount consumes this + // selector and returns the account at the specified address. + const account = getTargetAccount(state, fromAddress); + // Initiate gas slices work to fetch gasPrice estimates. We need to get the + // new state after this is set to determine if initialization can proceed. + await thunkApi.dispatch(fetchBasicGasEstimates()); + const { + gas: { basicEstimateStatus, basicEstimates }, + } = thunkApi.getState(); + // Default gasPrice to 1 gwei if all estimation fails + const gasPrice = + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY + ? getGasPriceInHexWei(basicEstimates.average) + : '0x1'; + // Set a basic gasLimit in the event that other estimation fails + let gasLimit = + asset.type === ASSET_TYPES.TOKEN + ? GAS_LIMITS.BASE_TOKEN_ESTIMATE + : GAS_LIMITS.SIMPLE; + if ( + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY && + stage !== SEND_STAGES.EDIT + ) { + // Run our estimateGasLimit logic to get a more accurate estimation of + // required gas. If this value isn't nullish, set it as the new gasLimit + const estimatedGasLimit = await estimateGasLimitForSend({ + gasPrice: getGasPriceInHexWei(basicEstimates.average), + blockGasLimit: metamask.blockGasLimit, + selectedAddress: fromAddress, + sendToken: asset.details, + to: recipient.address.toLowerCase(), + value: amount.value, + data: draftTransaction.userInputHexData, + }); + gasLimit = estimatedGasLimit || gasLimit; + } + // We have to keep the gas slice in sync with the draft send transaction + // so that it'll be initialized correctly if the gas modal is opened. + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + // We must determine the balance of the asset that the transaction will be + // sending. This is done by referencing the native balance on the account + // for native assets, and calling the balanceOf method on the ERC20 + // contract for token sends. + let { balance } = account; + if (asset.type === ASSET_TYPES.TOKEN) { + if (asset.details === null) { + // If we're sending a token but details have not been provided we must + // abort and set the send slice into invalid status. + throw new Error( + 'Send slice initialized as token send without token details', + ); + } + balance = await getERC20Balance(asset.details, fromAddress); + } + return { + address: fromAddress, + nativeBalance: account.balance, + assetBalance: balance, + chainId: getCurrentChainId(state), + tokens: getTokens(state), + gasPrice, + gasLimit, + gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), + }; + }, +); + +export const initialState = { + // which stage of the send flow is the user on + stage: SEND_STAGES.UNINITIALIZED, + // status of the send slice, either VALID or INVALID + status: SEND_STATUSES.VALID, + account: { + // from account address, defaults to selected account. will be the account + // the original transaction was sent from in the case of the EDIT stage + address: null, + // balance of the from account + balance: '0x0', + }, + gas: { + // indicate whether the gas estimate is loading + isGasEstimateLoading: true, + // has the user set custom gas in the custom gas modal + isCustomGasSet: false, + // maximum gas needed for tx + gasLimit: '0x0', + // price in gwei to pay per gas + gasPrice: '0x0', + // maximum total price in gwei to pay + gasTotal: '0x0', + // minimum supported gasLimit + minimumGasLimit: GAS_LIMITS.SIMPLE, + // error to display for gas fields + error: null, + }, + amount: { + // The mode to use when determining new amounts. For INPUT mode the + // provided payload is always used. For MAX it is calculated based on avail + // asset balance + mode: AMOUNT_MODES.INPUT, + // Current value of the transaction, how much of the asset are we sending + value: '0x0', + // error to display for amount field + error: null, + }, + asset: { + // type can be either NATIVE such as ETH or TOKEN for ERC20 tokens + type: ASSET_TYPES.NATIVE, + // the balance the user holds at the from address for this asset + balance: '0x0', + // In the case of tokens, the address, decimals and symbol of the token + // will be included in details + details: null, + }, + draftTransaction: { + // The metamask internal id of the transaction. Only populated in the EDIT + // stage. + id: null, + // The hex encoded data provided by the user who has enabled hex data field + // in advanced settings + userInputHexData: null, + // The txParams that should be submitted to the network once this + // transaction is confirmed. This object is computed on every write to the + // slice of fields that would result in the txParams changing + txParams: { + to: '', + from: '', + data: undefined, + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }, + }, + recipient: { + // Defines which mode to use for searching for matches in the input field + mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + // Partial, not yet validated, entry into the address field. Used to share + // user input amongst the AddRecipient and EnsInput components. + userInput: '', + // The address of the recipient + address: '', + // The nickname stored in the user's address book for the recipient address + nickname: '', + // Error to display on the address field + error: null, + // Warning to display on the address field + warning: null, + }, +}; + +const slice = createSlice({ + name, + initialState, + reducers: { + /** + * update current amount.value in state and run post update validation of + * the amount field and the send state. Recomputes the draftTransaction + */ + updateSendAmount: (state, action) => { + state.amount.value = addHexPrefix(action.payload); + // Once amount has changed, validate the field + slice.caseReducers.validateAmountField(state); + if (state.asset.type === ASSET_TYPES.NATIVE) { + // if sending the native asset the amount being sent will impact the + // gas field as well because the gas validation takes into + // consideration the available balance minus amount sent before + // checking if there is enough left to cover the gas fee. + slice.caseReducers.validateGasField(state); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * computes the maximum amount of asset that can be sent and then calls + * the updateSendAmount action above with the computed value, which will + * revalidate the field and form and recomputes the draftTransaction + */ + updateAmountToMax: (state) => { + let amount = '0x0'; + if (state.asset.type === ASSET_TYPES.TOKEN) { + const decimals = state.asset.details?.decimals ?? 0; + const multiplier = Math.pow(10, Number(decimals)); + + amount = multiplyCurrencies(state.asset.balance, multiplier, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }); + } else { + amount = subtractCurrencies( + addHexPrefix(state.asset.balance), + addHexPrefix(state.gas.gasTotal), + { + toNumericBase: 'hex', + aBase: 16, + bBase: 16, + }, + ); + } + slice.caseReducers.updateSendAmount(state, { + payload: amount, + }); + // draftTransaction update happens in updateSendAmount + }, + /** + * updates the draftTransaction.userInputHexData state key and then + * recomputes the draftTransaction if the user is currently sending the + * native asset. When sending ERC20 assets, this is unnecessary because the + * hex data used in the transaction will be that for interacting with the + * ERC20 contract + */ + updateUserInputHexData: (state, action) => { + state.draftTransaction.userInputHexData = action.payload; + if (state.asset.type === ASSET_TYPES.NATIVE) { + slice.caseReducers.updateDraftTransaction(state); + } + }, + /** + * Initiates the edit transaction flow by setting the stage to 'EDIT' and + * then pulling the details of the previously submitted transaction from + * the action payload. It also computes a new draftTransaction that will be + * used when updating the transaction in the provider + */ + editTransaction: (state, action) => { + state.stage = SEND_STAGES.EDIT; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.amount.value = action.payload.amount; + state.gas.error = null; + state.amount.error = null; + state.recipient.address = action.payload.address; + state.recipient.nickname = action.payload.nickname; + state.draftTransaction.id = action.payload.id; + state.draftTransaction.txParams.from = action.payload.from; + slice.caseReducers.updateDraftTransaction(state); + }, + /** + * gasTotal is computed based on gasPrice and gasLimit and set in state + * recomputes the maximum amount if the current amount mode is 'MAX' and + * sending the native token. ERC20 assets max amount is unaffected by + * gasTotal so does not need to be recomputed. Finally, validates the gas + * field and send state, then updates the draft transaction. + */ + calculateGasTotal: (state) => { + state.gas.gasTotal = addHexPrefix( + calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), + ); + if ( + state.amount.mode === AMOUNT_MODES.MAX && + state.asset.type === ASSET_TYPES.NATIVE + ) { + slice.caseReducers.updateAmountToMax(state); + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal. + */ + updateGasLimit: (state, action) => { + state.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the provided gasPrice in state and then recomputes the gasTotal + */ + updateGasPrice: (state, action) => { + state.gas.gasPrice = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the amount mode to the provided value as long as it is one of the + * supported modes (MAX|INPUT) + */ + updateAmountMode: (state, action) => { + if (Object.values(AMOUNT_MODES).includes(action.payload)) { + state.amount.mode = action.payload; + } + }, + updateAsset: (state, action) => { + state.asset.type = action.payload.type; + state.asset.balance = action.payload.balance; + if (state.asset.type === ASSET_TYPES.TOKEN) { + state.asset.details = action.payload.details; + } else { + // clear the details object when sending native currency + state.asset.details = null; + if (state.recipient.error === CONTRACT_ADDRESS_ERROR) { + // Errors related to sending tokens to their own contract address + // are no longer valid when sending native currency. + state.recipient.error = null; + } + + if (state.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING) { + // Warning related to sending tokens to a known contract address + // are no longer valid when sending native currency. + state.recipient.warning = null; + } + } + // if amount mode is MAX update amount to max of new asset, otherwise set + // to zero. This will revalidate the send amount field. + if (state.amount.mode === AMOUNT_MODES.MAX) { + slice.caseReducers.updateAmountToMax(state); + } else { + slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateRecipient: (state, action) => { + state.recipient.error = null; + state.recipient.userInput = ''; + state.recipient.address = action.payload.address ?? ''; + state.recipient.nickname = action.payload.nickname ?? ''; + + if (state.recipient.address === '') { + // If address is null we are clearing the recipient and must return + // to the ADD_RECIPIENT stage. + state.stage = SEND_STAGES.ADD_RECIPIENT; + } else { + // if and address is provided and an id exists on the draft transaction, + // we progress to the EDIT stage, otherwise we progress to the DRAFT + // stage. We also reset the search mode for recipient search. + state.stage = + state.draftTransaction.id === null + ? SEND_STAGES.DRAFT + : SEND_STAGES.EDIT; + state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; + } + + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateDraftTransaction: (state) => { + // We keep a copy of txParams in state that could be submitted to the + // network if the form state is valid. + if (state.status === SEND_STATUSES.VALID) { + state.draftTransaction.txParams.from = state.account.address; + switch (state.asset.type) { + case ASSET_TYPES.TOKEN: + // When sending a token the to address is the contract address of + // the token being sent. The value is set to '0x0' and the data + // is generated from the recipient address, token being sent and + // amount. + state.draftTransaction.txParams.to = state.asset.details.address; + state.draftTransaction.txParams.value = '0x0'; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = generateTokenTransferData({ + toAddress: state.recipient.address, + amount: state.amount.value, + sendToken: state.asset.details, + }); + break; + case ASSET_TYPES.NATIVE: + default: + // When sending native currency the to and value fields use the + // recipient and amount values and the data key is either null or + // populated with the user input provided in hex field. + state.draftTransaction.txParams.to = state.recipient.address; + state.draftTransaction.txParams.value = state.amount.value; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = + state.draftTransaction.userInputHexData ?? undefined; + } + } + }, + useDefaultGas: (state) => { + // Show the default gas price/limit fields in the send page + state.gas.isCustomGasSet = false; + }, + useCustomGas: (state) => { + // Show the gas fees set in the custom gas modal (state.gas.customData) + state.gas.isCustomGasSet = true; + }, + updateRecipientUserInput: (state, action) => { + // Update the value in state to match what the user is typing into the + // input field + state.recipient.userInput = action.payload; + }, + validateRecipientUserInput: (state, action) => { + const { asset, recipient } = state; + + if ( + recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || + recipient.userInput === '' || + recipient.userInput === null + ) { + recipient.error = null; + recipient.warning = null; + } else { + const isSendingToken = asset.type === ASSET_TYPES.TOKEN; + const { chainId, tokens } = action.payload; + if ( + isBurnAddress(recipient.userInput) || + (!isValidHexAddress(recipient.userInput, { + mixedCaseUseChecksum: true, + }) && + !isValidDomainName(recipient.userInput)) + ) { + recipient.error = isDefaultMetaMaskChain(chainId) + ? INVALID_RECIPIENT_ADDRESS_ERROR + : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; + } else if ( + isSendingToken && + isOriginContractAddress(recipient.userInput, asset.details.address) + ) { + recipient.error = CONTRACT_ADDRESS_ERROR; + } else { + recipient.error = null; + } + + if ( + isSendingToken && + (toChecksumAddress(recipient.userInput) in contractMap || + checkExistingAddresses(recipient.userInput, tokens)) + ) { + recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; + } else { + recipient.warning = null; + } + } + }, + updateRecipientSearchMode: (state, action) => { + state.recipient.userInput = ''; + state.recipient.mode = action.payload; + }, + resetSendState: () => initialState, + validateAmountField: (state) => { + switch (true) { + // set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower + // than the total price of the transaction inclusive of gas fees. + case state.asset.type === ASSET_TYPES.NATIVE && + !isBalanceSufficient({ + amount: state.amount.value, + balance: state.asset.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }): + state.amount.error = INSUFFICIENT_FUNDS_ERROR; + break; + // set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower + // than the amount of token the user is attempting to send. + case state.asset.type === ASSET_TYPES.TOKEN && + !isTokenBalanceSufficient({ + tokenBalance: state.asset.balance ?? '0x0', + amount: state.amount.value, + decimals: state.asset.details.decimals, + }): + state.amount.error = INSUFFICIENT_TOKENS_ERROR; + break; + // if the amount is negative, set error to NEGATIVE_ETH_ERROR + // TODO: change this to NEGATIVE_ERROR and remove the currency bias. + case conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: state.amount.value, fromNumericBase: 'hex' }, + ): + state.amount.error = NEGATIVE_ETH_ERROR; + break; + // If none of the above are true, set error to null + default: + state.amount.error = null; + } + }, + validateGasField: (state) => { + // Checks if the user has enough funds to cover the cost of gas, always + // uses the native currency and does not take into account the amount + // being sent. If the user has enough to cover cost of gas but not gas + // + amount then the error will be displayed on the amount field. + const insufficientFunds = !isBalanceSufficient({ + amount: + state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0', + balance: state.account.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }); + + state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null; + }, + validateSendState: (state) => { + switch (true) { + // 1 + 2. State is invalid when either gas or amount fields have errors + // 3. State is invalid if asset type is a token and the token details + // are unknown. + // 4. State is invalid if no recipient has been added + // 5. State is invalid if the send state is uninitialized + // 6. State is invalid if gas estimates are loading + // 7. State is invalid if gasLimit is less than the minimumGasLimit + // 8. State is invalid if the selected asset is a ERC721 + case Boolean(state.amount.error): + case Boolean(state.gas.error): + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details === null: + case state.stage === SEND_STAGES.ADD_RECIPIENT: + case state.stage === SEND_STAGES.UNINITIALIZED: + case state.gas.isGasEstimateLoading: + case new BigNumber(state.gas.gasLimit, 16).lessThan( + new BigNumber(state.gas.minimumGasLimit), + ): + state.status = SEND_STATUSES.INVALID; + break; + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details.isERC721 === true: + state.state = SEND_STATUSES.INVALID; + break; + default: + state.status = SEND_STATUSES.VALID; + // Recompute the draftTransaction object + slice.caseReducers.updateDraftTransaction(state); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(QR_CODE_DETECTED, (state, action) => { + // When data is received from the QR Code Scanner we set the recipient + // as long as a valid address can be pulled from the data. If an + // address is pulled but it is invalid, we display an error. + const qrCodeData = action.value; + if (qrCodeData) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (state.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } + }) + .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow the account we are keyed into will be the + // original 'from' account, which may differ from the selected account + if (state.stage !== SEND_STAGES.EDIT) { + // This event occurs when the user selects a new account from the + // account menu, or the currently active account's balance updates. + state.account.balance = action.payload.account.balance; + state.account.address = action.payload.account.address; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow then we need to watch for changes to the + // current account.address in state and keep balance updated + // appropriately + if ( + state.stage === SEND_STAGES.EDIT && + action.payload.account.address === state.account.address + ) { + // This event occurs when the user's account details update due to + // background state changes. If the account that is being updated is + // the current from account on the edit flow we need to update + // the balance for the account and revalidate the send state. + state.account.balance = action.payload.account.balance; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ADDRESS_BOOK_UPDATED, (state, action) => { + // When the address book updates from background state changes we need + // to check to see if an entry exists for the current address or if the + // entry changed. + const { addressBook } = action.payload; + if (addressBook[state.recipient.address]?.name) { + state.recipient.nickname = addressBook[state.recipient.address].name; + } + }) + .addCase(initializeSendState.pending, (state) => { + // when we begin initializing state, which can happen when switching + // chains even after loading the send flow, we set + // gas.isGasEstimateLoading as initialization will trigger a fetch + // for gasPrice estimates. + state.gas.isGasEstimateLoading = true; + }) + .addCase(initializeSendState.fulfilled, (state, action) => { + // writes the computed initialized state values into the slice and then + // calculates slice validity using the caseReducers. + state.account.address = action.payload.address; + state.account.balance = action.payload.nativeBalance; + state.asset.balance = action.payload.assetBalance; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.gas.gasTotal = action.payload.gasTotal; + if (state.stage !== SEND_STAGES.UNINITIALIZED) { + slice.caseReducers.validateRecipientUserInput(state, { + payload: { + chainId: action.payload.chainId, + tokens: action.payload.tokens, + }, + }); + } + state.stage = + state.stage === SEND_STAGES.UNINITIALIZED + ? SEND_STAGES.ADD_RECIPIENT + : state.stage; + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit we should indicate we are loading + // a gas estimate. + state.gas.isGasEstimateLoading = true; + }) + .addCase(computeEstimatedGasLimit.fulfilled, (state, action) => { + // When we receive a new gasLimit from the computeEstimatedGasLimit + // thunk we need to update our gasLimit in the slice. We call into the + // caseReducer updateGasLimit to tap into the appropriate follow up + // checks and gasTotal calculation. First set isGasEstimateLoading to + // false. + state.gas.isGasEstimateLoading = false; + if (action.payload?.gasLimit) { + slice.caseReducers.updateGasLimit(state, { + payload: action.payload.gasLimit, + }); + } + }) + .addCase(SET_BASIC_GAS_ESTIMATE_DATA, (state, action) => { + // When we receive a new gasPrice via the gas duck we need to update + // the gasPrice in our slice. We call into the caseReducer + // updateGasPrice to also tap into the appropriate follow up checks + // and gasTotal calculation. + slice.caseReducers.updateGasPrice(state, { + payload: getGasPriceInHexWei(action.value.average), + }); + }) + .addCase(BASIC_GAS_ESTIMATE_STATUS, (state, action) => { + // When we fetch gas prices we should temporarily set the form invalid + // Once the price updates we get that value in the + // SET_BASIC_GAS_ESTIMATE_DATA extraReducer above. Finally as long as + // the state is 'READY' we will revalidate the form. + switch (action.value) { + case BASIC_ESTIMATE_STATES.FAILED: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.LOADING: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.READY: + default: + state.gas.isGasEstimateLoading = false; + slice.caseReducers.validateSendState(state); + } + }); + }, +}); + +const { actions, reducer } = slice; + +export default reducer; + +const { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, + validateRecipientUserInput, + updateRecipientSearchMode, +} = actions; + +export { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, +}; + +// Action Creators + +/** + * Updates the amount the user intends to send and performs side effects. + * 1. If the current mode is MAX change to INPUT + * 2. If sending a token, recompute the gasLimit estimate + * @param {string} amount - hex string representing value + * @returns {void} + */ +export function updateSendAmount(amount) { + return async (dispatch, getState) => { + await dispatch(actions.updateSendAmount(amount)); + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + } + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * updates the asset to send to one of NATIVE or TOKEN and ensures that the + * asset balance is set. If sending a TOKEN also updates the asset details + * object with the appropriate ERC20 details including address, symbol and + * decimals. + * @param {Object} payload - action payload + * @param {string} payload.type - type of asset to send + * @param {Object} [payload.details] - ERC20 details if sending TOKEN asset + * @param {string} [payload.details.address] - contract address for ERC20 + * @param {string} [payload.details.decimals] - Number of token decimals + * @param {string} [payload.details.symbol] - asset symbol to display + * @returns {void} + */ +export function updateSendAsset({ type, details }) { + return async (dispatch, getState) => { + const state = getState(); + let { balance } = state.send.asset; + if (type === ASSET_TYPES.TOKEN) { + // if changing to a token, get the balance from the network. The asset + // overview page and asset list on the wallet overview page contain + // send buttons that call this method before initialization occurs. + // When this happens we don't yet have an account.address so default to + // the currently active account. In addition its possible for the balance + // check to take a decent amount of time, so we display a loading + // indication so that that immediate feedback is displayed to the user. + await dispatch(showLoadingIndication()); + balance = await getERC20Balance( + details, + state.send.account.address ?? getSelectedAddress(state), + ); + if (details && details.isERC721 === undefined) { + const updatedAssetDetails = await updateTokenType(details.address); + details.isERC721 = updatedAssetDetails.isERC721; + } + + await dispatch(hideLoadingIndication()); + } else { + // if changing to native currency, get it from the account key in send + // state which is kept in sync when accounts change. + balance = state.send.account.balance; + } + // update the asset in state which will re-run amount and gas validation + await dispatch(actions.updateAsset({ type, details, balance })); + await dispatch(computeEstimatedGasLimit()); + }; +} + +/** + * This method is for usage when validating user input so that validation + * is only run after a delay in typing of 300ms. Usage at callsites requires + * passing in both the dispatch method and the payload to dispatch, which makes + * it only applicable for use within action creators. + */ +const debouncedValidateRecipientUserInput = debounce((dispatch, payload) => { + dispatch(validateRecipientUserInput(payload)); +}, 300); + +/** + * This method is called to update the user's input into the ENS input field. + * Once the field is updated, the field will be validated using a debounced + * version of the validateRecipientUserInput action. This way validation only + * occurs once the user has stopped typing. + * @param {string} userInput - the value that the user is typing into the field + * @returns {void} + */ +export function updateRecipientUserInput(userInput) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipientUserInput(userInput)); + const state = getState(); + const chainId = getCurrentChainId(state); + const tokens = getTokens(state); + debouncedValidateRecipientUserInput(dispatch, { chainId, tokens }); + }; +} + +export function useContactListForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); + }; +} + +export function useMyAccountsForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); + }; +} + +/** + * Updates the recipient in state based on the input provided, and then will + * recompute gas limit when sending a TOKEN asset type. Changing the recipient + * address results in hex data changing because the recipient address is + * encoded in the data instead of being in the 'to' field. The to field in a + * token send will always be the token contract address. + * @param {Object} recipient - Recipient information + * @param {string} recipient.address - hex address to send the transaction to + * @param {string} [recipient.nickname] - Alias for the address to display + * to the user + * @returns {void} + */ +export function updateRecipient({ address, nickname }) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipient({ address, nickname })); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Clears out the recipient user input, ENS resolution and recipient validation + * @returns {void} + */ +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(updateRecipientUserInput('')); + await dispatch(updateRecipient({ address: '', nickname: '' })); + await dispatch(resetResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +/** + * When a user has enabled hex data field in advanced settings they will be + * able to supply hex data on a transaction. This method updates the user + * supplied data. Note, when sending native assets this will result in + * recomputing estimated gasLimit. When sending a ERC20 asset this is not done + * because the data sent in the transaction will be determined by the asset, + * recipient and value, NOT what the user has supplied. + * @param {string} hexData - hex encoded string representing transaction data + * @returns {void} + */ +export function updateSendHexData(hexData) { + return async (dispatch, getState) => { + await dispatch(actions.updateUserInputHexData(hexData)); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.NATIVE) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Toggles the amount.mode between INPUT and MAX modes. + * As a result, the amount.value will change to either '0x0' when moving from + * MAX to INPUT, or to the maximum allowable amount based on current asset when + * moving from INPUT to MAX. + * @returns {void} + */ +export function toggleSendMaxMode() { + return async (dispatch, getState) => { + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + await dispatch(actions.updateSendAmount('0x0')); + } else { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); + await dispatch(actions.updateAmountToMax()); + } + }; +} + +/** + * Signs a transaction or updates a transaction in state if editing. + * This method is called when a user clicks the next button in the footer of + * the send page, signaling that a transaction should be executed. This method + * will create the transaction in state (by way of the various global provider + * constructs) which will eventually (and fairly quickly from user perspective) + * result in a confirmation window being displayed for the transaction. + * @returns {void} + */ +export function signTransaction() { + return async (dispatch, getState) => { + const state = getState(); + const { + asset, + stage, + draftTransaction: { id, txParams }, + recipient: { address }, + amount: { value }, + } = state[name]; + if (stage === SEND_STAGES.EDIT) { + // When dealing with the edit flow there is already a transaction in + // state that we must update, this branch is responsible for that logic. + // We first must grab the previous transaction object from state and then + // merge in the modified txParams. Once the transaction has been modified + // we can send that to the background to update the transaction in state. + const unapprovedTxs = getUnapprovedTxs(state); + const unapprovedTx = unapprovedTxs[id]; + const editingTx = { + ...unapprovedTx, + txParams: Object.assign(unapprovedTx.txParams, txParams), + }; + dispatch(updateTransaction(editingTx)); + } else if (asset.type === ASSET_TYPES.TOKEN) { + // When sending a token transaction we have to the token.transfer method + // on the token contract to construct the transaction. This results in + // the proper transaction data and properties being set and a new + // transaction being added to background state. Once the new transaction + // is added to state a subsequent confirmation will be queued. + try { + const token = global.eth.contract(abi).at(asset.details.address); + token.transfer(address, value, { + ...txParams, + to: undefined, + data: undefined, + }); + dispatch(showConfTxPage()); + dispatch(hideLoadingIndication()); + } catch (error) { + dispatch(hideLoadingIndication()); + dispatch(displayWarning(error.message)); + } + } else { + // When sending a native asset we use the ethQuery.sendTransaction method + // which will result in the transaction being added to background state + // and a subsequent confirmation will be queued. + global.ethQuery.sendTransaction(txParams, (err) => { + if (err) { + dispatch(displayWarning(err.message)); + } + }); + dispatch(showConfTxPage()); + } + }; +} + +export function editTransaction( + assetType, + transactionId, + tokenData, + assetDetails, +) { + return async (dispatch, getState) => { + const state = getState(); + const unapprovedTransactions = getUnapprovedTxs(state); + const transaction = unapprovedTransactions[transactionId]; + const { txParams } = transaction; + if (assetType === ASSET_TYPES.NATIVE) { + const { + from, + gas: gasLimit, + gasPrice, + to: address, + value: amount, + } = txParams; + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount, + address, + nickname, + }), + ); + } else if (!tokenData || !assetDetails) { + throw new Error( + `send/editTransaction dispatched with assetType 'TOKEN' but missing assetData or assetDetails parameter`, + ); + } else { + const { from, to: tokenAddress, gas: gasLimit, gasPrice } = txParams; + const tokenAmountInDec = getTokenValueParam(tokenData); + const address = getTokenAddressParam(tokenData); + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + + const tokenAmountInHex = addHexPrefix( + conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }), + ); + + await dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { ...assetDetails, address: tokenAddress }, + }), + ); + + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount: tokenAmountInHex, + address, + nickname, + }), + ); + } + }; +} + +// Selectors + +// Gas selectors +export function getGasLimit(state) { + return state[name].gas.gasLimit; +} + +export function getGasPrice(state) { + return state[name].gas.gasPrice; +} + +export function getGasTotal(state) { + return state[name].gas.gasTotal; +} + +export function gasFeeIsInError(state) { + return Boolean(state[name].gas.error); +} + +export function getMinimumGasLimitForSend(state) { + return state[name].gas.minimumGasLimit; +} + +export function getGasInputMode(state) { + const isMainnet = getIsMainnet(state); + const showAdvancedGasFields = getAdvancedInlineGasShown(state); + if (state[name].gas.isCustomGasSet) { + return GAS_INPUT_MODES.CUSTOM; + } + if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { + return GAS_INPUT_MODES.INLINE; + } + return GAS_INPUT_MODES.BASIC; +} + +// Asset Selectors + +export function getSendAsset(state) { + return state[name].asset; +} + +export function getSendAssetAddress(state) { + return getSendAsset(state)?.details?.address; +} + +export function getIsAssetSendable(state) { + if (state[name].asset.type === ASSET_TYPES.NATIVE) { + return true; + } + return state[name].asset.details.isERC721 === false; +} + +// Amount Selectors +export function getSendAmount(state) { + return state[name].amount.value; +} + +export function getIsBalanceInsufficient(state) { + return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; +} +export function getSendMaxModeState(state) { + return state[name].amount.mode === AMOUNT_MODES.MAX; +} + +export function getSendHexData(state) { + return state[name].draftTransaction.userInputHexData; +} + +export function sendAmountIsInError(state) { + return Boolean(state[name].amount.error); +} + +// Recipient Selectors + +export function getSendTo(state) { + return state[name].recipient.address; +} + +export function getIsUsingMyAccountForRecipientSearch(state) { + return state[name].recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; +} + +export function getRecipientUserInput(state) { + return state[name].recipient.userInput; +} + +export function getRecipient(state) { + return state[name].recipient; +} + +// Overall validity and stage selectors + +export function getSendErrors(state) { + return { + gasFee: state.send.gas.error, + amount: state.send.amount.error, + }; +} + +export function isSendStateInitialized(state) { + return state[name].stage !== SEND_STAGES.UNINITIALIZED; +} + +export function isSendFormInvalid(state) { + return state[name].status === SEND_STATUSES.INVALID; +} + +export function getSendStage(state) { + return state[name].stage; +} diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js new file mode 100644 index 000000000..b4918d01d --- /dev/null +++ b/ui/ducks/send/send.test.js @@ -0,0 +1,1808 @@ +import sinon from 'sinon'; +import createMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { ethers } from 'ethers'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; +import { BASIC_ESTIMATE_STATES } from '../gas/gas.duck'; +import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; +import sendReducer, { + initialState, + initializeSendState, + updateSendAmount, + updateSendAsset, + updateRecipientUserInput, + useContactListForRecipientSearch, + useMyAccountsForRecipientSearch, + updateRecipient, + resetRecipientInput, + updateSendHexData, + toggleSendMaxMode, + signTransaction, + SEND_STATUSES, + ASSET_TYPES, + SEND_STAGES, + AMOUNT_MODES, + RECIPIENT_SEARCH_MODES, + editTransaction, +} from './send'; + +const mockStore = createMockStore([thunk]); + +jest.mock('../../store/actions', () => { + const actual = jest.requireActual('../../store/actions'); + return { + ...actual, + estimateGas: jest.fn(() => Promise.resolve('0x0')), + updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })), + }; +}); + +jest.mock('./send', () => { + const actual = jest.requireActual('./send'); + return { + __esModule: true, + ...actual, + getERC20Balance: jest.fn(() => '0x0'), + }; +}); + +describe('Send Slice', () => { + describe('Reducers', () => { + describe('updateSendAmount', () => { + it('should', async () => { + const action = { type: 'send/updateSendAmount', payload: '0x1' }; + const result = sendReducer(initialState, action); + expect(result.amount.value).toStrictEqual('0x1'); + }); + }); + + describe('updateAmountToMax', () => { + it('should calculate the max amount based off of the asset balance and gas total then updates send amount value', () => { + const maxAmountState = { + amount: { + value: '', + }, + asset: { + balance: '0x56bc75e2d63100000', // 100000000000000000000 + }, + gas: { + gasLimit: '0x5208', // 21000 + gasTotal: '0x1319718a5000', // 21000000000000 + minimumGasLimit: '0x5208', + }, + }; + + const state = { ...initialState, ...maxAmountState }; + const action = { type: 'send/updateAmountToMax' }; + const result = sendReducer(state, action); + + expect(result.amount.value).toStrictEqual('0x56bc74b13f185b000'); // 99999979000000000000 + }); + }); + + describe('updateUserInputHexData', () => { + it('should', () => { + const action = { + type: 'send/updateUserInputHexData', + payload: 'TestData', + }; + const result = sendReducer(initialState, action); + + expect(result.draftTransaction.userInputHexData).toStrictEqual( + action.payload, + ); + }); + }); + + describe('updateGasLimit', () => { + const action = { + type: 'send/updateGasLimit', + payload: '0x5208', // 21000 + }; + + it('should', () => { + const result = sendReducer( + { + ...initialState, + stage: SEND_STAGES.DRAFT, + gas: { ...initialState.gas, isGasEstimateLoading: false }, + }, + action, + ); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + ...initialState, + gas: { + gasLimit: '0x0', + gasPrice: '0x3b9aca00', // 1000000000 + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.gas.gasPrice).toStrictEqual(gasState.gas.gasPrice); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateGasPrice', () => { + const action = { + type: 'send/updateGasPrice', + payload: '0x3b9aca00', // 1000000000 + }; + + it('should update gas price and update draft transaction with validated state', () => { + const validSendState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + account: { + balance: '0x56bc75e2d63100000', + }, + asset: { + balance: '0x56bc75e2d63100000', + type: ASSET_TYPES.NATIVE, + }, + gas: { + isGasEstimateLoading: false, + gasTotal: '0x1319718a5000', // 21000000000000 + gasLimit: '0x5208', // 21000 + minimumGasLimit: '0x5208', + }, + }; + + const result = sendReducer(validSendState, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + gas: { + gasLimit: '0x5208', // 21000, + gasPrice: '0x0', + }, + }; + + const state = { ...initialState, ...gasState }; + const result = sendReducer(state, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateAmountMode', () => { + it('should change to INPUT amount mode', () => { + const emptyAmountModeState = { + amount: { + mode: '', + }, + }; + + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.INPUT, + }; + const result = sendReducer(emptyAmountModeState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should change to MAX amount mode', () => { + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.MAX, + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should', () => { + const action = { + type: 'send/updateAmountMode', + payload: 'RANDOM', + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).not.toStrictEqual(action.payload); + }); + }); + + describe('updateAsset', () => { + it('should update asset type and balance from respective action payload', () => { + const updateAssetState = { + ...initialState, + asset: { + type: 'old type', + balance: 'old balance', + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'new type', + balance: 'new balance', + }, + }; + + const result = sendReducer(updateAssetState, action); + + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.balance).toStrictEqual(action.payload.balance); + }); + + it('should nullify old contract address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + error: CONTRACT_ADDRESS_ERROR, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.error).not.toStrictEqual( + recipientErrorState.recipient.error, + ); + expect(result.recipient.error).toBeNull(); + }); + + it('should nullify old known address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + warning: KNOWN_RECIPIENT_ADDRESS_WARNING, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.warning).not.toStrictEqual( + recipientErrorState.recipient.warning, + ); + expect(result.recipient.warning).toBeNull(); + }); + + it('should update asset type and details to TOKEN payload', () => { + const action = { + type: 'send/updateAsset', + payload: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 0, + symbol: 'TKN', + }, + }, + }; + + const result = sendReducer(initialState, action); + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.details).toStrictEqual(action.payload.details); + }); + }); + + describe('updateRecipient', () => { + it('should', () => { + const action = { + type: 'send/updateRecipient', + payload: { + address: '0xNewAddress', + }, + }; + + const result = sendReducer(initialState, action); + + expect(result.stage).toStrictEqual(SEND_STAGES.DRAFT); + expect(result.recipient.address).toStrictEqual(action.payload.address); + }); + }); + + describe('updateDraftTransaction', () => { + it('should', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: '', + }, + recipient: { + address: '0xRecipientAddress', + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.recipient.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + detailsForDraftTransactionState.amount.value, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + }); + + it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + }, + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.asset.details.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + expect(result.draftTransaction.txParams.data).toStrictEqual( + '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + ); + }); + }); + + describe('useDefaultGas', () => { + it('should', () => { + const action = { + type: 'send/useDefaultGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(false); + }); + }); + + describe('useCustomGas', () => { + it('should', () => { + const action = { + type: 'send/useCustomGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(true); + }); + }); + + describe('updateRecipientUserInput', () => { + it('should update recipient user input with payload', () => { + const action = { + type: 'send/updateRecipientUserInput', + payload: 'user input', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.userInput).toStrictEqual(action.payload); + }); + }); + + describe('validateRecipientUserInput', () => { + it('should set recipient error and warning to null when user input is', () => { + const noUserInputState = { + recipient: { + mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + userInput: '', + error: 'someError', + warning: 'someWarning', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + }; + + const result = sendReducer(noUserInputState, action); + + expect(result.recipient.error).toBeNull(); + expect(result.recipient.warning).toBeNull(); + }); + + it('should error with an invalid address error when user input is not a valid hex string', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + // TODO: Expectation might change in the future + it('should error with an invalid network error when user input is not a valid hex string on a non default network', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x55', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual( + 'invalidAddressRecipientNotEthNetwork', + ); + }); + + it('should error with invalid address recipient when the user inputs the burn address', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0x0000000000000000000000000000000000000000', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + it('should error with same address recipient as a token', () => { + const tokenAssetTypeState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + recipient: { + userInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('contractAddressError'); + }); + }); + + describe('updateRecipientSearchMode', () => { + it('should', () => { + const action = { + type: 'send/updateRecipientSearchMode', + payload: 'a-random-string', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.mode).toStrictEqual(action.payload); + }); + }); + + describe('resetSendState', () => { + it('should', () => { + const action = { + type: 'send/resetSendState', + }; + + const result = sendReducer({}, action); + + expect(result).toStrictEqual(initialState); + }); + }); + + describe('validateAmountField', () => { + it('should error with insufficient funds when amount asset value plust gas is higher than asset balance', () => { + const nativeAssetState = { + ...initialState, + amount: { + value: '0x6fc23ac0', // 1875000000 + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0x77359400', // 2000000000 + }, + gas: { + gasTotal: '0x8f0d180', // 150000000 + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(nativeAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + + it('should error with insufficient tokens when amount value of tokens is higher than asset balance of token', () => { + const tokenAssetState = { + ...initialState, + amount: { + value: '0x77359400', // 2000000000 + }, + asset: { + type: ASSET_TYPES.TOKEN, + balance: '0x6fc23ac0', // 1875000000 + details: { + decimals: 0, + }, + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(tokenAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_TOKENS_ERROR); + }); + + it('should error negative value amount', () => { + const negativeAmountState = { + ...initialState, + amount: { + value: '-1', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(negativeAmountState, action); + + expect(result.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); + }); + + it('should not error for positive value amount', () => { + const otherState = { + ...initialState, + amount: { + error: 'someError', + value: '1', + }, + asset: { + type: '', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(otherState, action); + expect(result.amount.error).toBeNull(); + }); + }); + + describe('validateGasField', () => { + it('should error when total amount of gas is higher than account balance', () => { + const gasFieldState = { + ...initialState, + account: { + balance: '0x0', + }, + gas: { + gasTotal: '0x1319718a5000', // 21000000000000 + }, + }; + + const action = { + type: 'send/validateGasField', + }; + + const result = sendReducer(gasFieldState, action); + expect(result.gas.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + }); + + describe('validateSendState', () => { + it('should set `INVALID` send state status when amount error is present', () => { + const amountErrorState = { + ...initialState, + amount: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(amountErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gas error is present', () => { + const gasErrorState = { + ...initialState, + gas: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when asset type is `TOKEN` without token details present', () => { + const assetErrorState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(assetErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gasLimit is under the minimumGasLimit', () => { + const gasLimitErroState = { + ...initialState, + gas: { + gasLimit: '0x5207', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasLimitErroState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `VALID` send state status when conditionals have not been met', () => { + const validSendStatusState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x000', + }, + }, + gas: { + isGasEstimateLoading: false, + gasLimit: '0x5208', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).toStrictEqual(SEND_STATUSES.VALID); + }); + }); + }); + + describe('extraReducers/externalReducers', () => { + describe('QR Code Detected', () => { + const qrCodestate = { + ...initialState, + recipient: { + address: '0xAddress', + }, + }; + + it('should set the recipient address to the scanned address value if they are not equal', () => { + const action = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + }; + + const result = sendReducer(qrCodestate, action); + expect(result.recipient.address).toStrictEqual( + action.value.values.address, + ); + }); + + it('should not set the recipient address to invalid scanned address and errors', () => { + const badQRAddressAction = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0xBadAddress', + }, + }, + }; + + const result = sendReducer(qrCodestate, badQRAddressAction); + + expect(result.recipient.address).toStrictEqual( + qrCodestate.recipient.address, + ); + expect(result.recipient.error).toStrictEqual( + INVALID_RECIPIENT_ADDRESS_ERROR, + ); + }); + }); + + describe('Selected Address Changed', () => { + it('should update selected account address and balance on non-edit stages', () => { + const olderState = { + ...initialState, + account: { + balance: '0x0', + address: '0xAddress', + }, + }; + + const action = { + type: 'SELECTED_ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(olderState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + expect(result.account.address).toStrictEqual( + action.payload.account.address, + ); + }); + }); + + describe('Account Changed', () => { + it('should', () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + }); + + it(`should not edit account balance if action payload address is not the same as state's address`, () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + expect(result.account.address).not.toStrictEqual( + action.payload.account.address, + ); + expect(result.account.balance).not.toStrictEqual( + action.payload.account.balance, + ); + }); + }); + + describe('Initialize Pending Send State', () => { + let dispatchSpy; + let getState; + + beforeEach(() => { + dispatchSpy = jest.fn(); + }); + + it('should dispatch async action thunk first with pending, then finally fulfilling from minimal state', async () => { + getState = jest.fn().mockReturnValue({ + metamask: { + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + 0x4: { + '0xAddress': '0x0', + }, + }, + selectedAddress: '0xAddress', + provider: { + chainId: '0x4', + }, + }, + send: initialState, + gas: { + basicEstimateStatus: 'LOADING', + basicEstimatesStatus: { + safeLow: null, + average: null, + fast: null, + }, + }, + }); + + const action = initializeSendState(); + await action(dispatchSpy, getState, undefined); + + expect(dispatchSpy).toHaveBeenCalledTimes(4); + + expect(dispatchSpy.mock.calls[0][0].type).toStrictEqual( + 'send/initializeSendState/pending', + ); + expect(dispatchSpy.mock.calls[3][0].type).toStrictEqual( + 'send/initializeSendState/fulfilled', + ); + }); + }); + + describe('Set Basic Gas Estimate Data', () => { + it('should recalculate gas based off of average basic estimate data', () => { + const gasState = { + ...initialState, + gas: { + gasPrice: '0x0', + gasLimit: '0x5208', + gasTotal: '0x0', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA', + value: { + average: '1', + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); + }); + }); + + describe('BASIC_GAS_ESTIMATE_STATUS', () => { + it('should invalidate the send status when status is LOADING', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.LOADING, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + + it('should invalidate the send status when status is FAILED and use INLINE gas input mode', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.FAILED, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + }); + }); + + describe('Action Creators', () => { + describe('UpdateSendAmount', () => { + const defaultSendAmountState = { + send: { + amount: { + mode: undefined, + }, + asset: { + type: '', + }, + }, + }; + + it('should create an action to update send amount', async () => { + const store = mockStore(defaultSendAmountState); + + const newSendAmount = 'aNewSendAmount'; + + await store.dispatch(updateSendAmount(newSendAmount)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: 'aNewSendAmount' }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action to update send amount mode to `INPUT` when mode is `MAX`', async () => { + const maxModeSendState = { + send: { + ...defaultSendAmountState.send, + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + + const store = mockStore(maxModeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: undefined }, + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action computeEstimateGasLimit and change states from pending to fulfilled with token asset types', async () => { + const tokenAssetTypeSendState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + ...defaultSendAmountState.send, + send: { + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenAssetTypeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateSendAmount'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('UpdateSendAsset', () => { + const defaultSendAssetState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: '', + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + it('should create actions for updateSendAsset', async () => { + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: '', + details: { + address: '', + symbol: '', + decimals: '', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + + expect(actionResult[0].type).toStrictEqual('send/updateAsset'); + expect(actionResult[0].payload).toStrictEqual({ + ...newSendAsset, + balance: '', + }); + + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + + it('should create actions for updateSendAsset with tokens', async () => { + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + }; + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: ASSET_TYPES.TOKEN, + details: { + address: 'tokenAddress', + symbol: 'tokenSymbol', + decimals: 'tokenDecimals', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(6); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].payload).toStrictEqual({ + ...newSendAsset, + balance: '0x0', + }); + + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('updateRecipientUserInput', () => { + const updateRecipientUserInputState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + }; + + it('should create actions for updateRecipientUserInput and checks debounce for validation', async () => { + const clock = sinon.useFakeTimers(); + + const store = mockStore(updateRecipientUserInputState); + const newUserRecipientInput = 'newUserRecipientInput'; + + await store.dispatch(updateRecipientUserInput(newUserRecipientInput)); + + expect(store.getActions()).toHaveLength(1); + expect(store.getActions()[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(store.getActions()[0].payload).toStrictEqual( + newUserRecipientInput, + ); + + clock.tick(300); // debounce + + expect(store.getActions()).toHaveLength(2); + expect(store.getActions()[1].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + expect(store.getActions()[1].payload).toStrictEqual({ + chainId: '', + tokens: [], + }); + }); + }); + + describe('useContactListForRecipientSearch', () => { + it('should create action to change send recipient search to contact list', async () => { + const store = mockStore(); + + await store.dispatch(useContactListForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + }, + ]); + }); + }); + + describe('UseMyAccountsForRecipientSearch', () => { + it('should create action to change send recipient search to derived accounts', async () => { + const store = mockStore(); + + await store.dispatch(useMyAccountsForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + }, + ]); + }); + }); + + describe('UpdateRecipient', () => { + const recipient = { + address: '', + nickname: '', + }; + + it('should create an action to update recipient', async () => { + const updateRecipientState = { + send: { + asset: { + type: '', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { + type: 'send/updateRecipient', + payload: recipient, + }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create actions to update recipient and recalculate gas limit if the asset is a token', async () => { + const tokenState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('ResetRecipientInput', () => { + it('should create actions to reset recipient input and ens then validates input', async () => { + const updateRecipientState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(resetRecipientInput()); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(actionResult[0].payload).toStrictEqual(''); + expect(actionResult[1].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[2].type).toStrictEqual('ENS/resetResolution'); + expect(actionResult[3].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + }); + }); + + describe('UpdateSendHexData', () => { + const sendHexDataState = { + send: { + asset: { + type: '', + }, + }, + }; + + it('should create action to update hexData', async () => { + const hexData = '0x1'; + const store = mockStore(sendHexDataState); + + await store.dispatch(updateSendHexData(hexData)); + + const actionResult = store.getActions(); + + const expectActionResult = [ + { type: 'send/updateUserInputHexData', payload: hexData }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectActionResult); + }); + }); + + describe('ToggleSendMaxMode', () => { + it('should create actions to toggle update max mode when send amount mode is not max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: '', + }, + }, + }; + + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX }, + { type: 'send/updateAmountToMax', payload: undefined }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + + it('should create actions to toggle off max mode when send amount mode is max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + { type: 'send/updateSendAmount', payload: '0x0' }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + }); + + describe('SignTransaction', () => { + const signTransactionState = { + send: { + asset: {}, + stage: '', + draftTransaction: {}, + recipient: {}, + amount: {}, + }, + }; + + it('should show confirm tx page when no other conditions for signing have been met', async () => { + global.ethQuery = { + sendTransaction: sinon.stub(), + }; + + const store = mockStore(signTransactionState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('SHOW_CONF_TX_PAGE'); + }); + + it('should create actions for updateTransaction rejecting', async () => { + const editStageSignTxState = { + metamask: { + unapprovedTxs: { + 1: { + id: 1, + txParams: { + value: 'oldTxValue', + }, + }, + }, + }, + send: { + ...signTransactionState.send, + stage: SEND_STAGES.EDIT, + draftTransaction: { + id: 1, + txParams: { + value: 'newTxValue', + }, + }, + }, + }; + + jest.mock('../../store/actions.js'); + + const store = mockStore(editStageSignTxState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(5); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('UPDATE_TRANSACTION_PARAMS'); + expect(actionResult[2].type).toStrictEqual('HIDE_LOADING_INDICATION'); + }); + }); + + describe('editTransaction', () => { + it('should set up the appropriate state for editing a native asset transaction', async () => { + const editTransactionState = { + metamask: { + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xRecipientAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0xde0b6b3a7640000', // 1000000000000000000 + }, + }, + }, + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(editTransactionState); + + await store.dispatch(editTransaction(ASSET_TYPES.NATIVE, 1)); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('send/editTransaction'); + expect(actionResult[0].payload).toStrictEqual({ + address: '0xRecipientAddress', + amount: '0xde0b6b3a7640000', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[0]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + + it('should set up the appropriate state for editing a token asset transaction', async () => { + const editTransactionState = { + metamask: { + blockGasLimit: '0x3a98', + selectedAddress: '', + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xTokenAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0x0', + }, + }, + }, + }, + send: { + account: { + address: '0xAddress', + balance: '0x0', + }, + asset: { + type: '', + }, + gas: { + gasPrice: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + getCode: jest.fn(() => '0xa'), + }; + + const store = mockStore(editTransactionState); + + await store.dispatch( + editTransaction( + ASSET_TYPES.TOKEN, + 1, + { + name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, + args: { + _to: '0xRecipientAddress', + _value: ethers.BigNumber.from(15000), + }, + }, + { address: '0xAddress', symbol: 'SYMB', decimals: 18 }, + ), + ); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(7); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].type).toStrictEqual('send/updateAsset'); + expect(actionResult[2].payload).toStrictEqual({ + balance: '0x0', + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 18, + symbol: 'SYMB', + isERC721: false, + }, + }); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + expect(actionResult[6].type).toStrictEqual('send/editTransaction'); + expect(actionResult[6].payload).toStrictEqual({ + address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase + amount: '0x3a98', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[6]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + }); + }); +}); diff --git a/ui/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/pages/confirm-send-ether/confirm-send-ether.container.js index 475ee5213..5f7527226 100644 --- a/ui/pages/confirm-send-ether/confirm-send-ether.container.js +++ b/ui/pages/confirm-send-ether/confirm-send-ether.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; -import { updateSend } from '../../ducks/send/send.duck'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -18,22 +18,8 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { editTransaction: (txData) => { - const { id, txParams } = txData; - const { from, gas: gasLimit, gasPrice, to, value: amount } = txParams; - - dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - }), - ); - + const { id } = txData; + dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString())); dispatch(clearConfirmTransaction()); }, }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.container.js b/ui/pages/confirm-send-token/confirm-send-token.container.js index fd869a9a7..d823e25aa 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -3,13 +3,8 @@ import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { showSendTokenPage } from '../../store/actions'; -import { conversionUtil } from '../../helpers/utils/conversion-util'; -import { - getTokenValueParam, - getTokenAddressParam, -} from '../../helpers/utils/token-util'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors'; -import { updateSend } from '../../ducks/send/send.duck'; import ConfirmSendToken from './confirm-send-token.component'; const mapStateToProps = (state) => { @@ -22,35 +17,15 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - editTransaction: ({ txData, tokenData, tokenProps }) => { - const { - id, - txParams: { from, to: tokenAddress, gas: gasLimit, gasPrice } = {}, - } = txData; - - const to = getTokenValueParam(tokenData); - const tokenAmountInDec = getTokenAddressParam(tokenData); - - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }); - + editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => { + const { id } = txData; dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - token: { - ...tokenProps, - address: tokenAddress, - }, - }), + editTransaction( + ASSET_TYPES.TOKEN, + id.toString(), + tokenData, + assetDetails, + ), ); dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); diff --git a/ui/pages/confirm-transaction/conf-tx.js b/ui/pages/confirm-transaction/conf-tx.js index 4f2028197..8990e21c5 100644 --- a/ui/pages/confirm-transaction/conf-tx.js +++ b/ui/pages/confirm-transaction/conf-tx.js @@ -12,6 +12,7 @@ import Loading from '../../components/ui/loading-screen'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; +import { getSendTo } from '../../ducks/send'; function mapStateToProps(state) { const { metamask, appState } = state; @@ -38,7 +39,7 @@ function mapStateToProps(state) { unapprovedMsgCount, unapprovedPersonalMsgCount, unapprovedTypedMessagesCount, - send: state.send, + sendTo: getSendTo(state), currentNetworkTxList: state.metamask.currentNetworkTxList, }; } @@ -68,9 +69,7 @@ class ConfirmTxScreen extends Component { history: PropTypes.object, identities: PropTypes.object, dispatch: PropTypes.func.isRequired, - send: PropTypes.shape({ - to: PropTypes.string, - }).isRequired, + sendTo: PropTypes.string, }; getUnapprovedMessagesTotal() { @@ -182,13 +181,13 @@ class ConfirmTxScreen extends Component { mostRecentOverviewPage, network, chainId, - send, + sendTo, } = this.props; const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId); if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { history.push(mostRecentOverviewPage); @@ -201,7 +200,7 @@ class ConfirmTxScreen extends Component { network, chainId, currentNetworkTxList, - send, + sendTo, history, match: { params: { id: transactionId } = {} }, mostRecentOverviewPage, @@ -241,7 +240,7 @@ class ConfirmTxScreen extends Component { if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { this.props.history.push(mostRecentOverviewPage); diff --git a/ui/pages/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirm-transaction/confirm-transaction.component.js index b88424cc7..5bc374edc 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirm-transaction/confirm-transaction.component.js @@ -35,7 +35,7 @@ export default class ConfirmTransaction extends Component { static propTypes = { history: PropTypes.object.isRequired, totalUnapprovedCount: PropTypes.number.isRequired, - send: PropTypes.object, + sendTo: PropTypes.string, setTransactionToConfirm: PropTypes.func, clearConfirmTransaction: PropTypes.func, fetchBasicGasEstimates: PropTypes.func, @@ -52,7 +52,7 @@ export default class ConfirmTransaction extends Component { componentDidMount() { const { totalUnapprovedCount = 0, - send = {}, + sendTo, history, mostRecentOverviewPage, transaction: { txParams: { data, to } = {} } = {}, @@ -64,7 +64,7 @@ export default class ConfirmTransaction extends Component { isTokenMethodAction, } = this.props; - if (!totalUnapprovedCount && !send.to) { + if (!totalUnapprovedCount && !sendTo) { history.replace(mostRecentOverviewPage); return; } diff --git a/ui/pages/confirm-transaction/confirm-transaction.container.js b/ui/pages/confirm-transaction/confirm-transaction.container.js index b04ee76fa..bf0020c64 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.container.js +++ b/ui/pages/confirm-transaction/confirm-transaction.container.js @@ -15,17 +15,18 @@ import { } from '../../store/actions'; import { unconfirmedTransactionsListSelector } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; +import { getSendTo } from '../../ducks/send'; import ConfirmTransaction from './confirm-transaction.component'; const mapStateToProps = (state, ownProps) => { const { metamask: { unapprovedTxs }, - send, } = state; const { match: { params = {} }, } = ownProps; const { id } = params; + const sendTo = getSendTo(state); const unconfirmedTransactions = unconfirmedTransactionsListSelector(state); const totalUnconfirmed = unconfirmedTransactions.length; @@ -36,7 +37,7 @@ const mapStateToProps = (state, ownProps) => { return { totalUnapprovedCount: totalUnconfirmed, - send, + sendTo, unapprovedTxs, id, mostRecentOverviewPage: getMostRecentOverviewPage(state), diff --git a/ui/pages/send/index.js b/ui/pages/send/index.js index 36fa285d4..2fc7580b7 100644 --- a/ui/pages/send/index.js +++ b/ui/pages/send/index.js @@ -1 +1 @@ -export { default } from './send.container'; +export { default } from './send'; diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.js index da7999c94..322dca677 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.js @@ -8,26 +8,28 @@ import RecipientGroup from '../../../../components/app/contact-list/recipient-gr import { ellipsify } from '../../send.utils'; import Button from '../../../../components/ui/button'; import Confusable from '../../../../components/ui/confusable'; -import { - isBurnAddress, - isValidHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; export default class AddRecipient extends Component { static propTypes = { - query: PropTypes.string, + userInput: PropTypes.string, ownedAccounts: PropTypes.array, addressBook: PropTypes.array, - updateGas: PropTypes.func, - updateSendTo: PropTypes.func, + updateRecipient: PropTypes.func, ensResolution: PropTypes.string, - toError: PropTypes.string, - toWarning: PropTypes.string, - ensResolutionError: PropTypes.string, + ensError: PropTypes.string, + ensWarning: PropTypes.string, addressBookEntryName: PropTypes.string, contacts: PropTypes.array, nonContacts: PropTypes.array, - setInternalSearch: PropTypes.func, + useMyAccountsForRecipientSearch: PropTypes.func, + useContactListForRecipientSearch: PropTypes.func, + isUsingMyAccountsForRecipientSearch: PropTypes.bool, + recipient: PropTypes.shape({ + address: PropTypes.string, + nickname: PropTypes.nickname, + error: PropTypes.string, + warning: PropTypes.string, + }), }; constructor(props) { @@ -61,60 +63,58 @@ export default class AddRecipient extends Component { metricsEvent: PropTypes.func, }; - state = { - isShowingTransfer: false, - }; - - selectRecipient = (to, nickname = '') => { - const { updateSendTo, updateGas } = this.props; - - updateSendTo(to, nickname); - updateGas({ to }); + selectRecipient = (address, nickname = '') => { + this.props.updateRecipient({ address, nickname }); }; searchForContacts = () => { - const { query, contacts } = this.props; + const { userInput, contacts } = this.props; let _contacts = contacts; - if (query) { + if (userInput) { this.contactFuse.setCollection(contacts); - _contacts = this.contactFuse.search(query); + _contacts = this.contactFuse.search(userInput); } return _contacts; }; searchForRecents = () => { - const { query, nonContacts } = this.props; + const { userInput, nonContacts } = this.props; let _nonContacts = nonContacts; - if (query) { + if (userInput) { this.recentFuse.setCollection(nonContacts); - _nonContacts = this.recentFuse.search(query); + _nonContacts = this.recentFuse.search(userInput); } return _nonContacts; }; render() { - const { ensResolution, query, addressBookEntryName } = this.props; - const { isShowingTransfer } = this.state; + const { + ensResolution, + recipient, + userInput, + addressBookEntryName, + isUsingMyAccountsForRecipientSearch, + } = this.props; let content; - if ( - !isBurnAddress(query) && - isValidHexAddress(query, { mixedCaseUseChecksum: true }) - ) { - content = this.renderExplicitAddress(query); + if (recipient.address) { + content = this.renderExplicitAddress( + recipient.address, + recipient.nickname, + ); } else if (ensResolution) { content = this.renderExplicitAddress( ensResolution, - addressBookEntryName || query, + addressBookEntryName || userInput, ); - } else if (isShowingTransfer) { + } else if (isUsingMyAccountsForRecipientSearch) { content = this.renderTransfer(); } @@ -150,15 +150,18 @@ export default class AddRecipient extends Component { renderTransfer() { let { ownedAccounts } = this.props; - const { query, setInternalSearch } = this.props; + const { + userInput, + useContactListForRecipientSearch, + isUsingMyAccountsForRecipientSearch, + } = this.props; const { t } = this.context; - const { isShowingTransfer } = this.state; - if (isShowingTransfer && query) { + if (isUsingMyAccountsForRecipientSearch && userInput) { ownedAccounts = ownedAccounts.filter( (item) => - item.name.toLowerCase().indexOf(query.toLowerCase()) > -1 || - item.address.toLowerCase().indexOf(query.toLowerCase()) > -1, + item.name.toLowerCase().indexOf(userInput.toLowerCase()) > -1 || + item.address.toLowerCase().indexOf(userInput.toLowerCase()) > -1, ); } @@ -167,10 +170,7 @@ export default class AddRecipient extends Component { @@ -219,30 +216,19 @@ export default class AddRecipient extends Component { } renderDialogs() { - const { - toError, - toWarning, - ensResolutionError, - ensResolution, - } = this.props; + const { ensError, recipient, ensWarning } = this.props; const { t } = this.context; - if (ensResolutionError) { + if (ensError || (recipient.error && recipient.error !== 'required')) { return ( - {ensResolutionError} + {t(ensError ?? recipient.error)} ); - } else if (toError && toError !== 'required' && !ensResolution) { - return ( - - {t(toError)} - - ); - } else if (toWarning) { + } else if (ensWarning || recipient.warning) { return ( - {t(toWarning)} + {t(ensWarning ?? recipient.warning)} ); } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js index ec2772f13..7c58d36fa 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js @@ -5,30 +5,24 @@ import Dialog from '../../../../components/ui/dialog'; import AddRecipient from './add-recipient.component'; const propsMethodSpies = { - closeToDropdown: sinon.spy(), - openToDropdown: sinon.spy(), - updateGas: sinon.spy(), - updateSendTo: sinon.spy(), - updateSendToError: sinon.spy(), - updateSendToWarning: sinon.spy(), + updateRecipient: sinon.spy(), + useMyAccountsForRecipientSearch: sinon.spy(), + useContactListForRecipientSearch: sinon.spy(), }; describe('AddRecipient Component', () => { let wrapper; - let instance; beforeEach(() => { wrapper = shallow( { />, { context: { t: (str) => `${str}_t` } }, ); - instance = wrapper.instance(); }); afterEach(() => { - propsMethodSpies.closeToDropdown.resetHistory(); - propsMethodSpies.openToDropdown.resetHistory(); - propsMethodSpies.updateSendTo.resetHistory(); - propsMethodSpies.updateSendToError.resetHistory(); - propsMethodSpies.updateSendToWarning.resetHistory(); - propsMethodSpies.updateGas.resetHistory(); - }); - - describe('selectRecipient', () => { - it('should call updateSendTo', () => { - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(0); - instance.selectRecipient('mockTo2', 'mockNickname'); - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.updateSendTo.getCall(0).args).toStrictEqual([ - 'mockTo2', - 'mockNickname', - ]); - }); - - it('should call updateGas if there is no to error', () => { - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(0); - instance.selectRecipient(false); - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(1); - }); + propsMethodSpies.updateRecipient.resetHistory(); + propsMethodSpies.useMyAccountsForRecipientSearch.resetHistory(); + propsMethodSpies.useContactListForRecipientSearch.resetHistory(); }); describe('render', () => { @@ -104,6 +76,7 @@ describe('AddRecipient Component', () => { it('should render transfer', () => { wrapper.setProps({ + isUsingMyAccountsForRecipientSearch: true, ownedAccounts: [ { address: '0x123', name: '123' }, { address: '0x124', name: '124' }, @@ -163,7 +136,7 @@ describe('AddRecipient Component', () => { it('should render error when query has no results', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', contacts: [], nonContacts: [], }); @@ -178,8 +151,7 @@ describe('AddRecipient Component', () => { it('should render error when query has ens does not resolve', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', - ensResolutionError: 'very bad', + ensError: 'very bad', contacts: [], nonContacts: [], }); @@ -187,20 +159,20 @@ describe('AddRecipient Component', () => { const dialog = wrapper.find(Dialog); expect(dialog.props().type).toStrictEqual('error'); - expect(dialog.props().children).toStrictEqual('very bad'); + expect(dialog.props().children).toStrictEqual('very bad_t'); expect(dialog).toHaveLength(1); }); - it('should not render error when ens resolved', () => { + it('should render error when ens resolved but ens error exists', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', ensResolution: '0x128', }); const dialog = wrapper.find(Dialog); - expect(dialog).toHaveLength(0); + expect(dialog).toHaveLength(1); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.js index c131ebb7f..27353e778 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.js @@ -1,19 +1,30 @@ import { connect } from 'react-redux'; import { - getSendEnsResolution, - getSendEnsResolutionError, accountsWithSendEtherInfoSelector, getAddressBook, getAddressBookEntry, } from '../../../../selectors'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; +import { + updateRecipient, + updateRecipientUserInput, + useMyAccountsForRecipientSearch, + useContactListForRecipientSearch, + getIsUsingMyAccountForRecipientSearch, + getRecipientUserInput, + getRecipient, +} from '../../../../ducks/send'; +import { + getEnsResolution, + getEnsError, + getEnsWarning, +} from '../../../../ducks/ens'; import AddRecipient from './add-recipient.component'; export default connect(mapStateToProps, mapDispatchToProps)(AddRecipient); function mapStateToProps(state) { - const ensResolution = getSendEnsResolution(state); + const ensResolution = getEnsResolution(state); let addressBookEntryName = ''; if (ensResolution) { @@ -32,14 +43,27 @@ function mapStateToProps(state) { addressBookEntryName, contacts: addressBook.filter(({ name }) => Boolean(name)), ensResolution, - ensResolutionError: getSendEnsResolutionError(state), + ensError: getEnsError(state), + ensWarning: getEnsWarning(state), nonContacts: addressBook.filter(({ name }) => !name), ownedAccounts, + isUsingMyAccountsForRecipientSearch: getIsUsingMyAccountForRecipientSearch( + state, + ), + userInput: getRecipientUserInput(state), + recipient: getRecipient(state), }; } function mapDispatchToProps(dispatch) { return { - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), + updateRecipient: ({ address, nickname }) => + dispatch(updateRecipient({ address, nickname })), + updateRecipientUserInput: (newInput) => + dispatch(updateRecipientUserInput(newInput)), + useMyAccountsForRecipientSearch: () => + dispatch(useMyAccountsForRecipientSearch()), + useContactListForRecipientSearch: () => + dispatch(useContactListForRecipientSearch()), }; } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js index 1d8e05bdc..81db6cf28 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js @@ -1,6 +1,3 @@ -import sinon from 'sinon'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; - let mapStateToProps; let mapDispatchToProps; @@ -13,8 +10,6 @@ jest.mock('react-redux', () => ({ })); jest.mock('../../../../selectors', () => ({ - getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`, - getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`, getAddressBook: (s) => [{ name: `mockAddressBook:${s}` }], getAddressBookEntry: (s) => `mockAddressBookEntry:${s}`, accountsWithSendEtherInfoSelector: () => [ @@ -23,8 +18,26 @@ jest.mock('../../../../selectors', () => ({ ], })); -jest.mock('../../../../ducks/send/send.duck.js', () => ({ - updateSendTo: jest.fn(), +jest.mock('../../../../ducks/ens', () => ({ + getEnsResolution: (s) => `mockSendEnsResolution:${s}`, + getEnsError: (s) => `mockSendEnsResolutionError:${s}`, + getEnsWarning: (s) => `mockSendEnsResolutionWarning:${s}`, + useMyAccountsForRecipientSearch: (s) => + `useMyAccountsForRecipientSearch:${s}`, +})); + +jest.mock('../../../../ducks/send', () => ({ + updateRecipient: ({ address, nickname }) => + `{mockUpdateRecipient: {address: ${address}, nickname: ${nickname}}}`, + updateRecipientUserInput: (s) => `mockUpdateRecipientUserInput:${s}`, + useMyAccountsForRecipientSearch: (s) => + `mockUseMyAccountsForRecipientSearch:${s}`, + useContactListForRecipientSearch: (s) => + `mockUseContactListForRecipientSearch:${s}`, + getIsUsingMyAccountForRecipientSearch: (s) => + `mockGetIsUsingMyAccountForRecipientSearch:${s}`, + getRecipientUserInput: (s) => `mockRecipientUserInput:${s}`, + getRecipient: (s) => `mockRecipient:${s}`, })); require('./add-recipient.container.js'); @@ -34,29 +47,40 @@ describe('add-recipient container', () => { it('should map the correct properties to props', () => { expect(mapStateToProps('mockState')).toStrictEqual({ addressBook: [{ name: 'mockAddressBook:mockState' }], + addressBookEntryName: undefined, contacts: [{ name: 'mockAddressBook:mockState' }], ensResolution: 'mockSendEnsResolution:mockState', - ensResolutionError: 'mockSendEnsResolutionError:mockState', - ownedAccounts: [ - { name: `account1:mockState` }, - { name: `account2:mockState` }, - ], - addressBookEntryName: undefined, + ensError: 'mockSendEnsResolutionError:mockState', + ensWarning: 'mockSendEnsResolutionWarning:mockState', nonContacts: [], + ownedAccounts: [ + { name: 'account1:mockState' }, + { name: 'account2:mockState' }, + ], + isUsingMyAccountsForRecipientSearch: + 'mockGetIsUsingMyAccountForRecipientSearch:mockState', + userInput: 'mockRecipientUserInput:mockState', + recipient: 'mockRecipient:mockState', }); }); }); describe('mapDispatchToProps()', () => { - describe('updateSendTo()', () => { - const dispatchSpy = sinon.spy(); + describe('updateRecipient()', () => { + const dispatchSpy = jest.fn(); + const mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendTo).toHaveBeenCalled(); - expect(updateSendTo).toHaveBeenCalledWith('mockTo', 'mockNickname'); + mapDispatchToPropsObject.updateRecipient({ + address: 'mockAddress', + nickname: 'mockNickname', + }); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy.mock.calls[0][0]).toStrictEqual( + '{mockUpdateRecipient: {address: mockAddress, nickname: mockNickname}}', + ); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.js b/ui/pages/send/send-content/add-recipient/add-recipient.js deleted file mode 100644 index 5141fda1d..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.js +++ /dev/null @@ -1,56 +0,0 @@ -import contractMap from '@metamask/contract-metadata'; -import { isConfusing } from 'unicode-confusables'; -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; - -import { - checkExistingAddresses, - isValidDomainName, - isOriginContractAddress, - isDefaultMetaMaskChain, -} from '../../../../helpers/utils/util'; -import { - isBurnAddress, - isValidHexAddress, - toChecksumHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; - -export function getToErrorObject(to, sendTokenAddress, chainId) { - let toError = null; - if (!to) { - toError = REQUIRED_ERROR; - } else if ( - isBurnAddress(to) || - (!isValidHexAddress(to, { mixedCaseUseChecksum: true }) && - !isValidDomainName(to)) - ) { - toError = isDefaultMetaMaskChain(chainId) - ? INVALID_RECIPIENT_ADDRESS_ERROR - : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; - } else if (isOriginContractAddress(to, sendTokenAddress)) { - toError = CONTRACT_ADDRESS_ERROR; - } - - return { to: toError }; -} - -export function getToWarningObject(to, tokens = [], sendToken = null) { - let toWarning = null; - if ( - sendToken && - (toChecksumHexAddress(to) in contractMap || - checkExistingAddresses(to, tokens)) - ) { - toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR; - } else if (isValidDomainName(to) && isConfusing(to)) { - toWarning = CONFUSING_ENS_ERROR; - } - - return { to: toWarning }; -} diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js deleted file mode 100644 index 4a9605d32..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; -import { getToErrorObject, getToWarningObject } from './add-recipient'; - -jest.mock('../../../../helpers/utils/util', () => ({ - isDefaultMetaMaskChain: jest.fn().mockReturnValue(true), - isEthNetwork: jest.fn().mockReturnValue(true), - checkExistingAddresses: jest.fn().mockReturnValue(true), - isValidDomainName: jest.requireActual('../../../../helpers/utils/util') - .isValidDomainName, - isOriginContractAddress: jest.requireActual('../../../../helpers/utils/util') - .isOriginContractAddress, -})); - -jest.mock('../../../../../shared/modules/hexstring-utils', () => ({ - isValidHexAddress: jest.fn((to) => - Boolean(to.match(/^[0xabcdef123456798]+$/u)), - ), - isBurnAddress: jest.fn(() => false), - toChecksumHexAddress: jest.fn((input) => input), -})); - -describe('add-recipient utils', () => { - describe('getToErrorObject()', () => { - it('should return a required error if "to" is falsy', () => { - expect(getToErrorObject(null)).toStrictEqual({ - to: REQUIRED_ERROR, - }); - }); - - it('should return an invalid recipient error if "to" is truthy but invalid', () => { - expect(getToErrorObject('mockInvalidTo')).toStrictEqual({ - to: INVALID_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should return null if "to" is truthy and valid', () => { - expect(getToErrorObject('0xabc123')).toStrictEqual({ - to: null, - }); - }); - - it('should return a contract address error if the recipient is the same as the tokens contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc123')).toStrictEqual({ - to: CONTRACT_ADDRESS_ERROR, - }); - }); - - it('should return null if the recipient address is not the token contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc456')).toStrictEqual({ - to: null, - }); - }); - }); - - describe('getToWarningObject()', () => { - it('should return a known address recipient error if "to" is a token address', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }], { - address: '0xabc123', - }), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should null if "to" is a token address but sendToken is falsy', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }]), - ).toStrictEqual({ - to: null, - }); - }); - - it('should return a known address recipient error if "to" is part of contract metadata', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - it('should null if "to" is part of contract metadata but sendToken is falsy', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should warn if name is a valid domain and confusable', () => { - expect(getToWarningObject('demo.eth')).toStrictEqual({ - to: CONFUSING_ENS_ERROR, - }); - }); - - it('should not warn if name is a valid domain and not confusable', () => { - expect(getToWarningObject('vitalik.eth')).toStrictEqual({ - to: null, - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/add-recipient/ens-input.component.js b/ui/pages/send/send-content/add-recipient/ens-input.component.js index 658ac9bde..bb1c7f3e7 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.component.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.component.js @@ -2,146 +2,39 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { debounce } from 'lodash'; -import copyToClipboard from 'copy-to-clipboard/index'; -import ENS from 'ethjs-ens'; -import networkMap from 'ethereum-ens-network-map'; -import log from 'loglevel'; -import { isHexString } from 'ethereumjs-util'; import { ellipsify } from '../../send.utils'; import { isValidDomainName } from '../../../../helpers/utils/util'; -import { MAINNET_NETWORK_ID } from '../../../../../shared/constants/network'; import { isBurnAddress, isValidHexAddress, } from '../../../../../shared/modules/hexstring-utils'; -// Local Constants -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -const ZERO_X_ERROR_ADDRESS = '0x'; - export default class EnsInput extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, }; static propTypes = { className: PropTypes.string, - network: PropTypes.string, selectedAddress: PropTypes.string, selectedName: PropTypes.string, - onChange: PropTypes.func, - updateEnsResolution: PropTypes.func, scanQrCode: PropTypes.func, - updateEnsResolutionError: PropTypes.func, onPaste: PropTypes.func, - onReset: PropTypes.func, onValidAddressTyped: PropTypes.func, - contact: PropTypes.object, - value: PropTypes.string, internalSearch: PropTypes.bool, - }; - - state = { - input: '', - toError: null, - ensResolution: undefined, + userInput: PropTypes.string, + onChange: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + lookupEnsName: PropTypes.func.isRequired, + initializeEnsSlice: PropTypes.func.isRequired, + resetEnsResolution: PropTypes.func.isRequired, }; componentDidMount() { - const { network, internalSearch } = this.props; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ ensResolution: ZERO_ADDRESS }); - - if (networkHasEnsSupport && !internalSearch) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - } + this.props.initializeEnsSlice(); } - componentDidUpdate(prevProps) { - const { input } = this.state; - const { network, value, internalSearch } = this.props; - - let newValue; - - // Set the value of our input based on QR code provided by parent - const newProvidedValue = input !== value && prevProps.value !== value; - if (newProvidedValue) { - newValue = value; - } - - if (prevProps.network !== network) { - if (getNetworkEnsSupport(network)) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - if (!newProvidedValue) { - newValue = input; - } - } else { - // ens is null on mount on a network that does not have ens support - // this is intended to prevent accidental lookup of domains across - // networks - this.ens = null; - this.checkName = null; - } - } - - if (newValue !== undefined) { - this.onChange({ target: { value: newValue } }); - } - if (!internalSearch && prevProps.internalSearch) { - this.resetInput(); - } - } - - resetInput = () => { - const { - updateEnsResolution, - updateEnsResolutionError, - onReset, - } = this.props; - this.onChange({ target: { value: '' } }); - onReset(); - updateEnsResolution(''); - updateEnsResolutionError(''); - }; - - lookupEnsName = (ensName) => { - const { network } = this.props; - const recipient = ensName.trim(); - - log.info(`ENS attempting to resolve name: ${recipient}`); - this.ens - .lookup(recipient) - .then((address) => { - if (address === ZERO_ADDRESS) { - throw new Error(this.context.t('noAddressForName')); - } - if (address === ZERO_X_ERROR_ADDRESS) { - throw new Error(this.context.t('ensRegistrationError')); - } - this.props.updateEnsResolution(address); - }) - .catch((reason) => { - if ( - isValidDomainName(recipient) && - reason.message === 'ENS name not defined.' - ) { - this.props.updateEnsResolutionError( - network === MAINNET_NETWORK_ID - ? this.context.t('noAddressForName') - : this.context.t('ensNotFoundOnCurrentNetwork'), - ); - } else { - log.error(reason); - this.props.updateEnsResolutionError(reason.message); - } - }); - }; - onPaste = (event) => { event.clipboardData.items[0].getAsString((text) => { if ( @@ -155,40 +48,23 @@ export default class EnsInput extends Component { onChange = (e) => { const { - network, - onChange, - updateEnsResolution, - updateEnsResolutionError, onValidAddressTyped, internalSearch, + onChange, + lookupEnsName, + resetEnsResolution, } = this.props; const input = e.target.value; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ input }, () => onChange(input)); + onChange(input); if (internalSearch) { return null; } // Empty ENS state if input is empty // maybe scan ENS - if ( - !networkHasEnsSupport && - !( - isBurnAddress(input) === false && - isValidHexAddress(input, { mixedCaseUseChecksum: true }) - ) && - !isHexString(input) - ) { - updateEnsResolution(''); - updateEnsResolutionError( - networkHasEnsSupport ? '' : 'Network does not support ENS', - ); - return null; - } - if (isValidDomainName(input)) { - this.lookupEnsName(input); + lookupEnsName(input); } else if ( onValidAddressTyped && !isBurnAddress(input) && @@ -196,20 +72,16 @@ export default class EnsInput extends Component { ) { onValidAddressTyped(input); } else { - updateEnsResolution(''); - updateEnsResolutionError(''); + resetEnsResolution(); } return null; }; render() { const { t } = this.context; - const { className, selectedAddress } = this.props; - const { input } = this.state; + const { className, selectedAddress, selectedName, userInput } = this.props; - if (selectedAddress) { - return this.renderSelected(); - } + const hasSelectedAddress = Boolean(selectedAddress); return (
@@ -217,135 +89,61 @@ export default class EnsInput extends Component { className={classnames('ens-input__wrapper', { 'ens-input__wrapper__status-icon--error': false, 'ens-input__wrapper__status-icon--valid': false, + 'ens-input__wrapper--valid': hasSelectedAddress, })} > -
- -
-
- ); - } - - renderSelected() { - const { t } = this.context; - const { - className, - selectedAddress, - selectedName, - contact = {}, - } = this.props; - const name = contact.name || selectedName; - - return ( -
-
-
-
-
- {name || ellipsify(selectedAddress)} -
- {name && ( -
- {selectedAddress} + {hasSelectedAddress ? ( + <> +
+
+ {selectedName || ellipsify(selectedAddress)} +
+ {selectedName && ( +
+ {selectedAddress} +
+ )}
- )} -
-
+
+ + ) : ( + <> + +
); } - - ensIcon(recipient) { - const { hoverText } = this.state; - - return ( - - {this.ensIconContents(recipient)} - - ); - } - - ensIconContents() { - const { loadingEns, ensFailure, ensResolution, toError } = this.state; - - if (toError) { - return null; - } - - if (loadingEns) { - return ( - - ); - } - - if (ensFailure) { - return ; - } - - if (ensResolution && ensResolution !== ZERO_ADDRESS) { - return ( - { - event.preventDefault(); - event.stopPropagation(); - copyToClipboard(ensResolution); - }} - /> - ); - } - - return null; - } -} - -function getNetworkEnsSupport(network) { - return Boolean(networkMap[network]); } diff --git a/ui/pages/send/send-content/add-recipient/ens-input.container.js b/ui/pages/send/send-content/add-recipient/ens-input.container.js index 90d2c3ff4..ef61fce85 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.container.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.container.js @@ -1,20 +1,18 @@ +import { debounce } from 'lodash'; import { connect } from 'react-redux'; -import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network'; import { - getSendTo, - getSendToNickname, - getAddressBookEntry, - getCurrentChainId, -} from '../../../../selectors'; + lookupEnsName, + initializeEnsSlice, + resetResolution, +} from '../../../../ducks/ens'; import EnsInput from './ens-input.component'; -export default connect((state) => { - const selectedAddress = getSendTo(state); - const chainId = getCurrentChainId(state); +function mapDispatchToProps(dispatch) { return { - network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], - selectedAddress, - selectedName: getSendToNickname(state), - contact: getAddressBookEntry(state, selectedAddress), + lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150), + initializeEnsSlice: () => dispatch(initializeEnsSlice()), + resetEnsResolution: debounce(() => dispatch(resetResolution()), 300), }; -})(EnsInput); +} + +export default connect(null, mapDispatchToProps)(EnsInput); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js deleted file mode 100644 index 223e6da91..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ /dev/null @@ -1,81 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; - -export default class AmountMaxButton extends Component { - static propTypes = { - balance: PropTypes.string, - buttonDataLoading: PropTypes.bool, - clearMaxAmount: PropTypes.func, - inError: PropTypes.bool, - gasTotal: PropTypes.string, - maxModeOn: PropTypes.bool, - sendToken: PropTypes.object, - setAmountToMax: PropTypes.func, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - }; - - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - }; - - setMaxAmount() { - const { - balance, - gasTotal, - sendToken, - setAmountToMax, - tokenBalance, - } = this.props; - - setAmountToMax({ - balance, - gasTotal, - sendToken, - tokenBalance, - }); - } - - onMaxClick = () => { - const { setMaxModeTo, clearMaxAmount, maxModeOn } = this.props; - const { metricsEvent } = this.context; - - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Clicked "Amount Max"', - }, - }); - if (maxModeOn) { - setMaxModeTo(false); - clearMaxAmount(); - } else { - setMaxModeTo(true); - this.setMaxAmount(); - } - }; - - render() { - const { maxModeOn, buttonDataLoading, inError } = this.props; - - return ( - - ); - } -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js deleted file mode 100644 index 8c38d3be5..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import AmountMaxButton from './amount-max-button.component'; - -describe('AmountMaxButton Component', () => { - let wrapper; - let instance; - - const propsMethodSpies = { - setAmountToMax: sinon.spy(), - setMaxModeTo: sinon.spy(), - }; - - const MOCK_EVENT = { preventDefault: () => undefined }; - - beforeAll(() => { - sinon.spy(AmountMaxButton.prototype, 'setMaxAmount'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { - context: { - t: (str) => `${str}_t`, - metricsEvent: () => undefined, - }, - }, - ); - instance = wrapper.instance(); - }); - - afterEach(() => { - propsMethodSpies.setAmountToMax.resetHistory(); - propsMethodSpies.setMaxModeTo.resetHistory(); - AmountMaxButton.prototype.setMaxAmount.resetHistory(); - }); - - afterAll(() => { - sinon.restore(); - }); - - describe('setMaxAmount', () => { - it('should call setAmountToMax with the correct params', () => { - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(0); - instance.setMaxAmount(); - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(1); - expect(propsMethodSpies.setAmountToMax.getCall(0).args).toStrictEqual([ - { - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }, - ]); - }); - }); - - describe('render', () => { - it('should render an element with a send-v2__amount-max class', () => { - expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1); - }); - - it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => { - const { onClick } = wrapper.find('.send-v2__amount-max').props(); - - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(0); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(0); - onClick(MOCK_EVENT); - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.getCall(0).args).toStrictEqual([ - true, - ]); - }); - - it('should render the expected text when maxModeOn is false', () => { - wrapper.setProps({ maxModeOn: false }); - expect(wrapper.find('.send-v2__amount-max').text()).toStrictEqual( - 'max_t', - ); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js deleted file mode 100644 index a2fe64b94..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ /dev/null @@ -1,42 +0,0 @@ -import { connect } from 'react-redux'; -import { - getGasTotal, - getSendToken, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getBasicGasEstimateLoadingStatus, -} from '../../../../../selectors'; -import { - updateSendErrors, - updateSendAmount, - setMaxModeTo, -} from '../../../../../ducks/send/send.duck'; -import { calcMaxAmount } from './amount-max-button.utils'; -import AmountMaxButton from './amount-max-button.component'; - -export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton); - -function mapStateToProps(state) { - return { - balance: getSendFromBalance(state), - buttonDataLoading: getBasicGasEstimateLoadingStatus(state), - gasTotal: getGasTotal(state), - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, - clearMaxAmount: () => { - dispatch(updateSendAmount('0')); - }, - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), - }; -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js deleted file mode 100644 index cb86c88ff..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../../ducks/send/send.duck'; - -let mapStateToProps; -let mapDispatchToProps; - -jest.mock('react-redux', () => ({ - connect: (ms, md) => { - mapStateToProps = ms; - mapDispatchToProps = md; - return () => ({}); - }, -})); - -jest.mock('../../../../../selectors', () => ({ - getGasTotal: (s) => `mockGasTotal:${s}`, - getSendToken: (s) => `mockSendToken:${s}`, - getSendFromBalance: (s) => `mockBalance:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, - getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`, -})); - -jest.mock('./amount-max-button.utils.js', () => ({ - calcMaxAmount: (mockObj) => mockObj.val + 1, -})); - -jest.mock('../../../../../ducks/send/send.duck', () => ({ - setMaxModeTo: jest.fn(), - updateSendAmount: jest.fn(), - updateSendErrors: jest.fn(), -})); - -require('./amount-max-button.container.js'); - -describe('amount-max-button container', () => { - describe('mapStateToProps()', () => { - it('should map the correct properties to props', () => { - expect(mapStateToProps('mockState')).toStrictEqual({ - balance: 'mockBalance:mockState', - buttonDataLoading: 'mockButtonDataLoading:mockState', - gasTotal: 'mockGasTotal:mockState', - maxModeOn: 'mockMaxModeOn:mockState', - sendToken: 'mockSendToken:mockState', - tokenBalance: 'mockTokenBalance:mockState', - }); - }); - }); - - describe('mapDispatchToProps()', () => { - let dispatchSpy; - let mapDispatchToPropsObject; - - beforeEach(() => { - dispatchSpy = sinon.spy(); - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); - }); - - describe('setAmountToMax()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }); - expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ amount: null }); - expect(updateSendAmount).toHaveBeenCalled(); - expect(updateSendAmount).toHaveBeenCalledWith(12); - }); - }); - - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockVal'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalledWith('mockVal'); - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js new file mode 100644 index 000000000..7f143879b --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js @@ -0,0 +1,49 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { getBasicGasEstimateLoadingStatus } from '../../../../../selectors'; +import { + getSendMaxModeState, + isSendFormInvalid, + toggleSendMaxMode, +} from '../../../../../ducks/send'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useMetricEvent } from '../../../../../hooks/useMetricEvent'; + +export default function AmountMaxButton() { + const buttonDataLoading = useSelector(getBasicGasEstimateLoadingStatus); + const isDraftTransactionInvalid = useSelector(isSendFormInvalid); + const maxModeOn = useSelector(getSendMaxModeState); + const dispatch = useDispatch(); + const trackClickedMax = useMetricEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Clicked "Amount Max"', + }, + }); + const t = useI18nContext(); + + const onMaxClick = () => { + trackClickedMax(); + dispatch(toggleSendMaxMode()); + }; + + return ( + + ); +} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js new file mode 100644 index 000000000..7f4482517 --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; +import { renderWithProvider } from '../../../../../../test/jest'; +import AmountMaxButton from './amount-max-button'; + +const middleware = [thunk]; + +describe('AmountMaxButton Component', () => { + describe('render', () => { + it('should render a "Max" button', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + }), + ); + expect(getByText('Max')).toBeTruthy(); + }); + + it('should dispatch action to set mode to MAX', () => { + const store = configureMockStore(middleware)({ + send: { ...initialState, status: SEND_STATUSES.VALID }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'MAX' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + + it('should dispatch action to set amount mode to INPUT', () => { + const store = configureMockStore(middleware)({ + send: { + ...initialState, + status: SEND_STATUSES.VALID, + amount: { ...initialState.amount, mode: 'MAX' }, + }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'INPUT' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + }); +}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js deleted file mode 100644 index 6826b5e39..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - multiplyCurrencies, - subtractCurrencies, -} from '../../../../../helpers/utils/conversion-util'; -import { addHexPrefix } from '../../../../../../app/scripts/lib/util'; - -export function calcMaxAmount({ balance, gasTotal, sendToken, tokenBalance }) { - const { decimals } = sendToken || {}; - const multiplier = Math.pow(10, Number(decimals || 0)); - - return sendToken - ? multiplyCurrencies(tokenBalance, multiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }) - : subtractCurrencies(addHexPrefix(balance), addHexPrefix(gasTotal), { - toNumericBase: 'hex', - aBase: 16, - bBase: 16, - }); -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js deleted file mode 100644 index 87b334386..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { calcMaxAmount } from './amount-max-button.utils'; - -describe('amount-max-button utils', () => { - describe('calcMaxAmount()', () => { - it('should calculate the correct amount when no sendToken defined', () => { - expect( - calcMaxAmount({ - balance: 'ffffff', - gasTotal: 'ff', - sendToken: false, - }), - ).toStrictEqual('ffff00'); - }); - - it('should calculate the correct amount when a sendToken is defined', () => { - expect( - calcMaxAmount({ - sendToken: { - decimals: 10, - }, - tokenBalance: '64', - }), - ).toStrictEqual('e8d4a51000'); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js index 26d87ffb5..16657e95d 100644 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js @@ -1 +1 @@ -export { default } from './amount-max-button.container'; +export { default } from './amount-max-button'; diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js index 3f3d64e45..7cf67fdd1 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js @@ -1,111 +1,35 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; import SendRowWrapper from '../send-row-wrapper'; import UserPreferencedCurrencyInput from '../../../../components/app/user-preferenced-currency-input'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import AmountMaxButton from './amount-max-button'; export default class SendAmountRow extends Component { static propTypes = { amount: PropTypes.string, - balance: PropTypes.string, - conversionRate: PropTypes.number, - gasTotal: PropTypes.string, inError: PropTypes.bool, - primaryCurrency: PropTypes.string, - sendToken: PropTypes.object, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - updateGasFeeError: PropTypes.func, + asset: PropTypes.object, updateSendAmount: PropTypes.func, - updateSendAmountError: PropTypes.func, - updateGas: PropTypes.func, - maxModeOn: PropTypes.bool, }; static contextTypes = { t: PropTypes.func, }; - componentDidUpdate(prevProps) { - const { maxModeOn: prevMaxModeOn, gasTotal: prevGasTotal } = prevProps; - const { maxModeOn, amount, gasTotal, sendToken } = this.props; - - if (maxModeOn && sendToken && !prevMaxModeOn) { - this.updateGas(amount); - } - - if (prevGasTotal !== gasTotal) { - this.validateAmount(amount); - } - } - - updateGas = debounce(this.updateGas.bind(this), 500); - - validateAmount(amount) { - const { - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - updateGasFeeError, - updateSendAmountError, - } = this.props; - - updateSendAmountError({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - - if (sendToken) { - updateGasFeeError({ - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - } - } - - updateAmount(amount) { - const { updateSendAmount, setMaxModeTo } = this.props; - - setMaxModeTo(false); - updateSendAmount(amount); - } - - updateGas(amount) { - const { sendToken, updateGas } = this.props; - - if (sendToken) { - updateGas({ amount }); - } - } - handleChange = (newAmount) => { - this.validateAmount(newAmount); - this.updateGas(newAmount); - this.updateAmount(newAmount); + this.props.updateSendAmount(newAmount); }; renderInput() { - const { amount, inError, sendToken } = this.props; + const { amount, inError, asset } = this.props; - return sendToken ? ( + return asset.type === ASSET_TYPES.TOKEN ? ( ) : ( @@ -118,7 +42,7 @@ export default class SendAmountRow extends Component { } render() { - const { gasTotal, inError } = this.props; + const { inError } = this.props; return ( - {gasTotal && } + {this.renderInput()} ); diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js index 8ed1a7438..c6e8e23be 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js @@ -3,88 +3,13 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; -import AmountMaxButton from './amount-max-button/amount-max-button.container'; +import AmountMaxButton from './amount-max-button/amount-max-button'; describe('SendAmountRow Component', () => { - describe('validateAmount', () => { - it('should call updateSendAmountError with the correct params', () => { - const { - instance, - propsMethodSpies: { updateSendAmountError }, - } = shallowRenderSendAmountRow(); - - expect(updateSendAmountError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateSendAmountError.calledOnceWithExactly({ - amount: 'someAmount', - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call updateGasFeeError if sendToken is truthy', () => { - const { - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateGasFeeError.calledOnceWithExactly({ - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call not updateGasFeeError if sendToken is falsey', () => { - const { - wrapper, - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - wrapper.setProps({ sendToken: null }); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - }); - }); - describe('updateAmount', () => { - it('should call setMaxModeTo', () => { - const { - instance, - propsMethodSpies: { setMaxModeTo }, - } = shallowRenderSendAmountRow(); - - expect(setMaxModeTo.callCount).toStrictEqual(0); - - instance.updateAmount('someAmount'); - - expect(setMaxModeTo.calledOnceWithExactly(false)).toStrictEqual(true); - }); - it('should call updateSendAmount', () => { const { instance, @@ -93,7 +18,7 @@ describe('SendAmountRow Component', () => { expect(updateSendAmount.callCount).toStrictEqual(0); - instance.updateAmount('someAmount'); + instance.handleChange('someAmount'); expect( updateSendAmount.calledOnceWithExactly('someAmount'), @@ -136,10 +61,7 @@ describe('SendAmountRow Component', () => { }); it('should render the UserPreferencedTokenInput with the correct props', () => { - const { - wrapper, - instanceSpies: { updateGas, updateAmount, validateAmount }, - } = shallowRenderSendAmountRow(); + const { wrapper } = shallowRenderSendAmountRow(); const { onChange, error, value } = wrapper .find(SendRowWrapper) .childAt(1) @@ -147,67 +69,34 @@ describe('SendAmountRow Component', () => { expect(error).toStrictEqual(false); expect(value).toStrictEqual('mockAmount'); - expect(updateGas.callCount).toStrictEqual(0); - expect(updateAmount.callCount).toStrictEqual(0); - expect(validateAmount.callCount).toStrictEqual(0); onChange('mockNewAmount'); - - expect(updateGas.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect(updateAmount.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect( - validateAmount.calledOnceWithExactly('mockNewAmount'), - ).toStrictEqual(true); }); }); }); function shallowRenderSendAmountRow() { - const setMaxModeTo = sinon.spy(); - const updateGasFeeError = sinon.spy(); const updateSendAmount = sinon.spy(); - const updateSendAmountError = sinon.spy(); const wrapper = shallow( undefined} />, { context: { t: (str) => `${str}_t` } }, ); const instance = wrapper.instance(); - const updateAmount = sinon.spy(instance, 'updateAmount'); - const updateGas = sinon.spy(instance, 'updateGas'); - const validateAmount = sinon.spy(instance, 'validateAmount'); return { instance, wrapper, propsMethodSpies: { - setMaxModeTo, - updateGasFeeError, updateSendAmount, - updateSendAmountError, - }, - instanceSpies: { - updateAmount, - updateGas, - validateAmount, }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js index ea76e87a4..261c91168 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js @@ -1,21 +1,10 @@ import { connect } from 'react-redux'; import { - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - sendAmountIsInError, -} from '../../../../selectors'; -import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils'; -import { - updateSendErrors, - setMaxModeTo, updateSendAmount, -} from '../../../../ducks/send/send.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; + getSendAmount, + sendAmountIsInError, + getSendAsset, +} from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); @@ -23,26 +12,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); function mapStateToProps(state) { return { amount: getSendAmount(state), - balance: getSendFromBalance(state), - conversionRate: getConversionRate(state), - gasTotal: getGasTotal(state), inError: sendAmountIsInError(state), - primaryCurrency: getPrimaryCurrency(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - maxModeOn: getSendMaxModeState(state), + asset: getSendAsset(state), }; } function mapDispatchToProps(dispatch) { return { - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), updateSendAmount: (newAmount) => dispatch(updateSendAmount(newAmount)), - updateGasFeeError: (amountDataObject) => { - dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))); - }, - updateSendAmountError: (amountDataObject) => { - dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))); - }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js index edad05014..4911cb612 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js @@ -1,10 +1,6 @@ import sinon from 'sinon'; -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; +import { updateSendAmount } from '../../../../ducks/send'; let mapDispatchToProps; @@ -15,24 +11,7 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors/send.js', () => ({ - sendAmountIsInError: (s) => `mockInError:${s}`, -})); - -jest.mock('../../send.utils', () => ({ - getAmountErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockChange: true, - }), - getGasFeeErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockGasFeeErrorChange: true, - }), -})); - -jest.mock('../../../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - setMaxModeTo: jest.fn(), +jest.mock('../../../../ducks/send', () => ({ updateSendAmount: jest.fn(), })); @@ -48,15 +27,6 @@ describe('send-amount-row container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); }); - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockBool'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalled(); - expect(setMaxModeTo).toHaveBeenCalledWith('mockBool'); - }); - }); - describe('updateSendAmount()', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.updateSendAmount('mockAmount'); @@ -65,29 +35,5 @@ describe('send-amount-row container', () => { expect(updateSendAmount).toHaveBeenCalledWith('mockAmount'); }); }); - - describe('updateGasFeeError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockGasFeeErrorChange: true, - }); - }); - }); - - describe('updateSendAmountError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockChange: true, - }); - }); - }); }); }); diff --git a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js index 785b711b0..e6c1d9bb9 100644 --- a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js +++ b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js @@ -5,6 +5,7 @@ import Identicon from '../../../../components/ui/identicon/identicon.component'; import TokenBalance from '../../../../components/ui/token-balance'; import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display'; import { ERC20, PRIMARY } from '../../../../helpers/constants/common'; +import { ASSET_TYPES } from '../../../../ducks/send'; export default class SendAssetRow extends Component { static propTypes = { @@ -18,13 +19,10 @@ export default class SendAssetRow extends Component { accounts: PropTypes.object.isRequired, assetImages: PropTypes.object, selectedAddress: PropTypes.string.isRequired, - sendTokenAddress: PropTypes.string, - setSendToken: PropTypes.func.isRequired, + sendAssetAddress: PropTypes.string, + updateSendAsset: PropTypes.func.isRequired, nativeCurrency: PropTypes.string, nativeCurrencyImage: PropTypes.string, - setUnsendableAssetError: PropTypes.func.isRequired, - updateSendErrors: PropTypes.func.isRequired, - updateTokenType: PropTypes.func.isRequired, }; static contextTypes = { @@ -46,29 +44,7 @@ export default class SendAssetRow extends Component { closeDropdown = () => this.setState({ isShowingDropdown: false }); - clearUnsendableAssetError = () => { - this.props.setUnsendableAssetError(false); - this.props.updateSendErrors({ - unsendableAssetError: null, - gasLoadingError: null, - }); - }; - - selectToken = async (token) => { - if (token && token.isERC721 === undefined) { - const updatedToken = await this.props.updateTokenType(token.address); - if (updatedToken.isERC721) { - this.props.setUnsendableAssetError(true); - this.props.updateSendErrors({ - unsendableAssetError: 'unsendableAssetError', - }); - } - } - - if ((token && token.isERC721 === false) || token === undefined) { - this.clearUnsendableAssetError(); - } - + selectToken = (type, token) => { this.setState( { isShowingDropdown: false, @@ -84,7 +60,10 @@ export default class SendAssetRow extends Component { assetSelected: token ? ERC20 : this.props.nativeCurrency, }, }); - this.props.setSendToken(token); + this.props.updateSendAsset({ + type, + details: type === ASSET_TYPES.NATIVE ? null : token, + }); }, ); }; @@ -105,9 +84,9 @@ export default class SendAssetRow extends Component { } renderSendToken() { - const { sendTokenAddress } = this.props; + const { sendAssetAddress } = this.props; const token = this.props.tokens.find( - ({ address }) => address === sendTokenAddress, + ({ address }) => address === sendAssetAddress, ); return (
this.selectToken()} + onClick={() => this.selectToken(ASSET_TYPES.NATIVE)} >
this.selectToken(token)} + onClick={() => this.selectToken(ASSET_TYPES.TOKEN, token)} >
dispatch(updateSendToken(token)), - updateTokenType: (tokenAddress) => dispatch(updateTokenType(tokenAddress)), - updateSendErrors: (error) => { - dispatch(updateSendErrors(error)); - }, + updateSendAsset: ({ type, details }) => + dispatch(updateSendAsset({ type, details })), }; } diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 96d728024..fc27aff25 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -18,12 +18,8 @@ export default class SendContent extends Component { t: PropTypes.func, }; - state = { - unsendableAssetError: false, - }; - static propTypes = { - updateGas: PropTypes.func, + isAssetSendable: PropTypes.bool, showAddToAddressBookModal: PropTypes.func, showHexData: PropTypes.bool, contact: PropTypes.object, @@ -35,11 +31,6 @@ export default class SendContent extends Component { noGasPrice: PropTypes.bool, }; - updateGas = (updateData) => this.props.updateGas(updateData); - - setUnsendableAssetError = (unsendableAssetError) => - this.setState({ unsendableAssetError }); - render() { const { warning, @@ -47,9 +38,9 @@ export default class SendContent extends Component { gasIsExcessive, isEthGasPrice, noGasPrice, + isAssetSendable, } = this.props; - const { unsendableAssetError } = this.state; let gasError; if (gasIsExcessive) gasError = GAS_PRICE_EXCESSIVE_ERROR_KEY; else if (noGasPrice) gasError = GAS_PRICE_FETCH_FAILURE_ERROR_KEY; @@ -59,18 +50,15 @@ export default class SendContent extends Component {
{gasError && this.renderError(gasError)} {isEthGasPrice && this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)} - {unsendableAssetError && this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} + {isAssetSendable === false && + this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} {error && this.renderError(error)} {warning && this.renderWarning()} {this.maybeRenderAddContact()} - - + + - {this.props.showHexData && ( - - )} + {this.props.showHexData && }
); diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index 3c99b3237..be623f937 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -1,12 +1,13 @@ import { connect } from 'react-redux'; import { - getSendTo, accountsWithSendEtherInfoSelector, getAddressBookEntry, getIsEthGasPriceFetched, getNoGasPriceFetched, } from '../../../selectors'; +import { getIsAssetSendable, getSendTo } from '../../../ducks/send'; + import * as actions from '../../../store/actions'; import SendContent from './send-content.component'; @@ -14,15 +15,16 @@ function mapStateToProps(state) { const ownedAccounts = accountsWithSendEtherInfoSelector(state); const to = getSendTo(state); return { + isAssetSendable: getIsAssetSendable(state), isOwnedAccount: Boolean( ownedAccounts.find( ({ address }) => address.toLowerCase() === to.toLowerCase(), ), ), contact: getAddressBookEntry(state, to), - to, isEthGasPrice: getIsEthGasPriceFetched(state), noGasPrice: getNoGasPriceFetched(state), + to, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js index 0ee7a6030..b662261bb 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js @@ -3,31 +3,25 @@ import PropTypes from 'prop-types'; import SendRowWrapper from '../send-row-wrapper'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; import AdvancedGasInputs from '../../../../components/app/gas-customization/advanced-gas-inputs'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; export default class SendGasRow extends Component { static propTypes = { - balance: PropTypes.string, gasFeeError: PropTypes.bool, gasLoadingError: PropTypes.bool, gasTotal: PropTypes.string, - maxModeOn: PropTypes.bool, showCustomizeGasModal: PropTypes.func, - sendToken: PropTypes.object, - setAmountToMax: PropTypes.func, - setGasPrice: PropTypes.func, - setGasLimit: PropTypes.func, - tokenBalance: PropTypes.string, + updateGasPrice: PropTypes.func, + updateGasLimit: PropTypes.func, + gasInputMode: PropTypes.oneOf(Object.values(GAS_INPUT_MODES)), gasPriceButtonGroupProps: PropTypes.object, - gasButtonGroupShown: PropTypes.bool, advancedInlineGasShown: PropTypes.bool, resetGasButtons: PropTypes.func, gasPrice: PropTypes.string, gasLimit: PropTypes.string, insufficientBalance: PropTypes.bool, - isMainnet: PropTypes.bool, - isEthGasPrice: PropTypes.bool, - noGasPrice: PropTypes.bool, + minimumGasLimit: PropTypes.string, }; static contextTypes = { @@ -37,19 +31,7 @@ export default class SendGasRow extends Component { renderAdvancedOptionsButton() { const { trackEvent } = this.context; - const { - showCustomizeGasModal, - isMainnet, - isEthGasPrice, - noGasPrice, - } = this.props; - // Tests should behave in same way as mainnet, but are using Localhost - if (!isMainnet && !process.env.IN_TEST) { - return null; - } - if (isEthGasPrice || noGasPrice) { - return null; - } + const { showCustomizeGasModal } = this.props; return (
@@ -120,9 +80,6 @@ export default class SendGasRow extends Component { }, }); await gasPriceButtonGroupProps.handleGasPriceSelection(opts); - if (maxModeOn) { - this.setMaxAmount(); - } }} />
@@ -131,51 +88,38 @@ export default class SendGasRow extends Component { { - resetGasButtons(); - if (maxModeOn) { - this.setMaxAmount(); - } - }} - onClick={() => showCustomizeGasModal()} + onReset={resetGasButtons} + onClick={showCustomizeGasModal} /> ); const advancedGasInputs = (
- setGasPrice({ gasPrice: newGasPrice, gasLimit }) - } - updateCustomGasLimit={(newGasLimit) => - setGasLimit(newGasLimit, gasPrice) - } + updateCustomGasPrice={updateGasPrice} + updateCustomGasLimit={updateGasLimit} customGasPrice={gasPrice} customGasLimit={gasLimit} insufficientBalance={insufficientBalance} + minimumGasLimit={minimumGasLimit} customPriceIsSafe isSpeedUp={false} />
); // Tests should behave in same way as mainnet, but are using Localhost - if ( - advancedInlineGasShown || - (!isMainnet && !process.env.IN_TEST) || - gasPriceFetchFailure - ) { - return advancedGasInputs; - } else if (gasButtonGroupShown) { - return gasPriceButtonGroup; + switch (gasInputMode) { + case GAS_INPUT_MODES.BASIC: + return gasPriceButtonGroup; + case GAS_INPUT_MODES.INLINE: + return advancedGasInputs; + case GAS_INPUT_MODES.CUSTOM: + default: + return gasFeeDisplay; } - return gasFeeDisplay; } render() { - const { - gasFeeError, - gasButtonGroupShown, - advancedInlineGasShown, - } = this.props; + const { gasFeeError, gasInputMode, advancedInlineGasShown } = this.props; return ( <> @@ -186,7 +130,7 @@ export default class SendGasRow extends Component { > {this.renderContent()} - {gasButtonGroupShown || advancedInlineGasShown ? ( + {gasInputMode === GAS_INPUT_MODES.BASIC || advancedInlineGasShown ? ( {this.renderAdvancedOptionsButton()} ) : null} diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js index 7f4505558..9c5cfa30f 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js @@ -3,6 +3,7 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import SendGasRow from './send-gas-row.component'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; @@ -24,7 +25,7 @@ describe('SendGasRow Component', () => { gasFeeError gasLoadingError={false} gasTotal="mockGasTotal" - gasButtonGroupShown={false} + gasInputMode={GAS_INPUT_MODES.CUSTOM} showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal} resetGasButtons={propsMethodSpies.resetGasButtons} gasPriceButtonGroupProps={{ @@ -76,8 +77,8 @@ describe('SendGasRow Component', () => { expect(propsMethodSpies.resetGasButtons.callCount).toStrictEqual(1); }); - it('should render the GasPriceButtonGroup if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render the GasPriceButtonGroup if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).first().childAt(0); expect(wrapper.children()).toHaveLength(2); @@ -95,8 +96,8 @@ describe('SendGasRow Component', () => { ).toStrictEqual('bar'); }); - it('should render an advanced options button if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render an advanced options button if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).last(); expect(wrapper.children()).toHaveLength(2); diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js index 84d6886fb..189fa95e4 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js @@ -1,42 +1,30 @@ import { connect } from 'react-redux'; +import { + getBasicGasEstimateLoadingStatus, + getRenderableEstimateDataForSmallButtonsFromGWEI, + getDefaultActiveButtonIndex, + getAdvancedInlineGasShown, +} from '../../../../selectors'; import { getGasTotal, getGasPrice, getGasLimit, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getGasLoadingError, gasFeeIsInError, - getGasButtonGroupShown, - getAdvancedInlineGasShown, - getCurrentEthBalance, - getSendToken, - getBasicGasEstimateLoadingStatus, - getRenderableEstimateDataForSmallButtonsFromGWEI, - getDefaultActiveButtonIndex, - getIsMainnet, - getIsEthGasPriceFetched, - getNoGasPriceFetched, -} from '../../../../selectors'; -import { isBalanceSufficient, calcGasTotal } from '../../send.utils'; -import { calcMaxAmount } from '../send-amount-row/amount-max-button/amount-max-button.utils'; -import { - showGasButtonGroup, - updateSendErrors, - setGasPrice, - setGasLimit, - setGasTotal, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; + getGasInputMode, + updateGasPrice, + updateGasLimit, + isSendStateInitialized, + getIsBalanceInsufficient, + getMinimumGasLimitForSend, + useDefaultGas, +} from '../../../../ducks/send'; import { resetCustomData, setCustomGasPrice, setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { showModal } from '../../../../store/actions'; +import { hexToDecimal } from '../../../../helpers/utils/conversions.util'; import SendGasRow from './send-gas-row.component'; export default connect( @@ -55,40 +43,25 @@ function mapStateToProps(state) { ); const gasTotal = getGasTotal(state); - const conversionRate = getConversionRate(state); - const balance = getCurrentEthBalance(state); - const insufficientBalance = !isBalanceSufficient({ - amount: getSendToken(state) ? '0x0' : getSendAmount(state), - gasTotal, - balance, - conversionRate, - }); - const isEthGasPrice = getIsEthGasPriceFetched(state); - const noGasPrice = getNoGasPriceFetched(state); + const minimumGasLimit = getMinimumGasLimitForSend(state); return { - balance: getSendFromBalance(state), gasTotal, + minimumGasLimit: hexToDecimal(minimumGasLimit), gasFeeError: gasFeeIsInError(state), - gasLoadingError: getGasLoadingError(state), + gasLoadingError: isSendStateInitialized(state), gasPriceButtonGroupProps: { buttonDataLoading: getBasicGasEstimateLoadingStatus(state), defaultActiveButtonIndex: 1, newActiveButtonIndex: activeButtonIndex > -1 ? activeButtonIndex : null, gasButtonInfo, }, - gasButtonGroupShown: getGasButtonGroupShown(state), advancedInlineGasShown: getAdvancedInlineGasShown(state), + gasInputMode: getGasInputMode(state), gasPrice, gasLimit, - insufficientBalance, - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - isMainnet: getIsMainnet(state), - isEthGasPrice, - noGasPrice, + insufficientBalance: getIsBalanceInsufficient(state), }; } @@ -96,26 +69,16 @@ function mapDispatchToProps(dispatch) { return { showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS', hideBasic: true })), - setGasPrice: ({ gasPrice, gasLimit }) => { - dispatch(setGasPrice(gasPrice)); + updateGasPrice: (gasPrice) => { + dispatch(updateGasPrice(gasPrice)); dispatch(setCustomGasPrice(gasPrice)); - if (gasLimit) { - dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))); - } }, - setGasLimit: (newLimit, gasPrice) => { - dispatch(setGasLimit(newLimit)); + updateGasLimit: (newLimit) => { + dispatch(updateGasLimit(newLimit)); dispatch(setCustomGasLimit(newLimit)); - if (gasPrice) { - dispatch(setGasTotal(calcGasTotal(newLimit, gasPrice))); - } }, - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, - showGasButtonGroup: () => dispatch(showGasButtonGroup()), resetCustomData: () => dispatch(resetCustomData()), + useDefaultGas: () => dispatch(useDefaultGas()), }; } @@ -123,8 +86,8 @@ function mergeProps(stateProps, dispatchProps, ownProps) { const { gasPriceButtonGroupProps } = stateProps; const { gasButtonInfo } = gasPriceButtonGroupProps; const { - setGasPrice: dispatchSetGasPrice, - showGasButtonGroup: dispatchShowGasButtonGroup, + updateGasPrice: dispatchUpdateGasPrice, + useDefaultGas: dispatchUseDefaultGas, resetCustomData: dispatchResetCustomData, ...otherDispatchProps } = dispatchProps; @@ -135,13 +98,14 @@ function mergeProps(stateProps, dispatchProps, ownProps) { ...ownProps, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, - handleGasPriceSelection: dispatchSetGasPrice, + handleGasPriceSelection: ({ gasPrice }) => + dispatchUpdateGasPrice(gasPrice), }, resetGasButtons: () => { dispatchResetCustomData(); - dispatchSetGasPrice(gasButtonInfo[1].priceInHexWei); - dispatchShowGasButtonGroup(); + dispatchUpdateGasPrice(gasButtonInfo[1].priceInHexWei); + dispatchUseDefaultGas(); }, - setGasPrice: dispatchSetGasPrice, + updateGasPrice: dispatchUpdateGasPrice, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js index 80757f230..b7aa23c86 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js @@ -8,12 +8,7 @@ import { setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { - showGasButtonGroup, - setGasPrice, - setGasTotal, - setGasLimit, -} from '../../../../ducks/send/send.duck'; +import { updateGasPrice, updateGasLimit } from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -26,9 +21,15 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors', () => ({ - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, -})); +jest.mock('../../../../ducks/send', () => { + const original = jest.requireActual('../../../../ducks/send'); + return { + ...original, + getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, + updateGasPrice: jest.fn(), + updateGasLimit: jest.fn(), + }; +}); jest.mock('../../send.utils.js', () => ({ isBalanceSufficient: ({ amount, gasTotal, balance, conversionRate }) => @@ -41,13 +42,6 @@ jest.mock('../../../../store/actions', () => ({ showModal: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - showGasButtonGroup: jest.fn(), - setGasPrice: jest.fn(), - setGasTotal: jest.fn(), - setGasLimit: jest.fn(), -})); - jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), setCustomGasPrice: jest.fn(), @@ -77,36 +71,21 @@ describe('send-gas-row container', () => { }); }); - describe('setGasPrice()', () => { + describe('updateGasPrice()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasPrice({ - gasPrice: 'mockNewPrice', - gasLimit: 'mockLimit', - }); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasPrice('mockNewPrice'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasPrice).toHaveBeenCalled(); expect(setCustomGasPrice).toHaveBeenCalledWith('mockNewPrice'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockLimitmockNewPrice'); }); }); - describe('setGasLimit()', () => { + describe('updateGasLimit()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasLimit('mockNewLimit', 'mockPrice'); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasLimit).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasLimit('mockNewLimit'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasLimit).toHaveBeenCalled(); expect(setCustomGasLimit).toHaveBeenCalledWith('mockNewLimit'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockNewLimitmockPrice'); - }); - }); - - describe('showGasButtonGroup()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.showGasButtonGroup(); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(showGasButtonGroup).toHaveBeenCalled(); }); }); @@ -129,7 +108,7 @@ describe('send-gas-row container', () => { someOtherStateProp: 'baz', }; const dispatchProps = { - setGasPrice: sinon.spy(), + updateGasPrice: sinon.spy(), someOtherDispatchProp: sinon.spy(), }; const ownProps = { someOwnProp: 123 }; @@ -144,9 +123,11 @@ describe('send-gas-row container', () => { ).toStrictEqual('bar'); expect(result.someOwnProp).toStrictEqual(123); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(0); - result.gasPriceButtonGroupProps.handleGasPriceSelection(); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(1); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(0); + result.gasPriceButtonGroupProps.handleGasPriceSelection({ + gasPrice: undefined, + }); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(1); expect(dispatchProps.someOtherDispatchProp.callCount).toStrictEqual(0); result.someOtherDispatchProp(); diff --git a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js index 497b7e5e3..bcb6530f8 100644 --- a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js +++ b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js @@ -6,7 +6,6 @@ export default class SendHexDataRow extends Component { static propTypes = { inError: PropTypes.bool, updateSendHexData: PropTypes.func.isRequired, - updateGas: PropTypes.func.isRequired, }; static contextTypes = { @@ -14,10 +13,9 @@ export default class SendHexDataRow extends Component { }; onInput = (event) => { - const { updateSendHexData, updateGas } = this.props; + const { updateSendHexData } = this.props; const data = event.target.value.replace(/\n/gu, '') || null; updateSendHexData(data); - updateGas({ data }); }; render() { diff --git a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js index f645aff7a..044f3eb69 100644 --- a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js +++ b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.container.js @@ -1,12 +1,12 @@ import { connect } from 'react-redux'; -import { updateSendHexData } from '../../../../ducks/send/send.duck'; +import { getSendHexData, updateSendHexData } from '../../../../ducks/send'; import SendHexDataRow from './send-hex-data-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow); function mapStateToProps(state) { return { - data: state.send.data, + data: getSendHexData(state), }; } diff --git a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js index f857183f3..45a208537 100644 --- a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js +++ b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { getSendErrors } from '../../../../../selectors'; +import { getSendErrors } from '../../../../../ducks/send'; import SendRowErrorMessage from './send-row-error-message.component'; export default connect(mapStateToProps)(SendRowErrorMessage); diff --git a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js index a8012f200..23f1d2c68 100644 --- a/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js +++ b/ui/pages/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.test.js @@ -8,7 +8,7 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../../selectors', () => ({ +jest.mock('../../../../../ducks/send', () => ({ getSendErrors: (s) => `mockErrors:${s}`, })); diff --git a/ui/pages/send/send-footer/send-footer.component.js b/ui/pages/send/send-footer/send-footer.component.js index ef18f48b1..840146f5f 100644 --- a/ui/pages/send/send-footer/send-footer.component.js +++ b/ui/pages/send/send-footer/send-footer.component.js @@ -1,33 +1,21 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import { isEqual } from 'lodash'; import PageContainerFooter from '../../../components/ui/page-container/page-container-footer'; import { CONFIRM_TRANSACTION_ROUTE } from '../../../helpers/constants/routes'; export default class SendFooter extends Component { static propTypes = { addToAddressBookIfNew: PropTypes.func, - amount: PropTypes.string, - data: PropTypes.string, - clearSend: PropTypes.func, - editingTransactionId: PropTypes.string, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, + resetSendState: PropTypes.func, + disabled: PropTypes.bool.isRequired, history: PropTypes.object, - inError: PropTypes.bool, - sendToken: PropTypes.object, sign: PropTypes.func, to: PropTypes.string, toAccounts: PropTypes.array, - tokenBalance: PropTypes.string, - unapprovedTxs: PropTypes.object, - update: PropTypes.func, sendErrors: PropTypes.object, gasEstimateType: PropTypes.string, - gasIsLoading: PropTypes.bool, mostRecentOverviewPage: PropTypes.string.isRequired, - noGasPrice: PropTypes.bool, }; static contextTypes = { @@ -36,8 +24,8 @@ export default class SendFooter extends Component { }; onCancel() { - const { clearSend, history, mostRecentOverviewPage } = this.props; - clearSend(); + const { resetSendState, history, mostRecentOverviewPage } = this.props; + resetSendState(); history.push(mostRecentOverviewPage); } @@ -45,45 +33,17 @@ export default class SendFooter extends Component { event.preventDefault(); const { addToAddressBookIfNew, - amount, - data, - editingTransactionId, - from: { address: from }, - gasLimit: gas, - gasPrice, - sendToken, sign, to, - unapprovedTxs, - update, toAccounts, history, gasEstimateType, } = this.props; const { metricsEvent } = this.context; - // Should not be needed because submit should be disabled if there are errors. - // const noErrors = !amountError && toError === null - - // if (!noErrors) { - // return - // } - // TODO: add nickname functionality await addToAddressBookIfNew(to, toAccounts); - const promise = editingTransactionId - ? update({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }) - : sign({ data, sendToken, to, amount, from, gas, gasPrice }); + const promise = sign(); Promise.resolve(promise).then(() => { metricsEvent({ @@ -100,35 +60,13 @@ export default class SendFooter extends Component { }); } - formShouldBeDisabled() { - const { - data, - inError, - sendToken, - tokenBalance, - gasTotal, - to, - gasLimit, - gasIsLoading, - noGasPrice, - } = this.props; - const missingTokenBalance = sendToken && !tokenBalance; - const gasLimitTooLow = gasLimit < 5208; // 5208 is hex value of 21000, minimum gas limit - const shouldBeDisabled = - inError || - !gasTotal || - missingTokenBalance || - !(data || to) || - gasLimitTooLow || - gasIsLoading || - noGasPrice; - return shouldBeDisabled; - } - componentDidUpdate(prevProps) { - const { inError, sendErrors } = this.props; + const { sendErrors } = this.props; const { metricsEvent } = this.context; - if (!prevProps.inError && inError) { + if ( + Object.keys(sendErrors).length > 0 && + isEqual(sendErrors, prevProps.sendErrors) === false + ) { const errorField = Object.keys(sendErrors).find((key) => sendErrors[key]); const errorMessage = sendErrors[errorField]; @@ -151,7 +89,7 @@ export default class SendFooter extends Component { this.onCancel()} onSubmit={(e) => this.onSubmit(e)} - disabled={this.formShouldBeDisabled()} + disabled={this.props.disabled} /> ); } diff --git a/ui/pages/send/send-footer/send-footer.component.test.js b/ui/pages/send/send-footer/send-footer.component.test.js index 900c26b2a..fcd4472d6 100644 --- a/ui/pages/send/send-footer/send-footer.component.test.js +++ b/ui/pages/send/send-footer/send-footer.component.test.js @@ -10,7 +10,7 @@ describe('SendFooter Component', () => { const propsMethodSpies = { addToAddressBookIfNew: sinon.spy(), - clearSend: sinon.spy(), + resetSendState: sinon.spy(), sign: sinon.spy(), update: sinon.spy(), mostRecentOverviewPage: '/', @@ -29,36 +29,24 @@ describe('SendFooter Component', () => { wrapper = shallow( , { context: { t: (str) => str, metricsEvent: () => ({}) } }, ); }); afterEach(() => { - propsMethodSpies.clearSend.resetHistory(); + propsMethodSpies.resetSendState.resetHistory(); propsMethodSpies.addToAddressBookIfNew.resetHistory(); - propsMethodSpies.clearSend.resetHistory(); + propsMethodSpies.resetSendState.resetHistory(); propsMethodSpies.sign.resetHistory(); propsMethodSpies.update.resetHistory(); historySpies.push.resetHistory(); @@ -71,10 +59,10 @@ describe('SendFooter Component', () => { }); describe('onCancel', () => { - it('should call clearSend', () => { - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(0); + it('should call resetSendState', () => { + expect(propsMethodSpies.resetSendState.callCount).toStrictEqual(0); wrapper.instance().onCancel(); - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(1); + expect(propsMethodSpies.resetSendState.callCount).toStrictEqual(1); }); it('should call history.push', () => { @@ -87,59 +75,6 @@ describe('SendFooter Component', () => { }); }); - describe('formShouldBeDisabled()', () => { - const config = { - 'should return true if inError is truthy': { - inError: true, - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if gasTotal is falsy': { - inError: false, - gasTotal: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if to is truthy': { - to: '0xsomevalidAddress', - inError: false, - gasTotal: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if sendToken is truthy and tokenBalance is falsy': { - sendToken: { mockProp: 'mockSendTokenProp' }, - tokenBalance: '', - expectedResult: true, - gasIsLoading: false, - }, - 'should return true if gasIsLoading is truthy but all other params are falsy': { - inError: false, - gasTotal: '', - sendToken: null, - tokenBalance: '', - expectedResult: true, - gasIsLoading: true, - }, - 'should return false if inError is false and all other params are truthy': { - inError: false, - gasTotal: '0x123', - sendToken: { mockProp: 'mockSendTokenProp' }, - tokenBalance: '123', - expectedResult: false, - gasIsLoading: false, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - wrapper.setProps(obj); - expect(wrapper.instance().formShouldBeDisabled()).toStrictEqual( - obj.expectedResult, - ); - }); - }); - }); - describe('onSubmit', () => { it('should call addToAddressBookIfNew with the correct params', () => { wrapper.instance().onSubmit(MOCK_EVENT); @@ -151,43 +86,9 @@ describe('SendFooter Component', () => { ).toStrictEqual(['mockTo', ['mockAccount']]); }); - it('should call props.update if editingTransactionId is truthy', async () => { - await wrapper.instance().onSubmit(MOCK_EVENT); - expect(propsMethodSpies.update.calledOnce).toStrictEqual(true); - expect(propsMethodSpies.update.getCall(0).args[0]).toStrictEqual({ - data: undefined, - amount: 'mockAmount', - editingTransactionId: 'mockEditingTransactionId', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - sendToken: { mockProp: 'mockSendTokenProp' }, - to: 'mockTo', - unapprovedTxs: {}, - }); - }); - - it('should not call props.sign if editingTransactionId is truthy', () => { - expect(propsMethodSpies.sign.callCount).toStrictEqual(0); - }); - - it('should call props.sign if editingTransactionId is falsy', async () => { - wrapper.setProps({ editingTransactionId: null }); + it('should call props.sign whe submitting', async () => { await wrapper.instance().onSubmit(MOCK_EVENT); expect(propsMethodSpies.sign.calledOnce).toStrictEqual(true); - expect(propsMethodSpies.sign.getCall(0).args[0]).toStrictEqual({ - data: undefined, - amount: 'mockAmount', - from: 'mockAddress', - gas: 'mockGasLimit', - gasPrice: 'mockGasPrice', - sendToken: { mockProp: 'mockSendTokenProp' }, - to: 'mockTo', - }); - }); - - it('should not call props.update if editingTransactionId is falsy', () => { - expect(propsMethodSpies.update.callCount).toStrictEqual(0); }); it('should call history.push', async () => { @@ -201,12 +102,11 @@ describe('SendFooter Component', () => { describe('render', () => { beforeEach(() => { - sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns(true); wrapper = shallow( { gasPrice="mockGasPrice" gasTotal="mockGasTotal" history={historySpies} - inError={false} sendToken={{ mockProp: 'mockSendTokenProp' }} sign={propsMethodSpies.sign} to="mockTo" @@ -229,10 +128,6 @@ describe('SendFooter Component', () => { ); }); - afterEach(() => { - SendFooter.prototype.formShouldBeDisabled.restore(); - }); - it('should render a PageContainerFooter component', () => { expect(wrapper.find(PageContainerFooter)).toHaveLength(1); }); diff --git a/ui/pages/send/send-footer/send-footer.container.js b/ui/pages/send/send-footer/send-footer.container.js index 8848255d3..bcdb796e1 100644 --- a/ui/pages/send/send-footer/send-footer.container.js +++ b/ui/pages/send/send-footer/send-footer.container.js @@ -1,44 +1,32 @@ import { connect } from 'react-redux'; +import { addToAddressBook } from '../../../store/actions'; import { - addToAddressBook, - signTokenTx, - signTx, - updateTransaction, -} from '../../../store/actions'; -import { - getGasLimit, - getGasPrice, - getGasTotal, - getSendToken, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, - getSendTo, - getSendHexData, - getTokenBalance, - getSendErrors, - isSendFormInError, - getGasIsLoading, getRenderableEstimateDataForSmallButtonsFromGWEI, getDefaultActiveButtonIndex, - getNoGasPriceFetched, } from '../../../selectors'; +import { + resetSendState, + getGasPrice, + getSendTo, + getSendErrors, + isSendFormInvalid, + signTransaction, +} from '../../../ducks/send'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { addHexPrefix } from '../../../../app/scripts/lib/util'; -import { - getSendToAccounts, - getUnapprovedTxs, -} from '../../../ducks/metamask/metamask'; -import { clearSend } from '../../../ducks/send/send.duck'; +import { getSendToAccounts } from '../../../ducks/metamask/metamask'; import SendFooter from './send-footer.component'; -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; export default connect(mapStateToProps, mapDispatchToProps)(SendFooter); +function addressIsNew(toAccounts, newAddress) { + const newAddressNormalized = newAddress.toLowerCase(); + const foundMatching = toAccounts.some( + ({ address }) => address.toLowerCase() === newAddressNormalized, + ); + return !foundMatching; +} + function mapStateToProps(state) { const gasButtonInfo = getRenderableEstimateDataForSmallButtonsFromGWEI(state); const gasPrice = getGasPrice(state); @@ -50,74 +38,21 @@ function mapStateToProps(state) { activeButtonIndex >= 0 ? gasButtonInfo[activeButtonIndex].gasEstimateType : 'custom'; - const editingTransactionId = getSendEditingTransactionId(state); return { - amount: getSendAmount(state), - data: getSendHexData(state), - editingTransactionId, - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - inError: isSendFormInError(state), - sendToken: getSendToken(state), + disabled: isSendFormInvalid(state), to: getSendTo(state), toAccounts: getSendToAccounts(state), - tokenBalance: getTokenBalance(state), - unapprovedTxs: getUnapprovedTxs(state), sendErrors: getSendErrors(state), gasEstimateType, - gasIsLoading: getGasIsLoading(state), mostRecentOverviewPage: getMostRecentOverviewPage(state), - noGasPrice: getNoGasPriceFetched(state), }; } function mapDispatchToProps(dispatch) { return { - clearSend: () => dispatch(clearSend()), - sign: ({ sendToken, to, amount, from, gas, gasPrice, data }) => { - const txParams = constructTxParams({ - amount, - data, - from, - gas, - gasPrice, - sendToken, - to, - }); - - return sendToken - ? dispatch(signTokenTx(sendToken.address, to, amount, txParams)) - : dispatch(signTx(txParams)); - }, - update: ({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }) => { - const editingTx = constructUpdatedTx({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, - }); - - return dispatch(updateTransaction(editingTx)); - }, - + resetSendState: () => dispatch(resetSendState()), + sign: () => dispatch(signTransaction()), addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { const hexPrefixedAddress = addHexPrefix(newAddress); if (addressIsNew(toAccounts, hexPrefixedAddress)) { diff --git a/ui/pages/send/send-footer/send-footer.container.test.js b/ui/pages/send/send-footer/send-footer.container.test.js index 3cb6e474e..61c081719 100644 --- a/ui/pages/send/send-footer/send-footer.container.test.js +++ b/ui/pages/send/send-footer/send-footer.container.test.js @@ -1,12 +1,7 @@ import sinon from 'sinon'; -import { clearSend } from '../../../ducks/send/send.duck'; -import { signTx, signTokenTx, addToAddressBook } from '../../../store/actions'; -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; +import { addToAddressBook } from '../../../store/actions'; +import { resetSendState, signTransaction } from '../../../ducks/send'; let mapDispatchToProps; @@ -19,32 +14,18 @@ jest.mock('react-redux', () => ({ jest.mock('../../../store/actions.js', () => ({ addToAddressBook: jest.fn(), - signTokenTx: jest.fn(), - signTx: jest.fn(), - updateTransaction: jest.fn(), })); -jest.mock('../../../ducks/send/send.duck.js', () => ({ - clearSend: jest.fn(), +jest.mock('../../../ducks/metamask/metamask', () => ({ + getSendToAccounts: (s) => [`mockToAccounts:${s}`], })); -jest.mock('../../../selectors/send.js', () => ({ - getGasLimit: (s) => `mockGasLimit:${s}`, +jest.mock('../../../ducks/send', () => ({ getGasPrice: (s) => `mockGasPrice:${s}`, - getGasTotal: (s) => `mockGasTotal:${s}`, - getSendToken: (s) => `mockSendToken:${s}`, - getSendAmount: (s) => `mockAmount:${s}`, - getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, - getSendFromObject: (s) => `mockFromObject:${s}`, getSendTo: (s) => `mockTo:${s}`, - getSendToNickname: (s) => `mockToNickname:${s}`, - getSendToAccounts: (s) => `mockToAccounts:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getSendHexData: (s) => `mockHexData:${s}`, - getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`, getSendErrors: (s) => `mockSendErrors:${s}`, - isSendFormInError: (s) => `mockInError:${s}`, - getDefaultActiveButtonIndex: () => 0, + resetSendState: jest.fn(), + signTransaction: jest.fn(), })); jest.mock('../../../selectors/custom-gas.js', () => ({ @@ -52,15 +33,6 @@ jest.mock('../../../selectors/custom-gas.js', () => ({ { gasEstimateType: `mockGasEstimateType:${s}` }, ], })); - -jest.mock('./send-footer.utils', () => ({ - addressIsNew: jest.fn().mockReturnValue(true), - constructTxParams: jest.fn().mockReturnValue({ value: 'mockAmount' }), - constructUpdatedTx: jest - .fn() - .mockReturnValue('mockConstructedUpdatedTxParams'), -})); - require('./send-footer.container.js'); describe('send-footer container', () => { @@ -73,94 +45,19 @@ describe('send-footer container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); }); - describe('clearSend()', () => { + describe('resetSendState()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.clearSend(); + mapDispatchToPropsObject.resetSendState(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(clearSend).toHaveBeenCalled(); + expect(resetSendState).toHaveBeenCalled(); }); }); describe('sign()', () => { - it('should dispatch a signTokenTx action if sendToken is defined', () => { - mapDispatchToPropsObject.sign({ - sendToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); + it('should dispatch a signTransaction action', () => { + mapDispatchToPropsObject.sign(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructTxParams).toHaveBeenCalledWith({ - data: undefined, - sendToken: { - address: '0xabc', - }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(signTokenTx).toHaveBeenCalledWith( - '0xabc', - 'mockTo', - 'mockAmount', - { value: 'mockAmount' }, - ); - }); - - it('should dispatch a sign action if sendToken is not defined', () => { - mapDispatchToPropsObject.sign({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructTxParams).toHaveBeenCalledWith({ - data: undefined, - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }); - expect(signTx).toHaveBeenCalledWith({ - value: 'mockAmount', - }); - }); - }); - - describe('update()', () => { - it('should dispatch an updateTransaction action', () => { - mapDispatchToPropsObject.update({ - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - sendToken: { address: 'mockAddress' }, - unapprovedTxs: 'mockUnapprovedTxs', - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(constructUpdatedTx).toHaveBeenCalledWith({ - data: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - editingTransactionId: 'mockEditingTransactionId', - sendToken: { address: 'mockAddress' }, - unapprovedTxs: 'mockUnapprovedTxs', - }); + expect(signTransaction).toHaveBeenCalledTimes(1); }); }); @@ -168,14 +65,10 @@ describe('send-footer container', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.addToAddressBookIfNew( 'mockNewAddress', - 'mockToAccounts', + [{ address: 'mockToAccounts' }], 'mockNickname', ); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(addressIsNew).toHaveBeenCalledWith( - 'mockToAccounts', - '0xmockNewAddress', - ); expect(addToAddressBook).toHaveBeenCalledWith( '0xmockNewAddress', 'mockNickname', diff --git a/ui/pages/send/send-footer/send-footer.utils.js b/ui/pages/send/send-footer/send-footer.utils.js deleted file mode 100644 index 778b07867..000000000 --- a/ui/pages/send/send-footer/send-footer.utils.js +++ /dev/null @@ -1,96 +0,0 @@ -import ethAbi from 'ethereumjs-abi'; -import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from '../send.constants'; -import { addHexPrefix } from '../../../../app/scripts/lib/util'; -import { addHexPrefixToObjectValues } from '../../../helpers/utils/util'; - -export function constructTxParams({ - sendToken, - data, - to, - amount, - from, - gas, - gasPrice, -}) { - const txParams = { - data, - from, - value: '0', - gas, - gasPrice, - }; - - if (!sendToken) { - txParams.value = amount; - txParams.to = to; - } - - return addHexPrefixToObjectValues(txParams); -} - -export function constructUpdatedTx({ - amount, - data, - editingTransactionId, - from, - gas, - gasPrice, - sendToken, - to, - unapprovedTxs, -}) { - const unapprovedTx = unapprovedTxs[editingTransactionId]; - const txParamsData = unapprovedTx.txParams.data - ? unapprovedTx.txParams.data - : data; - - const editingTx = { - ...unapprovedTx, - txParams: Object.assign( - unapprovedTx.txParams, - addHexPrefixToObjectValues({ - data: txParamsData, - to, - from, - gas, - gasPrice, - value: amount, - }), - ), - }; - - if (sendToken) { - Object.assign( - editingTx.txParams, - addHexPrefixToObjectValues({ - value: '0', - to: sendToken.address, - data: - TOKEN_TRANSFER_FUNCTION_SIGNATURE + - Array.prototype.map - .call( - ethAbi.rawEncode( - ['address', 'uint256'], - [to, addHexPrefix(amount)], - ), - (x) => `00${x.toString(16)}`.slice(-2), - ) - .join(''), - }), - ); - } - - if (typeof editingTx.txParams.data === 'undefined') { - delete editingTx.txParams.data; - } - - return editingTx; -} - -export function addressIsNew(toAccounts, newAddress) { - const newAddressNormalized = newAddress.toLowerCase(); - const foundMatching = toAccounts.some( - ({ address }) => address.toLowerCase() === newAddressNormalized, - ); - return !foundMatching; -} diff --git a/ui/pages/send/send-footer/send-footer.utils.test.js b/ui/pages/send/send-footer/send-footer.utils.test.js deleted file mode 100644 index 034ca2ecd..000000000 --- a/ui/pages/send/send-footer/send-footer.utils.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { addHexPrefixToObjectValues } from '../../../helpers/utils/util'; -import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from '../send.constants'; - -import { - addressIsNew, - constructTxParams, - constructUpdatedTx, -} from './send-footer.utils'; - -jest.mock('ethereumjs-abi', () => ({ - rawEncode: jest.fn((arr1, arr2) => { - return [...arr1, ...arr2]; - }), -})); - -describe('send-footer utils', () => { - describe('addHexPrefixToObjectValues()', () => { - it('should return a new object with the same properties with a 0x prefix', () => { - expect( - addHexPrefixToObjectValues({ - prop1: '0x123', - prop2: '456', - prop3: 'x', - }), - ).toStrictEqual({ - prop1: '0x123', - prop2: '0x456', - prop3: '0xx', - }); - }); - }); - - describe('addressIsNew()', () => { - it('should return false if the address exists in toAccounts', () => { - expect( - addressIsNew( - [{ address: '0xabc' }, { address: '0xdef' }, { address: '0xghi' }], - '0xdef', - ), - ).toStrictEqual(false); - }); - - it('should return true if the address does not exists in toAccounts', () => { - expect( - addressIsNew( - [{ address: '0xabc' }, { address: '0xdef' }, { address: '0xghi' }], - '0xxyz', - ), - ).toStrictEqual(true); - }); - }); - - describe('constructTxParams()', () => { - it('should return a new txParams object with data if there data is given', () => { - expect( - constructTxParams({ - data: 'someData', - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: '0xsomeData', - to: '0xmockTo', - value: '0xmockAmount', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - - it('should return a new txParams object with value and to properties if there is no sendToken', () => { - expect( - constructTxParams({ - sendToken: undefined, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: undefined, - to: '0xmockTo', - value: '0xmockAmount', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - - it('should return a new txParams object without a to property and a 0 value if there is a sendToken', () => { - expect( - constructTxParams({ - sendToken: { address: '0x0' }, - to: 'mockTo', - amount: 'mockAmount', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - }), - ).toStrictEqual({ - data: undefined, - value: '0x0', - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - }); - }); - }); - - describe('constructUpdatedTx()', () => { - it('should return a new object with an updated txParams', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: false, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: { - data: 'someData', - }, - }, - }, - }); - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0xmockAmount', - to: '0xmockTo', - data: '0xsomeData', - }, - }); - }); - - it('should not have data property if there is non in the original tx', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: false, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: { - from: 'oldFrom', - gas: 'oldGas', - gasPrice: 'oldGasPrice', - }, - }, - }, - }); - - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0xmockAmount', - to: '0xmockTo', - }, - }); - }); - - it('should have token property values if sendToken is truthy', () => { - const result = constructUpdatedTx({ - amount: 'mockAmount', - editingTransactionId: '0x456', - from: 'mockFrom', - gas: 'mockGas', - gasPrice: 'mockGasPrice', - sendToken: { - address: 'mockTokenAddress', - }, - to: 'mockTo', - unapprovedTxs: { - '0x123': {}, - '0x456': { - unapprovedTxParam: 'someOtherParam', - txParams: {}, - }, - }, - }); - - expect(result).toStrictEqual({ - unapprovedTxParam: 'someOtherParam', - txParams: { - from: '0xmockFrom', - gas: '0xmockGas', - gasPrice: '0xmockGasPrice', - value: '0x0', - to: '0xmockTokenAddress', - data: `${TOKEN_TRANSFER_FUNCTION_SIGNATURE}ss56Tont`, - }, - }); - }); - }); -}); diff --git a/ui/pages/send/send-header/index.js b/ui/pages/send/send-header/index.js index cfb482303..b4bda8af7 100644 --- a/ui/pages/send/send-header/index.js +++ b/ui/pages/send/send-header/index.js @@ -1 +1 @@ -export { default } from './send-header.container'; +export { default } from './send-header.component'; diff --git a/ui/pages/send/send-header/send-header.component.js b/ui/pages/send/send-header/send-header.component.js index 303ef4c7a..1b8af5312 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -1,33 +1,47 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import { getMostRecentOverviewPage } from '../../../ducks/history/history'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + ASSET_TYPES, + getSendAsset, + getSendStage, + resetSendState, + SEND_STAGES, +} from '../../../ducks/send'; -export default class SendHeader extends Component { - static propTypes = { - clearSend: PropTypes.func, - history: PropTypes.object, - mostRecentOverviewPage: PropTypes.string, - titleKey: PropTypes.string, - }; +export default function SendHeader() { + const history = useHistory(); + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const dispatch = useDispatch(); + const stage = useSelector(getSendStage); + const asset = useSelector(getSendAsset); + const t = useI18nContext(); - static contextTypes = { - t: PropTypes.func, - }; - - onClose() { - const { clearSend, history, mostRecentOverviewPage } = this.props; - clearSend(); + const onClose = () => { + dispatch(resetSendState()); history.push(mostRecentOverviewPage); + }; + + let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); + + if ( + stage === SEND_STAGES.ADD_RECIPIENT || + stage === SEND_STAGES.UNINITIALIZED + ) { + title = t('addRecipient'); + } else if (stage === SEND_STAGES.EDIT) { + title = t('edit'); } - render() { - return ( - this.onClose()} - title={this.context.t(this.props.titleKey)} - headerCloseText={this.context.t('cancel')} - /> - ); - } + return ( + + ); } diff --git a/ui/pages/send/send-header/send-header.component.test.js b/ui/pages/send/send-header/send-header.component.test.js index 8ff76c35e..a8fa64342 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -1,73 +1,120 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { ASSET_TYPES, initialState, SEND_STAGES } from '../../../ducks/send'; +import { renderWithProvider } from '../../../../test/jest'; import SendHeader from './send-header.component'; +const middleware = [thunk]; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: () => ({ + push: jest.fn(), + }), + }; +}); + describe('SendHeader Component', () => { - let wrapper; - - const propsMethodSpies = { - clearSend: sinon.spy(), - }; - const historySpies = { - push: sinon.spy(), - }; - - beforeAll(() => { - sinon.spy(SendHeader.prototype, 'onClose'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { context: { t: (str1, str2) => (str2 ? str1 + str2 : str1) } }, - ); - }); - - afterEach(() => { - propsMethodSpies.clearSend.resetHistory(); - historySpies.push.resetHistory(); - SendHeader.prototype.onClose.resetHistory(); - }); - - afterAll(() => { - sinon.restore(); - }); - - describe('onClose', () => { - it('should call clearSend', () => { - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(1); - }); - - it('should call history.push', () => { - expect(historySpies.push.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(historySpies.push.callCount).toStrictEqual(1); - expect(historySpies.push.getCall(0).args[0]).toStrictEqual( - 'mostRecentOverviewPage', + describe('Title', () => { + it('should render "Add Recipient" for UNINITIALIZED or ADD_RECIPIENT stages', () => { + const { getByText, rerender } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), ); + expect(getByText('Add Recipient')).toBeTruthy(); + rerender( + , + configureMockStore(middleware)({ + send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Add Recipient')).toBeTruthy(); + }); + + it('should render "Send" for DRAFT stage when asset type is NATIVE', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send')).toBeTruthy(); + }); + + it('should render "Send Tokens" for DRAFT stage when asset type is TOKEN', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send Tokens')).toBeTruthy(); + }); + + it('should render "Edit" for EDIT stage', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.EDIT, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Edit')).toBeTruthy(); }); }); - describe('render', () => { - it('should render a PageContainerHeader component', () => { - expect(wrapper.find(PageContainerHeader)).toHaveLength(1); + describe('Cancel Button', () => { + it('has a cancel button in header', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Cancel')).toBeTruthy(); }); - it('should pass the correct props to PageContainerHeader', () => { - const { onClose, title } = wrapper.find(PageContainerHeader).props(); - expect(title).toStrictEqual('mockTitleKey'); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(0); - onClose(); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(1); + it('resets send state when clicked', () => { + const store = configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }); + const { getByText } = renderWithProvider(, store); + const expectedActions = [ + { type: 'send/resetSendState', payload: undefined }, + ]; + fireEvent.click(getByText('Cancel')); + expect(store.getActions()).toStrictEqual(expectedActions); }); }); }); diff --git a/ui/pages/send/send-header/send-header.container.js b/ui/pages/send/send-header/send-header.container.js deleted file mode 100644 index b66a9ba89..000000000 --- a/ui/pages/send/send-header/send-header.container.js +++ /dev/null @@ -1,20 +0,0 @@ -import { connect } from 'react-redux'; -import { clearSend } from '../../../ducks/send/send.duck'; -import { getTitleKey } from '../../../selectors'; -import { getMostRecentOverviewPage } from '../../../ducks/history/history'; -import SendHeader from './send-header.component'; - -export default connect(mapStateToProps, mapDispatchToProps)(SendHeader); - -function mapStateToProps(state) { - return { - mostRecentOverviewPage: getMostRecentOverviewPage(state), - titleKey: getTitleKey(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - clearSend: () => dispatch(clearSend()), - }; -} diff --git a/ui/pages/send/send.component.js b/ui/pages/send/send.component.js deleted file mode 100644 index 6954f9cfc..000000000 --- a/ui/pages/send/send.component.js +++ /dev/null @@ -1,403 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { isValidHexAddress } from '../../../shared/modules/hexstring-utils'; -import { - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - doesAmountErrorRequireUpdate, -} from './send.utils'; -import { - getToWarningObject, - getToErrorObject, -} from './send-content/add-recipient/add-recipient'; -import SendHeader from './send-header'; -import AddRecipient from './send-content/add-recipient'; -import SendContent from './send-content'; -import SendFooter from './send-footer'; -import EnsInput from './send-content/add-recipient/ens-input'; -import { - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from './send.constants'; - -export default class SendTransactionScreen extends Component { - static propTypes = { - addressBook: PropTypes.arrayOf(PropTypes.object), - amount: PropTypes.string, - blockGasLimit: PropTypes.string, - conversionRate: PropTypes.number, - editingTransactionId: PropTypes.string, - fetchBasicGasEstimates: PropTypes.func.isRequired, - from: PropTypes.object, - gasLimit: PropTypes.string, - gasPrice: PropTypes.string, - gasTotal: PropTypes.string, - history: PropTypes.object, - chainId: PropTypes.string, - primaryCurrency: PropTypes.string, - resetSendState: PropTypes.func.isRequired, - selectedAddress: PropTypes.string, - sendToken: PropTypes.object, - showHexData: PropTypes.bool, - to: PropTypes.string, - toNickname: PropTypes.string, - tokens: PropTypes.array, - tokenBalance: PropTypes.string, - tokenContract: PropTypes.object, - updateAndSetGasLimit: PropTypes.func.isRequired, - updateSendEnsResolution: PropTypes.func.isRequired, - updateSendEnsResolutionError: PropTypes.func.isRequired, - updateSendErrors: PropTypes.func.isRequired, - updateSendTo: PropTypes.func.isRequired, - updateSendTokenBalance: PropTypes.func.isRequired, - updateToNicknameIfNecessary: PropTypes.func.isRequired, - scanQrCode: PropTypes.func.isRequired, - qrCodeDetected: PropTypes.func.isRequired, - qrCodeData: PropTypes.object, - sendTokenAddress: PropTypes.string, - gasIsExcessive: PropTypes.bool.isRequired, - }; - - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - }; - - state = { - query: '', - toError: null, - toWarning: null, - internalSearch: false, - }; - - constructor(props) { - super(props); - this.dValidate = debounce(this.validate, 1000); - } - - componentDidUpdate(prevProps) { - const { - amount, - conversionRate, - from: { address, balance }, - gasTotal, - chainId, - primaryCurrency, - sendToken, - tokenBalance, - updateSendErrors, - updateSendTo, - updateSendTokenBalance, - tokenContract, - to, - toNickname, - addressBook, - updateToNicknameIfNecessary, - qrCodeData, - qrCodeDetected, - } = this.props; - const { toError, toWarning } = this.state; - - let updateGas = false; - const { - from: { balance: prevBalance }, - gasTotal: prevGasTotal, - tokenBalance: prevTokenBalance, - chainId: prevChainId, - sendToken: prevSendToken, - to: prevTo, - } = prevProps; - - const uninitialized = [prevBalance, prevGasTotal].every((n) => n === null); - - const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - sendToken, - tokenBalance, - }); - - if (amountErrorRequiresUpdate) { - const amountErrorObject = getAmountErrorObject({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - const gasFeeErrorObject = sendToken - ? getGasFeeErrorObject({ - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - }) - : { gasFee: null }; - updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)); - } - - if (!uninitialized) { - if (chainId !== prevChainId && chainId !== undefined) { - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }); - updateToNicknameIfNecessary(to, toNickname, addressBook); - this.props.fetchBasicGasEstimates(); - updateGas = true; - } - } - - const prevTokenAddress = prevSendToken && prevSendToken.address; - const sendTokenAddress = sendToken && sendToken.address; - - if (sendTokenAddress && prevTokenAddress !== sendTokenAddress) { - this.updateSendToken(); - this.validate(this.state.query); - updateGas = true; - } - - let scannedAddress; - if (qrCodeData) { - if (qrCodeData.type === 'address') { - scannedAddress = qrCodeData.values.address.toLowerCase(); - if (isValidHexAddress(scannedAddress, { allowNonPrefixed: false })) { - const currentAddress = prevTo?.toLowerCase(); - if (currentAddress !== scannedAddress) { - updateSendTo(scannedAddress); - updateGas = true; - // Clean up QR code data after handling - qrCodeDetected(null); - } - } else { - scannedAddress = null; - qrCodeDetected(null); - this.setState({ toError: INVALID_RECIPIENT_ADDRESS_ERROR }); - } - } - } - - if (updateGas) { - if (scannedAddress) { - this.updateGas({ to: scannedAddress }); - } else { - this.updateGas(); - } - } - - // If selecting ETH after selecting a token, clear token related messages. - if (prevSendToken && !sendToken) { - let error = toError; - let warning = toWarning; - - if (toError === CONTRACT_ADDRESS_ERROR) { - error = null; - } - - if (toWarning === KNOWN_RECIPIENT_ADDRESS_ERROR) { - warning = null; - } - - this.setState({ - toError: error, - toWarning: warning, - }); - } - } - - componentDidMount() { - this.props.fetchBasicGasEstimates().then(() => { - this.updateGas(); - }); - } - - UNSAFE_componentWillMount() { - this.updateSendToken(); - - // Show QR Scanner modal if ?scan=true - if (window.location.search === '?scan=true') { - this.props.scanQrCode(); - - // Clear the queryString param after showing the modal - const cleanUrl = window.location.href.split('?')[0]; - window.history.pushState({}, null, `${cleanUrl}`); - window.location.hash = '#send'; - } - } - - componentWillUnmount() { - this.props.resetSendState(); - } - - onRecipientInputChange = (query) => { - const { internalSearch } = this.state; - - if (!internalSearch) { - if (query) { - this.dValidate(query); - } else { - this.dValidate.cancel(); - this.validate(query); - } - } - - this.setState({ query }); - }; - - setInternalSearch(internalSearch) { - this.setState({ query: '', internalSearch }); - } - - validate(query) { - const { tokens, sendToken, chainId, sendTokenAddress } = this.props; - - const { internalSearch } = this.state; - - if (!query || internalSearch) { - this.setState({ toError: '', toWarning: '' }); - return; - } - - const toErrorObject = getToErrorObject(query, sendTokenAddress, chainId); - const toWarningObject = getToWarningObject(query, tokens, sendToken); - - this.setState({ - toError: toErrorObject.to, - toWarning: toWarningObject.to, - }); - } - - updateSendToken() { - const { - from: { address }, - sendToken, - tokenContract, - updateSendTokenBalance, - } = this.props; - - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }); - } - - updateGas({ to: updatedToAddress, amount: value, data } = {}) { - const { - amount, - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to: currentToAddress, - updateAndSetGasLimit, - } = this.props; - - updateAndSetGasLimit({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), - value: value || amount, - data, - }); - } - - render() { - const { history, to } = this.props; - let content; - - if (to) { - content = this.renderSendContent(); - } else { - content = this.renderAddRecipient(); - } - - return ( -
- - {this.renderInput()} - {content} -
- ); - } - - renderInput() { - const { internalSearch } = this.state; - return ( - { - this.context.metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Used QR scanner', - }, - }); - this.props.scanQrCode(); - }} - onChange={this.onRecipientInputChange} - onValidAddressTyped={(address) => this.props.updateSendTo(address, '')} - onPaste={(text) => { - this.props.updateSendTo(text) && this.updateGas(); - }} - onReset={() => this.props.updateSendTo('', '')} - updateEnsResolution={this.props.updateSendEnsResolution} - updateEnsResolutionError={this.props.updateSendEnsResolutionError} - internalSearch={internalSearch} - /> - ); - } - - renderAddRecipient() { - const { toError, toWarning } = this.state; - return ( - - this.updateGas({ to, amount, data }) - } - query={this.state.query} - toError={toError} - toWarning={toWarning} - setInternalSearch={(internalSearch) => - this.setInternalSearch(internalSearch) - } - /> - ); - } - - renderSendContent() { - const { history, showHexData, gasIsExcessive } = this.props; - const { toWarning, toError } = this.state; - - return [ - - this.updateGas({ to, amount, data }) - } - showHexData={showHexData} - warning={toWarning} - error={toError} - gasIsExcessive={gasIsExcessive} - />, - , - ]; - } -} diff --git a/ui/pages/send/send.component.test.js b/ui/pages/send/send.component.test.js deleted file mode 100644 index 5cc90307e..000000000 --- a/ui/pages/send/send.component.test.js +++ /dev/null @@ -1,467 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import { - RINKEBY_CHAIN_ID, - ROPSTEN_CHAIN_ID, -} from '../../../shared/constants/network'; -import SendTransactionScreen from './send.component'; -import * as util from './send.utils'; - -import SendHeader from './send-header/send-header.container'; -import SendContent from './send-content/send-content.container'; -import SendFooter from './send-footer/send-footer.container'; - -import AddRecipient from './send-content/add-recipient/add-recipient.container'; - -jest.mock('./send.utils', () => ({ - getToAddressForGasUpdate: jest.fn().mockReturnValue('mockAddress'), - getAmountErrorObject: jest.fn().mockReturnValue({ - amount: 'mockAmountError', - }), - getGasFeeErrorObject: jest.fn().mockReturnValue({ - gasFee: 'mockGasFeeError', - }), - doesAmountErrorRequireUpdate: jest.fn( - (obj) => obj.balance !== obj.prevBalance, - ), -})); - -describe('Send Component', () => { - let wrapper, didMountSpy, updateGasSpy; - - const mockBasicGasEstimates = { - blockTime: 'mockBlockTime', - }; - - const propsMethodSpies = { - updateAndSetGasLimit: jest.fn(), - updateSendErrors: jest.fn(), - updateSendTokenBalance: jest.fn(), - resetSendState: jest.fn(), - fetchBasicGasEstimates: jest.fn(() => - Promise.resolve(mockBasicGasEstimates), - ), - fetchGasEstimates: jest.fn(), - updateToNicknameIfNecessary: jest.fn(), - }; - - beforeAll(() => { - didMountSpy = sinon.spy( - SendTransactionScreen.prototype, - 'componentDidMount', - ); - updateGasSpy = sinon.spy(SendTransactionScreen.prototype, 'updateGas'); - }); - - beforeEach(() => { - wrapper = shallow( - undefined} - scanQrCode={() => undefined} - updateSendEnsResolution={() => undefined} - updateSendEnsResolutionError={() => undefined} - updateSendErrors={propsMethodSpies.updateSendErrors} - updateSendTo={() => undefined} - updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} - resetSendState={propsMethodSpies.resetSendState} - updateToNicknameIfNecessary={ - propsMethodSpies.updateToNicknameIfNecessary - } - gasIsExcessive={false} - />, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - didMountSpy.resetHistory(); - updateGasSpy.resetHistory(); - }); - - describe('componentDidMount', () => { - it('should call componentDidMount', () => { - expect(didMountSpy.callCount).toStrictEqual(1); - }); - - it('should call props.fetchBasicGasAndTimeEstimates', () => { - propsMethodSpies.fetchBasicGasEstimates.mockClear(); - expect(propsMethodSpies.fetchBasicGasEstimates).not.toHaveBeenCalled(); - wrapper.instance().componentDidMount(); - expect(propsMethodSpies.fetchBasicGasEstimates).toHaveBeenCalled(); - }); - - it('should call this.updateGas', () => { - expect(updateGasSpy.callCount).toStrictEqual(1); - }); - }); - - describe('componentWillUnmount', () => { - it('should call this.props.resetSendState', () => { - propsMethodSpies.resetSendState.mockClear(); - expect(propsMethodSpies.resetSendState).not.toHaveBeenCalled(); - wrapper.instance().componentWillUnmount(); - expect(propsMethodSpies.resetSendState).toHaveBeenCalled(); - }); - }); - - describe('componentDidUpdate', () => { - it('should call doesAmountErrorRequireUpdate with the expected params', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: '', - }, - }); - expect(util.doesAmountErrorRequireUpdate).toHaveBeenCalled(); - expect(util.doesAmountErrorRequireUpdate.mock.calls[0][0]).toMatchObject({ - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - prevBalance: '', - prevGasTotal: undefined, - prevTokenBalance: undefined, - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - tokenBalance: 'mockTokenBalance', - }); - }); - - it('should not call getAmountErrorObject if doesAmountErrorRequireUpdate returns false', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'mockBalance', - }, - }); - expect(util.getAmountErrorObject).not.toHaveBeenCalled(); - }); - - it('should call getAmountErrorObject if doesAmountErrorRequireUpdate returns true', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getAmountErrorObject).toHaveBeenCalled(); - expect(util.getAmountErrorObject.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmount', - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - tokenBalance: 'mockTokenBalance', - }); - }); - - it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and sendToken is truthy', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getGasFeeErrorObject).toHaveBeenCalled(); - expect(util.getGasFeeErrorObject.mock.calls[0][0]).toMatchObject({ - balance: 'mockBalance', - conversionRate: 10, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - }); - }); - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => { - wrapper.instance().componentDidUpdate({ - from: { address: 'mockAddress', balance: 'mockBalance' }, - }); - expect(util.getGasFeeErrorObject).not.toHaveBeenCalled(); - }); - - it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but sendToken is falsy', () => { - wrapper.setProps({ sendToken: null }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(util.getGasFeeErrorObject).not.toHaveBeenCalled(); - }); - - it('should call updateSendErrors with the expected params if sendToken is falsy', () => { - wrapper.setProps({ sendToken: null }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(propsMethodSpies.updateSendErrors).toHaveBeenCalledTimes(1); - expect(propsMethodSpies.updateSendErrors.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmountError', - gasFee: null, - }); - }); - - it('should call updateSendErrors with the expected params if sendToken is truthy', () => { - wrapper.setProps({ - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, - }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - }); - expect(propsMethodSpies.updateSendErrors).toHaveBeenCalled(); - expect(propsMethodSpies.updateSendErrors.mock.calls[0][0]).toMatchObject({ - amount: 'mockAmountError', - gasFee: 'mockGasFeeError', - }); - }); - - it('should not call updateSendTokenBalance or this.updateGas if network === prevNetwork', () => { - propsMethodSpies.updateSendTokenBalance.mockClear(); - updateGasSpy.resetHistory(); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: ROPSTEN_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).not.toHaveBeenCalled(); - expect(updateGasSpy.callCount).toStrictEqual(0); - }); - - it('should not call updateSendTokenBalance or this.updateGas if network === loading', () => { - propsMethodSpies.updateSendTokenBalance.mockClear(); - updateGasSpy.resetHistory(); - wrapper.setProps({ network: 'loading' }); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: ROPSTEN_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).not.toHaveBeenCalled(); - expect(updateGasSpy.callCount).toStrictEqual(0); - }); - - it('should call updateSendTokenBalance and this.updateGas with the correct params', () => { - updateGasSpy.resetHistory(); - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balanceChanged', - }, - chainId: RINKEBY_CHAIN_ID, - sendToken: { address: 'mockTokenAddress', decimals: 18, symbol: 'TST' }, // Make sure not to hit updateGas when changing asset - }); - expect(propsMethodSpies.updateSendTokenBalance).toHaveBeenCalled(); - expect( - propsMethodSpies.updateSendTokenBalance.mock.calls[0][0], - ).toMatchObject({ - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, // Make sure not to hit updateGas when changing asset - tokenContract: { method: 'mockTokenMethod' }, - address: 'mockAddress', - }); - expect(updateGasSpy.callCount).toStrictEqual(1); - }); - - it('should call updateGas when sendToken.address is changed', () => { - wrapper.instance().componentDidUpdate({ - from: { - balance: 'balancedChanged', - }, - chainId: ROPSTEN_CHAIN_ID, // Make sure not to hit updateGas when changing network - sendToken: { address: 'newSelectedToken' }, - }); - expect( - propsMethodSpies.updateToNicknameIfNecessary, - ).not.toHaveBeenCalled(); // Network did not change - expect(propsMethodSpies.updateAndSetGasLimit).toHaveBeenCalled(); - }); - }); - - describe('updateGas', () => { - it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => { - propsMethodSpies.updateAndSetGasLimit.mockClear(); - wrapper.instance().updateGas(); - expect(propsMethodSpies.updateAndSetGasLimit).toHaveBeenCalled(); - expect( - propsMethodSpies.updateAndSetGasLimit.mock.calls[0][0], - ).toMatchObject({ - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: 'mockEditingTransactionId', - gasLimit: 'mockGasLimit', - gasPrice: 'mockGasPrice', - selectedAddress: 'mockSelectedAddress', - sendToken: { - address: 'mockTokenAddress', - decimals: 18, - symbol: 'TST', - }, - to: 'mockAddress', - value: 'mockAmount', - data: undefined, - }); - }); - }); - - describe('render', () => { - it('should render a page-container class', () => { - expect(wrapper.find('.page-container')).toHaveLength(1); - }); - - it('should render SendHeader and AddRecipient', () => { - expect(wrapper.find(SendHeader)).toHaveLength(1); - expect(wrapper.find(AddRecipient)).toHaveLength(1); - }); - - it('should pass the history prop to SendHeader and SendFooter', () => { - wrapper.setProps({ - to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - }); - expect(wrapper.find(SendHeader)).toHaveLength(1); - expect(wrapper.find(SendContent)).toHaveLength(1); - expect(wrapper.find(SendFooter)).toHaveLength(1); - expect(wrapper.find(SendFooter).props()).toStrictEqual({ - history: { mockProp: 'history-abc' }, - }); - }); - - it('should pass showHexData to SendContent', () => { - wrapper.setProps({ - to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - }); - expect(wrapper.find(SendContent).props().showHexData).toStrictEqual(true); - }); - }); - - describe('validate when input change', () => { - let clock; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should validate when input changes', () => { - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - ); - - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', - toError: null, - toWarning: null, - }); - }); - - it('should validate when input changes and has error', () => { - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - }); - - it('should validate when input changes and has error on a bad network', () => { - wrapper.setProps({ network: 'bad' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - }); - - it('should synchronously validate when input changes to ""', () => { - wrapper.setProps({ network: 'bad' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510', - toError: 'invalidAddressRecipient', - toWarning: null, - }); - - instance.onRecipientInputChange(''); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '', - toError: '', - toWarning: '', - }); - }); - - it('should warn when send to a known token contract address', () => { - wrapper.setProps({ address: '0x888', decimals: 18, symbol: '888' }); - const instance = wrapper.instance(); - instance.onRecipientInputChange( - '0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64', - ); - - clock.tick(1001); - expect(instance.state).toStrictEqual({ - internalSearch: false, - query: '0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64', - toError: null, - toWarning: 'knownAddressRecipient', - }); - }); - }); -}); diff --git a/ui/pages/send/send.constants.js b/ui/pages/send/send.constants.js index ba5113603..48e96ef95 100644 --- a/ui/pages/send/send.constants.js +++ b/ui/pages/send/send.constants.js @@ -34,17 +34,29 @@ const INVALID_RECIPIENT_ADDRESS_ERROR = 'invalidAddressRecipient'; const INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR = 'invalidAddressRecipientNotEthNetwork'; const REQUIRED_ERROR = 'required'; -const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient'; +const KNOWN_RECIPIENT_ADDRESS_WARNING = 'knownAddressRecipient'; const CONTRACT_ADDRESS_ERROR = 'contractAddressError'; const CONFUSING_ENS_ERROR = 'confusingEnsDomain'; +const ENS_NO_ADDRESS_FOR_NAME = 'noAddressForName'; +const ENS_NOT_FOUND_ON_NETWORK = 'ensNotFoundOnCurrentNetwork'; +const ENS_NOT_SUPPORTED_ON_NETWORK = 'ensNotSupportedOnNetwork'; +const ENS_ILLEGAL_CHARACTER = 'ensIllegalCharacter'; +const ENS_UNKNOWN_ERROR = 'ensUnknownError'; +const ENS_REGISTRATION_ERROR = 'ensRegistrationError'; export { INSUFFICIENT_FUNDS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, CONTRACT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + ENS_NO_ADDRESS_FOR_NAME, + ENS_NOT_FOUND_ON_NETWORK, + ENS_NOT_SUPPORTED_ON_NETWORK, + ENS_ILLEGAL_CHARACTER, + ENS_UNKNOWN_ERROR, + ENS_REGISTRATION_ERROR, MIN_GAS_LIMIT_DEC, MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_DEC, diff --git a/ui/pages/send/send.container.js b/ui/pages/send/send.container.js deleted file mode 100644 index f942131dd..000000000 --- a/ui/pages/send/send.container.js +++ /dev/null @@ -1,138 +0,0 @@ -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { compose } from 'redux'; - -import { - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, - getSendTo, - getSendToNickname, - getTokenBalance, - getQrCodeData, - getSelectedAddress, - getAddressBook, - getSendTokenAddress, - isCustomPriceExcessive, - getCurrentChainId, -} from '../../selectors'; - -import { showQrScanner, qrCodeDetected } from '../../store/actions'; -import { - resetSendState, - updateSendErrors, - updateSendTo, - updateSendTokenBalance, - updateGasData, - setGasTotal, - updateSendEnsResolution, - updateSendEnsResolutionError, -} from '../../ducks/send/send.duck'; -import { fetchBasicGasEstimates } from '../../ducks/gas/gas.duck'; -import { - getBlockGasLimit, - getConversionRate, - getSendHexDataFeatureFlagState, - getTokens, -} from '../../ducks/metamask/metamask'; -import { isValidDomainName } from '../../helpers/utils/util'; -import { calcGasTotal } from './send.utils'; -import SendEther from './send.component'; - -function mapStateToProps(state) { - const editingTransactionId = getSendEditingTransactionId(state); - - return { - addressBook: getAddressBook(state), - amount: getSendAmount(state), - blockGasLimit: getBlockGasLimit(state), - conversionRate: getConversionRate(state), - editingTransactionId, - from: getSendFromObject(state), - gasLimit: getGasLimit(state), - gasPrice: getGasPrice(state), - gasTotal: getGasTotal(state), - chainId: getCurrentChainId(state), - primaryCurrency: getPrimaryCurrency(state), - qrCodeData: getQrCodeData(state), - selectedAddress: getSelectedAddress(state), - sendToken: getSendToken(state), - showHexData: getSendHexDataFeatureFlagState(state), - to: getSendTo(state), - toNickname: getSendToNickname(state), - tokens: getTokens(state), - tokenBalance: getTokenBalance(state), - tokenContract: getSendTokenContract(state), - sendTokenAddress: getSendTokenAddress(state), - gasIsExcessive: isCustomPriceExcessive(state, true), - }; -} - -function mapDispatchToProps(dispatch) { - return { - updateAndSetGasLimit: ({ - blockGasLimit, - editingTransactionId, - gasLimit, - gasPrice, - selectedAddress, - sendToken, - to, - value, - data, - }) => { - editingTransactionId - ? dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) - : dispatch( - updateGasData({ - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - }), - ); - }, - updateSendTokenBalance: ({ sendToken, tokenContract, address }) => { - dispatch( - updateSendTokenBalance({ - sendToken, - tokenContract, - address, - }), - ); - }, - updateSendErrors: (newError) => dispatch(updateSendErrors(newError)), - resetSendState: () => dispatch(resetSendState()), - scanQrCode: () => dispatch(showQrScanner()), - qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), - fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()), - updateSendEnsResolution: (ensResolution) => - dispatch(updateSendEnsResolution(ensResolution)), - updateSendEnsResolutionError: (message) => - dispatch(updateSendEnsResolutionError(message)), - updateToNicknameIfNecessary: (to, toNickname, addressBook) => { - if (isValidDomainName(toNickname)) { - const addressBookEntry = - addressBook.find(({ address }) => to === address) || {}; - if (!addressBookEntry.name !== toNickname) { - dispatch(updateSendTo(to, addressBookEntry.name || '')); - } - } - }, - }; -} - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps), -)(SendEther); diff --git a/ui/pages/send/send.container.test.js b/ui/pages/send/send.container.test.js deleted file mode 100644 index 3072b3243..000000000 --- a/ui/pages/send/send.container.test.js +++ /dev/null @@ -1,128 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendTokenBalance, - updateGasData, - setGasTotal, - updateSendErrors, - resetSendState, -} from '../../ducks/send/send.duck'; - -let mapDispatchToProps; - -jest.mock('react-redux', () => ({ - connect: (_, md) => { - mapDispatchToProps = md; - return () => ({}); - }, -})); - -jest.mock('react-router-dom', () => ({ - withRouter: () => undefined, -})); - -jest.mock('redux', () => ({ - compose: (_, arg2) => () => arg2(), -})); - -jest.mock('../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - resetSendState: jest.fn(), - updateSendTokenBalance: jest.fn(), - updateGasData: jest.fn(), - setGasTotal: jest.fn(), -})); - -jest.mock('./send.utils.js', () => ({ - calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, -})); - -require('./send.container.js'); - -describe('send container', () => { - describe('mapDispatchToProps()', () => { - let dispatchSpy; - let mapDispatchToPropsObject; - - beforeEach(() => { - dispatchSpy = sinon.spy(); - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); - }); - - describe('updateAndSetGasLimit()', () => { - const mockProps = { - blockGasLimit: 'mockBlockGasLimit', - editingTransactionId: '0x2', - gasLimit: '0x3', - gasPrice: '0x4', - selectedAddress: '0x4', - sendToken: { address: '0x1' }, - to: 'mockTo', - value: 'mockValue', - data: undefined, - }; - - it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { - mapDispatchToPropsObject.updateAndSetGasLimit(mockProps); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setGasTotal).toHaveBeenCalledWith('0x30x4'); - }); - - it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { - const { - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - } = mockProps; - mapDispatchToPropsObject.updateAndSetGasLimit({ - ...mockProps, - editingTransactionId: false, - }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateGasData).toHaveBeenCalledWith({ - gasPrice, - selectedAddress, - sendToken, - blockGasLimit, - to, - value, - data, - }); - }); - }); - - describe('updateSendTokenBalance()', () => { - const mockProps = { - address: '0x10', - tokenContract: '0x00a', - sendToken: { address: '0x1' }, - }; - - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTokenBalance({ ...mockProps }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendTokenBalance).toHaveBeenCalledWith(mockProps); - }); - }); - - describe('updateSendErrors()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendErrors('mockError'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalledWith('mockError'); - }); - }); - - describe('resetSendState()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.resetSendState(); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(resetSendState).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js new file mode 100644 index 000000000..1e908d9de --- /dev/null +++ b/ui/pages/send/send.js @@ -0,0 +1,112 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { + getIsUsingMyAccountForRecipientSearch, + getRecipient, + getRecipientUserInput, + getSendStage, + initializeSendState, + resetRecipientInput, + resetSendState, + SEND_STAGES, + updateRecipient, + updateRecipientUserInput, +} from '../../ducks/send'; +import { getCurrentChainId, isCustomPriceExcessive } from '../../selectors'; +import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask'; +import { showQrScanner } from '../../store/actions'; +import { useMetricEvent } from '../../hooks/useMetricEvent'; +import SendHeader from './send-header'; +import AddRecipient from './send-content/add-recipient'; +import SendContent from './send-content'; +import SendFooter from './send-footer'; +import EnsInput from './send-content/add-recipient/ens-input'; + +const sendSliceIsCustomPriceExcessive = (state) => + isCustomPriceExcessive(state, true); + +export default function SendTransactionScreen() { + const history = useHistory(); + const chainId = useSelector(getCurrentChainId); + const stage = useSelector(getSendStage); + const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive); + const isUsingMyAccountsForRecipientSearch = useSelector( + getIsUsingMyAccountForRecipientSearch, + ); + const recipient = useSelector(getRecipient); + const showHexData = useSelector(getSendHexDataFeatureFlagState); + const userInput = useSelector(getRecipientUserInput); + const location = useLocation(); + const trackUsedQRScanner = useMetricEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Used QR scanner', + }, + }); + + const dispatch = useDispatch(); + useEffect(() => { + if (chainId !== undefined) { + dispatch(initializeSendState()); + } + }, [chainId, dispatch]); + + useEffect(() => { + if (location.search === '?scan=true') { + dispatch(showQrScanner()); + + // Clear the queryString param after showing the modal + const cleanUrl = window.location.href.split('?')[0]; + window.history.pushState({}, null, `${cleanUrl}`); + window.location.hash = '#send'; + } + }, [location, dispatch]); + + useEffect(() => { + return () => { + dispatch(resetSendState()); + }; + }, [dispatch]); + + let content; + + if ([SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage)) { + content = ( + <> + + + + ); + } else { + content = ; + } + + return ( +
+ + dispatch(updateRecipientUserInput(address))} + onValidAddressTyped={(address) => + dispatch(updateRecipient({ address, nickname: '' })) + } + internalSearch={isUsingMyAccountsForRecipientSearch} + selectedAddress={recipient.address} + selectedName={recipient.nickname} + onPaste={(text) => updateRecipient({ address: text, nickname: '' })} + onReset={() => dispatch(resetRecipientInput())} + scanQrCode={() => { + trackUsedQRScanner(); + dispatch(showQrScanner()); + }} + /> + {content} +
+ ); +} diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js new file mode 100644 index 000000000..07bef5b26 --- /dev/null +++ b/ui/pages/send/send.test.js @@ -0,0 +1,173 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { useLocation } from 'react-router-dom'; +import { describe } from 'globalthis/implementation'; +import { initialState, SEND_STAGES } from '../../ducks/send'; +import { ensInitialState } from '../../ducks/ens'; +import { renderWithProvider } from '../../../test/jest'; +import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import Send from './send'; + +const middleware = [thunk]; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useLocation: jest.fn(() => ({ search: '' })), + useHistory: () => ({ + push: jest.fn(), + }), + }; +}); + +jest.mock( + 'ethjs-ens', + () => + class MocKENS { + async ensLookup() { + return ''; + } + }, +); + +const baseStore = { + send: initialState, + ENS: ensInitialState, + gas: { + basicEstimateStatus: 'READY', + basicEstimates: { slow: '0x0', average: '0x1', fast: '0x2' }, + customData: { limit: null, price: null }, + }, + history: { mostRecentOverviewPage: 'activity' }, + metamask: { + tokens: [], + preferences: { + useNativeCurrencyAsPrimaryCurrency: false, + }, + currentCurrency: 'USD', + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + nativeCurrency: 'ETH', + featureFlags: { + sendHexData: false, + }, + addressBook: { + [RINKEBY_CHAIN_ID]: [], + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: {}, + }, + accounts: { + '0x0': { balance: '0x0', address: '0x0' }, + }, + identities: { '0x0': {} }, + }, +}; + +describe('Send Page', () => { + describe('Send Flow Initialization', () => { + it('should initialize the send, ENS, and gas slices on render', () => { + const store = configureMockStore(middleware)(baseStore); + renderWithProvider(, store); + const actions = store.getActions(); + expect(actions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ENS/enableEnsLookup', + }), + expect.objectContaining({ + type: 'send/initializeSendState/pending', + }), + expect.objectContaining({ + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + }), + expect.objectContaining({ + type: 'metamask/gas/SET_ESTIMATE_SOURCE', + }), + ]), + ); + }); + + it('should showQrScanner when location.search is ?scan=true', () => { + useLocation.mockImplementation(() => ({ search: '?scan=true' })); + const store = configureMockStore(middleware)(baseStore); + renderWithProvider(, store); + const actions = store.getActions(); + expect(actions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ENS/enableEnsLookup', + }), + expect.objectContaining({ + type: 'send/initializeSendState/pending', + }), + expect.objectContaining({ + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + }), + expect.objectContaining({ + type: 'metamask/gas/SET_ESTIMATE_SOURCE', + }), + expect.objectContaining({ + type: 'UI_MODAL_OPEN', + payload: { name: 'QR_SCANNER' }, + }), + ]), + ); + useLocation.mockImplementation(() => ({ search: '' })); + }); + }); + + describe('Add Recipient Flow', () => { + it('should render the header with Add Recipient displayed', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByText } = renderWithProvider(, store); + expect(getByText('Add Recipient')).toBeTruthy(); + }); + + it('should render the EnsInput field', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByPlaceholderText } = renderWithProvider(, store); + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + }); + + it('should not render the footer', () => { + const store = configureMockStore(middleware)(baseStore); + const { queryByText } = renderWithProvider(, store); + expect(queryByText('Next')).toBeNull(); + }); + }); + + describe('Send and Edit Flow', () => { + it('should render the header with Send displayed', () => { + const store = configureMockStore(middleware)({ + ...baseStore, + send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + }); + const { getByText } = renderWithProvider(, store); + expect(getByText('Send')).toBeTruthy(); + }); + + it('should render the EnsInput field', () => { + const store = configureMockStore(middleware)(baseStore); + const { getByPlaceholderText } = renderWithProvider(, store); + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + }); + + it('should render the footer', () => { + const store = configureMockStore(middleware)({ + ...baseStore, + send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + }); + const { getByText } = renderWithProvider(, store); + expect(getByText('Next')).toBeTruthy(); + }); + }); +}); diff --git a/ui/pages/send/send.utils.js b/ui/pages/send/send.utils.js index 1d7fb3562..d4d4e7670 100644 --- a/ui/pages/send/send.utils.js +++ b/ui/pages/send/send.utils.js @@ -11,28 +11,14 @@ import { import { calcTokenAmount } from '../../helpers/utils/token-util'; import { addHexPrefix } from '../../../app/scripts/lib/util'; -import { GAS_LIMITS } from '../../../shared/constants/gas'; -import { - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, - MIN_GAS_LIMIT_HEX, - NEGATIVE_ETH_ERROR, - TOKEN_TRANSFER_FUNCTION_SIGNATURE, -} from './send.constants'; +import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from './send.constants'; export { addGasBuffer, calcGasTotal, - calcTokenBalance, - doesAmountErrorRequireUpdate, - estimateGasForSend, generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, isBalanceSufficient, isTokenBalanceSufficient, - removeLeadingZeroes, ellipsify, }; @@ -93,186 +79,6 @@ function isTokenBalanceSufficient({ amount = '0x0', tokenBalance, decimals }) { return tokenBalanceIsSufficient; } -function getAmountErrorObject({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, -}) { - let insufficientFunds = false; - if (gasTotal && conversionRate && !sendToken) { - insufficientFunds = !isBalanceSufficient({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - }); - } - - let inSufficientTokens = false; - if (sendToken && tokenBalance !== null) { - const { decimals } = sendToken; - inSufficientTokens = !isTokenBalanceSufficient({ - tokenBalance, - amount, - decimals, - }); - } - - const amountLessThanZero = conversionGreaterThan( - { value: 0, fromNumericBase: 'dec' }, - { value: amount, fromNumericBase: 'hex' }, - ); - - let amountError = null; - - if (insufficientFunds) { - amountError = INSUFFICIENT_FUNDS_ERROR; - } else if (inSufficientTokens) { - amountError = INSUFFICIENT_TOKENS_ERROR; - } else if (amountLessThanZero) { - amountError = NEGATIVE_ETH_ERROR; - } - - return { amount: amountError }; -} - -function getGasFeeErrorObject({ - balance, - conversionRate, - gasTotal, - primaryCurrency, -}) { - let gasFeeError = null; - - if (gasTotal && conversionRate) { - const insufficientFunds = !isBalanceSufficient({ - amount: '0x0', - balance, - conversionRate, - gasTotal, - primaryCurrency, - }); - - if (insufficientFunds) { - gasFeeError = INSUFFICIENT_FUNDS_ERROR; - } - } - - return { gasFee: gasFeeError }; -} - -function calcTokenBalance({ sendToken, usersToken }) { - const { decimals } = sendToken || {}; - return calcTokenAmount(usersToken.balance.toString(), decimals).toString(16); -} - -function doesAmountErrorRequireUpdate({ - balance, - gasTotal, - prevBalance, - prevGasTotal, - prevTokenBalance, - sendToken, - tokenBalance, -}) { - const balanceHasChanged = balance !== prevBalance; - const gasTotalHasChange = gasTotal !== prevGasTotal; - const tokenBalanceHasChanged = sendToken && tokenBalance !== prevTokenBalance; - const amountErrorRequiresUpdate = - balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged; - - return amountErrorRequiresUpdate; -} - -async function estimateGasForSend({ - selectedAddress, - sendToken, - blockGasLimit = MIN_GAS_LIMIT_HEX, - to, - value, - data, - gasPrice, - estimateGasMethod, -}) { - const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; - - // if recipient has no code, gas is 21k max: - if (!sendToken && !data) { - const code = Boolean(to) && (await global.eth.getCode(to)); - // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' - const codeIsEmpty = !code || code === '0x' || code === '0x0'; - if (codeIsEmpty) { - return GAS_LIMITS.SIMPLE; - } - } else if (sendToken && !to) { - return GAS_LIMITS.BASE_TOKEN_ESTIMATE; - } - - if (sendToken) { - paramsForGasEstimate.value = '0x0'; - paramsForGasEstimate.data = generateTokenTransferData({ - toAddress: to, - amount: value, - sendToken, - }); - paramsForGasEstimate.to = sendToken.address; - } else { - if (data) { - paramsForGasEstimate.data = data; - } - - if (to) { - paramsForGasEstimate.to = to; - } - - if (!value || value === '0') { - paramsForGasEstimate.value = '0xff'; - } - } - - // if not, fall back to block gasLimit - if (!blockGasLimit) { - // eslint-disable-next-line no-param-reassign - blockGasLimit = MIN_GAS_LIMIT_HEX; - } - - paramsForGasEstimate.gas = addHexPrefix( - multiplyCurrencies(blockGasLimit, 0.95, { - multiplicandBase: 16, - multiplierBase: 10, - roundDown: '0', - toNumericBase: 'hex', - }), - ); - - // run tx - try { - const estimatedGas = await estimateGasMethod(paramsForGasEstimate); - const estimateWithBuffer = addGasBuffer(estimatedGas, blockGasLimit, 1.5); - return addHexPrefix(estimateWithBuffer); - } catch (error) { - const simulationFailed = - error.message.includes('Transaction execution error.') || - error.message.includes( - 'gas required exceeds allowance or always failing transaction', - ); - if (simulationFailed) { - const estimateWithBuffer = addGasBuffer( - paramsForGasEstimate.gas, - blockGasLimit, - 1.5, - ); - return addHexPrefix(estimateWithBuffer); - } - throw error; - } -} - function addGasBuffer( initialGasLimitHex, blockGasLimitHex, @@ -339,16 +145,6 @@ function generateTokenTransferData({ ); } -function getToAddressForGasUpdate(...addresses) { - return [...addresses, ''] - .find((str) => str !== undefined && str !== null) - .toLowerCase(); -} - -function removeLeadingZeroes(str) { - return str.replace(/^0*(?=\d)/u, ''); -} - function ellipsify(text, first = 6, last = 4) { return `${text.slice(0, first)}...${text.slice(-last)}`; } diff --git a/ui/pages/send/send.utils.test.js b/ui/pages/send/send.utils.test.js index 02b45f1fa..7960b4aca 100644 --- a/ui/pages/send/send.utils.test.js +++ b/ui/pages/send/send.utils.test.js @@ -1,4 +1,3 @@ -import sinon from 'sinon'; import { rawEncode } from 'ethereumjs-abi'; import { @@ -8,26 +7,13 @@ import { conversionUtil, } from '../../helpers/utils/conversion-util'; -import { GAS_LIMITS } from '../../../shared/constants/gas'; import { calcGasTotal, - estimateGasForSend, - doesAmountErrorRequireUpdate, generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - calcTokenBalance, isBalanceSufficient, isTokenBalanceSufficient, - removeLeadingZeroes, } from './send.utils'; -import { - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, -} from './send.constants'; - jest.mock('../../helpers/utils/conversion-util', () => ({ addCurrencies: jest.fn((a, b) => { let [a1, b1] = [a, b]; @@ -67,44 +53,6 @@ describe('send utils', () => { }); }); - describe('doesAmountErrorRequireUpdate()', () => { - const config = { - 'should return true if balances are different': { - balance: 0, - prevBalance: 1, - expectedResult: true, - }, - 'should return true if gasTotals are different': { - gasTotal: 0, - prevGasTotal: 1, - expectedResult: true, - }, - 'should return true if token balances are different': { - tokenBalance: 0, - prevTokenBalance: 1, - sendToken: { address: '0x0' }, - expectedResult: true, - }, - 'should return false if they are all the same': { - balance: 1, - prevBalance: 1, - gasTotal: 1, - prevGasTotal: 1, - tokenBalance: 1, - prevTokenBalance: 1, - sendToken: { address: '0x0' }, - expectedResult: false, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(doesAmountErrorRequireUpdate(obj)).toStrictEqual( - obj.expectedResult, - ); - }); - }); - }); - describe('generateTokenTransferData()', () => { it('should return undefined if not passed a send token', () => { expect( @@ -141,86 +89,6 @@ describe('send utils', () => { }); }); - describe('getAmountErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - amount: 15, - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should not return insufficientFunds error if sendToken is truthy': { - amount: '0x0', - balance: 1, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - sendToken: { address: '0x0', symbol: 'DEF', decimals: 0 }, - decimals: 0, - tokenBalance: 'sometokenbalance', - expectedResult: { amount: null }, - }, - 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': { - amount: '0x10', - balance: 100, - conversionRate: 3, - decimals: 10, - gasTotal: 17, - primaryCurrency: 'ABC', - sendToken: { address: '0x0' }, - tokenBalance: 123, - expectedResult: { amount: INSUFFICIENT_TOKENS_ERROR }, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(getAmountErrorObject(obj)).toStrictEqual(obj.expectedResult); - }); - }); - }); - - describe('getGasFeeErrorObject()', () => { - const config = { - 'should return insufficientFunds error if isBalanceSufficient returns false': { - balance: 16, - conversionRate: 3, - gasTotal: 17, - primaryCurrency: 'ABC', - expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR }, - }, - 'should return null error if isBalanceSufficient returns true': { - balance: 16, - conversionRate: 3, - gasTotal: 15, - primaryCurrency: 'ABC', - expectedResult: { gasFee: null }, - }, - }; - Object.entries(config).forEach(([description, obj]) => { - it(`${description}`, () => { - expect(getGasFeeErrorObject(obj)).toStrictEqual(obj.expectedResult); - }); - }); - }); - - describe('calcTokenBalance()', () => { - it('should return the calculated token balance', () => { - expect( - calcTokenBalance({ - sendToken: { - address: '0x0', - decimals: 11, - }, - usersToken: { - balance: 20, - }, - }), - ).toStrictEqual('calc:2011'); - }); - }); - describe('isBalanceSufficient()', () => { it('should correctly call addCurrencies and return the result of calling conversionGTE', () => { const result = isBalanceSufficient({ @@ -279,201 +147,4 @@ describe('send utils', () => { expect(result).toStrictEqual(false); }); }); - - describe('estimateGasForSend', () => { - const baseMockParams = { - blockGasLimit: '0x64', - selectedAddress: 'mockAddress', - to: '0xisContract', - estimateGasMethod: sinon.stub().callsFake(({ to }) => { - if (typeof to === 'string' && to.match(/willFailBecauseOf:/u)) { - throw new Error(to.match(/:(.+)$/u)[1]); - } - return '0xabc16'; - }), - }; - const baseexpectedCall = { - from: 'mockAddress', - gas: '0x64x0.95', - to: '0xisContract', - value: '0xff', - }; - - beforeEach(() => { - global.eth = { - getCode: sinon - .stub() - .callsFake((address) => - Promise.resolve(address.match(/isContract/u) ? 'not-0x' : '0x'), - ), - }; - }); - - afterEach(() => { - baseMockParams.estimateGasMethod.resetHistory(); - global.eth.getCode.resetHistory(); - }); - - it('should call ethQuery.estimateGasForSend with the expected params', async () => { - const result = await estimateGasForSend(baseMockParams); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: undefined, - ...baseexpectedCall, - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it('should call ethQuery.estimateGasForSend with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - blockGasLimit: '0xbcd', - }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: undefined, - ...baseexpectedCall, - gas: '0xbcdx0.95', - }, - ); - expect(result).toStrictEqual('0xabc16x1.5'); - }); - - it('should call ethQuery.estimateGasForSend with a value of 0x0 and the expected data and to if passed a sendToken', async () => { - const result = await estimateGasForSend({ - data: 'mockData', - sendToken: { address: 'mockAddress' }, - ...baseMockParams, - }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - ...baseexpectedCall, - gasPrice: undefined, - value: '0x0', - data: '0xa9059cbb', - to: 'mockAddress', - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it('should call ethQuery.estimateGasForSend without a recipient if the recipient is empty and data passed', async () => { - const data = 'mockData'; - const to = ''; - const result = await estimateGasForSend({ ...baseMockParams, data, to }); - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(1); - expect(baseMockParams.estimateGasMethod.getCall(0).args[0]).toStrictEqual( - { - gasPrice: undefined, - value: '0xff', - data, - from: baseexpectedCall.from, - gas: baseexpectedCall.gas, - }, - ); - expect(result).toStrictEqual('0xabc16'); - }); - - it(`should return ${GAS_LIMITS.SIMPLE} if ethQuery.getCode does not return '0x'`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ - ...baseMockParams, - to: '0x123', - }); - expect(result).toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should return ${GAS_LIMITS.SIMPLE} if not passed a sendToken or truthy to address`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ ...baseMockParams, to: null }); - expect(result).toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should not return ${GAS_LIMITS.SIMPLE} if passed a sendToken`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ - ...baseMockParams, - to: '0x123', - sendToken: { address: '0x0' }, - }); - expect(result).not.toStrictEqual(GAS_LIMITS.SIMPLE); - }); - - it(`should return ${GAS_LIMITS.BASE_TOKEN_ESTIMATE} if passed a sendToken but no to address`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: null, - sendToken: { address: '0x0' }, - }); - expect(result).toStrictEqual(GAS_LIMITS.BASE_TOKEN_ESTIMATE); - }); - - it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: 'isContract willFailBecauseOf:Transaction execution error.', - }); - expect(result).toStrictEqual('0x64x0.95'); - }); - - it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: - 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.', - }); - expect(result).toStrictEqual('0x64x0.95'); - }); - - it(`should reject other errors`, async () => { - await expect( - estimateGasForSend({ - ...baseMockParams, - to: 'isContract willFailBecauseOf:some other error', - }), - ).rejects.toThrow('some other error'); - }); - }); - - describe('getToAddressForGasUpdate()', () => { - it('should return empty string if all params are undefined or null', () => { - expect(getToAddressForGasUpdate(undefined, null)).toStrictEqual(''); - }); - - it('should return the first string that is not defined or null in lower case', () => { - expect(getToAddressForGasUpdate('A', null)).toStrictEqual('a'); - expect(getToAddressForGasUpdate(undefined, 'B')).toStrictEqual('b'); - }); - }); - - describe('removeLeadingZeroes()', () => { - it('should remove leading zeroes from int when user types', () => { - expect(removeLeadingZeroes('0')).toStrictEqual('0'); - expect(removeLeadingZeroes('1')).toStrictEqual('1'); - expect(removeLeadingZeroes('00')).toStrictEqual('0'); - expect(removeLeadingZeroes('01')).toStrictEqual('1'); - }); - - it('should remove leading zeroes from int when user copy/paste', () => { - expect(removeLeadingZeroes('001')).toStrictEqual('1'); - }); - - it('should remove leading zeroes from float when user types', () => { - expect(removeLeadingZeroes('0.')).toStrictEqual('0.'); - expect(removeLeadingZeroes('0.0')).toStrictEqual('0.0'); - expect(removeLeadingZeroes('0.00')).toStrictEqual('0.00'); - expect(removeLeadingZeroes('0.001')).toStrictEqual('0.001'); - expect(removeLeadingZeroes('0.10')).toStrictEqual('0.10'); - }); - - it('should remove leading zeroes from float when user copy/paste', () => { - expect(removeLeadingZeroes('00.1')).toStrictEqual('0.1'); - }); - }); }); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js index e454838d1..64e076973 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js @@ -11,6 +11,7 @@ import { isBurnAddress, isValidHexAddress, } from '../../../../../shared/modules/hexstring-utils'; +import { INVALID_RECIPIENT_ADDRESS_ERROR } from '../../../send/send.constants'; export default class AddContact extends PureComponent { static contextTypes = { @@ -24,28 +25,32 @@ export default class AddContact extends PureComponent { qrCodeData: PropTypes.object /* eslint-disable-line react/no-unused-prop-types */, qrCodeDetected: PropTypes.func, + ensResolution: PropTypes.string, + ensError: PropTypes.string, + resetResolution: PropTypes.func, }; state = { newName: '', ethAddress: '', - ensAddress: '', error: '', - ensError: '', + input: '', }; constructor(props) { super(props); - this.dValidate = debounce(this.validate, 1000); + this.dValidate = debounce(this.validate, 500); } UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.qrCodeData) { if (nextProps.qrCodeData.type === 'address') { + const { ensResolution } = this.props; const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase(); - const currentAddress = this.state.ensAddress || this.state.ethAddress; + const currentAddress = ensResolution || this.state.ethAddress; if (currentAddress.toLowerCase() !== scannedAddress) { - this.setState({ ethAddress: scannedAddress, ensAddress: '' }); + this.setState({ input: scannedAddress }); + this.validate(scannedAddress); // Clean up QR code data after handling this.props.qrCodeDetected(null); } @@ -62,43 +67,48 @@ export default class AddContact extends PureComponent { if (valid || validEnsAddress || address === '') { this.setState({ error: '', ethAddress: address }); } else { - this.setState({ error: 'Invalid Address' }); + this.setState({ error: INVALID_RECIPIENT_ADDRESS_ERROR }); } }; + onChange = (input) => { + this.setState({ input }); + this.dValidate(input); + }; + renderInput() { return ( { this.props.scanQrCode(); }} - onChange={this.dValidate} - onPaste={(text) => this.setState({ ethAddress: text })} - onReset={() => this.setState({ ethAddress: '', ensAddress: '' })} - updateEnsResolution={(address) => { - this.setState({ ensAddress: address, error: '', ensError: '' }); + onChange={this.onChange} + onPaste={(text) => { + this.setState({ input: text }); + this.validate(text); }} - updateEnsResolutionError={(message) => - this.setState({ ensError: message }) - } - value={this.state.ethAddress || ''} + onReset={() => { + this.props.resetResolution(); + this.setState({ ethAddress: '', input: '' }); + }} + userInput={this.state.input} /> ); } render() { const { t } = this.context; - const { history, addToAddressBook } = this.props; + const { history, addToAddressBook, ensError, ensResolution } = this.props; - const errorToRender = this.state.ensError || this.state.error; + const errorToRender = ensError || this.state.error; return (
- {this.state.ensAddress && ( + {ensResolution && (
- +
- {this.state.ensAddress} + {ensResolution}
)} @@ -124,7 +134,7 @@ export default class AddContact extends PureComponent { {this.renderInput()} {errorToRender && (
- {errorToRender} + {t(errorToRender)}
)}
@@ -134,7 +144,7 @@ export default class AddContact extends PureComponent { disabled={Boolean(this.state.error)} onSubmit={async () => { await addToAddressBook( - this.state.ensAddress || this.state.ethAddress, + ensResolution || this.state.ethAddress, this.state.newName, ); history.push(CONTACT_LIST_ROUTE); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 8d3c63c5f..49f4deb70 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js @@ -6,12 +6,19 @@ import { showQrScanner, qrCodeDetected, } from '../../../../store/actions'; -import { getQrCodeData } from '../../../../selectors'; +import { getQrCodeData } from '../../../../ducks/app/app'; +import { + getEnsError, + getEnsResolution, + resetResolution, +} from '../../../../ducks/ens'; import AddContact from './add-contact.component'; const mapStateToProps = (state) => { return { qrCodeData: getQrCodeData(state), + ensError: getEnsError(state), + ensResolution: getEnsResolution(state), }; }; @@ -21,6 +28,7 @@ const mapDispatchToProps = (dispatch) => { dispatch(addToAddressBook(recipient, nickname)), scanQrCode: () => dispatch(showQrScanner()), qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), + resetResolution: () => dispatch(resetResolution()), }; }; diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index 041d971d2..c53e37c36 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -9,14 +9,10 @@ import { formatETHFee } from '../helpers/utils/formatters'; import { calcGasTotal } from '../pages/send/send.utils'; import { GAS_ESTIMATE_TYPES } from '../helpers/constants/common'; +import { getGasPrice } from '../ducks/send'; import { BASIC_ESTIMATE_STATES, GAS_SOURCE } from '../ducks/gas/gas.duck'; import { GAS_LIMITS } from '../../shared/constants/gas'; -import { - getCurrentCurrency, - getIsMainnet, - getPreferences, - getGasPrice, -} from '.'; +import { getCurrentCurrency, getIsMainnet, getPreferences } from '.'; const NUMBER_OF_DECIMALS_SM_BTNS = 5; @@ -296,7 +292,7 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { const isMainnet = getIsMainnet(state); const showFiat = isMainnet || Boolean(showFiatInTestnets); const gasLimit = - state.send.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; + state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); const { diff --git a/ui/selectors/custom-gas.test.js b/ui/selectors/custom-gas.test.js index 91344b96b..fb383248d 100644 --- a/ui/selectors/custom-gas.test.js +++ b/ui/selectors/custom-gas.test.js @@ -112,7 +112,9 @@ describe('custom-gas selectors', () => { it('should return false gas.basicEstimates.price 0x28bed01600 (175) (checkSend=true)', () => { const mockState = { send: { - gasPrice: '0x28bed0160', + gas: { + gasPrice: '0x28bed0160', + }, }, gas: { customData: { price: null }, @@ -124,7 +126,9 @@ describe('custom-gas selectors', () => { it('should return true gas.basicEstimates.price 0x30e4f9b400 (210) (checkSend=true)', () => { const mockState = { send: { - gasPrice: '0x30e4f9b400', + gas: { + gasPrice: '0x30e4f9b400', + }, }, gas: { customData: { price: null }, @@ -226,7 +230,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -277,7 +283,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -328,7 +336,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -373,7 +383,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -434,7 +446,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -479,7 +493,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -530,7 +546,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -581,7 +599,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { @@ -626,7 +646,9 @@ describe('custom-gas selectors', () => { }, }, send: { - gasLimit: GAS_LIMITS.SIMPLE, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, }, gas: { basicEstimates: { diff --git a/ui/selectors/index.js b/ui/selectors/index.js index b82c59c05..3f4ff3b0e 100644 --- a/ui/selectors/index.js +++ b/ui/selectors/index.js @@ -3,5 +3,4 @@ export * from './custom-gas'; export * from './first-time-flow'; export * from './permissions'; export * from './selectors'; -export * from './send'; export * from './transactions'; diff --git a/ui/selectors/send-selectors-test-data.js b/ui/selectors/send-selectors-test-data.js deleted file mode 100644 index b2663aadb..000000000 --- a/ui/selectors/send-selectors-test-data.js +++ /dev/null @@ -1,214 +0,0 @@ -import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; - -const state = { - metamask: { - isInitialized: true, - isUnlocked: true, - featureFlags: { sendHexData: true }, - identities: { - '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - name: 'Send Account 1', - }, - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': { - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - name: 'Send Account 2', - }, - '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': { - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - name: 'Send Account 3', - }, - '0xd85a4b6a394794842887b8284293d69163007bbb': { - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }, - }, - cachedBalances: {}, - currentBlockGasLimit: '0x4c1878', - currentCurrency: 'USD', - conversionRate: 1200.88200327, - conversionDate: 1489013762, - nativeCurrency: 'ETH', - frequentRpcList: [], - network: '3', - provider: { - type: 'testnet', - chainId: '0x3', - }, - accounts: { - '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { - code: '0x', - balance: '0x47c9d71831c76efe', - nonce: '0x1b', - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - }, - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': { - code: '0x', - balance: '0x37452b1315889f80', - nonce: '0xa', - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - }, - '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': { - code: '0x', - balance: '0x30c9d71831c76efe', - nonce: '0x1c', - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - }, - '0xd85a4b6a394794842887b8284293d69163007bbb': { - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - }, - addressBook: { - '0x3': { - '0x06195827297c7a80a443b6894d3bdb8824b43896': { - address: '0x06195827297c7a80a443b6894d3bdb8824b43896', - name: 'Address Book Account 1', - chainId: '0x3', - }, - }, - }, - tokens: [ - { - address: '0x1a195821297c7a80a433b6894d3bdb8824b43896', - decimals: 18, - symbol: 'ABC', - }, - { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - { - address: '0xa42084c8d1d9a2198631988579bb36b48433a72b', - decimals: 18, - symbol: 'GHI', - }, - ], - transactions: {}, - currentNetworkTxList: [ - { - id: 'mockTokenTx1', - txParams: { - to: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1700000000000, - }, - { - id: 'mockTokenTx2', - txParams: { - to: '0xafaketokenaddress', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1600000000000, - }, - { - id: 'mockTokenTx3', - txParams: { - to: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1500000000000, - }, - { - id: 'mockEthTx1', - txParams: { - to: '0xd85a4b6a394794842887b8284293d69163007bbb', - from: '0xd85a4b6a394794842887b8284293d69163007bbb', - }, - time: 1400000000000, - }, - ], - unapprovedMsgs: { - '0xabc': { id: 'unapprovedMessage1', time: 1650000000000 }, - '0xdef': { id: 'unapprovedMessage2', time: 1550000000000 }, - '0xghi': { id: 'unapprovedMessage3', time: 1450000000000 }, - }, - unapprovedMsgCount: 0, - unapprovedPersonalMsgs: {}, - unapprovedPersonalMsgCount: 0, - unapprovedDecryptMsgs: {}, - unapprovedDecryptMsgCount: 0, - unapprovedEncryptionPublicKeyMsgs: {}, - unapprovedEncryptionPublicKeyMsgCount: 0, - keyringTypes: ['Simple Key Pair', 'HD Key Tree'], - keyrings: [ - { - type: 'HD Key Tree', - accounts: [ - 'fdea65c8e26263f6d9a1b5de9555d2931a33b825', - 'c5b8dbac4c1d3f152cdeb400e2313f309c410acb', - '2f8d4a878cfa04a6e60d46362f5644deab66572d', - ], - }, - { - type: 'Simple Key Pair', - accounts: ['0xd85a4b6a394794842887b8284293d69163007bbb'], - }, - ], - selectedAddress: '0xd85a4b6a394794842887b8284293d69163007bbb', - unapprovedTxs: { - 4768706228115573: { - id: 4768706228115573, - time: 1487363153561, - status: TRANSACTION_STATUSES.UNAPPROVED, - gasMultiplier: 1, - metamaskNetworkId: '3', - txParams: { - from: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - to: '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761', - value: '0xde0b6b3a7640000', - metamaskId: 4768706228115573, - metamaskNetworkId: '3', - gas: '0x5209', - }, - txFee: '17e0186e60800', - txValue: 'de0b6b3a7640000', - maxCost: 'de234b52e4a0800', - gasPrice: '4a817c800', - }, - }, - currentLocale: 'en', - }, - appState: { - menuOpen: false, - currentView: { - name: 'accountDetail', - detailView: null, - context: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - }, - accountDetail: { - subview: 'transactions', - }, - modal: { - modalState: {}, - previousModalState: {}, - }, - isLoading: false, - warning: null, - scrollToBottom: false, - forgottenPassword: null, - }, - identities: {}, - send: { - fromDropdownOpen: false, - gasLimit: '0xFFFF', - gasPrice: '0xaa', - gasTotal: '0xb451dc41b578', - tokenBalance: 3434, - from: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - to: '0x987fedabc', - amount: '0x080', - memo: '', - errors: { - someError: null, - }, - maxModeOn: false, - editingTransactionId: 97531, - }, -}; - -export default state; diff --git a/ui/selectors/send.js b/ui/selectors/send.js deleted file mode 100644 index 3d0b11d83..000000000 --- a/ui/selectors/send.js +++ /dev/null @@ -1,135 +0,0 @@ -import abi from 'human-standard-token-abi'; -import { calcGasTotal } from '../pages/send/send.utils'; -import { - getSelectedAccount, - getTargetAccount, - getAveragePriceEstimateInHexWEI, -} from '.'; - -export function getGasLimit(state) { - return state.send.gasLimit || '0'; -} - -export function getGasPrice(state) { - return state.send.gasPrice || getAveragePriceEstimateInHexWEI(state); -} - -export function getGasTotal(state) { - return calcGasTotal(getGasLimit(state), getGasPrice(state)); -} - -export function getPrimaryCurrency(state) { - const sendToken = getSendToken(state); - return sendToken?.symbol; -} - -export function getSendToken(state) { - return state.send.token; -} - -export function getSendTokenAddress(state) { - return getSendToken(state)?.address; -} - -export function getSendTokenContract(state) { - const sendTokenAddress = getSendTokenAddress(state); - return sendTokenAddress - ? global.eth.contract(abi).at(sendTokenAddress) - : null; -} - -export function getSendAmount(state) { - return state.send.amount; -} - -export function getSendHexData(state) { - return state.send.data; -} - -export function getSendEditingTransactionId(state) { - return state.send.editingTransactionId; -} - -export function getSendErrors(state) { - return state.send.errors; -} - -export function sendAmountIsInError(state) { - return Boolean(state.send.errors.amount); -} - -export function getSendFrom(state) { - return state.send.from; -} - -export function getSendFromBalance(state) { - const fromAccount = getSendFromObject(state); - return fromAccount.balance; -} - -export function getSendFromObject(state) { - const fromAddress = getSendFrom(state); - return fromAddress - ? getTargetAccount(state, fromAddress) - : getSelectedAccount(state); -} - -export function getSendMaxModeState(state) { - return state.send.maxModeOn; -} - -export function getSendTo(state) { - return state.send.to; -} - -export function getSendToNickname(state) { - return state.send.toNickname; -} - -export function getTokenBalance(state) { - return state.send.tokenBalance; -} - -export function getSendEnsResolution(state) { - return state.send.ensResolution; -} - -export function getSendEnsResolutionError(state) { - return state.send.ensResolutionError; -} - -export function getQrCodeData(state) { - return state.appState.qrCodeData; -} - -export function getGasLoadingError(state) { - return state.send.errors.gasLoading; -} - -export function gasFeeIsInError(state) { - return Boolean(state.send.errors.gasFee); -} - -export function getGasButtonGroupShown(state) { - return state.send.gasButtonGroupShown; -} - -export function getTitleKey(state) { - const isEditing = Boolean(getSendEditingTransactionId(state)); - const isToken = Boolean(getSendToken(state)); - - if (!getSendTo(state)) { - return 'addRecipient'; - } - - if (isEditing) { - return 'edit'; - } else if (isToken) { - return 'sendTokens'; - } - return 'send'; -} - -export function isSendFormInError(state) { - return Object.values(getSendErrors(state)).some((n) => n); -} diff --git a/ui/selectors/send.test.js b/ui/selectors/send.test.js deleted file mode 100644 index aadbc28e5..000000000 --- a/ui/selectors/send.test.js +++ /dev/null @@ -1,417 +0,0 @@ -import sinon from 'sinon'; -import { - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - sendAmountIsInError, - getSendEditingTransactionId, - getSendErrors, - getSendFrom, - getSendFromBalance, - getSendFromObject, - getSendMaxModeState, - getSendTo, - getTokenBalance, - gasFeeIsInError, - getGasLoadingError, - getGasButtonGroupShown, - getTitleKey, - isSendFormInError, -} from './send'; -import mockState from './send-selectors-test-data'; -import { - accountsWithSendEtherInfoSelector, - getCurrentAccountWithSendEtherInfo, -} from '.'; - -describe('send selectors', () => { - const tempGlobalEth = { ...global.eth }; - beforeEach(() => { - global.eth = { - contract: sinon.stub().returns({ - at: (address) => `mockAt:${address}`, - }), - }; - }); - - afterEach(() => { - global.eth = tempGlobalEth; - }); - - describe('accountsWithSendEtherInfoSelector()', () => { - it('should return an array of account objects with name info from identities', () => { - expect(accountsWithSendEtherInfoSelector(mockState)).toStrictEqual([ - { - code: '0x', - balance: '0x47c9d71831c76efe', - nonce: '0x1b', - address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825', - name: 'Send Account 1', - }, - { - code: '0x', - balance: '0x37452b1315889f80', - nonce: '0xa', - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - name: 'Send Account 2', - }, - { - code: '0x', - balance: '0x30c9d71831c76efe', - nonce: '0x1c', - address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d', - name: 'Send Account 3', - }, - { - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }, - ]); - }); - }); - - describe('getCurrentAccountWithSendEtherInfo()', () => { - it('should return the currently selected account with identity info', () => { - expect(getCurrentAccountWithSendEtherInfo(mockState)).toStrictEqual({ - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - name: 'Send Account 4', - }); - }); - }); - - describe('getGasLimit()', () => { - it('should return the send.gasLimit', () => { - expect(getGasLimit(mockState)).toStrictEqual('0xFFFF'); - }); - }); - - describe('getGasPrice()', () => { - it('should return the send.gasPrice', () => { - expect(getGasPrice(mockState)).toStrictEqual('0xaa'); - }); - }); - - describe('getGasTotal()', () => { - it('should return the send.gasTotal', () => { - expect(getGasTotal(mockState)).toStrictEqual('a9ff56'); - }); - }); - - describe('getPrimaryCurrency()', () => { - it('should return the symbol of the send token', () => { - expect( - getPrimaryCurrency({ - send: { token: { symbol: 'DEF' } }, - }), - ).toStrictEqual('DEF'); - }); - }); - - describe('getSendToken()', () => { - it('should return the current send token if set', () => { - expect( - getSendToken({ - send: { - token: { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - }, - }), - ).toStrictEqual({ - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }); - }); - }); - - describe('getSendTokenContract()', () => { - it('should return the contract at the send token address', () => { - expect( - getSendTokenContract({ - send: { - token: { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - }, - }), - ).toStrictEqual('mockAt:0x8d6b81208414189a58339873ab429b6c47ab92d3'); - }); - - it('should return null if send token is not set', () => { - expect(getSendTokenContract({ ...mockState, send: {} })).toBeNull(); - }); - }); - - describe('getSendAmount()', () => { - it('should return the send.amount', () => { - expect(getSendAmount(mockState)).toStrictEqual('0x080'); - }); - }); - - describe('getSendEditingTransactionId()', () => { - it('should return the send.editingTransactionId', () => { - expect(getSendEditingTransactionId(mockState)).toStrictEqual(97531); - }); - }); - - describe('getSendErrors()', () => { - it('should return the send.errors', () => { - expect(getSendErrors(mockState)).toStrictEqual({ someError: null }); - }); - }); - - describe('getSendFrom()', () => { - it('should return the send.from', () => { - expect(getSendFrom(mockState)).toStrictEqual( - '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - ); - }); - }); - - describe('getSendFromBalance()', () => { - it('should get the send.from balance if it exists', () => { - expect(getSendFromBalance(mockState)).toStrictEqual('0x37452b1315889f80'); - }); - - it('should get the selected account balance if the send.from does not exist', () => { - const editedMockState = { - ...mockState, - send: { - ...mockState.send, - from: null, - }, - }; - expect(getSendFromBalance(editedMockState)).toStrictEqual('0x0'); - }); - }); - - describe('getSendFromObject()', () => { - it('should return send.from if it exists', () => { - expect(getSendFromObject(mockState)).toStrictEqual({ - address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb', - balance: '0x37452b1315889f80', - code: '0x', - nonce: '0xa', - }); - }); - - it('should return the current account if send.from does not exist', () => { - const editedMockState = { - ...mockState, - send: { - from: null, - }, - }; - expect(getSendFromObject(editedMockState)).toStrictEqual({ - code: '0x', - balance: '0x0', - nonce: '0x0', - address: '0xd85a4b6a394794842887b8284293d69163007bbb', - }); - }); - }); - - describe('getSendMaxModeState()', () => { - it('should return send.maxModeOn', () => { - expect(getSendMaxModeState(mockState)).toStrictEqual(false); - }); - }); - - describe('getSendTo()', () => { - it('should return send.to', () => { - expect(getSendTo(mockState)).toStrictEqual('0x987fedabc'); - }); - }); - - describe('getTokenBalance()', () => { - it('should', () => { - expect(getTokenBalance(mockState)).toStrictEqual(3434); - }); - }); - - describe('send-amount-row selectors', () => { - describe('sendAmountIsInError()', () => { - it('should return true if send.errors.amount is truthy', () => { - const state = { - send: { - errors: { - amount: 'abc', - }, - }, - }; - - expect(sendAmountIsInError(state)).toStrictEqual(true); - }); - - it('should return false if send.errors.amount is falsy', () => { - const state = { - send: { - errors: { - amount: null, - }, - }, - }; - - expect(sendAmountIsInError(state)).toStrictEqual(false); - }); - }); - }); - - describe('send-gas-row selectors', () => { - describe('getGasLoadingError()', () => { - it('should return send.errors.gasLoading', () => { - const state = { - send: { - errors: { - gasLoading: 'abc', - }, - }, - }; - - expect(getGasLoadingError(state)).toStrictEqual('abc'); - }); - }); - - describe('gasFeeIsInError()', () => { - it('should return true if send.errors.gasFee is truthy', () => { - const state = { - send: { - errors: { - gasFee: 'def', - }, - }, - }; - - expect(gasFeeIsInError(state)).toStrictEqual(true); - }); - - it('should return false send.errors.gasFee is falsely', () => { - const state = { - send: { - errors: { - gasFee: null, - }, - }, - }; - - expect(gasFeeIsInError(state)).toStrictEqual(false); - }); - }); - - describe('getGasButtonGroupShown()', () => { - it('should return send.gasButtonGroupShown', () => { - const state = { - send: { - gasButtonGroupShown: 'foobar', - }, - }; - - expect(getGasButtonGroupShown(state)).toStrictEqual('foobar'); - }); - }); - }); - - describe('send-header selectors', () => { - const getMetamaskSendMockState = (send) => { - return { - send: { ...send }, - }; - }; - - describe('getTitleKey()', () => { - it('should return the correct key when "to" is empty', () => { - expect(getTitleKey(getMetamaskSendMockState({}))).toStrictEqual( - 'addRecipient', - ); - }); - - it('should return the correct key when getSendEditingTransactionId is truthy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: true, - token: {}, - }), - ), - ).toStrictEqual('edit'); - }); - - it('should return the correct key when getSendEditingTransactionId is falsy and getSendToken is truthy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: false, - token: {}, - }), - ), - ).toStrictEqual('sendTokens'); - }); - - it('should return the correct key when getSendEditingTransactionId is falsy and getSendToken is falsy', () => { - expect( - getTitleKey( - getMetamaskSendMockState({ - to: true, - editingTransactionId: false, - token: null, - }), - ), - ).toStrictEqual('send'); - }); - }); - }); - - describe('send-footer selectors', () => { - const getSendMockState = (send) => { - return { - send: { ...send }, - }; - }; - - describe('isSendFormInError()', () => { - it('should return true if any of the values of the object returned by getSendErrors are truthy', () => { - expect( - isSendFormInError( - getSendMockState({ - errors: [true], - }), - ), - ).toStrictEqual(true); - }); - - it('should return false if all of the values of the object returned by getSendErrors are falsy', () => { - expect( - isSendFormInError( - getSendMockState({ - errors: [], - }), - ), - ).toStrictEqual(false); - expect( - isSendFormInError( - getSendMockState({ - errors: [false], - }), - ), - ).toStrictEqual(false); - }); - }); - }); -}); diff --git a/ui/store/actionConstants.js b/ui/store/actionConstants.js index d16caa61f..977c6dee2 100644 --- a/ui/store/actionConstants.js +++ b/ui/store/actionConstants.js @@ -15,6 +15,10 @@ export const NETWORK_DROPDOWN_CLOSE = 'UI_NETWORK_DROPDOWN_CLOSE'; // remote state export const UPDATE_METAMASK_STATE = 'UPDATE_METAMASK_STATE'; export const SELECTED_ADDRESS_CHANGED = 'SELECTED_ADDRESS_CHANGED'; +export const SELECTED_ACCOUNT_CHANGED = 'SELECTED_ACCOUNT_CHANGED'; +export const ACCOUNT_CHANGED = 'ACCOUNT_CHANGED'; +export const CHAIN_CHANGED = 'CHAIN_CHANGED'; +export const ADDRESS_BOOK_UPDATED = 'ADDRESS_BOOK_UPDATED'; export const FORGOT_PASSWORD = 'FORGOT_PASSWORD'; export const CLOSE_WELCOME_SCREEN = 'CLOSE_WELCOME_SCREEN'; // unlock screen diff --git a/ui/store/actionConstants.test.js b/ui/store/actionConstants.test.js index 7cfef827f..eba555a51 100644 --- a/ui/store/actionConstants.test.js +++ b/ui/store/actionConstants.test.js @@ -63,9 +63,7 @@ describe('Redux actionConstants', () => { describe('SHOW_ACCOUNT_DETAIL', () => { it('updates metamask state', () => { const initialState = { - metamask: { - selectedAddress: 'foo', - }, + metamask: {}, }; freeze(initialState); @@ -76,9 +74,8 @@ describe('Redux actionConstants', () => { freeze(action); const resultingState = reducers(initialState, action); - expect(resultingState.metamask.selectedAddress).toStrictEqual( - action.value, - ); + expect(resultingState.metamask.isUnlocked).toStrictEqual(true); + expect(resultingState.metamask.isInitialized).toStrictEqual(true); }); }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 57f11fe04..967fb3ca2 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -1,7 +1,6 @@ -import abi from 'human-standard-token-abi'; import pify from 'pify'; import log from 'loglevel'; -import { capitalize } from 'lodash'; +import { capitalize, isEqual } from 'lodash'; import getBuyEthUrl from '../../app/scripts/lib/buy-eth-url'; import { fetchLocale, @@ -15,14 +14,15 @@ import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util'; import txHelper from '../helpers/utils/tx-helper'; import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util'; import { + getMetaMaskAccounts, getPermittedAccountsForCurrentTab, getSelectedAddress, } from '../selectors'; +import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; import { LISTED_CONTRACT_ADDRESSES } from '../../shared/constants/tokens'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; -import { clearSend } from '../ducks/send/send.duck'; import * as actionConstants from './actionConstants'; let background = null; @@ -621,19 +621,6 @@ export function signTypedMsg(msgData) { }; } -export function signTx(txData) { - return async (dispatch) => { - dispatch(showLoadingIndication()); - global.ethQuery.sendTransaction(txData, (err) => { - if (err) { - dispatch(displayWarning(err.message)); - } - }); - dispatch(hideLoadingIndication()); - dispatch(showConfTxPage()); - }; -} - export function updateCustomNonce(value) { return { type: actionConstants.UPDATE_CUSTOM_NONCE, @@ -641,22 +628,6 @@ export function updateCustomNonce(value) { }; } -export function signTokenTx(tokenAddress, toAddress, amount, txData) { - return async (dispatch) => { - dispatch(showLoadingIndication()); - - try { - const token = global.eth.contract(abi).at(tokenAddress); - token.transfer(toAddress, addHexPrefix(amount), txData); - dispatch(showConfTxPage()); - dispatch(hideLoadingIndication()); - } catch (error) { - dispatch(hideLoadingIndication()); - dispatch(displayWarning(error.message)); - } - }; -} - const updateMetamaskStateFromBackground = () => { log.debug(`background.getState`); @@ -721,7 +692,7 @@ export function updateAndApproveTx(txData, dontShowLoadingIndicator) { return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, (err) => { dispatch(updateTransactionParams(txData.id, txData.txParams)); - dispatch(clearSend()); + dispatch(resetSendState()); if (err) { dispatch(txError(err)); @@ -737,7 +708,7 @@ export function updateAndApproveTx(txData, dontShowLoadingIndicator) { .then(() => updateMetamaskStateFromBackground()) .then((newState) => dispatch(updateMetamaskState(newState))) .then(() => { - dispatch(clearSend()); + dispatch(resetSendState()); dispatch(completedTx(txData.id)); dispatch(hideLoadingIndication()); dispatch(updateCustomNonce('')); @@ -907,7 +878,7 @@ export function cancelTx(txData, _showLoadingIndication = true) { .then(() => updateMetamaskStateFromBackground()) .then((newState) => dispatch(updateMetamaskState(newState))) .then(() => { - dispatch(clearSend()); + dispatch(resetSendState()); dispatch(completedTx(txData.id)); dispatch(hideLoadingIndication()); dispatch(closeCurrentNotificationWindow()); @@ -950,7 +921,7 @@ export function cancelTxs(txDataList) { const newState = await updateMetamaskStateFromBackground(); dispatch(updateMetamaskState(newState)); - dispatch(clearSend()); + dispatch(resetSendState()); txIds.forEach((id) => { dispatch(completedTx(id)); @@ -1038,19 +1009,59 @@ export function updateMetamaskState(newState) { return (dispatch, getState) => { const { metamask: currentState } = getState(); - const { currentLocale, selectedAddress } = currentState; + const { currentLocale, selectedAddress, provider } = currentState; const { currentLocale: newLocale, selectedAddress: newSelectedAddress, + provider: newProvider, } = newState; if (currentLocale && newLocale && currentLocale !== newLocale) { dispatch(updateCurrentLocale(newLocale)); } + if (selectedAddress !== newSelectedAddress) { dispatch({ type: actionConstants.SELECTED_ADDRESS_CHANGED }); } + const newAddressBook = newState.addressBook?.[newProvider?.chainId] ?? {}; + const oldAddressBook = currentState.addressBook?.[provider?.chainId] ?? {}; + const newAccounts = getMetaMaskAccounts({ metamask: newState }); + const oldAccounts = getMetaMaskAccounts({ metamask: currentState }); + const newSelectedAccount = newAccounts[newSelectedAddress]; + const oldSelectedAccount = newAccounts[selectedAddress]; + // dispatch an ACCOUNT_CHANGED for any account whose balance or other + // properties changed in this update + Object.entries(oldAccounts).forEach(([address, oldAccount]) => { + if (!isEqual(oldAccount, newAccounts[address])) { + dispatch({ + type: actionConstants.ACCOUNT_CHANGED, + payload: { account: newAccounts[address] }, + }); + } + }); + // Also emit an event for the selected account changing, either due to a + // property update or if the entire account changes. + if (isEqual(oldSelectedAccount, newSelectedAccount) === false) { + dispatch({ + type: actionConstants.SELECTED_ACCOUNT_CHANGED, + payload: { account: newSelectedAccount }, + }); + } + // We need to keep track of changing address book entries + if (isEqual(oldAddressBook, newAddressBook) === false) { + dispatch({ + type: actionConstants.ADDRESS_BOOK_UPDATED, + payload: { addressBook: newAddressBook }, + }); + } + + if (provider.chainId !== newProvider.chainId) { + dispatch({ + type: actionConstants.CHAIN_CHANGED, + payload: newProvider.chainId, + }); + } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE, value: newState, @@ -1141,6 +1152,7 @@ export function showAccountDetail(address) { try { await _setSelectedAddress(dispatch, address); + await forceUpdateMetamaskState(dispatch); } catch (error) { dispatch(displayWarning(error.message)); return; @@ -1234,21 +1246,6 @@ export function addToken( }; } -export function updateTokenType(tokenAddress) { - return async (dispatch) => { - let token = {}; - dispatch(showLoadingIndication()); - try { - token = await promisifiedBackground.updateTokenType(tokenAddress); - } catch (error) { - log.error(error); - } finally { - dispatch(hideLoadingIndication()); - } - return token; - }; -} - export function removeToken(address) { return (dispatch) => { dispatch(showLoadingIndication()); @@ -1672,9 +1669,16 @@ export function hideAlert() { * or null (used to clear the previous value) */ export function qrCodeDetected(qrCodeData) { - return { - type: actionConstants.QR_CODE_DETECTED, - value: qrCodeData, + return async (dispatch) => { + await dispatch({ + type: actionConstants.QR_CODE_DETECTED, + value: qrCodeData, + }); + + // If on the send page, the send slice will listen for the QR_CODE_DETECTED + // action and update its state. Address changes need to recompute gasLimit + // so we fire this method so that the send page gasLimit can be recomputed + dispatch(computeEstimatedGasLimit()); }; } @@ -2719,6 +2723,16 @@ export function estimateGas(params) { return promisifiedBackground.estimateGas(params); } +export async function updateTokenType(tokenAddress) { + let token = {}; + try { + token = await promisifiedBackground.updateTokenType(tokenAddress); + } catch (error) { + log.error(error); + } + return token; +} + // MetaMetrics /** * @typedef {import('../../shared/constants/metametrics').MetaMetricsEventPayload} MetaMetricsEventPayload diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index d28defb8c..847141fd5 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1,7 +1,6 @@ import sinon from 'sinon'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import EthQuery from 'eth-query'; import enLocale from '../../app/_locales/en/messages.json'; import MetaMaskController from '../../app/scripts/metamask-controller'; import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; @@ -14,10 +13,22 @@ const defaultState = { currentLocale: 'test', selectedAddress: '0xFirstAddress', provider: { chainId: '0x1' }, + accounts: { + '0xFirstAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xFirstAddress': '0x0', + }, + }, }, }; const mockStore = (state = defaultState) => configureStore(middleware)(state); +const baseMockState = defaultState.metamask; + describe('Actions', () => { let background; @@ -25,12 +36,7 @@ describe('Actions', () => { beforeEach(async () => { background = sinon.createStubInstance(MetaMaskController, { - getState: sinon.stub().callsFake((cb) => - cb(null, { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }), - ), + getState: sinon.stub().callsFake((cb) => cb(null, baseMockState)), }); }); @@ -58,10 +64,7 @@ describe('Actions', () => { { type: 'UNLOCK_SUCCEEDED', value: undefined }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, { type: 'HIDE_LOADING_INDICATION' }, ]; @@ -111,7 +114,7 @@ describe('Actions', () => { { type: 'UNLOCK_SUCCEEDED', value: undefined }, { type: 'UPDATE_METAMASK_STATE', - value: { currentLocale: 'test', selectedAddress: '0xFirstAddress' }, + value: baseMockState, }, { type: 'DISPLAY_WARNING', value: 'error' }, { type: 'UNLOCK_FAILED', value: 'error' }, @@ -159,10 +162,7 @@ describe('Actions', () => { { type: 'FORGOT_PASSWORD', value: false }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, { type: 'SHOW_ACCOUNTS_PAGE' }, { type: 'HIDE_LOADING_INDICATION' }, @@ -254,6 +254,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xAnotherAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xAnotherAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xAnotherAddress': '0x0', + }, + }, }), ); @@ -264,6 +277,8 @@ describe('Actions', () => { const expectedActions = [ 'SHOW_LOADING_INDICATION', 'SELECTED_ADDRESS_CHANGED', + 'ACCOUNT_CHANGED', + 'SELECTED_ACCOUNT_CHANGED', 'UPDATE_METAMASK_STATE', 'HIDE_LOADING_INDICATION', 'SHOW_ACCOUNTS_PAGE', @@ -400,7 +415,9 @@ describe('Actions', () => { describe('#addNewAccount', () => { it('adds a new account', async () => { - const store = mockStore({ metamask: { identities: {} } }); + const store = mockStore({ + metamask: { identities: {}, ...defaultState.metamask }, + }); const addNewAccount = background.addNewAccount.callsFake((cb) => cb(null, { @@ -660,7 +677,7 @@ describe('Actions', () => { const store = mockStore(); const signMessage = background.signMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -705,7 +722,7 @@ describe('Actions', () => { const store = mockStore(); const signPersonalMessage = background.signPersonalMessage.callsFake( - (_, cb) => cb(null, defaultState), + (_, cb) => cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -786,7 +803,7 @@ describe('Actions', () => { const store = mockStore(); const signTypedMsg = background.signTypedMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -816,58 +833,6 @@ describe('Actions', () => { }); }); - describe('#signTx', () => { - beforeEach(() => { - global.ethQuery = sinon.createStubInstance(EthQuery); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('calls sendTransaction in global ethQuery', async () => { - const store = mockStore(); - - actions._setBackgroundConnection(background); - - await store.dispatch(actions.signTx()); - - expect(global.ethQuery.sendTransaction.callCount).toStrictEqual(1); - }); - - it('errors in when sendTransaction throws', async () => { - const store = mockStore(); - const expectedActions = [ - { type: 'SHOW_LOADING_INDICATION', value: undefined }, - { type: 'DISPLAY_WARNING', value: 'error' }, - { type: 'HIDE_LOADING_INDICATION' }, - { type: 'SHOW_CONF_TX_PAGE', id: undefined }, - ]; - - global.ethQuery.sendTransaction.callsFake((_, callback) => { - callback(new Error('error')); - }); - - actions._setBackgroundConnection(background); - - await store.dispatch(actions.signTx()); - expect(store.getActions()).toStrictEqual(expectedActions); - }); - }); - - describe('#signTokenTx', () => { - it('calls eth.contract', async () => { - global.eth = { - contract: sinon.stub(), - }; - - const store = mockStore(); - - await store.dispatch(actions.signTokenTx()); - expect(global.eth.contract.callCount).toStrictEqual(1); - }); - }); - describe('#updateTransaction', () => { const txParams = { from: '0x1', @@ -895,12 +860,7 @@ describe('Actions', () => { background.getApi.returns({ updateTransaction: updateTransactionStub, - getState: sinon.stub().callsFake((cb) => - cb(null, { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }), - ), + getState: sinon.stub().callsFake((cb) => cb(null, baseMockState)), }); actions._setBackgroundConnection(background.getApi()); @@ -1699,10 +1659,7 @@ describe('Actions', () => { { type: 'FORGOT_PASSWORD', value: true }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, ]; @@ -1762,6 +1719,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xFirstAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xFirstAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xFirstAddress': '0x0', + }, + }, }), ), });