diff --git a/app/_locales/am/messages.json b/app/_locales/am/messages.json index 91c2eb214f58..5516f446532b 100644 --- a/app/_locales/am/messages.json +++ b/app/_locales/am/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "የቅርብ ጊዜያት" }, - "recipientAddress": { - "message": "የተቀባይ አድራሻ" - }, "recipientAddressPlaceholder": { "message": "ፍለጋ፣ ለሕዝብ ክፍት የሆነ አድራሻ (0x), ወይም ENS" }, diff --git a/app/_locales/ar/messages.json b/app/_locales/ar/messages.json index ffe065f16a5c..14ecf04ed944 100644 --- a/app/_locales/ar/messages.json +++ b/app/_locales/ar/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "الحديث" }, - "recipientAddress": { - "message": "عنوان المستلم" - }, "recipientAddressPlaceholder": { "message": "البحث، العنوان العام (0x)، أو ENS" }, diff --git a/app/_locales/bg/messages.json b/app/_locales/bg/messages.json index dde63701f34f..9903c065a01a 100644 --- a/app/_locales/bg/messages.json +++ b/app/_locales/bg/messages.json @@ -750,9 +750,6 @@ "recents": { "message": "Скорошни" }, - "recipientAddress": { - "message": "Адрес на получателя" - }, "recipientAddressPlaceholder": { "message": "Търсене, публичен адрес (0x) или ENS" }, diff --git a/app/_locales/bn/messages.json b/app/_locales/bn/messages.json index d2c08ab7bc20..c35429f22df9 100644 --- a/app/_locales/bn/messages.json +++ b/app/_locales/bn/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "সাম্প্রতিকগুলি" }, - "recipientAddress": { - "message": "প্রাপকের ঠিকানা" - }, "recipientAddressPlaceholder": { "message": "অনুসন্ধান, সার্বজনীন ঠিকানা (0x), বা ENS" }, diff --git a/app/_locales/ca/messages.json b/app/_locales/ca/messages.json index ab0e91b84220..1608d3fbfe92 100644 --- a/app/_locales/ca/messages.json +++ b/app/_locales/ca/messages.json @@ -732,9 +732,6 @@ "readdToken": { "message": "Pots tornar a afegir aquesta fitxa en el futur anant a \"Afegir fitxa\" al menu d'opcions dels teus comptes." }, - "recipientAddress": { - "message": "Adreça del destinatari" - }, "recipientAddressPlaceholder": { "message": "Cerca, adreça pública (0x), o ENS" }, diff --git a/app/_locales/cs/messages.json b/app/_locales/cs/messages.json index 442b91544333..cc75f6444780 100644 --- a/app/_locales/cs/messages.json +++ b/app/_locales/cs/messages.json @@ -308,9 +308,6 @@ "readdToken": { "message": "Tento token můžete v budoucnu přidat zpět s „Přidat token“ v nastavení účtu." }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "reject": { "message": "Odmítnout" }, diff --git a/app/_locales/da/messages.json b/app/_locales/da/messages.json index 24fd9a480f64..05daa9b459f8 100644 --- a/app/_locales/da/messages.json +++ b/app/_locales/da/messages.json @@ -735,9 +735,6 @@ "recents": { "message": "Seneste" }, - "recipientAddress": { - "message": "Modtagerens adresse" - }, "recipientAddressPlaceholder": { "message": "Søg, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index f04fba67c269..e38061843447 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -723,9 +723,6 @@ "recents": { "message": "Letzte" }, - "recipientAddress": { - "message": "Empfängeradresse" - }, "recipientAddressPlaceholder": { "message": "Suchen, öffentliche Adresse (0x) oder ENS" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index d7902743a18d..d18a8ef43e1a 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Πρόσφατα" }, - "recipientAddress": { - "message": "Διεύθυνση Παραλήπτη" - }, "recipientAddressPlaceholder": { "message": "Αναζήτηση, δημόσια διεύθυνση (0x) ή ENS" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 36f1930cd9e5..1d6fc4709a1a 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -643,6 +643,9 @@ "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" }, @@ -1412,9 +1415,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 ed988deac2ac..4237af5da52c 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -1204,9 +1204,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Buscar, dirección pública (0x) o ENS" }, diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 706afa7be413..d5c932045ec5 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Buscar, dirección pública (0x) o ENS" }, diff --git a/app/_locales/et/messages.json b/app/_locales/et/messages.json index 4139c4a922e4..36bac4a2f1ff 100644 --- a/app/_locales/et/messages.json +++ b/app/_locales/et/messages.json @@ -744,9 +744,6 @@ "recents": { "message": "Hiljutised" }, - "recipientAddress": { - "message": "Saaja aadress" - }, "recipientAddressPlaceholder": { "message": "Otsing, avalik aadress (0x) või ENS" }, diff --git a/app/_locales/fa/messages.json b/app/_locales/fa/messages.json index a60b658cfbd9..4d871ca55fd5 100644 --- a/app/_locales/fa/messages.json +++ b/app/_locales/fa/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "واپسین" }, - "recipientAddress": { - "message": "آدرس دریافت کننده" - }, "recipientAddressPlaceholder": { "message": "جستجو، آدرس عمومی (0x)، یا ENS" }, diff --git a/app/_locales/fi/messages.json b/app/_locales/fi/messages.json index 79b779fbb5b0..a2bc0c34dae0 100644 --- a/app/_locales/fi/messages.json +++ b/app/_locales/fi/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Viimeaikaiset" }, - "recipientAddress": { - "message": "Vastaanottajan osoite" - }, "recipientAddressPlaceholder": { "message": "Haku, julkinen osoite (0x) tai ENS" }, diff --git a/app/_locales/fil/messages.json b/app/_locales/fil/messages.json index be0fef29c91a..43f7b79d7072 100644 --- a/app/_locales/fil/messages.json +++ b/app/_locales/fil/messages.json @@ -678,9 +678,6 @@ "recents": { "message": "Kamakailan" }, - "recipientAddress": { - "message": "Address ng Recipient" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 77207d133b80..2e5bb49f95e7 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -736,9 +736,6 @@ "recents": { "message": "Récents" }, - "recipientAddress": { - "message": "Adresse du destinataire" - }, "recipientAddressPlaceholder": { "message": "Recherche, adresse publique (0x) ou ENS" }, diff --git a/app/_locales/he/messages.json b/app/_locales/he/messages.json index 72123870b090..f8ca504c0175 100644 --- a/app/_locales/he/messages.json +++ b/app/_locales/he/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "אחרונים" }, - "recipientAddress": { - "message": "כתובת הנמען" - }, "recipientAddressPlaceholder": { "message": "חיפוש, כתובת ציבורית (0x), או ENS" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 3ae4310ae96b..e9195b49672b 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "हाल ही के" }, - "recipientAddress": { - "message": "प्राप्तकर्ता का पता" - }, "recipientAddressPlaceholder": { "message": "खोज, सार्वजनिक पता (0x) या ENS" }, diff --git a/app/_locales/hn/messages.json b/app/_locales/hn/messages.json index f440c3ff9a5c..2dfdfd179808 100644 --- a/app/_locales/hn/messages.json +++ b/app/_locales/hn/messages.json @@ -285,9 +285,6 @@ "readdToken": { "message": "आप अपने खाता विकल्प मेनू में .टोकन जोड़ें. पर जाकर भविष्य में इस टोकन को वापस जोड़ सकते हैं।" }, - "recipientAddress": { - "message": "प्राप्तकर्ता पता" - }, "reject": { "message": "अस्वीकार" }, diff --git a/app/_locales/hr/messages.json b/app/_locales/hr/messages.json index bf02b3939cc0..76004391850d 100644 --- a/app/_locales/hr/messages.json +++ b/app/_locales/hr/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Adresa primatelja" - }, "recipientAddressPlaceholder": { "message": "Pretraži, javne adrese (0x) ili ENS" }, diff --git a/app/_locales/ht/messages.json b/app/_locales/ht/messages.json index 2f750cd7d9d5..61d5c1a9696e 100644 --- a/app/_locales/ht/messages.json +++ b/app/_locales/ht/messages.json @@ -450,9 +450,6 @@ "readdToken": { "message": "Ou ka ajoute token sa aprè sa ankò ou prale nan \"Ajoute token\" nan opsyon meni kont ou an." }, - "recipientAddress": { - "message": "Adrès pou resevwa" - }, "reject": { "message": "Rejte" }, diff --git a/app/_locales/hu/messages.json b/app/_locales/hu/messages.json index cb397cd69c03..b124316cde66 100644 --- a/app/_locales/hu/messages.json +++ b/app/_locales/hu/messages.json @@ -747,9 +747,6 @@ "recents": { "message": "Legutóbbiak" }, - "recipientAddress": { - "message": "Címzett címe" - }, "recipientAddressPlaceholder": { "message": "Keresés, nyilvános cím (0x) vagy ENS" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 71712e1e5d30..740c837636fd 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -1195,9 +1195,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 36212c4f6ca3..98150805808e 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -1201,9 +1201,6 @@ "recents": { "message": "Recenti" }, - "recipientAddress": { - "message": "Indirizzo Destinatario" - }, "recipientAddressPlaceholder": { "message": "Ricerca, indirizzo pubblico (0x) o ENS" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 0b03a20aa61f..9e489b218d09 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -1204,9 +1204,6 @@ "recents": { "message": "最近の履歴" }, - "recipientAddress": { - "message": "受取アドレス" - }, "recipientAddressPlaceholder": { "message": "パブリックアドレス(0x)、またはENSを検索" }, diff --git a/app/_locales/kn/messages.json b/app/_locales/kn/messages.json index cbe33d70e59b..dff966fe2aa3 100644 --- a/app/_locales/kn/messages.json +++ b/app/_locales/kn/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "ಇತ್ತೀಚಿನವುಗಳು" }, - "recipientAddress": { - "message": "ಸ್ವೀಕರಿಸುವವರ ವಿಳಾಸ" - }, "recipientAddressPlaceholder": { "message": "ಸಾರ್ವಜನಿಕ ವಿಳಾಸ (0x) ಅಥವಾ ENS ಹುಡುಕಿ" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index de0131234ab8..a93c36742aff 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "최근" }, - "recipientAddress": { - "message": "수신인 주소" - }, "recipientAddressPlaceholder": { "message": "검색, 공개 주소(0x) 또는 ENS" }, diff --git a/app/_locales/lt/messages.json b/app/_locales/lt/messages.json index f18067844983..833ccc493812 100644 --- a/app/_locales/lt/messages.json +++ b/app/_locales/lt/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "Naujausi" }, - "recipientAddress": { - "message": "Gavėjo adresas" - }, "recipientAddressPlaceholder": { "message": "Ieška, viešieji adresai (0x) arba ENS" }, diff --git a/app/_locales/lv/messages.json b/app/_locales/lv/messages.json index 85848338a4e6..0ad89609f8e3 100644 --- a/app/_locales/lv/messages.json +++ b/app/_locales/lv/messages.json @@ -750,9 +750,6 @@ "recents": { "message": "Nesenie" }, - "recipientAddress": { - "message": "Saņēmēja adrese" - }, "recipientAddressPlaceholder": { "message": "Meklēšana, publiskā adrese (0x) vai ENS" }, diff --git a/app/_locales/ms/messages.json b/app/_locales/ms/messages.json index 483925f9c7db..1f5438c72ca7 100644 --- a/app/_locales/ms/messages.json +++ b/app/_locales/ms/messages.json @@ -731,9 +731,6 @@ "recents": { "message": "Baru-baru ini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat awam (0x), atau ENS" }, diff --git a/app/_locales/nl/messages.json b/app/_locales/nl/messages.json index 2366f8283643..aaaac5ffcd08 100644 --- a/app/_locales/nl/messages.json +++ b/app/_locales/nl/messages.json @@ -272,9 +272,6 @@ "readdToken": { "message": "U kunt dit token in de toekomst weer toevoegen door naar \"Token toevoegen\" te gaan in het menu met accountopties." }, - "recipientAddress": { - "message": "Geadresseerde adres" - }, "reject": { "message": "Afwijzen" }, diff --git a/app/_locales/no/messages.json b/app/_locales/no/messages.json index a6bba82538d1..ff6fd6bfd5f3 100644 --- a/app/_locales/no/messages.json +++ b/app/_locales/no/messages.json @@ -741,9 +741,6 @@ "recents": { "message": "Nylige" }, - "recipientAddress": { - "message": "Mottakeradresse" - }, "recipientAddressPlaceholder": { "message": "Søk, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index f15348820b48..cc994c966bab 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -189,9 +189,6 @@ "readdToken": { "message": "Upang muling idagdag ang token na ito, pumunta sa “Magdagdag ng Token” sa options menu ng iyong account." }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "reject": { "message": "Tanggihan" }, diff --git a/app/_locales/pl/messages.json b/app/_locales/pl/messages.json index e2f4db68c757..0759406041e9 100644 --- a/app/_locales/pl/messages.json +++ b/app/_locales/pl/messages.json @@ -748,9 +748,6 @@ "recents": { "message": "Ostatnie" }, - "recipientAddress": { - "message": "Adres odbiorcy" - }, "recipientAddressPlaceholder": { "message": "Szukaj, adres publiczny (0x) lub ENS" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index c9f598cba074..e72e564df877 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -282,9 +282,6 @@ "readdToken": { "message": "Pode adicionar este token de novo clicando na opção “Adicionar token” no menu de opções da sua conta." }, - "recipientAddress": { - "message": "Endereço do Destinatário" - }, "reject": { "message": "Rejeitar" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index 69c028186cfa..86e5f830dbaf 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "Recentes" }, - "recipientAddress": { - "message": "Endereço do Destinatário" - }, "recipientAddressPlaceholder": { "message": "Buscar, endereço público (0x) ou ENS" }, diff --git a/app/_locales/ro/messages.json b/app/_locales/ro/messages.json index 444ba049f116..d18b032dfd21 100644 --- a/app/_locales/ro/messages.json +++ b/app/_locales/ro/messages.json @@ -741,9 +741,6 @@ "recents": { "message": "Recente" }, - "recipientAddress": { - "message": "Adresă destinatar" - }, "recipientAddressPlaceholder": { "message": "Căutare, adresa publică (0x) sau ENS" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 7e74a02e9637..4c1229712f0c 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "Недавние" }, - "recipientAddress": { - "message": "Адрес получателя" - }, "recipientAddressPlaceholder": { "message": "Поиск, публичный адрес (0x) или ENS" }, diff --git a/app/_locales/sk/messages.json b/app/_locales/sk/messages.json index e23dfb73ec58..c8be9c255012 100644 --- a/app/_locales/sk/messages.json +++ b/app/_locales/sk/messages.json @@ -723,9 +723,6 @@ "recents": { "message": "Posledné" }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "recipientAddressPlaceholder": { "message": "Vyhľadávať verejnú adresu (0x) alebo ENS" }, diff --git a/app/_locales/sl/messages.json b/app/_locales/sl/messages.json index 47a5a1737837..d1779de49061 100644 --- a/app/_locales/sl/messages.json +++ b/app/_locales/sl/messages.json @@ -742,9 +742,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Prejemnikov naslov" - }, "recipientAddressPlaceholder": { "message": "Iskanje, javni naslov (0x) ali ENS" }, diff --git a/app/_locales/sr/messages.json b/app/_locales/sr/messages.json index 7cbcdf1757d9..50780aa35303 100644 --- a/app/_locales/sr/messages.json +++ b/app/_locales/sr/messages.json @@ -745,9 +745,6 @@ "recents": { "message": "Skorašnje" }, - "recipientAddress": { - "message": "Adresa primaoca" - }, "recipientAddressPlaceholder": { "message": "Pretraga, javna adresa (0x) ili ENS" }, diff --git a/app/_locales/sv/messages.json b/app/_locales/sv/messages.json index bfa2e92f60e6..4ce66b4e6f06 100644 --- a/app/_locales/sv/messages.json +++ b/app/_locales/sv/messages.json @@ -738,9 +738,6 @@ "recents": { "message": "Senaste" }, - "recipientAddress": { - "message": "Mottagaradress" - }, "recipientAddressPlaceholder": { "message": "Sök, allmän adress (0x) eller ENS" }, diff --git a/app/_locales/sw/messages.json b/app/_locales/sw/messages.json index f4d9acc5b421..541a39f90d77 100644 --- a/app/_locales/sw/messages.json +++ b/app/_locales/sw/messages.json @@ -732,9 +732,6 @@ "recents": { "message": "Za hivi karibuni" }, - "recipientAddress": { - "message": "Anwani ya Mpokeaji" - }, "recipientAddressPlaceholder": { "message": "Tafuta, anwani za umma (0x), au ENS" }, diff --git a/app/_locales/ta/messages.json b/app/_locales/ta/messages.json index 29a239a41cb1..4f6ab5c14a3c 100644 --- a/app/_locales/ta/messages.json +++ b/app/_locales/ta/messages.json @@ -372,9 +372,6 @@ "readdToken": { "message": "உங்கள் கணக்கு விருப்பங்கள் மெனுவில் \"டோக்கனைச் சேர்\" என்பதன் மூலம் நீங்கள் எதிர்காலத்தில் இந்த டோக்கனை மீண்டும் சேர்க்கலாம்." }, - "recipientAddress": { - "message": "பெறுநர் முகவரி" - }, "reject": { "message": "நிராகரி" }, diff --git a/app/_locales/th/messages.json b/app/_locales/th/messages.json index 324722400c88..0f8e7cdeb6fd 100644 --- a/app/_locales/th/messages.json +++ b/app/_locales/th/messages.json @@ -375,9 +375,6 @@ "readdToken": { "message": "คุณสามารถเพิ่มโทเค็นนี้ในอนาคตได้โดยไปที่ “เพิ่มโทเค็น” ในเมนูตัวเลือกบัญชีของคุณ" }, - "recipientAddress": { - "message": "แอดแดรสผู้รับ" - }, "reject": { "message": "ปฏิเสธ" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index a6f9e150698a..c858d74a25cb 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -1192,9 +1192,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index d47b570adb4b..1bb278b72fe3 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -324,9 +324,6 @@ "readdToken": { "message": "Gelecekte Bu jetonu hesap seçenekleri menüsünde “Jeton ekle”'ye giderek geri ekleyebilirsiniz." }, - "recipientAddress": { - "message": "Alıcı adresi" - }, "reject": { "message": "Reddetmek" }, diff --git a/app/_locales/uk/messages.json b/app/_locales/uk/messages.json index 27766159ab9c..5b6fe762ec3c 100644 --- a/app/_locales/uk/messages.json +++ b/app/_locales/uk/messages.json @@ -754,9 +754,6 @@ "recents": { "message": "Останні" }, - "recipientAddress": { - "message": "Адреса отримувача" - }, "recipientAddressPlaceholder": { "message": "Пошук, публічна адреса (0x), або ENS" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index d5fc9a3d38fc..aac1d76d4164 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -1195,9 +1195,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 d8a1027163c2..7ef1e625a721 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1195,9 +1195,6 @@ "recents": { "message": "最近记录" }, - "recipientAddress": { - "message": "接收地址" - }, "recipientAddressPlaceholder": { "message": "查找、公用地址 (0x) 或 ENS" }, diff --git a/app/_locales/zh_TW/messages.json b/app/_locales/zh_TW/messages.json index bd95f859ea35..5756be9285d5 100644 --- a/app/_locales/zh_TW/messages.json +++ b/app/_locales/zh_TW/messages.json @@ -751,9 +751,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "接收位址" - }, "recipientAddressPlaceholder": { "message": "搜尋,公開地址 (0x),或 ENS" }, diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 9b7011a66c85..1e9623aa2bff 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -23,6 +23,7 @@ import { TRANSACTION_TYPES, } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; +import { GAS_LIMITS } from '../../../../shared/constants/gas'; import TransactionStateManager from './tx-state-manager'; import TxGasUtil from './tx-gas-utils'; import PendingTransactionTracker from './pending-tx-tracker'; @@ -30,7 +31,6 @@ import * as txUtils from './lib/util'; const hstInterface = new ethers.utils.Interface(abi); -const SIMPLE_GAS_COST = '0x5208'; // Hex for 21000, cost of a simple send. const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory /** @@ -366,7 +366,7 @@ export default class TransactionController extends EventEmitter { } // This is a standard ether simple send, gas requirement is exactly 21k - return { gasLimit: SIMPLE_GAS_COST }; + return { gasLimit: GAS_LIMITS.SIMPLE }; } const { diff --git a/shared/constants/gas.js b/shared/constants/gas.js new file mode 100644 index 000000000000..910cd4859a7c --- /dev/null +++ b/shared/constants/gas.js @@ -0,0 +1,6 @@ +export const GAS_LIMITS = { + // Hex for 21000, maximum gasLimit of a simple send + SIMPLE: '0x5208', + // Hex for 100000, a base estimate for token transfers. + BASE_TOKEN_ESTIMATE: '0x186a0', +}; 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 7096664d46a8..743786aa7f1e 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 '../../../store/actions'; +import { updateSendAsset } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; @@ -68,10 +68,13 @@ const AssetListItem = ({ e.stopPropagation(); sendTokenEvent(); dispatch( - updateSendToken({ - address: tokenAddress, - decimals: tokenDecimals, - symbol: tokenSymbol, + updateSendAsset({ + type: 'TOKEN', + details: { + address: tokenAddress, + decimals: tokenDecimals, + symbol: tokenSymbol, + }, }), ); history.push(SEND_ROUTE); diff --git a/ui/components/app/asset-list/asset-list.js b/ui/components/app/asset-list/asset-list.js index e4b9efea47ab..98ce9f04207f 100644 --- a/ui/components/app/asset-list/asset-list.js +++ b/ui/components/app/asset-list/asset-list.js @@ -11,10 +11,10 @@ import { useMetricEvent } from '../../../hooks/useMetricEvent'; import { useUserPreferencedCurrency } from '../../../hooks/useUserPreferencedCurrency'; import { getCurrentAccountWithSendEtherInfo, - getNativeCurrency, getShouldShowFiat, getNativeCurrencyImage, } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { useCurrencyDisplay } from '../../../hooks/useCurrencyDisplay'; const AssetList = ({ onClickAsset }) => { 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 bcbbaa9d1454..4d8d5f5ccf51 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 @@ -1,6 +1,6 @@ import sinon from 'sinon'; -import { hideModal, setGasLimit, setGasPrice } from '../../../../store/actions'; +import { hideModal } from '../../../../store/actions'; import { setCustomGasPrice, @@ -8,7 +8,11 @@ import { resetCustomData, } from '../../../../ducks/gas/gas.duck'; -import { hideGasButtonGroup } from '../../../../ducks/send/send.duck'; +import { + hideGasButtonGroup, + setGasLimit, + setGasPrice, +} from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -29,7 +33,7 @@ jest.mock('../../../../selectors', () => ({ getDefaultActiveButtonIndex: (a, b) => a + b, getCurrentEthBalance: (state) => state.metamask.balance || '0x0', getSendToken: () => null, - getTokenBalance: (state) => state.metamask.send.tokenBalance || '0x0', + 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'), @@ -44,8 +48,6 @@ jest.mock('../../../../selectors', () => ({ jest.mock('../../../../store/actions', () => ({ hideModal: jest.fn(), - setGasLimit: jest.fn(), - setGasPrice: jest.fn(), updateTransaction: jest.fn(), })); @@ -55,8 +57,10 @@ jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ +jest.mock('../../../../ducks/send', () => ({ hideGasButtonGroup: jest.fn(), + setGasLimit: jest.fn(), + setGasPrice: jest.fn(), })); require('./gas-modal-page-container.container'); 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 d85d07c72618..e23171ac5443 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 @@ -2,13 +2,9 @@ import { connect } from 'react-redux'; import { addHexPrefix } from '../../../../../app/scripts/lib/util'; import { hideModal, - setGasLimit, - setGasPrice, createRetryTransaction, createSpeedUpTransaction, hideSidebar, - updateSendAmount, - setGasTotal, updateTransaction, } from '../../../../store/actions'; import { @@ -18,15 +14,20 @@ import { fetchBasicGasEstimates, } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - updateSendErrors, -} from '../../../../ducks/send/send.duck'; + getSendMaxModeState, + getSendToken, + getGasLimit, + getGasPrice, + getSendAmount, + updateGasLimit, + updateGasPrice, + useAdvancedGasEstimation, +} from '../../../../ducks/send'; import { conversionRateSelector as getConversionRate, getCurrentCurrency, getCurrentEthBalance, getIsMainnet, - getSendToken, getPreferences, getBasicGasEstimateLoadingStatus, getCustomGasLimit, @@ -34,8 +35,6 @@ import { getDefaultActiveButtonIndex, getRenderableBasicEstimateData, isCustomPriceSafe, - getTokenBalance, - getSendMaxModeState, getAveragePriceEstimateInHexWEI, isCustomPriceExcessive, } from '../../../../selectors'; @@ -53,12 +52,14 @@ 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 GasModalPageContainer from './gas-modal-page-container.component'; const mapStateToProps = (state, ownProps) => { - const { currentNetworkTxList, send } = state.metamask; + 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; @@ -72,9 +73,9 @@ const mapStateToProps = (state, ownProps) => { const txParams = selectedTransaction?.txParams ? selectedTransaction.txParams : { - gas: send.gasLimit || '0x5208', - gasPrice: send.gasPrice || getAveragePriceEstimateInHexWEI(state, true), - value: sendToken ? '0x0' : send.amount, + gas: gasLimit || '0x5208', + gasPrice: gasPrice || getAveragePriceEstimateInHexWEI(state, true), + value: sendToken ? '0x0' : amount, }; const { gasPrice: currentGasPrice, gas: currentGasLimit } = txParams; @@ -173,7 +174,6 @@ const mapStateToProps = (state, ownProps) => { isMainnet, sendToken, balance, - tokenBalance: getTokenBalance(state), conversionRate, value, onSubmit, @@ -190,12 +190,13 @@ const mapDispatchToProps = (dispatch) => { dispatch(hideModal()); }, hideModal: () => dispatch(hideModal()), + useAdvancedGasEstimation: () => dispatch(useAdvancedGasEstimation()), 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); @@ -208,14 +209,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))); - }, }; }; @@ -228,17 +223,11 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { isSpeedUp, isRetry, insufficientBalance, - maxModeOn, customGasPrice, - customGasTotal, - balance, - sendToken, - tokenBalance, customGasLimit, transaction, } = stateProps; const { - hideGasButtonGroup: dispatchHideGasButtonGroup, setGasData: dispatchSetGasData, updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate, createSpeedUpTransaction: dispatchCreateSpeedUpTransaction, @@ -246,7 +235,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { hideSidebar: dispatchHideSidebar, cancelAndClose: dispatchCancelAndClose, hideModal: dispatchHideModal, - setAmountToMax: dispatchSetAmountToMax, ...otherDispatchProps } = dispatchProps; @@ -282,17 +270,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { dispatchCancelAndClose(); } else { dispatchSetGasData(gasLimit, gasPrice); - dispatchHideGasButtonGroup(); + dispatchProps.useAdvancedGasEstimation(); dispatchCancelAndClose(); } - if (maxModeOn) { - dispatchSetAmountToMax({ - balance, - gasTotal: customGasTotal, - sendToken, - tokenBalance, - }); - } }, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, diff --git a/ui/components/app/transaction-activity-log/transaction-activity-log.container.js b/ui/components/app/transaction-activity-log/transaction-activity-log.container.js index 99f96bacaf43..dad4e1ee79f1 100644 --- a/ui/components/app/transaction-activity-log/transaction-activity-log.container.js +++ b/ui/components/app/transaction-activity-log/transaction-activity-log.container.js @@ -2,9 +2,9 @@ import { connect } from 'react-redux'; import { findLastIndex } from 'lodash'; import { conversionRateSelector, - getNativeCurrency, getRpcPrefsForCurrentProvider, } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import TransactionActivityLog from './transaction-activity-log.component'; import { combineTransactionHistories } from './transaction-activity-log.util'; import { diff --git a/ui/components/app/transaction-breakdown/transaction-breakdown.container.js b/ui/components/app/transaction-breakdown/transaction-breakdown.container.js index d8f39cdd7747..01cdc036e540 100644 --- a/ui/components/app/transaction-breakdown/transaction-breakdown.container.js +++ b/ui/components/app/transaction-breakdown/transaction-breakdown.container.js @@ -1,9 +1,6 @@ import { connect } from 'react-redux'; -import { - getIsMainnet, - getNativeCurrency, - getPreferences, -} from '../../../selectors'; +import { getIsMainnet, getPreferences } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { getHexGasTotal } from '../../../helpers/utils/confirm-tx.util'; import { sumHexes } from '../../../helpers/utils/transactions.util'; import TransactionBreakdown from './transaction-breakdown.component'; diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index 4b71e3dba221..6169526b6aec 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 '../../../store/actions'; +import { updateSendAsset } from '../../../ducks/send'; import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; import { getAssetImages, @@ -85,7 +85,12 @@ const TokenOverview = ({ className, token }) => { className="token-overview__button" onClick={() => { sendTokenEvent(); - dispatch(updateSendToken(token)); + dispatch( + updateSendAsset({ + type: 'TOKEN', + details: token, + }), + ); history.push(SEND_ROUTE); }} Icon={SendIcon} diff --git a/ui/components/ui/unit-input/unit-input.component.js b/ui/components/ui/unit-input/unit-input.component.js index 8eeb39e1bbc3..78458cab6cb7 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 7a1cafd80165..7c1e3e66a8a2 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -15,7 +15,7 @@ import { getNumberOfAccounts, getNumberOfTokens, } from '../selectors/selectors'; -import { getSendToken } from '../selectors/send'; +import { getSendToken } from '../ducks/send'; import { txDataSelector } from '../selectors/confirm-transaction'; import { getEnvironmentType } from '../../app/scripts/lib/util'; import { trackMetaMetricsEvent } from '../store/actions'; diff --git a/ui/ducks/app/app.js b/ui/ducks/app/app.js index 20bab88f7213..767bff2c86df 100644 --- a/ui/ducks/app/app.js +++ b/ui/ducks/app/app.js @@ -293,18 +293,6 @@ export default function reduceApp(state = {}, action) { isMouseUser: action.value, }; - case actionConstants.GAS_LOADING_STARTED: - return { - ...appState, - gasIsLoading: true, - }; - - case actionConstants.GAS_LOADING_FINISHED: - return { - ...appState, - gasIsLoading: false, - }; - case actionConstants.SET_SELECTED_SETTINGS_RPC_URL: return { ...appState, @@ -377,3 +365,8 @@ export function hideWhatsNewPopup() { type: actionConstants.HIDE_WHATS_NEW_POPUP, }; } + +// Selectors +export function getQrCodeData(state) { + return state.appState.qrCodeData; +} diff --git a/ui/ducks/app/app.test.js b/ui/ducks/app/app.test.js index 7a25bfe2bf62..6f536c903ad9 100644 --- a/ui/ducks/app/app.test.js +++ b/ui/ducks/app/app.test.js @@ -352,22 +352,4 @@ describe('App State', () => { expect(state.isMouseUser).toStrictEqual(true); }); - - it('sets gas loading', () => { - const state = reduceApp(metamaskState, { - type: actions.GAS_LOADING_STARTED, - }); - - expect(state.gasIsLoading).toStrictEqual(true); - }); - - it('unsets gas loading', () => { - const gasLoadingState = { gasIsLoading: true }; - const oldState = { ...metamaskState, ...gasLoadingState }; - const state = reduceApp(oldState, { - type: actions.GAS_LOADING_FINISHED, - }); - - expect(state.gasIsLoading).toStrictEqual(false); - }); }); diff --git a/ui/ducks/confirm-transaction/confirm-transaction.duck.js b/ui/ducks/confirm-transaction/confirm-transaction.duck.js index a59785c7ab5d..09e74a2e8199 100644 --- a/ui/ducks/confirm-transaction/confirm-transaction.duck.js +++ b/ui/ducks/confirm-transaction/confirm-transaction.duck.js @@ -2,8 +2,8 @@ import { conversionRateSelector, currentCurrencySelector, unconfirmedTransactionsHashSelector, - getNativeCurrency, } from '../../selectors'; +import { getNativeCurrency } from '../metamask/metamask'; import { getValueFromWeiHex, diff --git a/ui/ducks/ens.js b/ui/ducks/ens.js new file mode 100644 index 000000000000..5978be340bb4 --- /dev/null +++ b/ui/ducks/ens.js @@ -0,0 +1,183 @@ +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 { + isBurnAddress, + isValidHexAddress, +} from '../../shared/modules/hexstring-utils'; + +// Local Constants +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +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: { + processEnsError: (state, action) => { + const { recipient, network, reason } = action.payload; + if ( + isValidDomainName(recipient) && + reason.message === 'ENS name not defined.' + ) { + state.error = + network === MAINNET_NETWORK_ID + ? ENS_NO_ADDRESS_FOR_NAME + : ENS_NOT_FOUND_ON_NETWORK; + } else if (reason.message === 'Illegal Character for ENS.') { + state.error = ENS_ILLEGAL_CHARACTER; + } else { + log.error(reason); + state.error = ENS_UNKNOWN_ERROR; + } + }, + processEnsResult: (state, action) => { + state.resolution = action.payload; + if (state.resolution === ZERO_ADDRESS) { + state.error = ENS_NO_ADDRESS_FOR_NAME; + } + if (state.resolution === ZERO_X_ERROR_ADDRESS) { + state.error = ENS_REGISTRATION_ERROR; + } + if ( + isValidDomainName(state.resolution) && + isConfusing(state.resolution) + ) { + 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'; + } + }); + }, +}); + +const { reducer, actions } = slice; +export default reducer; + +const { + disableEnsLookup, + processEnsError, + processEnsResult, + enableEnsLookup, + resetResolution, +} = actions; +export { resetResolution }; + +export function initializeEnsSlice() { + return async (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 }); + await dispatch(enableEnsLookup(network)); + } else { + ens = null; + await dispatch(disableEnsLookup()); + } + }; +} + +export function lookupEnsName(ensName) { + return async (dispatch, getState) => { + let state = getState(); + if (state[name].stage === 'UNINITIALIZED' || ens === null) { + await dispatch(initializeEnsSlice()); + } + state = getState(); + if ( + state[name].stage === 'NO_NETWORK_SUPPORT' && + !( + isBurnAddress(ensName) === false && + isValidHexAddress(ensName, { mixedCaseUseChecksum: true }) + ) && + !isHexString(ensName) + ) { + await dispatch(resetResolution()); + } else { + const recipient = ensName.trim(); + + log.info(`ENS attempting to resolve name: ${recipient}`); + try { + const address = await ens.lookup(recipient); + await dispatch(processEnsResult(address)); + } catch (reason) { + await dispatch(processEnsError({ recipient, reason })); + } + } + }; +} + +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 000000000000..19cb16ee72aa --- /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 d4301f9b3ef1..221e4dbd8d79 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 863d726faca2..a17c73b7afbd 100644 --- a/ui/ducks/gas/gas.duck.js +++ b/ui/ducks/gas/gas.duck.js @@ -10,8 +10,16 @@ import { } from '../../helpers/utils/conversions.util'; import { getIsMainnet, getCurrentChainId } from '../../selectors'; import fetchWithCache from '../../helpers/utils/fetch-with-cache'; - -const BASIC_ESTIMATE_STATES = { +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', FAILED: 'FAILED', READY: 'READY', @@ -22,14 +30,6 @@ 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 bae560536841..11b525e4cf82 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/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 093f3e925ec2..c8b0d77ee5f9 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -1,6 +1,10 @@ import * as actionConstants from '../../store/actionConstants'; import { ALERT_TYPES } from '../../../shared/constants/alerts'; import { NETWORK_TYPE_RPC } from '../../../shared/constants/network'; +import { + accountsWithSendEtherInfoSelector, + getAddressBook, +} from '../../selectors'; export default function reduceMetamask(state = {}, action) { const metamaskState = { @@ -15,26 +19,11 @@ export default function reduceMetamask(state = {}, action) { tokens: [], pendingTokens: {}, customNonceValue: '', - send: { - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - }, useBlockie: false, featureFlags: {}, welcomeScreenSeen: false, currentLocale: '', + currentBlockGasLimit: '', preferences: { autoLockTimeLimit: undefined, showFiatInTestnets: false, @@ -46,6 +35,8 @@ export default function reduceMetamask(state = {}, action) { participateInMetaMetrics: null, metaMetricsSendCount: 0, nextNonce: null, + conversionRate: null, + nativeCurrency: 'ETH', ...state, }; @@ -81,7 +72,6 @@ export default function reduceMetamask(state = {}, action) { ...metamaskState, isUnlocked: true, isInitialized: true, - selectedAddress: action.value, }; case actionConstants.SET_ACCOUNT_LABEL: { @@ -99,28 +89,11 @@ export default function reduceMetamask(state = {}, action) { tokens: action.newTokens, }; - // metamask.send - case actionConstants.UPDATE_GAS_LIMIT: - return { - ...metamaskState, - send: { - ...metamaskState.send, - gasLimit: action.value, - }, - }; case actionConstants.UPDATE_CUSTOM_NONCE: return { ...metamaskState, customNonceValue: action.value, }; - case actionConstants.UPDATE_GAS_PRICE: - return { - ...metamaskState, - send: { - ...metamaskState.send, - gasPrice: action.value, - }, - }; case actionConstants.TOGGLE_ACCOUNT_MENU: return { @@ -128,139 +101,6 @@ export default function reduceMetamask(state = {}, action) { isAccountMenuOpen: !metamaskState.isAccountMenuOpen, }; - case actionConstants.UPDATE_GAS_TOTAL: - return { - ...metamaskState, - send: { - ...metamaskState.send, - gasTotal: action.value, - }, - }; - - case actionConstants.UPDATE_SEND_TOKEN_BALANCE: - return { - ...metamaskState, - send: { - ...metamaskState.send, - tokenBalance: action.value, - }, - }; - - case actionConstants.UPDATE_SEND_HEX_DATA: - return { - ...metamaskState, - send: { - ...metamaskState.send, - data: action.value, - }, - }; - - case actionConstants.UPDATE_SEND_TO: - return { - ...metamaskState, - send: { - ...metamaskState.send, - to: action.value.to, - toNickname: action.value.nickname, - }, - }; - - case actionConstants.UPDATE_SEND_AMOUNT: - return { - ...metamaskState, - send: { - ...metamaskState.send, - amount: action.value, - }, - }; - - case actionConstants.UPDATE_MAX_MODE: - return { - ...metamaskState, - send: { - ...metamaskState.send, - maxModeOn: action.value, - }, - }; - - case actionConstants.UPDATE_SEND: - return Object.assign(metamaskState, { - send: { - ...metamaskState.send, - ...action.value, - }, - }); - - case actionConstants.UPDATE_SEND_TOKEN: { - const newSend = { - ...metamaskState.send, - 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(metamaskState, { - send: newSend, - }); - } - - case actionConstants.UPDATE_SEND_ENS_RESOLUTION: - return { - ...metamaskState, - send: { - ...metamaskState.send, - ensResolution: action.payload, - ensResolutionError: '', - }, - }; - - case actionConstants.UPDATE_SEND_ENS_RESOLUTION_ERROR: - return { - ...metamaskState, - send: { - ...metamaskState.send, - ensResolution: null, - ensResolutionError: action.payload, - }, - }; - - case actionConstants.CLEAR_SEND: - return { - ...metamaskState, - send: { - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - }, - }; - case actionConstants.UPDATE_TRANSACTION_PARAMS: { const { id: txId, value } = action; let { currentNetworkTxList } = metamaskState; @@ -378,3 +218,29 @@ export const getUnconnectedAccountAlertShown = (state) => state.metamask.unconnectedAccountAlertShownOrigins; export const getTokens = (state) => state.metamask.tokens; + +export function getBlockGasLimit(state) { + return state.metamask.currentBlockGasLimit; +} + +export function getConversionRate(state) { + return state.metamask.conversionRate; +} + +export function getNativeCurrency(state) { + return state.metamask.nativeCurrency; +} + +export function getSendHexDataFeatureFlagState(state) { + return state.metamask.featureFlags.sendHexData; +} + +export function getSendToAccounts(state) { + const fromAccounts = accountsWithSendEtherInfoSelector(state); + const addressBookAccounts = getAddressBook(state); + return [...fromAccounts, ...addressBookAccounts]; +} + +export function getUnapprovedTxs(state) { + return state.metamask.unapprovedTxs; +} diff --git a/ui/ducks/metamask/metamask.test.js b/ui/ducks/metamask/metamask.test.js index c830cd4a27c1..c10f58caef51 100644 --- a/ui/ducks/metamask/metamask.test.js +++ b/ui/ducks/metamask/metamask.test.js @@ -92,30 +92,6 @@ describe('MetaMask Reducers', () => { expect(state.tokens).toStrictEqual(newTokens); }); - it('updates send gas limit', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_GAS_LIMIT, - value: '0xGasLimit', - }, - ); - - expect(state.send.gasLimit).toStrictEqual('0xGasLimit'); - }); - - it('updates send gas price', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_GAS_PRICE, - value: '0xGasPrice', - }, - ); - - expect(state.send.gasPrice).toStrictEqual('0xGasPrice'); - }); - it('toggles account menu', () => { const state = reduceMetamask( {}, @@ -127,153 +103,6 @@ describe('MetaMask Reducers', () => { expect(state.isAccountMenuOpen).toStrictEqual(true); }); - it('updates gas total', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_GAS_TOTAL, - value: '0xGasTotal', - }, - ); - - expect(state.send.gasTotal).toStrictEqual('0xGasTotal'); - }); - - it('updates send token balance', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_TOKEN_BALANCE, - value: '0xTokenBalance', - }, - ); - - expect(state.send.tokenBalance).toStrictEqual('0xTokenBalance'); - }); - - it('updates data', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_HEX_DATA, - value: '0xData', - }, - ); - - expect(state.send.data).toStrictEqual('0xData'); - }); - - it('updates send to', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_TO, - value: { - to: '0xAddress', - nickname: 'nickname', - }, - }, - ); - - expect(state.send.to).toStrictEqual('0xAddress'); - expect(state.send.toNickname).toStrictEqual('nickname'); - }); - - it('update send amount', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_AMOUNT, - value: '0xAmount', - }, - ); - - expect(state.send.amount).toStrictEqual('0xAmount'); - }); - - it('updates max mode', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_MAX_MODE, - value: true, - }, - ); - - expect(state.send.maxModeOn).toStrictEqual(true); - }); - - it('update send', () => { - const value = { - gasLimit: '0xGasLimit', - gasPrice: '0xGasPrice', - gasTotal: '0xGasTotal', - tokenBalance: '0xBalance', - from: '0xAddress', - to: '0xAddress', - toNickname: '', - maxModeOn: false, - amount: '0xAmount', - memo: '0xMemo', - errors: {}, - editingTransactionId: 22, - ensResolution: null, - ensResolutionError: '', - }; - - const sendState = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND, - value, - }, - ); - - expect(sendState.send).toStrictEqual(value); - }); - - it('clears send', () => { - const initStateSend = { - send: { - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - }, - }; - - const sendState = { - send: { - gasLimit: '0xGasLimit', - gasPrice: '0xGasPrice', - gasTotal: '0xGasTotal', - tokenBalance: '0xBalance', - from: '0xAddress', - to: '0xAddress', - toNickname: '', - maxModeOn: false, - amount: '0xAmount', - memo: '0xMemo', - errors: {}, - editingTransactionId: 22, - }, - }; - - const state = reduceMetamask(sendState, { - type: actionConstants.CLEAR_SEND, - }); - - expect(state.send).toStrictEqual(initStateSend.send); - }); - it('updates value of tx by id', () => { const oldState = { currentNetworkTxList: [ @@ -377,30 +206,4 @@ describe('MetaMask Reducers', () => { expect(state.pendingTokens).toStrictEqual({}); }); - - it('update ensResolution', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_ENS_RESOLUTION, - payload: '0x1337', - }, - ); - - expect(state.send.ensResolution).toStrictEqual('0x1337'); - expect(state.send.ensResolutionError).toStrictEqual(''); - }); - - it('update ensResolutionError', () => { - const state = reduceMetamask( - {}, - { - type: actionConstants.UPDATE_SEND_ENS_RESOLUTION_ERROR, - payload: 'ens name not found', - }, - ); - - expect(state.send.ensResolutionError).toStrictEqual('ens name not found'); - expect(state.send.ensResolution).toBeNull(); - }); }); diff --git a/ui/ducks/send/index.js b/ui/ducks/send/index.js new file mode 100644 index 000000000000..d1ab99c82d2a --- /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 12ef5bbb3f4e..000000000000 --- a/ui/ducks/send/send-duck.test.js +++ /dev/null @@ -1,128 +0,0 @@ -import SendReducer, { - openToDropdown, - closeToDropdown, - updateSendErrors, - showGasButtonGroup, - hideGasButtonGroup, -} from './send.duck'; - -describe('Send Duck', () => { - const mockState = { - mockProp: 123, - }; - const initState = { - toDropdownOpen: false, - errors: {}, - gasButtonGroupShown: true, - }; - 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 b8d9744575c5..000000000000 --- a/ui/ducks/send/send.duck.js +++ /dev/null @@ -1,79 +0,0 @@ -// 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 initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, -}; - -// 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 RESET_SEND_STATE: - return { ...initState }; - 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 }; -} diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js new file mode 100644 index 000000000000..b54b5c8babaf --- /dev/null +++ b/ui/ducks/send/send.js @@ -0,0 +1,1275 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import abi from 'human-standard-token-abi'; +import log from 'loglevel'; +import contractMap from '@metamask/contract-metadata'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; +import { debounce } from 'lodash'; +import { + conversionGreaterThan, + 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_ERROR, + MIN_GAS_LIMIT_HEX, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; + +import { + addGasBuffer, + calcGasTotal, + generateTokenTransferData, + isBalanceSufficient, + isTokenBalanceSufficient, +} from '../../pages/send/send.utils'; +import { + getCurrentChainId, + getGasPriceInHexWei, + getSelectedAddress, + getTargetAccount, +} from '../../selectors'; +import { + displayWarning, + estimateGas, + hideLoadingIndication, + showConfTxPage, + 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, +} from '../../store/actionConstants'; +import { calcTokenAmount } 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. + * 1. ADD_RECIPIENT - The user is selecting which address to send an asset to + * 2. DRAFT - The send form is shown for a transaction yet to be submitted to + * the network. + * 3. EDIT - The send form is shown for a transaction already submitted that + * has not yet been signed (confirmed). This happens when the confirmation + * page shows the 'edit' button in top left and the user can drop back to + * modify transaction details. + */ +export const SEND_STAGES = { + UNINITIALIZED: 'UNINITIALIZED', + 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', +}; + +/** + * 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) + */ +const AMOUNT_MODES = { + INPUT: 'INPUT', + MAX: 'MAX', +}; + +const RECIPIENT_SEARCH_MODES = { + MY_ACCOUNTS: 'MY_ACCOUNTS', + CONTACT_LIST: 'CONTACT_LIST', +}; + +async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + 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, which is the core component to our background process that + // estimates gasLimit. 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') { + // ??? Assuming that the value cannot be nullish or 0 to properly + // estimate gasLimit? + 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.toString(16), + 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); + } + log.error(error); + throw error; + } +} + +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. +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; + } + // We have to keep the customGas setting in sync with the gasLimit when + // changing it due to side effects of user actions. + 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: { + // which gas estimation mode is being used + mode: 'BASIC', + // 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 theEDIT + // 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, + }, +}; + +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); + // validate send state + slice.caseReducers.validateSendState(state); + // Recompute the draftTransaction object + slice.caseReducers.updateDraftTransaction(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, payload) => { + state.draftTransaction.userInputHexData = 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); + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal, + * setting that value in state as well. Once gasTotal is computed + * 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. + */ + updateGasLimit: (state, action) => { + state.gas.gasLimit = addHexPrefix(action.payload); + 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.validateGasField(state); + // validate send state + slice.caseReducers.validateSendState(state); + slice.caseReducers.updateDraftTransaction(state); + }, + /** + * sets the provided gasPrice in state and then recomputes the gasTotal, + * setting that value in state as well. Once gasTotal is computed + * 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. + */ + updateGasPrice: (state, action) => { + state.gas.gasPrice = addHexPrefix(action.payload); + 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.validateGasField(state); + // validate send state + slice.caseReducers.validateSendState(state); + slice.caseReducers.updateDraftTransaction(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_ERROR) { + // 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); + // update the draft transaction + slice.caseReducers.updateDraftTransaction(state); + }, + updateRecipient: (state, action) => { + state.recipient.address = action.payload.address; + state.recipient.nickname = action.payload.nickname ?? ''; + // if an id exists on the draft transaction, we progress to the edit + // stage, otherwise we progress to the draft stage. + state.stage = + state.draftTransaction.id === null + ? SEND_STAGES.DRAFT + : SEND_STAGES.EDIT; + // validate send state + slice.caseReducers.validateSendState(state); + // update the draft transaction + slice.caseReducers.updateDraftTransaction(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; + } + } + }, + useBasicGasEstimation: (state) => { + // Show the basic gas estimation buttons in the send page + state.gas.mode = 'BASIC'; + }, + useAdvancedGasEstimation: (state) => { + // Show the advanced gas price/limit fields in the send page + state.gas.mode = 'ADVANCED'; + }, + 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; + } + + if ( + isSendingToken && + (toChecksumAddress(recipient.userInput) in contractMap || + checkExistingAddresses(recipient.userInput, tokens)) + ) { + recipient.warning = KNOWN_RECIPIENT_ADDRESS_ERROR; + } + } + }, + updateRecipientSearchMode: (state, action) => { + 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: '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 gasLimit is less than the minimumGasLimit + 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 new BigNumber(state.gas.gasLimit, 16).lessThan( + new BigNumber(state.gas.minimumGasLimit), + ): + state.status = SEND_STATUSES.INVALID; + break; + default: + state.status = SEND_STATUSES.VALID; + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(QR_CODE_DETECTED, (state, action) => { + // When data is received from the QR Code Scanner we set the recipient + // as long as a valid address can be pulled from the data. If an + // address is pulled but it is invalid, we display an error. + const qrCodeData = action.value; + if (qrCodeData) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (state.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } + }) + .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow the account we are keyed into will be the + // original 'from' account, which may differ from the selected account + if (state.stage !== SEND_STAGES.EDIT) { + // This event occurs when the user selects a new account from the + // account menu, or the currently active account's balance updates. + state.account.balance = action.payload.account.balance; + state.account.address = action.payload.account.address; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow then we need to watch for changes to the + // current account.address in state and keep balance updated + // appropriately + if ( + state.stage === SEND_STAGES.EDIT && + action.payload.account.address === state.account.address + ) { + // 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(initializeSendState.pending, (state) => { + // when we begin initializing state, which can happen when switching + // chains even after loading the send flow, we need to temporarily + // prevent the form from being submitted to allow for related state + // changes to be pulled in. + state.status = SEND_STATUSES.INVALID; + }) + .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); + slice.caseReducers.updateDraftTransaction(state); + }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit the slice should be marked invalid + state.status = SEND_STATUSES.INVALID; + }) + .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. + 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, + getGasPriceInHexWei(action.value.average ?? '0x0'), + ); + }) + .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: + case BASIC_ESTIMATE_STATES.LOADING: + state.status = SEND_STATUSES.INVALID; + break; + case BASIC_ESTIMATE_STATES.READY: + default: + slice.caseReducers.validateSendState(state); + } + }); + }, +}); + +const { actions, reducer } = slice; + +export default reducer; + +const { + editTransaction, + useBasicGasEstimation, + useAdvancedGasEstimation, + updateGasLimit, + updateGasPrice, + resetSendState, + validateRecipientUserInput, + updateRecipientSearchMode, +} = actions; + +export { + editTransaction, + useBasicGasEstimation, + useAdvancedGasEstimation, + 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 === '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. + balance = await getERC20Balance( + details, + state.send.account.address ?? getSelectedAddress(state), + ); + } 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()); + }; +} + +const debouncedValidateRecipientUserInput = debounce((dispatch, payload) => { + dispatch(validateRecipientUserInput(payload)); +}, 300); + +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)); + }; +} + +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()); + } + }; +} + +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(updateRecipientUserInput('')); + await dispatch(resetResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +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()); + } + }; +} + +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()); + } + }; +} + +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) { + 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) { + 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 { + global.ethQuery.sendTransaction(txParams, (err) => { + if (err) { + dispatch(displayWarning(err.message)); + } + }); + dispatch(showConfTxPage()); + } + }; +} + +// 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 getSendAsset(state) { + return state[name].asset; +} + +export function getSendToken(state) { + if (state[name].asset.type === ASSET_TYPES.TOKEN) { + return state[name].asset.details; + } + return null; +} + +export function getSendTokenAddress(state) { + return getSendToken(state)?.address; +} + +export function getSendAmount(state) { + return state[name].amount.value; +} + +export function getSendHexData(state) { + return state[name].draftTransaction.userInputHexData; +} + +export function getSendTransactionId(state) { + return state[name].stage === SEND_STAGES.EDIT + ? state[name].draftTransaction.id + : null; +} + +export function getIsBalanceInsufficient(state) { + return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; +} + +export function getSendFrom(state) { + return state[name].account.address; +} + +export function getSendFromBalance(state) { + return state[name].account.balance; +} + +export function getSendFromObject(state) { + return state[name].account; +} + +export function getSendMaxModeState(state) { + return state[name].amount.mode === AMOUNT_MODES.MAX; +} + +export function getSendTo(state) { + return state[name].recipient.address; +} + +export function getSendToNickname(state) { + return state[name].recipient.nickname; +} + +export function getTokenBalance(state) { + return state[name].asset.balance; +} + +export function getSendErrors(state) { + return { + gas: state.send.gas.error, + amount: state.send.amount.error, + }; +} + +export function sendAmountIsInError(state) { + return Boolean(state[name].amount.error); +} + +export function gasFeeIsInError(state) { + return Boolean(state[name].gas.error); +} + +export function getGasButtonGroupShown(state) { + return state[name].gas.mode === 'BASIC'; +} + +export function isSendStateInitialized(state) { + return state[name].stage !== SEND_STAGES.UNINITIALIZED; +} + +export function isSendFormInvalid(state) { + return state[name].status === SEND_STATUSES.INVALID; +} + +export function isSendFormInError(state) { + return Boolean(state[name].amount.error || state[name].gas.error); +} + +export function getSendStage(state) { + return state[name].stage; +} + +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; +} + +export function getDraftTransaction(state) { + return state[name].draftTransaction; +} + +export function getMinimumGasLimitForSend(state) { + return state[name].gas.minimumGasLimit; +} diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js new file mode 100644 index 000000000000..184bfe6b3810 --- /dev/null +++ b/ui/ducks/send/send.test.js @@ -0,0 +1,535 @@ +import sinon from 'sinon'; +import configureMockStore from 'redux-mock-store'; +// import sendReducer from './send'; +import thunk from 'redux-thunk'; + +import { addHexPrefix } from 'ethereumjs-util'; +import { describe, it } from 'globalthis/implementation'; +import configureStore from '../../store/store'; +import { addGasBuffer } from '../../pages/send/send.utils'; +import { multiplyCurrencies } from '../../helpers/utils/conversion-util'; +import { + gasFeeIsInError, + getGasButtonGroupShown, + getGasLimit, + getGasLoadingError, + getGasPrice, + getGasTotal, + getPrimaryCurrency, + getSendAmount, + getSendEditingTransactionId, + getSendEnsResolution, + getSendEnsResolutionError, + getSendErrors, + getSendFrom, + getSendFromBalance, + getSendFromObject, + getSendHexData, + getSendMaxModeState, + getSendTo, + getSendToken, + getSendTokenAddress, + getSendTokenContract, + getSendToNickname, + getTitleKey, + getTokenBalance, + isSendFormInError, + sendAmountIsInError, + setEditingTransactionId, + setSendFrom, + updateGasData, + updateSendToken, +} from './send'; +import { + hideGasButtonGroup, + setGasLimit, + setGasPrice, + setMaxModeTo, + setSendTokenBalance, + showGasButtonGroup, + updateSendAmount, + updateSendErrors, + updateSendHexData, + updateSendTo, + initialState, + resetSendState, + updateSendEnsResolution, + updateSendEnsResolutionError, +} from '.'; + +jest.mock('../../selectors', () => ({ + getSelectedAccount: jest.fn(() => ({ + address: '0xab', + balance: '0x0', + })), +})); + +jest.mock('../../store/actions', () => ({ + estimateGas: jest.fn(({ value }) => { + if (value === '0xbadvalue') { + return Promise.reject(new Error('BAD VALUE')); + } else if (value === '0xgassimfail') { + return Promise.reject(new Error('Transaction execution error.')); + } + return Promise.resolve('0x52ac'); + }), +})); + +describe('send slice', () => { + let store; + let mockStore; + beforeEach(() => { + store = configureStore(); + mockStore = configureMockStore([thunk])({}); + jest.resetModules(); + }); + describe('simple actions', () => { + it('updateSendErrors adds to the error object', () => { + store.dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })); + let state = store.getState(); + expect(state.send.errors).toHaveProperty('gasLoadingError'); + store.dispatch(updateSendErrors({ gasPrice: 'gasPriceError' })); + state = store.getState(); + expect(state.send.errors).toHaveProperty('gasLoadingError'); + expect(state.send.errors).toHaveProperty('gasPrice'); + }); + + it('showGasButtonGroup should set gasButtonGroupShown to true', () => { + store.dispatch(showGasButtonGroup()); + const state = store.getState(); + expect(state.send.gasButtonGroupShown).toBe(true); + }); + + it('hideGasButtonGroup should set gasButtonGroupShown to false', () => { + store.dispatch(showGasButtonGroup()); + store.dispatch(hideGasButtonGroup()); + const state = store.getState(); + expect(state.send.gasButtonGroupShown).toBe(false); + }); + + it('setGasLimit should set the gasLimit in state', () => { + store.dispatch(setGasLimit('0x5208')); + const state = store.getState(); + expect(state.send.gasLimit).toBe('0x5208'); + }); + + it('setGasPrice should set the gasPrice in state', () => { + store.dispatch(setGasPrice('0x6E')); + const state = store.getState(); + expect(state.send.gasPrice).toBe('0x6E'); + }); + + it('setSendTokenBalance should set the tokenBalance in state', () => { + store.dispatch(setSendTokenBalance('0x0')); + const state = store.getState(); + expect(state.send.tokenBalance).toBe('0x0'); + }); + + it('updateSendHexData should set the data key in state', () => { + store.dispatch(updateSendHexData('0x0')); + const state = store.getState(); + expect(state.send.data).toBe('0x0'); + }); + + it('updateSendTo should set the to and toNickname in state', () => { + store.dispatch(updateSendTo({ to: '0x0', nickname: 'account 2' })); + const state = store.getState(); + expect(state.send.to).toBe('0x0'); + expect(state.send.toNickname).toBe('account 2'); + }); + + it('updateSendAmount should set the amount in state', () => { + store.dispatch(updateSendAmount('0x0')); + const state = store.getState(); + expect(state.send.amount).toBe('0x0'); + }); + + it('setMaxModeTo should set the maxModeOn key in state', () => { + store.dispatch(setMaxModeTo(true)); + const state = store.getState(); + expect(state.send.maxModeOn).toBe(true); + }); + + it('setSendFrom should set the from key in state', () => { + store.dispatch(setSendFrom('0x00')); + const state = store.getState(); + expect(state.send.from).toBe('0x00'); + }); + + it('setEditingTransactionId should set the editingTransactionId key in state', () => { + store.dispatch(setEditingTransactionId('0')); + const state = store.getState(); + expect(state.send.editingTransactionId).toBe('0'); + }); + + it('updateSendEnsResolution clears ensResolutionError and sets ensResolution', () => { + store.dispatch(updateSendEnsResolutionError('notFound')); + store.dispatch(updateSendEnsResolution('0x00')); + const state = store.getState(); + expect(state.send.ensResolutionError).toBe(''); + expect(state.send.ensResolution).toBe('0x00'); + }); + + it('updateSendEnsResolutionError clears ensResolution and sets ensResolutionError', () => { + store.dispatch(updateSendEnsResolution('0x00')); + store.dispatch(updateSendEnsResolutionError('notFound')); + const state = store.getState(); + expect(state.send.ensResolutionError).toBe('notFound'); + expect(state.send.ensResolution).toBeNull(); + }); + + it('resetSendState sets state back to the initial value', () => { + store.dispatch(setGasLimit('0xEE')); + store.dispatch(setGasPrice('0xFF')); + let state = store.getState(); + expect(state.send.gasLimit).toBe('0xEE'); + expect(state.send.gasPrice).toBe('0xFF'); + store.dispatch(resetSendState()); + state = store.getState(); + expect(state.send).toMatchObject(initialState); + }); + }); + describe('action creators', () => { + describe('updatedGasData', () => { + it('starts and stops gas loading when there is an error', async () => { + global.eth = { + getCode: sinon.stub().rejects(), + }; + + const mockData = { + gasPrice: '0x3b9aca00', // + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xde0b6b3a7640000', // 1000000000000000000 + }; + + await mockStore.dispatch(updateGasData(mockData)); + + const actions = mockStore.getActions(); + + expect(actions[0].type).toBe('send/gasLoadingStarted'); + expect(actions[actions.length - 1].type).toBe( + 'send/gasLoadingFinished', + ); + }); + + it('starts and stops gas loading when there is not an error', async () => { + const mockData = { + gasPrice: '0x3b9aca00', + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xde0b6b3a7640000', // 1000000000000000000 + }; + + global.eth = { + getCode: sinon.stub().returns('0x'), + }; + + await mockStore.dispatch(updateGasData(mockData)); + + const actions = mockStore.getActions(); + + expect(actions[0].type).toBe('send/gasLoadingStarted'); + expect(actions[actions.length - 1].type).toBe( + 'send/gasLoadingFinished', + ); + }); + + it('errors when get code does not return', async () => { + global.eth = { + getCode: sinon.stub().rejects(), + }; + + const mockData = { + gasPrice: '0x3b9aca00', // + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xde0b6b3a7640000', // 1000000000000000000 + }; + + await store.dispatch(updateGasData(mockData)); + + expect(store.getState().send.errors.gasLoadingError).toBe( + 'gasLoadingError', + ); + }); + + it('returns default gas limit for basic eth transaction', async () => { + const mockData = { + gasPrice: '0x3b9aca00', + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xde0b6b3a7640000', // 1000000000000000000 + }; + + global.eth = { + getCode: sinon.stub().returns('0x'), + }; + + await store.dispatch(updateGasData(mockData)); + expect(store.getState().send.gasLimit).toBe('0x5208'); + }); + + it('returns the gasLimit provided by the background when sending to a contract', async () => { + const mockData = { + gasPrice: '0x3b9aca00', + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xde0b6b3a7640000', // 1000000000000000000 + }; + + global.eth = { + getCode: sinon.stub().returns('0xff'), + }; + + await store.dispatch(updateGasData(mockData)); + const expectedGasLimit = addHexPrefix( + addGasBuffer('0x52ac'.toString(16), mockData.blockGasLimit, 1.5), + ); + expect(store.getState().send.gasLimit).toBe(expectedGasLimit); + }); + + it('sets the error when gas simulation encounters an unknown error in background', async () => { + const mockData = { + gasPrice: '0x3b9aca00', + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xbadvalue', // 1000000000000000000 + }; + + global.eth = { + getCode: sinon.stub().returns('0xff'), + }; + + await store.dispatch(updateGasData(mockData)); + expect(store.getState().send.errors.gasLoadingError).toBe( + 'gasLoadingError', + ); + }); + + it('sets the gasLimit when gas simulation encounters a known error in background', async () => { + const mockData = { + gasPrice: '0x3b9aca00', + blockGasLimit: '0x6ad79a', // 7002010 + selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', + to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', + value: '0xgassimfail', // 1000000000000000000 + }; + + global.eth = { + getCode: sinon.stub().returns('0xff'), + }; + + await store.dispatch(updateGasData(mockData)); + const gasEst = multiplyCurrencies(mockData.blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }); + const expectedGasLimit = addHexPrefix( + addGasBuffer(gasEst.toString(16), mockData.blockGasLimit, 1.5), + ); + expect(store.getState().send.gasLimit).toBe(expectedGasLimit); + }); + }); + }); + describe('selectors', () => { + it('getGasPrice should return gasPrice from state', async () => { + await store.dispatch(setGasPrice('0x0')); + expect(getGasPrice(store.getState())).toBe('0x0'); + }); + + it('getGasLimit should return gasLimit from state', async () => { + await store.dispatch(setGasLimit('0x0')); + expect(getGasLimit(store.getState())).toBe('0x0'); + }); + + it('getGasTotal should return gasPrice * gasLimit from state', async () => { + await store.dispatch(setGasPrice('0x1')); + await store.dispatch(setGasLimit('0x5208')); + expect(getGasTotal(store.getState())).toBe('5208'); + }); + + it('getSendToken should return token from state', async () => { + await store.dispatch( + updateSendToken({ address: '0x000', symbol: 'DAI' }), + ); + expect(getSendToken(store.getState())).toMatchObject({ + address: '0x000', + symbol: 'DAI', + }); + }); + + it('getSendTokenAddress should return token.address from state', async () => { + await store.dispatch( + updateSendToken({ address: '0x000', symbol: 'DAI' }), + ); + expect(getSendTokenAddress(store.getState())).toBe('0x000'); + }); + + it('getPrimaryCurrency should return token.symbol from state', async () => { + await store.dispatch( + updateSendToken({ address: '0x000', symbol: 'DAI' }), + ); + expect(getPrimaryCurrency(store.getState())).toBe('DAI'); + }); + + it('getSendTokenContract should return contract code from EVM', async () => { + global.eth = { + contract: () => ({ + at: jest.fn((address) => { + if (address === '0x000') { + return '0x000'; + } + return '0xf'; + }), + }), + }; + await store.dispatch( + updateSendToken({ address: '0x000', symbol: 'DAI' }), + ); + expect(getSendTokenContract(store.getState())).toBe('0x000'); + }); + + it('getSendAmount should return amount from state', async () => { + await store.dispatch(updateSendAmount('0x0')); + expect(getSendAmount(store.getState())).toBe('0x0'); + }); + + it('getSendHexData should return amount from state', async () => { + await store.dispatch(updateSendHexData('0x0')); + expect(getSendHexData(store.getState())).toBe('0x0'); + }); + + it('getSendEditingTransactionId should return editingTransactionId from state', async () => { + store.dispatch(setEditingTransactionId('0')); + expect(getSendEditingTransactionId(store.getState())).toBe('0'); + }); + + it('getSendFrom should return "from" from state', async () => { + store.dispatch(setSendFrom('0xFF')); + expect(getSendFrom(store.getState())).toBe('0xFF'); + }); + + it('getSendFromObject should return the selected account from state', async () => { + expect(getSendFromObject(store.getState())).toMatchObject({ + address: '0xab', + balance: '0x0', + }); + }); + + it('getSendFromBalance should return the selected account from state', async () => { + expect(getSendFromBalance(store.getState())).toBe('0x0'); + }); + + it('getSendMaxModeState should return maxModeOn from state', async () => { + await store.dispatch(setMaxModeTo(true)); + expect(getSendMaxModeState(store.getState())).toBe(true); + }); + + it('getSendTo should return to from state', async () => { + await store.dispatch( + updateSendTo({ + to: '0x0', + }), + ); + expect(getSendTo(store.getState())).toBe('0x0'); + }); + + it('getSendToNickname should return toNickname from state', async () => { + await store.dispatch( + updateSendTo({ + nickname: 'account 1', + }), + ); + expect(getSendToNickname(store.getState())).toBe('account 1'); + }); + + it('getTokenBalance should return tokenBalance from state', async () => { + await store.dispatch(setSendTokenBalance('0x10')); + expect(getTokenBalance(store.getState())).toBe('0x10'); + }); + + it('getSendEnsResolution should return ensResolution from state', async () => { + await store.dispatch(updateSendEnsResolution('0x00ab')); + expect(getSendEnsResolution(store.getState())).toBe('0x00ab'); + }); + + it('getSendEnsResolutionError should return ensResolutionError from state', async () => { + await store.dispatch(updateSendEnsResolutionError('invalidAddress')); + expect(getSendEnsResolutionError(store.getState())).toBe( + 'invalidAddress', + ); + }); + + it('getSendErrors should return errors from state', async () => { + await store.dispatch(updateSendErrors({ gasLoading: 'gasLoadingError' })); + expect(getSendErrors(store.getState())).toMatchObject({ + gasLoading: 'gasLoadingError', + }); + }); + + it('sendAmountIsInError should return true if amount has an error in state', async () => { + await store.dispatch(updateSendErrors({ amount: 'insufficientFunds' })); + expect(sendAmountIsInError(store.getState())).toBe(true); + }); + + it('getGasLoadingErrors should return gasLoading error from state', async () => { + await store.dispatch(updateSendErrors({ gasLoading: 'gasLoadingError' })); + expect(getGasLoadingError(store.getState())).toBe('gasLoadingError'); + }); + + it('getFeeIsInError should return true if amount has an error in state', async () => { + await store.dispatch(updateSendErrors({ gasFee: 'minimumLimitError' })); + expect(gasFeeIsInError(store.getState())).toBe(true); + }); + + it('isSendFormInError should return true if any errors exist in state', async () => { + await store.dispatch(updateSendErrors({ gasFee: 'minimumLimitError' })); + expect(isSendFormInError(store.getState())).toBe(true); + }); + + it('getGasButtonGroupShown should return gasButtonGroupShown from state', async () => { + await store.dispatch(showGasButtonGroup()); + expect(getGasButtonGroupShown(store.getState())).toBe(true); + }); + + describe('getTitleKey', () => { + it('should return "addRecipient" when no to address specified', () => { + expect(getTitleKey(store.getState())).toBe('addRecipient'); + }); + + it('should return "send" if not sending token and not currently editing id', async () => { + await store.dispatch( + updateSendTo({ to: '0x00', nickname: 'account 1' }), + ); + expect(getTitleKey(store.getState())).toBe('send'); + }); + + it('should return "sendTokens" if sending token and not currently editing id', async () => { + await store.dispatch( + updateSendTo({ to: '0x00', nickname: 'account 1' }), + ); + await store.dispatch( + updateSendToken({ address: '0xff', symbol: 'TST' }), + ); + expect(getTitleKey(store.getState())).toBe('sendTokens'); + }); + + it('should return "edit" if currently editing id', async () => { + await store.dispatch(setEditingTransactionId('0')); + await store.dispatch( + updateSendTo({ to: '0x00', nickname: 'account 1' }), + ); + expect(getTitleKey(store.getState())).toBe('edit'); + }); + }); + }); +}); diff --git a/ui/hooks/useCancelTransaction.js b/ui/hooks/useCancelTransaction.js index 03bad3c546d6..2074ec6e4ce0 100644 --- a/ui/hooks/useCancelTransaction.js +++ b/ui/hooks/useCancelTransaction.js @@ -7,11 +7,9 @@ import { getHexGasTotal, increaseLastGasPrice, } from '../helpers/utils/confirm-tx.util'; -import { - getConversionRate, - getSelectedAccount, - getIsMainnet, -} from '../selectors'; +import { getSelectedAccount, getIsMainnet } from '../selectors'; +import { getConversionRate } from '../ducks/metamask/metamask'; + import { setCustomGasLimit, setCustomGasPriceForRetry, diff --git a/ui/hooks/useCurrencyDisplay.js b/ui/hooks/useCurrencyDisplay.js index 8b3f8271d95e..6b770e38e766 100644 --- a/ui/hooks/useCurrencyDisplay.js +++ b/ui/hooks/useCurrencyDisplay.js @@ -4,11 +4,11 @@ import { formatCurrency, getValueFromWeiHex, } from '../helpers/utils/confirm-tx.util'; +import { getCurrentCurrency } from '../selectors'; import { - getCurrentCurrency, getConversionRate, getNativeCurrency, -} from '../selectors'; +} from '../ducks/metamask/metamask'; /** * Defines the shape of the options parameter for useCurrencyDisplay diff --git a/ui/hooks/useCurrencyDisplay.test.js b/ui/hooks/useCurrencyDisplay.test.js index 1231e5f140f1..89625a36b46a 100644 --- a/ui/hooks/useCurrencyDisplay.test.js +++ b/ui/hooks/useCurrencyDisplay.test.js @@ -1,11 +1,11 @@ import { renderHook } from '@testing-library/react-hooks'; import * as reactRedux from 'react-redux'; import sinon from 'sinon'; +import { getCurrentCurrency } from '../selectors'; import { - getCurrentCurrency, - getNativeCurrency, getConversionRate, -} from '../selectors'; + getNativeCurrency, +} from '../ducks/metamask/metamask'; import { useCurrencyDisplay } from './useCurrencyDisplay'; const tests = [ diff --git a/ui/hooks/useEthFiatAmount.js b/ui/hooks/useEthFiatAmount.js index 10ce717890f7..88ee9d9b9c85 100644 --- a/ui/hooks/useEthFiatAmount.js +++ b/ui/hooks/useEthFiatAmount.js @@ -1,12 +1,9 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { - getConversionRate, - getCurrentCurrency, - getShouldShowFiat, -} from '../selectors'; +import { getCurrentCurrency, getShouldShowFiat } from '../selectors'; import { decEthToConvertedCurrency } from '../helpers/utils/conversions.util'; import { formatCurrency } from '../helpers/utils/confirm-tx.util'; +import { getConversionRate } from '../ducks/metamask/metamask'; /** * Get an Eth amount converted to fiat and formatted for display diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index 835b6593648e..1e3974525ea9 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -2,11 +2,11 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { getTokenExchangeRates, - getConversionRate, getCurrentCurrency, getShouldShowFiat, } from '../selectors'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; +import { getConversionRate } from '../ducks/metamask/metamask'; /** * Get the token balance converted to fiat and formatted for display diff --git a/ui/hooks/useTokensToSearch.js b/ui/hooks/useTokensToSearch.js index 5d6f8dd1a04a..3d5bc15c4104 100644 --- a/ui/hooks/useTokensToSearch.js +++ b/ui/hooks/useTokensToSearch.js @@ -6,11 +6,12 @@ import { isEqual, shuffle } from 'lodash'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { getTokenExchangeRates, - getConversionRate, getCurrentCurrency, getSwapsDefaultToken, getCurrentChainId, } from '../selectors'; +import { getConversionRate } from '../ducks/metamask/metamask'; + import { getSwapsTokens } from '../ducks/swaps/swaps'; import { isSwapsDefaultTokenSymbol } from '../../shared/modules/swaps.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; diff --git a/ui/hooks/useTransactionDisplayData.test.js b/ui/hooks/useTransactionDisplayData.test.js index e91566acf3b4..3b36c255bbe5 100644 --- a/ui/hooks/useTransactionDisplayData.test.js +++ b/ui/hooks/useTransactionDisplayData.test.js @@ -7,11 +7,10 @@ import transactions from '../../test/data/transaction-data.json'; import { getPreferences, getShouldShowFiat, - getNativeCurrency, getCurrentCurrency, getCurrentChainId, } from '../selectors'; -import { getTokens } from '../ducks/metamask/metamask'; +import { getTokens, getNativeCurrency } from '../ducks/metamask/metamask'; import { getMessage } from '../helpers/utils/i18n-helper'; import messages from '../../app/_locales/en/messages.json'; import { ASSET_ROUTE, DEFAULT_ROUTE } from '../helpers/constants/routes'; diff --git a/ui/hooks/useUserPreferencedCurrency.js b/ui/hooks/useUserPreferencedCurrency.js index d1a080954852..50b742abfb15 100644 --- a/ui/hooks/useUserPreferencedCurrency.js +++ b/ui/hooks/useUserPreferencedCurrency.js @@ -1,9 +1,7 @@ import { useSelector } from 'react-redux'; -import { - getPreferences, - getShouldShowFiat, - getNativeCurrency, -} from '../selectors'; +import { getPreferences, getShouldShowFiat } from '../selectors'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; + import { PRIMARY, SECONDARY, ETH } from '../helpers/constants/common'; /** diff --git a/ui/pages/confirm-approve/confirm-approve.js b/ui/pages/confirm-approve/confirm-approve.js index a0257e7a46c4..2283122cd525 100644 --- a/ui/pages/confirm-approve/confirm-approve.js +++ b/ui/pages/confirm-approve/confirm-approve.js @@ -14,13 +14,12 @@ import { getTokenValueParam, } from '../../helpers/utils/token-util'; import { useTokenTracker } from '../../hooks/useTokenTracker'; -import { getTokens } from '../../ducks/metamask/metamask'; +import { getTokens, getNativeCurrency } from '../../ducks/metamask/metamask'; import { transactionFeeSelector, txDataSelector, getCurrentCurrency, getDomainMetadata, - getNativeCurrency, getUseNonceField, getCustomNonceValue, getNextSuggestedNonce, @@ -28,6 +27,7 @@ import { getIsEthGasPriceFetched, getIsMainnet, } from '../../selectors'; + import { currentNetworkTxListSelector } from '../../selectors/transactions'; import Loading from '../../components/ui/loading-screen'; import { getCustomTxParamsData } from './confirm-approve.util'; 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 35f1ccd73832..ee520138728b 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 '../../store/actions'; +import { editTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -19,21 +19,23 @@ const mapDispatchToProps = (dispatch) => { return { editTransaction: (txData) => { const { id, txParams } = txData; - const { from, gas: gasLimit, gasPrice, to, value: amount } = txParams; - + const { + from, + gas: gasLimit, + gasPrice, + to: address, + value: amount, + } = txParams; dispatch( - updateSend({ - from, + editTransaction({ + id: id?.toString(), gasLimit, gasPrice, - gasTotal: null, - to, + from, amount, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), + address, }), ); - 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 e7514acf9e7b..2d85af96b2e6 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -2,7 +2,8 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; -import { updateSend, showSendTokenPage } from '../../store/actions'; +import { showSendTokenPage } from '../../store/actions'; +import { editTransaction, updateSendAsset } from '../../ducks/send'; import { conversionUtil } from '../../helpers/utils/conversion-util'; import { getTokenValueParam, @@ -27,7 +28,7 @@ const mapDispatchToProps = (dispatch) => { txParams: { from, to: tokenAddress, gas: gasLimit, gasPrice } = {}, } = txData; - const to = getTokenValueParam(tokenData); + const address = getTokenValueParam(tokenData); const tokenAmountInDec = getTokenAddressParam(tokenData); const tokenAmountInHex = conversionUtil(tokenAmountInDec, { @@ -36,21 +37,23 @@ const mapDispatchToProps = (dispatch) => { }); dispatch( - updateSend({ - from, + editTransaction({ + id: id?.toString(), gasLimit, gasPrice, - gasTotal: null, - to, + from, amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - token: { - ...tokenProps, - address: tokenAddress, - }, + address, }), ); + + dispatch( + updateSendAsset({ + type: 'TOKEN', + details: { ...tokenProps, address: tokenAddress }, + }), + ); + dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); }, diff --git a/ui/pages/confirm-transaction/conf-tx.js b/ui/pages/confirm-transaction/conf-tx.js index 23f6e2375509..8990e21c5a6d 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.metamask.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 b88424cc76a9..5bc374edc9c9 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 21acaa7ffbc9..bf0020c64dd8 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.container.js +++ b/ui/pages/confirm-transaction/confirm-transaction.container.js @@ -15,16 +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: { send, unapprovedTxs }, + metamask: { unapprovedTxs }, } = state; const { match: { params = {} }, } = ownProps; const { id } = params; + const sendTo = getSendTo(state); const unconfirmedTransactions = unconfirmedTransactionsListSelector(state); const totalUnconfirmed = unconfirmedTransactions.length; @@ -35,7 +37,7 @@ const mapStateToProps = (state, ownProps) => { return { totalUnapprovedCount: totalUnconfirmed, - send, + sendTo, unapprovedTxs, id, mostRecentOverviewPage: getMostRecentOverviewPage(state), diff --git a/ui/pages/permissions-connect/permissions-connect.container.js b/ui/pages/permissions-connect/permissions-connect.container.js index abf349208af0..4e95406ec3f1 100644 --- a/ui/pages/permissions-connect/permissions-connect.container.js +++ b/ui/pages/permissions-connect/permissions-connect.container.js @@ -2,12 +2,12 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { getPermissionsRequests, - getNativeCurrency, getAccountsWithLabels, getLastConnectedInfo, getDomainMetadata, getSelectedAddress, } from '../../selectors'; +import { getNativeCurrency } from '../../ducks/metamask/metamask'; import { formatDate } from '../../helpers/utils/util'; import { diff --git a/ui/pages/send/index.js b/ui/pages/send/index.js index 36fa285d44a1..2fc7580b7c02 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 da7999c94d76..322dca6776d3 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.js @@ -8,26 +8,28 @@ import RecipientGroup from '../../../../components/app/contact-list/recipient-gr import { ellipsify } from '../../send.utils'; import Button from '../../../../components/ui/button'; import Confusable from '../../../../components/ui/confusable'; -import { - isBurnAddress, - isValidHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; export default class AddRecipient extends Component { static propTypes = { - query: PropTypes.string, + userInput: PropTypes.string, ownedAccounts: PropTypes.array, addressBook: PropTypes.array, - updateGas: PropTypes.func, - updateSendTo: PropTypes.func, + updateRecipient: PropTypes.func, ensResolution: PropTypes.string, - toError: PropTypes.string, - toWarning: PropTypes.string, - ensResolutionError: PropTypes.string, + ensError: PropTypes.string, + ensWarning: PropTypes.string, addressBookEntryName: PropTypes.string, contacts: PropTypes.array, nonContacts: PropTypes.array, - setInternalSearch: PropTypes.func, + useMyAccountsForRecipientSearch: PropTypes.func, + useContactListForRecipientSearch: PropTypes.func, + isUsingMyAccountsForRecipientSearch: PropTypes.bool, + recipient: PropTypes.shape({ + address: PropTypes.string, + nickname: PropTypes.nickname, + error: PropTypes.string, + warning: PropTypes.string, + }), }; constructor(props) { @@ -61,60 +63,58 @@ export default class AddRecipient extends Component { metricsEvent: PropTypes.func, }; - state = { - isShowingTransfer: false, - }; - - selectRecipient = (to, nickname = '') => { - const { updateSendTo, updateGas } = this.props; - - updateSendTo(to, nickname); - updateGas({ to }); + selectRecipient = (address, nickname = '') => { + this.props.updateRecipient({ address, nickname }); }; searchForContacts = () => { - const { query, contacts } = this.props; + const { userInput, contacts } = this.props; let _contacts = contacts; - if (query) { + if (userInput) { this.contactFuse.setCollection(contacts); - _contacts = this.contactFuse.search(query); + _contacts = this.contactFuse.search(userInput); } return _contacts; }; searchForRecents = () => { - const { query, nonContacts } = this.props; + const { userInput, nonContacts } = this.props; let _nonContacts = nonContacts; - if (query) { + if (userInput) { this.recentFuse.setCollection(nonContacts); - _nonContacts = this.recentFuse.search(query); + _nonContacts = this.recentFuse.search(userInput); } return _nonContacts; }; render() { - const { ensResolution, query, addressBookEntryName } = this.props; - const { isShowingTransfer } = this.state; + const { + ensResolution, + recipient, + userInput, + addressBookEntryName, + isUsingMyAccountsForRecipientSearch, + } = this.props; let content; - if ( - !isBurnAddress(query) && - isValidHexAddress(query, { mixedCaseUseChecksum: true }) - ) { - content = this.renderExplicitAddress(query); + if (recipient.address) { + content = this.renderExplicitAddress( + recipient.address, + recipient.nickname, + ); } else if (ensResolution) { content = this.renderExplicitAddress( ensResolution, - addressBookEntryName || query, + addressBookEntryName || userInput, ); - } else if (isShowingTransfer) { + } else if (isUsingMyAccountsForRecipientSearch) { content = this.renderTransfer(); } @@ -150,15 +150,18 @@ export default class AddRecipient extends Component { renderTransfer() { let { ownedAccounts } = this.props; - const { query, setInternalSearch } = this.props; + const { + userInput, + useContactListForRecipientSearch, + isUsingMyAccountsForRecipientSearch, + } = this.props; const { t } = this.context; - const { isShowingTransfer } = this.state; - if (isShowingTransfer && query) { + if (isUsingMyAccountsForRecipientSearch && userInput) { ownedAccounts = ownedAccounts.filter( (item) => - item.name.toLowerCase().indexOf(query.toLowerCase()) > -1 || - item.address.toLowerCase().indexOf(query.toLowerCase()) > -1, + item.name.toLowerCase().indexOf(userInput.toLowerCase()) > -1 || + item.address.toLowerCase().indexOf(userInput.toLowerCase()) > -1, ); } @@ -167,10 +170,7 @@ export default class AddRecipient extends Component { @@ -219,30 +216,19 @@ export default class AddRecipient extends Component { } renderDialogs() { - const { - toError, - toWarning, - ensResolutionError, - ensResolution, - } = this.props; + const { ensError, recipient, ensWarning } = this.props; const { t } = this.context; - if (ensResolutionError) { - return ( - - {ensResolutionError} - - ); - } else if (toError && toError !== 'required' && !ensResolution) { + if (ensError || (recipient.error && recipient.error !== 'required')) { return ( - {t(toError)} + {t(ensError ?? recipient.error)} ); - } else if (toWarning) { + } else if (ensWarning || recipient.warning) { return ( - {t(toWarning)} + {t(ensWarning ?? recipient.warning)} ); } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js index ec2772f13fc9..a5fa7b885aaf 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,8 +5,6 @@ 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(), @@ -20,14 +18,11 @@ describe('AddRecipient Component', () => { beforeEach(() => { wrapper = shallow( { }); afterEach(() => { - propsMethodSpies.closeToDropdown.resetHistory(); - propsMethodSpies.openToDropdown.resetHistory(); propsMethodSpies.updateSendTo.resetHistory(); propsMethodSpies.updateSendToError.resetHistory(); propsMethodSpies.updateSendToWarning.resetHistory(); 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 2e3ea94fc029..27353e778071 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 '../../../../store/actions'; +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 696ca926bc7f..5786caffd80b 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,6 @@ import sinon from 'sinon'; -import { updateSendTo } from '../../../../store/actions'; +import { updateSendTo } from '../../../../ducks/send'; let mapStateToProps; let mapDispatchToProps; @@ -14,8 +14,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: () => [ @@ -24,8 +22,10 @@ jest.mock('../../../../selectors', () => ({ ], })); -jest.mock('../../../../store/actions', () => ({ +jest.mock('../../../../ducks/send', () => ({ updateSendTo: jest.fn(), + getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`, + getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`, })); require('./add-recipient.container.js'); @@ -57,7 +57,10 @@ describe('add-recipient container', () => { mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname'); expect(dispatchSpy.calledOnce).toStrictEqual(true); expect(updateSendTo).toHaveBeenCalled(); - expect(updateSendTo).toHaveBeenCalledWith('mockTo', 'mockNickname'); + expect(updateSendTo).toHaveBeenCalledWith({ + to: 'mockTo', + 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 5141fda1d52f..000000000000 --- 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 4a9605d3213f..000000000000 --- 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 658ac9bde6c0..bb1c7f3e7737 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 90d2c3ff45f3..ef61fce85a00 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 index db520fcdce10..d4fe76910fef 100644 --- 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 @@ -1,81 +1,47 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; 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, +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 inError = 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()); }; - 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 ( + return ( +
+
- -
- {this.context.t('max')} -
+ {t('max')}
- ); - } +
+ ); } 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 index 8c38d3be54de..c3b452f870a1 100644 --- 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 @@ -1,93 +1,61 @@ 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; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; - const propsMethodSpies = { - setAmountToMax: sinon.spy(), - setMaxModeTo: sinon.spy(), - }; - - const MOCK_EVENT = { preventDefault: () => undefined }; - - beforeAll(() => { - sinon.spy(AmountMaxButton.prototype, 'setMaxAmount'); - }); +import { fireEvent } from '@testing-library/react'; +import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; +import { renderWithProvider } from '../../../../../../test/jest'; +import AmountMaxButton from './amount-max-button.component'; - beforeEach(() => { - wrapper = shallow( - , - { - context: { - t: (str) => `${str}_t`, - metricsEvent: () => undefined, - }, - }, - ); - instance = wrapper.instance(); - }); +const middleware = [thunk]; - afterEach(() => { - propsMethodSpies.setAmountToMax.resetHistory(); - propsMethodSpies.setMaxModeTo.resetHistory(); - AmountMaxButton.prototype.setMaxAmount.resetHistory(); - }); +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(); + }); - afterAll(() => { - sinon.restore(); - }); + 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); - 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', - }, - ]); - }); - }); + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'MAX' }, + ]; - describe('render', () => { - it('should render an element with a send-v2__amount-max class', () => { - expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1); + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); }); - it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => { - const { onClick } = wrapper.find('.send-v2__amount-max').props(); + 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); - 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, - ]); - }); + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'INPUT' }, + ]; - 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', - ); + 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.container.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js deleted file mode 100644 index 593d3e57a042..000000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ /dev/null @@ -1,39 +0,0 @@ -import { connect } from 'react-redux'; -import { - getGasTotal, - getSendToken, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getBasicGasEstimateLoadingStatus, -} from '../../../../../selectors'; -import { updateSendAmount, setMaxModeTo } from '../../../../../store/actions'; -import { updateSendErrors } 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 90f95e6cba03..000000000000 --- 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 { setMaxModeTo, updateSendAmount } from '../../../../../store/actions'; - -import { updateSendErrors } 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('../../../../../store/actions', () => ({ - setMaxModeTo: jest.fn(), - updateSendAmount: jest.fn(), -})); -jest.mock('../../../../../ducks/send/send.duck', () => ({ - 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.utils.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js deleted file mode 100644 index 6826b5e39338..000000000000 --- 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 87b334386d52..000000000000 --- 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 26d87ffb5cb3..53b78839bf18 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.component'; 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 3f3d64e45b85..66e6dcc1b707 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,6 +1,5 @@ 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'; @@ -9,103 +8,27 @@ 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 === 'TOKEN' ? ( ) : ( @@ -118,7 +41,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 8ed1a7438e02..bc62fe1896bc 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 @@ -5,86 +5,10 @@ import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; 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.component'; 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 +17,7 @@ describe('SendAmountRow Component', () => { expect(updateSendAmount.callCount).toStrictEqual(0); - instance.updateAmount('someAmount'); + instance.handleChange('someAmount'); expect( updateSendAmount.calledOnceWithExactly('someAmount'), @@ -136,10 +60,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 +68,35 @@ 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 f2d5b2e06aa1..261c91168655 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,18 +1,10 @@ import { connect } from 'react-redux'; import { - getConversionRate, - getGasTotal, - getPrimaryCurrency, - getSendToken, + updateSendAmount, getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, sendAmountIsInError, -} from '../../../../selectors'; -import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils'; -import { setMaxModeTo, updateSendAmount } from '../../../../store/actions'; -import { updateSendErrors } from '../../../../ducks/send/send.duck'; + getSendAsset, +} from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); @@ -20,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 6d3b06aef02d..4911cb612299 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,8 +1,6 @@ import sinon from 'sinon'; -import { setMaxModeTo, updateSendAmount } from '../../../../store/actions'; - -import { updateSendErrors } from '../../../../ducks/send/send.duck'; +import { updateSendAmount } from '../../../../ducks/send'; let mapDispatchToProps; @@ -13,30 +11,10 @@ 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('../../../../store/actions', () => ({ - setMaxModeTo: jest.fn(), +jest.mock('../../../../ducks/send', () => ({ updateSendAmount: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), -})); - require('./send-amount-row.container.js'); describe('send-amount-row container', () => { @@ -49,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'); @@ -66,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 35c89a04158a..4589973a2b3f 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 @@ -19,7 +19,7 @@ export default class SendAssetRow extends Component { assetImages: PropTypes.object, selectedAddress: PropTypes.string.isRequired, sendTokenAddress: PropTypes.string, - setSendToken: PropTypes.func.isRequired, + updateSendAsset: PropTypes.func.isRequired, nativeCurrency: PropTypes.string, nativeCurrencyImage: PropTypes.string, }; @@ -37,7 +37,7 @@ export default class SendAssetRow extends Component { closeDropdown = () => this.setState({ isShowingDropdown: false }); - selectToken = (token) => { + selectToken = (type, token) => { this.setState( { isShowingDropdown: false, @@ -53,7 +53,10 @@ export default class SendAssetRow extends Component { assetSelected: token ? ERC20 : this.props.nativeCurrency, }, }); - this.props.setSendToken(token); + this.props.updateSendAsset({ + type, + details: type === 'NATIVE' ? null : token, + }); }, ); }; @@ -123,7 +126,7 @@ export default class SendAssetRow extends Component { ? 'send-v2__asset-dropdown__asset' : 'send-v2__asset-dropdown__single-asset' } - onClick={() => this.selectToken()} + onClick={() => this.selectToken('NATIVE')} >
this.selectToken(token)} + onClick={() => this.selectToken('TOKEN', token)} >
dispatch(updateSendToken(token)), + 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 6f1a82b92535..c34416097c2f 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -18,7 +18,6 @@ export default class SendContent extends Component { }; static propTypes = { - updateGas: PropTypes.func, showAddToAddressBookModal: PropTypes.func, showHexData: PropTypes.bool, contact: PropTypes.object, @@ -30,8 +29,6 @@ export default class SendContent extends Component { noGasPrice: PropTypes.bool, }; - updateGas = (updateData) => this.props.updateGas(updateData); - render() { const { warning, @@ -54,11 +51,9 @@ export default class SendContent extends Component { {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 3c99b32376f3..e3bde77d6942 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 { getSendTo } from '../../../ducks/send'; + import * as actions from '../../../store/actions'; import SendContent from './send-content.component'; @@ -20,9 +21,9 @@ function mapStateToProps(state) { ), ), 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 9cbe216294b1..22eb188cb02b 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 @@ -7,17 +7,12 @@ 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, gasPriceButtonGroupProps: PropTypes.object, gasButtonGroupShown: PropTypes.bool, advancedInlineGasShown: PropTypes.bool, @@ -28,6 +23,7 @@ export default class SendGasRow extends Component { isMainnet: PropTypes.bool, isEthGasPrice: PropTypes.bool, noGasPrice: PropTypes.bool, + minimumGasLimit: PropTypes.string, }; static contextTypes = { @@ -69,23 +65,6 @@ export default class SendGasRow extends Component { ); } - setMaxAmount() { - const { - balance, - gasTotal, - sendToken, - setAmountToMax, - tokenBalance, - } = this.props; - - setAmountToMax({ - balance, - gasTotal, - sendToken, - tokenBalance, - }); - } - renderContent() { const { gasLoadingError, @@ -94,15 +73,15 @@ export default class SendGasRow extends Component { gasPriceButtonGroupProps, gasButtonGroupShown, advancedInlineGasShown, - maxModeOn, resetGasButtons, - setGasPrice, - setGasLimit, + updateGasPrice, + updateGasLimit, gasPrice, gasLimit, insufficientBalance, isMainnet, isEthGasPrice, + minimumGasLimit, noGasPrice, } = this.props; const { metricsEvent } = this.context; @@ -123,9 +102,6 @@ export default class SendGasRow extends Component { }, }); await gasPriceButtonGroupProps.handleGasPriceSelection(opts); - if (maxModeOn) { - this.setMaxAmount(); - } }} />
@@ -134,27 +110,19 @@ 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} /> 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 32b0529c4c63..eda854542098 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,19 +1,6 @@ import { connect } from 'react-redux'; import { - getConversionRate, - getGasTotal, - getGasPrice, - getGasLimit, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getGasLoadingError, - gasFeeIsInError, - getGasButtonGroupShown, getAdvancedInlineGasShown, - getCurrentEthBalance, - getSendToken, getBasicGasEstimateLoadingStatus, getRenderableEstimateDataForSmallButtonsFromGWEI, getDefaultActiveButtonIndex, @@ -21,24 +8,26 @@ import { 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, -} from '../../../../ducks/send/send.duck'; + getGasTotal, + getGasPrice, + getGasLimit, + gasFeeIsInError, + getGasButtonGroupShown, + updateGasPrice, + updateGasLimit, + useBasicGasEstimation, + isSendStateInitialized, + getIsBalanceInsufficient, + getMinimumGasLimitForSend, +} from '../../../../ducks/send'; import { resetCustomData, setCustomGasPrice, setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { - showModal, - setGasPrice, - setGasLimit, - setGasTotal, - updateSendAmount, -} from '../../../../store/actions'; +import { showModal } from '../../../../store/actions'; +import { hexToDecimal } from '../../../../helpers/utils/conversions.util'; import SendGasRow from './send-gas-row.component'; export default connect( @@ -57,23 +46,17 @@ 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, @@ -84,10 +67,7 @@ function mapStateToProps(state) { advancedInlineGasShown: getAdvancedInlineGasShown(state), gasPrice, gasLimit, - insufficientBalance, - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), + insufficientBalance: getIsBalanceInsufficient(state), isMainnet: getIsMainnet(state), isEthGasPrice, noGasPrice, @@ -98,25 +78,15 @@ 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()), + useBasicGasEstimation: () => dispatch(useBasicGasEstimation()), resetCustomData: () => dispatch(resetCustomData()), }; } @@ -125,8 +95,8 @@ function mergeProps(stateProps, dispatchProps, ownProps) { const { gasPriceButtonGroupProps } = stateProps; const { gasButtonInfo } = gasPriceButtonGroupProps; const { - setGasPrice: dispatchSetGasPrice, - showGasButtonGroup: dispatchShowGasButtonGroup, + updateGasPrice: dispatchUpdateGasPrice, + useBasicGasEstimation: dispatchUseBasicGasEstimation, resetCustomData: dispatchResetCustomData, ...otherDispatchProps } = dispatchProps; @@ -137,13 +107,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); + dispatchUseBasicGasEstimation(); }, - 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 db322d25afa4..855faf132287 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 @@ -1,11 +1,6 @@ import sinon from 'sinon'; -import { - showModal, - setGasPrice, - setGasTotal, - setGasLimit, -} from '../../../../store/actions'; +import { showModal } from '../../../../store/actions'; import { resetCustomData, @@ -13,7 +8,11 @@ import { setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { showGasButtonGroup } from '../../../../ducks/send/send.duck'; +import { + useBasicGasEstimation, + updateGasPrice, + updateGasLimit, +} from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -26,8 +25,11 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors', () => ({ +jest.mock('../../../../ducks/send', () => ({ getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, + useBasicGasEstimation: jest.fn(), + updateGasPrice: jest.fn(), + updateGasLimit: jest.fn(), })); jest.mock('../../send.utils.js', () => ({ @@ -39,13 +41,6 @@ jest.mock('../../send.utils.js', () => ({ jest.mock('../../../../store/actions', () => ({ showModal: jest.fn(), - setGasPrice: jest.fn(), - setGasTotal: jest.fn(), - setGasLimit: jest.fn(), -})); - -jest.mock('../../../../ducks/send/send.duck', () => ({ - showGasButtonGroup: jest.fn(), })); jest.mock('../../../../ducks/gas/gas.duck', () => ({ @@ -77,36 +72,29 @@ 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()', () => { + describe('useBasicGasEstimation()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.showGasButtonGroup(); + mapDispatchToPropsObject.useBasicGasEstimation(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(showGasButtonGroup).toHaveBeenCalled(); + expect(useBasicGasEstimation).toHaveBeenCalled(); }); }); @@ -129,7 +117,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 +132,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 080b97b37b73..291013016ee4 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 20a8cad4376c..044f3eb69b78 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 '../../../../store/actions'; +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.metamask.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 f857183f32da..45a208537d91 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 a8012f2008e5..23f1d2c68c00 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 ef18f48b11af..c7c5a2f91561 100644 --- a/ui/pages/send/send-footer/send-footer.component.js +++ b/ui/pages/send/send-footer/send-footer.component.js @@ -6,28 +6,16 @@ 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,31 +60,6 @@ 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 { metricsEvent } = this.context; @@ -151,7 +86,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 900c26b2ae64..4ec018140486 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,25 @@ 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 +60,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 +76,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 +87,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 +103,11 @@ describe('SendFooter Component', () => { describe('render', () => { beforeEach(() => { - sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns(true); wrapper = shallow( { ); }); - 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 e8ea039e1289..6264b554125c 100644 --- a/ui/pages/send/send-footer/send-footer.container.js +++ b/ui/pages/send/send-footer/send-footer.container.js @@ -1,42 +1,33 @@ import { connect } from 'react-redux'; +import { addToAddressBook } from '../../../store/actions'; import { - addToAddressBook, - clearSend, - signTokenTx, - signTx, - updateTransaction, -} from '../../../store/actions'; + getRenderableEstimateDataForSmallButtonsFromGWEI, + getDefaultActiveButtonIndex, +} from '../../../selectors'; import { - getGasLimit, + resetSendState, getGasPrice, - getGasTotal, - getSendToken, - getSendAmount, - getSendEditingTransactionId, - getSendFromObject, getSendTo, - getSendToAccounts, - getSendHexData, - getTokenBalance, - getUnapprovedTxs, getSendErrors, isSendFormInError, - getGasIsLoading, - getRenderableEstimateDataForSmallButtonsFromGWEI, - getDefaultActiveButtonIndex, - getNoGasPriceFetched, -} from '../../../selectors'; + isSendFormInvalid, + signTransaction, +} from '../../../ducks/send'; +import { getSendToAccounts } from '../../../ducks/metamask/metamask'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { addHexPrefix } from '../../../../app/scripts/lib/util'; 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); @@ -48,74 +39,22 @@ 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, - }); - - 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 7fb79cbaee56..f3a1a0496fdc 100644 --- a/ui/pages/send/send-footer/send-footer.container.test.js +++ b/ui/pages/send/send-footer/send-footer.container.test.js @@ -1,16 +1,7 @@ import sinon from 'sinon'; -import { - clearSend, - 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; @@ -23,29 +14,19 @@ jest.mock('react-redux', () => ({ jest.mock('../../../store/actions.js', () => ({ addToAddressBook: jest.fn(), - clearSend: jest.fn(), - signTokenTx: jest.fn(), - signTx: jest.fn(), - updateTransaction: jest.fn(), })); -jest.mock('../../../selectors/send.js', () => ({ - getGasLimit: (s) => `mockGasLimit:${s}`, +jest.mock('../../../ducks/metamask/metamask', () => ({ + getSendToAccounts: (s) => [`mockToAccounts:${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', () => ({ @@ -53,15 +34,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', () => { @@ -74,94 +46,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); }); }); @@ -169,14 +66,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 778b0786759d..000000000000 --- 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 034ca2ecdce1..000000000000 --- 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 cfb482303286..b4bda8af7be8 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 303ef4c7ae5b..1b8af5312aff 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -1,33 +1,47 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import { getMostRecentOverviewPage } from '../../../ducks/history/history'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + ASSET_TYPES, + getSendAsset, + getSendStage, + resetSendState, + SEND_STAGES, +} from '../../../ducks/send'; -export default class SendHeader extends Component { - static propTypes = { - clearSend: PropTypes.func, - history: PropTypes.object, - mostRecentOverviewPage: PropTypes.string, - titleKey: PropTypes.string, - }; +export default function SendHeader() { + const history = useHistory(); + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const dispatch = useDispatch(); + const stage = useSelector(getSendStage); + const asset = useSelector(getSendAsset); + const t = useI18nContext(); - static contextTypes = { - t: PropTypes.func, + const onClose = () => { + dispatch(resetSendState()); + history.push(mostRecentOverviewPage); }; - onClose() { - const { clearSend, history, mostRecentOverviewPage } = this.props; - clearSend(); - history.push(mostRecentOverviewPage); - } + let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); - render() { - return ( - this.onClose()} - title={this.context.t(this.props.titleKey)} - headerCloseText={this.context.t('cancel')} - /> - ); + if ( + stage === SEND_STAGES.ADD_RECIPIENT || + stage === SEND_STAGES.UNINITIALIZED + ) { + title = t('addRecipient'); + } else if (stage === SEND_STAGES.EDIT) { + title = t('edit'); } + + return ( + + ); } diff --git a/ui/pages/send/send-header/send-header.component.test.js b/ui/pages/send/send-header/send-header.component.test.js index 8ff76c35ef01..a8fa643421dc 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -1,73 +1,120 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import PageContainerHeader from '../../../components/ui/page-container/page-container-header'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { ASSET_TYPES, initialState, SEND_STAGES } from '../../../ducks/send'; +import { renderWithProvider } from '../../../../test/jest'; import SendHeader from './send-header.component'; -describe('SendHeader Component', () => { - let wrapper; +const middleware = [thunk]; - const propsMethodSpies = { - clearSend: sinon.spy(), - }; - const historySpies = { - push: sinon.spy(), +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: () => ({ + push: jest.fn(), + }), }; +}); - beforeAll(() => { - sinon.spy(SendHeader.prototype, 'onClose'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { context: { t: (str1, str2) => (str2 ? str1 + str2 : str1) } }, - ); - }); - - afterEach(() => { - propsMethodSpies.clearSend.resetHistory(); - historySpies.push.resetHistory(); - SendHeader.prototype.onClose.resetHistory(); - }); +describe('SendHeader Component', () => { + describe('Title', () => { + it('should render "Add Recipient" for UNINITIALIZED or ADD_RECIPIENT stages', () => { + const { getByText, rerender } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Add Recipient')).toBeTruthy(); + rerender( + , + configureMockStore(middleware)({ + send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Add Recipient')).toBeTruthy(); + }); - afterAll(() => { - sinon.restore(); - }); + it('should render "Send" for DRAFT stage when asset type is NATIVE', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send')).toBeTruthy(); + }); - describe('onClose', () => { - it('should call clearSend', () => { - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(propsMethodSpies.clearSend.callCount).toStrictEqual(1); + it('should render "Send Tokens" for DRAFT stage when asset type is TOKEN', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN }, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Send Tokens')).toBeTruthy(); }); - it('should call history.push', () => { - expect(historySpies.push.callCount).toStrictEqual(0); - wrapper.instance().onClose(); - expect(historySpies.push.callCount).toStrictEqual(1); - expect(historySpies.push.getCall(0).args[0]).toStrictEqual( - 'mostRecentOverviewPage', + it('should render "Edit" for EDIT stage', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: { + ...initialState, + stage: SEND_STAGES.EDIT, + }, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), ); + expect(getByText('Edit')).toBeTruthy(); }); }); - describe('render', () => { - it('should render a PageContainerHeader component', () => { - expect(wrapper.find(PageContainerHeader)).toHaveLength(1); + describe('Cancel Button', () => { + it('has a cancel button in header', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }), + ); + expect(getByText('Cancel')).toBeTruthy(); }); - it('should pass the correct props to PageContainerHeader', () => { - const { onClose, title } = wrapper.find(PageContainerHeader).props(); - expect(title).toStrictEqual('mockTitleKey'); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(0); - onClose(); - expect(SendHeader.prototype.onClose.callCount).toStrictEqual(1); + it('resets send state when clicked', () => { + const store = configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + history: { mostRecentOverviewPage: 'activity' }, + }); + const { getByText } = renderWithProvider(, store); + const expectedActions = [ + { type: 'send/resetSendState', payload: undefined }, + ]; + fireEvent.click(getByText('Cancel')); + expect(store.getActions()).toStrictEqual(expectedActions); }); }); }); diff --git a/ui/pages/send/send-header/send-header.container.js b/ui/pages/send/send-header/send-header.container.js deleted file mode 100644 index 9f67cb2af048..000000000000 --- a/ui/pages/send/send-header/send-header.container.js +++ /dev/null @@ -1,20 +0,0 @@ -import { connect } from 'react-redux'; -import { clearSend } from '../../../store/actions'; -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 6954f9cfca07..000000000000 --- 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 5cc90307ed4a..000000000000 --- 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 ffeaf0397f49..cf929a445ec7 100644 --- a/ui/pages/send/send.constants.js +++ b/ui/pages/send/send.constants.js @@ -37,9 +37,12 @@ const REQUIRED_ERROR = 'required'; const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient'; const CONTRACT_ADDRESS_ERROR = 'contractAddressError'; const CONFUSING_ENS_ERROR = 'confusingEnsDomain'; - -const SIMPLE_GAS_COST = '0x5208'; // Hex for 21000, cost of a simple send. -const BASE_TOKEN_GAS_COST = '0x186a0'; // Hex for 100000, a base estimate for token transfers. +const ENS_NO_ADDRESS_FOR_NAME = 'noAddressForName'; +const ENS_NOT_FOUND_ON_NETWORK = 'ensNotFoundOnCurrentNetwork'; +const ENS_NOT_SUPPORTED_ON_NETWORK = 'ensNotSupportedOnNetwork'; // 'Network does not support ENS' +const ENS_ILLEGAL_CHARACTER = 'ensIllegalCharacter'; +const ENS_UNKNOWN_ERROR = 'ensUnknownError'; +const ENS_REGISTRATION_ERROR = 'ensRegistrationError'; export { INSUFFICIENT_FUNDS_ERROR, @@ -48,6 +51,12 @@ export { KNOWN_RECIPIENT_ADDRESS_ERROR, 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, @@ -57,7 +66,5 @@ export { NEGATIVE_ETH_ERROR, REQUIRED_ERROR, CONFUSING_ENS_ERROR, - SIMPLE_GAS_COST, TOKEN_TRANSFER_FUNCTION_SIGNATURE, - BASE_TOKEN_GAS_COST, }; diff --git a/ui/pages/send/send.container.js b/ui/pages/send/send.container.js deleted file mode 100644 index 2c710bdc4696..000000000000 --- a/ui/pages/send/send.container.js +++ /dev/null @@ -1,136 +0,0 @@ -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { compose } from 'redux'; - -import { - getBlockGasLimit, - getConversionRate, - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - getSendEditingTransactionId, - getSendHexDataFeatureFlagState, - getSendFromObject, - getSendTo, - getSendToNickname, - getTokenBalance, - getQrCodeData, - getSelectedAddress, - getAddressBook, - getSendTokenAddress, - isCustomPriceExcessive, - getCurrentChainId, -} from '../../selectors'; - -import { - updateSendTo, - updateSendTokenBalance, - updateGasData, - setGasTotal, - showQrScanner, - qrCodeDetected, - updateSendEnsResolution, - updateSendEnsResolutionError, -} from '../../store/actions'; -import { resetSendState, updateSendErrors } from '../../ducks/send/send.duck'; -import { fetchBasicGasEstimates } from '../../ducks/gas/gas.duck'; -import { 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 bb2aca2dda32..000000000000 --- a/ui/pages/send/send.container.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendTokenBalance, - updateGasData, - setGasTotal, -} from '../../store/actions'; - -import { 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('../../store/actions', () => ({ - updateSendTokenBalance: jest.fn(), - updateGasData: jest.fn(), - setGasTotal: jest.fn(), -})); -jest.mock('../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - resetSendState: 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 000000000000..edfb1ef40c9a --- /dev/null +++ b/ui/pages/send/send.js @@ -0,0 +1,108 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { + 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 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: '' })) + } + 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 000000000000..ff024ab89af4 --- /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 1603061e2020..d4d4e76703f3 100644 --- a/ui/pages/send/send.utils.js +++ b/ui/pages/send/send.utils.js @@ -11,29 +11,14 @@ import { import { calcTokenAmount } from '../../helpers/utils/token-util'; import { addHexPrefix } from '../../../app/scripts/lib/util'; -import { - BASE_TOKEN_GAS_COST, - INSUFFICIENT_FUNDS_ERROR, - INSUFFICIENT_TOKENS_ERROR, - MIN_GAS_LIMIT_HEX, - NEGATIVE_ETH_ERROR, - SIMPLE_GAS_COST, - 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, }; @@ -94,190 +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 SIMPLE_GAS_COST; - } - } else if (sendToken && !to) { - return BASE_TOKEN_GAS_COST; - } - - 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.toString(16), - 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, @@ -344,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 06d3d8edc889..7960b4acad13 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 { @@ -10,25 +9,11 @@ import { import { calcGasTotal, - estimateGasForSend, - doesAmountErrorRequireUpdate, generateTokenTransferData, - getAmountErrorObject, - getGasFeeErrorObject, - getToAddressForGasUpdate, - calcTokenBalance, isBalanceSufficient, isTokenBalanceSufficient, - removeLeadingZeroes, } from './send.utils'; -import { - BASE_TOKEN_GAS_COST, - SIMPLE_GAS_COST, - 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]; @@ -68,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( @@ -142,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({ @@ -280,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 { toString: (n) => `0xabc${n}` }; - }), - }; - 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 ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => { - expect(baseMockParams.estimateGasMethod.callCount).toStrictEqual(0); - const result = await estimateGasForSend({ - ...baseMockParams, - to: '0x123', - }); - expect(result).toStrictEqual(SIMPLE_GAS_COST); - }); - - it(`should return ${SIMPLE_GAS_COST} 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(SIMPLE_GAS_COST); - }); - - it(`should not return ${SIMPLE_GAS_COST} 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(SIMPLE_GAS_COST); - }); - - it(`should return ${BASE_TOKEN_GAS_COST} if passed a sendToken but no to address`, async () => { - const result = await estimateGasForSend({ - ...baseMockParams, - to: null, - sendToken: { address: '0x0' }, - }); - expect(result).toStrictEqual(BASE_TOKEN_GAS_COST); - }); - - 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 e454838d121b..7f716926b200 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 @@ -32,6 +32,7 @@ export default class AddContact extends PureComponent { ensAddress: '', error: '', ensError: '', + input: '', }; constructor(props) { @@ -66,22 +67,29 @@ export default class AddContact extends PureComponent { } }; + onChange = (input) => { + this.setState({ input }); + this.dValidate(input); + }; + renderInput() { return ( { this.props.scanQrCode(); }} - onChange={this.dValidate} + onChange={this.onChange} onPaste={(text) => this.setState({ ethAddress: text })} - onReset={() => this.setState({ ethAddress: '', ensAddress: '' })} + onReset={() => + this.setState({ ethAddress: '', ensAddress: '', input: '' }) + } updateEnsResolution={(address) => { this.setState({ ensAddress: address, error: '', ensError: '' }); }} updateEnsResolutionError={(message) => this.setState({ ensError: message }) } - value={this.state.ethAddress || ''} + userInput={this.state.ethAddress || this.state.input} /> ); } diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 8d3c63c5f138..6b369a9b3040 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,7 +6,7 @@ import { showQrScanner, qrCodeDetected, } from '../../../../store/actions'; -import { getQrCodeData } from '../../../../selectors'; +import { getQrCodeData } from '../../../../ducks/app/app'; import AddContact from './add-contact.component'; const mapStateToProps = (state) => { diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js index d9c0ea7c355d..013b67a87d40 100644 --- a/ui/pages/swaps/build-quote/build-quote.js +++ b/ui/pages/swaps/build-quote/build-quote.js @@ -18,7 +18,7 @@ import { I18nContext } from '../../../contexts/i18n'; import DropdownInputPair from '../dropdown-input-pair'; import DropdownSearchList from '../dropdown-search-list'; import SlippageButtons from '../slippage-buttons'; -import { getTokens } from '../../../ducks/metamask/metamask'; +import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; import InfoTooltip from '../../../components/ui/info-tooltip'; import ActionableMessage from '../actionable-message'; @@ -35,11 +35,11 @@ import { import { getSwapsDefaultToken, getTokenExchangeRates, - getConversionRate, getCurrentCurrency, getCurrentChainId, getRpcPrefsForCurrentProvider, } from '../../../selectors'; + import { getValueFromWeiHex, hexToDecimal, diff --git a/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js b/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js index 15efd88d6097..9bcd95717151 100644 --- a/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js +++ b/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js @@ -8,9 +8,9 @@ import { getDefaultActiveButtonIndex, getRenderableGasButtonData, getUSDConversionRate, - getNativeCurrency, getSwapsDefaultToken, } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { getSwapsCustomizationModalPrice, diff --git a/ui/pages/swaps/view-quote/view-quote.js b/ui/pages/swaps/view-quote/view-quote.js index c472f1f400b8..217de73dee7a 100644 --- a/ui/pages/swaps/view-quote/view-quote.js +++ b/ui/pages/swaps/view-quote/view-quote.js @@ -38,12 +38,13 @@ import { getTokenExchangeRates, getSwapsDefaultToken, getCurrentChainId, - getNativeCurrency, isHardwareWallet, getHardwareWalletType, } from '../../../selectors'; +import { getNativeCurrency, getTokens } from '../../../ducks/metamask/metamask'; + import { toPrecisionWithoutTrailingZeros } from '../../../helpers/utils/util'; -import { getTokens } from '../../../ducks/metamask/metamask'; + import { safeRefetchQuotes, setCustomApproveTxData, diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index ba837e8ab4b9..70982d1b1af5 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -11,8 +11,8 @@ import { } from '../helpers/utils/confirm-tx.util'; import { sumHexes } from '../helpers/utils/transactions.util'; import { transactionMatchesNetwork } from '../../shared/modules/transaction.utils'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; import { getCurrentChainId, deprecatedGetCurrentNetworkId } from './selectors'; -import { getNativeCurrency } from '.'; const unapprovedTxsSelector = (state) => state.metamask.unapprovedTxs; const unapprovedMsgsSelector = (state) => state.metamask.unapprovedMsgs; diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index ea64bcbe6a89..526455f407ad 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -9,12 +9,9 @@ import { formatETHFee } from '../helpers/utils/formatters'; import { calcGasTotal } from '../pages/send/send.utils'; import { GAS_ESTIMATE_TYPES } from '../helpers/constants/common'; -import { - getCurrentCurrency, - getIsMainnet, - getPreferences, - getGasPrice, -} from '.'; +import { getGasPrice } from '../ducks/send'; +import { BASIC_ESTIMATE_STATES } from '../ducks/gas/gas.duck'; +import { getCurrentCurrency, getIsMainnet, getPreferences } from '.'; const NUMBER_OF_DECIMALS_SM_BTNS = 5; @@ -293,8 +290,7 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { const { showFiatInTestnets } = getPreferences(state); const isMainnet = getIsMainnet(state); const showFiat = isMainnet || Boolean(showFiatInTestnets); - const gasLimit = - state.metamask.send.gasLimit || getCustomGasLimit(state) || '0x5208'; + const gasLimit = state.send.gasLimit || getCustomGasLimit(state) || '0x5208'; const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); const { @@ -369,5 +365,5 @@ export function getIsEthGasPriceFetched(state) { export function getNoGasPriceFetched(state) { const gasState = state.gas; - return Boolean(gasState.basicEstimateStatus === 'FAILED'); + return Boolean(gasState.basicEstimateStatus === BASIC_ESTIMATE_STATES.FAILED); } diff --git a/ui/selectors/custom-gas.test.js b/ui/selectors/custom-gas.test.js index 8838641fe7ef..983df192970c 100644 --- a/ui/selectors/custom-gas.test.js +++ b/ui/selectors/custom-gas.test.js @@ -110,10 +110,8 @@ describe('custom-gas selectors', () => { }); it('should return false gas.basicEstimates.price 0x28bed01600 (175) (checkSend=true)', () => { const mockState = { - metamask: { - send: { - gasPrice: '0x28bed0160', - }, + send: { + gasPrice: '0x28bed0160', }, gas: { customData: { price: null }, @@ -124,10 +122,8 @@ describe('custom-gas selectors', () => { }); it('should return true gas.basicEstimates.price 0x30e4f9b400 (210) (checkSend=true)', () => { const mockState = { - metamask: { - send: { - gasPrice: '0x30e4f9b400', - }, + send: { + gasPrice: '0x30e4f9b400', }, gas: { customData: { price: null }, @@ -220,9 +216,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: false, }, @@ -231,6 +224,9 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { blockTime: 14.16326530612245, @@ -271,9 +267,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: false, }, @@ -282,6 +275,9 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { blockTime: 14.16326530612245, @@ -322,9 +318,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: true, }, @@ -333,6 +326,9 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { safeLow: 5, @@ -367,9 +363,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: true, }, @@ -378,6 +371,9 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { safeLow: 5, @@ -428,9 +424,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 255.71, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: false, }, @@ -439,6 +432,9 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { safeLow: 25, @@ -473,9 +469,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: false, }, @@ -484,6 +477,9 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { blockTime: 14.16326530612245, @@ -524,9 +520,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: false, }, @@ -535,6 +528,9 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { blockTime: 14.16326530612245, @@ -575,9 +571,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: true, }, @@ -586,6 +579,9 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { safeLow: 50, @@ -620,9 +616,6 @@ describe('custom-gas selectors', () => { metamask: { conversionRate: 2557.1, currentCurrency: 'usd', - send: { - gasLimit: '0x5208', - }, preferences: { showFiatInTestnets: true, }, @@ -631,6 +624,9 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, + send: { + gasLimit: '0x5208', + }, gas: { basicEstimates: { safeLow: 50, diff --git a/ui/selectors/index.js b/ui/selectors/index.js index b82c59c05eaf..3f4ff3b0eceb 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/selectors.js b/ui/selectors/selectors.js index dff8c6a91eba..c1cd67a5a0ee 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -23,7 +23,7 @@ import { import { TEMPLATED_CONFIRMATION_MESSAGE_TYPES } from '../pages/confirmation/templates'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; -import { getNativeCurrency } from './send'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; /** * One of the only remaining valid uses of selecting the network subkey of the diff --git a/ui/selectors/send-selectors-test-data.js b/ui/selectors/send-selectors-test-data.js index e6c0d230c97d..963e540c5747 100644 --- a/ui/selectors/send-selectors-test-data.js +++ b/ui/selectors/send-selectors-test-data.js @@ -210,7 +210,6 @@ const state = { identities: {}, send: { fromDropdownOpen: false, - toDropdownOpen: false, errors: { someError: null }, }, }; diff --git a/ui/selectors/send.js b/ui/selectors/send.js deleted file mode 100644 index d6f291c9f15b..000000000000 --- a/ui/selectors/send.js +++ /dev/null @@ -1,162 +0,0 @@ -import abi from 'human-standard-token-abi'; -import { calcGasTotal } from '../pages/send/send.utils'; -import { - accountsWithSendEtherInfoSelector, - getAddressBook, - getSelectedAccount, - getTargetAccount, - getAveragePriceEstimateInHexWEI, -} from '.'; - -export function getBlockGasLimit(state) { - return state.metamask.currentBlockGasLimit; -} - -export function getConversionRate(state) { - return state.metamask.conversionRate; -} - -export function getNativeCurrency(state) { - return state.metamask.nativeCurrency; -} - -export function getGasLimit(state) { - return state.metamask.send.gasLimit || '0'; -} - -export function getGasPrice(state) { - return state.metamask.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.metamask.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.metamask.send.amount; -} - -export function getSendHexData(state) { - return state.metamask.send.data; -} - -export function getSendHexDataFeatureFlagState(state) { - return state.metamask.featureFlags.sendHexData; -} - -export function getSendEditingTransactionId(state) { - return state.metamask.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.metamask.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.metamask.send.maxModeOn; -} - -export function getSendTo(state) { - return state.metamask.send.to; -} - -export function getSendToNickname(state) { - return state.metamask.send.toNickname; -} - -export function getSendToAccounts(state) { - const fromAccounts = accountsWithSendEtherInfoSelector(state); - const addressBookAccounts = getAddressBook(state); - return [...fromAccounts, ...addressBookAccounts]; -} -export function getTokenBalance(state) { - return state.metamask.send.tokenBalance; -} - -export function getSendEnsResolution(state) { - return state.metamask.send.ensResolution; -} - -export function getSendEnsResolutionError(state) { - return state.metamask.send.ensResolutionError; -} - -export function getUnapprovedTxs(state) { - return state.metamask.unapprovedTxs; -} - -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 bab7b63feb61..000000000000 --- a/ui/selectors/send.test.js +++ /dev/null @@ -1,526 +0,0 @@ -import sinon from 'sinon'; -import { TRANSACTION_STATUSES } from '../../shared/constants/transaction'; -import { - getBlockGasLimit, - getConversionRate, - getNativeCurrency, - getGasLimit, - getGasPrice, - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendTokenContract, - getSendAmount, - sendAmountIsInError, - getSendEditingTransactionId, - getSendErrors, - getSendFrom, - getSendFromBalance, - getSendFromObject, - getSendHexDataFeatureFlagState, - getSendMaxModeState, - getSendTo, - getSendToAccounts, - getTokenBalance, - getUnapprovedTxs, - 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('getBlockGasLimit', () => { - it('should return the current block gas limit', () => { - expect(getBlockGasLimit(mockState)).toStrictEqual('0x4c1878'); - }); - }); - - describe('getConversionRate()', () => { - it('should return the eth conversion rate', () => { - expect(getConversionRate(mockState)).toStrictEqual(1200.88200327); - }); - }); - - 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('getNativeCurrency()', () => { - it('should return the ticker symbol of the selected network', () => { - expect(getNativeCurrency(mockState)).toStrictEqual('ETH'); - }); - }); - - 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({ - metamask: { send: { token: { symbol: 'DEF' } } }, - }), - ).toStrictEqual('DEF'); - }); - }); - - describe('getSendToken()', () => { - it('should return the current send token if set', () => { - expect( - getSendToken({ - metamask: { - 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({ - metamask: { - send: { - token: { - address: '0x8d6b81208414189a58339873ab429b6c47ab92d3', - decimals: 4, - symbol: 'DEF', - }, - }, - }, - }), - ).toStrictEqual('mockAt:0x8d6b81208414189a58339873ab429b6c47ab92d3'); - }); - - it('should return null if send token is not set', () => { - const modifiedMetamaskState = { ...mockState.metamask, send: {} }; - expect( - getSendTokenContract({ ...mockState, metamask: modifiedMetamaskState }), - ).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('getSendHexDataFeatureFlagState()', () => { - it('should return the sendHexData feature flag state', () => { - expect(getSendHexDataFeatureFlagState(mockState)).toStrictEqual(true); - }); - }); - - 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 = { - metamask: { - ...mockState.metamask, - 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 = { - metamask: { - ...mockState.metamask, - 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('getSendToAccounts()', () => { - it('should return an array including all the users accounts and the address book', () => { - expect(getSendToAccounts(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', - }, - { - address: '0x06195827297c7a80a443b6894d3bdb8824b43896', - name: 'Address Book Account 1', - chainId: '0x3', - }, - ]); - }); - }); - - describe('getTokenBalance()', () => { - it('should', () => { - expect(getTokenBalance(mockState)).toStrictEqual(3434); - }); - }); - - describe('getUnapprovedTxs()', () => { - it('should return the unapproved txs', () => { - expect(getUnapprovedTxs(mockState)).toStrictEqual({ - 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', - }, - }); - }); - }); - - 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 { - metamask: { - 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 8a9f5cd84d4f..4f6e634edf54 100644 --- a/ui/store/actionConstants.js +++ b/ui/store/actionConstants.js @@ -15,6 +15,9 @@ 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 FORGOT_PASSWORD = 'FORGOT_PASSWORD'; export const CLOSE_WELCOME_SCREEN = 'CLOSE_WELCOME_SCREEN'; // unlock screen @@ -39,24 +42,6 @@ export const COMPLETED_TX = 'COMPLETED_TX'; export const TRANSACTION_ERROR = 'TRANSACTION_ERROR'; export const UPDATE_TRANSACTION_PARAMS = 'UPDATE_TRANSACTION_PARAMS'; export const SET_NEXT_NONCE = 'SET_NEXT_NONCE'; -// send screen -export const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT'; -export const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE'; -export const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL'; -export const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA'; -export const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE'; -export const UPDATE_SEND_TO = 'UPDATE_SEND_TO'; -export const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT'; -export const UPDATE_SEND_ERRORS = 'UPDATE_SEND_ERRORS'; -export const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE'; -export const UPDATE_SEND = 'UPDATE_SEND'; -export const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN'; -export const CLEAR_SEND = 'CLEAR_SEND'; -export const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED'; -export const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED'; -export const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION'; -export const UPDATE_SEND_ENS_RESOLUTION_ERROR = - 'UPDATE_SEND_ENS_RESOLUTION_ERROR'; // config screen export const SET_RPC_TARGET = 'SET_RPC_TARGET'; export const SET_PROVIDER_TYPE = 'SET_PROVIDER_TYPE'; diff --git a/ui/store/actions.js b/ui/store/actions.js index d701d20ad570..85b18ab4ee03 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -1,9 +1,8 @@ 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 { calcTokenBalance, estimateGasForSend } from '../pages/send/send.utils'; import { fetchLocale, loadRelativeTimeFormatLocaleData, @@ -13,13 +12,14 @@ import { getSymbolAndDecimals } from '../helpers/utils/token-util'; import switchDirection from '../helpers/utils/switch-direction'; import { ENVIRONMENT_TYPE_NOTIFICATION } from '../../shared/constants/app'; import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util'; -import { setCustomGasLimit } from '../ducks/gas/gas.duck'; import txHelper from '../helpers/utils/tx-helper'; import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util'; import { + getMetaMaskAccounts, getPermittedAccountsForCurrentTab, getSelectedAddress, } from '../selectors'; +import { 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'; @@ -634,128 +634,6 @@ export function signTx(txData) { }; } -export function setGasLimit(gasLimit) { - return { - type: actionConstants.UPDATE_GAS_LIMIT, - value: gasLimit, - }; -} - -export function setGasPrice(gasPrice) { - return { - type: actionConstants.UPDATE_GAS_PRICE, - value: gasPrice, - }; -} - -export function setGasTotal(gasTotal) { - return { - type: actionConstants.UPDATE_GAS_TOTAL, - value: gasTotal, - }; -} - -export function updateGasData({ - gasPrice, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - data, -}) { - return (dispatch) => { - dispatch(gasLoadingStarted()); - return estimateGasForSend({ - estimateGasMethod: promisifiedBackground.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: actionConstants.GAS_LOADING_STARTED, - }; -} - -export function gasLoadingFinished() { - return { - type: actionConstants.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 updateSendErrors(errorObject) { - return { - type: actionConstants.UPDATE_SEND_ERRORS, - value: errorObject, - }; -} - -export function setSendTokenBalance(tokenBalance) { - return { - type: actionConstants.UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, - }; -} - -export function updateSendHexData(value) { - return { - type: actionConstants.UPDATE_SEND_HEX_DATA, - value, - }; -} - -export function updateSendTo(to, nickname = '') { - return { - type: actionConstants.UPDATE_SEND_TO, - value: { to, nickname }, - }; -} - -export function updateSendAmount(amount) { - return { - type: actionConstants.UPDATE_SEND_AMOUNT, - value: amount, - }; -} - export function updateCustomNonce(value) { return { type: actionConstants.UPDATE_CUSTOM_NONCE, @@ -763,47 +641,6 @@ export function updateCustomNonce(value) { }; } -export function setMaxModeTo(bool) { - return { - type: actionConstants.UPDATE_MAX_MODE, - value: bool, - }; -} - -export function updateSend(newSend) { - return { - type: actionConstants.UPDATE_SEND, - value: newSend, - }; -} - -export function updateSendToken(token) { - return { - type: actionConstants.UPDATE_SEND_TOKEN, - value: token, - }; -} - -export function clearSend() { - return { - type: actionConstants.CLEAR_SEND, - }; -} - -export function updateSendEnsResolution(ensResolution) { - return { - type: actionConstants.UPDATE_SEND_ENS_RESOLUTION, - payload: ensResolution, - }; -} - -export function updateSendEnsResolutionError(errorMessage) { - return { - type: actionConstants.UPDATE_SEND_ENS_RESOLUTION_ERROR, - payload: errorMessage, - }; -} - export function signTokenTx(tokenAddress, toAddress, amount, txData) { return async (dispatch) => { dispatch(showLoadingIndication()); @@ -885,7 +722,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)); @@ -901,7 +738,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('')); @@ -1071,7 +908,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()); @@ -1114,7 +951,7 @@ export function cancelTxs(txDataList) { const newState = await updateMetamaskStateFromBackground(); dispatch(updateMetamaskState(newState)); - dispatch(clearSend()); + dispatch(resetSendState()); txIds.forEach((id) => { dispatch(completedTx(id)); @@ -1202,19 +1039,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 }); } + // Ensuring that the chainId is not undefined for both providers ensures we + // can rely upon the logic of our getMetaMaskAccounts selector instead of + // replicating its behavior here in a way more tolerant of missing + // properties + if (provider?.chainId !== undefined && newProvider?.chainId !== undefined) { + 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 }, + }); + } + } + + if ( + provider?.chainId !== newProvider?.chainId && + newProvider?.chainId !== undefined + ) { + dispatch({ + type: actionConstants.CHAIN_CHANGED, + payload: newProvider.chainId, + }); + } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE, value: newState, @@ -1305,6 +1182,7 @@ export function showAccountDetail(address) { try { await _setSelectedAddress(dispatch, address); + await forceUpdateMetamaskState(dispatch); } catch (error) { dispatch(displayWarning(error.message)); return; @@ -2570,18 +2448,6 @@ export function setConnectedStatusPopoverHasBeenShown() { }; } -export async function setAlertEnabledness(alertId, enabledness) { - await promisifiedBackground.setAlertEnabledness(alertId, enabledness); -} - -export async function setUnconnectedAccountAlertShown(origin) { - await promisifiedBackground.setUnconnectedAccountAlertShown(origin); -} - -export async function setWeb3ShimUsageAlertDismissed(origin) { - await promisifiedBackground.setWeb3ShimUsageAlertDismissed(origin); -} - export function loadingMethodDataStarted() { return { type: actionConstants.LOADING_METHOD_DATA_STARTED, @@ -2848,6 +2714,18 @@ export function setLedgerLivePreference(value) { }; } +// Wrappers around promisifedBackground +/** + * The "actions" below are not actions nor action creators. They cannot use + * dispatch nor should they be dispatched when used. Instead they can be + * called directly. These wrappers will be moved into their location at some + * point in the future. + */ + +export function estimateGas() { + return promisifiedBackground.estimateGas(); +} + // MetaMetrics /** * @typedef {import('../../shared/constants/metametrics').MetaMetricsEventPayload} MetaMetricsEventPayload @@ -2879,3 +2757,15 @@ export function updateViewedNotifications(notificationIdViewedStatusMap) { notificationIdViewedStatusMap, ); } + +export async function setAlertEnabledness(alertId, enabledness) { + await promisifiedBackground.setAlertEnabledness(alertId, enabledness); +} + +export async function setUnconnectedAccountAlertShown(origin) { + await promisifiedBackground.setUnconnectedAccountAlertShown(origin); +} + +export async function setWeb3ShimUsageAlertDismissed(origin) { + await promisifiedBackground.setWeb3ShimUsageAlertDismissed(origin); +} diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 409f038538d2..62f5bf72bd4e 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -13,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; @@ -24,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)), }); }); @@ -57,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' }, ]; @@ -110,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' }, @@ -158,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' }, @@ -253,6 +254,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xAnotherAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xAnotherAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xAnotherAddress': '0x0', + }, + }, }), ); @@ -263,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', @@ -399,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, { @@ -659,7 +677,7 @@ describe('Actions', () => { const store = mockStore(); const signMessage = background.signMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -704,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); @@ -785,7 +803,7 @@ describe('Actions', () => { const store = mockStore(); const signTypedMsg = background.signTypedMessage.callsFake((_, cb) => - cb(null, defaultState), + cb(null, defaultState.metamask), ); actions._setBackgroundConnection(background); @@ -852,67 +870,6 @@ describe('Actions', () => { }); }); - describe('#updatedGasData', () => { - it('errors when get code does not return', async () => { - const store = mockStore(); - - background.estimateGas = sinon.stub().rejects(); - - actions._setBackgroundConnection(background); - - global.eth = { - getCode: sinon.stub().rejects(), - }; - - const expectedActions = [ - { type: 'GAS_LOADING_STARTED' }, - { - type: 'UPDATE_SEND_ERRORS', - value: { gasLoadingError: 'gasLoadingError' }, - }, - { type: 'GAS_LOADING_FINISHED' }, - ]; - - const mockData = { - gasPrice: '0x3b9aca00', // - blockGasLimit: '0x6ad79a', // 7002010 - selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', - to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', - value: '0xde0b6b3a7640000', // 1000000000000000000 - }; - - await store.dispatch(actions.updateGasData(mockData)); - - expect(store.getActions()).toStrictEqual(expectedActions); - }); - - it('returns default gas limit for basic eth transaction', async () => { - const mockData = { - gasPrice: '0x3b9aca00', - blockGasLimit: '0x6ad79a', // 7002010 - selectedAddress: '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', - to: '0xEC1Adf982415D2Ef5ec55899b9Bfb8BC0f29251B', - value: '0xde0b6b3a7640000', // 1000000000000000000 - }; - - global.eth = { - getCode: sinon.stub().returns('0x'), - }; - const store = mockStore(); - - const expectedActions = [ - { type: 'GAS_LOADING_STARTED' }, - { type: 'UPDATE_GAS_LIMIT', value: '0x5208' }, - { type: 'metamask/gas/SET_CUSTOM_GAS_LIMIT', value: '0x5208' }, - { type: 'UPDATE_SEND_ERRORS', value: { gasLoadingError: null } }, - { type: 'GAS_LOADING_FINISHED' }, - ]; - - await store.dispatch(actions.updateGasData(mockData)); - expect(store.getActions()).toStrictEqual(expectedActions); - }); - }); - describe('#signTokenTx', () => { it('calls eth.contract', async () => { global.eth = { @@ -953,12 +910,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()); @@ -1757,10 +1709,7 @@ describe('Actions', () => { { type: 'FORGOT_PASSWORD', value: true }, { type: 'UPDATE_METAMASK_STATE', - value: { - currentLocale: 'test', - selectedAddress: '0xFirstAddress', - }, + value: baseMockState, }, ]; @@ -1820,6 +1769,19 @@ describe('Actions', () => { cb(null, { currentLocale: 'test', selectedAddress: '0xFirstAddress', + provider: { + chainId: '0x1', + }, + accounts: { + '0xFirstAddress': { + balance: '0x0', + }, + }, + cachedBalances: { + '0x1': { + '0xFirstAddress': '0x0', + }, + }, }), ), });