diff --git a/.circleci/config.yml b/.circleci/config.yml index 6648b608d..cc41155be 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -343,7 +343,7 @@ jobs: command: | if .circleci/scripts/test-run-e2e.sh then - yarn test:e2e:chrome + yarn test:e2e:chrome --retries 2 fi no_output_timeout: 20m - store_artifacts: @@ -370,7 +370,7 @@ jobs: command: | if .circleci/scripts/test-run-e2e.sh then - yarn test:e2e:chrome:metrics + yarn test:e2e:chrome:metrics --retries 2 fi no_output_timeout: 20m - store_artifacts: @@ -397,7 +397,7 @@ jobs: command: | if .circleci/scripts/test-run-e2e.sh then - yarn test:e2e:firefox + yarn test:e2e:firefox --retries 2 fi no_output_timeout: 20m - store_artifacts: @@ -424,7 +424,7 @@ jobs: command: | if .circleci/scripts/test-run-e2e.sh then - yarn test:e2e:firefox:metrics + yarn test:e2e:firefox:metrics --retries 2 fi no_output_timeout: 20m - store_artifacts: @@ -448,7 +448,7 @@ jobs: command: mv ./builds-test ./builds - run: name: Run page load benchmark - command: yarn benchmark:chrome --out test-artifacts/chrome/benchmark/pageload.json + command: yarn benchmark:chrome --out test-artifacts/chrome/benchmark/pageload.json --retries 2 - store_artifacts: path: test-artifacts destination: test-artifacts diff --git a/.circleci/scripts/chrome-install.sh b/.circleci/scripts/chrome-install.sh index c301f9056..37272e1a5 100755 --- a/.circleci/scripts/chrome-install.sh +++ b/.circleci/scripts/chrome-install.sh @@ -14,7 +14,10 @@ wget -O "${CHROME_BINARY}" -t 5 "${CHROME_BINARY_URL}" if [[ $(shasum -a 512 "${CHROME_BINARY}" | cut '--delimiter= ' -f1) != "${CHROME_BINARY_SHA512SUM}" ]] then + echo "Google Chrome binary checksum did not match." exit 1 +else + echo "Google Chrome binary checksum verified." fi (sudo dpkg -i "${CHROME_BINARY}" || sudo apt-get -fy install) diff --git a/.eslintrc.js b/.eslintrc.js index fa67adaaf..56c2d4b86 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,7 +108,11 @@ module.exports = { }, { files: ['**/*.test.js'], - excludedFiles: ['ui/**/*.test.js', 'ui/__mocks__/*.js'], + excludedFiles: [ + 'ui/**/*.test.js', + 'ui/__mocks__/*.js', + 'shared/**/*.test.js', + ], extends: ['@metamask/eslint-config-mocha'], rules: { 'mocha/no-setup-in-describe': 'off', @@ -125,7 +129,7 @@ module.exports = { }, }, { - files: ['ui/**/*.test.js', 'ui/__mocks__/*.js'], + files: ['ui/**/*.test.js', 'ui/__mocks__/*.js', 'shared/**/*.test.js'], extends: ['@metamask/eslint-config-jest'], rules: { 'jest/no-restricted-matchers': 'off', diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 32c24690f..7d034d86c 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -140,7 +140,6 @@ const state = { } }, "participateInMetaMetrics": true, - "metaMetricsSendCount": 2, "nextNonce": 71, "connectedStatusPopoverHasBeenShown": true, "swapsWelcomeMessageHasBeenShown": true, @@ -261,7 +260,7 @@ const state = { } }, "assetImages": { - "0xad6d458402f60fd3bd25163575031acdce07538d": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xaD6D458402F60fD3Bd25163575031ACDce07538D/logo.png" + "0xad6d458402f60fd3bd25163575031acdce07538d": "./images/logo.png" }, "hiddenTokens": [], "suggestedTokens": {}, @@ -272,7 +271,7 @@ const state = { "ipfsGateway": "dweb.link", "infuraBlocked": false, "migratedPrivacyMode": false, - "selectedAddress": "0x64a845a5b02460acf8a3d84503b0d68d028b4bb4", + "selectedAddress": "0x9d0ba4ddac06032527b140912ec808ab9451b788", "metaMetricsId": "0xc2377d11fec1c3b7dd88c4854240ee5e3ed0d9f63b00456d98d80320337b827f", "conversionDate": 1620710825.03, "conversionRate": 3910.28, diff --git a/README.md b/README.md index cc546cd93..b05ac8625 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Hey! We are hiring JavaScript Engineers! [Apply here](https://boards.greenhouse. You can find the latest version of MetaMask on [our official website](https://metamask.io/). For help using MetaMask, visit our [User Support Site](https://metamask.zendesk.com/hc/en-us). -For [general questions](https://metamask.zendesk.com/hc/en-us/community/topics/360000682532-General), [feature requests](https://metamask.zendesk.com/hc/en-us/community/topics/360000682552-Feature-Requests-Ideas), or [developer questions](https://metamask.zendesk.com/hc/en-us/community/topics/360001751291-Developer-Questions), visit our [Community Forum](https://metamask.zendesk.com/hc/en-us/community/topics). +For [general questions](https://community.metamask.io/c/learn/26), [feature requests](https://community.metamask.io/c/feature-requests-ideas/13), or [developer questions](https://community.metamask.io/c/developer-questions/11), visit our [Community Forum](https://community.metamask.io/). MetaMask supports Firefox, Google Chrome, and Chromium-based browsers. We recommend using the latest available browser version. diff --git a/app/_locales/am/messages.json b/app/_locales/am/messages.json index 91c2eb214..18c3b7378 100644 --- a/app/_locales/am/messages.json +++ b/app/_locales/am/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "ማሰሺያዎት አልተደገፈም..." }, - "builtInCalifornia": { - "message": "MetaMask ካሊፎርኒያ ውስጥ ተዘጋጅቶ የተገነባ ነው።" - }, "buyWithWyre": { "message": "ETH በ Wyre ይግዙ" }, @@ -751,9 +748,6 @@ "recents": { "message": "የቅርብ ጊዜያት" }, - "recipientAddress": { - "message": "የተቀባይ አድራሻ" - }, "recipientAddressPlaceholder": { "message": "ፍለጋ፣ ለሕዝብ ክፍት የሆነ አድራሻ (0x), ወይም ENS" }, diff --git a/app/_locales/ar/messages.json b/app/_locales/ar/messages.json index ffe065f16..90f7618c8 100644 --- a/app/_locales/ar/messages.json +++ b/app/_locales/ar/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "متصفحك غير مدعوم..." }, - "builtInCalifornia": { - "message": "تم تصميم وإنشاء MetaMask في ولاية كاليفورنيا." - }, "buyWithWyre": { "message": "قم بشراء عملة إيثير بواسطة Wyre" }, @@ -747,9 +744,6 @@ "recents": { "message": "الحديث" }, - "recipientAddress": { - "message": "عنوان المستلم" - }, "recipientAddressPlaceholder": { "message": "البحث، العنوان العام (0x)، أو ENS" }, diff --git a/app/_locales/bg/messages.json b/app/_locales/bg/messages.json index dde63701f..26cae225a 100644 --- a/app/_locales/bg/messages.json +++ b/app/_locales/bg/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Браузърът ви не се поддържа ..." }, - "builtInCalifornia": { - "message": "MetaMask е проектиран и създаден в Калифорния." - }, "buyWithWyre": { "message": "Купете ETH с Wyre" }, @@ -750,9 +747,6 @@ "recents": { "message": "Скорошни" }, - "recipientAddress": { - "message": "Адрес на получателя" - }, "recipientAddressPlaceholder": { "message": "Търсене, публичен адрес (0x) или ENS" }, diff --git a/app/_locales/bn/messages.json b/app/_locales/bn/messages.json index d2c08ab7b..2b1724128 100644 --- a/app/_locales/bn/messages.json +++ b/app/_locales/bn/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "আপনার ব্রাউজার সমর্থিত নয়..." }, - "builtInCalifornia": { - "message": "MetaMask ক্যালিফোর্নিয়াতে ডিজাইন করা এবং নির্মিত।" - }, "buyWithWyre": { "message": "Wyre দিয়ে ETH ক্রয় করুন" }, @@ -754,9 +751,6 @@ "recents": { "message": "সাম্প্রতিকগুলি" }, - "recipientAddress": { - "message": "প্রাপকের ঠিকানা" - }, "recipientAddressPlaceholder": { "message": "অনুসন্ধান, সার্বজনীন ঠিকানা (0x), বা ENS" }, diff --git a/app/_locales/ca/messages.json b/app/_locales/ca/messages.json index ab0e91b84..7571840c8 100644 --- a/app/_locales/ca/messages.json +++ b/app/_locales/ca/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "El teu navegador no és suportat..." }, - "builtInCalifornia": { - "message": "MetaMask ha estat dissenyat i desenvolupat a Califòrnia." - }, "buyWithWyre": { "message": "Compra ETH amb Wyre" }, @@ -732,9 +729,6 @@ "readdToken": { "message": "Pots tornar a afegir aquesta fitxa en el futur anant a \"Afegir fitxa\" al menu d'opcions dels teus comptes." }, - "recipientAddress": { - "message": "Adreça del destinatari" - }, "recipientAddressPlaceholder": { "message": "Cerca, adreça pública (0x), o ENS" }, diff --git a/app/_locales/cs/messages.json b/app/_locales/cs/messages.json index 442b91544..3e158ca01 100644 --- a/app/_locales/cs/messages.json +++ b/app/_locales/cs/messages.json @@ -46,9 +46,6 @@ "blockiesIdenticon": { "message": "Použít Blockies Identicon" }, - "builtInCalifornia": { - "message": "MetaMask je navržen a vytvořen v Kalifornii." - }, "cancel": { "message": "Zrušit" }, @@ -308,9 +305,6 @@ "readdToken": { "message": "Tento token můžete v budoucnu přidat zpět s „Přidat token“ v nastavení účtu." }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "reject": { "message": "Odmítnout" }, diff --git a/app/_locales/da/messages.json b/app/_locales/da/messages.json index 24fd9a480..8138bdc93 100644 --- a/app/_locales/da/messages.json +++ b/app/_locales/da/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Din browser er ikke understøttet..." }, - "builtInCalifornia": { - "message": "MetaMask er designet og bygget i Californien." - }, "buyWithWyre": { "message": "Køb ETH med Wyre" }, @@ -735,9 +732,6 @@ "recents": { "message": "Seneste" }, - "recipientAddress": { - "message": "Modtagerens adresse" - }, "recipientAddressPlaceholder": { "message": "Søg, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index f04fba67c..48cfd91bc 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -140,9 +140,6 @@ "browserNotSupported": { "message": "Ihr Browser wird nicht unterstützt …" }, - "builtInCalifornia": { - "message": "MetaMask wurde in Kalifornien entwickelt und gebaut." - }, "buyWithWyre": { "message": "ETH mit Wyre kaufen" }, @@ -723,9 +720,6 @@ "recents": { "message": "Letzte" }, - "recipientAddress": { - "message": "Empfängeradresse" - }, "recipientAddressPlaceholder": { "message": "Suchen, öffentliche Adresse (0x) oder ENS" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index d7902743a..59ba5729b 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Το Πρόγραμμα Περιήγησής σας δεν υποστηρίζεται..." }, - "builtInCalifornia": { - "message": "Το MetaMask έχει σχεδιαστεί και αναπτυχθεί στην Καλιφόρνια." - }, "buyWithWyre": { "message": "Αγοράστε ETH με το Wyre" }, @@ -751,9 +748,6 @@ "recents": { "message": "Πρόσφατα" }, - "recipientAddress": { - "message": "Διεύθυνση Παραλήπτη" - }, "recipientAddressPlaceholder": { "message": "Αναζήτηση, δημόσια διεύθυνση (0x) ή ENS" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 40f8bd8ac..521cebef2 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -97,6 +97,9 @@ "addTokens": { "message": "Add Tokens" }, + "addressBookIcon": { + "message": "Address book icon" + }, "advanced": { "message": "Advanced" }, @@ -150,6 +153,9 @@ "amount": { "message": "Amount" }, + "amountGasFee": { + "message": "Amount + Gas Fee" + }, "amountWithColon": { "message": "Amount:" }, @@ -223,7 +229,7 @@ "message": "This secret code is required to recover your wallet in case you lose your device, forget your password, have to re-install MetaMask, or want to access your wallet on another device." }, "backupApprovalNotice": { - "message": "Backup your Secret Recovery code to keep your wallet and funds secure." + "message": "Backup your Secret Recovery Phrase to keep your wallet and funds secure." }, "backupNow": { "message": "Backup now" @@ -253,15 +259,21 @@ "browserNotSupported": { "message": "Your Browser is not supported..." }, - "builContactList": { + "buildContactList": { "message": "Build your contact list" }, - "builtInCalifornia": { - "message": "MetaMask is designed and built in California." + "builtAroundTheWorld": { + "message": "MetaMask is designed and built around the world." }, "buy": { "message": "Buy" }, + "buyWithTransak": { + "message": "Buy ETH with Transak" + }, + "buyWithTransakDescription": { + "message": "Transak supports debit card and bank transfers (depending on location) in 59+ countries. ETH deposits into your MetaMask account." + }, "buyWithWyre": { "message": "Buy ETH with Wyre" }, @@ -414,6 +426,9 @@ "continue": { "message": "Continue" }, + "continueToTransak": { + "message": "Continue to Transak" + }, "continueToWyre": { "message": "Continue to Wyre" }, @@ -492,6 +507,9 @@ "customToken": { "message": "Custom Token" }, + "data": { + "message": "Data" + }, "dataBackupFoundInfo": { "message": "Some of your account data was backed up during a previous installation of MetaMask. This could include your settings, contacts, and tokens. Would you like to restore this data now?" }, @@ -650,12 +668,21 @@ "message": "The endpoint returned a different chain ID: $1", "description": "$1 is the return value of eth_chainId from an RPC endpoint" }, + "ensIllegalCharacter": { + "message": "Illegal Character for ENS." + }, "ensNotFoundOnCurrentNetwork": { "message": "ENS name not found on the current network. Try switching to Ethereum Mainnet." }, + "ensNotSupportedOnNetwork": { + "message": "Network does not support ENS" + }, "ensRegistrationError": { "message": "Error in ENS name registration" }, + "ensUnknownError": { + "message": "ENS Lookup failed." + }, "enterAnAlias": { "message": "Enter an alias" }, @@ -777,6 +804,12 @@ "functionType": { "message": "Function Type" }, + "gasFee": { + "message": "Gas Fee" + }, + "gasFeeEstimate": { + "message": "Estimate" + }, "gasLimit": { "message": "Gas Limit" }, @@ -1064,6 +1097,12 @@ "max": { "message": "Max" }, + "maxFee": { + "message": "Max fee" + }, + "maxPriorityFee": { + "message": "Max priority fee" + }, "memo": { "message": "memo" }, @@ -1355,6 +1394,9 @@ "onlyConnectTrust": { "message": "Only connect with sites you trust." }, + "optional": { + "message": "Optional" + }, "optionalBlockExplorerUrl": { "message": "Block Explorer URL (optional)" }, @@ -1451,12 +1493,12 @@ "recents": { "message": "Recents" }, - "recipientAddress": { - "message": "Recipient Address" - }, "recipientAddressPlaceholder": { "message": "Search, public address (0x), or ENS" }, + "recommendedGasLabel": { + "message": "Recommended" + }, "recoveryPhraseReminderBackupStart": { "message": "Start here" }, @@ -2189,6 +2231,9 @@ "symbolBetweenZeroTwelve": { "message": "Symbol must be 11 characters or fewer." }, + "syncInProgress": { + "message": "Sync in progress" + }, "syncWithMobile": { "message": "Sync with mobile" }, @@ -2369,6 +2414,10 @@ "message": "verify the network details", "description": "Serves as link text for the 'unrecognizedChain' key. This text will be embedded inside the translation for that key." }, + "unsendableAsset": { + "message": "Sending collectible (ERC-721) tokens is not currently supported", + "description": "This is an error message we show the user if they attempt to send a collectible asset type, for which currently don't support sending" + }, "updatedWithDate": { "message": "Updated $1" }, @@ -2411,6 +2460,9 @@ "viewContact": { "message": "View Contact" }, + "viewFullTransactionDetails": { + "message": "View full transaction details" + }, "viewMore": { "message": "View More" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index ea6826817..23d44c8af 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -6,7 +6,7 @@ "message": "Versión, centro de soporte técnico e información de contacto" }, "acceleratingATransaction": { - "message": "* Usar un precio de gas más alto para acelerar una transacción aumenta las posibilidades de un procesamiento más rápido en la red, pero esto no siempre se garantiza." + "message": "* Usar un precio de gas más alto para acelerar una transacción aumenta las posibilidades de un procesamiento más rápido en la red, pero esto no siempre se garantiza." }, "acceptTermsOfUse": { "message": "Leí y estoy de acuerdo con $1", @@ -52,6 +52,10 @@ "addContact": { "message": "Agregar contacto" }, + "addCustomTokenByContractAddress": { + "message": "¿No encuentra un token? Puede agregar cualquier token si copia su dirección. Puede encontrar la dirección de contrato del token en $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Esto permitirá que la red se utilice en MetaMask." }, @@ -85,7 +89,7 @@ "message": "Agregar a la libreta de direcciones" }, "addToAddressBookModalPlaceholder": { - "message": "p. ej., John D." + "message": "p. ej., John D." }, "addToken": { "message": "Agregar token" @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "El explorador no es compatible…" }, - "builContactList": { + "buildContactList": { "message": "Cree su lista de contactos" }, - "builtInCalifornia": { - "message": "MetaMask se diseñó y compiló en California." - }, "buy": { "message": "Comprar" }, @@ -268,7 +269,7 @@ "message": "Bytes" }, "canToggleInSettings": { - "message": "Puede volver a activar esta notificación desde Configuración > Alertas." + "message": "Puede volver a activar esta notificación desde Configuración -> Alertas." }, "cancel": { "message": "Cancelar" @@ -285,8 +286,11 @@ "chainIdDefinition": { "message": "El identificador de cadena que se utiliza para firmar transacciones en esta red." }, + "chainIdExistsErrorMsg": { + "message": "En este momento, la red $1 está utilizando este identificador de cadena." + }, "chromeRequiredForHardwareWallets": { - "message": "Debe usar MetaMask en Google Chrome para poder conectarse a su cartera de hardware." + "message": "Debe usar MetaMask en Google Chrome para poder conectarse a su cartera de hardware." }, "clickToRevealSeed": { "message": "Haga clic aquí para revelar las palabras secretas" @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Continuar a Wyre" }, + "contract": { + "message": "Contrato" + }, "contractAddressError": { "message": "Está enviando tokens a la dirección de contrato del token. Esto puede provocar la pérdida de los tokens." }, @@ -572,7 +579,7 @@ "message": "No volver a mostrar" }, "downloadGoogleChrome": { - "message": "Descargar Google Chrome" + "message": "Descargar Google Chrome" }, "downloadSecretBackup": { "message": "Descargue esta frase secreta de respaldo y guárdela en un medio de almacenamiento o disco duro externo cifrado." @@ -626,6 +633,10 @@ "endOfFlowMessage6": { "message": "Si necesita volver a crear una copia de seguridad de la frase secreta de recuperación, puede encontrarla en Configuración -> Seguridad." }, + "endOfFlowMessage7": { + "message": "Si tiene preguntas o nota movimientos sospechosos, comuníquese con soporte técnico $1.", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." + }, "endOfFlowMessage8": { "message": "MetaMask no puede recuperar la frase secreta de recuperación." }, @@ -770,7 +781,7 @@ "message": "El límite de gas es la cantidad máxima de unidades de gas que está dispuesto a gastar." }, "gasLimitTooLow": { - "message": "El límite de gas debe ser al menos 21 000" + "message": "El límite de gas debe ser al menos 21 000" }, "gasLimitTooLowWithDynamicFee": { "message": "El límite de gas debe ser al menos $1", @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "Importar una cuenta con la frase secreta de recuperación" }, + "importAccountText": { + "message": "o $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "¿Desea importar el token?" + }, + "importTokenWarning": { + "message": "Toda persona puede crear un token con cualquier nombre, incluso versiones falsas de tokens existentes. ¡Agréguelo y realice transacciones bajo su propio riesgo!" + }, "importWallet": { "message": "Importar cartera" }, @@ -956,7 +977,7 @@ "message": "Número no válido. Quite todos los ceros iniciales." }, "invalidRPC": { - "message": "Dirección URL de RPC no válida" + "message": "Dirección URL de RPC no válida" }, "invalidSeedPhrase": { "message": "Frase secreta de recuperación no válida" @@ -1023,7 +1044,7 @@ "message": "Cargando tokens…" }, "localhost": { - "message": "Host local 8545" + "message": "Host local 8545" }, "lock": { "message": "Bloquear" @@ -1110,7 +1131,7 @@ "message": "Escriba su contraseña para confirmar que es usted." }, "mustSelectOne": { - "message": "Debe seleccionar al menos 1 token." + "message": "Debe seleccionar al menos 1 token." }, "myAccounts": { "message": "Mis cuentas" @@ -1160,10 +1181,10 @@ "message": "Agregar y editar redes RPC personalizadas" }, "networkURL": { - "message": "Dirección URL de la red" + "message": "Dirección URL de la red" }, "networkURLDefinition": { - "message": "La dirección URL que se utilizó para acceder a esta red." + "message": "La dirección URL que se utilizó para acceder a esta red." }, "networks": { "message": "Redes" @@ -1191,7 +1212,7 @@ "message": "Red nueva" }, "newPassword": { - "message": "Contraseña nueva (mín. de 8 caracteres)" + "message": "Contraseña nueva (mín. de 8 caracteres)" }, "newToMetaMask": { "message": "¿Es nuevo en MetaMask?" @@ -1287,6 +1308,22 @@ "message": "Su \"frase de recuperación\" ahora se llama \"frase secreta de recuperación.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "A partir de la versión 91 de Chrome, la API que habilitaba nuestro soporte para Ledger (U2F) ya no es compatible con carteras de hardware. MetaMask ha implementado un nuevo soporte para Ledger Live mediante el cual usted puede seguir conectándose a su dispositivo Ledger a través de la aplicación de escritorio Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Cuando interactúe con su cuenta de Ledger a través de MetaMask, se abrirá una nueva pestaña y se le pedirá que abra la aplicación Ledger Live. Una vez que se abra la aplicación, se le pedirá que otorgue permiso para establecer una conexión WebSocket con su cuenta de MetaMask. ¡Eso es todo!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "A fin de habilitar el soporte para Live Ledger, haga clic en Configuración > Avanzada > Utilizar Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Actualización del soporte para Ledger destinada a usuarios de Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "de" }, @@ -1372,7 +1409,7 @@ "message": "Moneda principal" }, "primaryCurrencySettingDescription": { - "message": "Seleccione Nativa para dar prioridad a mostrar los valores en la moneda nativa de la cadena (p. ej., ETH). Seleccione Fiduciaria para dar prioridad a mostrar los valores en la moneda fiduciaria seleccionada." + "message": "Seleccione Nativa para dar prioridad a mostrar los valores en la moneda nativa de la cadena (p. ej., ETH). Seleccione Fiduciaria para dar prioridad a mostrar los valores en la moneda fiduciaria seleccionada." }, "privacyMsg": { "message": "Política de privacidad" @@ -1411,12 +1448,33 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Iniciar aquí" + }, + "recoveryPhraseReminderConfirm": { + "message": "Entendido" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Guarde siempre su frase secreta de recuperación en un lugar seguro y secreto." + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "¿Necesita volver a crear una copia de seguridad de su frase secreta de recuperación?" + }, + "recoveryPhraseReminderItemOne": { + "message": "No comparta nunca su frase secreta de recuperación con nadie." + }, + "recoveryPhraseReminderItemTwo": { + "message": "El equipo de MetaMask nunca le pedirá su frase secreta de recuperación." + }, + "recoveryPhraseReminderSubText": { + "message": "Mediante su frase secreta de recuperación, se controlan todas sus cuentas." + }, + "recoveryPhraseReminderTitle": { + "message": "Proteja sus fondos." + }, "reject": { "message": "Rechazar" }, @@ -1424,7 +1482,7 @@ "message": "Rechazar todo" }, "rejectTxsDescription": { - "message": "Está a punto de rechazar $1 transacciones en lote." + "message": "Está a punto de rechazar $1 transacciones en lote." }, "rejectTxsN": { "message": "Rechazar $1 transacciones" @@ -1442,7 +1500,7 @@ "message": "Quitar cuenta" }, "removeAccountDescription": { - "message": "Esta cuenta se quitará de la cartera. Antes de continuar, asegúrese de tener la frase secreta de recuperación original o la clave privada de esta cuenta importada. Puede importar o crear cuentas nuevamente desde el menú desplegable de la cuenta." + "message": "Esta cuenta se quitará de la cartera. Antes de continuar, asegúrese de tener la frase secreta de recuperación original o la clave privada de esta cuenta importada. Puede importar o crear cuentas nuevamente en la lista desplegable de la cuenta. " }, "requestsAwaitingAcknowledgement": { "message": "solicitudes en espera de confirmación" @@ -1539,11 +1597,47 @@ "message": "Ingrese su frase secreta aquí para restaurar su bóveda." }, "securityAndPrivacy": { - "message": "Seguridad y privacidad" + "message": "Seguridad y privacidad" }, "securitySettingsDescription": { "message": "Configuración de privacidad y frase secreta de recuperación de la cartera" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "Escríbala y guárdela en varios lugares secretos." + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "Guárdela en un administrador de contraseñas" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "Guárdela en una caja fuerte." + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "Guárdela en una bóveda bancaria." + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "Su frase secreta de recuperación es la “llave maestra” de su cartera y sus fondos." + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "Si alguien le pide su frase de recuperación, es posible que tenga intenciones de estafarlo." + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "Nunca comparta su frase secreta de recuperación, ni siquiera con MetaMask." + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "¿Qué es una frase de recuperación?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "¿Debería compartir mi frase de recuperación?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "¿Cómo guardo mi frase de recuperación?" + }, + "seedPhraseIntroTitle": { + "message": "Proteger su cartera" + }, + "seedPhraseIntroTitleCopy": { + "message": "Antes de comenzar, mire este breve video para aprender sobre su frase de recuperación y sobre cómo mantener segura su cartera." + }, "seedPhrasePlaceholder": { "message": "Separar cada palabra con un solo espacio" }, @@ -1551,7 +1645,7 @@ "message": "Pegar la frase secreta de recuperación desde el Portapapeles" }, "seedPhraseReq": { - "message": "Las frases secretas de recuperación contienen 12, 15, 18, 21 o 24 palabras" + "message": "Las frases secretas de recuperación contienen 12, 15, 18, 21 o 24 palabras" }, "selectAHigherGasFee": { "message": "Seleccione una cuota de gas más alta para acelerar el procesamiento de la transacción.*" @@ -1870,7 +1964,7 @@ "message": "Cuota de MetaMask" }, "swapMetaMaskFeeDescription": { - "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", + "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, "swapNQuotes": { @@ -1893,9 +1987,18 @@ "description": "This message represents the price slippage for the swap. $1 and $4 are a number (ex: 2.89), $2 and $5 are symbols (ex: ETH), and $3 and $6 are fiat currency amounts." }, "swapPriceDifferenceTitle": { - "message": "Diferencia de precio de ~$1 %", + "message": "Diferencia de precio de ~$1 %", "description": "$1 is a number (ex: 1.23) that represents the price difference." }, + "swapPriceImpactTooltip": { + "message": "El impacto sobre el precio es la diferencia entre el precio actual del mercado y el monto recibido durante la ejecución de la transacción. El impacto sobre el precio es una función del tamaño de su transacción respecto de la dimensión del fondo de liquidez." + }, + "swapPriceUnavailableDescription": { + "message": "No se pudo determinar el impacto sobre el precio debido a la falta de datos de los precios del mercado. Antes de realizar el canje, confirme que está de acuerdo con la cantidad de tokens que está a punto de recibir." + }, + "swapPriceUnavailableTitle": { + "message": "Antes de continuar, verifique su tasa" + }, "swapProcessing": { "message": "Procesamiento" }, @@ -1906,7 +2009,7 @@ "message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"." }, "swapQuoteIncludesRate": { - "message": "La cotización incluye una cuota de MetaMask de $1 %", + "message": "La cotización incluye una cuota de MetaMask de $1 %", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, "swapQuoteNofN": { @@ -1997,6 +2100,9 @@ "message": "Canjear $1 por $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Este token se añadió de forma manual." + }, "swapTokenVerificationMessage": { "message": "Siempre confirme la dirección del token en $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2012,7 +2118,7 @@ "message": "Transacción completa" }, "swapTwoTransactions": { - "message": "2 transacciones" + "message": "2 transacciones" }, "swapUnknown": { "message": "Desconocido" @@ -2029,13 +2135,13 @@ "description": "Tells the user how much of a token they have in their balance. $1 is a decimal number amount of tokens, and $2 is a token symbol" }, "swapZeroSlippage": { - "message": "0 % de desfase" + "message": "0 % de desfase" }, "swapsAdvancedOptions": { "message": "Opciones avanzadas" }, "swapsExcessiveSlippageWarning": { - "message": "El monto del desfase es muy alto, por lo que recibirá una tasa de conversión desfavorable. Disminuya su tolerancia de desfase a un valor menor al 15 %." + "message": "El monto del desfase es muy alto, por lo que recibirá una tasa de conversión desfavorable. Disminuya su tolerancia de desfase a un valor menor al 15 %." }, "swapsMaxSlippage": { "message": "Tolerancia de desfase" @@ -2075,7 +2181,7 @@ "message": "Símbolo" }, "symbolBetweenZeroTwelve": { - "message": "El símbolo debe tener 11 caracteres o menos." + "message": "El símbolo debe tener 11 caracteres o menos." }, "syncWithMobile": { "message": "Sincronizar con dispositivo móvil" @@ -2264,7 +2370,7 @@ "message": "Las direcciones URL requieren el prefijo HTTP/HTTPS adecuado." }, "urlExistsErrorMsg": { - "message": "La dirección URL ya está en la lista de redes existentes" + "message": "En este momento, la red $1 está utilizando esta dirección URL." }, "usePhishingDetection": { "message": "Usar detección de phishing" @@ -2286,6 +2392,10 @@ "message": "Comprobar este token en $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Verifique este token en $1 y asegúrese de que sea el token con el que quiere realizar la transacción.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Ver cuenta" }, @@ -2320,7 +2430,7 @@ "message": "Frase secreta de recuperación de la cartera" }, "web3ShimUsageNotification": { - "message": "Parece que el sitio web actual intentó utilizar la API de window.web3 que se eliminó. Si el sitio no funciona, haga clic en $1 para obtener más información.", + "message": "Parece que el sitio web actual intentó utilizar la API de window.web3 que se eliminó. Si el sitio no funciona, haga clic en $1 para obtener más información.", "description": "$1 is a clickable link." }, "welcome": { diff --git a/app/_locales/es_419/messages.json b/app/_locales/es_419/messages.json index 413624d20..23d44c8af 100644 --- a/app/_locales/es_419/messages.json +++ b/app/_locales/es_419/messages.json @@ -6,7 +6,7 @@ "message": "Versión, centro de soporte técnico e información de contacto" }, "acceleratingATransaction": { - "message": "* Usar un precio de gas más alto para acelerar una transacción aumenta las posibilidades de un procesamiento más rápido en la red, pero esto no siempre se garantiza." + "message": "* Usar un precio de gas más alto para acelerar una transacción aumenta las posibilidades de un procesamiento más rápido en la red, pero esto no siempre se garantiza." }, "acceptTermsOfUse": { "message": "Leí y estoy de acuerdo con $1", @@ -52,6 +52,10 @@ "addContact": { "message": "Agregar contacto" }, + "addCustomTokenByContractAddress": { + "message": "¿No encuentra un token? Puede agregar cualquier token si copia su dirección. Puede encontrar la dirección de contrato del token en $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Esto permitirá que la red se utilice en MetaMask." }, @@ -85,7 +89,7 @@ "message": "Agregar a la libreta de direcciones" }, "addToAddressBookModalPlaceholder": { - "message": "p. ej., John D." + "message": "p. ej., John D." }, "addToken": { "message": "Agregar token" @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "El explorador no es compatible…" }, - "builContactList": { + "buildContactList": { "message": "Cree su lista de contactos" }, - "builtInCalifornia": { - "message": "MetaMask se diseñó y compiló en California." - }, "buy": { "message": "Comprar" }, @@ -268,7 +269,7 @@ "message": "Bytes" }, "canToggleInSettings": { - "message": "Puede volver a activar esta notificación desde Configuración > Alertas." + "message": "Puede volver a activar esta notificación desde Configuración -> Alertas." }, "cancel": { "message": "Cancelar" @@ -285,8 +286,11 @@ "chainIdDefinition": { "message": "El identificador de cadena que se utiliza para firmar transacciones en esta red." }, + "chainIdExistsErrorMsg": { + "message": "En este momento, la red $1 está utilizando este identificador de cadena." + }, "chromeRequiredForHardwareWallets": { - "message": "Debe usar MetaMask en Google Chrome para poder conectarse a su cartera de hardware." + "message": "Debe usar MetaMask en Google Chrome para poder conectarse a su cartera de hardware." }, "clickToRevealSeed": { "message": "Haga clic aquí para revelar las palabras secretas" @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Continuar a Wyre" }, + "contract": { + "message": "Contrato" + }, "contractAddressError": { "message": "Está enviando tokens a la dirección de contrato del token. Esto puede provocar la pérdida de los tokens." }, @@ -572,7 +579,7 @@ "message": "No volver a mostrar" }, "downloadGoogleChrome": { - "message": "Descargar Google Chrome" + "message": "Descargar Google Chrome" }, "downloadSecretBackup": { "message": "Descargue esta frase secreta de respaldo y guárdela en un medio de almacenamiento o disco duro externo cifrado." @@ -774,7 +781,7 @@ "message": "El límite de gas es la cantidad máxima de unidades de gas que está dispuesto a gastar." }, "gasLimitTooLow": { - "message": "El límite de gas debe ser al menos 21 000" + "message": "El límite de gas debe ser al menos 21 000" }, "gasLimitTooLowWithDynamicFee": { "message": "El límite de gas debe ser al menos $1", @@ -893,6 +900,12 @@ "message": "o $1", "description": "$1 represents the text from `importAccountLinkText` as a link" }, + "importTokenQuestion": { + "message": "¿Desea importar el token?" + }, + "importTokenWarning": { + "message": "Toda persona puede crear un token con cualquier nombre, incluso versiones falsas de tokens existentes. ¡Agréguelo y realice transacciones bajo su propio riesgo!" + }, "importWallet": { "message": "Importar cartera" }, @@ -964,7 +977,7 @@ "message": "Número no válido. Quite todos los ceros iniciales." }, "invalidRPC": { - "message": "Dirección URL de RPC no válida" + "message": "Dirección URL de RPC no válida" }, "invalidSeedPhrase": { "message": "Frase secreta de recuperación no válida" @@ -1031,7 +1044,7 @@ "message": "Cargando tokens…" }, "localhost": { - "message": "Host local 8545" + "message": "Host local 8545" }, "lock": { "message": "Bloquear" @@ -1118,7 +1131,7 @@ "message": "Escriba su contraseña para confirmar que es usted." }, "mustSelectOne": { - "message": "Debe seleccionar al menos 1 token." + "message": "Debe seleccionar al menos 1 token." }, "myAccounts": { "message": "Mis cuentas" @@ -1168,10 +1181,10 @@ "message": "Agregar y editar redes RPC personalizadas" }, "networkURL": { - "message": "Dirección URL de la red" + "message": "Dirección URL de la red" }, "networkURLDefinition": { - "message": "La dirección URL que se utilizó para acceder a esta red." + "message": "La dirección URL que se utilizó para acceder a esta red." }, "networks": { "message": "Redes" @@ -1199,7 +1212,7 @@ "message": "Red nueva" }, "newPassword": { - "message": "Contraseña nueva (mín. de 8 caracteres)" + "message": "Contraseña nueva (mín. de 8 caracteres)" }, "newToMetaMask": { "message": "¿Es nuevo en MetaMask?" @@ -1295,6 +1308,22 @@ "message": "Su \"frase de recuperación\" ahora se llama \"frase secreta de recuperación.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "A partir de la versión 91 de Chrome, la API que habilitaba nuestro soporte para Ledger (U2F) ya no es compatible con carteras de hardware. MetaMask ha implementado un nuevo soporte para Ledger Live mediante el cual usted puede seguir conectándose a su dispositivo Ledger a través de la aplicación de escritorio Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Cuando interactúe con su cuenta de Ledger a través de MetaMask, se abrirá una nueva pestaña y se le pedirá que abra la aplicación Ledger Live. Una vez que se abra la aplicación, se le pedirá que otorgue permiso para establecer una conexión WebSocket con su cuenta de MetaMask. ¡Eso es todo!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "A fin de habilitar el soporte para Live Ledger, haga clic en Configuración > Avanzada > Utilizar Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Actualización del soporte para Ledger destinada a usuarios de Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "de" }, @@ -1380,7 +1409,7 @@ "message": "Moneda principal" }, "primaryCurrencySettingDescription": { - "message": "Seleccione Nativa para dar prioridad a mostrar los valores en la moneda nativa de la cadena (p. ej., ETH). Seleccione Fiduciaria para dar prioridad a mostrar los valores en la moneda fiduciaria seleccionada." + "message": "Seleccione Nativa para dar prioridad a mostrar los valores en la moneda nativa de la cadena (p. ej., ETH). Seleccione Fiduciaria para dar prioridad a mostrar los valores en la moneda fiduciaria seleccionada." }, "privacyMsg": { "message": "Política de privacidad" @@ -1419,12 +1448,33 @@ "recents": { "message": "Recientes" }, - "recipientAddress": { - "message": "Dirección del destinatario" - }, "recipientAddressPlaceholder": { "message": "Búsqueda, dirección pública (0x) o ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Iniciar aquí" + }, + "recoveryPhraseReminderConfirm": { + "message": "Entendido" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Guarde siempre su frase secreta de recuperación en un lugar seguro y secreto." + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "¿Necesita volver a crear una copia de seguridad de su frase secreta de recuperación?" + }, + "recoveryPhraseReminderItemOne": { + "message": "No comparta nunca su frase secreta de recuperación con nadie." + }, + "recoveryPhraseReminderItemTwo": { + "message": "El equipo de MetaMask nunca le pedirá su frase secreta de recuperación." + }, + "recoveryPhraseReminderSubText": { + "message": "Mediante su frase secreta de recuperación, se controlan todas sus cuentas." + }, + "recoveryPhraseReminderTitle": { + "message": "Proteja sus fondos." + }, "reject": { "message": "Rechazar" }, @@ -1432,7 +1482,7 @@ "message": "Rechazar todo" }, "rejectTxsDescription": { - "message": "Está a punto de rechazar $1 transacciones en lote." + "message": "Está a punto de rechazar $1 transacciones en lote." }, "rejectTxsN": { "message": "Rechazar $1 transacciones" @@ -1547,7 +1597,7 @@ "message": "Ingrese su frase secreta aquí para restaurar su bóveda." }, "securityAndPrivacy": { - "message": "Seguridad y privacidad" + "message": "Seguridad y privacidad" }, "securitySettingsDescription": { "message": "Configuración de privacidad y frase secreta de recuperación de la cartera" @@ -1595,7 +1645,7 @@ "message": "Pegar la frase secreta de recuperación desde el Portapapeles" }, "seedPhraseReq": { - "message": "Las frases secretas de recuperación contienen 12, 15, 18, 21 o 24 palabras" + "message": "Las frases secretas de recuperación contienen 12, 15, 18, 21 o 24 palabras" }, "selectAHigherGasFee": { "message": "Seleccione una cuota de gas más alta para acelerar el procesamiento de la transacción.*" @@ -1914,7 +1964,7 @@ "message": "Cuota de MetaMask" }, "swapMetaMaskFeeDescription": { - "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", + "message": "Buscamos el mejor precio en las fuentes de liquidez más importantes, todo el tiempo. Se incorpora de manera automática a esta cotización una cuota del $1 %.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, "swapNQuotes": { @@ -1937,9 +1987,18 @@ "description": "This message represents the price slippage for the swap. $1 and $4 are a number (ex: 2.89), $2 and $5 are symbols (ex: ETH), and $3 and $6 are fiat currency amounts." }, "swapPriceDifferenceTitle": { - "message": "Diferencia de precio de ~$1 %", + "message": "Diferencia de precio de ~$1 %", "description": "$1 is a number (ex: 1.23) that represents the price difference." }, + "swapPriceImpactTooltip": { + "message": "El impacto sobre el precio es la diferencia entre el precio actual del mercado y el monto recibido durante la ejecución de la transacción. El impacto sobre el precio es una función del tamaño de su transacción respecto de la dimensión del fondo de liquidez." + }, + "swapPriceUnavailableDescription": { + "message": "No se pudo determinar el impacto sobre el precio debido a la falta de datos de los precios del mercado. Antes de realizar el canje, confirme que está de acuerdo con la cantidad de tokens que está a punto de recibir." + }, + "swapPriceUnavailableTitle": { + "message": "Antes de continuar, verifique su tasa" + }, "swapProcessing": { "message": "Procesamiento" }, @@ -1950,7 +2009,7 @@ "message": "Si el precio cambia entre el momento en que hace el pedido y cuando se confirma, se denomina \"desfase\". El canje se cancelará automáticamente si el desfase supera lo establecido en la configuración \"tolerancia de desfase\"." }, "swapQuoteIncludesRate": { - "message": "La cotización incluye una cuota de MetaMask de $1 %", + "message": "La cotización incluye una cuota de MetaMask de $1 %", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, "swapQuoteNofN": { @@ -2041,6 +2100,9 @@ "message": "Canjear $1 por $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Este token se añadió de forma manual." + }, "swapTokenVerificationMessage": { "message": "Siempre confirme la dirección del token en $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2056,7 +2118,7 @@ "message": "Transacción completa" }, "swapTwoTransactions": { - "message": "2 transacciones" + "message": "2 transacciones" }, "swapUnknown": { "message": "Desconocido" @@ -2073,13 +2135,13 @@ "description": "Tells the user how much of a token they have in their balance. $1 is a decimal number amount of tokens, and $2 is a token symbol" }, "swapZeroSlippage": { - "message": "0 % de desfase" + "message": "0 % de desfase" }, "swapsAdvancedOptions": { "message": "Opciones avanzadas" }, "swapsExcessiveSlippageWarning": { - "message": "El monto del desfase es muy alto, por lo que recibirá una tasa de conversión desfavorable. Disminuya su tolerancia de desfase a un valor menor al 15 %." + "message": "El monto del desfase es muy alto, por lo que recibirá una tasa de conversión desfavorable. Disminuya su tolerancia de desfase a un valor menor al 15 %." }, "swapsMaxSlippage": { "message": "Tolerancia de desfase" @@ -2119,7 +2181,7 @@ "message": "Símbolo" }, "symbolBetweenZeroTwelve": { - "message": "El símbolo debe tener 11 caracteres o menos." + "message": "El símbolo debe tener 11 caracteres o menos." }, "syncWithMobile": { "message": "Sincronizar con dispositivo móvil" @@ -2308,7 +2370,7 @@ "message": "Las direcciones URL requieren el prefijo HTTP/HTTPS adecuado." }, "urlExistsErrorMsg": { - "message": "La dirección URL ya está en la lista de redes existentes" + "message": "En este momento, la red $1 está utilizando esta dirección URL." }, "usePhishingDetection": { "message": "Usar detección de phishing" @@ -2330,6 +2392,10 @@ "message": "Comprobar este token en $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Verifique este token en $1 y asegúrese de que sea el token con el que quiere realizar la transacción.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Ver cuenta" }, @@ -2364,7 +2430,7 @@ "message": "Frase secreta de recuperación de la cartera" }, "web3ShimUsageNotification": { - "message": "Parece que el sitio web actual intentó utilizar la API de window.web3 que se eliminó. Si el sitio no funciona, haga clic en $1 para obtener más información.", + "message": "Parece que el sitio web actual intentó utilizar la API de window.web3 que se eliminó. Si el sitio no funciona, haga clic en $1 para obtener más información.", "description": "$1 is a clickable link." }, "welcome": { diff --git a/app/_locales/et/messages.json b/app/_locales/et/messages.json index 4139c4a92..23de7d7e1 100644 --- a/app/_locales/et/messages.json +++ b/app/_locales/et/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Teie lehitsejat ei toetata..." }, - "builtInCalifornia": { - "message": "MetaMask on projekteeritud ja loodud Californias." - }, "buyWithWyre": { "message": "Ostke ETH-d Wyre'iga" }, @@ -744,9 +741,6 @@ "recents": { "message": "Hiljutised" }, - "recipientAddress": { - "message": "Saaja aadress" - }, "recipientAddressPlaceholder": { "message": "Otsing, avalik aadress (0x) või ENS" }, diff --git a/app/_locales/fa/messages.json b/app/_locales/fa/messages.json index a60b658cf..d5230d513 100644 --- a/app/_locales/fa/messages.json +++ b/app/_locales/fa/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "مرورگر شما پشتیبانی نمیشود" }, - "builtInCalifornia": { - "message": "MetaMask در کالیفورنیا طراحی و ساخته شده است." - }, "buyWithWyre": { "message": "ETH را توسط Wyre خریداری نمایید" }, @@ -754,9 +751,6 @@ "recents": { "message": "واپسین" }, - "recipientAddress": { - "message": "آدرس دریافت کننده" - }, "recipientAddressPlaceholder": { "message": "جستجو، آدرس عمومی (0x)، یا ENS" }, diff --git a/app/_locales/fi/messages.json b/app/_locales/fi/messages.json index 79b779fbb..f2a96b511 100644 --- a/app/_locales/fi/messages.json +++ b/app/_locales/fi/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Selaintasi ei tueta..." }, - "builtInCalifornia": { - "message": "MetaMask on suunniteltu ja koottu Kaliforniassa." - }, "buyWithWyre": { "message": "Osta ETH:ta Wyrella" }, @@ -751,9 +748,6 @@ "recents": { "message": "Viimeaikaiset" }, - "recipientAddress": { - "message": "Vastaanottajan osoite" - }, "recipientAddressPlaceholder": { "message": "Haku, julkinen osoite (0x) tai ENS" }, diff --git a/app/_locales/fil/messages.json b/app/_locales/fil/messages.json index be0fef29c..d7ef62fa2 100644 --- a/app/_locales/fil/messages.json +++ b/app/_locales/fil/messages.json @@ -128,9 +128,6 @@ "browserNotSupported": { "message": "Hindi sinusuportahan ang iyong Browser..." }, - "builtInCalifornia": { - "message": "Ang MetaMask ay dinisenyo at binuo sa California." - }, "buyWithWyre": { "message": "Bumili ng ETH gamit ang Wyre" }, @@ -678,9 +675,6 @@ "recents": { "message": "Kamakailan" }, - "recipientAddress": { - "message": "Address ng Recipient" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 77207d133..31358aa71 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -137,9 +137,6 @@ "browserNotSupported": { "message": "Votre navigateur internet n'est pas supporté..." }, - "builtInCalifornia": { - "message": "MetaMask est designé et developpé en Californie." - }, "buyWithWyre": { "message": "Acheter ETH avec Wyre" }, @@ -736,9 +733,6 @@ "recents": { "message": "Récents" }, - "recipientAddress": { - "message": "Adresse du destinataire" - }, "recipientAddressPlaceholder": { "message": "Recherche, adresse publique (0x) ou ENS" }, diff --git a/app/_locales/he/messages.json b/app/_locales/he/messages.json index 72123870b..795a25c53 100644 --- a/app/_locales/he/messages.json +++ b/app/_locales/he/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "הדפדפן שלך אינו נתמך..." }, - "builtInCalifornia": { - "message": "MetaMask תוכנן ונבנה בקליפורניה." - }, "buyWithWyre": { "message": "רכישת את'ר עם Wyre" }, @@ -751,9 +748,6 @@ "recents": { "message": "אחרונים" }, - "recipientAddress": { - "message": "כתובת הנמען" - }, "recipientAddressPlaceholder": { "message": "חיפוש, כתובת ציבורית (0x), או ENS" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 7d57f515b..4bb244a11 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "संपर्क जोड़ें" }, + "addCustomTokenByContractAddress": { + "message": "टोकन नहीं मिल रहा है? आप अपने पते को चिपकाकर मैन्युअल रूप से किसी भी टोकन को जोड़ सकते हैं। टोकन अनुबंध पते $1 पर मिल सकते हैं।", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "इससे इस नेटवर्क को MetaMask के अंदर उपयोग करने की अनुमति मिलेगी।" }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "आपका ब्राउज़र समर्थित नहीं है..." }, - "builContactList": { + "buildContactList": { "message": "अपनी संपर्क सूची बनाएं" }, - "builtInCalifornia": { - "message": "MetaMask को कैलिफोर्निया में डिज़ाइन और निर्मित किया गया है।" - }, "buy": { "message": "खरीदें" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "इस नेटवर्क के लिए लेन-देन पर हस्ताक्षर करने के लिए उपयोग की जाने वाली चेन ID।" }, + "chainIdExistsErrorMsg": { + "message": "यह चेन ID वर्तमान में $1 नेटवर्क द्वारा उपयोग किया जाता है।" + }, "chromeRequiredForHardwareWallets": { "message": "अपने हार्डवेयर वॉलेट से कनेक्ट करने के लिए आपको Google Chrome पर MetaMask का उपयोग करने की आवश्यकता है।" }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Wyre पर जारी रखें" }, + "contract": { + "message": "अनुबंध" + }, "contractAddressError": { "message": "आप टोकन के अनुबंध पते पर टोकन भेज रहे हैं। इसके परिणामस्वरूप इन टोकनों का नुकसान हो सकता है।" }, @@ -624,7 +631,11 @@ "message": "फ़िशिंग से सावधान रहें! MetaMask कभी भी अनायास ही आपके गुप्त रिकवरी फ्रेज़ के बारे में नहीं पूछेगा।" }, "endOfFlowMessage6": { - "message": "यदि आपको अपने गुप्त रिकवरी फ्रेज़ को फिर से बैकअप लेने की आवश्यकता है, तो आप इसे सेटिंग्स -> सुरक्षा में पा सकते हैं।" + "message": "यदि आपको अपने गुप्त रिकवरी फ्रेज़ को फिर से बैकअप लेने की आवश्यकता है, तो आप इसे सेटिंग -> सुरक्षा में पा सकते हैं।" + }, + "endOfFlowMessage7": { + "message": "यदि आपको कभी कुछ पूछना हो या कुछ गड़बड़ लगे, तो हमारी सहायता $1 से संपर्क करें।", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." }, "endOfFlowMessage8": { "message": "MetaMask आपके गुप्त रिकवरी फ्रेज़ को पुनर्प्राप्त नहीं कर सकता है।" @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "गुप्त रिकवरी फ्रेज़ के साथ एक खाता आयात करें" }, + "importAccountText": { + "message": "या $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "टोकन का आयात करें?" + }, + "importTokenWarning": { + "message": "कोई भी किसी भी नाम के साथ एक टोकन बना सकता है, जिसमें मौजूदा टोकन के नकली संस्करण शामिल हैं। अपने जोखिम पर जोड़ें और व्यापार करें!" + }, "importWallet": { "message": "वॉलेट आयात करें" }, @@ -1287,6 +1308,22 @@ "message": "आपके \"सीड फ्रेज़\" को अब आपका \"गुप्त रिकवरी फ्रेज़\" कहा जाता है।", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Chrome के संस्करण 91 से, वह API जो हमारे Ledger सपोर्ट (U2F) को सक्षम करती है वह अब हार्डवेयर वॉलेट का समर्थन नहीं करती। MetaMask ने एक नया Ledger Live सपोर्ट लागू किया है, जिसकी मदद से आप Ledger Live डेस्कटॉप ऐप के माध्यम से अपने Ledger डिवाइस से कनेक्ट करना जारी रख सकते हैं।", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "MetaMask में अपने Ledger खाते पर काम करते समय, एक नया टैब खुल जाएगा और आपको Ledger Live ऐप खोलने के लिए कहा जाएगा। ऐप खुलने के बाद, आपको अपने MetaMask खाते के लिए एक WebSocket कनेक्शन को अनुमति देने के लिए कहा जाएगा। बस इतना ही!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "आप सेटिंग > उन्नत > Ledger Live का उपयोग करें पर क्लिक करके Ledger Live सहायता को सक्षम कर सकते हैं।", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Chrome उपयोगकर्ताओं के लिए Ledger सहायता अद्यतन", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "/" }, @@ -1411,12 +1448,33 @@ "recents": { "message": "हाल ही के" }, - "recipientAddress": { - "message": "प्राप्तकर्ता का पता" - }, "recipientAddressPlaceholder": { "message": "खोज, सार्वजनिक पता (0x) या ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "यहाँ से प्रारंभ करें" + }, + "recoveryPhraseReminderConfirm": { + "message": "समझ गया" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "अपने गुप्त रिकवरी फ्रेज़ को हमेशा सुरक्षित और गुप्त स्थान पर रखें।" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "अपने गुप्त रिकवरी फ्रेज़ को फिर से बैकअप करने की आवश्यकता है?" + }, + "recoveryPhraseReminderItemOne": { + "message": "कभी भी अपना गुप्त रिकवरी फ्रेज़ किसी के साथ साझा न करें" + }, + "recoveryPhraseReminderItemTwo": { + "message": "MetaMask टीम कभी भी आपके गुप्त रिकवरी फ्रेज़ के बारे में नहीं पूछेगा" + }, + "recoveryPhraseReminderSubText": { + "message": "आपका गुप्त रिकवरी फ्रेज़ आपके सभी खातों को नियंत्रित करता है।" + }, + "recoveryPhraseReminderTitle": { + "message": "अपने धन को सुरक्षित रखें" + }, "reject": { "message": "अस्वीकार करें" }, @@ -1544,6 +1602,42 @@ "securitySettingsDescription": { "message": "गोपनीयता सेटिंग्स और वॉलेट का गुप्त रिकवरी फ्रेज़" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "लिख लें और कई गुप्त स्थानों में स्टोर करें।" + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "पासवर्ड मैनेजर में सहेजें" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "सेफ़ डिपोज़िट बॉक्स में स्टोर करें।" + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "बैंक की तिजोरी में रखें।" + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "आपका रिकवरी फ्रेज़ आपके वॉलेट और धन के लिए “मास्टर कुंजी” है।" + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "यदि कोई व्यक्ति आपका रिकवरी फ्रेज़ मांगता है, तो सबसे अधिक संभावना है कि वे आपको धोखा देने का प्रयास कर रहे हैं।" + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "कभी भी अपना रिकवरी फ्रेज़ साझा न करें, MetaMask के साथ भी नहीं!" + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "रिकवरी फ्रेज़ क्या है?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "क्या मुझे अपना रिकवरी फ्रेज़ साझा करना चाहिए?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "मैं अपना रिकवरी फ्रेज़ कैसे सहेजूं?" + }, + "seedPhraseIntroTitle": { + "message": "अपने वॉलेट को सुरक्षित करें" + }, + "seedPhraseIntroTitleCopy": { + "message": "शुरुआत करने से पहले, अपने रिकवरी फ्रेज़ और अपने वॉलेट को सुरक्षित रखने के तरीके के बारे में जानने के लिए यह छोटा-सा वीडियो देखें।" + }, "seedPhrasePlaceholder": { "message": "प्रत्येक शब्द को एक रिक्ति से अलग करें" }, @@ -2006,6 +2100,9 @@ "message": "$1 से $2 में स्वैप करें", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "इस टोकन को मैन्युअल रूप से जोड़ा गया है।" + }, "swapTokenVerificationMessage": { "message": "हमेशा $1 पर टोकन पते की पुष्टि करें।", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2273,7 +2370,7 @@ "message": "URL को उपयुक्त HTTP/HTTPS उपसर्ग की आवश्यकता होती है।" }, "urlExistsErrorMsg": { - "message": "URL नेटवर्क की मौजूदा सूची में पहले से मौजूद है" + "message": "यह URL वर्तमान में $1 नेटवर्क द्वारा उपयोग किया जाता है।" }, "usePhishingDetection": { "message": "फ़िशिंग डिटेक्शन का उपयोग करें" @@ -2295,6 +2392,10 @@ "message": "इस टोकन को $1 पर सत्यापित करें", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "इस टोकन को $1 पर सत्यापित करें और सुनिश्चित करें कि यह वही टोकन है जिससे आप व्यापार करना चाहते हैं।", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "खाता देखें" }, @@ -2328,6 +2429,10 @@ "walletSeedRestore": { "message": "वॉलेट का गुप्त रिकवरी फ्रेज़" }, + "web3ShimUsageNotification": { + "message": "हमने देखा है कि वर्तमान वेबसाइट ने हटाए गए window.web3 API का उपयोग करने की कोशिश की। यदि साइट में गड़बड़ी लगती है, तो कृपया अधिक जानकारी के लिए $1 पर क्लिक करें।", + "description": "$1 is a clickable link." + }, "welcome": { "message": "MetaMask में आपका स्वागत है" }, diff --git a/app/_locales/hn/messages.json b/app/_locales/hn/messages.json index f440c3ff9..d8aeceb8e 100644 --- a/app/_locales/hn/messages.json +++ b/app/_locales/hn/messages.json @@ -43,9 +43,6 @@ "blockiesIdenticon": { "message": "ब्लॉकीज पहचान का उपयोग करें" }, - "builtInCalifornia": { - "message": "मेटामास्क कैलिफ़ोर्निया में डिज़ाइन और बनाया गया है।" - }, "cancel": { "message": "रद्द करें" }, @@ -285,9 +282,6 @@ "readdToken": { "message": "आप अपने खाता विकल्प मेनू में .टोकन जोड़ें. पर जाकर भविष्य में इस टोकन को वापस जोड़ सकते हैं।" }, - "recipientAddress": { - "message": "प्राप्तकर्ता पता" - }, "reject": { "message": "अस्वीकार" }, diff --git a/app/_locales/hr/messages.json b/app/_locales/hr/messages.json index bf02b3939..c56b5d88b 100644 --- a/app/_locales/hr/messages.json +++ b/app/_locales/hr/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Vaš se preglednik ne podržava..." }, - "builtInCalifornia": { - "message": "MetaMask je osmišljen i izrađen u Kaliforniji." - }, "buyWithWyre": { "message": "Kupi ETH Wyerom" }, @@ -747,9 +744,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Adresa primatelja" - }, "recipientAddressPlaceholder": { "message": "Pretraži, javne adrese (0x) ili ENS" }, diff --git a/app/_locales/ht/messages.json b/app/_locales/ht/messages.json index 2f750cd7d..78cc96a2a 100644 --- a/app/_locales/ht/messages.json +++ b/app/_locales/ht/messages.json @@ -73,9 +73,6 @@ "browserNotSupported": { "message": "Navigatè ou a pa sipòte..." }, - "builtInCalifornia": { - "message": "MetaMask fèt e bati nan California." - }, "cancel": { "message": "Anile" }, @@ -450,9 +447,6 @@ "readdToken": { "message": "Ou ka ajoute token sa aprè sa ankò ou prale nan \"Ajoute token\" nan opsyon meni kont ou an." }, - "recipientAddress": { - "message": "Adrès pou resevwa" - }, "reject": { "message": "Rejte" }, diff --git a/app/_locales/hu/messages.json b/app/_locales/hu/messages.json index cb397cd69..2293d2956 100644 --- a/app/_locales/hu/messages.json +++ b/app/_locales/hu/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Az ön böngészője nem támogatott..." }, - "builtInCalifornia": { - "message": "A MetaMaskot Kaliforniában tervezték és hozták létre." - }, "buyWithWyre": { "message": "Vásároljon ETH-t a Wyre-rel" }, @@ -747,9 +744,6 @@ "recents": { "message": "Legutóbbiak" }, - "recipientAddress": { - "message": "Címzett címe" - }, "recipientAddressPlaceholder": { "message": "Keresés, nyilvános cím (0x) vagy ENS" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index b019c25b5..d5e42a1b0 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "Tambah kontak" }, + "addCustomTokenByContractAddress": { + "message": "Tidak dapat menemukan token? Anda dapat menambahkan token secara manual dengan menempelkan alamatnya. Alamat kontrak token dapat ditemukan di $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Ini akan memungkinkan jaringan ini digunakan dengan MetaMask." }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "Browser Anda tidak didukung..." }, - "builContactList": { + "buildContactList": { "message": "Buat daftar kontak Anda" }, - "builtInCalifornia": { - "message": "MetaMask didesain dan didirikan di California." - }, "buy": { "message": "Beli" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "ID rantai digunakan untuk menandatangani transaksi untuk jaringan ini." }, + "chainIdExistsErrorMsg": { + "message": "ID Rantai ini saat ini digunakan oleh jaringan $1." + }, "chromeRequiredForHardwareWallets": { "message": "Anda perlu menggunakan MetaMask di Google Chrome untuk terhubung ke Dompet Perangkat Keras Anda." }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Lanjutkan ke Wyre" }, + "contract": { + "message": "Kontrak" + }, "contractAddressError": { "message": "Anda mengirim token ke alamat kontrak token. Ini dapat mengakibatkan token ini hilang." }, @@ -626,6 +633,10 @@ "endOfFlowMessage6": { "message": "Jika Anda perlu mencadangkan Frasa Pemulihan Rahasia lagi, Anda dapat menemukannya di Pengaturan -> Keamanan." }, + "endOfFlowMessage7": { + "message": "Jika Anda memiliki pertanyaan atau melihat sesuatu yang mencurigakan, hubungi dukungan $1 kami.", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." + }, "endOfFlowMessage8": { "message": "MetaMask tidak dapat memulihkan Frasa Pemulihan Rahasia Anda." }, @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "Impor akun dengan Frasa Pemulihan Rahasia" }, + "importAccountText": { + "message": "atau $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "Impor token?" + }, + "importTokenWarning": { + "message": "Siapa pun dapat membuat token dengan nama apa pun, termasuk versi palsu dari token yang ada. Tambahkan dan perdagangkan dengan risiko Anda sendiri!" + }, "importWallet": { "message": "Impor dompet" }, @@ -1287,6 +1308,22 @@ "message": "\"Frasa Pemulihan\" Anda kini disebut \"Frasa Pemulihan Rahasia.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Pada Chrome versi 91, API yang memungkinkan dukungan Ledger (U2F) kami tidak lagi mendukung dompet perangkat keras. MetaMask telah menerapkan dukungan Ledger Live baru yang memungkinkan Anda terus terhubung ke perangkat Ledger Anda melalui aplikasi desktop Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Saat berinteraksi dengan akun Ledger Anda di MetaMask, tab baru akan terbuka dan Anda akan diminta untuk membuka aplikasi Ledger Live. Setelah aplikasi tersebut terbuka, Anda akan diminta untuk mengizinkan koneksi WebSocket ke akun MetaMask Anda. Tidak diperlukan tindakan lebih lanjut.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Anda dapat mengaktifkan dukungan Ledger Live dengan mengklik Pengaturan > Lanjutan > Gunakan Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Pembaruan Dukungan Ledger untuk Pengguna Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "dari" }, @@ -1411,12 +1448,33 @@ "recents": { "message": "Terkini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat publik (0x), atau ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Mulai di sini" + }, + "recoveryPhraseReminderConfirm": { + "message": "Mengerti" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Jaga selalu Frasa Pemulihan Rahasia Anda di tempat yang aman dan rahasia" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Perlu mencadangkan Frasa Pemulihan Rahasia Anda lagi?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Jangan membagikan Frasa Pemulihan Rahasia Anda kepada siapa pun" + }, + "recoveryPhraseReminderItemTwo": { + "message": "Tim MetaMask tidak akan pernah meminta Frasa Pemulihan Rahasia Anda" + }, + "recoveryPhraseReminderSubText": { + "message": "Frasa Pemulihan Rahasia Anda mengendalikan semua akun Anda." + }, + "recoveryPhraseReminderTitle": { + "message": "Lindungi dana Anda" + }, "reject": { "message": "Tolak" }, @@ -1442,7 +1500,7 @@ "message": "Hapus akun" }, "removeAccountDescription": { - "message": "Akun ini akan dihapus dari dompet Anda. Pastikan Anda memiliki Frasa Pemulihan Rahasia asli atau kunci privat untuk akun impor ini sebelum melanjutkan. Anda dapat mengimpor atau membuat akun lagi dari drop down akun. " + "message": "Akun ini akan dihapus dari dompet Anda. Pastikan Anda memiliki Frasa Pemulihan Rahasia asli atau kunci privat untuk akun impor ini sebelum melanjutkan. Anda dapat mengimpor atau membuat akun lagi dari akun drop down. " }, "requestsAwaitingAcknowledgement": { "message": "permintaan menunggu untuk diakui" @@ -1544,6 +1602,42 @@ "securitySettingsDescription": { "message": "Pengaturan privasi dan Frasa Pemulihan Rahasia dompet" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "Tuliskan dan simpan di beberapa tempat rahasia." + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "Simpan dalam pengelola kata sandi" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "Simpan di kotak deposit yang aman." + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "Simpan di vault bank." + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "Frasa pemulihan Anda adalah “kunci induk” ke dompet dan dana Anda." + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "Jika seseorang menanyakan frasa pemulihan Anda, kemungkinan mereka akan mencoba menipu Anda." + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "Jangan pernah membagikan frasa pemulihan Anda bahkan kepada MetaMask!" + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "Apa itu frasa pemulihan?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "Haruskah saya membagikan frasa pemulihan saya?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "Bagaimana cara menyimpan frasa pemulihan saya?" + }, + "seedPhraseIntroTitle": { + "message": "Amankan dompet Anda" + }, + "seedPhraseIntroTitleCopy": { + "message": "Sebelum memulai, lihat video singkat ini untuk mempelajari tentang frasa pemulihan Anda dan cara menjaga keamanan dompet Anda." + }, "seedPhrasePlaceholder": { "message": "Pisahkan setiap kata dengan satu spasi" }, @@ -2006,6 +2100,9 @@ "message": "Tukar $1 untuk $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Token ini telah ditambahkan secara manual." + }, "swapTokenVerificationMessage": { "message": "Selalu konfirmasikan alamat token di $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2273,7 +2370,7 @@ "message": "URL memerlukan awalan HTTP/HTTPS yang sesuai." }, "urlExistsErrorMsg": { - "message": "URL sudah ada dalam daftar jaringan yang ada" + "message": "URL ini saat ini digunakan oleh jaringan $1." }, "usePhishingDetection": { "message": "Menggunakan Deteksi Phishing" @@ -2295,6 +2392,10 @@ "message": "Verifikasikan token ini di $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Verifikasi token ini di $1 dan pastikan ini adalah token yang ingin Anda perdagangkan.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Lihat Akun" }, @@ -2328,6 +2429,10 @@ "walletSeedRestore": { "message": "Frasa Pemulihan Rahasia Dompet" }, + "web3ShimUsageNotification": { + "message": "Kami melihat situs web saat ini mencoba menggunakan API window.web3 yang dihapus. Jika situs tersebut tampak bermasalah, silakan klik $1 untuk informasi selengkapnya.", + "description": "$1 is a clickable link." + }, "welcome": { "message": "Selamat datang di MetaMask" }, diff --git a/app/_locales/it/messages.json b/app/_locales/it/messages.json index 161086c29..f09f50b96 100644 --- a/app/_locales/it/messages.json +++ b/app/_locales/it/messages.json @@ -217,9 +217,6 @@ "browserNotSupported": { "message": "Il tuo Browser non è supportato..." }, - "builtInCalifornia": { - "message": "MetaMask è progettato e realizzato in California." - }, "buy": { "message": "Compra" }, @@ -1201,9 +1198,6 @@ "recents": { "message": "Recenti" }, - "recipientAddress": { - "message": "Indirizzo Destinatario" - }, "recipientAddressPlaceholder": { "message": "Ricerca, indirizzo pubblico (0x) o ENS" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 7ac44b231..bd73411ca 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "連絡先の追加" }, + "addCustomTokenByContractAddress": { + "message": "トークンを発見できませんか?アドレスをペーストすることで手動でトークンを追加することができます。トークン コントラクト アドレスは $1 にあります。", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "これにより、このネットワークは MetaMask 内で使用できるようになります。" }, @@ -109,7 +113,7 @@ "message": "アグリゲーター ネットワーク料金" }, "alertDisableTooltip": { - "message": "\"設定 > 警告\" の設定で変更できます" + "message": "これは、[\"設定 > 警告\"] で変更できます" }, "alertSettingsUnconnectedAccount": { "message": "選択した未接続のアカウントを使用して Web サイトをブラウズしています" @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "ご使用のブラウザーはサポートされていません..." }, - "builContactList": { + "buildContactList": { "message": "連絡先リストを作成する" }, - "builtInCalifornia": { - "message": "MetaMask はカリフォルニアで設計および作成されました。" - }, "buy": { "message": "購入" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "このネットワークのトランザクションの署名に使用されるチェーン ID。" }, + "chainIdExistsErrorMsg": { + "message": "このチェーン ID は現在 $1 ネットワークで使用しています。" + }, "chromeRequiredForHardwareWallets": { "message": "ハードウェア ウォレットに接続するには、MetaMask on Google Chrome を使用する必要があります。" }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Wyre に進む" }, + "contract": { + "message": "コントラクト" + }, "contractAddressError": { "message": "トークンのコントラクト アドレスにトークンを送信しています。これにより、これらのトークンが失われる可能性があります。" }, @@ -624,7 +631,11 @@ "message": "フィッシングにご注意ください!MetaMask の動作として、シークレット リカバリー フレーズを要求することは絶対にありません。" }, "endOfFlowMessage6": { - "message": "シークレット リカバリー フレーズを再度バックアップする場合は、[設定] -> [セキュリティとプライバシー] にアクセスしてください。" + "message": "シークレット リカバリー フレーズを再度バックアップする場合は、[設定] -> [セキュリティ] でそれを見つけることができます。" + }, + "endOfFlowMessage7": { + "message": "ご質問、または不審な点がある場合は、当社のサポート $1 までお問い合わせください。", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." }, "endOfFlowMessage8": { "message": "MetaMask はシークレット リカバリー フレーズを復元できません。" @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "シークレット リカバリー フレーズを使用してアカウントをインポートする:" }, + "importAccountText": { + "message": "または $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "トークンをインポートしますか?" + }, + "importTokenWarning": { + "message": "誰でも既存のトークンの偽バージョンを含めて、任意の名前でトークンを作成することができます。自己責任で追加およびトレードしてください。" + }, "importWallet": { "message": "ウォレットのインポート" }, @@ -1287,6 +1308,22 @@ "message": "これで、\"シード フレーズ\" は \"シークレット リカバリー フレーズ\" と呼ばれます。", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Chrome バージョン 91 以降は、レジャーのサポート (U2F) を可能にした API がハードウェア ウォレットをサポートしなくなります。MetaMask では、ユーザーがレジャー ライブのデスクトップ アプリを介して、レジャー デバイスに継続的に接続することができる新しいレジャー ライブのサポートを導入しました。", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "MetaMask のレジャーのアカウントを使用する際は、新しいタブが開き、レジャー ライブのアプリを開くよう指示されます。アプリが開いたら、WebSocket 接続を MetaMask のアカウントに許可するよう指示されます。以上です。", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "[設定] > [詳細] > [レジャー ライブを使用] の順にクリックすることで、レジャー ライブのサポートを有効にすることができます。", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Chrome ユーザー向けのレジャーのサポートの更新", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "中の" }, @@ -1411,12 +1448,33 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "受信者のアドレス" - }, "recipientAddressPlaceholder": { "message": "検索、パブリック アドレス (0x)、または ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "ここから開始" + }, + "recoveryPhraseReminderConfirm": { + "message": "OK" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "シークレット リカバリー フレーズは常に安全かつ秘密の場所に保管してください" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "シークレット リカバリー フレーズのバックアップが必要ですか?" + }, + "recoveryPhraseReminderItemOne": { + "message": "シークレット リカバリー フレーズは誰とも決して共有しないでください" + }, + "recoveryPhraseReminderItemTwo": { + "message": "MetaMask チームが、ユーザーのシークレット リカバリー フレーズを確認することは絶対にありません" + }, + "recoveryPhraseReminderSubText": { + "message": "シークレット リカバリー フレーズは、ご利用のすべてのアカウントを制御します。" + }, + "recoveryPhraseReminderTitle": { + "message": "資産を保護してください" + }, "reject": { "message": "拒否" }, @@ -1544,6 +1602,42 @@ "securitySettingsDescription": { "message": "プライバシーの設定とシークレット リカバリー フレーズ" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "書き留めて、複数の秘密の場所に保存します。" + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "パスワード マネージャーに保存する" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "セーフティ ボックスに保管する。" + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "銀行の金庫に保管する。" + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "あなたのリカバリー フレーズは、ウォレットと資金への「マスターキー」です。" + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "誰かがあなたのリカバリー フレーズを尋ねてきたら、おそらくあなたを騙そうとしているのです。" + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "MetaMask を共有しても、リカバリ フレーズは決して共有しないでください。" + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "リカバリー フレーズとは何ですか?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "リカバリーフレーズは共有すべきですか?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "リカバリー フレーズはどのように保存すべきですか?" + }, + "seedPhraseIntroTitle": { + "message": "ウォレットの保護" + }, + "seedPhraseIntroTitleCopy": { + "message": "始める前に、この短いビデオを見て、リカバリー フレーズとウォレットを安全に保つ方法について確認してください。" + }, "seedPhrasePlaceholder": { "message": "単語ごとにスペースを 1 つ置いて分離します" }, @@ -1896,6 +1990,15 @@ "message": "約 $1% の価格差", "description": "$1 is a number (ex: 1.23) that represents the price difference." }, + "swapPriceImpactTooltip": { + "message": "プライスインパクトとは、現在の市場価格と取引の約定時に受け取った金額の差のことです。プライスインパクトとは、流動性プールの大きさに対するあなたのトレードの大きさを表わす関数です。" + }, + "swapPriceUnavailableDescription": { + "message": "市場価格のデータが不足しているため、プライスインパクトを測定できませんでした。スワップする前に、これから受領するトークンの額に問題がないか確認してください。" + }, + "swapPriceUnavailableTitle": { + "message": "続行する前にレートを確認してください" + }, "swapProcessing": { "message": "処理中" }, @@ -1997,6 +2100,9 @@ "message": "$1 を $2 にスワップ", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "このトークンは手動で追加されました。" + }, "swapTokenVerificationMessage": { "message": "常に $1 のトークン アドレスを確認してください。", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2264,7 +2370,7 @@ "message": "URL には適切な HTTP/HTTPS プレフィックスが必要です。" }, "urlExistsErrorMsg": { - "message": "URL はネットワークの既存のリストに既に存在します" + "message": "この URL は現在 $1 ネットワークで使用しています。" }, "usePhishingDetection": { "message": "フィッシング検出を使用" @@ -2286,6 +2392,10 @@ "message": "このトークンを $1 で検証", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "このトークンを $1 で検証して、それがトレードしたいトークンであることを確認してください。", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "アカウントを表示" }, diff --git a/app/_locales/kn/messages.json b/app/_locales/kn/messages.json index cbe33d70e..9f3b17c66 100644 --- a/app/_locales/kn/messages.json +++ b/app/_locales/kn/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "ನಿಮ್ಮ ಬ್ರೌಸರ್ ಬೆಂಬಲಿಸುತ್ತಿಲ್ಲ..." }, - "builtInCalifornia": { - "message": "MetaMask ಅನ್ನು ವಿನ್ಯಾಸಗೊಳಿಸಲಾಗಿದೆ ಮತ್ತು ಕ್ಯಾಲಿಫೋರ್ನಿಯಾದಲ್ಲಿ ನಿರ್ಮಿಸಲಾಗಿದೆ." - }, "buyWithWyre": { "message": "Wyre ನೊಂದಿಗೆ ETH ಖರೀದಿಸಿ" }, @@ -754,9 +751,6 @@ "recents": { "message": "ಇತ್ತೀಚಿನವುಗಳು" }, - "recipientAddress": { - "message": "ಸ್ವೀಕರಿಸುವವರ ವಿಳಾಸ" - }, "recipientAddressPlaceholder": { "message": "ಸಾರ್ವಜನಿಕ ವಿಳಾಸ (0x) ಅಥವಾ ENS ಹುಡುಕಿ" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index e81d77b82..0cd69a0f5 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -6,7 +6,7 @@ "message": "버전, 지원 센터, 연락처" }, "acceleratingATransaction": { - "message": "* 높은 Gas 가격을 이용한 거래 가속화는 네트워크를 통한 처리 속도 개선 확률을 높이지만, 항상 그렇지는 않습니다." + "message": "* 높은 Gas 가격을 이용해 거래를 가속화하면 네트워크를 통한 처리 속도가 개선되지만 항상 그렇지는 않습니다." }, "acceptTermsOfUse": { "message": "$1을(를) 읽고 이에 동의합니다.", @@ -52,22 +52,26 @@ "addContact": { "message": "연락처 추가" }, + "addCustomTokenByContractAddress": { + "message": "이 토큰을 찾을 수 없으신가요? 토큰 주소를 붙여넣으면 토큰을 직접 추가할 수 있습니다. 토큰의 계약 주소는 $1에서 찾을 수 있습니다.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { - "message": "이는 이 네트워크가 MetaMask 내에서 사용될 수 있도록 합니다." + "message": "이렇게 하면 이 네트워크가 MetaMask 내에서 사용됩니다." }, "addEthereumChainConfirmationRisks": { "message": "MetaMask는 맞춤형 네트워크를 검증하지 않습니다." }, "addEthereumChainConfirmationRisksLearnMore": { - "message": "$1에 대해 자세히 알아보십시오.", + "message": "$1에 대해 자세히 알아보세요.", "description": "$1 is a link with text that is provided by the 'addEthereumChainConfirmationRisksLearnMoreLink' key" }, "addEthereumChainConfirmationRisksLearnMoreLink": { - "message": "사기와 보안 위험에 대해 자세히 알아보기", + "message": "사기와 네트워크 보안 위험", "description": "Link text for the 'addEthereumChainConfirmationRisksLearnMore' translation key" }, "addEthereumChainConfirmationTitle": { - "message": "이 사이트에서 네트워크를 추가하도록 허용하시겠습니까?" + "message": "이 사이트에서 네트워크를 추가하도록 허용하시겠어요?" }, "addFriendsAndAddresses": { "message": "신뢰하는 친구와 주소 추가하기" @@ -106,7 +110,7 @@ "message": "동의함" }, "aggregatorFeeCost": { - "message": "애그리게이터 네트워크 요금" + "message": "애그리게이터 네트워크 수수료" }, "alertDisableTooltip": { "message": "\"설정 > 경고\"에서 변경할 수 있습니다." @@ -118,10 +122,10 @@ "message": "이 경고는 연결된 web3 사이트를 탐색하고 있지만 현재 선택된 계정이 연결되지 않은 경우 팝업에 표시됩니다." }, "alertSettingsWeb3ShimUsage": { - "message": "웹사이트가 제거된 window.web3 API를 이용하려 할 때" + "message": "웹사이트가 제거된 window.web3 API를 이용하는 경우" }, "alertSettingsWeb3ShimUsageDescription": { - "message": "이 경고는 제거된 window.web3 API를 이용하는 사이트를 탐색할 때 팝업에 표시되며 손상이 발생할 수 있습니다." + "message": "이 경고는 제거된 window.web3 API를 이용하려고 시도하여 결과적으로 작동하지 않을 수 있는 사이트를 탐색할 때 팝업으로 표시됩니다." }, "alerts": { "message": "경고" @@ -133,7 +137,7 @@ "message": "이 외부 확장을 통해 다음을 하도록 허용:" }, "allowOriginSpendToken": { - "message": "$1에서 $2을(를) 사용하도록 허용하시겠습니까?", + "message": "$1에서 $2을(를) 지출하도록 허용하시겠어요?", "description": "$1 is the url of the site and $2 is the symbol of the token they are requesting to spend" }, "allowThisSiteTo": { @@ -158,7 +162,7 @@ "description": "The name of the application" }, "approvalAndAggregatorTxFeeCost": { - "message": "승인 및 애그리게이터 네트워크 요금" + "message": "승인 및 애그리게이터 네트워크 수수료" }, "approvalTxGasCost": { "message": "승인 Tx Gas 비용" @@ -186,13 +190,13 @@ "message": "자산" }, "attemptToCancel": { - "message": "취소하시겠습니까?" + "message": "취소하시겠어요?" }, "attemptToCancelDescription": { "message": "이 시도를 제출한다고 해서 원래 거래가 반드시 취소되지는 않습니다. 취소 시도가 성공하면 위의 거래 수수료가 부과됩니다." }, "attemptingConnect": { - "message": "블록체인 연결 시도 중입니다." + "message": "블록체인에 연결 중입니다." }, "attributions": { "message": "속성" @@ -216,10 +220,10 @@ "message": "전체 목록으로 돌아가기" }, "backupApprovalInfo": { - "message": "이 비밀 코드는 장치를 분실하여 지갑을 복구하거나, 암호를 잊거나, MetaMask를 다시 설치해야 하거나, 다른 장치에서 지갑에 액세스해야 할 때 필요합니다." + "message": "이 비밀 코드는 장치를 분실하여 지갑을 복구해야 하거나, 암호를 잊었거나, MetaMask를 다시 설치해야 하거나, 다른 장치에서 지갑에 액세스해야 할 때 필요합니다." }, "backupApprovalNotice": { - "message": "비밀 복구 코드를 백업하여 지갑과 자금을 안전하게 보호하십시오." + "message": "시드 코드를 백업하여 지갑과 자금을 안전하게 보호하세요." }, "backupNow": { "message": "지금 백업" @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "지원되지 않는 브라우저입니다..." }, - "builContactList": { + "buildContactList": { "message": "연락처 목록 작성하기" }, - "builtInCalifornia": { - "message": "MetaMask는 캘리포니아에서 설계 및 제작됩니다." - }, "buy": { "message": "구매" }, @@ -285,11 +286,14 @@ "chainIdDefinition": { "message": "이 네트워크의 거래에 서명하는 데 사용되는 체인 ID입니다." }, + "chainIdExistsErrorMsg": { + "message": "이 체인 ID는 현재 $1 네트워크에서 사용됩니다." + }, "chromeRequiredForHardwareWallets": { "message": "하드웨어 지갑에 연결하려면 Google Chrome에서 MetaMask를 사용해야 합니다." }, "clickToRevealSeed": { - "message": "암호를 표시하려면 여기를 클릭하세요" + "message": "비밀 단어를 표시하려면 여기를 클릭하세요." }, "close": { "message": "닫기" @@ -307,13 +311,13 @@ "message": "확인됨" }, "confusableUnicode": { - "message": "'$1'은 '$2'와 유사합니다." + "message": "'$1'은(는) '$2'와(과) 유사합니다." }, "confusableZeroWidthUnicode": { "message": "폭이 0인 문자를 발견했습니다." }, "confusingEnsDomain": { - "message": "ENS 이름에서 혼동하기 쉬운 문자를 발견했습니다. 잠재적 사기를 막기 위해 ENS 이름을 확인하십시오." + "message": "ENS 이름에서 혼동하기 쉬운 문자를 발견했습니다. 잠재적 사기를 막기 위해 ENS 이름을 확인하세요." }, "congratulations": { "message": "축하합니다." @@ -347,7 +351,7 @@ "description": "$1 will be replaced by the translation of connectToMultipleNumberOfAccounts" }, "connectToMultipleNumberOfAccounts": { - "message": "$1 계정", + "message": "$1개 계정", "description": "$1 is the number of accounts to which the web3 site/application is asking to connect; this will substitute $1 in connectToMultiple" }, "connectWithMetaMask": { @@ -367,7 +371,7 @@ "message": "연결된 사이트" }, "connectedSitesDescription": { - "message": "$1이(가) 이 사이트에 연결되어 있습니다. 귀하의 계정 주소를 볼 수 있습니다.", + "message": "$1이(가) 이 사이트에 연결되어 귀하의 계정 주소를 볼 수 있습니다.", "description": "$1 is the account name" }, "connectedSitesEmptyDescription": { @@ -396,7 +400,7 @@ "message": "Ropsten 테스트 네트워크에 연결 중" }, "contactUs": { - "message": "당사에 문의하세요" + "message": "문의하기" }, "contacts": { "message": "연락처" @@ -410,8 +414,11 @@ "continueToWyre": { "message": "Wyre로 넘어가기" }, + "contract": { + "message": "계약" + }, "contractAddressError": { - "message": "토큰의 계약 주소로 토큰을 보냅니다. 토큰이 손실될 수 있습니다." + "message": "토큰의 계약 주소로 토큰을 보냅니다. 이로 인해 토큰이 손실될 수 있습니다." }, "contractDeployment": { "message": "계약 배포" @@ -471,7 +478,7 @@ "message": "Gas 맞춤화" }, "customGasSubTitle": { - "message": "요금을 올리면 처리 시간이 줄어들 수 있지만 반드시 그렇지는 않습니다." + "message": "수수료를 올리면 처리 시간이 단축되기도 하지만 항상 그렇지는 않습니다." }, "customRPC": { "message": "맞춤형 RPC" @@ -483,7 +490,7 @@ "message": "맞춤형 토큰" }, "dataBackupFoundInfo": { - "message": "계정 데이터 일부가 MetaMask 이전 설치 도중에 백업되었습니다. 여기에는 설정, 연락처, 토큰이 포함될 수 있습니다. 지금 이 데이터를 복구하시겠습니까?" + "message": "일부 계정 데이터가 이전의 MetaMask 설치 도중에 백업되었습니다. 여기에는 설정, 연락처, 토큰이 포함될 수 있습니다. 지금 이 데이터를 복구하시겠어요?" }, "decimal": { "message": "토큰 십진수" @@ -512,7 +519,7 @@ "message": "요청 암호 해독" }, "defaultNetwork": { - "message": "Ether 거래의 기존 네트워크는 메인 넷입니다." + "message": "Ether 거래의 기본 네트워크는 메인 넷입니다." }, "delete": { "message": "삭제" @@ -521,10 +528,10 @@ "message": "계정 삭제" }, "deleteNetwork": { - "message": "네트워크를 삭제합니까?" + "message": "네트워크를 삭제하시겠어요?" }, "deleteNetworkDescription": { - "message": "이 네트워크를 삭제하시겠습니까?" + "message": "이 네트워크를 삭제하시겠어요?" }, "depositEther": { "message": "Ether 예치" @@ -545,7 +552,7 @@ "message": "모든 계정 연결 해제" }, "disconnectAllAccountsConfirmationDescription": { - "message": "이 네트워크의 연결을 해제하시겠습니까? 사이트 기능을 이용하지 못하게 될 수도 있습니다." + "message": "연결을 해제하시겠어요? 사이트 기능을 이용하지 못하게 될 수도 있습니다." }, "disconnectPrompt": { "message": "$1 연결 해제" @@ -557,10 +564,10 @@ "message": "해지" }, "dismissReminderDescriptionField": { - "message": "이것을 켜서 복구 구문 백업 알림 메시지를 해지하십시오. 지갑을 복원할 수 있도록 비밀 복구 구문을 저장할 것을 강력하게 권장합니다." + "message": "이 기능을 켜면 복구 구문 백업 알림 메시지를 해지할 수 있습니다. 단, 자금 손실을 방지하려면 비밀 복구 구문을 백업하는 것이 좋습니다." }, "dismissReminderField": { - "message": "복구 구문 백업 알림을 해지하십시오." + "message": "복구 구문 백업 알림 해지" }, "domain": { "message": "도메인" @@ -569,7 +576,7 @@ "message": "완료" }, "dontShowThisAgain": { - "message": "이 메시지를 다시 표시하지 않음" + "message": "다시 표시 안 함" }, "downloadGoogleChrome": { "message": "Google Chrome 다운로드" @@ -593,20 +600,20 @@ "message": "임시값 편집" }, "editNonceMessage": { - "message": "이것은 고급 기능으로, 주의해서 사용해야 합니다." + "message": "이는 고급 기능으로, 주의해서 사용해야 합니다." }, "editPermission": { "message": "권한 편집" }, "encryptionPublicKeyNotice": { - "message": "$1에서 귀하의 공개 암호화 키를 요구합니다. 동의하면 이 사이트에서 암호화된 메시지를 작성하여 귀하에게 전송할 수 있습니다.", + "message": "$1에서 귀하의 공개 암호화 키를 요구합니다. 동의를 받으면 이 사이트에서 암호화된 메시지를 작성하여 귀하에게 전송할 수 있습니다.", "description": "$1 is the web3 site name" }, "encryptionPublicKeyRequest": { "message": "암호화 공개 키 요구" }, "endOfFlowMessage1": { - "message": "테스트를 통과하셨습니다. 비밀 복구 구문을 안전하게 보관할 책임은 귀하에게 있습니다!" + "message": "테스트를 통과하셨습니다. 비밀 복구 구문을 안전하게 보관할 책임은 본인에게 있습니다." }, "endOfFlowMessage10": { "message": "모두 완료" @@ -621,10 +628,14 @@ "message": "구문을 누구와도 공유하지 마세요." }, "endOfFlowMessage5": { - "message": "피싱을 조심하십시오! MetaMask에서는 절대로 시드 구문을 갑자기 물어보지 않습니다." + "message": "피싱에 유의하세요. MetaMask에서는 절대로 비밀 복구 구문을 갑자기 물어보지 않습니다." }, "endOfFlowMessage6": { - "message": "비밀 복구 구문을 다시 백업해야 한다면 설정 -> 보안에서 시드 구문을 찾을 수 있습니다." + "message": "비밀 복구 구문을 다시 백업해야 한다면 설정 -> 보안에서 해당 구문을 찾을 수 있습니다." + }, + "endOfFlowMessage7": { + "message": "질문이 있거나 의심스러운 행위를 목격했다면 지원을 요청하세요($1).", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." }, "endOfFlowMessage8": { "message": "MetaMask에서는 계정 시드 구문을 복구할 수 없습니다." @@ -690,7 +701,7 @@ "message": "예상 처리 시간" }, "ethGasPriceFetchWarning": { - "message": "현재 주요 가스 견적 서비스를 사용할 수 없으므로 백업 가스 가격을 제공합니다." + "message": "현재 주요 Gas 견적 서비스를 사용할 수 없으므로 백업 Gas 가격을 제공합니다." }, "eth_accounts": { "message": "허용되는 계정의 주소 보기(필수)", @@ -715,7 +726,7 @@ "message": "외부 확장" }, "extraApprovalGas": { - "message": "+$1 승인 Gas", + "message": "+$1의 승인 Gas", "description": "Expresses an additional gas amount the user will have to pay, on top of some other displayed amount. $1 is a decimal amount of gas" }, "failed": { @@ -734,14 +745,14 @@ "message": "가장 빠름" }, "feeAssociatedRequest": { - "message": "요금이 이 권한과 연결되어 있습니다." + "message": "수수료가 이 요청과 연결되어 있습니다." }, "fiat": { "message": "명목", "description": "Exchange type" }, "fileImportFail": { - "message": "파일 가져오기가 작동하지 않나요? 여기를 클릭하세요!", + "message": "파일 가져오기가 작동하지 않나요? 여기를 클릭하세요.", "description": "Helps user import their account from a JSON file" }, "forbiddenIpfsGateway": { @@ -780,7 +791,7 @@ "message": "Gas 가격(GWEI)" }, "gasPriceExcessive": { - "message": "Gas 수수료가 불필요하게 높게 설정되었습니다. 수량을 낮추는 것을 고려해 보십시오." + "message": "Gas 수수료가 불필요하게 높게 설정되었습니다. 수량을 낮추는 것을 고려해 보세요." }, "gasPriceExcessiveInput": { "message": "Gas 가격이 초과하였습니다." @@ -798,11 +809,11 @@ "message": "사용한 Gas" }, "gdprMessage": { - "message": "이 데이터는 집계되므로 개인정보보호 규정(EU) 2016/679의 목적에 따라 익명으로 관리됩니다. 당사 개인정보 보호 관행과 관련된 자세한 내용은 $1을(를) 참조하세요.", + "message": "이 데이터는 집계되며 일반 데이터 보호 규칙(EU) 2016/679의 목적에 따라 익명으로 관리됩니다. 당사의 개인정보보호 관행에 관한 자세한 내용은 $1을(를) 참조하세요.", "description": "$1 refers to the gdprMessagePrivacyPolicy message, the translation of which is meant to be used exclusively in the context of gdprMessage" }, "gdprMessagePrivacyPolicy": { - "message": "개인정보 보호정책", + "message": "개인정보보호정책", "description": "this translation is intended to be exclusively used as the replacement for the $1 in the gdprMessage translation" }, "general": { @@ -834,17 +845,17 @@ "message": "하드웨어 지갑 연결됨" }, "hardwareWalletLegacyDescription": { - "message": "(래거시)", + "message": "(레거시)", "description": "Text representing the MEW path" }, "hardwareWalletSupportLinkConversion": { - "message": "여기를 클릭하세요." + "message": "여기를 클릭" }, "hardwareWallets": { "message": "하드웨어 지갑 연결" }, "hardwareWalletsMsg": { - "message": "MetaMask와 함께 사용할 하드웨어 지갑을 선택하십시오." + "message": "MetaMask와 함께 사용할 하드웨어 지갑을 선택하세요." }, "here": { "message": "여기", @@ -857,7 +868,7 @@ "message": "숨기기" }, "hideTokenPrompt": { - "message": "토큰을 숨기시겠습니까?" + "message": "토큰을 숨기시겠어요?" }, "hideTokenSymbol": { "message": "$1 숨기기", @@ -877,34 +888,40 @@ "message": "계정 가져오기" }, "importAccountLinkText": { - "message": "계정 시드 구문으로 가져오기" + "message": "비밀 복구 구문을 사용해 가져오기" }, "importAccountMsg": { - "message": " 가져온 계정은 생성한 MetaMask 계정 시드 구문 원본에 연결되지 않습니다. 가져온 계정에 대해 자세히 알아보십시오. " + "message": " 가져온 계정은 생성한 MetaMask 계정 비밀 복구 구문 원본에 연결되지 않습니다. 가져온 계정에 대해 자세히 알아보기 " }, "importAccountSeedPhrase": { - "message": "시드 구문으로 계정 가져오기" + "message": "비밀 복구 구문으로 계정 가져오기" }, "importAccountText": { "message": "또는 $1", "description": "$1 represents the text from `importAccountLinkText` as a link" }, + "importTokenQuestion": { + "message": "토큰을 가져오시겠어요?" + }, + "importTokenWarning": { + "message": "기존 토큰의 가짜 버전을 포함하여 누구나 어떤 이름으로든 토큰을 만들 수 있습니다. 추가 및 거래는 사용자의 책임입니다." + }, "importWallet": { "message": "지갑 가져오기" }, "importYourExisting": { - "message": "12단어 시드 구문을 사용하여 지갑 가져오기" + "message": "비밀 복구 구문을 사용하여 기존 지갑 가져오기" }, "imported": { "message": "가져옴", "description": "status showing that an account has been fully loaded into the keyring" }, "infuraBlockedNotification": { - "message": "MetaMask이 블록체인 호스트에 연결할 수 없습니다. 가능성 있는 원인 $1을 검토하십시오.", + "message": "MetaMask이 블록체인 호스트에 연결할 수 없습니다. 가능성 있는 원인 $1을(를) 검토하세요.", "description": "$1 is a clickable link with with text defined by the 'here' key" }, "initialTransactionConfirmed": { - "message": "최초 거래를 네트워크에서 확인했습니다. 확인을 클릭하여 뒤로 돌아가세요." + "message": "최초 거래를 네트워크에서 확인했습니다. 돌아가려면 확인을 클릭하세요." }, "insufficientBalance": { "message": "잔액이 부족합니다." @@ -931,7 +948,7 @@ "message": "잘못된 체인 ID. 체인 ID가 너무 큽니다." }, "invalidCustomNetworkAlertContent1": { - "message": "맞춤형 네트워크 '$1의 체인 ID를 다시 입력해야 합니다.", + "message": "맞춤형 네트워크 '$1'의 체인 ID를 다시 입력해야 합니다.", "description": "$1 is the name/identifier of the network." }, "invalidCustomNetworkAlertContent2": { @@ -963,7 +980,7 @@ "message": "잘못된 RPC URL" }, "invalidSeedPhrase": { - "message": "잘못된 계정 시드 구문" + "message": "잘못된 비밀 복구 구문" }, "ipfsGateway": { "message": "IPFS 게이트웨이" @@ -979,7 +996,7 @@ "message": "알려진 계약 주소입니다." }, "knownTokenWarning": { - "message": "이 작업은 피싱에 사용할 수 있는, 지갑에 이미 나열된 토큰을 편집합니다. 이 토큰이 나타내는 내용을 변경해야 할 때만 작업을 승인하세요." + "message": "이 작업은 지갑에 이미 나열되어 있고 피싱에 사용될 수 있는 토큰을 편집합니다. 해당 토큰이 나타내는 내용을 변경하려는 경우에만 작업을 승인하세요." }, "kovan": { "message": "Kovan 테스트 네트워크" @@ -997,28 +1014,28 @@ "message": "Ledger Live 사용하기" }, "ledgerLiveAdvancedSettingDescription": { - "message": "새로운 Ledger Live 브리지를 통해 Ledger를 더 쉽게 사용할 수 있습니다. Chrome에서만 사용 가능." + "message": "새로운 Ledger Live 브리지를 통해 Ledger를 더 쉽게 사용할 수 있습니다. Chrome에서만 사용 가능합니다." }, "ledgerLiveApp": { "message": "Ledger Live 앱" }, "ledgerLocked": { - "message": "Ledger 장치에 연결할 수 없습니다. 장치의 잠금이 해제되어 있고 이더리움 앱이 열려 있는지 확인하십시오." + "message": "Ledger 장치에 연결할 수 없습니다. 장치의 잠금이 해제되어 있고 이더리움 앱이 열려 있는지 확인하세요." }, "ledgerTimeout": { - "message": "Ledger Live의 응답 시간이 너무 길거나 연결 시간을 초과하였습니다. Ledger Live가 열려있고 장치의 잠금이 해제되어 있는지 확인하십시오." + "message": "Ledger Live의 응답 시간이 너무 길거나 연결 시간을 초과하였습니다. Ledger Live 앱이 열려 있고 장치의 잠금이 해제되어 있는지 확인하세요." }, "letsGoSetUp": { "message": "설정을 시작하죠!" }, "likeToAddTokens": { - "message": "이 토큰을 추가하시겠습니까?" + "message": "이 토큰을 추가하시겠어요?" }, "links": { "message": "링크" }, "loadMore": { - "message": "추가 항목 로드" + "message": "추가 로드" }, "loading": { "message": "로드 중..." @@ -1033,7 +1050,7 @@ "message": "잠금" }, "lockTimeTooGreat": { - "message": "자금 시간이 너무 김" + "message": "잠금 시간이 너무 깁니다." }, "mainnet": { "message": "이더리움 메인넷" @@ -1054,13 +1071,13 @@ "message": "메시지" }, "metaMaskConnectStatusParagraphOne": { - "message": "MetaMask의 계정 연결에 대한 제어 기능이 강화되었습니다." + "message": "이제 MetaMask의 계정 연결을 더 효과적으로 제어할 수 있습니다." }, "metaMaskConnectStatusParagraphThree": { "message": "클릭하여 연결된 계정을 관리하세요." }, "metaMaskConnectStatusParagraphTwo": { - "message": "방문 중인 웹사이트가 현재 선택된 계정에 연결되어 있다면 연결 상태 버튼이 표시됩니다." + "message": "방문 중인 웹사이트가 현재 선택한 계정에 연결되어 있다면 연결 상태 버튼이 표시됩니다." }, "metamaskDescription": { "message": "이더리움 및 분산형 웹에 연결합니다." @@ -1082,15 +1099,15 @@ "message": "MetaMask에서는.." }, "metametricsCommitmentsNeverCollectIP": { - "message": "$1은(는) 전체 IP 주소를 수집하지 않습니다.", + "message": "전체 IP 주소를 절대 수집하지 않습니다.", "description": "The $1 is the bolded word 'Never', from 'metametricsCommitmentsBoldNever'" }, "metametricsCommitmentsNeverCollectKeysEtc": { - "message": "$1은(는) 키, 주소, 거래, 잔액, 해시 또는 개인 정보를 수집합니다.", + "message": "키, 주소, 거래, 잔액, 해시 또는 개인 정보를 절대 수집하지 않습니다.", "description": "The $1 is the bolded word 'Never', from 'metametricsCommitmentsBoldNever'" }, "metametricsCommitmentsNeverSellDataForProfit": { - "message": "$1은(는) 수익을 위해 데이터를 판매합니다. 절대로요!", + "message": "수익을 위해 데이터를 절대 판매하지 않습니다. 결코 그렇습니다.", "description": "The $1 is the bolded word 'Never', from 'metametricsCommitmentsBoldNever'" }, "metametricsCommitmentsSendAnonymizedEvents": { @@ -1100,7 +1117,7 @@ "message": "MetaMask 개선에 참여" }, "metametricsOptInDescription": { - "message": "MetaMask는 사용자의 확장 사용 방식을 더 잘 이해하기 위해 사용 데이터를 수집하려 합니다. 이 데이터는 제품과 이더리움 생태계의 사용 편의성과 사용자 경험을 지속적으로 개선하는 데 사용됩니다." + "message": "MetaMask는 사용자가 확장 프로그램과 상호작용하는 방식을 자세히 이해하기 위해 사용 데이터를 수집하려 합니다. 이 데이터는 당사의 제품과 이더리움 에코시스템의 사용 편의성 및 사용자 경험을 지속적으로 개선하는 데 사용됩니다." }, "mismatchedChain": { "message": "이 체인 ID의 네트워크 세부 정보가 기록과 일치하지 않습니다. 진행하기 전에 $1을(를) 권장합니다.", @@ -1111,7 +1128,7 @@ "description": "Serves as link text for the 'mismatchedChain' key. This text will be embedded inside the translation for that key." }, "mobileSyncText": { - "message": "암호를 입력하여 본인임을 확인하세요!" + "message": "암호를 입력하여 본인임을 인증하세요." }, "mustSelectOne": { "message": "토큰을 1개 이상 선택해야 합니다." @@ -1126,7 +1143,7 @@ "message": "MetaMask를 이용하는 분산형 애플리케이션과 상호작용하려면 지갑에 Ether가 있어야 합니다." }, "needHelp": { - "message": "도움이 필요하십니까? $1에 문의하십시오.", + "message": "도움이 필요하신가요? $1에 문의하세요.", "description": "$1 represents `needHelpLinkText`, the text which goes in the help link" }, "needHelpLinkText": { @@ -1158,7 +1175,7 @@ "message": "테스트넷" }, "networkSettingsChainIdDescription": { - "message": "체인 ID는 거래 서명에 사용합니다. 네트워크에서 반환하는 체인 ID와 일치해야 합니다. 십진수나 '0x'로 시작하는 16진수를 입력할 수 있지만, 표시될 때는 십진수로 표시됩니다." + "message": "체인 ID는 거래 서명에 사용됩니다. 이는 네트워크에서 반환하는 체인 ID와 일치해야 합니다. 십진수나 '0x'로 시작하는 16진수를 입력할 수 있지만, 표시되는 형식은 십진수입니다." }, "networkSettingsDescription": { "message": "맞춤형 RPC 네트워크 추가 및 편집" @@ -1167,7 +1184,7 @@ "message": "네트워크 URL" }, "networkURLDefinition": { - "message": "이 네트워크에 접근하기 위한 URL입니다." + "message": "이 네트워크에 액세스하는 데 사용되는 URL입니다." }, "networks": { "message": "네트워크" @@ -1210,20 +1227,20 @@ "message": "다음" }, "nextNonceWarning": { - "message": "임시값이 추천 임시값($1)보다 큼", + "message": "임시값이 추천 임시값($1)보다 큽니다.", "description": "The next nonce according to MetaMask's internal logic" }, "noAccountsFound": { - "message": "검색 쿼리에 맞는 계정 없음" + "message": "검색어에 해당하는 계정이 없습니다." }, "noAddressForName": { "message": "이 이름에 설정된 주소가 없습니다." }, "noAlreadyHaveSeed": { - "message": "아니요. 이미 시드 구문이 있습니다." + "message": "아니요. 이미 비밀 복구 구문이 있습니다." }, "noConversionRateAvailable": { - "message": "사용 가능한 전환율 없음" + "message": "사용 가능한 전환율이 없음" }, "noThanks": { "message": "괜찮습니다" @@ -1238,19 +1255,19 @@ "message": "웹캠을 찾을 수 없음" }, "nonce": { - "message": "임시" + "message": "임시값" }, "nonceField": { "message": "거래 임시값 맞춤화" }, "nonceFieldDescription": { - "message": "이 기능을 켜면 확인 화면에서 임시값(거래 번호)을 변경할 수 있습니다. 이것은 고급 기능으로, 주의해서 사용해야 합니다." + "message": "이 기능을 켜면 확인 화면에서 임시값(거래 번호)을 변경할 수 있습니다. 이는 고급 기능으로, 주의해서 사용해야 합니다." }, "nonceFieldHeading": { - "message": "맞춤 임시값" + "message": "맞춤형 임시값" }, "notCurrentAccount": { - "message": "이(가) 올바른 계정인가요? 지갑에서 현재 선택된 계정과 다릅니다." + "message": "올바른 계정인가요? 현재 지갑에서 선택된 계정과 다릅니다." }, "notEnoughGas": { "message": "Gas 부족" @@ -1268,7 +1285,7 @@ "description": "The 'call to action' on the button, or link, of the 'Stay secure' notification. Upon clicking, users will be taken to a page about security on the metamask support website." }, "notifications3Description": { - "message": "MetaMask 보안에 대한 모범 사례의 최신 정보를 얻고 공식 MetaMask 지원에서 최신 보안 팁을 확인하십시오.", + "message": "MetaMask 보안에 대한 모범 사례의 최신 정보를 얻고 공식 MetaMask 지원에서 최신 보안 팁을 확인하세요.", "description": "Description of a notification in the 'See What's New' popup. Describes the information they can get on security from the linked support page." }, "notifications3Title": { @@ -1280,7 +1297,7 @@ "description": "The 'call to action' on the button, or link, of the 'Swap on Binance Smart Chain!' notification. Upon clicking, users will be taken to a page where then can swap tokens on Binance Smart Chain." }, "notifications4Description": { - "message": "지갑에서 토큰 스왑 최고가를 바로 이용하십시오. MetaMask는 이제 바이낸스 스마트 체인의 여러 분산형 교환 애그리게이터 및 투자전문기관과 연결됩니다.", + "message": "지갑에서 토큰 스왑 최고가를 바로 이용하세요. MetaMask는 이제 바이낸스 스마트 체인의 여러 분산형 교환 애그리게이터 및 투자전문기관과 연결됩니다.", "description": "Description of a notification in the 'See What's New' popup." }, "notifications4Title": { @@ -1291,6 +1308,22 @@ "message": "\"시드 구문\"을 이제 \"계정 시드 구문\"이라고 합니다.", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Chrome 버전 91부터 Ledger 지원(U2F)을 활성화한 API에서 하드웨어 지갑을 지원하지 않습니다. MetaMask는 Ledger Live 데스크톱 앱을 통해 Ledger 장치에 계속 연결할 수 있는 새로운 Ledger Live 지원을 구축했습니다.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "MetaMask에서 Ledger 계정과 상호작용하면 새 탭이 열리고 Ledger Live 앱을 열라는 메시지가 표시됩니다. 앱이 열리면 MetaMask 계정에 대한 WebSocket 연결을 허용하라는 메시지가 표시됩니다. 이제 다 됐습니다.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Ledger Live 지원은 설정 > 고급 > Ledger Live 사용을 클릭하여 활성화할 수 있습니다.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Chrome 사용자용 Ledger 지원 업데이트", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "/" }, @@ -1317,7 +1350,7 @@ "message": "메인넷에서만 사용 가능" }, "onlyConnectTrust": { - "message": "신뢰하는 사이트에만 연결하세요." + "message": "신뢰하는 사이트만 연결하세요." }, "optionalBlockExplorerUrl": { "message": "블록 탐색기 URL(선택 사항)" @@ -1354,10 +1387,10 @@ "message": "보류 중" }, "permissionCheckedIconDescription": { - "message": "이 권한을 수락하셨습니다." + "message": "이 권한을 승인했습니다." }, "permissionUncheckedIconDescription": { - "message": "이 권한을 수락하지 않으셨습니다." + "message": "이 권한을 승인하지 않았습니다." }, "permissions": { "message": "권한" @@ -1366,7 +1399,7 @@ "message": "개인 주소가 발견되었습니다. 토큰 계약 주소를 입력하세요." }, "plusXMore": { - "message": "+ 외 $1개", + "message": "+ 그 외 $1개", "description": "$1 is a number of additional but unshown items in a list- this message will be shown in place of those items" }, "prev": { @@ -1379,7 +1412,7 @@ "message": "체인의 고유 통화(예: ETH)로 값을 우선 표시하려면 고유를 선택합니다. 선택한 명목 통화로 값을 우선 표시하려면 명목을 선택합니다." }, "privacyMsg": { - "message": "개인정보 보호정책" + "message": "개인정보보호정책" }, "privateKey": { "message": "비공개 키", @@ -1407,7 +1440,7 @@ "message": "대기열에 지정됨" }, "readdToken": { - "message": "나중에 계정 옵션 메뉴의 “토큰 추가”로 이동하면 이 토큰을 다시 추가할 수 있습니다." + "message": "나중에 계정 옵션 메뉴의 '토큰 추가'로 이동하면 이 토큰을 다시 추가할 수 있습니다." }, "receive": { "message": "받기" @@ -1415,12 +1448,33 @@ "recents": { "message": "최근" }, - "recipientAddress": { - "message": "수신인 주소" - }, "recipientAddressPlaceholder": { "message": "검색, 공개 주소(0x) 또는 ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "여기에서 시작" + }, + "recoveryPhraseReminderConfirm": { + "message": "확인" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "계정 시드 구문은 언제나 보안이 유지되고 알려지지 않은 곳에 보관해야 합니다." + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "계정 시드 구문을 다시 백업해야 합니까?" + }, + "recoveryPhraseReminderItemOne": { + "message": "절대로 다른 사람과 계정 시드 구문을 공유하지 마십시오" + }, + "recoveryPhraseReminderItemTwo": { + "message": "MetaMask 팀에서는 절대로 계정 시드 구문을 물어보지 않습니다" + }, + "recoveryPhraseReminderSubText": { + "message": "계정 시드 구문으로 귀하의 모든 계정을 관리합니다." + }, + "recoveryPhraseReminderTitle": { + "message": "자금을 지키세요" + }, "reject": { "message": "거부" }, @@ -1446,7 +1500,7 @@ "message": "계정 제거" }, "removeAccountDescription": { - "message": "이 계정이 지갑에서 제거됩니다. 계속하기 전에 가져온 이 계정에 대한 원본 시드 구문이나 비공개 키가 있는지 확인하십시오. 계정 드롭다운에서 계정을 가져오거나 다시 만들 수 있습니다. " + "message": "이 계정이 지갑에서 제거됩니다. 계속하기 전에 가져온 이 계정에 대한 원본 비밀 복구 구문이나 비공개 키가 있는지 확인하세요. 계정 드롭다운에서 계정을 가져오거나 다시 만들 수 있습니다. " }, "requestsAwaitingAcknowledgement": { "message": "확인 대기 중인 요청" @@ -1461,32 +1515,32 @@ "message": "계정 재설정" }, "resetAccountDescription": { - "message": "계정을 재설정하면 거래 내역이 지워집니다. 계정의 잔액이 변경되지 않으면 시드 구문을 다시 입력하지 않아도 됩니다." + "message": "계정을 재설정하면 거래 내역이 지워집니다. 계정의 잔액은 변경되지 않으며 비밀 복구 구문을 다시 입력하지 않아도 됩니다." }, "restore": { "message": "복구" }, "restoreAccountWithSeed": { - "message": "시드 구문으로 계정 복구" + "message": "비밀 복구 구문으로 계정 복구" }, "restoreWalletPreferences": { - "message": "$1의 데이터 백업이 발견되었습니다. 지갑 환경설정을 복구하시겠습니까?", + "message": "$1의 데이터 백업이 발견되었습니다. 지갑 환경설정을 복원하시겠어요?", "description": "$1 is the date at which the data was backed up" }, "retryTransaction": { "message": "거래 재시도" }, "reusedTokenNameWarning": { - "message": "여기에 있는 토큰은 사용자가 확인한 다른 토큰의 기호를 재사용하기 때문에 혼란스럽거나 속기 쉽습니다." + "message": "여기에 있는 토큰은 사용자가 주시 중인 다른 토큰의 기호를 재사용하기 때문에 혼동되거나 속기 쉽습니다." }, "revealSeedWords": { - "message": "계정 시드 구문 공개" + "message": "비밀 복구 구문 공개" }, "revealSeedWordsDescription": { - "message": "브라우저를 변경하거나 컴퓨터를 옮긴 경우, 계정에 액세스하려면 이 시드 구문이 필요합니다. 기밀이 보장된 안전한 곳에 보관하십시오." + "message": "브라우저를 변경하거나 컴퓨터를 옮긴 경우, 계정에 액세스하려면 이 비밀 복구 구문이 필요합니다. 기밀이 보장된 안전한 곳에 보관하세요." }, "revealSeedWordsTitle": { - "message": "계정 시드 구문" + "message": "비밀 복구 구문" }, "revealSeedWordsWarning": { "message": "이 구문은 계정 전체를 도용하는 데 사용될 수 있습니다." @@ -1546,10 +1600,10 @@ "message": "보안 및 개인정보 보호" }, "securitySettingsDescription": { - "message": "개인정보 설정 및 지갑 시드 구문" + "message": "개인정보 설정 및 지갑 비밀 복구 구문" }, "seedPhraseIntroSidebarBulletFour": { - "message": "적어서 여러 비밀 장소에 보관." + "message": "적어서 여러 비밀 장소에 보관하세요." }, "seedPhraseIntroSidebarBulletOne": { "message": "암호 관리자에 저장" @@ -1561,40 +1615,40 @@ "message": "은행 금고에 보관." }, "seedPhraseIntroSidebarCopyOne": { - "message": "복구 구문은 지갑과 펀드의 “마스터 키” 입니다." + "message": "복구 구문은 지갑과 자금의 '마스터 키'입니다." }, "seedPhraseIntroSidebarCopyThree": { - "message": "복구 구문을 요청하는 사람은 사기를 치려고 하는 것입니다." + "message": "복구 구문을 요청하는 사람은 사기를 치려는 것입니다." }, "seedPhraseIntroSidebarCopyTwo": { - "message": "절대로, MetaMask와도 복구 구문을 공유하면 안 됩니다!" + "message": "절대로, MetaMask와도 시드 구문을 공유하면 안 됩니다!" }, "seedPhraseIntroSidebarTitleOne": { - "message": "'복구 구문'이란 무엇입니까?" + "message": "'복구 구문'이란 무엇인가요?" }, "seedPhraseIntroSidebarTitleThree": { - "message": "복구 구문을 공유해야 합니까?" + "message": "복구 구문을 공유해야 하나요?" }, "seedPhraseIntroSidebarTitleTwo": { - "message": "복구 구문을 어떻게 저장합니까?" + "message": "복구 구문은 어떻게 저장하나요?" }, "seedPhraseIntroTitle": { "message": "지갑 보호하기" }, "seedPhraseIntroTitleCopy": { - "message": "시작하기 전에 이 비디오를 통해 복구 구문과 지갑을 보호하는 방법에 대해 알아보십시오." + "message": "시작하기 전에 이 짧은 동영상을 보고 복구 구문과 지갑을 안전하게 보호하는 방법에 대해 알아보세요." }, "seedPhrasePlaceholder": { - "message": "공백 한 칸으로 각 단어를 구분하십시오." + "message": "공백 한 칸으로 각 단어를 구분하세요." }, "seedPhrasePlaceholderPaste": { - "message": "클립보드에서 시드 구문 붙여넣기" + "message": "클립보드에서 비밀 복구 구문 붙여넣기" }, "seedPhraseReq": { - "message": "시드 구문에 12, 15, 18, 21 또는 24단어 포함" + "message": "비밀 복구 구문은 12, 15, 18, 21 또는 24개의 단어를 포함합니다." }, "selectAHigherGasFee": { - "message": "높은 가스 요금을 선택하면 거래 처리 속도를 높일 수 있습니다.*" + "message": "높은 Gas 수수료를 선택하면 거래 처리 속도를 높일 수 있습니다.*" }, "selectAccounts": { "message": "계정 선택" @@ -1634,7 +1688,7 @@ "message": "보내기" }, "sendAmount": { - "message": "금액 보내기" + "message": "송금" }, "sendSpecifiedTokens": { "message": "$1 보내기", @@ -1647,7 +1701,7 @@ "message": "Ether 보냄" }, "separateEachWord": { - "message": "공백 한 칸으로 각 단어를 구분하십시오." + "message": "공백 한 칸으로 각 단어를 구분하세요." }, "settings": { "message": "설정" @@ -1656,13 +1710,13 @@ "message": "고급 Gas 제어 기능" }, "showAdvancedGasInlineDescription": { - "message": "이 항목을 선택하면 보내기 및 확인 화면에 Gas 가격이 표시되며 제어 기능을 바로 제한할 수 있습니다." + "message": "이 항목을 선택하면 보내기 및 확인 화면에서 바로 Gas 가격을 표시하고 제어 기능을 제한할 수 있습니다." }, "showFiatConversionInTestnets": { "message": "테스트넷에 전환 표시" }, "showFiatConversionInTestnetsDescription": { - "message": "이 항목을 선택하면 테스트넷에 명목 전환을 표시합니다." + "message": "이 항목을 선택하면 테스트넷에 명목 전환이 표시됩니다." }, "showHexData": { "message": "16진수 데이터 표시" @@ -1674,7 +1728,7 @@ "message": "수신 거래 표시" }, "showIncomingTransactionsDescription": { - "message": "이 항목을 선택하면 Etherscan을 사용하여 거래 목록에 수신 거래를 표시합니다." + "message": "이 항목을 선택하면 Etherscan을 사용해 거래 목록에 수신 거래를 표시할 수 있습니다." }, "showPermissions": { "message": "권한 표시" @@ -1683,7 +1737,7 @@ "message": "비공개 키 표시" }, "showSeedPhrase": { - "message": "계정 시드 구문 표시" + "message": "비밀 복구 구문 표시" }, "sigRequest": { "message": "서명 요청" @@ -1759,21 +1813,21 @@ "message": "Ledger 앱 다운로드" }, "step1LedgerWalletMsg": { - "message": "$1의 잠금을 해제하기 위해 다운로드, 설정 및 암호를 입력하세요.", + "message": "$1의 잠금을 해제하려면 다운로드, 설정 및 암호를 입력하세요.", "description": "$1 represents the `ledgerLiveApp` localization value" }, "step1TrezorWallet": { "message": "Trezor 지갑 연결" }, "step1TrezorWalletMsg": { - "message": "지갑을 컴퓨터에 바로 연결합니다. 하드웨어 지갑 장치를 사용하기 위한 더 많은 내용은 $1", + "message": "지갑을 컴퓨터에 바로 연결합니다. 하드웨어 지갑 장치를 사용하는 방법에 관한 자세한 내용은 $1", "description": "$1 represents the `hardwareWalletSupportLinkConversion` localization key" }, "step2LedgerWallet": { "message": "Ledger 지갑 연결" }, "step2LedgerWalletMsg": { - "message": "지갑을 컴퓨터에 바로 연결합니다. Ledger를 잠금 해제하고 Ethereum 앱을 엽니다. 하드웨어 지갑 장치를 사용하기 위한 더 많은 내용은 $1.", + "message": "지갑을 컴퓨터에 바로 연결합니다. Ledger를 잠금 해제하고 Ethereum 앱을 엽니다. 하드웨어 지갑 장치를 사용하는 방법에 관한 자세한 내용은 $1.", "description": "$1 represents the `hardwareWalletSupportLinkConversion` localization key" }, "storePhrase": { @@ -1795,7 +1849,7 @@ "message": "스왑" }, "swapAdvancedSlippageInfo": { - "message": "주문 시점과 확인 시점 사이에 가격이 변동되는 현상을 “슬리패지”라고 합니다. 슬리패지가 “최대 슬리패지” 설정을 초과하면 스왑이 자동으로 취소됩니다." + "message": "주문 시점과 확인 시점 사이에 가격이 변동되는 현상을 '슬리패지'라고 합니다. 슬리패지가 '최대 슬리패지' 설정을 초과하면 스왑이 자동으로 취소됩니다." }, "swapAggregator": { "message": "애그리게이터" @@ -1811,18 +1865,18 @@ "message": "수신하는 최소 금액입니다. 슬리패지에 따라 추가 금액을 받을 수도 있습니다." }, "swapApproval": { - "message": "$1 스왑 승인", + "message": "스왑을 위해 $1 승인", "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be swapped.. $1 is the symbol of a token that has been approved." }, "swapApproveNeedMoreTokens": { - "message": "이 스왑을 완료하려면 $1와(과) 추가 $2이(가) 필요합니다.", + "message": "이 스왑을 완료하려면 $1개의 추가 $2이(가) 필요합니다.", "description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol." }, "swapBetterQuoteAvailable": { "message": "더 나은 견적이 있습니다." }, "swapBuildQuotePlaceHolderText": { - "message": "$1와(과) 일치하는 가용 토큰 없음", + "message": "$1와(과) 일치하는 토큰이 없습니다.", "description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text" }, "swapCheckingQuote": { @@ -1848,21 +1902,21 @@ "message": "한도 편집" }, "swapEnableDescription": { - "message": "필수이며 MetaMask에게 $1을(를) 스왑할 권한을 제공합니다.", + "message": "필수이며 MetaMask에게 $1을(를) 스왑할 권한을 부여합니다.", "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." }, "swapEstimatedNetworkFee": { - "message": "예상 네트워크 요금" + "message": "예상 네트워크 수수료" }, "swapEstimatedNetworkFeeSummary": { - "message": "“$1”은(는) 당사가 예상하는 실제 요금입니다. 정확한 금액은 네트워크 상태에 따라 달라집니다.", + "message": "'$1'은(는) 당사가 예상하는 실제 수수료입니다. 정확한 금액은 네트워크 상태에 따라 달라집니다.", "description": "$1 will be the translation of swapEstimatedNetworkFee, with the font bolded" }, "swapEstimatedNetworkFees": { - "message": "예상 네트워크 요금" + "message": "예상 네트워크 수수료" }, "swapEstimatedNetworkFeesInfo": { - "message": "스왑을 완료하는 데 사용할 네트워크 요금 예상치입니다. 실제 금액은 네트워크 조건에 따라 달라질 수 있습니다." + "message": "스왑을 완료하는 데 사용될 예상 네트워크 수수료입니다. 실제 금액은 네트워크 상태에 따라 달라질 수 있습니다." }, "swapFailedErrorDescriptionWithSupportLink": { "message": "거래가 실패할 경우 언제든 문의하세요. 오류가 해결되지 않는다면 고객 지원 $1에 문의하세요.", @@ -1875,7 +1929,7 @@ "message": "음.... 문제가 발생했습니다. 다시 시도해 보고 오류가 해결되지 않는다면 고객 지원에 문의하세요." }, "swapFetchingQuotesErrorTitle": { - "message": "견적 가져오는 중 오류 발생" + "message": "견적을 가져오는 중 오류 발생" }, "swapFetchingTokens": { "message": "토큰 가져오는 중..." @@ -1884,11 +1938,11 @@ "message": "마무리 중..." }, "swapFromTo": { - "message": "$1를 $2로 스왑", + "message": "$1을(를) $2(으)로 스왑", "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" }, "swapGasFeesSplit": { - "message": "이전 화면의 Gas 요금은 이 두 거래로 나뉩니다." + "message": "이전 화면의 Gas 수수료는 이 두 거래로 나뉩니다." }, "swapHighSlippageWarning": { "message": "슬리패지 금액이 아주 큽니다." @@ -1901,16 +1955,16 @@ "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" }, "swapMaxNetworkFees": { - "message": "최대 네트워크 요금" + "message": "최대 네트워크 수수료" }, "swapMaxSlippage": { "message": "최대 슬리패지" }, "swapMetaMaskFee": { - "message": "MetaMask 요금" + "message": "MetaMask 수수료" }, "swapMetaMaskFeeDescription": { - "message": "당사는 매번 최상의 유동성 소스에서 최적 가격을 찾습니다. 이 견적에는 $1%의 수수료가 자동으로 반영됩니다.", + "message": "당사는 매번 최상의 유동성 소스에서 최적의 가격을 찾습니다. 이 견적에는 $1%의 수수료가 자동으로 반영됩니다.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." }, "swapNQuotes": { @@ -1918,7 +1972,7 @@ "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" }, "swapNetworkFeeSummary": { - "message": "네트워크 요금에는 스왑을 처리하고 $1 네트워크에 보관하는 비용이 적용됩니다. MetaMask는 이 요금을 이용해 이득을 얻지 않습니다." + "message": "네트워크 수수료에는 스왑을 처리하고 $1 네트워크에 보관하는 비용이 포함됩니다. MetaMask는 이 수수료로 수익을 얻지 않습니다." }, "swapNewQuoteIn": { "message": "$1의 새 견적", @@ -1929,7 +1983,7 @@ "description": "This message communicates the token that is being transferred. It is shown on the awaiting swap screen. The $1 will be a token symbol." }, "swapPriceDifference": { - "message": "$1 $2 (~$3)을 $4 $5 (~$6)로 스왑합니다.", + "message": "$1 $2(~$3)을(를) $4 $5(~$6)(으)로 스왑하려고 합니다.", "description": "This message represents the price slippage for the swap. $1 and $4 are a number (ex: 2.89), $2 and $5 are symbols (ex: ETH), and $3 and $6 are fiat currency amounts." }, "swapPriceDifferenceTitle": { @@ -1940,10 +1994,10 @@ "message": "가격 영향은 현재 시장 가격과 거래 실행 도중 받은 금액 사이의 차이입니다. 가격 영향은 유동성 풀의 크기 대비 거래의 크기를 나타내는 함수입니다." }, "swapPriceUnavailableDescription": { - "message": "시장 가격 데이터가 부족하여 가격 영향을 파악할 수 없습니다. 스왑하기 전에 받게 될 토큰 수에 만족하시는지 확인하시기 바랍니다." + "message": "시장 가격 데이터가 부족하여 가격 영향을 파악할 수 없습니다. 스왑하기 전에 받게 될 토큰 수가 만족스러운지 확인하시기 바랍니다." }, "swapPriceUnavailableTitle": { - "message": "진행하기 전에 요율을 확인하십시오." + "message": "진행하기 전에 요율 확인" }, "swapProcessing": { "message": "처리 중" @@ -1969,7 +2023,7 @@ "message": "견적은 현재 시장 상황을 반영하도록 자주 갱신됩니다." }, "swapQuotesExpiredErrorDescription": { - "message": "지금 견적을 요청해 최신 요율을 확인하세요." + "message": "새 견적을 요청해 최신 요율을 확인하세요." }, "swapQuotesExpiredErrorTitle": { "message": "견적 시간 초과" @@ -2017,7 +2071,7 @@ "message": "유동성 소스" }, "swapSourceInfo": { - "message": "당사에서는 여러 유동성 소스(교환, 애그리게이터, 투자전문기관)를 검색하여 최상의 요율과 최저 네트워크 요금을 찾습니다." + "message": "당사에서는 여러 유동성 소스(교환, 애그리게이터, 투자전문기관)를 검색하여 최상의 요율과 최저 네트워크 수수료를 찾습니다." }, "swapSwapFrom": { "message": "다음에서 스왑" @@ -2046,15 +2100,18 @@ "message": "$1에서 $2(으)로 스왑", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "이 토큰은 수동으로 추가되었습니다." + }, "swapTokenVerificationMessage": { - "message": "항상 $1에서 토큰 주소를 확인하십시오.", + "message": "항상 $1에서 토큰 주소를 확인하세요.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." }, "swapTokenVerificationOnlyOneSource": { "message": "1개의 소스에서만 확인됩니다." }, "swapTokenVerificationSources": { - "message": "$1 소스에서 확인되었습니다.", + "message": "$1개 소스에서 확인되었습니다.", "description": "Indicates the number of token information sources that recognize the symbol + address. $1 is a decimal number." }, "swapTransactionComplete": { @@ -2084,13 +2141,13 @@ "message": "고급 옵션" }, "swapsExcessiveSlippageWarning": { - "message": "슬리패지 금액이 너무 커서 전환율이 좋지 않습니다. 최대 슬리패지를 15% 값 이하로 줄이십시오." + "message": "슬리패지 금액이 너무 커서 전환율이 좋지 않습니다. 슬리패지 허용치를 15% 값 이하로 줄이세요." }, "swapsMaxSlippage": { - "message": "최대 슬리패지" + "message": "슬리패지 허용치" }, "swapsNotEnoughForTx": { - "message": "$1이(가) 부족하여 이 거래를 완료할 수 없음", + "message": "$1이(가) 부족하여 이 거래를 완료할 수 없습니다.", "description": "Tells the user that they don't have enough of a token for a proposed swap. $1 is a token symbol" }, "swapsViewInActivity": { @@ -2100,13 +2157,13 @@ "message": "이는 MetaMask 내의 선택된 네트워크를 이전에 추가된 다음 네트워크로 전환합니다." }, "switchEthereumChainConfirmationTitle": { - "message": "이 사이트가 네트워크를 전환하도록 허용합니까?" + "message": "이 사이트가 네트워크를 전환하도록 허용하시겠어요?" }, "switchLedgerPaths": { "message": "Ledger 경로 전환" }, "switchLedgerPathsText": { - "message": "다른 계정을 보려면 Ledger 경로 선택" + "message": "다른 계정을 보려면 Ledger 경로를 선택하세요." }, "switchNetwork": { "message": "네트워크 전환" @@ -2206,16 +2263,16 @@ "message": "거래" }, "transactionCancelAttempted": { - "message": "$2에서 Gas 요금이 $1인 거래 취소 시도됨" + "message": "$2에서 Gas 수수료가 $1인 거래의 취소가 시도되었습니다." }, "transactionCancelSuccess": { "message": "$2에서 거래 취소 성공" }, "transactionConfirmed": { - "message": "$2에서의 거래가 확인되었습니다." + "message": "$2에서 거래가 확인되었습니다." }, "transactionCreated": { - "message": "$2에서 $1 값으로 거래를 만들었습니다." + "message": "$2에서 $1 값으로 거래가 생성되었습니다." }, "transactionDropped": { "message": "$2에서의 거래가 중단되었습니다." @@ -2233,10 +2290,10 @@ "message": "거래 수수료" }, "transactionResubmitted": { - "message": "$2에서 Gas 요금이 $1(으)로 증가한 거래가 다시 제출됨" + "message": "$2에서 Gas 수수료가 $1(으)로 증가한 거래가 다시 제출되었습니다." }, "transactionSubmitted": { - "message": "$2에서 Gas 요금이 $1인 거래가 제출되었습니다." + "message": "$2에서 Gas 수수료가 $1인 거래가 제출되었습니다." }, "transactionUpdated": { "message": "$2에서의 거래가 업데이트되었습니다." @@ -2245,17 +2302,17 @@ "message": "전송" }, "transferBetweenAccounts": { - "message": "계정 간 전송" + "message": "내 계정 간 전송" }, "transferFrom": { "message": "전송 위치" }, "troubleConnectingToWallet": { - "message": "$1 연결 도중 문제가 발생했습니다. $2을(를) 검도하고 다시 시도해 보세요.", + "message": "$1 연결 도중 문제가 발생했습니다. $2을(를) 검토하고 다시 시도해 보세요.", "description": "$1 is the wallet device name; $2 is a link to wallet connection guide" }, "troubleTokenBalances": { - "message": "토큰 잔액을 로드하는 도중 문제가 발생했습니다. 잔액을 확인할 수 있습니다. ", + "message": "토큰 잔액을 로드하는 도중 문제가 발생했습니다. 다음에서 잔액을 확인하세요. ", "description": "Followed by a link (here) to view token balances" }, "trustSiteApprovePermission": { @@ -2313,7 +2370,7 @@ "message": "URI에는 적절한 HTTP/HTTPS 접두사가 필요합니다." }, "urlExistsErrorMsg": { - "message": "URL이 기존 네트워크 목록에 이미 존재함" + "message": "이 URL은 현재 $1 네트워크에서 사용됩니다." }, "usePhishingDetection": { "message": "피싱 감지 사용" @@ -2335,6 +2392,10 @@ "message": "$1에서 이 토큰 확인", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "$1에서 이 토큰이 거래하려는 토큰이 맞는지 확인하세요.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "계정 보기" }, @@ -2363,10 +2424,14 @@ "message": "당사의 하드웨어 지갑 연결 가이드" }, "walletSeed": { - "message": "계정 시드 구문" + "message": "비밀 복구 구문" }, "walletSeedRestore": { - "message": "지갑 계정 시드 구문" + "message": "지갑 비밀 복구 구문" + }, + "web3ShimUsageNotification": { + "message": "현재의 웹사이트가 제거된 window.web3 API를 이용하려고 합니다. 이 사이트가 제대로 작동하지 않는 경우, $1을(를) 클릭해 자세히 알아보세요.", + "description": "$1 is a clickable link." }, "welcome": { "message": "MetaMask 방문을 환영합니다" @@ -2385,11 +2450,11 @@ "message": "메모지에 이 구문을 적어 안전한 곳에 보관하세요. 보안을 더욱 강화하고 싶다면 여러 메모지에 적은 다음 2~3곳에 보관하세요." }, "xOfY": { - "message": "$2 중 $1", + "message": "$1/$2개", "description": "$1 and $2 are intended to be two numbers, where $2 is a total, and $1 is a count towards that total" }, "xOfYPending": { - "message": "$2개 중 $1개 보류 중", + "message": "$1/$2개 보류 중", "description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total" }, "yesLetsTry": { @@ -2402,9 +2467,9 @@ "message": "서명 중입니다." }, "yourPrivateSeedPhrase": { - "message": "비밀 백업 구문 확인" + "message": "비공개 비밀 복구 구문" }, "zeroGasPriceOnSpeedUpError": { - "message": "가속화 시 가스 가격 0" + "message": "가속화 시 Gas 가격 0" } } diff --git a/app/_locales/lt/messages.json b/app/_locales/lt/messages.json index f18067844..da2c253b0 100644 --- a/app/_locales/lt/messages.json +++ b/app/_locales/lt/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Jūsų naršyklė neatpažįstama..." }, - "builtInCalifornia": { - "message": "„MetaMask“ suprojektuota ir įdiegta Kalifornijoje." - }, "buyWithWyre": { "message": "Pirkti ETH su „Wyre“" }, @@ -754,9 +751,6 @@ "recents": { "message": "Naujausi" }, - "recipientAddress": { - "message": "Gavėjo adresas" - }, "recipientAddressPlaceholder": { "message": "Ieška, viešieji adresai (0x) arba ENS" }, diff --git a/app/_locales/lv/messages.json b/app/_locales/lv/messages.json index 85848338a..2aa8e03e4 100644 --- a/app/_locales/lv/messages.json +++ b/app/_locales/lv/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Jūsu pārlūkprogramma netiek atbalstīta..." }, - "builtInCalifornia": { - "message": "MetaMask ir izstrādāta un izveidota Kalifornijā." - }, "buyWithWyre": { "message": "Pirkt ETH ar Wyre" }, @@ -750,9 +747,6 @@ "recents": { "message": "Nesenie" }, - "recipientAddress": { - "message": "Saņēmēja adrese" - }, "recipientAddressPlaceholder": { "message": "Meklēšana, publiskā adrese (0x) vai ENS" }, diff --git a/app/_locales/ms/messages.json b/app/_locales/ms/messages.json index 483925f9c..7ad59e94f 100644 --- a/app/_locales/ms/messages.json +++ b/app/_locales/ms/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Pelayar anda tidak disokong..." }, - "builtInCalifornia": { - "message": "MetaMask direka dan dibina di California." - }, "buyWithWyre": { "message": "Beli ETH dengan Wyre" }, @@ -731,9 +728,6 @@ "recents": { "message": "Baru-baru ini" }, - "recipientAddress": { - "message": "Alamat Penerima" - }, "recipientAddressPlaceholder": { "message": "Cari, alamat awam (0x), atau ENS" }, diff --git a/app/_locales/nl/messages.json b/app/_locales/nl/messages.json index 2366f8283..e70f716af 100644 --- a/app/_locales/nl/messages.json +++ b/app/_locales/nl/messages.json @@ -40,9 +40,6 @@ "blockiesIdenticon": { "message": "Gebruik Blockies Identicon" }, - "builtInCalifornia": { - "message": "MetaMask is ontworpen en gebouwd in Californië." - }, "cancel": { "message": "Annuleer" }, @@ -272,9 +269,6 @@ "readdToken": { "message": "U kunt dit token in de toekomst weer toevoegen door naar \"Token toevoegen\" te gaan in het menu met accountopties." }, - "recipientAddress": { - "message": "Geadresseerde adres" - }, "reject": { "message": "Afwijzen" }, diff --git a/app/_locales/no/messages.json b/app/_locales/no/messages.json index a6bba8253..1728b607c 100644 --- a/app/_locales/no/messages.json +++ b/app/_locales/no/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Nettleseren din støttes ikke ..." }, - "builtInCalifornia": { - "message": "MetaMask ble bygget og designet i California." - }, "buyWithWyre": { "message": "Kjøp ETH med Wyre" }, @@ -741,9 +738,6 @@ "recents": { "message": "Nylige" }, - "recipientAddress": { - "message": "Mottakeradresse" - }, "recipientAddressPlaceholder": { "message": "Søk, offentlig adresse (0x) eller ENS" }, diff --git a/app/_locales/ph/messages.json b/app/_locales/ph/messages.json index 3a9141757..56763f94d 100644 --- a/app/_locales/ph/messages.json +++ b/app/_locales/ph/messages.json @@ -6,7 +6,7 @@ "message": "Bersyon, support center, at impormasyon sa pakikipag-ugnayan" }, "acceleratingATransaction": { - "message": "* Kapag in-accelerate ang transaksyon sa pamamagitan ng paggamit ng mas mataas na presyo ng gas, mas magiging malaki ang tsansang mas mabilis na maproseso ng network, pero hindi ito palaging ginagarantiya." + "message": "* Kapag in-accelerate ang transaksyon sa pamamagitan ng paggamit ng mas mataas na presyo ng gas, mas magiging malaki ang tsansang mas mabilis na maiproseso ng network, pero hindi ito palaging ginagarantiya." }, "acceptTermsOfUse": { "message": "Nabasa ko at sumasang-ayon ako sa $1", @@ -52,6 +52,10 @@ "addContact": { "message": "Magdagdag ng contact" }, + "addCustomTokenByContractAddress": { + "message": "Walang makitang token? Puwede kang manual na magdagdag ng anumang token sa pamamagitan ng pag-paste ng address nito. Makikita ang mga address ng kontrata ng token sa $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Bibigyang-daan nito na magamit ang network na ito sa MetaMask." }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "Hindi sinusuportahan ang iyong Browser..." }, - "builContactList": { + "buildContactList": { "message": "Buuin ang iyong listahan ng contact" }, - "builtInCalifornia": { - "message": "Ang MetaMask ay idinisenyo at binuo sa California." - }, "buy": { "message": "Bumili" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "Ginagamit ang chain ID para maglagda ng mga transaksyon para sa network na ito." }, + "chainIdExistsErrorMsg": { + "message": "Kasalukuyang ginagamit ng $1 network ang Chain ID na ito." + }, "chromeRequiredForHardwareWallets": { "message": "Kailangan mong gamitin ang MetaMask sa Google Chrome para maikonekta sa iyong Hardware Wallet." }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Magpatuloy sa Wyre" }, + "contract": { + "message": "Kontrata" + }, "contractAddressError": { "message": "Magpapadala ka ng mga token sa address ng kontrata ng token. Posible itong magresulta sa pagkawala ng mga token na ito." }, @@ -641,7 +648,7 @@ "description": "$1 is the return value of eth_chainId from an RPC endpoint" }, "ensNotFoundOnCurrentNetwork": { - "message": "Hindi nahanapa ang ENS name sa kasalukuyang network. Subukang lumipat sa Ethereum Mainnet." + "message": "Hindi nahanap ang ENS name sa kasalukuyang network. Subukang lumipat sa Ethereum Mainnet." }, "ensRegistrationError": { "message": "Nagka-error sa pag-register ng ENS name" @@ -694,7 +701,7 @@ "message": "Mga Tinatantyang Tagal ng Pagproseso" }, "ethGasPriceFetchWarning": { - "message": "Ibinibigay ang backup na presyo ng gas dahil hindi available ang pangunahing serbisyo sa pagtatantiya ng gas sa ngayon." + "message": "Ibinibigay ang backup na presyo ng gas dahil hindi available ang pangunahing serbisyo sa pagtatantya ng gas sa ngayon." }, "eth_accounts": { "message": "Tingnan ang mga address ng iyong mga pinapayagang account (kinakailangan)", @@ -793,7 +800,7 @@ "message": "Sobrang Baba ng Presyo ng Gas" }, "gasPriceFetchFailed": { - "message": "Hindi nagtagumpay ang pagtatantiya ng presyo ng gas dahil sa error sa network." + "message": "Hindi nagtagumpay ang pagtatantya ng presyo ng gas dahil sa error sa network." }, "gasPriceInfoTooltipContent": { "message": "Tinutukoy ng presyo ng gas ang halaga ng Ether na handa mong bayaran para sa bawat unit ng gas." @@ -893,6 +900,12 @@ "message": "o $1", "description": "$1 represents the text from `importAccountLinkText` as a link" }, + "importTokenQuestion": { + "message": "Mag-import ng token?" + }, + "importTokenWarning": { + "message": "Sinuman ay makakagawa ng token na may anumang pangalan, kasama ang mga pekeng bersyon ng mga token na mayroon na. Magdagdag at mag-trade sa sarili mong pananagutan!" + }, "importWallet": { "message": "Mag-import ng wallet" }, @@ -1022,7 +1035,7 @@ "message": "Mga Link" }, "loadMore": { - "message": "Matuto Pa" + "message": "Mag-load Pa" }, "loading": { "message": "Nilo-load..." @@ -1127,7 +1140,7 @@ "message": "Pangalan" }, "needEtherInWallet": { - "message": "Para makaugnayan ang mga decentralized ma application gamit ang MetaMask, kakailanganin mo ang Ether sa iyong wallet." + "message": "Para makaugnayan ang mga decentralized na application gamit ang MetaMask, kakailanganin mo ang Ether sa iyong wallet." }, "needHelp": { "message": "Kailangan ng tulong? Makipag-ugnayan sa $1", @@ -1162,7 +1175,7 @@ "message": "Testnet" }, "networkSettingsChainIdDescription": { - "message": "Ginagaamit ang chain ID sa paglagda ng mga transaksyon. Dapat itong tumugma sa chain ID na ibinalik ng network. Puwede kang maglagay ng decimal o '0x'-prefixed hexadecimal number, pero ipapakita namin ang numero sa decimal." + "message": "Ginagamit ang chain ID sa paglagda ng mga transaksyon. Dapat itong tumugma sa chain ID na ibinalik ng network. Puwede kang maglagay ng decimal o '0x'-prefixed hexadecimal number, pero ipapakita namin ang numero sa decimal." }, "networkSettingsDescription": { "message": "Magdagdag at mag-edit ng mga custom na RPC network" @@ -1214,7 +1227,7 @@ "message": "Susunod" }, "nextNonceWarning": { - "message": "Mas mataas ang noncesa iminumungkahing nonce na $1", + "message": "Mas mataas ang nonce sa iminumungkahing nonce na $1", "description": "The next nonce according to MetaMask's internal logic" }, "noAccountsFound": { @@ -1295,6 +1308,22 @@ "message": "Tinatawag na ngayong \"Secret Recovery Phrase\" mo ang iyong \"Seed Phrase.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Simula sa Chrome version 91, hindi na susuportahan ng API na nag-enable sa aming Ledger support (U2F) ang mga hardware wallet. Nagpatupad ang MetaMask ng bagong Ledger Live support na nagbibigay-daan sa iyong patuloy na ikonekta ang Ledger device mo sa pamamagitan ng Ledger Live desktop app.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Kapag ginagamit ang iyong Ledger account sa MetaMask, may bagong tab na magbubukas at hihilingin sa iyong buksan ang Ledger Live app. Kapag nagbukas ang app, hihilingin sa iyong payagan ang isang koneksyon ng WebSocket sa MetaMask account mo. Iyon lang!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Puwede mong i-enable ang Ledger Live support sa pamamagitan ng pag-click sa Mga Setting > Advanced > Gamitin ang Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Update sa Ledger Support para sa Mga Chrome User", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "ng" }, @@ -1419,12 +1448,33 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Magsimula rito" + }, + "recoveryPhraseReminderConfirm": { + "message": "OK" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Palaging panatilihin ang iyong Secret Recovery Phrase sa isang ligtas at lihim na lugar" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Kailangan ulit i-back up ang Secret Recovery Phrase mo?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Huwag kailanman ipaalam sa iba ang iyong Secret Recovery Phrase" + }, + "recoveryPhraseReminderItemTwo": { + "message": "Hindi kailanman hihingin ng MetaMask team ang iyong Secret Recovery Phrase" + }, + "recoveryPhraseReminderSubText": { + "message": "Kinokontrol ng iyong Secret Recovery Phrase ang lahat ng iyong account." + }, + "recoveryPhraseReminderTitle": { + "message": "Protektahan ang iyong pondo!" + }, "reject": { "message": "Tanggihan" }, @@ -1869,7 +1919,7 @@ "message": "Ito ay pagtatantya ng bayarin sa network na gagamitin para kumpletuhin ang iyong pag-swap. Posibleng magbago ang aktuwal na halaga ayon sa mga kundisyon ng network." }, "swapFailedErrorDescriptionWithSupportLink": { - "message": "May mga hindi pagtatagumpay sa transkasyon na nangyayari at narito kami para tumulong. Kung magpapatuloy ang isyung ito, puwede kang makipag-ugnayan sa aming suporta sa customer sa $1 para sa karagdagang tulong.", + "message": "May mga hindi pagtatagumpay sa transaksyon na nangyayari at narito kami para tumulong. Kung magpapatuloy ang isyung ito, puwede kang makipag-ugnayan sa aming suporta sa customer sa $1 para sa karagdagang tulong.", "description": "This message is shown to a user if their swap fails. The $1 will be replaced by support.metamask.io" }, "swapFailedErrorTitle": { @@ -1901,7 +1951,7 @@ "message": "Posibleng hindi magtagumpay ang transaksyon, masyadong mababa ang max na slippage." }, "swapMaxNetworkFeeInfo": { - "message": "Aang “$1” ay ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.", + "message": "“$1” ang pinakamalaking gagastusin mo. Kapag volatile ang network, maaaring malaking halaga ito.", "description": "$1 will be the translation of swapMaxNetworkFees, with the font bolded" }, "swapMaxNetworkFees": { @@ -2050,6 +2100,9 @@ "message": "I-swap ang $1 sa $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Manual na idinagdag ang token na ito." + }, "swapTokenVerificationMessage": { "message": "Palaging kumpirmahin ang address ng token sa $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2317,7 +2370,7 @@ "message": "Kinakailangan ng mga URL ang naaangkop na HTTP/HTTPS prefix." }, "urlExistsErrorMsg": { - "message": "Nasa kasalukuyang listahan ng mga network na ang URL" + "message": "Kasalukuyang ginagamit ng $1 network ang URL na ito." }, "usePhishingDetection": { "message": "Gumamit ng Pag-detect ng Phishing" @@ -2339,6 +2392,10 @@ "message": "I-verify ang token na ito sa $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "I-verify ang token na ito sa $1 at tiyaking ito ang token na gusto mong i-trade.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Tingnan ang Account" }, diff --git a/app/_locales/pl/messages.json b/app/_locales/pl/messages.json index e2f4db68c..209b01e35 100644 --- a/app/_locales/pl/messages.json +++ b/app/_locales/pl/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Twoja przeglądarka nie jest obsługiwana..." }, - "builtInCalifornia": { - "message": "MetaMask został zaprojektowany i stworzony w Kaliforni." - }, "buyWithWyre": { "message": "Kup ETH poprzez Wyre" }, @@ -748,9 +745,6 @@ "recents": { "message": "Ostatnie" }, - "recipientAddress": { - "message": "Adres odbiorcy" - }, "recipientAddressPlaceholder": { "message": "Szukaj, adres publiczny (0x) lub ENS" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index c9f598cba..1d0676ee1 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -43,9 +43,6 @@ "blockiesIdenticon": { "message": "Usar Blockies Identicon" }, - "builtInCalifornia": { - "message": "MetaMask é desenhada e construída na California." - }, "cancel": { "message": "Cancelar" }, @@ -282,9 +279,6 @@ "readdToken": { "message": "Pode adicionar este token de novo clicando na opção “Adicionar token” no menu de opções da sua conta." }, - "recipientAddress": { - "message": "Endereço do Destinatário" - }, "reject": { "message": "Rejeitar" }, diff --git a/app/_locales/pt_BR/messages.json b/app/_locales/pt_BR/messages.json index f6d41ac24..117c704f8 100644 --- a/app/_locales/pt_BR/messages.json +++ b/app/_locales/pt_BR/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "Adicionar contato" }, + "addCustomTokenByContractAddress": { + "message": "Não conseguiu encontrar um token? Cole o endereço para adicionar manualmente qualquer token. Os endereços de contato do token podem ser encontrados em $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Isso permitirá esta rede ser usada dentro do MetaMask." }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "Seu navegador não é compatível..." }, - "builContactList": { + "buildContactList": { "message": "Crie sua lista de contatos" }, - "builtInCalifornia": { - "message": "O MetaMask é projetado e construído na Califórnia." - }, "buy": { "message": "Comprar" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "O ID da chain usado para assinar transações para essa rede." }, + "chainIdExistsErrorMsg": { + "message": "O ID da chain é usado no momento pela rede $1." + }, "chromeRequiredForHardwareWallets": { "message": "Você precisa usar MetaMask no Google Chrome para se conectar com sua carteira de hardware." }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Continuar para o Wyre" }, + "contract": { + "message": "Contrato" + }, "contractAddressError": { "message": "Você está enviando tokens ao endereço de contrato do token. Isso pode resultar na perda destes tokens." }, @@ -626,6 +633,10 @@ "endOfFlowMessage6": { "message": "Se você precisar fazer backup da sua Frase de recuperação secreta novamente, encontre-a em Configurações -> Segurança." }, + "endOfFlowMessage7": { + "message": "Se você tiver alguma pergunta ou vir algo suspeito, entre em contato com o atendimento ao cliente em $1.", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." + }, "endOfFlowMessage8": { "message": "O MetaMask não pode recuperar sua Frase de recuperação secreta." }, @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "Importe uma conta com a Frase de recuperação secreta" }, + "importAccountText": { + "message": "ou $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "Importar token?" + }, + "importTokenWarning": { + "message": "Qualquer pessoa pode criar um token com um nome, incluindo versões falsas de tokens existentes. Adicione e negocie, assumindo o risco sozinho!" + }, "importWallet": { "message": "Importar carteira" }, @@ -961,6 +982,12 @@ "invalidSeedPhrase": { "message": "Frase de recuperação secreta inválida" }, + "ipfsGateway": { + "message": "Gateway IPFS" + }, + "ipfsGatewayDescription": { + "message": "Informe o URL do gateway de CID do IPFS para usar com resolução de conteúdo de ENS." + }, "jsonFile": { "message": "Arquivo JSON", "description": "format for importing an account" @@ -1104,7 +1131,7 @@ "message": "Informe sua senha para confirmar que é você mesmo!" }, "mustSelectOne": { - "message": "Selecione pelo menos 1 token." + "message": "Selecione pelo menos 1 token." }, "myAccounts": { "message": "Minhas contas" @@ -1281,8 +1308,24 @@ "message": "A sua \"Frase Semente\" agora é chamada de sua \"Frase Secreta de Recuperação.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "A partir do Chrome versão 91, a API que permitia nosso suporte ao Ledger (U2F) não é mais compatível com carteiras de hardware. O MetaMask implementou um novo suporte ao Ledger Live que permite continuar conectando o seu dispositivo Ledger device por meio do aplicativo de desktop Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Ao interagir com sua conta do Ledger no MetaMask, uma nova aba será aberta e você deverá abrir o aplicativo Ledger Live. Quando o aplicativo for aberto, você precisará permitir uma conexão do WebSocket com sua conta do MetaMask. É isso!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Você pode habilitar o suporte do Ledger Live clicando em Configurações > Avançadas > Usar Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Atualização de suporte do Ledger para usuários do Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { - "message": " de " + "message": "de" }, "off": { "message": "Desativado" @@ -1405,12 +1448,33 @@ "recents": { "message": "Recentes" }, - "recipientAddress": { - "message": "Endereço do destinatário" - }, "recipientAddressPlaceholder": { "message": "Busca, endereço público (0x) ou ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Iniciar aqui" + }, + "recoveryPhraseReminderConfirm": { + "message": "Entendi" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Mantenha sempre o sigilo e proteja a sua Frase de Recuperação Secreta." + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Precisa fazer backup da sua Frase de recuperação Secreta novamente?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Nunca compartilhe a sua Frase de recuperação secreta com ninguém" + }, + "recoveryPhraseReminderItemTwo": { + "message": "A equipe do MetaMask jamais pedirá sua Frase de recuperação secreta." + }, + "recoveryPhraseReminderSubText": { + "message": "Sua Frase de recuperação secreta controla todas as suas contas." + }, + "recoveryPhraseReminderTitle": { + "message": "Proteja seu dinheiro" + }, "reject": { "message": "Rejeitar" }, @@ -1459,6 +1523,16 @@ "restoreAccountWithSeed": { "message": "Restaure sua conta com a Frase de recuperação secreta" }, + "restoreWalletPreferences": { + "message": "Encontramos um backup dos seus dados de $1. Gostaria de restaurar as preferências da sua carteira?", + "description": "$1 is the date at which the data was backed up" + }, + "retryTransaction": { + "message": "Refazer transação" + }, + "reusedTokenNameWarning": { + "message": "O token aqui reutiliza um símbolo de outro token que você observa; isso pode causar confusões ou induzir ao erro." + }, "revealSeedWords": { "message": "Revelar Frase de recuperação secreta" }, @@ -1528,6 +1602,42 @@ "securitySettingsDescription": { "message": "Configurações de privacidade e Frase de recuperação secreta" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "Anote e guarde em vários locais secretos." + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "Salve em um gerenciador de senhas" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "Guarde dentro de um cofre." + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "Guarde em um cofre-forte bancário." + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "A sua frase de recuperação é a “chave-mestra” para sua carteira e seus fundos." + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "Caso alguém lhe peça a sua frase de recuperação, essa pessoa provavelmente está tentando dar um golpe em você." + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "Jamais compartilhe a sua frase de recuperação, mesmo com o MetaMask!" + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "O que é uma frase de recuperação?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "Devo compartilhar minha frase de recuperação?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "Como salvo minha frase de recuperação?" + }, + "seedPhraseIntroTitle": { + "message": "Proteger sua carteira" + }, + "seedPhraseIntroTitleCopy": { + "message": "Antes de iniciar, assista esse vídeo curto para aprender sobre sua frase de recuperação e sobre como manter sua carteira segura." + }, "seedPhrasePlaceholder": { "message": "Separe cada palavra com um único espaço" }, @@ -1990,6 +2100,9 @@ "message": "Swap $1 para $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Este token foi adicionado manualmente." + }, "swapTokenVerificationMessage": { "message": "Sempre confirme o endereço do token em $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2257,7 +2370,7 @@ "message": "Os URLs precisam do prefixo HTTP/HTTPS adequado." }, "urlExistsErrorMsg": { - "message": "O URL já está presente na lista de redes existente" + "message": "O ID da chain é usado no momento pela rede $1." }, "usePhishingDetection": { "message": "Usar detecção de phishing" @@ -2279,6 +2392,10 @@ "message": "Verificar este token em $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Verifique este token em $1 garanta que seja o token que você deseja negociar.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Exibir conta" }, @@ -2312,6 +2429,10 @@ "walletSeedRestore": { "message": "Frase de recuperação secreta da carteira" }, + "web3ShimUsageNotification": { + "message": "Percebemos que o site atual tentou usar a API window.web3 removida. Se o site parecer estar corrompido, clique em $1 para obter mais informações.", + "description": "$1 is a clickable link." + }, "welcome": { "message": "Bem-vindo(a) ao MetaMask" }, diff --git a/app/_locales/ro/messages.json b/app/_locales/ro/messages.json index 444ba049f..2ba1a3a60 100644 --- a/app/_locales/ro/messages.json +++ b/app/_locales/ro/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Browserul dvs. nu este compatibil..." }, - "builtInCalifornia": { - "message": "MetaMask este concepută și creată în California." - }, "buyWithWyre": { "message": "Cumpărați ETH cu Wyre" }, @@ -741,9 +738,6 @@ "recents": { "message": "Recente" }, - "recipientAddress": { - "message": "Adresă destinatar" - }, "recipientAddressPlaceholder": { "message": "Căutare, adresa publică (0x) sau ENS" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 02dfbd8bb..233d76216 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "Добавить контакт" }, + "addCustomTokenByContractAddress": { + "message": "Невозможно найти токен? Вы можете вручную добавить любой токен, вставив его адрес. Контактные адреса токена можно найти на $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Это позволит использовать ее в MetaMask." }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "Ваш браузер не поддерживается..." }, - "builContactList": { + "buildContactList": { "message": "Создайте список контактов" }, - "builtInCalifornia": { - "message": "MetaMask разработан и построен в Калифорнии." - }, "buy": { "message": "Купить" }, @@ -280,11 +281,14 @@ "message": "Отменено" }, "chainId": { - "message": "Идентификатор цепи" + "message": "Идентификатор цепочки" }, "chainIdDefinition": { "message": "Идентификатор цепочки, используемый для подписания транзакций для этой сети." }, + "chainIdExistsErrorMsg": { + "message": "Этот идентификатор цепочки в настоящее время используется сетью $1." + }, "chromeRequiredForHardwareWallets": { "message": "Вам необходимо использовать MetaMask в Google Chrome, чтобы подключиться к аппаратному кошельку." }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Продолжить к Wyre" }, + "contract": { + "message": "Контракт" + }, "contractAddressError": { "message": "Вы отправляете токены на адрес контракта токена. Это может привести к потере токенов." }, @@ -626,6 +633,10 @@ "endOfFlowMessage6": { "message": "Если вам нужно снова создать резервную копию секретной фразы восстановления, вы можете найти ее в Настройки -> Безопасность." }, + "endOfFlowMessage7": { + "message": "Если у вас возникнут вопросы или вы увидите что-то подозрительное, обратитесь в службу поддержки $1.", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." + }, "endOfFlowMessage8": { "message": "Просто помните, что MetaMask не может восстановить секретную фразу восстановления." }, @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "Импортировать счет с секретной фразой восстановления" }, + "importAccountText": { + "message": "или $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "Импортировать токен?" + }, + "importTokenWarning": { + "message": "Кто угодно может создать токен с любым именем, включая поддельные версии существующих токенов. Добавляйте и торгуйте на свой страх и риск!" + }, "importWallet": { "message": "Импортировать кошелек" }, @@ -1287,6 +1308,22 @@ "message": "Исходная фраза теперь называется секретной фразой восстановления.", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Начиная с Chrome версии 91, API, обеспечивающий поддержку нашего Ledger (U2F), аппаратные кошельки больше не поддерживаются. MetaMask реализовала новую поддержку Ledger Live, которая позволяет продолжать подключаться к устройству Ledger через настольное приложение Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "При взаимодействии с вашим счетом Ledger в MetaMask откроется новая вкладка, и вам будет предложено открыть приложение Ledger Live. Когда приложение откроется, вам будет предложено разрешить WebSocket-соединение с вашим счетом MetaMask. Вот и все!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Вы можете включить поддержку Ledger Live, нажав «Настройки» > «Дополнительно» > «Использовать Ledger Live».", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Обновление поддержки Ledger для пользователей Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "из" }, @@ -1411,12 +1448,33 @@ "recents": { "message": "Недавние" }, - "recipientAddress": { - "message": "Адрес получателя" - }, "recipientAddressPlaceholder": { "message": "Поиск, публичный адрес (0x) или ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Начать здесь" + }, + "recoveryPhraseReminderConfirm": { + "message": "Понятно" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Всегда храните свою секретную фразу восстановления в надежном и секретном месте" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Нужно снова сделать резервную копию секретной фразы восстановления?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Никогда никому не сообщайте свою секретную фразу восстановления" + }, + "recoveryPhraseReminderItemTwo": { + "message": "Команда MetaMask никогда неожиданно не запросит вашу секретную фразу восстановления" + }, + "recoveryPhraseReminderSubText": { + "message": "Ваша секретная фраза восстановления контролирует все ваши счета." + }, + "recoveryPhraseReminderTitle": { + "message": "Защитите свои активы" + }, "reject": { "message": "Отклонить" }, @@ -1476,7 +1534,7 @@ "message": "Токен здесь повторно использует символ из другого токена, который вы смотрите, это может запутать или ввести в заблуждение." }, "revealSeedWords": { - "message": "Показать секретную фразу восстановления" + "message": "Раскрыть секретную фразу восстановления" }, "revealSeedWordsDescription": { "message": "Если вы меняете браузер или переходите на другой компьютер, вам понадобится эта секретная фраза восстановления для доступа к своим счетам. Сохраните ее в безопасном секретном месте." @@ -1544,6 +1602,42 @@ "securitySettingsDescription": { "message": "Настройки конфиденциальности и секретная фраза восстановления кошелька" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "Запишите и храните в нескольких секретных местах." + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "В диспетчере паролей." + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "В банковской ячейке." + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "В банковском сейфе." + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "Фраза восстановления — это главный ключ к кошельку и средствам в нем." + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "Если кто-нибудь интересуется вашей фразой восстановления, этот человек, скорее всего, пытается вас обмануть." + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "Не сообщайте свою фразу восстановления никому, даже сотрудникам MetaMask." + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "Что такое фраза восстановления?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "Можно ли сообщать кому-либо свою фразу восстановления?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "Как хранить фразу восстановления?" + }, + "seedPhraseIntroTitle": { + "message": "Защитите свой кошелек" + }, + "seedPhraseIntroTitleCopy": { + "message": "Прежде чем приступить к работе, посмотрите это короткое видео о том, что такое фраза восстановления и как обезопасить кошелек." + }, "seedPhrasePlaceholder": { "message": "Отделяйте каждое слово одним пробелом" }, @@ -2006,6 +2100,9 @@ "message": "Своп $1 на $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Этот токен был добавлен вручную." + }, "swapTokenVerificationMessage": { "message": "Всегда проверяйте адрес токена на $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2273,7 +2370,7 @@ "message": "Для URL требуется соответствующий префикс HTTP/HTTPS." }, "urlExistsErrorMsg": { - "message": "URL уже присутствует в имеющемся списке сетей" + "message": "Это URL в настоящее время используется сетью $1." }, "usePhishingDetection": { "message": "Использовать обнаружение фишинга" @@ -2295,6 +2392,10 @@ "message": "Проверить этот токен на $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Проверьте этот токен на $1 и убедитесь, что это тот токен, которым вы хотите торговать.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Посмотреть счет" }, @@ -2328,6 +2429,10 @@ "walletSeedRestore": { "message": "Секретная фраза восстановления кошелька" }, + "web3ShimUsageNotification": { + "message": "Мы заметили, что текущий веб-сайт пытался использовать удаленный API window.web3. Если сайт не работает, нажмите $1 для получения дополнительной информации.", + "description": "$1 is a clickable link." + }, "welcome": { "message": "Добро пожаловать в MetaMask" }, diff --git a/app/_locales/sk/messages.json b/app/_locales/sk/messages.json index e23dfb73e..2cf89a50b 100644 --- a/app/_locales/sk/messages.json +++ b/app/_locales/sk/messages.json @@ -137,9 +137,6 @@ "browserNotSupported": { "message": "Váš prehliadač nie je podporovaný..." }, - "builtInCalifornia": { - "message": "MetaMask je navržen a vytvořen v Kalifornii." - }, "buyWithWyre": { "message": "Kúpte ETH s Wyre" }, @@ -723,9 +720,6 @@ "recents": { "message": "Posledné" }, - "recipientAddress": { - "message": "Adresa příjemce" - }, "recipientAddressPlaceholder": { "message": "Vyhľadávať verejnú adresu (0x) alebo ENS" }, diff --git a/app/_locales/sl/messages.json b/app/_locales/sl/messages.json index 47a5a1737..d4de42b88 100644 --- a/app/_locales/sl/messages.json +++ b/app/_locales/sl/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Vaš brskalnik ni podptrt ..." }, - "builtInCalifornia": { - "message": "MetaMask je zasnovan in ustvarjen v Kaliforniji." - }, "buyWithWyre": { "message": "Kupi ETH z Wyre" }, @@ -742,9 +739,6 @@ "recents": { "message": "Nedavno" }, - "recipientAddress": { - "message": "Prejemnikov naslov" - }, "recipientAddressPlaceholder": { "message": "Iskanje, javni naslov (0x) ali ENS" }, diff --git a/app/_locales/sr/messages.json b/app/_locales/sr/messages.json index 7cbcdf175..8170b146a 100644 --- a/app/_locales/sr/messages.json +++ b/app/_locales/sr/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Vaš pregledač nije podržan..." }, - "builtInCalifornia": { - "message": "MetaMask je dizajniran i izgrađen u Kaliforniji." - }, "buyWithWyre": { "message": "Kupite ETH preko servisa Wyre" }, @@ -745,9 +742,6 @@ "recents": { "message": "Skorašnje" }, - "recipientAddress": { - "message": "Adresa primaoca" - }, "recipientAddressPlaceholder": { "message": "Pretraga, javna adresa (0x) ili ENS" }, diff --git a/app/_locales/sv/messages.json b/app/_locales/sv/messages.json index bfa2e92f6..8fbe73469 100644 --- a/app/_locales/sv/messages.json +++ b/app/_locales/sv/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Din webbläsare stöds inte..." }, - "builtInCalifornia": { - "message": "MetaMask är skapat och utformat i Kalifornien." - }, "buyWithWyre": { "message": "Köp ETH med Wyre" }, @@ -738,9 +735,6 @@ "recents": { "message": "Senaste" }, - "recipientAddress": { - "message": "Mottagaradress" - }, "recipientAddressPlaceholder": { "message": "Sök, allmän adress (0x) eller ENS" }, diff --git a/app/_locales/sw/messages.json b/app/_locales/sw/messages.json index f4d9acc5b..5e493a99f 100644 --- a/app/_locales/sw/messages.json +++ b/app/_locales/sw/messages.json @@ -140,9 +140,6 @@ "browserNotSupported": { "message": "Kivinjari chaku hakiwezeshwi..." }, - "builtInCalifornia": { - "message": "MetaMask imeundwa na kutengenezwa California." - }, "buyWithWyre": { "message": "Nunua ETH kwa kutumia Wyre" }, @@ -732,9 +729,6 @@ "recents": { "message": "Za hivi karibuni" }, - "recipientAddress": { - "message": "Anwani ya Mpokeaji" - }, "recipientAddressPlaceholder": { "message": "Tafuta, anwani za umma (0x), au ENS" }, diff --git a/app/_locales/ta/messages.json b/app/_locales/ta/messages.json index 29a239a41..425b104c1 100644 --- a/app/_locales/ta/messages.json +++ b/app/_locales/ta/messages.json @@ -55,9 +55,6 @@ "blockiesIdenticon": { "message": "ப்ளாக்கிஸ் ஐடென்டிகோன் பயன்பாட்டு" }, - "builtInCalifornia": { - "message": "மேடமஸ்க் வடிவமைக்கப்பட்டு கலிபோர்னியாவில் கட்டப்பட்டுள்ளது." - }, "cancel": { "message": "ரத்து செய்" }, @@ -372,9 +369,6 @@ "readdToken": { "message": "உங்கள் கணக்கு விருப்பங்கள் மெனுவில் \"டோக்கனைச் சேர்\" என்பதன் மூலம் நீங்கள் எதிர்காலத்தில் இந்த டோக்கனை மீண்டும் சேர்க்கலாம்." }, - "recipientAddress": { - "message": "பெறுநர் முகவரி" - }, "reject": { "message": "நிராகரி" }, diff --git a/app/_locales/th/messages.json b/app/_locales/th/messages.json index 324722400..3c193a838 100644 --- a/app/_locales/th/messages.json +++ b/app/_locales/th/messages.json @@ -49,9 +49,6 @@ "blockiesIdenticon": { "message": "ใช้งาน Blockies Identicon" }, - "builtInCalifornia": { - "message": "MetaMask ออกแบบและพัฒนาที่แคลิฟอร์เนีย" - }, "cancel": { "message": "ยกเลิก" }, @@ -375,9 +372,6 @@ "readdToken": { "message": "คุณสามารถเพิ่มโทเค็นนี้ในอนาคตได้โดยไปที่ “เพิ่มโทเค็น” ในเมนูตัวเลือกบัญชีของคุณ" }, - "recipientAddress": { - "message": "แอดแดรสผู้รับ" - }, "reject": { "message": "ปฏิเสธ" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index caf6059fd..af413eadd 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -211,9 +211,6 @@ "browserNotSupported": { "message": "Hindi sinusuportahan ang iyong Browser..." }, - "builtInCalifornia": { - "message": "Ang MetaMask ay idinisenyo at binuo sa California." - }, "buy": { "message": "Bilhin" }, @@ -1192,9 +1189,6 @@ "recents": { "message": "Mga Kamakailan" }, - "recipientAddress": { - "message": "Address ng Tatanggap" - }, "recipientAddressPlaceholder": { "message": "Maghanap, pampublikong address (0x), o ENS" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index d47b570ad..3c4bdebf7 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -46,9 +46,6 @@ "blockiesIdenticon": { "message": "Blockies Identicon kullan" }, - "builtInCalifornia": { - "message": "MetaMask California'da tasarlandı ve yaratıldı" - }, "cancel": { "message": "Vazgeç" }, @@ -324,9 +321,6 @@ "readdToken": { "message": "Gelecekte Bu jetonu hesap seçenekleri menüsünde “Jeton ekle”'ye giderek geri ekleyebilirsiniz." }, - "recipientAddress": { - "message": "Alıcı adresi" - }, "reject": { "message": "Reddetmek" }, diff --git a/app/_locales/uk/messages.json b/app/_locales/uk/messages.json index 27766159a..290378224 100644 --- a/app/_locales/uk/messages.json +++ b/app/_locales/uk/messages.json @@ -143,9 +143,6 @@ "browserNotSupported": { "message": "Ваш браузер не підтримується..." }, - "builtInCalifornia": { - "message": "MetaMask розроблено й створено в Каліфорнії." - }, "buyWithWyre": { "message": "Купити ETH через Wyre" }, @@ -754,9 +751,6 @@ "recents": { "message": "Останні" }, - "recipientAddress": { - "message": "Адреса отримувача" - }, "recipientAddressPlaceholder": { "message": "Пошук, публічна адреса (0x), або ENS" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index c2955653b..ca3ca9c66 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -52,6 +52,10 @@ "addContact": { "message": "Thêm người liên hệ" }, + "addCustomTokenByContractAddress": { + "message": "Bạn không tìm thấy token? Bạn có thể dán địa chỉ của bất kỳ token nào để thêm token đó theo cách thủ công. Bạn có thể tìm thấy địa chỉ hợp đồng token trên $1.", + "description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum" + }, "addEthereumChainConfirmationDescription": { "message": "Thao tác này sẽ cho phép sử dụng mạng này trong MetaMask." }, @@ -249,12 +253,9 @@ "browserNotSupported": { "message": "Trình duyệt của bạn không được hỗ trợ..." }, - "builContactList": { + "buildContactList": { "message": "Xây dựng danh sách liên hệ của bạn" }, - "builtInCalifornia": { - "message": "MetaMask được thiết kế và phát triển tại California." - }, "buy": { "message": "Mua" }, @@ -285,6 +286,9 @@ "chainIdDefinition": { "message": "Mã chuỗi được dùng để ký các giao dịch cho mạng này." }, + "chainIdExistsErrorMsg": { + "message": "Mạng $1 hiện đang sử dụng mã chuỗi này." + }, "chromeRequiredForHardwareWallets": { "message": "Bạn cần sử dụng MetaMask trên Google Chrome để kết nối với Ví cứng của bạn." }, @@ -410,6 +414,9 @@ "continueToWyre": { "message": "Tiếp tục chuyển đến Wyre" }, + "contract": { + "message": "Hợp đồng" + }, "contractAddressError": { "message": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể khiến bạn bị mất những token này." }, @@ -624,7 +631,11 @@ "message": "Hãy cẩn thận với hoạt động lừa đảo! MetaMask sẽ không bao giờ tự ý hỏi Cụm mật khẩu khôi phục bí mật của bạn." }, "endOfFlowMessage6": { - "message": "Nếu bạn cần sao lưu lại Cụm mật khẩu khôi phục bí mật, bạn có thể tìm thấy chức năng này trong Cài đặt -> Bảo mật." + "message": "Nếu bạn cần sao lưu lại Cụm mật khẩu khôi phục bí mật, bạn có thể tìm thấy chức năng này trong phần Cài đặt -> Bảo mật." + }, + "endOfFlowMessage7": { + "message": "Nếu bạn có thắc mắc hoặc thấy điều gì đó đáng ngờ, hãy liên hệ với bộ phận hỗ trợ của chúng tôi $1.", + "description": "$1 is a clickable link with text defined by the 'here' key. The link will open to a form where users can file support tickets." }, "endOfFlowMessage8": { "message": "MetaMask không thể khôi phục Cụm mật khẩu khôi phục bí mật của bạn." @@ -885,6 +896,16 @@ "importAccountSeedPhrase": { "message": "Nhập một tài khoản bằng Cụm mật khẩu khôi phục bí mật" }, + "importAccountText": { + "message": "hoặc $1", + "description": "$1 represents the text from `importAccountLinkText` as a link" + }, + "importTokenQuestion": { + "message": "Bạn muốn nhập token?" + }, + "importTokenWarning": { + "message": "Bất kỳ ai cũng tạo được token bằng bất kỳ tên nào, kể cả phiên bản giả của token hiện có. Bạn tự chịu rủi ro khi thêm và giao dịch!" + }, "importWallet": { "message": "Nhập ví" }, @@ -1287,6 +1308,22 @@ "message": "Từ giờ, \"Cụm mật khẩu gốc\" sẽ được gọi là \"Cụm mật khẩu khôi phục bí mật.\"", "description": "Description of a notification in the 'See What's New' popup. Describes the seed phrase wording update." }, + "notifications6DescriptionOne": { + "message": "Kể từ phiên bản Chrome 91, API từng cho phép hỗ trợ Ledger (U2F) của chúng tôi không còn hỗ trợ ví cứng nữa. MetaMask đã triển khai một tính năng hỗ trợ Ledger Live mới cho phép bạn tiếp tục kết nối với thiết bị Ledger của mình thông qua ứng dụng Ledger Live trên máy tính.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionThree": { + "message": "Khi tương tác với tài khoản Ledger của bạn trong MetaMask, một tab mới sẽ mở ra và bạn sẽ được yêu cầu mở ứng dụng Ledger Live. Khi ứng dụng này mở ra, bạn sẽ được yêu cầu cho phép kết nối WebSocket với tài khoản MetaMask của mình. Đơn giản vậy thôi!", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6DescriptionTwo": { + "message": "Bạn có thể kích hoạt tính năng hỗ trợ Ledger Live bằng cách nhấp vào phần Cài đặt > Nâng cao > Sử dụng Ledger Live.", + "description": "Description of a notification in the 'See What's New' popup. Describes the Ledger support update." + }, + "notifications6Title": { + "message": "Thông tin cập nhật về việc hỗ trợ Ledger cho người dùng Chrome", + "description": "Title for a notification in the 'See What's New' popup. Lets users know about the Ledger support update" + }, "ofTextNofM": { "message": "trên" }, @@ -1411,12 +1448,33 @@ "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" }, + "recoveryPhraseReminderBackupStart": { + "message": "Bắt đầu tại đây" + }, + "recoveryPhraseReminderConfirm": { + "message": "Đã hiểu" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Luôn lưu giữ Cụm mật khẩu khôi phục bí mật ở nơi an toàn và bí mật" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Bạn cần sao lưu lại Cụm mật khẩu khôi phục bí mật?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Tuyệt đối không cho ai biết Cụm mật khẩu khôi phục bí mật" + }, + "recoveryPhraseReminderItemTwo": { + "message": "Nhóm MetaMask sẽ không bao giờ hỏi Cụm mật khẩu khôi phục bí mật của bạn" + }, + "recoveryPhraseReminderSubText": { + "message": "Cụm mật khẩu khôi phục bí mật sẽ kiểm soát mọi thứ trong tài khoản của bạn." + }, + "recoveryPhraseReminderTitle": { + "message": "Bảo vệ tiền của bạn" + }, "reject": { "message": "Từ chối" }, @@ -1442,7 +1500,7 @@ "message": "Xóa tài khoản" }, "removeAccountDescription": { - "message": "Tài khoản này sẽ được xóa khỏi ví của bạn. Xin đảm bảo rằng bạn có Cụm mật khẩu khôi phục bí mật ban đầu hoặc khóa riêng tư cho tài khoản được nhập trước khi tiếp tục. Bạn có thể nhập hoặc tạo lại tài khoản từ trình đơn tài khoản thả xuống. " + "message": "Tài khoản này sẽ được xóa khỏi ví của bạn. Hãy đảm bảo rằng bạn có Cụm mật khẩu khôi phục bí mật ban đầu hoặc khóa riêng tư cho tài khoản được nhập trước khi tiếp tục. Bạn có thể nhập hoặc tạo lại tài khoản từ trình đơn tài khoản thả xuống. " }, "requestsAwaitingAcknowledgement": { "message": "yêu cầu đang chờ xác nhận" @@ -1476,10 +1534,10 @@ "message": "Một token trong đây sử dụng lại ký hiệu của một token khác mà bạn thấy, điều này có thể gây nhầm lẫn hoặc mang tính lừa dối." }, "revealSeedWords": { - "message": "Hiện cụm mật khẩu khôi phục bí mật" + "message": "Hiện Cụm mật khẩu khôi phục bí mật" }, "revealSeedWordsDescription": { - "message": "Nếu thay đổi trình duyệt hoặc chuyển máy tính, bạn sẽ cần Cụm mật khẩu khôi phục bí mật này để truy cập tài khoản của mình. Hãy lưu cụm mật khẩu gốc này ở nơi an toàn và bí mật." + "message": "Nếu thay đổi trình duyệt hoặc chuyển máy tính, bạn sẽ cần Cụm mật khẩu khôi phục bí mật này để truy cập tài khoản của mình. Hãy lưu Cụm mật khẩu khôi phục bí mật này ở nơi an toàn và bí mật." }, "revealSeedWordsTitle": { "message": "Cụm mật khẩu khôi phục bí mật" @@ -1544,6 +1602,42 @@ "securitySettingsDescription": { "message": "Các cài đặt quyền riêng tư và Cụm mật khẩu khôi phục bí mật của ví" }, + "seedPhraseIntroSidebarBulletFour": { + "message": "Viết ra và cất ở nhiều nơi bí mật." + }, + "seedPhraseIntroSidebarBulletOne": { + "message": "Lưu trong một trình quản lý mật khẩu" + }, + "seedPhraseIntroSidebarBulletThree": { + "message": "Lưu giữ trong hộp ký gửi an toàn." + }, + "seedPhraseIntroSidebarBulletTwo": { + "message": "Lưu giữ trong két an toàn." + }, + "seedPhraseIntroSidebarCopyOne": { + "message": "Cụm mật khẩu khôi phục bí mật là “chìa khóa chính” để truy cập ví và số tiền của bạn." + }, + "seedPhraseIntroSidebarCopyThree": { + "message": "Nếu ai đó hỏi bạn cụm mật khẩu khôi phục bí mật, thì họ đang cố gắng lừa đảo bạn." + }, + "seedPhraseIntroSidebarCopyTwo": { + "message": "Đừng bao giờ cho ai biết cụm mật khẩu khôi phục bí mật, kể cả MetaMask!" + }, + "seedPhraseIntroSidebarTitleOne": { + "message": "Cụm mật khẩu khôi phục là gì?" + }, + "seedPhraseIntroSidebarTitleThree": { + "message": "Tôi có nên cho ai biết cụm mật khẩu khôi phục bí mật của mình không?" + }, + "seedPhraseIntroSidebarTitleTwo": { + "message": "Tôi lưu cụm mật khẩu khôi phục của mình bằng cách nào?" + }, + "seedPhraseIntroTitle": { + "message": "Bảo mật cho ví của bạn" + }, + "seedPhraseIntroTitleCopy": { + "message": "Trước khi bắt đầu, hãy xem video ngắn này để tìm hiểu thêm về cụm mật khẩu khôi phục bí mật của bạn và cách bảo vệ ví của bạn." + }, "seedPhrasePlaceholder": { "message": "Phân tách mỗi từ bằng một dấu cách" }, @@ -1844,7 +1938,7 @@ "message": "Đang hoàn tất..." }, "swapFromTo": { - "message": "Hoán đổi $1 sang $2", + "message": "Giao dịch hoán đổi $1 sang $2", "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" }, "swapGasFeesSplit": { @@ -2006,6 +2100,9 @@ "message": "Hoán đổi $1 sang $2", "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." }, + "swapTokenVerificationAddedManually": { + "message": "Token này đã được thêm theo cách thủ công." + }, "swapTokenVerificationMessage": { "message": "Luôn xác nhận địa chỉ token trên $1.", "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." @@ -2273,7 +2370,7 @@ "message": "URL phải có tiền tố HTTP/HTTPS phù hợp." }, "urlExistsErrorMsg": { - "message": "URL đã có trong danh sách mạng hiện tại" + "message": "Mạng $1 hiện đang sử dụng URL này." }, "usePhishingDetection": { "message": "Sử dụng tính năng Phát hiện lừa đảo" @@ -2295,6 +2392,10 @@ "message": "Xác minh token này trên $1", "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" }, + "verifyThisUnconfirmedTokenOn": { + "message": "Hãy xác minh token này trên $1 và đảm bảo đây là token bạn muốn giao dịch.", + "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" + }, "viewAccount": { "message": "Xem tài khoản" }, @@ -2328,6 +2429,10 @@ "walletSeedRestore": { "message": "Cụm mật khẩu khôi phục bí mật của ví" }, + "web3ShimUsageNotification": { + "message": "Chúng tôi nhận thấy rằng trang web hiện tại đã cố dùng API window.web3 đã bị xóa. Nếu trang web có vẻ như đã bị lỗi, vui lòng nhấp vào $1 để biết thêm thông tin.", + "description": "$1 is a clickable link." + }, "welcome": { "message": "Chào mừng bạn đến với MetaMask" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index cb1950b2c..3eb07cf4e 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -217,9 +217,6 @@ "browserNotSupported": { "message": "您的浏览器不支持该功能……" }, - "builtInCalifornia": { - "message": "MetaMask在加利福尼亚设计和制造。" - }, "buy": { "message": "购买" }, @@ -1195,9 +1192,6 @@ "recents": { "message": "最近记录" }, - "recipientAddress": { - "message": "接收地址" - }, "recipientAddressPlaceholder": { "message": "查找、公用地址 (0x) 或 ENS" }, diff --git a/app/_locales/zh_TW/messages.json b/app/_locales/zh_TW/messages.json index bd95f859e..245558800 100644 --- a/app/_locales/zh_TW/messages.json +++ b/app/_locales/zh_TW/messages.json @@ -149,9 +149,6 @@ "browserNotSupported": { "message": "您的瀏覽器尚未支援..." }, - "builtInCalifornia": { - "message": "MetaMask 是在加州設計製造" - }, "buy": { "message": "買" }, @@ -751,9 +748,6 @@ "recents": { "message": "最近" }, - "recipientAddress": { - "message": "接收位址" - }, "recipientAddressPlaceholder": { "message": "搜尋,公開地址 (0x),或 ENS" }, diff --git a/app/images/logo.png b/app/images/logo.png new file mode 100644 index 000000000..cd87ccb43 Binary files /dev/null and b/app/images/logo.png differ diff --git a/app/images/transak.svg b/app/images/transak.svg new file mode 100644 index 000000000..8f8d7790a --- /dev/null +++ b/app/images/transak.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/scripts/constants/on-ramp.js b/app/scripts/constants/on-ramp.js new file mode 100644 index 000000000..335d7a9ad --- /dev/null +++ b/app/scripts/constants/on-ramp.js @@ -0,0 +1 @@ +export const TRANSAK_API_KEY = '25ac1309-a49b-4411-b20e-5e56c61a5b1c'; // It's a public key, which will be included in a URL for Transak. diff --git a/app/scripts/controllers/detect-tokens.test.js b/app/scripts/controllers/detect-tokens.test.js index 3b5eddd24..7bd788271 100644 --- a/app/scripts/controllers/detect-tokens.test.js +++ b/app/scripts/controllers/detect-tokens.test.js @@ -11,7 +11,7 @@ import PreferencesController from './preferences'; describe('DetectTokensController', function () { const sandbox = sinon.createSandbox(); - let keyringMemStore, network, preferences; + let keyringMemStore, network, preferences, provider; const noop = () => undefined; @@ -23,12 +23,19 @@ describe('DetectTokensController', function () { keyringMemStore = new ObservableStore({ isUnlocked: false }); network = new NetworkController(); network.setInfuraProjectId('foo'); - preferences = new PreferencesController({ network }); + network.initializeProvider(networkControllerProviderConfig); + provider = network.getProviderAndBlockTracker().provider; + preferences = new PreferencesController({ network, provider }); preferences.setAddresses([ '0x7e57e2', '0xbc86727e770de68b1060c91f6bb6945c73e10388', ]); - network.initializeProvider(networkControllerProviderConfig); + sandbox + .stub(network, 'getLatestBlock') + .callsFake(() => Promise.resolve({})); + sandbox + .stub(preferences, '_detectIsERC721') + .returns(Promise.resolve(false)); }); after(function () { @@ -125,6 +132,7 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, ]); }); @@ -177,11 +185,13 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, { address: tokenAddressToAdd.toLowerCase(), decimals: tokenToAdd.decimals, symbol: tokenToAdd.symbol, + isERC721: false, }, ]); }); @@ -234,11 +244,13 @@ describe('DetectTokensController', function () { address: existingTokenAddress.toLowerCase(), decimals: existingToken.decimals, symbol: existingToken.symbol, + isERC721: false, }, { address: tokenAddressToAdd.toLowerCase(), decimals: tokenToAdd.decimals, symbol: tokenToAdd.symbol, + isERC721: false, }, ]); }); diff --git a/app/scripts/controllers/incoming-transactions.js b/app/scripts/controllers/incoming-transactions.js index e5fc03543..4d13532fc 100644 --- a/app/scripts/controllers/incoming-transactions.js +++ b/app/scripts/controllers/incoming-transactions.js @@ -34,8 +34,10 @@ const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); * @typedef {Object} EtherscanTransaction * @property {string} blockNumber - The number of the block this transaction was found in, in decimal * @property {string} from - The hex-prefixed address of the sender - * @property {string} gas - The gas limit, in decimal WEI - * @property {string} gasPrice - The gas price, in decimal WEI + * @property {string} gas - The gas limit, in decimal GWEI + * @property {string} [gasPrice] - The gas price, in decimal WEI + * @property {string} [maxFeePerGas] - The maximum fee per gas, inclusive of tip, in decimal WEI + * @property {string} [maxPriorityFeePerGas] - The maximum tip per gas in decimal WEI * @property {string} hash - The hex-prefixed transaction hash * @property {string} isError - Whether the transaction was confirmed or failed (0 for confirmed, 1 for failed) * @property {string} nonce - The transaction nonce, in decimal @@ -267,6 +269,25 @@ export default class IncomingTransactionsController { etherscanTransaction.isError === '0' ? TRANSACTION_STATUSES.CONFIRMED : TRANSACTION_STATUSES.FAILED; + const txParams = { + from: etherscanTransaction.from, + gas: bnToHex(new BN(etherscanTransaction.gas)), + nonce: bnToHex(new BN(etherscanTransaction.nonce)), + to: etherscanTransaction.to, + value: bnToHex(new BN(etherscanTransaction.value)), + }; + + if (etherscanTransaction.gasPrice) { + txParams.gasPrice = bnToHex(new BN(etherscanTransaction.gasPrice)); + } else if (etherscanTransaction.maxFeePerGas) { + txParams.maxFeePerGas = bnToHex( + new BN(etherscanTransaction.maxFeePerGas), + ); + txParams.maxPriorityFeePerGas = bnToHex( + new BN(etherscanTransaction.maxPriorityFeePerGas), + ); + } + return { blockNumber: etherscanTransaction.blockNumber, id: createId(), @@ -274,14 +295,7 @@ export default class IncomingTransactionsController { metamaskNetworkId: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], status, time, - txParams: { - from: etherscanTransaction.from, - gas: bnToHex(new BN(etherscanTransaction.gas)), - gasPrice: bnToHex(new BN(etherscanTransaction.gasPrice)), - nonce: bnToHex(new BN(etherscanTransaction.nonce)), - to: etherscanTransaction.to, - value: bnToHex(new BN(etherscanTransaction.value)), - }, + txParams, hash: etherscanTransaction.hash, type: TRANSACTION_TYPES.INCOMING, }; diff --git a/app/scripts/controllers/incoming-transactions.test.js b/app/scripts/controllers/incoming-transactions.test.js index f4cac0309..70b511ace 100644 --- a/app/scripts/controllers/incoming-transactions.test.js +++ b/app/scripts/controllers/incoming-transactions.test.js @@ -103,15 +103,34 @@ function getMockBlockTracker() { /** * Returns a transaction object matching the expected format returned * by the Etherscan API - * - * @param {string} [toAddress] - The hex-prefixed address of the recipient - * @param {number} [blockNumber] - The block number for the transaction + * @param {Object} [params] - options bag + * @param {string} [params.toAddress] - The hex-prefixed address of the recipient + * @param {number} [params.blockNumber] - The block number for the transaction + * @param {boolean} [params.useEIP1559] - Use EIP-1559 gas fields + * @param * @returns {EtherscanTransaction} */ -const getFakeEtherscanTransaction = ( +const getFakeEtherscanTransaction = ({ toAddress = MOCK_SELECTED_ADDRESS, blockNumber = 10, -) => { + useEIP1559 = false, + hash = '0xfake', +} = {}) => { + if (useEIP1559) { + return { + blockNumber: blockNumber.toString(), + from: '0xfake', + gas: '0', + maxFeePerGas: '10', + maxPriorityFeePerGas: '1', + hash, + isError: '0', + nonce: '100', + timeStamp: '16000000000000', + to: toAddress, + value: '0', + }; + } return { blockNumber: blockNumber.toString(), from: '0xfake', @@ -243,7 +262,13 @@ describe('IncomingTransactionsController', function () { 200, JSON.stringify({ status: '1', - result: [getFakeEtherscanTransaction()], + result: [ + getFakeEtherscanTransaction(), + getFakeEtherscanTransaction({ + hash: '0xfakeeip1559', + useEIP1559: true, + }), + ], }), ); const updateStateStub = sinon.stub( @@ -263,6 +288,9 @@ describe('IncomingTransactionsController', function () { const actualStateWithoutGenerated = cloneDeep(actualState); delete actualStateWithoutGenerated?.incomingTransactions?.['0xfake']?.id; + delete actualStateWithoutGenerated?.incomingTransactions?.[ + '0xfakeeip1559' + ]?.id; assert.ok( typeof generatedTxId === 'number' && generatedTxId > 0, @@ -290,6 +318,24 @@ describe('IncomingTransactionsController', function () { value: '0x0', }, }, + '0xfakeeip1559': { + blockNumber: '10', + hash: '0xfakeeip1559', + metamaskNetworkId: ROPSTEN_NETWORK_ID, + chainId: ROPSTEN_CHAIN_ID, + status: TRANSACTION_STATUSES.CONFIRMED, + time: 16000000000000000, + type: TRANSACTION_TYPES.INCOMING, + txParams: { + from: '0xfake', + gas: '0x0', + maxFeePerGas: '0xa', + maxPriorityFeePerGas: '0x1', + nonce: '0x64', + to: '0x0101', + value: '0x0', + }, + }, }, incomingTxLastFetchedBlockByChainId: { ...getNonEmptyInitState().incomingTxLastFetchedBlockByChainId, @@ -509,7 +555,11 @@ describe('IncomingTransactionsController', function () { 200, JSON.stringify({ status: '1', - result: [getFakeEtherscanTransaction(NEW_MOCK_SELECTED_ADDRESS)], + result: [ + getFakeEtherscanTransaction({ + toAddress: NEW_MOCK_SELECTED_ADDRESS, + }), + ], }), ); const updateStateStub = sinon.stub( @@ -586,7 +636,9 @@ describe('IncomingTransactionsController', function () { // reply with a valid request for any supported network, so that this test has every opportunity to fail nockEtherscanApiForAllChains({ status: '1', - result: [getFakeEtherscanTransaction(NEW_MOCK_SELECTED_ADDRESS)], + result: [ + getFakeEtherscanTransaction({ toAddress: NEW_MOCK_SELECTED_ADDRESS }), + ], }); const updateStateStub = sinon.stub( incomingTransactionsController.store, @@ -954,7 +1006,9 @@ describe('IncomingTransactionsController', function () { describe('_getNewIncomingTransactions', function () { const ADDRESS_TO_FETCH_FOR = '0xfakeaddress'; - const FETCHED_TX = getFakeEtherscanTransaction(ADDRESS_TO_FETCH_FOR); + const FETCHED_TX = getFakeEtherscanTransaction({ + toAddress: ADDRESS_TO_FETCH_FOR, + }); const mockFetch = sinon.stub().returns( Promise.resolve({ json: () => Promise.resolve({ status: '1', result: [FETCHED_TX] }), @@ -1212,5 +1266,53 @@ describe('IncomingTransactionsController', function () { type: TRANSACTION_TYPES.INCOMING, }); }); + + it('should return the expected data when the tx uses EIP-1559 fields', function () { + const incomingTransactionsController = new IncomingTransactionsController( + { + blockTracker: getMockBlockTracker(), + ...getMockNetworkControllerMethods(ROPSTEN_CHAIN_ID), + preferencesController: getMockPreferencesController(), + initState: getNonEmptyInitState(), + }, + ); + + const result = incomingTransactionsController._normalizeTxFromEtherscan( + { + timeStamp: '4444', + isError: '0', + blockNumber: 333, + from: '0xa', + gas: '11', + maxFeePerGas: '12', + maxPriorityFeePerGas: '1', + nonce: '13', + to: '0xe', + value: '15', + hash: '0xg', + }, + ROPSTEN_CHAIN_ID, + ); + + assert.deepStrictEqual(result, { + blockNumber: 333, + id: 54321, + metamaskNetworkId: ROPSTEN_NETWORK_ID, + chainId: ROPSTEN_CHAIN_ID, + status: TRANSACTION_STATUSES.CONFIRMED, + time: 4444000, + txParams: { + from: '0xa', + gas: '0xb', + maxFeePerGas: '0xc', + maxPriorityFeePerGas: '0x1', + nonce: '0xd', + to: '0xe', + value: '0xf', + }, + hash: '0xg', + type: TRANSACTION_TYPES.INCOMING, + }); + }); }); }); diff --git a/app/scripts/controllers/metametrics.js b/app/scripts/controllers/metametrics.js index c55a97c85..1539cbcba 100644 --- a/app/scripts/controllers/metametrics.js +++ b/app/scripts/controllers/metametrics.js @@ -7,30 +7,6 @@ import { METAMETRICS_BACKGROUND_PAGE_OBJECT, } from '../../../shared/constants/metametrics'; -/** - * Used to determine whether or not to attach a user's metametrics id - * to events that include on-chain data. This helps to prevent identifying - * a user by being able to trace their activity on etherscan/block exploring - */ -const trackableSendCounts = { - 1: true, - 10: true, - 30: true, - 50: true, - 100: true, - 250: true, - 500: true, - 1000: true, - 2500: true, - 5000: true, - 10000: true, - 25000: true, -}; - -export function sendCountIsTrackable(sendCount) { - return Boolean(trackableSendCounts[sendCount]); -} - /** * @typedef {import('../../../shared/constants/metametrics').MetaMetricsContext} MetaMetricsContext * @typedef {import('../../../shared/constants/metametrics').MetaMetricsEventPayload} MetaMetricsEventPayload @@ -48,9 +24,6 @@ export function sendCountIsTrackable(sendCount) { * @property {?boolean} participateInMetaMetrics - The user's preference for * participating in the MetaMetrics analytics program. This setting controls * whether or not events are tracked - * @property {number} metaMetricsSendCount - How many send transactions have - * been tracked through this controller. Used to prevent attaching sensitive - * data that can be traced through on chain data. */ export default class MetaMetricsController { @@ -89,7 +62,6 @@ export default class MetaMetricsController { this.store = new ObservableStore({ participateInMetaMetrics: null, metaMetricsId: null, - metaMetricsSendCount: 0, ...initState, }); @@ -138,10 +110,6 @@ export default class MetaMetricsController { return this.store.getState(); } - setMetaMetricsSendCount(val) { - this.store.updateState({ metaMetricsSendCount: val }); - } - /** * Build the context object to attach to page and track events. * @private @@ -231,11 +199,7 @@ export default class MetaMetricsController { // to be updated to work with the new tracking plan. I think we should use // a config setting for this instead of trying to match the event name const isSendFlow = Boolean(payload.event.match(/^send|^confirm/iu)); - if ( - isSendFlow && - this.state.metaMetricsSendCount && - !sendCountIsTrackable(this.state.metaMetricsSendCount + 1) - ) { + if (isSendFlow) { excludeMetaMetricsId = true; } // If we are tracking sensitive data we will always use the anonymousId diff --git a/app/scripts/controllers/metametrics.test.js b/app/scripts/controllers/metametrics.test.js index e30b6ad5e..bb0d15bed 100644 --- a/app/scripts/controllers/metametrics.test.js +++ b/app/scripts/controllers/metametrics.test.js @@ -84,7 +84,6 @@ function getMockPreferencesStore({ currentLocale = LOCALE } = {}) { function getMetaMetricsController({ participateInMetaMetrics = true, metaMetricsId = TEST_META_METRICS_ID, - metaMetricsSendCount = 0, preferencesStore = getMockPreferencesStore(), networkController = getMockNetworkController(), } = {}) { @@ -106,7 +105,6 @@ function getMetaMetricsController({ initState: { participateInMetaMetrics, metaMetricsId, - metaMetricsSendCount, }, }); } @@ -198,14 +196,6 @@ describe('MetaMetricsController', function () { }); }); - describe('setMetaMetricsSendCount', function () { - it('should update the send count in state', function () { - const metaMetricsController = getMetaMetricsController(); - metaMetricsController.setMetaMetricsSendCount(1); - assert.equal(metaMetricsController.state.metaMetricsSendCount, 1); - }); - }); - describe('trackEvent', function () { it('should not track an event if user is not participating in metametrics', function () { const mock = sinon.mock(segment); @@ -337,61 +327,6 @@ describe('MetaMetricsController', function () { mock.verify(); }); - it('should use anonymousId when metametrics send count is not trackable in send flow', function () { - const mock = sinon.mock(segment); - const metaMetricsController = getMetaMetricsController({ - metaMetricsSendCount: 1, - }); - mock - .expects('track') - .once() - .withArgs({ - event: 'Send Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - test: 1, - ...DEFAULT_EVENT_PROPERTIES, - }, - }); - metaMetricsController.trackEvent({ - event: 'Send Fake Event', - category: 'Unit Test', - properties: { - test: 1, - }, - }); - mock.verify(); - }); - - it('should use user metametrics id when metametrics send count is trackable in send flow', function () { - const mock = sinon.mock(segment); - const metaMetricsController = getMetaMetricsController(); - mock - .expects('track') - .once() - .withArgs({ - event: 'Send Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - test: 1, - ...DEFAULT_EVENT_PROPERTIES, - }, - }); - metaMetricsController.trackEvent( - { - event: 'Send Fake Event', - category: 'Unit Test', - properties: { - test: 1, - }, - }, - { metaMetricsSendCount: 0 }, - ); - mock.verify(); - }); - it('should immediately flush queue if flushImmediately set to true', async function () { const metaMetricsController = getMetaMetricsController(); const flushStub = sinon.stub(segment, 'flush'); diff --git a/app/scripts/controllers/network/network-controller.test.js b/app/scripts/controllers/network/network-controller.test.js index 3417241f6..6c234e852 100644 --- a/app/scripts/controllers/network/network-controller.test.js +++ b/app/scripts/controllers/network/network-controller.test.js @@ -1,11 +1,13 @@ import { strict as assert } from 'assert'; import sinon from 'sinon'; import { getNetworkDisplayName } from './util'; -import NetworkController from './network'; +import NetworkController, { NETWORK_EVENTS } from './network'; describe('NetworkController', function () { describe('controller', function () { let networkController; + let getLatestBlockStub; + let setProviderTypeAndWait; const noop = () => undefined; const networkControllerProviderConfig = { getAccounts: noop, @@ -13,7 +15,21 @@ describe('NetworkController', function () { beforeEach(function () { networkController = new NetworkController(); + getLatestBlockStub = sinon + .stub(networkController, 'getLatestBlock') + .callsFake(() => Promise.resolve({})); networkController.setInfuraProjectId('foo'); + setProviderTypeAndWait = () => + new Promise((resolve) => { + networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { + resolve(); + }); + networkController.setProviderType('mainnet'); + }); + }); + + afterEach(function () { + getLatestBlockStub.reset(); }); describe('#provider', function () { @@ -67,6 +83,59 @@ describe('NetworkController', function () { ); }); }); + + describe('#getEIP1559Compatibility', function () { + it('should return false when baseFeePerGas is not in the block header', async function () { + networkController.initializeProvider(networkControllerProviderConfig); + const supportsEIP1559 = await networkController.getEIP1559Compatibility(); + assert.equal(supportsEIP1559, false); + }); + + it('should return true when baseFeePerGas is in block header', async function () { + networkController.initializeProvider(networkControllerProviderConfig); + getLatestBlockStub.callsFake(() => + Promise.resolve({ baseFeePerGas: '0xa ' }), + ); + const supportsEIP1559 = await networkController.getEIP1559Compatibility(); + assert.equal(supportsEIP1559, true); + }); + + it('should store EIP1559 support in state to reduce calls to getLatestBlock', async function () { + networkController.initializeProvider(networkControllerProviderConfig); + getLatestBlockStub.callsFake(() => + Promise.resolve({ baseFeePerGas: '0xa ' }), + ); + await networkController.getEIP1559Compatibility(); + const supportsEIP1559 = await networkController.getEIP1559Compatibility(); + assert.equal(getLatestBlockStub.calledOnce, true); + assert.equal(supportsEIP1559, true); + }); + + it('should clear stored EIP1559 support when changing networks', async function () { + networkController.initializeProvider(networkControllerProviderConfig); + networkController.consoleThis = true; + getLatestBlockStub.callsFake(() => + Promise.resolve({ baseFeePerGas: '0xa ' }), + ); + await networkController.getEIP1559Compatibility(); + assert.equal( + networkController.networkDetails.getState().EIPS[1559], + true, + ); + getLatestBlockStub.callsFake(() => Promise.resolve({})); + await setProviderTypeAndWait('mainnet'); + assert.equal( + networkController.networkDetails.getState().EIPS[1559], + undefined, + ); + await networkController.getEIP1559Compatibility(); + assert.equal( + networkController.networkDetails.getState().EIPS[1559], + false, + ); + assert.equal(getLatestBlockStub.calledTwice, true); + }); + }); }); describe('utils', function () { diff --git a/app/scripts/controllers/network/network.js b/app/scripts/controllers/network/network.js index 1799f658f..2056cc154 100644 --- a/app/scripts/controllers/network/network.js +++ b/app/scripts/controllers/network/network.js @@ -51,6 +51,10 @@ const defaultProviderConfig = { ...defaultProviderConfigOpts, }; +const defaultNetworkDetailsState = { + EIPS: { 1559: undefined }, +}; + export const NETWORK_EVENTS = { // Fired after the actively selected network is changed NETWORK_DID_CHANGE: 'networkDidChange', @@ -74,10 +78,21 @@ export default class NetworkController extends EventEmitter { this.providerStore.getState(), ); this.networkStore = new ObservableStore('loading'); + // We need to keep track of a few details about the current network + // Ideally we'd merge this.networkStore with this new store, but doing so + // will require a decent sized refactor of how we're accessing network + // state. Currently this is only used for detecting EIP 1559 support but + // can be extended to track other network details. + this.networkDetails = new ObservableStore( + opts.networkDetails || { + ...defaultNetworkDetailsState, + }, + ); this.store = new ComposedStore({ provider: this.providerStore, previousProviderStore: this.previousProviderStore, network: this.networkStore, + networkDetails: this.networkDetails, }); // provider and block tracker @@ -120,6 +135,42 @@ export default class NetworkController extends EventEmitter { return { provider, blockTracker }; } + /** + * Method to return the latest block for the current network + * @returns {Object} Block header + */ + getLatestBlock() { + return new Promise((resolve, reject) => { + const { provider } = this.getProviderAndBlockTracker(); + const ethQuery = new EthQuery(provider); + ethQuery.sendAsync( + { method: 'eth_getBlockByNumber', params: ['latest', false] }, + (err, block) => { + if (err) { + return reject(err); + } + return resolve(block); + }, + ); + }); + } + + /** + * Method to check if the block header contains fields that indicate EIP 1559 + * support (baseFeePerGas). + * @returns {Promise} true if current network supports EIP 1559 + */ + async getEIP1559Compatibility() { + const { EIPS } = this.networkDetails.getState(); + if (EIPS[1559] !== undefined) { + return EIPS[1559]; + } + const latestBlock = await this.getLatestBlock(); + const supportsEIP1559 = latestBlock.baseFeePerGas !== undefined; + this.setNetworkEIPSupport(1559, supportsEIP1559); + return supportsEIP1559; + } + verifyNetwork() { // Check network when restoring connectivity: if (this.isNetworkLoading()) { @@ -135,6 +186,26 @@ export default class NetworkController extends EventEmitter { this.networkStore.putState(network); } + /** + * Set EIP support indication in the networkDetails store + * @param {number} EIPNumber - The number of the EIP to mark support for + * @param {boolean} isSupported - True if the EIP is supported + */ + setNetworkEIPSupport(EIPNumber, isSupported) { + this.networkDetails.updateState({ + EIPS: { + [EIPNumber]: isSupported, + }, + }); + } + + /** + * Reset EIP support to default (no support) + */ + clearNetworkDetails() { + this.networkDetails.putState({ ...defaultNetworkDetailsState }); + } + isNetworkLoading() { return this.getNetworkState() === 'loading'; } @@ -154,6 +225,8 @@ export default class NetworkController extends EventEmitter { 'NetworkController - lookupNetwork aborted due to missing chainId', ); this.setNetworkState('loading'); + // keep network details in sync with network state + this.clearNetworkDetails(); return; } @@ -174,10 +247,14 @@ export default class NetworkController extends EventEmitter { if (initialNetwork === currentNetwork) { if (err) { this.setNetworkState('loading'); + // keep network details in sync with network state + this.clearNetworkDetails(); return; } this.setNetworkState(networkVersion); + // look up EIP-1559 support + this.getEIP1559Compatibility(); } }); } @@ -298,9 +375,15 @@ export default class NetworkController extends EventEmitter { } _switchNetwork(opts) { + // Indicate to subscribers that network is about to change this.emit(NETWORK_EVENTS.NETWORK_WILL_CHANGE); + // Set loading state this.setNetworkState('loading'); + // Reset network details + this.clearNetworkDetails(); + // Configure the provider appropriately this._configureProvider(opts); + // Notify subscribers that network has changed this.emit(NETWORK_EVENTS.NETWORK_DID_CHANGE, opts.type); } diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 19aef453e..1c34a46b2 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -2,14 +2,21 @@ import { strict as assert } from 'assert'; import { ObservableStore } from '@metamask/obs-store'; import { ethErrors } from 'eth-rpc-errors'; import { normalize as normalizeAddress } from 'eth-sig-util'; -import ethers from 'ethers'; +import { ethers } from 'ethers'; import log from 'loglevel'; +import abiERC721 from 'human-standard-collectible-abi'; +import contractsMap from '@metamask/contract-metadata'; import { LISTED_CONTRACT_ADDRESSES } from '../../../shared/constants/tokens'; import { NETWORK_TYPE_TO_ID_MAP } from '../../../shared/constants/network'; import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils'; -import { isValidHexAddress } from '../../../shared/modules/hexstring-utils'; +import { + isValidHexAddress, + toChecksumHexAddress, +} from '../../../shared/modules/hexstring-utils'; import { NETWORK_EVENTS } from './network'; +const ERC721METADATA_INTERFACE_ID = '0x5b5e139f'; + export default class PreferencesController { /** * @@ -73,11 +80,18 @@ export default class PreferencesController { }; this.network = opts.network; + this.ethersProvider = new ethers.providers.Web3Provider(opts.provider); this.store = new ObservableStore(initState); this.store.setMaxListeners(12); this.openPopup = opts.openPopup; this.migrateAddressBookState = opts.migrateAddressBookState; - this._subscribeToNetworkDidChange(); + + this.network.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { + const { tokens, hiddenTokens } = this._getTokenRelatedStates(); + this.ethersProvider = new ethers.providers.Web3Provider(opts.provider); + this._updateAccountTokens(tokens, this.getAssetImages(), hiddenTokens); + }); + this._subscribeToInfuraAvailability(); global.setPreference = (key, value) => { @@ -393,6 +407,8 @@ export default class PreferencesController { }); const previousIndex = tokens.indexOf(previousEntry); + newEntry.isERC721 = await this._detectIsERC721(newEntry.address); + if (previousEntry) { tokens[previousIndex] = newEntry; } else { @@ -403,6 +419,24 @@ export default class PreferencesController { return Promise.resolve(tokens); } + /** + * Adds isERC721 field to token object + * (Called when a user attempts to add tokens that were previously added which do not yet had isERC721 field) + * + * @param {string} tokenAddress - The contract address of the token requiring the isERC721 field added. + * @returns {Promise} The new token object with the added isERC721 field. + * + */ + async updateTokenType(tokenAddress) { + const { tokens } = this.store.getState(); + const tokenIndex = tokens.findIndex((token) => { + return token.address === tokenAddress; + }); + tokens[tokenIndex].isERC721 = await this._detectIsERC721(tokenAddress); + this.store.updateState({ tokens }); + return Promise.resolve(tokens[tokenIndex]); + } + /** * Removes a specified token from the tokens array and adds it to hiddenTokens array * @@ -480,11 +514,8 @@ export default class PreferencesController { let addressBookKey = rpcDetail.chainId; if (!addressBookKey) { // We need to find the networkId to determine what these addresses were keyed by - const provider = new ethers.providers.JsonRpcProvider( - rpcDetail.rpcUrl, - ); try { - addressBookKey = await provider.send('net_version'); + addressBookKey = await this.ethersProvider.send('net_version'); assert(typeof addressBookKey === 'string'); } catch (error) { log.debug(error); @@ -701,17 +732,6 @@ export default class PreferencesController { // PRIVATE METHODS // - /** - * Handle updating token list to reflect current network by listening for the - * NETWORK_DID_CHANGE event. - */ - _subscribeToNetworkDidChange() { - this.network.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { - const { tokens, hiddenTokens } = this._getTokenRelatedStates(); - this._updateAccountTokens(tokens, this.getAssetImages(), hiddenTokens); - }); - } - _subscribeToInfuraAvailability() { this.network.on(NETWORK_EVENTS.INFURA_IS_BLOCKED, () => { this._setInfuraBlocked(true); @@ -763,6 +783,43 @@ export default class PreferencesController { }); } + /** + * Detects whether or not a token is ERC-721 compatible. + * + * @param {string} tokensAddress - the token contract address. + * + */ + async _detectIsERC721(tokenAddress) { + const checksumAddress = toChecksumHexAddress(tokenAddress); + // if this token is already in our contract metadata map we don't need + // to check against the contract + if (contractsMap[checksumAddress]?.erc721 === true) { + return Promise.resolve(true); + } + const tokenContract = await this._createEthersContract( + tokenAddress, + abiERC721, + this.ethersProvider, + ); + + return await tokenContract + .supportsInterface(ERC721METADATA_INTERFACE_ID) + .catch((error) => { + console.log('error', error); + log.debug(error); + return false; + }); + } + + async _createEthersContract(tokenAddress, abi, ethersProvider) { + const tokenContract = await new ethers.Contract( + tokenAddress, + abi, + ethersProvider, + ); + return tokenContract; + } + /** * Updates `tokens` and `hiddenTokens` of current account and network. * diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index 4141f0f5f..5e454d55a 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -1,10 +1,13 @@ import { strict as assert } from 'assert'; import sinon from 'sinon'; +import contractMaps from '@metamask/contract-metadata'; +import abiERC721 from 'human-standard-collectible-abi'; import { MAINNET_CHAIN_ID, RINKEBY_CHAIN_ID, } from '../../../shared/constants/network'; import PreferencesController from './preferences'; +import NetworkController from './network'; describe('preferences controller', function () { let preferencesController; @@ -13,19 +16,35 @@ describe('preferences controller', function () { let triggerNetworkChange; let switchToMainnet; let switchToRinkeby; + let provider; const migrateAddressBookState = sinon.stub(); beforeEach(function () { + const sandbox = sinon.createSandbox(); currentChainId = MAINNET_CHAIN_ID; - network = { - getCurrentChainId: () => currentChainId, - on: sinon.spy(), + const networkControllerProviderConfig = { + getAccounts: () => undefined, }; + network = new NetworkController(); + network.setInfuraProjectId('foo'); + network.initializeProvider(networkControllerProviderConfig); + provider = network.getProviderAndBlockTracker().provider; + + sandbox + .stub(network, 'getLatestBlock') + .callsFake(() => Promise.resolve({})); + sandbox.stub(network, 'getCurrentChainId').callsFake(() => currentChainId); + sandbox + .stub(network, 'getProviderConfig') + .callsFake(() => ({ type: 'mainnet' })); + const spy = sandbox.spy(network, 'on'); + preferencesController = new PreferencesController({ migrateAddressBookState, network, + provider, }); - triggerNetworkChange = network.on.firstCall.args[1]; + triggerNetworkChange = spy.firstCall.args[1]; switchToMainnet = () => { currentChainId = MAINNET_CHAIN_ID; triggerNetworkChange(); @@ -86,6 +105,104 @@ describe('preferences controller', function () { }); }); + describe('updateTokenType', function () { + it('should add isERC721 = true to token object in state when token is collectible and in our contract-metadata repo', async function () { + const contractAddresses = Object.keys(contractMaps); + const erc721ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc721 === true, + ); + const address = erc721ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + preferencesController.store.updateState({ + tokens: [{ address, symbol, decimals }], + }); + const result = await preferencesController.updateTokenType(address); + assert.equal(result.isERC721, true); + }); + + it('should add isERC721 = true to token object in state when token is collectible and not in our contract-metadata repo', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + preferencesController.store.updateState({ + tokens: [ + { + address: tokenAddress, + symbol: 'TESTNFT', + decimals: '0', + }, + ], + }); + sinon + .stub(preferencesController, '_detectIsERC721') + .callsFake(() => true); + + const result = await preferencesController.updateTokenType(tokenAddress); + assert.equal( + preferencesController._detectIsERC721.getCall(0).args[0], + tokenAddress, + ); + assert.equal(result.isERC721, true); + }); + }); + + describe('_detectIsERC721', function () { + it('should return true when token is in our contract-metadata repo', async function () { + const tokenAddress = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal(result, true); + }); + + it('should return true when the token is not in our contract-metadata repo but tokenContract.supportsInterface returns true', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + const supportsInterfaceStub = sinon.stub().returns(Promise.resolve(true)); + sinon + .stub(preferencesController, '_createEthersContract') + .callsFake(() => ({ supportsInterface: supportsInterfaceStub })); + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[0], + tokenAddress, + ); + assert.deepEqual( + preferencesController._createEthersContract.getCall(0).args[1], + abiERC721, + ); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[2], + preferencesController.ethersProvider, + ); + assert.equal(result, true); + }); + + it('should return false when the token is not in our contract-metadata repo and tokenContract.supportsInterface returns false', async function () { + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + const supportsInterfaceStub = sinon + .stub() + .returns(Promise.resolve(false)); + sinon + .stub(preferencesController, '_createEthersContract') + .callsFake(() => ({ supportsInterface: supportsInterfaceStub })); + + const result = await preferencesController._detectIsERC721(tokenAddress); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[0], + tokenAddress, + ); + assert.deepEqual( + preferencesController._createEthersContract.getCall(0).args[1], + abiERC721, + ); + assert.equal( + preferencesController._createEthersContract.getCall(0).args[2], + preferencesController.ethersProvider, + ); + assert.equal(result, false); + }); + }); + describe('removeAddress', function () { it('should remove an address from state', function () { preferencesController.setAddresses(['0xda22le', '0x7e57e2']); @@ -291,7 +408,12 @@ describe('preferences controller', function () { assert.equal(tokens.length, 1, 'one token removed'); const [token1] = tokens; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); }); it('should remove a token from its state on corresponding address', async function () { @@ -310,7 +432,12 @@ describe('preferences controller', function () { assert.equal(tokensFirst.length, 1, 'one token removed in account'); const [token1] = tokensFirst; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); await preferencesController.setSelectedAddress('0x7e57e3'); const tokensSecond = preferencesController.getTokens(); @@ -335,7 +462,12 @@ describe('preferences controller', function () { assert.equal(tokensFirst.length, 1, 'one token removed in network'); const [token1] = tokensFirst; - assert.deepEqual(token1, { address: '0xb', symbol: 'B', decimals: 5 }); + assert.deepEqual(token1, { + address: '0xb', + symbol: 'B', + decimals: 5, + isERC721: false, + }); switchToRinkeby(); const tokensSecond = preferencesController.getTokens(); diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index fa7c46a2b..3f10c4ba3 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -1,10 +1,11 @@ import EventEmitter from 'safe-event-emitter'; import { ObservableStore } from '@metamask/obs-store'; import { bufferToHex, keccak, toBuffer } from 'ethereumjs-util'; -import Transaction from 'ethereumjs-tx'; import EthQuery from 'ethjs-query'; import { ethErrors } from 'eth-rpc-errors'; import abi from 'human-standard-token-abi'; +import Common from '@ethereumjs/common'; +import { TransactionFactory } from '@ethereumjs/tx'; import { ethers } from 'ethers'; import NonceTracker from 'nonce-tracker'; import log from 'loglevel'; @@ -24,15 +25,29 @@ import { } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import { GAS_LIMITS } from '../../../../shared/constants/gas'; +import { + MAINNET, + NETWORK_TYPE_RPC, +} from '../../../../shared/constants/network'; import TransactionStateManager from './tx-state-manager'; import TxGasUtil from './tx-gas-utils'; import PendingTransactionTracker from './pending-tx-tracker'; import * as txUtils from './lib/util'; +const HARDFORK = 'berlin'; + const hstInterface = new ethers.utils.Interface(abi); const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory +export const TRANSACTION_EVENTS = { + ADDED: 'Transaction Added', + APPROVED: 'Transaction Approved', + FINALIZED: 'Transaction Finalized', + REJECTED: 'Transaction Rejected', + SUBMITTED: 'Transaction Submitted', +}; + /** Transaction Controller is an aggregate of sub-controllers and trackers composing them in a way to be exposed to the metamask controller @@ -53,7 +68,7 @@ const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonc @param {Object} opts.networkStore - an observable store for network number @param {Object} opts.blockTracker - An instance of eth-blocktracker @param {Object} opts.provider - A network provider. - @param {Function} opts.signTransaction - function the signs an ethereumjs-tx + @param {Function} opts.signTransaction - function the signs an @ethereumjs/tx @param {Object} opts.getPermittedAccounts - get accounts that an origin has permissions for @param {Function} opts.signTransaction - ethTx signer that returns a rawTx @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state @@ -65,6 +80,7 @@ export default class TransactionController extends EventEmitter { super(); this.networkStore = opts.networkStore || new ObservableStore({}); this._getCurrentChainId = opts.getCurrentChainId; + this.getProviderConfig = opts.getProviderConfig; this.preferencesStore = opts.preferencesStore || new ObservableStore({}); this.provider = opts.provider; this.getPermittedAccounts = opts.getPermittedAccounts; @@ -146,6 +162,49 @@ export default class TransactionController extends EventEmitter { return integerChainId; } + /** + * @ethereumjs/tx uses @ethereumjs/common as a configuration tool for + * specifying which chain, network, hardfork and EIPs to support for + * a transaction. By referencing this configuration, and analyzing the fields + * specified in txParams, @ethereumjs/tx is able to determine which EIP-2718 + * transaction type to use. + * @returns {Common} common configuration object + */ + getCommonConfiguration() { + const { type, nickname: name } = this.getProviderConfig(); + + // type will be one of our default network names or 'rpc'. the default + // network names are sufficient configuration, simply pass the name as the + // chain argument in the constructor. + if (type !== NETWORK_TYPE_RPC) { + return new Common({ chain: type, hardfork: HARDFORK }); + } + + // For 'rpc' we need to use the same basic configuration as mainnet, + // since we only support EVM compatible chains, and then override the + // name, chainId and networkId properties. This is done using the + // `forCustomChain` static method on the Common class. + const chainId = parseInt(this._getCurrentChainId(), 16); + const networkId = this.networkStore.getState(); + + const customChainParams = { + name, + chainId, + // It is improbable for a transaction to be signed while the network + // is loading for two reasons. + // 1. Pending, unconfirmed transactions are wiped on network change + // 2. The UI is unusable (loading indicator) when network is loading. + // setting the networkId to 0 is for type safety and to explicity lead + // the transaction to failing if a user is able to get to this branch + // on a custom network that requires valid network id. I have not ran + // into this limitation on any network I have attempted, even when + // hardcoding networkId to 'loading'. + networkId: networkId === 'loading' ? 0 : parseInt(networkId, 10), + }; + + return Common.forCustomChain(MAINNET, customChainParams, HARDFORK); + } + /** Adds a tx to the txlist @emits ${txMeta.id}:unapproved @@ -153,6 +212,7 @@ export default class TransactionController extends EventEmitter { addTransaction(txMeta) { this.txStateManager.addTransaction(txMeta); this.emit(`${txMeta.id}:unapproved`, txMeta); + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.ADDED); } /** @@ -532,12 +592,13 @@ export default class TransactionController extends EventEmitter { // sign transaction const rawTx = await this.signTransaction(txId); await this.publishTransaction(txId, rawTx); + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.APPROVED); // must set transaction to submitted/failed before releasing lock nonceLock.releaseLock(); } catch (err) { // this is try-catch wrapped so that we can guarantee that the nonceLock is released try { - this.txStateManager.setTxStatusFailed(txId, err); + this._failTransaction(txId, err); } catch (err2) { log.error(err2); } @@ -561,17 +622,22 @@ export default class TransactionController extends EventEmitter { const txMeta = this.txStateManager.getTransaction(txId); // add network/chain id const chainId = this.getChainId(); - const txParams = { ...txMeta.txParams, chainId }; + const txParams = { + ...txMeta.txParams, + chainId, + gasLimit: txMeta.txParams.gas, + }; // sign tx const fromAddress = txParams.from; - const ethTx = new Transaction(txParams); - await this.signEthTx(ethTx, fromAddress); + const common = this.getCommonConfiguration(); + const unsignedEthTx = TransactionFactory.fromTxData(txParams, { common }); + const signedEthTx = await this.signEthTx(unsignedEthTx, fromAddress); // add r,s,v values for provider request purposes see createMetamaskMiddleware // and JSON rpc standard for further explanation - txMeta.r = bufferToHex(ethTx.r); - txMeta.s = bufferToHex(ethTx.s); - txMeta.v = bufferToHex(ethTx.v); + txMeta.r = bufferToHex(signedEthTx.r); + txMeta.s = bufferToHex(signedEthTx.s); + txMeta.v = bufferToHex(signedEthTx.v); this.txStateManager.updateTransaction( txMeta, @@ -580,7 +646,7 @@ export default class TransactionController extends EventEmitter { // set state to signed this.txStateManager.setTxStatusSigned(txMeta.id); - const rawTx = bufferToHex(ethTx.serialize()); + const rawTx = bufferToHex(signedEthTx.serialize()); return rawTx; } @@ -615,6 +681,11 @@ export default class TransactionController extends EventEmitter { this.setTxHash(txId, txHash); this.txStateManager.setTxStatusSubmitted(txId); + + const { gas } = txMeta.txParams; + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.SUBMITTED, { + gas_limit: gas, + }); } /** @@ -647,6 +718,29 @@ export default class TransactionController extends EventEmitter { this.txStateManager.setTxStatusConfirmed(txId); this._markNonceDuplicatesDropped(txId); + const { submittedTime } = txMeta; + const { blockNumber } = txReceipt; + const metricsParams = { gas_used: gasUsed }; + const completionTime = await this._getTransactionCompletionTime( + blockNumber, + submittedTime, + ); + + if (completionTime) { + metricsParams.completion_time = completionTime; + } + + if (txReceipt.status === '0x0') { + metricsParams.status = 'failed on-chain'; + // metricsParams.error = TODO: figure out a way to get the on-chain failure reason + } + + this._trackTransactionMetricsEvent( + txMeta, + TRANSACTION_EVENTS.FINALIZED, + metricsParams, + ); + this.txStateManager.updateTransaction( txMeta, 'transactions#confirmTransaction - add txReceipt', @@ -680,7 +774,9 @@ export default class TransactionController extends EventEmitter { @returns {Promise} */ async cancelTransaction(txId) { + const txMeta = this.txStateManager.getTransaction(txId); this.txStateManager.setTxStatusRejected(txId); + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.REJECTED); } /** @@ -763,7 +859,7 @@ export default class TransactionController extends EventEmitter { txMeta, 'failed to estimate gas during boot cleanup.', ); - this.txStateManager.setTxStatusFailed(txMeta.id, error); + this._failTransaction(txMeta.id, error); }); }); @@ -777,7 +873,7 @@ export default class TransactionController extends EventEmitter { const txSignError = new Error( 'Transaction found as "approved" during boot - possibly stuck during signing', ); - this.txStateManager.setTxStatusFailed(txMeta.id, txSignError); + this._failTransaction(txMeta.id, txSignError); }); } @@ -797,17 +893,15 @@ export default class TransactionController extends EventEmitter { 'transactions/pending-tx-tracker#event: tx:warning', ); }); - this.pendingTxTracker.on( - 'tx:failed', - this.txStateManager.setTxStatusFailed.bind(this.txStateManager), - ); + this.pendingTxTracker.on('tx:failed', (txId, error) => { + this._failTransaction(txId, error); + }); this.pendingTxTracker.on('tx:confirmed', (txId, transactionReceipt) => this.confirmTransaction(txId, transactionReceipt), ); - this.pendingTxTracker.on( - 'tx:dropped', - this.txStateManager.setTxStatusDropped.bind(this.txStateManager), - ); + this.pendingTxTracker.on('tx:dropped', (txId) => { + this._dropTransaction(txId); + }); this.pendingTxTracker.on('tx:block-update', (txMeta, latestBlockNumber) => { if (!txMeta.firstRetryBlockNumber) { txMeta.firstRetryBlockNumber = latestBlockNumber; @@ -917,7 +1011,7 @@ export default class TransactionController extends EventEmitter { txMeta, 'transactions/pending-tx-tracker#event: tx:confirmed reference to confirmed txHash with same nonce', ); - this.txStateManager.setTxStatusDropped(otherTxMeta.id); + this._dropTransaction(otherTxMeta.id); }); } @@ -1010,4 +1104,71 @@ export default class TransactionController extends EventEmitter { } } } + + /** + * Extracts relevant properties from a transaction meta + * object and uses them to create and send metrics for various transaction + * events. + * @param {Object} txMeta - the txMeta object + * @param {string} event - the name of the transaction event + * @param {Object} extraParams - optional props and values to include in sensitiveProperties + */ + _trackTransactionMetricsEvent(txMeta, event, extraParams = {}) { + const { + type, + time, + status, + chainId, + origin: referrer, + txParams: { gasPrice }, + metamaskNetworkId: network, + } = txMeta; + const source = referrer === 'metamask' ? 'user' : 'dapp'; + + this._trackMetaMetricsEvent({ + event, + category: 'Transactions', + sensitiveProperties: { + type, + status, + referrer, + source, + network, + chain_id: chainId, + gas_price: gasPrice, + first_seen: time, + ...extraParams, + }, + }); + } + + async _getTransactionCompletionTime(blockNumber, submittedTime) { + const transactionBlock = await this.query.getBlockByNumber( + blockNumber.toString(16), + false, + ); + + if (!transactionBlock) { + return ''; + } + + return new BigNumber(transactionBlock.timestamp, 10) + .minus(submittedTime / 1000) + .round() + .toString(10); + } + + _failTransaction(txId, error) { + this.txStateManager.setTxStatusFailed(txId, error); + const txMeta = this.txStateManager.getTransaction(txId); + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.FINALIZED, { + error: error.message, + }); + } + + _dropTransaction(txId) { + this.txStateManager.setTxStatusDropped(txId); + const txMeta = this.txStateManager.getTransaction(txId); + this._trackTransactionMetricsEvent(txMeta, TRANSACTION_EVENTS.FINALIZED); + } } diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index 3615c33d0..c60c5d6ea 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -1,7 +1,7 @@ import { strict as assert } from 'assert'; import EventEmitter from 'events'; import { toBuffer } from 'ethereumjs-util'; -import EthTx from 'ethereumjs-tx'; +import { TransactionFactory } from '@ethereumjs/tx'; import { ObservableStore } from '@metamask/obs-store'; import sinon from 'sinon'; @@ -15,11 +15,14 @@ import { } from '../../../../shared/constants/transaction'; import { SECOND } from '../../../../shared/constants/time'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; -import TransactionController from '.'; +import TransactionController, { TRANSACTION_EVENTS } from '.'; const noop = () => true; const currentNetworkId = '42'; const currentChainId = '0x2a'; +const providerConfig = { + type: 'kovan', +}; const VALID_ADDRESS = '0x0000000000000000000000000000000000000000'; const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001'; @@ -36,6 +39,7 @@ describe('Transaction Controller', function () { }; provider = createTestProviderTools({ scaffold: providerResultStub }) .provider; + fromAccount = getTestAccounts()[0]; const blockTrackerStub = new EventEmitter(); blockTrackerStub.getCurrentBlock = noop; @@ -50,12 +54,13 @@ describe('Transaction Controller', function () { blockTracker: blockTrackerStub, signTransaction: (ethTx) => new Promise((resolve) => { - ethTx.sign(fromAccount.key); - resolve(); + resolve(ethTx.sign(fromAccount.key)); }), + getProviderConfig: () => providerConfig, getPermittedAccounts: () => undefined, getCurrentChainId: () => currentChainId, getParticipateInMetrics: () => false, + trackMetaMetricsEvent: () => undefined, }); txController.nonceTracker.getNonceLock = () => Promise.resolve({ nextNonce: 0, releaseLock: noop }); @@ -414,6 +419,19 @@ describe('Transaction Controller', function () { }); describe('#addTransaction', function () { + let trackTransactionMetricsEventSpy; + + beforeEach(function () { + trackTransactionMetricsEventSpy = sinon.spy( + txController, + '_trackTransactionMetricsEvent', + ); + }); + + afterEach(function () { + trackTransactionMetricsEventSpy.restore(); + }); + it('should emit updates', function (done) { const txMeta = { id: '1', @@ -451,6 +469,37 @@ describe('Transaction Controller', function () { .catch(done); txController.addTransaction(txMeta); }); + + it('should call _trackTransactionMetricsEvent with the correct params', function () { + const txMeta = { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + txParams: { + from: fromAccount.address, + to: '0x1678a085c290ebd122dc42cba69373b5953b831d', + gasPrice: '0x77359400', + gas: '0x7b0d', + nonce: '0x4b', + }, + type: 'sentEther', + origin: 'metamask', + chainId: currentChainId, + time: 1624408066355, + metamaskNetworkId: currentNetworkId, + }; + + txController.addTransaction(txMeta); + + assert.equal(trackTransactionMetricsEventSpy.callCount, 1); + assert.deepEqual( + trackTransactionMetricsEventSpy.getCall(0).args[0], + txMeta, + ); + assert.equal( + trackTransactionMetricsEventSpy.getCall(0).args[1], + TRANSACTION_EVENTS.ADDED, + ); + }); }); describe('#approveTransaction', function () { @@ -519,8 +568,8 @@ describe('Transaction Controller', function () { noop, ); const rawTx = await txController.signTransaction('1'); - const ethTx = new EthTx(toBuffer(rawTx)); - assert.equal(ethTx.getChainId(), 42); + const ethTx = TransactionFactory.fromSerializedData(toBuffer(rawTx)); + assert.equal(ethTx.common.chainIdBN().toNumber(), 42); }); }); @@ -723,7 +772,8 @@ describe('Transaction Controller', function () { }); describe('#publishTransaction', function () { - let hash, txMeta; + let hash, txMeta, trackTransactionMetricsEventSpy; + beforeEach(function () { hash = '0x2a5523c6fa98b47b7d9b6c8320179785150b42a16bcff36b398c5062b65657e8'; @@ -731,12 +781,21 @@ describe('Transaction Controller', function () { id: 1, status: TRANSACTION_STATUSES.UNAPPROVED, txParams: { + gas: '0x7b0d', to: VALID_ADDRESS, from: VALID_ADDRESS_TWO, }, metamaskNetworkId: currentNetworkId, }; providerResultStub.eth_sendRawTransaction = hash; + trackTransactionMetricsEventSpy = sinon.spy( + txController, + '_trackTransactionMetricsEvent', + ); + }); + + afterEach(function () { + trackTransactionMetricsEventSpy.restore(); }); it('should publish a tx, updates the rawTx when provided a one', async function () { @@ -764,6 +823,25 @@ describe('Transaction Controller', function () { ); assert.equal(publishedTx.status, TRANSACTION_STATUSES.SUBMITTED); }); + + it('should call _trackTransactionMetricsEvent with the correct params', async function () { + const rawTx = + '0x477b2e6553c917af0db0388ae3da62965ff1a184558f61b749d1266b2e6d024c'; + txController.txStateManager.addTransaction(txMeta); + await txController.publishTransaction(txMeta.id, rawTx); + assert.equal(trackTransactionMetricsEventSpy.callCount, 1); + assert.deepEqual( + trackTransactionMetricsEventSpy.getCall(0).args[0], + txMeta, + ); + assert.equal( + trackTransactionMetricsEventSpy.getCall(0).args[1], + TRANSACTION_EVENTS.SUBMITTED, + ); + assert.deepEqual(trackTransactionMetricsEventSpy.getCall(0).args[2], { + gas_limit: txMeta.txParams.gas, + }); + }); }); describe('#_markNonceDuplicatesDropped', function () { @@ -1105,4 +1183,154 @@ describe('Transaction Controller', function () { ); }); }); + + describe('#_trackTransactionMetricsEvent', function () { + let trackMetaMetricsEventSpy; + + beforeEach(function () { + trackMetaMetricsEventSpy = sinon.spy( + txController, + '_trackMetaMetricsEvent', + ); + }); + + afterEach(function () { + trackMetaMetricsEventSpy.restore(); + }); + + it('should call _trackMetaMetricsEvent with the correct payload (user source)', function () { + const txMeta = { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + txParams: { + from: fromAccount.address, + to: '0x1678a085c290ebd122dc42cba69373b5953b831d', + gasPrice: '0x77359400', + gas: '0x7b0d', + nonce: '0x4b', + }, + type: 'sentEther', + origin: 'metamask', + chainId: currentChainId, + time: 1624408066355, + metamaskNetworkId: currentNetworkId, + }; + const expectedPayload = { + event: 'Transaction Added', + category: 'Transactions', + sensitiveProperties: { + chain_id: '0x2a', + gas_price: '0x77359400', + first_seen: 1624408066355, + network: '42', + referrer: 'metamask', + source: 'user', + status: 'unapproved', + type: 'sentEther', + }, + }; + + txController._trackTransactionMetricsEvent( + txMeta, + TRANSACTION_EVENTS.ADDED, + ); + assert.equal(trackMetaMetricsEventSpy.callCount, 1); + assert.deepEqual( + trackMetaMetricsEventSpy.getCall(0).args[0], + expectedPayload, + ); + }); + + it('should call _trackMetaMetricsEvent with the correct payload (dapp source)', function () { + const txMeta = { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + txParams: { + from: fromAccount.address, + to: '0x1678a085c290ebd122dc42cba69373b5953b831d', + gasPrice: '0x77359400', + gas: '0x7b0d', + nonce: '0x4b', + }, + type: 'sentEther', + origin: 'other', + chainId: currentChainId, + time: 1624408066355, + metamaskNetworkId: currentNetworkId, + }; + const expectedPayload = { + event: 'Transaction Added', + category: 'Transactions', + sensitiveProperties: { + chain_id: '0x2a', + gas_price: '0x77359400', + first_seen: 1624408066355, + network: '42', + referrer: 'other', + source: 'dapp', + status: 'unapproved', + type: 'sentEther', + }, + }; + + txController._trackTransactionMetricsEvent( + txMeta, + TRANSACTION_EVENTS.ADDED, + ); + assert.equal(trackMetaMetricsEventSpy.callCount, 1); + assert.deepEqual( + trackMetaMetricsEventSpy.getCall(0).args[0], + expectedPayload, + ); + }); + + it('should call _trackMetaMetricsEvent with the correct payload (extra params)', function () { + const txMeta = { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + txParams: { + from: fromAccount.address, + to: '0x1678a085c290ebd122dc42cba69373b5953b831d', + gasPrice: '0x77359400', + gas: '0x7b0d', + nonce: '0x4b', + }, + type: 'sentEther', + origin: 'other', + chainId: currentChainId, + time: 1624408066355, + metamaskNetworkId: currentNetworkId, + }; + const expectedPayload = { + event: 'Transaction Added', + category: 'Transactions', + sensitiveProperties: { + baz: 3.0, + foo: 'bar', + chain_id: '0x2a', + gas_price: '0x77359400', + first_seen: 1624408066355, + network: '42', + referrer: 'other', + source: 'dapp', + status: 'unapproved', + type: 'sentEther', + }, + }; + + txController._trackTransactionMetricsEvent( + txMeta, + TRANSACTION_EVENTS.ADDED, + { + baz: 3.0, + foo: 'bar', + }, + ); + assert.equal(trackMetaMetricsEventSpy.callCount, 1); + assert.deepEqual( + trackMetaMetricsEventSpy.getCall(0).args[0], + expectedPayload, + ); + }); + }); }); diff --git a/app/scripts/controllers/transactions/tx-gas-utils.test.js b/app/scripts/controllers/transactions/tx-gas-utils.test.js index 635802925..15386e319 100644 --- a/app/scripts/controllers/transactions/tx-gas-utils.test.js +++ b/app/scripts/controllers/transactions/tx-gas-utils.test.js @@ -1,5 +1,6 @@ import { strict as assert } from 'assert'; -import Transaction from 'ethereumjs-tx'; +import { TransactionFactory } from '@ethereumjs/tx'; +import Common from '@ethereumjs/common'; import { hexToBn, bnToHex } from '../../lib/util'; import TxUtils from './tx-gas-utils'; @@ -31,8 +32,14 @@ describe('txUtils', function () { nonce: '0x3', chainId: 42, }; - const ethTx = new Transaction(txParams); - assert.equal(ethTx.getChainId(), 42, 'chainId is set from tx params'); + const ethTx = TransactionFactory.fromTxData(txParams, { + common: new Common({ chain: 'kovan' }), + }); + assert.equal( + ethTx.common.chainIdBN().toNumber(), + 42, + 'chainId is set from tx params', + ); }); }); diff --git a/app/scripts/lib/buy-eth-url.js b/app/scripts/lib/buy-eth-url.js index 2cb1b0e81..f9f15e33b 100644 --- a/app/scripts/lib/buy-eth-url.js +++ b/app/scripts/lib/buy-eth-url.js @@ -1,3 +1,6 @@ +import log from 'loglevel'; + +import { METASWAP_CHAINID_API_HOST_MAP } from '../../../shared/constants/swaps'; import { GOERLI_CHAIN_ID, KOVAN_CHAIN_ID, @@ -5,6 +8,54 @@ import { RINKEBY_CHAIN_ID, ROPSTEN_CHAIN_ID, } from '../../../shared/constants/network'; +import { SECOND } from '../../../shared/constants/time'; +import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; +import { TRANSAK_API_KEY } from '../constants/on-ramp'; + +const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); + +/** + * Create a Wyre purchase URL. + * @param {String} address Ethereum destination address + * @returns String + */ +const createWyrePurchaseUrl = async (address) => { + const fiatOnRampUrlApi = `${METASWAP_CHAINID_API_HOST_MAP[MAINNET_CHAIN_ID]}/fiatOnRampUrl?serviceName=wyre&destinationAddress=${address}`; + const wyrePurchaseUrlFallback = `https://pay.sendwyre.com/purchase?dest=ethereum:${address}&destCurrency=ETH&accountId=AC-7AG3W4XH4N2&paymentMethod=debit-card`; + try { + const response = await fetchWithTimeout(fiatOnRampUrlApi, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }); + const parsedResponse = await response.json(); + if (response.ok && parsedResponse.url) { + return parsedResponse.url; + } + log.warn('Failed to create a Wyre purchase URL', parsedResponse); + } catch (err) { + log.warn('Failed to create a Wyre purchase URL', err); + } + return wyrePurchaseUrlFallback; // In case the API call would fail, we return a fallback URL for Wyre's Checkout. +}; + +/** + * Create a Transak Checkout URL. + * API docs here: https://www.notion.so/Query-Parameters-9ec523df3b874ec58cef4fa3a906f238 + * @param {String} address Ethereum destination address + * @returns String + */ +const createTransakUrl = (address) => { + const queryParams = new URLSearchParams({ + apiKey: TRANSAK_API_KEY, + hostURL: 'https://metamask.io', + defaultCryptoCurrency: 'ETH', + walletAddress: address, + }); + return `https://global.transak.com/?${queryParams}`; +}; /** * Gives the caller a url at which the user can acquire eth, depending on the network they are in @@ -16,7 +67,7 @@ import { * chainId does not match any of the specified cases, or if no chainId is given, returns undefined. * */ -export default function getBuyEthUrl({ chainId, address, service }) { +export default async function getBuyEthUrl({ chainId, address, service }) { // default service by network if not specified if (!service) { // eslint-disable-next-line no-param-reassign @@ -25,7 +76,9 @@ export default function getBuyEthUrl({ chainId, address, service }) { switch (service) { case 'wyre': - return `https://pay.sendwyre.com/purchase?dest=ethereum:${address}&destCurrency=ETH&accountId=AC-7AG3W4XH4N2&paymentMethod=debit-card`; + return await createWyrePurchaseUrl(address); + case 'transak': + return createTransakUrl(address); case 'metamask-faucet': return 'https://faucet.metamask.io/'; case 'rinkeby-faucet': diff --git a/app/scripts/lib/buy-eth-url.test.js b/app/scripts/lib/buy-eth-url.test.js index 01837c8ef..d15af3e5d 100644 --- a/app/scripts/lib/buy-eth-url.test.js +++ b/app/scripts/lib/buy-eth-url.test.js @@ -1,49 +1,76 @@ import { strict as assert } from 'assert'; +import nock from 'nock'; import { KOVAN_CHAIN_ID, MAINNET_CHAIN_ID, RINKEBY_CHAIN_ID, ROPSTEN_CHAIN_ID, } from '../../../shared/constants/network'; +import { TRANSAK_API_KEY } from '../constants/on-ramp'; import getBuyEthUrl from './buy-eth-url'; -describe('buy-eth-url', function () { - const mainnet = { - chainId: MAINNET_CHAIN_ID, - amount: 5, - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - }; - const ropsten = { - chainId: ROPSTEN_CHAIN_ID, - }; - const rinkeby = { - chainId: RINKEBY_CHAIN_ID, - }; - const kovan = { - chainId: KOVAN_CHAIN_ID, - }; +const WYRE_ACCOUNT_ID = 'AC-7AG3W4XH4N2'; +const ETH_ADDRESS = '0x0dcd5d886577d5581b0c524242ef2ee70be3e7bc'; +const MAINNET = { + chainId: MAINNET_CHAIN_ID, + amount: 5, + address: ETH_ADDRESS, +}; +const ROPSTEN = { + chainId: ROPSTEN_CHAIN_ID, +}; +const RINKEBY = { + chainId: RINKEBY_CHAIN_ID, +}; +const KOVAN = { + chainId: KOVAN_CHAIN_ID, +}; - it('returns wyre url with address for network 1', function () { - const wyreUrl = getBuyEthUrl(mainnet); +describe('buy-eth-url', function () { + it('returns Wyre url with an ETH address for Ethereum mainnet', async function () { + nock('https://api.metaswap.codefi.network') + .get(`/fiatOnRampUrl?serviceName=wyre&destinationAddress=${ETH_ADDRESS}`) + .reply(200, { + url: `https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`, + }); + const wyreUrl = await getBuyEthUrl(MAINNET); + assert.equal( + wyreUrl, + `https://pay.sendwyre.com/purchase?accountId=${WYRE_ACCOUNT_ID}&utm_campaign=${WYRE_ACCOUNT_ID}&destCurrency=ETH&utm_medium=widget&paymentMethod=debit-card&reservation=MLZVUF8FMXZUMARJC23B&dest=ethereum%3A${ETH_ADDRESS}&utm_source=checkout`, + ); + nock.cleanAll(); + }); + + it('returns a fallback Wyre url if /orders/reserve API call fails', async function () { + const wyreUrl = await getBuyEthUrl(MAINNET); assert.equal( wyreUrl, - 'https://pay.sendwyre.com/purchase?dest=ethereum:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc&destCurrency=ETH&accountId=AC-7AG3W4XH4N2&paymentMethod=debit-card', + `https://pay.sendwyre.com/purchase?dest=ethereum:${ETH_ADDRESS}&destCurrency=ETH&accountId=${WYRE_ACCOUNT_ID}&paymentMethod=debit-card`, ); }); - it('returns metamask ropsten faucet for network 3', function () { - const ropstenUrl = getBuyEthUrl(ropsten); + it('returns Transak url with an ETH address for Ethereum mainnet', async function () { + const transakUrl = await getBuyEthUrl({ ...MAINNET, service: 'transak' }); + + assert.equal( + transakUrl, + `https://global.transak.com/?apiKey=${TRANSAK_API_KEY}&hostURL=https%3A%2F%2Fmetamask.io&defaultCryptoCurrency=ETH&walletAddress=${ETH_ADDRESS}`, + ); + }); + + it('returns metamask ropsten faucet for network 3', async function () { + const ropstenUrl = await getBuyEthUrl(ROPSTEN); assert.equal(ropstenUrl, 'https://faucet.metamask.io/'); }); - it('returns rinkeby dapp for network 4', function () { - const rinkebyUrl = getBuyEthUrl(rinkeby); + it('returns rinkeby dapp for network 4', async function () { + const rinkebyUrl = await getBuyEthUrl(RINKEBY); assert.equal(rinkebyUrl, 'https://www.rinkeby.io/'); }); - it('returns kovan github test faucet for network 42', function () { - const kovanUrl = getBuyEthUrl(kovan); + it('returns kovan github test faucet for network 42', async function () { + const kovanUrl = await getBuyEthUrl(KOVAN); assert.equal(kovanUrl, 'https://github.com/kovan-testnet/faucet'); }); }); diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 25bbd7a46..56625b220 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -36,7 +36,6 @@ export const SENTRY_STATE = { isInitialized: true, isUnlocked: true, metaMetricsId: true, - metaMetricsSendCount: true, nativeCurrency: true, network: true, nextNonce: true, diff --git a/app/scripts/lib/typed-message-manager.js b/app/scripts/lib/typed-message-manager.js index 33bc80dd9..63f371684 100644 --- a/app/scripts/lib/typed-message-manager.js +++ b/app/scripts/lib/typed-message-manager.js @@ -1,5 +1,5 @@ import EventEmitter from 'events'; -import assert from 'assert'; +import { strict as assert } from 'assert'; import { ObservableStore } from '@metamask/obs-store'; import { ethErrors } from 'eth-rpc-errors'; import { typedSignatureHash, TYPED_MESSAGE_SCHEMA } from 'eth-sig-util'; @@ -177,7 +177,7 @@ export default class TypedMessageManager extends EventEmitter { break; case 'V3': case 'V4': { - assert.strictEqual( + assert.equal( typeof params.data, 'string', '"params.data" must be a string.', @@ -191,18 +191,21 @@ export default class TypedMessageManager extends EventEmitter { data.primaryType in data.types, `Primary type of "${data.primaryType}" has no type definition.`, ); - assert.strictEqual( + assert.equal( validation.errors.length, 0, 'Signing data must conform to EIP-712 schema. See https://git.io/fNtcx.', ); - const { chainId } = data.domain; + let { chainId } = data.domain; if (chainId) { const activeChainId = parseInt(this._getCurrentChainId(), 16); assert.ok( !Number.isNaN(activeChainId), `Cannot sign messages for chainId "${chainId}", because MetaMask is switching networks.`, ); + if (typeof chainId === 'string') { + chainId = parseInt(chainId, 16); + } assert.equal( chainId, activeChainId, diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index 126e5c4f6..9c343ecf6 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import extension from 'extensionizer'; import { stripHexPrefix } from 'ethereumjs-util'; import BN from 'bn.js'; @@ -70,39 +69,6 @@ const getPlatform = (_) => { return PLATFORM_FIREFOX; }; -/** - * Checks whether a given balance of ETH, represented as a hex string, is sufficient to pay a value plus a gas fee - * - * @param {Object} txParams - Contains data about a transaction - * @param {string} txParams.gas - The gas for a transaction - * @param {string} txParams.gasPrice - The price per gas for the transaction - * @param {string} txParams.value - The value of ETH to send - * @param {string} hexBalance - A balance of ETH represented as a hex string - * @returns {boolean} Whether the balance is greater than or equal to the value plus the value of gas times gasPrice - * - */ -function sufficientBalance(txParams, hexBalance) { - // validate hexBalance is a hex string - assert.equal( - typeof hexBalance, - 'string', - 'sufficientBalance - hexBalance is not a hex string', - ); - assert.equal( - hexBalance.slice(0, 2), - '0x', - 'sufficientBalance - hexBalance is not a hex string', - ); - - const balance = hexToBn(hexBalance); - const value = hexToBn(txParams.value); - const gasLimit = hexToBn(txParams.gas); - const gasPrice = hexToBn(txParams.gasPrice); - - const maxCost = value.add(gasLimit.mul(gasPrice)); - return balance.gte(maxCost); -} - /** * Converts a hex string to a BN object * @@ -183,7 +149,6 @@ function bnToHex(inputBn) { export { getPlatform, getEnvironmentType, - sufficientBalance, hexToBn, BnMultiplyByFraction, checkForError, diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index c2753df56..f21c10d5e 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -7,7 +7,7 @@ import { ENVIRONMENT_TYPE_FULLSCREEN, ENVIRONMENT_TYPE_BACKGROUND, } from '../../../shared/constants/app'; -import { getEnvironmentType, sufficientBalance } from './util'; +import { getEnvironmentType } from './util'; describe('app utils', function () { describe('getEnvironmentType', function () { @@ -68,44 +68,6 @@ describe('app utils', function () { }); }); - describe('SufficientBalance', function () { - it('returns true if max tx cost is equal to balance.', function () { - const tx = { - value: '0x1', - gas: '0x2', - gasPrice: '0x3', - }; - const balance = '0x8'; - - const result = sufficientBalance(tx, balance); - assert.ok(result, 'sufficient balance found.'); - }); - - it('returns true if max tx cost is less than balance.', function () { - const tx = { - value: '0x1', - gas: '0x2', - gasPrice: '0x3', - }; - const balance = '0x9'; - - const result = sufficientBalance(tx, balance); - assert.ok(result, 'sufficient balance found.'); - }); - - it('returns false if max tx cost is more than balance.', function () { - const tx = { - value: '0x1', - gas: '0x2', - gasPrice: '0x3', - }; - const balance = '0x6'; - - const result = sufficientBalance(tx, balance); - assert.ok(!result, 'insufficient balance found.'); - }); - }); - describe('isPrefixedFormattedHexString', function () { it('should return true for valid hex strings', function () { assert.equal( diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e2546a91f..fa582507f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -132,11 +132,17 @@ export default class MetamaskController extends EventEmitter { this.networkController = new NetworkController(initState.NetworkController); this.networkController.setInfuraProjectId(opts.infuraProjectId); + // now we can initialize the RPC provider, which other controllers require + this.initializeProvider(); + this.provider = this.networkController.getProviderAndBlockTracker().provider; + this.blockTracker = this.networkController.getProviderAndBlockTracker().blockTracker; + this.preferencesController = new PreferencesController({ initState: initState.PreferencesController, initLangCode: opts.initLangCode, openPopup: opts.openPopup, network: this.networkController, + provider: this.provider, migrateAddressBookState: this.migrateAddressBookState.bind(this), }); @@ -183,11 +189,6 @@ export default class MetamaskController extends EventEmitter { initState.NotificationController, ); - // now we can initialize the RPC provider, which other controllers require - this.initializeProvider(); - this.provider = this.networkController.getProviderAndBlockTracker().provider; - this.blockTracker = this.networkController.getProviderAndBlockTracker().blockTracker; - // token exchange rate tracker this.tokenRatesController = new TokenRatesController({ preferences: this.preferencesController.store, @@ -322,6 +323,9 @@ export default class MetamaskController extends EventEmitter { getPermittedAccounts: this.permissionsController.getAccounts.bind( this.permissionsController, ), + getProviderConfig: this.networkController.getProviderConfig.bind( + this.networkController, + ), networkStore: this.networkController.networkStore, getCurrentChainId: this.networkController.getCurrentChainId.bind( this.networkController, @@ -676,7 +680,6 @@ export default class MetamaskController extends EventEmitter { setUsePhishDetect: this.setUsePhishDetect.bind(this), setIpfsGateway: this.setIpfsGateway.bind(this), setParticipateInMetaMetrics: this.setParticipateInMetaMetrics.bind(this), - setMetaMetricsSendCount: this.setMetaMetricsSendCount.bind(this), setFirstTimeFlowType: this.setFirstTimeFlowType.bind(this), setCurrentLocale: this.setCurrentLocale.bind(this), markPasswordForgotten: this.markPasswordForgotten.bind(this), @@ -728,6 +731,10 @@ export default class MetamaskController extends EventEmitter { preferencesController, ), addToken: nodeify(preferencesController.addToken, preferencesController), + updateTokenType: nodeify( + preferencesController.updateTokenType, + preferencesController, + ), removeToken: nodeify( preferencesController.removeToken, preferencesController, @@ -2760,18 +2767,6 @@ export default class MetamaskController extends EventEmitter { } } - setMetaMetricsSendCount(val, cb) { - try { - this.metaMetricsController.setMetaMetricsSendCount(val); - cb(null); - return; - } catch (err) { - cb(err); - // eslint-disable-next-line no-useless-return - return; - } - } - /** * Sets the type of first time flow the user wishes to follow: create or import * @param {string} type - Indicates the type of first time flow the user wishes to follow diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 87fed2aef..f75bf7b50 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -21,6 +21,11 @@ const firstTimeState = { rpcUrl: 'http://localhost:8545', chainId: '0x539', }, + networkDetails: { + EIPS: { + 1559: false, + }, + }, }, }; diff --git a/app/scripts/migrations/062.js b/app/scripts/migrations/062.js new file mode 100644 index 000000000..54b52b967 --- /dev/null +++ b/app/scripts/migrations/062.js @@ -0,0 +1,28 @@ +import { cloneDeep } from 'lodash'; + +const version = 62; + +/** + * Removes metaMetricsSendCount from MetaMetrics controller + */ +export default { + version, + async migrate(originalVersionedData) { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + const state = versionedData.data; + const newState = transformState(state); + versionedData.data = newState; + return versionedData; + }, +}; + +function transformState(state) { + if (state.MetaMetricsController) { + const { metaMetricsSendCount } = state.MetaMetricsController; + if (metaMetricsSendCount !== undefined) { + delete state.MetaMetricsController.metaMetricsSendCount; + } + } + return state; +} diff --git a/app/scripts/migrations/062.test.js b/app/scripts/migrations/062.test.js new file mode 100644 index 000000000..44229ac1d --- /dev/null +++ b/app/scripts/migrations/062.test.js @@ -0,0 +1,80 @@ +import { strict as assert } from 'assert'; +import migration62 from './062'; + +describe('migration #62', function () { + it('should update the version metadata', async function () { + const oldStorage = { + meta: { + version: 61, + }, + data: {}, + }; + + const newStorage = await migration62.migrate(oldStorage); + assert.deepEqual(newStorage.meta, { + version: 62, + }); + }); + + it('should remove metaMetricsSendCount from MetaMetricsController', async function () { + const oldStorage = { + meta: {}, + data: { + MetaMetricsController: { + metaMetricsSendCount: 1, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration62.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + MetaMetricsController: { + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it('should remove metaMetricsSendCount from MetaMetricsController (falsey but defined)', async function () { + const oldStorage = { + meta: {}, + data: { + MetaMetricsController: { + metaMetricsSendCount: 0, + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration62.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + MetaMetricsController: { + bar: 'baz', + }, + foo: 'bar', + }); + }); + + it('should not modify MetaMetricsController when metaMetricsSendCount is undefined', async function () { + const oldStorage = { + meta: {}, + data: { + MetaMetricsController: { + bar: 'baz', + }, + foo: 'bar', + }, + }; + + const newStorage = await migration62.migrate(oldStorage); + assert.deepStrictEqual(newStorage.data, { + MetaMetricsController: { + bar: 'baz', + }, + foo: 'bar', + }); + }); +}); diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 175ee0044..389b4ef4a 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -66,6 +66,7 @@ const migrations = [ require('./059').default, require('./060').default, require('./061').default, + require('./062').default, ]; export default migrations; diff --git a/development/build/scripts.js b/development/build/scripts.js index f0e399d47..7fa0595e1 100644 --- a/development/build/scripts.js +++ b/development/build/scripts.js @@ -44,7 +44,7 @@ const materialUIDependencies = ['@material-ui/core']; const reactDepenendencies = dependencies.filter((dep) => dep.match(/react/u)); const externalDependenciesMap = { - background: ['3box'], + background: ['3box', '@ethereumjs/common', 'unicode-confusables'], ui: [...materialUIDependencies, ...reactDepenendencies], }; @@ -433,6 +433,7 @@ function getEnvironmentVariables({ devMode, testing }) { PUBNUB_SUB_KEY: process.env.PUBNUB_SUB_KEY || '', PUBNUB_PUB_KEY: process.env.PUBNUB_PUB_KEY || '', CONF: devMode ? metamaskrc : {}, + SHOW_EIP_1559_UI: process.env.SHOW_EIP_1559_UI === '1', SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN_DEV: metamaskrc.SENTRY_DSN_DEV, INFURA_PROJECT_ID: testing diff --git a/development/build/static.js b/development/build/static.js index 9ae309e53..636af1106 100644 --- a/development/build/static.js +++ b/development/build/static.js @@ -76,7 +76,7 @@ for (const tag of languageTags) { const copyTargetsDev = [ ...copyTargets, { - src: './app/scripts/', + src: './development', pattern: '/chromereload.js', dest: ``, }, diff --git a/development/lib/exit-with-error.js b/development/lib/exit-with-error.js new file mode 100644 index 000000000..1ef855887 --- /dev/null +++ b/development/lib/exit-with-error.js @@ -0,0 +1,16 @@ +/** + * Exit the process with an error message. + * + * Note that this should be called before the process ends, but it will not + * itself end the process. This is because the Node.js documentation strongly + * advises against calling `process.exit` directly. + * + * @param {string} errorMessage - The error message that is causing the non- + * zero exit code. + */ +function exitWithError(errorMessage) { + console.error(errorMessage); + process.exitCode = 1; +} + +module.exports = { exitWithError }; diff --git a/development/lib/retry.js b/development/lib/retry.js new file mode 100644 index 000000000..356f1a08f --- /dev/null +++ b/development/lib/retry.js @@ -0,0 +1,23 @@ +/** + * Run the given function, retrying it upon failure until reaching the + * specified number of retries. + * + * @param {number} retries - The number of retries upon failure to attempt. + * @param {function} functionToRetry - The function that will be retried upon failure. + */ +async function retry(retries, functionToRetry) { + let attempts = 0; + while (attempts <= retries) { + try { + await functionToRetry(); + return; + } catch (error) { + console.error(error); + } finally { + attempts += 1; + } + } + throw new Error('Retry limit reached'); +} + +module.exports = { retry }; diff --git a/jest.config.js b/jest.config.js index 6fce2ce4d..f236fbb90 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,11 @@ module.exports = { restoreMocks: true, coverageDirectory: 'jest-coverage/', collectCoverageFrom: ['/ui/**/swaps/**'], - coveragePathIgnorePatterns: ['.stories.js', '.snap'], + coveragePathIgnorePatterns: [ + '.stories.js', + '.snap', + '**/shared/**/?(*.)+(test).js', + ], coverageThreshold: { global: { branches: 32.75, @@ -13,5 +17,5 @@ module.exports = { }, setupFiles: ['./test/setup.js', './test/env.js'], setupFilesAfterEnv: ['./test/jest/setup.js'], - testMatch: ['**/ui/**/?(*.)+(test).js'], + testMatch: ['ui/**/?(*.)+(test).js', '**/shared/**/?(*.)+(test).js'], }; diff --git a/package.json b/package.json index aea30bbba..c8fa7d063 100644 --- a/package.json +++ b/package.json @@ -23,20 +23,21 @@ "dapp-chain": "GANACHE_ARGS='-b 2' concurrently -k -n ganache,dapp -p '[{time}][{name}]' 'yarn ganache:start' 'sleep 5 && yarn dapp'", "forwarder": "node ./development/static-server.js ./node_modules/@metamask/forwarder/dist/ --port 9010", "dapp-forwarder": "concurrently -k -n forwarder,dapp -p '[{time}][{name}]' 'yarn forwarder' 'yarn dapp'", - "test:unit": "mocha --exit --require test/env.js --require test/setup.js --recursive './{app,shared}/**/*.test.js'", + "test:unit": "mocha --exit --require test/env.js --require test/setup.js --recursive './app/**/*.test.js'", "test:unit:global": "mocha --exit --require test/env.js --require test/setup.js --recursive test/unit-global/*.test.js", "test:unit:jest": "jest", "test:unit:jest:watch": "jest --watch", "test:unit:jest:watch:silent": "jest --watch --silent", "test:unit:jest:ci": "jest --maxWorkers=2", - "test:unit:lax": "mocha --exit --require test/env.js --require test/setup.js --ignore './app/scripts/controllers/permissions/*.test.js' --recursive './{app,shared}/**/*.test.js'", + "test:unit:lax": "mocha --exit --require test/env.js --require test/setup.js --ignore './app/scripts/controllers/permissions/*.test.js' --recursive './app/**/*.test.js'", "test:unit:strict": "mocha --exit --require test/env.js --require test/setup.js --recursive './app/scripts/controllers/permissions/*.test.js'", "test:unit:path": "mocha --exit --require test/env.js --require test/setup.js --recursive", - "test:e2e:chrome": "SELENIUM_BROWSER=chrome test/e2e/run-all.sh", - "test:e2e:chrome:metrics": "SELENIUM_BROWSER=chrome mocha test/e2e/metrics.spec.js", - "test:e2e:firefox": "SELENIUM_BROWSER=firefox test/e2e/run-all.sh", - "test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox mocha test/e2e/metrics.spec.js", + "test:e2e:chrome": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js", + "test:e2e:chrome:metrics": "SELENIUM_BROWSER=chrome node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js", + "test:e2e:firefox": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js", + "test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox node test/e2e/run-e2e-test.js test/e2e/metrics.spec.js", "test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html", + "test:e2e:single": "node test/e2e/run-e2e-test.js", "test:coverage:jest": "jest --coverage --maxWorkers=2", "test:coverage:strict": "nyc --check-coverage yarn test:unit:strict", "test:coverage:path": "nyc --check-coverage yarn test:unit:path", @@ -53,7 +54,7 @@ "verify-locales": "node ./development/verify-locale-strings.js", "verify-locales:fix": "node ./development/verify-locale-strings.js --fix", "mozilla-lint": "addons-linter dist/firefox", - "watch": "mocha --watch --require test/env.js --require test/setup.js --reporter min --recursive \"test/unit/**/*.js\" \"ui/**/*.test.js\" \"shared/**/*.test.js\"", + "watch": "mocha --watch --require test/env.js --require test/setup.js --reporter min --recursive \"test/unit/**/*.js\" \"ui/**/*.test.js\"", "devtools:react": "react-devtools", "devtools:redux": "remotedev --hostname=localhost --port=8000", "start:dev": "concurrently -k -n build,react,redux yarn:start yarn:devtools:react yarn:devtools:redux", @@ -92,13 +93,15 @@ "3box": "^1.10.2", "@babel/runtime": "^7.5.5", "@download/blockies": "^1.0.3", + "@ethereumjs/common": "^2.3.1", + "@ethereumjs/tx": "^3.2.1", "@formatjs/intl-relativetimeformat": "^5.2.6", "@fortawesome/fontawesome-free": "^5.13.0", "@lavamoat/preinstall-always-fail": "^1.0.0", "@material-ui/core": "^4.11.0", "@metamask/contract-metadata": "^1.26.0", - "@metamask/controllers": "^9.0.0", - "@metamask/eth-ledger-bridge-keyring": "^0.5.0", + "@metamask/controllers": "^10.0.0", + "@metamask/eth-ledger-bridge-keyring": "^0.6.0", "@metamask/eth-token-tracker": "^3.0.1", "@metamask/etherscan-link": "^2.1.0", "@metamask/jazzicon": "^2.0.0", @@ -134,10 +137,9 @@ "eth-query": "^2.1.2", "eth-rpc-errors": "^4.0.2", "eth-sig-util": "^3.0.0", - "eth-trezor-keyring": "^0.6.0", + "eth-trezor-keyring": "^0.7.0", "ethereum-ens-network-map": "^1.0.2", "ethereumjs-abi": "^0.6.4", - "ethereumjs-tx": "1.3.7", "ethereumjs-util": "^7.0.10", "ethereumjs-wallet": "^0.6.4", "ethers": "^5.0.8", @@ -151,6 +153,7 @@ "fast-safe-stringify": "^2.0.7", "fuse.js": "^3.2.0", "globalthis": "^1.0.1", + "human-standard-collectible-abi": "^1.0.2", "human-standard-token-abi": "^2.0.0", "immer": "^8.0.1", "json-rpc-engine": "^6.1.0", @@ -312,7 +315,8 @@ "vinyl-buffer": "^1.0.1", "vinyl-source-stream": "^2.0.0", "watchify": "^3.11.1", - "webpack": "^4.41.6" + "webpack": "^4.41.6", + "yargs": "^17.0.1" }, "engines": { "node": "^14.15.1", diff --git a/patches/selenium-webdriver+4.0.0-alpha.7.patch b/patches/selenium-webdriver+4.0.0-alpha.7.patch new file mode 100644 index 000000000..53144785b --- /dev/null +++ b/patches/selenium-webdriver+4.0.0-alpha.7.patch @@ -0,0 +1,19 @@ +diff --git a/node_modules/selenium-webdriver/chromium.js b/node_modules/selenium-webdriver/chromium.js +index d828ce5..87176f4 100644 +--- a/node_modules/selenium-webdriver/chromium.js ++++ b/node_modules/selenium-webdriver/chromium.js +@@ -197,6 +197,14 @@ class ServiceBuilder extends remote.DriverService.Builder { + return this.addArguments('--log-path=' + path); + } + ++ /** ++ * Enables Chrome logging. ++ * @returns {!ServiceBuilder} A self reference. ++ */ ++ enableChromeLogging() { ++ return this.addArguments('--enable-chrome-logs'); ++ } ++ + /** + * Enables verbose logging. + * @return {!ServiceBuilder} A self reference. diff --git a/test/e2e/benchmark.js b/test/e2e/benchmark.js index 9c3b7db00..fc8843c58 100644 --- a/test/e2e/benchmark.js +++ b/test/e2e/benchmark.js @@ -2,7 +2,11 @@ const path = require('path'); const { promises: fs, constants: fsConstants } = require('fs'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); const ttest = require('ttest'); +const { retry } = require('../../development/lib/retry'); +const { exitWithError } = require('../../development/lib/exit-with-error'); const { withFixtures } = require('./helpers'); const { PAGES } = require('./webdriver/driver'); @@ -38,6 +42,9 @@ const minResult = calculateResult((array) => Math.min(...array)); const maxResult = calculateResult((array) => Math.max(...array)); const averageResult = calculateResult((array) => calculateAverage(array)); const standardDeviationResult = calculateResult((array) => { + if (array.length === 1) { + return 0; + } const average = calculateAverage(array); const squareDiffs = array.map((value) => Math.pow(value - average, 2)); return Math.sqrt(calculateAverage(squareDiffs)); @@ -46,15 +53,19 @@ const standardDeviationResult = calculateResult((array) => { const calculateMarginOfError = (array) => ttest(array).confidence()[1] - calculateAverage(array); const marginOfErrorResult = calculateResult((array) => - calculateMarginOfError(array), + array.length === 1 ? 0 : calculateMarginOfError(array), ); -async function profilePageLoad(pages, numSamples) { +async function profilePageLoad(pages, numSamples, retries) { const results = {}; for (const pageName of pages) { const runResults = []; for (let i = 0; i < numSamples; i += 1) { - runResults.push(await measurePage(pageName)); + let result; + await retry(retries, async () => { + result = await measurePage(pageName); + }); + runResults.push(result); } if (runResults.some((result) => result.navigation.lenth > 1)) { @@ -126,66 +137,63 @@ async function getFirstParentDirectoryThatExists(directory) { } async function main() { - const args = process.argv.slice(2); + const { argv } = yargs(hideBin(process.argv)).usage( + '$0 [options]', + 'Run a page load benchmark', + (_yargs) => + _yargs + .option('pages', { + array: true, + default: ['home'], + description: + 'Set the page(s) to be benchmarked. This flag can accept multiple values (space-separated).', + choices: ALL_PAGES, + }) + .option('samples', { + default: DEFAULT_NUM_SAMPLES, + description: 'The number of times the benchmark should be run.', + type: 'number', + }) + .option('out', { + description: + 'Output filename. Output printed to STDOUT of this is omitted.', + type: 'string', + normalize: true, + }) + .option('retries', { + default: 0, + description: + 'Set how many times each benchmark sample should be retried upon failure.', + type: 'number', + }), + ); + + const { pages, samples, out, retries } = argv; - let pages = ['home']; - let numSamples = DEFAULT_NUM_SAMPLES; - let outputPath; let outputDirectory; let existingParentDirectory; - - while (args.length) { - if (/^(--pages|-p)$/u.test(args[0])) { - if (args[1] === undefined) { - throw new Error('Missing pages argument'); - } - pages = args[1].split(','); - for (const page of pages) { - if (!ALL_PAGES.includes(page)) { - throw new Error(`Invalid page: '${page}`); - } - } - args.splice(0, 2); - } else if (/^(--samples|-s)$/u.test(args[0])) { - if (args[1] === undefined) { - throw new Error('Missing number of samples'); - } - numSamples = parseInt(args[1], 10); - if (isNaN(numSamples)) { - throw new Error(`Invalid 'samples' argument given: '${args[1]}'`); - } - args.splice(0, 2); - } else if (/^(--out|-o)$/u.test(args[0])) { - if (args[1] === undefined) { - throw new Error('Missing output filename'); - } - outputPath = path.resolve(args[1]); - outputDirectory = path.dirname(outputPath); - existingParentDirectory = await getFirstParentDirectoryThatExists( - outputDirectory, - ); - if (!(await isWritable(existingParentDirectory))) { - throw new Error(`Specified directory is not writable: '${args[1]}'`); - } - args.splice(0, 2); - } else { - throw new Error(`Unrecognized argument: '${args[0]}'`); + if (out) { + outputDirectory = path.dirname(out); + existingParentDirectory = await getFirstParentDirectoryThatExists( + outputDirectory, + ); + if (!(await isWritable(existingParentDirectory))) { + throw new Error('Specified output file directory is not writable'); } } - const results = await profilePageLoad(pages, numSamples); + const results = await profilePageLoad(pages, samples, retries); - if (outputPath) { + if (out) { if (outputDirectory !== existingParentDirectory) { await fs.mkdir(outputDirectory, { recursive: true }); } - await fs.writeFile(outputPath, JSON.stringify(results, null, 2)); + await fs.writeFile(out, JSON.stringify(results, null, 2)); } else { console.log(JSON.stringify(results, null, 2)); } } -main().catch((e) => { - console.error(e); - process.exit(1); +main().catch((error) => { + exitWithError(error); }); diff --git a/test/e2e/fixtures/address-entry/state.json b/test/e2e/fixtures/address-entry/state.json index 0bc9f3b9d..c17fd7e0c 100644 --- a/test/e2e/fixtures/address-entry/state.json +++ b/test/e2e/fixtures/address-entry/state.json @@ -128,7 +128,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "useNativeCurrencyAsPrimaryCurrency": true diff --git a/test/e2e/fixtures/connected-state/state.json b/test/e2e/fixtures/connected-state/state.json index baebea6b7..5d3d2f68e 100644 --- a/test/e2e/fixtures/connected-state/state.json +++ b/test/e2e/fixtures/connected-state/state.json @@ -138,7 +138,6 @@ }, "completedOnboarding": true, "metaMetricsId": null, - "metaMetricsSendCount": 0, "ipfsGateway": "dweb.link", "selectedAddress": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1" }, diff --git a/test/e2e/fixtures/custom-rpc/state.json b/test/e2e/fixtures/custom-rpc/state.json index aa93a938c..54d6bb4a8 100644 --- a/test/e2e/fixtures/custom-rpc/state.json +++ b/test/e2e/fixtures/custom-rpc/state.json @@ -129,7 +129,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "useNativeCurrencyAsPrimaryCurrency": true diff --git a/test/e2e/fixtures/custom-token/state.json b/test/e2e/fixtures/custom-token/state.json index 1d3a24437..bd85436ad 100644 --- a/test/e2e/fixtures/custom-token/state.json +++ b/test/e2e/fixtures/custom-token/state.json @@ -121,7 +121,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "useNativeCurrencyAsPrimaryCurrency": true diff --git a/test/e2e/fixtures/import-ui/state.json b/test/e2e/fixtures/import-ui/state.json index 1e6572574..758a85d66 100644 --- a/test/e2e/fixtures/import-ui/state.json +++ b/test/e2e/fixtures/import-ui/state.json @@ -125,8 +125,7 @@ }, "MetaMetricsController": { "participateInMetaMetrics": false, - "metaMetricsId": null, - "metaMetricsSendCount": 1 + "metaMetricsId": null }, "PermissionsController": { "permissionsRequests": [], diff --git a/test/e2e/fixtures/imported-account/state.json b/test/e2e/fixtures/imported-account/state.json index 7c4a2108a..407bbbd83 100644 --- a/test/e2e/fixtures/imported-account/state.json +++ b/test/e2e/fixtures/imported-account/state.json @@ -114,7 +114,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "useNativeCurrencyAsPrimaryCurrency": true diff --git a/test/e2e/fixtures/localization/state.json b/test/e2e/fixtures/localization/state.json index 009e8ac77..73a3b97b2 100644 --- a/test/e2e/fixtures/localization/state.json +++ b/test/e2e/fixtures/localization/state.json @@ -114,7 +114,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "showFiatInTestnets": true, diff --git a/test/e2e/fixtures/metrics-enabled/state.json b/test/e2e/fixtures/metrics-enabled/state.json index 8e0f082d5..67cbf9977 100644 --- a/test/e2e/fixtures/metrics-enabled/state.json +++ b/test/e2e/fixtures/metrics-enabled/state.json @@ -138,7 +138,6 @@ }, "completedOnboarding": true, "metaMetricsId": "fake-metrics-id", - "metaMetricsSendCount": 0, "ipfsGateway": "dweb.link", "selectedAddress": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1" }, diff --git a/test/e2e/fixtures/send-edit/state.json b/test/e2e/fixtures/send-edit/state.json index a5f3a8bab..6c9658c28 100644 --- a/test/e2e/fixtures/send-edit/state.json +++ b/test/e2e/fixtures/send-edit/state.json @@ -115,7 +115,6 @@ "knownMethodData": {}, "lostIdentities": {}, "metaMetricsId": null, - "metaMetricsSendCount": 0, "participateInMetaMetrics": false, "preferences": { "useNativeCurrencyAsPrimaryCurrency": true diff --git a/test/e2e/fixtures/threebox-enabled/state.json b/test/e2e/fixtures/threebox-enabled/state.json index f182c5d47..abad9782b 100644 --- a/test/e2e/fixtures/threebox-enabled/state.json +++ b/test/e2e/fixtures/threebox-enabled/state.json @@ -120,8 +120,7 @@ }, "MetaMetricsController": { "metaMetricsId": null, - "participateInMetaMetrics": false, - "metaMetricsSendCount": 0 + "participateInMetaMetrics": false }, "ThreeBoxController": { "threeBoxSyncingAllowed": true, diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index ae8b4d6c5..cde3b0243 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -33,6 +33,7 @@ async function withFixtures(options, testSuite) { let segmentStub; let webDriver; + let failed = false; try { await ganacheServer.start(ganacheOptions); if (ganacheOptions?.concurrent) { @@ -103,6 +104,7 @@ async function withFixtures(options, testSuite) { } } } catch (error) { + failed = true; if (webDriver) { try { await webDriver.verboseReportOnFailure(title); @@ -112,26 +114,28 @@ async function withFixtures(options, testSuite) { } throw error; } finally { - await fixtureServer.stop(); - await ganacheServer.quit(); - if (ganacheOptions?.concurrent) { - await secondaryGanacheServer.quit(); - } - if (webDriver) { - await webDriver.quit(); - } - if (dappServer) { - await new Promise((resolve, reject) => { - dappServer.close((error) => { - if (error) { - return reject(error); - } - return resolve(); + if (!failed || process.env.E2E_LEAVE_RUNNING !== 'true') { + await fixtureServer.stop(); + await ganacheServer.quit(); + if (ganacheOptions?.concurrent) { + await secondaryGanacheServer.quit(); + } + if (webDriver) { + await webDriver.quit(); + } + if (dappServer) { + await new Promise((resolve, reject) => { + dappServer.close((error) => { + if (error) { + return reject(error); + } + return resolve(); + }); }); - }); - } - if (segmentServer) { - await segmentServer.stop(); + } + if (segmentServer) { + await segmentServer.stop(); + } } } } diff --git a/test/e2e/metamask-ui.spec.js b/test/e2e/metamask-ui.spec.js index b813a597d..63b4c8c02 100644 --- a/test/e2e/metamask-ui.spec.js +++ b/test/e2e/metamask-ui.spec.js @@ -1,24 +1,44 @@ const { strict: assert } = require('assert'); +const path = require('path'); const enLocaleMessages = require('../../app/_locales/en/messages.json'); +const createStaticServer = require('../../development/create-static-server'); const { tinyDelayMs, regularDelayMs, largeDelayMs } = require('./helpers'); const { buildWebDriver } = require('./webdriver'); const Ganache = require('./ganache'); const ganacheServer = new Ganache(); +const dappPort = 8080; describe('MetaMask', function () { let driver; + let dappServer; let tokenAddress; const testSeedPhrase = 'phrase upgrade clock rough situate wedding elder clever doctor stamp excess tent'; - this.timeout(0); this.bail(true); + let failed = false; + before(async function () { await ganacheServer.start(); + const dappDirectory = path.resolve( + __dirname, + '..', + '..', + 'node_modules', + '@metamask', + 'test-dapp', + 'dist', + ); + dappServer = createStaticServer(dappDirectory); + dappServer.listen(dappPort); + await new Promise((resolve, reject) => { + dappServer.on('listening', resolve); + dappServer.on('error', reject); + }); const result = await buildWebDriver(); driver = result.driver; await driver.navigate(); @@ -36,13 +56,25 @@ describe('MetaMask', function () { } } if (this.currentTest.state === 'failed') { + failed = true; await driver.verboseReportOnFailure(this.currentTest.title); } }); after(async function () { + if (process.env.E2E_LEAVE_RUNNING === 'true' && failed) { + return; + } await ganacheServer.quit(); await driver.quit(); + await new Promise((resolve, reject) => { + dappServer.close((error) => { + if (error) { + return reject(error); + } + return resolve(); + }); + }); }); describe('Going through the first time flow', function () { @@ -174,50 +206,6 @@ describe('MetaMask', function () { }); }); - describe('Lock an unlock', function () { - it('logs out of the account', async function () { - await driver.clickElement('.account-menu__icon'); - await driver.delay(regularDelayMs); - - const lockButton = await driver.findClickableElement( - '.account-menu__lock-button', - ); - assert.equal(await lockButton.getText(), 'Lock'); - await lockButton.click(); - await driver.delay(regularDelayMs); - }); - - it('accepts the account password after lock', async function () { - await driver.fill('#password', 'correct horse battery staple'); - await driver.press('#password', driver.Key.ENTER); - await driver.delay(largeDelayMs * 4); - }); - }); - - describe('Add account', function () { - it('choose Create Account from the account menu', async function () { - await driver.clickElement('.account-menu__icon'); - await driver.delay(regularDelayMs); - - await driver.clickElement({ text: 'Create Account', tag: 'div' }); - await driver.delay(regularDelayMs); - }); - - it('set account name', async function () { - await driver.fill('.new-account-create-form input', '2nd account'); - await driver.delay(regularDelayMs); - - await driver.clickElement({ text: 'Create', tag: 'button' }); - await driver.delay(largeDelayMs); - }); - - it('should display correct account name', async function () { - const accountName = await driver.findElement('.selected-account__name'); - assert.equal(await accountName.getText(), '2nd account'); - await driver.delay(regularDelayMs); - }); - }); - describe('Import Secret Recovery Phrase', function () { it('logs out of the vault', async function () { await driver.clickElement('.account-menu__icon'); @@ -265,184 +253,6 @@ describe('MetaMask', function () { }); }); - describe('Send ETH from inside MetaMask using default gas', function () { - it('starts a send transaction', async function () { - await driver.clickElement('[data-testid="eth-overview-send"]'); - await driver.delay(regularDelayMs); - - await driver.fill( - 'input[placeholder="Search, public address (0x), or ENS"]', - '0x2f318C334780961FB129D2a6c30D0763d9a5C970', - ); - - const inputAmount = await driver.findElement('.unit-input__input'); - await inputAmount.fill('1000'); - - const errorAmount = await driver.findElement('.send-v2__error-amount'); - assert.equal( - await errorAmount.getText(), - 'Insufficient funds.', - 'send screen should render an insufficient fund error message', - ); - - await inputAmount.press(driver.Key.BACK_SPACE); - await driver.delay(50); - await inputAmount.press(driver.Key.BACK_SPACE); - await driver.delay(50); - await inputAmount.press(driver.Key.BACK_SPACE); - await driver.delay(tinyDelayMs); - - await driver.assertElementNotPresent('.send-v2__error-amount'); - - const amountMax = await driver.findClickableElement( - '.send-v2__amount-max', - ); - await amountMax.click(); - - let inputValue = await inputAmount.getAttribute('value'); - - assert(Number(inputValue) > 99); - - await amountMax.click(); - - assert.equal(await inputAmount.isEnabled(), true); - - await inputAmount.fill('1'); - - inputValue = await inputAmount.getAttribute('value'); - assert.equal(inputValue, '1'); - await driver.delay(regularDelayMs); - - // Continue to next screen - await driver.clickElement({ text: 'Next', tag: 'button' }); - await driver.delay(regularDelayMs); - }); - - it('confirms the transaction', async function () { - await driver.clickElement({ text: 'Confirm', tag: 'button' }); - await driver.delay(largeDelayMs * 2); - }); - - it('finds the transaction in the transactions list', async function () { - await driver.clickElement('[data-testid="home__activity-tab"]'); - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .transaction-list-item', - ); - return confirmedTxes.length === 1; - }, 10000); - - await driver.waitForSelector({ - css: '.transaction-list-item__primary-currency', - text: '-1 ETH', - }); - }); - }); - - describe('Send ETH from inside MetaMask using fast gas option', function () { - it('starts a send transaction', async function () { - await driver.clickElement('[data-testid="eth-overview-send"]'); - await driver.delay(regularDelayMs); - - await driver.fill( - 'input[placeholder="Search, public address (0x), or ENS"]', - '0x2f318C334780961FB129D2a6c30D0763d9a5C970', - ); - - const inputAmount = await driver.findElement('.unit-input__input'); - await inputAmount.fill('1'); - - const inputValue = await inputAmount.getAttribute('value'); - assert.equal(inputValue, '1'); - - // Set the gas price - await driver.clickElement({ text: 'Fast', tag: 'button/div/div' }); - await driver.delay(regularDelayMs); - - // Continue to next screen - await driver.clickElement({ text: 'Next', tag: 'button' }); - await driver.delay(regularDelayMs); - }); - - it('confirms the transaction', async function () { - await driver.clickElement({ text: 'Confirm', tag: 'button' }); - await driver.delay(largeDelayMs); - }); - - it('finds the transaction in the transactions list', async function () { - await driver.waitForSelector( - '.transaction-list__completed-transactions .transaction-list-item:nth-child(2)', - ); - await driver.waitForSelector({ - css: '.transaction-list-item__primary-currency', - text: '-1 ETH', - }); - }); - }); - - describe('Send ETH from inside MetaMask using advanced gas modal', function () { - it('starts a send transaction', async function () { - await driver.clickElement('[data-testid="eth-overview-send"]'); - await driver.delay(regularDelayMs); - - await driver.fill( - 'input[placeholder="Search, public address (0x), or ENS"]', - '0x2f318C334780961FB129D2a6c30D0763d9a5C970', - ); - - const inputAmount = await driver.findElement('.unit-input__input'); - await inputAmount.fill('1'); - - const inputValue = await inputAmount.getAttribute('value'); - assert.equal(inputValue, '1'); - - // Set the gas limit - await driver.clickElement('.advanced-gas-options-btn'); - await driver.delay(regularDelayMs); - - // wait for gas modal to be visible - const gasModal = await driver.findVisibleElement('span .modal'); - - await driver.clickElement({ text: 'Save', tag: 'button' }); - - // Wait for gas modal to be removed from DOM - await gasModal.waitForElementState('hidden'); - await driver.delay(regularDelayMs); - - // Continue to next screen - await driver.clickElement({ text: 'Next', tag: 'button' }); - await driver.delay(regularDelayMs); - }); - - it('confirms the transaction', async function () { - const transactionAmounts = await driver.findElements( - '.currency-display-component__text', - ); - const transactionAmount = transactionAmounts[0]; - assert.equal(await transactionAmount.getText(), '1'); - - await driver.clickElement({ text: 'Confirm', tag: 'button' }); - await driver.delay(largeDelayMs); - }); - - it('finds the transaction in the transactions list', async function () { - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .transaction-list-item', - ); - return confirmedTxes.length === 3; - }, 10000); - - await driver.waitForSelector( - { - css: '.transaction-list-item__primary-currency', - text: '-1 ETH', - }, - { timeout: 10000 }, - ); - }); - }); - describe('Send ETH from dapp using advanced gas controls', function () { let windowHandles; let extension; @@ -553,11 +363,12 @@ describe('MetaMask', function () { }); it('finds the transaction in the transactions list', async function () { + await driver.clickElement('[data-testid="home__activity-tab"]'); await driver.wait(async () => { const confirmedTxes = await driver.findElements( '.transaction-list__completed-transactions .transaction-list-item', ); - return confirmedTxes.length === 4; + return confirmedTxes.length === 1; }, 10000); await driver.waitForSelector({ @@ -772,7 +583,7 @@ describe('MetaMask', function () { const confirmedTxes = await driver.findElements( '.transaction-list__completed-transactions .transaction-list-item', ); - return confirmedTxes.length === 5; + return confirmedTxes.length === 2; }, 10000); }); }); @@ -823,7 +634,7 @@ describe('MetaMask', function () { await driver.delay(largeDelayMs); await driver.waitForSelector( - '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(6)', + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(3)', ); await driver.waitForSelector( @@ -905,7 +716,7 @@ describe('MetaMask', function () { await driver.delay(regularDelayMs); await driver.waitForSelector( - '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(7)', + '.transaction-list__completed-transactions .transaction-list-item:nth-of-type(4)', { timeout: 10000 }, ); await driver.waitForSelector( @@ -940,7 +751,7 @@ describe('MetaMask', function () { const confirmedTxes = await driver.findElements( '.transaction-list__completed-transactions .transaction-list-item', ); - return confirmedTxes.length === 8; + return confirmedTxes.length === 5; }, 10000); await driver.waitForSelector( @@ -959,12 +770,12 @@ describe('MetaMask', function () { const balance = await driver.waitForSelector( { css: '[data-testid="eth-overview__primary-currency"]', - text: '87.', + text: '90.', }, { timeout: 10000 }, ); const tokenAmount = await balance.getText(); - assert.ok(/^87.*\s*ETH.*$/u.test(tokenAmount)); + assert.ok(/^90.*\s*ETH.*$/u.test(tokenAmount)); await driver.delay(regularDelayMs); }); }); diff --git a/test/e2e/run-all.js b/test/e2e/run-all.js new file mode 100644 index 000000000..a73021bd4 --- /dev/null +++ b/test/e2e/run-all.js @@ -0,0 +1,57 @@ +const path = require('path'); +const { promises: fs } = require('fs'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); +const { runInShell } = require('../../development/lib/run-command'); +const { exitWithError } = require('../../development/lib/exit-with-error'); + +async function main() { + const { argv } = yargs(hideBin(process.argv)) + .usage( + '$0 [options]', + 'Run all E2E tests, with a variable number of retries.', + (_yargs) => + _yargs + .option('browser', { + description: `Set the browser used; either 'chrome' or 'firefox'.`, + type: 'string', + choices: ['chrome', 'firefox'], + }) + .option('retries', { + description: + 'Set how many times the test should be retried upon failure.', + type: 'number', + }), + ) + .strict() + .help('help'); + + const { browser, retries } = argv; + + const testDir = path.join(__dirname, 'tests'); + const metamaskUiTest = path.join(__dirname, 'metamask-ui.spec.js'); + + const testFilenames = await fs.readdir(testDir); + const testPaths = testFilenames.map((filename) => + path.join(testDir, filename), + ); + const allE2eTestPaths = [...testPaths, metamaskUiTest]; + + const runE2eTestPath = path.join(__dirname, 'run-e2e-test.js'); + + const args = [runE2eTestPath]; + if (browser) { + args.push('--browser', browser); + } + if (retries) { + args.push('--retries', retries); + } + + for (const testPath of allE2eTestPaths) { + await runInShell('node', [...args, testPath]); + } +} + +main().catch((error) => { + exitWithError(error); +}); diff --git a/test/e2e/run-all.sh b/test/e2e/run-all.sh index 18c3443c9..88cc695d8 100755 --- a/test/e2e/run-all.sh +++ b/test/e2e/run-all.sh @@ -5,33 +5,11 @@ set -e set -u set -o pipefail -retry () { - retry=0 - limit="${METAMASK_E2E_RETRY_LIMIT:-3}" - while [[ $retry -lt $limit ]] - do - "$@" && break - retry=$(( retry + 1 )) - sleep 1 - done +readonly __DIR__=$( cd "${BASH_SOURCE[0]%/*}" && pwd ) - if [[ $retry == "$limit" ]] - then - exit 1 - fi -} - -export PATH="$PATH:./node_modules/.bin" - -for spec in test/e2e/tests/*.spec.js +for spec in "${__DIR__}"/tests/*.spec.js do - retry mocha --no-timeouts "${spec}" + node "${__DIR__}/run-e2e-test.js" "${spec}" done -retry concurrently --kill-others \ - --names 'dapp,e2e' \ - --prefix '[{time}][{name}]' \ - --success first \ - 'yarn dapp' \ - 'mocha test/e2e/metamask-ui.spec' - +node "${__DIR__}/run-e2e-test.js" "${__DIR__}/metamask-ui.spec.js" diff --git a/test/e2e/run-e2e-test.js b/test/e2e/run-e2e-test.js new file mode 100644 index 000000000..ce349ad02 --- /dev/null +++ b/test/e2e/run-e2e-test.js @@ -0,0 +1,83 @@ +const { promises: fs } = require('fs'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); +const { runInShell } = require('../../development/lib/run-command'); +const { exitWithError } = require('../../development/lib/exit-with-error'); +const { retry } = require('../../development/lib/retry'); + +async function main() { + const { argv } = yargs(hideBin(process.argv)) + .usage( + '$0 [options] ', + 'Run a single E2E test, with a variable number of retries.', + (_yargs) => + _yargs + .option('browser', { + default: process.env.SELENIUM_BROWSER, + description: `Set the browser used; either 'chrome' or 'firefox'.`, + type: 'string', + choices: ['chrome', 'firefox'], + }) + .option('retries', { + default: 0, + description: + 'Set how many times the test should be retried upon failure.', + type: 'number', + }) + .option('leave-running', { + default: false, + description: + 'Leaves the browser running after a test fails, along with anything else that the test used (ganache, the test dapp, etc.)', + type: 'boolean', + }) + .positional('e2e-test-path', { + describe: 'The path for the E2E test to run.', + type: 'string', + normalize: true, + }), + ) + .strict() + .help('help'); + + const { browser, e2eTestPath, retries, leaveRunning } = argv; + + if (!browser) { + exitWithError( + `"The browser must be set, via the '--browser' flag or the SELENIUM_BROWSER environment variable`, + ); + return; + } else if (browser !== process.env.SELENIUM_BROWSER) { + process.env.SELENIUM_BROWSER = browser; + } + + try { + const stat = await fs.stat(e2eTestPath); + if (!stat.isFile()) { + exitWithError('Test path must be a file'); + return; + } + } catch (error) { + if (error.code === 'ENOENT') { + exitWithError('Test path specified does not exist'); + return; + } else if (error.code === 'EACCES') { + exitWithError( + 'Access to test path is forbidden by file access permissions', + ); + return; + } + throw error; + } + + if (leaveRunning) { + process.env.E2E_LEAVE_RUNNING = 'true'; + } + + await retry(retries, async () => { + await runInShell('yarn', ['mocha', '--no-timeouts', e2eTestPath]); + }); +} + +main().catch((error) => { + exitWithError(error); +}); diff --git a/test/e2e/tests/add-account.spec.js b/test/e2e/tests/add-account.spec.js new file mode 100644 index 000000000..910402e43 --- /dev/null +++ b/test/e2e/tests/add-account.spec.js @@ -0,0 +1,39 @@ +const { strict: assert } = require('assert'); +const { withFixtures } = require('../helpers'); + +describe('Add account', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('should display correct new account name after create', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('.account-menu__icon'); + await driver.clickElement({ text: 'Create Account', tag: 'div' }); + await driver.fill('.new-account-create-form input', '2nd account'); + await driver.clickElement({ text: 'Create', tag: 'button' }); + + const accountName = await driver.waitForSelector({ + css: '.selected-account__name', + text: '2nd', + }); + assert.equal(await accountName.getText(), '2nd account'); + }, + ); + }); +}); diff --git a/test/e2e/tests/incremental-security.spec.js b/test/e2e/tests/incremental-security.spec.js index a07f44c96..185a0d8c0 100644 --- a/test/e2e/tests/incremental-security.spec.js +++ b/test/e2e/tests/incremental-security.spec.js @@ -122,7 +122,7 @@ describe('Incremental Security', function () { // should show a backup reminder const backupReminder = await driver.findElements({ xpath: - "//div[contains(@class, 'home-notification__text') and contains(text(), 'Backup your Secret Recovery code to keep your wallet and funds secure')]", + "//div[contains(@class, 'home-notification__text') and contains(text(), 'Backup your Secret Recovery Phrase to keep your wallet and funds secure')]", }); assert.equal(backupReminder.length, 1); diff --git a/test/e2e/tests/lock-account.spec.js b/test/e2e/tests/lock-account.spec.js new file mode 100644 index 000000000..c8c949a42 --- /dev/null +++ b/test/e2e/tests/lock-account.spec.js @@ -0,0 +1,42 @@ +const { strict: assert } = require('assert'); +const { withFixtures } = require('../helpers'); + +describe('Lock and unlock', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('successfully unlocks after lock', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('.account-menu__icon'); + const lockButton = await driver.findClickableElement( + '.account-menu__lock-button', + ); + assert.equal(await lockButton.getText(), 'Lock'); + await lockButton.click(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + const walletBalance = await driver.findElement( + '[data-testid="wallet-balance"] .list-item__heading', + ); + assert.equal(/^25\s*ETH$/u.test(await walletBalance.getText()), true); + }, + ); + }); +}); diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js new file mode 100644 index 000000000..2c1c1c97e --- /dev/null +++ b/test/e2e/tests/send-eth.spec.js @@ -0,0 +1,219 @@ +const { strict: assert } = require('assert'); +const { withFixtures, regularDelayMs } = require('../helpers'); + +describe('Send ETH from inside MetaMask using default gas', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1000'); + + const errorAmount = await driver.findElement('.send-v2__error-amount'); + assert.equal( + await errorAmount.getText(), + 'Insufficient funds.', + 'send screen should render an insufficient fund error message', + ); + + await inputAmount.press(driver.Key.BACK_SPACE); + await inputAmount.press(driver.Key.BACK_SPACE); + await inputAmount.press(driver.Key.BACK_SPACE); + await driver.delay(regularDelayMs); + + await driver.assertElementNotPresent('.send-v2__error-amount'); + + const amountMax = await driver.findClickableElement( + '.send-v2__amount-max', + ); + await amountMax.click(); + + let inputValue = await inputAmount.getAttribute('value'); + + assert(Number(inputValue) > 24); + + await amountMax.click(); + + assert.equal(await inputAmount.isEnabled(), true); + + await inputAmount.fill('1'); + + inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .transaction-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + + await driver.waitForSelector({ + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }); + }, + ); + }); +}); + +describe('Send ETH from inside MetaMask using fast gas option', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1'); + + const inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Set the gas price + await driver.clickElement({ text: 'Fast', tag: 'button/div/div' }); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.waitForSelector( + '.transaction-list__completed-transactions .transaction-list-item', + ); + await driver.waitForSelector({ + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }); + }, + ); + }); +}); + +describe('Send ETH from inside MetaMask using advanced gas modal', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: 25000000000000000000, + }, + ], + }; + it('finds the transaction in the transactions list', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + + const inputAmount = await driver.findElement('.unit-input__input'); + await inputAmount.fill('1'); + + const inputValue = await inputAmount.getAttribute('value'); + assert.equal(inputValue, '1'); + + // Set the gas limit + await driver.clickElement('.advanced-gas-options-btn'); + + // wait for gas modal to be visible + const gasModal = await driver.findVisibleElement('span .modal'); + + await driver.clickElement({ text: 'Save', tag: 'button' }); + + // Wait for gas modal to be removed from DOM + await gasModal.waitForElementState('hidden'); + + // Continue to next screen + await driver.clickElement({ text: 'Next', tag: 'button' }); + + const transactionAmounts = await driver.findElements( + '.currency-display-component__text', + ); + const transactionAmount = transactionAmounts[0]; + assert.equal(await transactionAmount.getText(), '1'); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .transaction-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + + await driver.waitForSelector( + { + css: '.transaction-list-item__primary-currency', + text: '-1 ETH', + }, + { timeout: 10000 }, + ); + }, + ); + }); +}); diff --git a/test/e2e/webdriver/chrome.js b/test/e2e/webdriver/chrome.js index e0bb65626..bf3c2d023 100644 --- a/test/e2e/webdriver/chrome.js +++ b/test/e2e/webdriver/chrome.js @@ -14,10 +14,21 @@ class ChromeDriver { const builder = new Builder() .forBrowser('chrome') .setChromeOptions(options); - if (port) { - const service = new chrome.ServiceBuilder().setPort(port); - builder.setChromeService(service); + const service = new chrome.ServiceBuilder(); + + // Enables Chrome logging. + // Especially useful for discovering why Chrome has crashed, but can also + // be useful for revealing console errors (from the page or background). + if ( + process.env.ENABLE_CHROME_LOGGING && + process.env.ENABLE_CHROME_LOGGING !== 'false' + ) { + service.setStdio('inherit').enableChromeLogging(); } + if (port) { + service.setPort(port); + } + builder.setChromeService(service); const driver = builder.build(); const chromeDriver = new ChromeDriver(driver); const extensionId = await chromeDriver.getExtensionIdByName('MetaMask'); diff --git a/test/e2e/webdriver/firefox.js b/test/e2e/webdriver/firefox.js index 6d327305a..d9d105b98 100644 --- a/test/e2e/webdriver/firefox.js +++ b/test/e2e/webdriver/firefox.js @@ -75,9 +75,7 @@ class FirefoxDriver { await this._driver.get('about:debugging#addons'); return await this._driver .wait( - until.elementLocated( - By.xpath("//dl/div[contains(., 'Internal UUID')]/dd"), - ), + until.elementLocated(By.xpath("//dl/div[contains(., 'UUID')]/dd")), 1000, ) .getText(); diff --git a/ui/components/app/advanced-gas-controls/advanced-gas-controls-row.component.js b/ui/components/app/advanced-gas-controls/advanced-gas-controls-row.component.js new file mode 100644 index 000000000..1ae8919b1 --- /dev/null +++ b/ui/components/app/advanced-gas-controls/advanced-gas-controls-row.component.js @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import Typography from '../../ui/typography/typography'; +import { + COLORS, + TEXT_ALIGN, + DISPLAY, + TYPOGRAPHY, + FONT_WEIGHT, +} from '../../../helpers/constants/design-system'; + +import NumericInput from '../../ui/numeric-input/numeric-input.component'; +import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; + +export default function AdvancedGasControlsRow({ + titleText, + tooltipText, + titleDetailText, + error, + onChange, + value, +}) { + return ( +
+ +
+ ); +} + +AdvancedGasControlsRow.propTypes = { + titleText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + tooltipText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + titleDetailText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + error: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.number, +}; + +AdvancedGasControlsRow.defaultProps = { + titleText: '', + tooltipText: '', + titleDetailText: '', + error: '', + onChange: undefined, + value: 0, +}; diff --git a/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js b/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js new file mode 100644 index 000000000..7231d67a0 --- /dev/null +++ b/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js @@ -0,0 +1,75 @@ +import React, { useContext, useState } from 'react'; +import { I18nContext } from '../../../contexts/i18n'; +import Typography from '../../ui/typography/typography'; +import { + FONT_WEIGHT, + TYPOGRAPHY, + COLORS, +} from '../../../helpers/constants/design-system'; +import AdvancedGasControlsRow from './advanced-gas-controls-row.component'; + +export default function AdvancedGasControls() { + const t = useContext(I18nContext); + + const [gasLimit, setGasLimit] = useState(0); + const [maxPriorityFee, setMaxPriorityFee] = useState(0); + const [maxFee, setMaxFee] = useState(0); + + return ( +
+ + + + {t('gasFeeEstimate')}: + {' '} + + + } + /> + + + {t('gasFeeEstimate')}: + {' '} + + + } + /> +
+ ); +} diff --git a/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js b/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js new file mode 100644 index 000000000..150380b1a --- /dev/null +++ b/ui/components/app/advanced-gas-controls/advanced-gas-controls.stories.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import AdvancedGasControls from '.'; + +export default { + title: 'Advanced Gas Controls', +}; + +export const simple = () => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/app/advanced-gas-controls/index.js b/ui/components/app/advanced-gas-controls/index.js new file mode 100644 index 000000000..ceedba1ec --- /dev/null +++ b/ui/components/app/advanced-gas-controls/index.js @@ -0,0 +1 @@ +export { default } from './advanced-gas-controls.component'; diff --git a/ui/components/app/advanced-gas-controls/index.scss b/ui/components/app/advanced-gas-controls/index.scss new file mode 100644 index 000000000..64888d83f --- /dev/null +++ b/ui/components/app/advanced-gas-controls/index.scss @@ -0,0 +1,34 @@ +.advanced-gas-controls { + &__row { + margin-bottom: 20px; + } + + &__row-heading { + display: flex; + } + + .info-tooltip { + display: inline-block; + } + + &__row-heading-detail { + flex-grow: 1; + align-self: center; + } + + &__row-error, + &__row--error h6 { + color: $error-1 !important; + padding-top: 6px; + } + + h6 { + padding-bottom: 6px; + margin-inline-end: 6px; + } + + i { + color: #dadada; + font-size: $font-size-h7; + } +} diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 89f2b9760..8f1e3926c 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -2,6 +2,7 @@ @import 'account-list-item/index'; @import 'account-menu/index'; @import 'add-token-button/index'; +@import 'advanced-gas-controls/index'; @import 'alerts/alerts'; @import 'app-header/index'; @import 'asset-list-item/asset-list-item'; @@ -10,6 +11,7 @@ @import 'connected-accounts-permissions/index'; @import 'connected-sites-list/index'; @import 'connected-status-indicator/index'; +@import 'edit-gas-display/index'; @import 'gas-customization/gas-modal-page-container/index'; @import 'gas-customization/gas-price-button-group/index'; @import 'gas-customization/index'; @@ -32,10 +34,13 @@ @import 'token-cell/token-cell'; @import 'transaction-activity-log/index'; @import 'transaction-breakdown/index'; +@import 'transaction-detail/index'; +@import 'transaction-detail-item/index'; @import 'transaction-icon/transaction-icon'; @import 'transaction-list-item-details/index'; @import 'transaction-list-item/index'; @import 'transaction-list/index'; @import 'transaction-status/index'; +@import 'transaction-total-banner/index'; @import 'wallet-overview/index'; @import 'whats-new-popup/index'; diff --git a/ui/components/app/asset-list-item/asset-list-item.js b/ui/components/app/asset-list-item/asset-list-item.js index c147297b5..801a8735b 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -10,7 +10,7 @@ import InfoIcon from '../../ui/icon/info-icon.component'; import Button from '../../ui/button'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useMetricEvent } from '../../../hooks/useMetricEvent'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; @@ -27,6 +27,7 @@ const AssetListItem = ({ primary, secondary, identiconBorder, + isERC721, }) => { const t = useI18nContext(); const dispatch = useDispatch(); @@ -68,13 +69,17 @@ const AssetListItem = ({ e.stopPropagation(); sendTokenEvent(); dispatch( - updateSendToken({ - address: tokenAddress, - decimals: tokenDecimals, - symbol: tokenSymbol, + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { + address: tokenAddress, + decimals: tokenDecimals, + symbol: tokenSymbol, + }, }), - ); - history.push(SEND_ROUTE); + ).then(() => { + history.push(SEND_ROUTE); + }); }} > {t('sendSpecifiedTokens', [tokenSymbol])} @@ -107,7 +112,7 @@ const AssetListItem = ({ } titleIcon={titleIcon} - subtitle={

{secondary}

} + subtitle={secondary ?

{secondary}

: null} onClick={onClick} icon={ - - {sendTokenButton} - + !isERC721 && ( + <> + + {sendTokenButton} + + ) } /> ); @@ -143,6 +150,7 @@ AssetListItem.propTypes = { 'primary': PropTypes.string, 'secondary': PropTypes.string, 'identiconBorder': PropTypes.bool, + 'isERC721': PropTypes.bool, }; AssetListItem.defaultProps = { diff --git a/ui/components/app/asset-list-item/asset-list-item.scss b/ui/components/app/asset-list-item/asset-list-item.scss index 6ee0853a5..f46fe7928 100644 --- a/ui/components/app/asset-list-item/asset-list-item.scss +++ b/ui/components/app/asset-list-item/asset-list-item.scss @@ -10,10 +10,7 @@ text-align: start; & h2 { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 100%; + display: flex; } & span { @@ -21,6 +18,16 @@ } } + &__token-value { + flex: 1; + padding-right: 5px; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + display: block; + overflow: hidden; + } + &__chevron-right { color: $Grey-500; } diff --git a/ui/components/app/asset-list/asset-list.js b/ui/components/app/asset-list/asset-list.js index 98ce9f042..213376df0 100644 --- a/ui/components/app/asset-list/asset-list.js +++ b/ui/components/app/asset-list/asset-list.js @@ -56,13 +56,13 @@ const AssetList = ({ onClickAsset }) => { }, ); - const [secondaryCurrencyDisplay] = useCurrencyDisplay( - selectedAccountBalance, - { - numberOfDecimals: secondaryNumberOfDecimals, - currency: secondaryCurrency, - }, - ); + const [ + secondaryCurrencyDisplay, + secondaryCurrencyProperties, + ] = useCurrencyDisplay(selectedAccountBalance, { + numberOfDecimals: secondaryNumberOfDecimals, + currency: secondaryCurrency, + }); const primaryTokenImage = useSelector(getNativeCurrencyImage); @@ -71,7 +71,9 @@ const AssetList = ({ onClickAsset }) => { onClickAsset(nativeCurrency)} data-testid="wallet-balance" - primary={primaryCurrencyProperties.value} + primary={ + primaryCurrencyProperties.value ?? secondaryCurrencyProperties.value + } tokenSymbol={primaryCurrencyProperties.suffix} secondary={showFiat ? secondaryCurrencyDisplay : undefined} tokenImage={primaryTokenImage} diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index e47bfaf78..28d5f578e 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -7,6 +7,10 @@ import { PageContainerFooter } from '../../../ui/page-container'; import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.'; export default class ConfirmPageContainerContent extends Component { + static contextTypes = { + t: PropTypes.func.isRequired, + }; + static propTypes = { action: PropTypes.string, dataComponent: PropTypes.node, @@ -44,14 +48,18 @@ export default class ConfirmPageContainerContent extends Component { } renderTabs() { + const { t } = this.context; const { detailsComponent, dataComponent } = this.props; return ( - + {detailsComponent} - + {dataComponent} diff --git a/ui/components/app/edit-gas-display/edit-gas-display.js b/ui/components/app/edit-gas-display/edit-gas-display.js new file mode 100644 index 000000000..4911633e2 --- /dev/null +++ b/ui/components/app/edit-gas-display/edit-gas-display.js @@ -0,0 +1,45 @@ +import React, { useState, useContext } from 'react'; + +import TransactionTotalBanner from '../transaction-total-banner/transaction-total-banner.component'; +import RadioGroup from '../../ui/radio-group/radio-group.component'; + +import AdvancedGasControls from '../advanced-gas-controls/advanced-gas-controls.component'; + +import { I18nContext } from '../../../contexts/i18n'; + +export default function EditGasDisplay() { + const t = useContext(I18nContext); + const [showAdvancedForm, setShowAdvancedForm] = useState(false); + + return ( +
+ + + + {showAdvancedForm && } +
+ ); +} diff --git a/ui/components/app/edit-gas-display/edit-gas-display.stories.js b/ui/components/app/edit-gas-display/edit-gas-display.stories.js new file mode 100644 index 000000000..a3f85ba5a --- /dev/null +++ b/ui/components/app/edit-gas-display/edit-gas-display.stories.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PopoverPortal from '../../ui/popover/popover.component'; +import Button from '../../ui/button'; +import EditGasDisplay from '.'; + +export default { + title: 'Edit Gas Display', +}; + +export const basic = () => { + return ( +
+ +
+ ); +}; + +export const insidePopover = () => { + return ( +
+ console.log('Closing!')} + footer={ + <> + + + } + > +
+ +
+
+
+ ); +}; diff --git a/ui/components/app/edit-gas-display/index.js b/ui/components/app/edit-gas-display/index.js new file mode 100644 index 000000000..05c4488a9 --- /dev/null +++ b/ui/components/app/edit-gas-display/index.js @@ -0,0 +1 @@ +export { default } from './edit-gas-display'; diff --git a/ui/components/app/edit-gas-display/index.scss b/ui/components/app/edit-gas-display/index.scss new file mode 100644 index 000000000..f8422eee8 --- /dev/null +++ b/ui/components/app/edit-gas-display/index.scss @@ -0,0 +1,17 @@ +.edit-gas-display { + .radio-group { + margin: 20px auto; + } + + &__advanced-button { + display: block; + margin: 0 auto; + background: transparent; + color: $primary-1; + font-weight: bold; + } + + .advanced-gas-controls { + margin-top: 20px; + } +} diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js index 6b36af651..d82a8fb0a 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container-container.test.js @@ -9,10 +9,10 @@ import { } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, -} from '../../../../ducks/send/send.duck'; + useCustomGas, + updateGasLimit, + updateGasPrice, +} from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -32,8 +32,6 @@ jest.mock('../../../../selectors', () => ({ `mockRenderableBasicEstimateData:${Object.keys(s).length}`, getDefaultActiveButtonIndex: (a, b) => a + b, getCurrentEthBalance: (state) => state.metamask.balance || '0x0', - getSendToken: () => null, - getTokenBalance: (state) => state.send.tokenBalance || '0x0', getCustomGasPrice: (state) => state.gas.customData.price || '0x0', getCustomGasLimit: (state) => state.gas.customData.limit || '0x0', getCurrentCurrency: jest.fn().mockReturnValue('usd'), @@ -57,11 +55,15 @@ jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - hideGasButtonGroup: jest.fn(), - setGasLimit: jest.fn(), - setGasPrice: jest.fn(), -})); +jest.mock('../../../../ducks/send', () => { + const { ASSET_TYPES } = jest.requireActual('../../../../ducks/send'); + return { + useCustomGas: jest.fn(), + updateGasLimit: jest.fn(), + updateGasPrice: jest.fn(), + getSendAsset: jest.fn(() => ({ type: ASSET_TYPES.NATIVE })), + }; +}); require('./gas-modal-page-container.container'); @@ -79,11 +81,11 @@ describe('gas-modal-page-container container', () => { dispatchSpy.resetHistory(); }); - describe('hideGasButtonGroup()', () => { - it('should dispatch a hideGasButtonGroup action', () => { - mapDispatchToPropsObject.hideGasButtonGroup(); + describe('useCustomGas()', () => { + it('should dispatch a useCustomGas action', () => { + mapDispatchToPropsObject.useCustomGas(); expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(hideGasButtonGroup).toHaveBeenCalled(); + expect(useCustomGas).toHaveBeenCalled(); }); }); @@ -126,13 +128,13 @@ describe('gas-modal-page-container container', () => { }); describe('setGasData()', () => { - it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => { + it('should dispatch a updateGasPrice and updateGasLimit action with the correct props', () => { mapDispatchToPropsObject.setGasData('ffff', 'aaaa'); expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalled(); - expect(setGasLimit).toHaveBeenCalledWith('ffff'); - expect(setGasPrice).toHaveBeenCalledWith('aaaa'); + expect(updateGasPrice).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalled(); + expect(updateGasLimit).toHaveBeenCalledWith('ffff'); + expect(updateGasPrice).toHaveBeenCalledWith('aaaa'); }); }); @@ -165,7 +167,7 @@ describe('gas-modal-page-container container', () => { }; dispatchProps = { updateCustomGasPrice: sinon.spy(), - hideGasButtonGroup: sinon.spy(), + useCustomGas: sinon.spy(), setGasData: sinon.spy(), updateConfirmTxGasAndCalculate: sinon.spy(), someOtherDispatchProp: sinon.spy(), @@ -194,7 +196,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(0); result.onSubmit(); @@ -203,7 +205,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(1); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.hideModal.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -238,7 +240,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(0); result.onSubmit('mockNewLimit', 'mockNewPrice'); @@ -251,7 +253,7 @@ describe('gas-modal-page-container container', () => { 'mockNewLimit', 'mockNewPrice', ]); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(1); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(1); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0); @@ -278,7 +280,7 @@ describe('gas-modal-page-container container', () => { dispatchProps.updateConfirmTxGasAndCalculate.callCount, ).toStrictEqual(0); expect(dispatchProps.setGasData.callCount).toStrictEqual(0); - expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0); + expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0); expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1); expect(dispatchProps.createSpeedUpTransaction.callCount).toStrictEqual(1); diff --git a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js index 9e70e3284..cb867c98b 100644 --- a/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ b/ui/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -14,20 +14,21 @@ import { fetchBasicGasEstimates, } from '../../../../ducks/gas/gas.duck'; import { - hideGasButtonGroup, - setGasLimit, - setGasPrice, - setGasTotal, - updateSendAmount, - updateSendErrors, -} from '../../../../ducks/send/send.duck'; + getSendMaxModeState, + getGasLimit, + getGasPrice, + getSendAmount, + updateGasLimit, + updateGasPrice, + useCustomGas, + getSendAsset, + ASSET_TYPES, +} from '../../../../ducks/send'; import { conversionRateSelector as getConversionRate, getCurrentCurrency, getCurrentEthBalance, getIsMainnet, - getSendToken, - getPreferences, getIsTestnet, getBasicGasEstimateLoadingStatus, getCustomGasLimit, @@ -35,12 +36,11 @@ import { getDefaultActiveButtonIndex, getRenderableBasicEstimateData, isCustomPriceSafe, - getTokenBalance, - getSendMaxModeState, isCustomPriceSafeForCustomNetwork, getAveragePriceEstimateInHexWEI, isCustomPriceExcessive, getIsGasEstimatesFetched, + getShouldShowFiat, } from '../../../../selectors'; import { @@ -56,16 +56,15 @@ import { isBalanceSufficient, } from '../../../../pages/send/send.utils'; import { MIN_GAS_LIMIT_DEC } from '../../../../pages/send/send.constants'; -import { calcMaxAmount } from '../../../../pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils'; import { TRANSACTION_STATUSES } from '../../../../../shared/constants/transaction'; import { GAS_LIMITS } from '../../../../../shared/constants/gas'; import GasModalPageContainer from './gas-modal-page-container.component'; const mapStateToProps = (state, ownProps) => { - const { - metamask: { currentNetworkTxList }, - send, - } = state; + const gasLimit = getGasLimit(state); + const gasPrice = getGasPrice(state); + const amount = getSendAmount(state); + const { currentNetworkTxList } = state.metamask; const { modalState: { props: modalProps } = {} } = state.appState.modal || {}; const { txData = {} } = modalProps || {}; const { transaction = {}, onSubmit } = ownProps; @@ -73,15 +72,15 @@ const mapStateToProps = (state, ownProps) => { ({ id }) => id === (transaction.id || txData.id), ); const buttonDataLoading = getBasicGasEstimateLoadingStatus(state); - const sendToken = getSendToken(state); + const asset = getSendAsset(state); // a "default" txParams is used during the send flow, since the transaction doesn't exist yet in that case const txParams = selectedTransaction?.txParams ? selectedTransaction.txParams : { - gas: send.gasLimit || GAS_LIMITS.SIMPLE, - gasPrice: send.gasPrice || getAveragePriceEstimateInHexWEI(state, true), - value: sendToken ? '0x0' : send.amount, + gas: gasLimit || GAS_LIMITS.SIMPLE, + gasPrice: gasPrice || getAveragePriceEstimateInHexWEI(state, true), + value: asset.type === ASSET_TYPES.TOKEN ? '0x0' : amount, }; const { gasPrice: currentGasPrice, gas: currentGasLimit } = txParams; @@ -115,20 +114,18 @@ const mapStateToProps = (state, ownProps) => { const balance = getCurrentEthBalance(state); - const { showFiatInTestnets } = getPreferences(state); const isMainnet = getIsMainnet(state); - const showFiat = Boolean(isMainnet || showFiatInTestnets); - - const isSendTokenSet = Boolean(sendToken); const isTestnet = getIsTestnet(state); + const showFiat = getShouldShowFiat(state); + const newTotalEth = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? sumHexWEIsToRenderableEth([balance, '0x0']) : sumHexWEIsToRenderableEth([value, customGasTotal]); const sendAmount = - maxModeOn && !isSendTokenSet + maxModeOn && asset.type === ASSET_TYPES.NATIVE ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) : sumHexWEIsToRenderableEth([value, '0x0']); @@ -189,9 +186,7 @@ const mapStateToProps = (state, ownProps) => { txId: transaction.id, insufficientBalance, isMainnet, - sendToken, balance, - tokenBalance: getTokenBalance(state), conversionRate, value, onSubmit, @@ -208,12 +203,13 @@ const mapDispatchToProps = (dispatch) => { dispatch(hideModal()); }, hideModal: () => dispatch(hideModal()), + useCustomGas: () => dispatch(useCustomGas()), updateCustomGasPrice, updateCustomGasLimit: (newLimit) => dispatch(setCustomGasLimit(addHexPrefix(newLimit))), setGasData: (newLimit, newPrice) => { - dispatch(setGasLimit(newLimit)); - dispatch(setGasPrice(newPrice)); + dispatch(updateGasLimit(newLimit)); + dispatch(updateGasPrice(newPrice)); }, updateConfirmTxGasAndCalculate: (gasLimit, gasPrice, updatedTx) => { updateCustomGasPrice(gasPrice); @@ -226,14 +222,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))); - }, }; }; @@ -246,17 +236,12 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { isSpeedUp, isRetry, insufficientBalance, - maxModeOn, customGasPrice, - customGasTotal, - balance, - sendToken, - tokenBalance, customGasLimit, transaction, } = stateProps; const { - hideGasButtonGroup: dispatchHideGasButtonGroup, + useCustomGas: dispatchUseCustomGas, setGasData: dispatchSetGasData, updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate, createSpeedUpTransaction: dispatchCreateSpeedUpTransaction, @@ -264,7 +249,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { hideSidebar: dispatchHideSidebar, cancelAndClose: dispatchCancelAndClose, hideModal: dispatchHideModal, - setAmountToMax: dispatchSetAmountToMax, ...otherDispatchProps } = dispatchProps; @@ -300,17 +284,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { dispatchCancelAndClose(); } else { dispatchSetGasData(gasLimit, gasPrice); - dispatchHideGasButtonGroup(); + dispatchUseCustomGas(); dispatchCancelAndClose(); } - if (maxModeOn) { - dispatchSetAmountToMax({ - balance, - gasTotal: customGasTotal, - sendToken, - tokenBalance, - }); - } }, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, diff --git a/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js b/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js index 3f1775a47..59fb89831 100644 --- a/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js +++ b/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js @@ -14,6 +14,7 @@ export default class DepositEtherModal extends Component { isTestnet: PropTypes.bool.isRequired, isMainnet: PropTypes.bool.isRequired, toWyre: PropTypes.func.isRequired, + toTransak: PropTypes.func.isRequired, address: PropTypes.string.isRequired, toFaucet: PropTypes.func.isRequired, hideWarning: PropTypes.func.isRequired, @@ -87,6 +88,7 @@ export default class DepositEtherModal extends Component { const { chainId, toWyre, + toTransak, address, toFaucet, isTestnet, @@ -138,6 +140,31 @@ export default class DepositEtherModal extends Component { }, hide: !isMainnet, })} + {this.renderRow({ + logo: ( +
+ ), + title: this.context.t('buyWithTransak'), + text: this.context.t('buyWithTransakDescription'), + buttonLabel: this.context.t('continueToTransak'), + onButtonClick: () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Deposit Ether', + name: 'Click buy Ether via Transak', + }, + }); + toTransak(address); + }, + hide: !isMainnet, + })} {this.renderRow({ logo: ( { dispatch(buyEth({ service: 'wyre', address })); }, + toTransak: (address) => { + dispatch(buyEth({ service: 'transak', address })); + }, hideModal: () => { dispatch(hideModal()); }, diff --git a/ui/components/app/modals/loading-network-error/loading-network-error.component.js b/ui/components/app/modals/loading-network-error/loading-network-error.component.js index 0b473f5f4..17c45376e 100644 --- a/ui/components/app/modals/loading-network-error/loading-network-error.component.js +++ b/ui/components/app/modals/loading-network-error/loading-network-error.component.js @@ -8,7 +8,7 @@ const LoadingNetworkError = (props, context) => { return ( hideModal()} submitText={t('tryAgain')}> - + ); }; diff --git a/ui/components/app/multiple-notifications/index.scss b/ui/components/app/multiple-notifications/index.scss index ea17e9467..01d3af889 100644 --- a/ui/components/app/multiple-notifications/index.scss +++ b/ui/components/app/multiple-notifications/index.scss @@ -55,9 +55,9 @@ .home-notification-wrapper--show-first { > div { - position: absolute; - bottom: 0; - right: 0; + position: fixed; + bottom: 10px; + right: 10px; visibility: hidden; } diff --git a/ui/components/app/token-cell/token-cell.js b/ui/components/app/token-cell/token-cell.js index 777e9b560..18a7cd698 100644 --- a/ui/components/app/token-cell/token-cell.js +++ b/ui/components/app/token-cell/token-cell.js @@ -15,6 +15,7 @@ export default function TokenCell({ string, image, onClick, + isERC721, }) { const userAddress = useSelector(getSelectedAddress); const t = useI18nContext(); @@ -50,6 +51,7 @@ export default function TokenCell({ warning={warning} primary={`${string || 0}`} secondary={formattedFiat} + isERC721={isERC721} /> ); } @@ -62,6 +64,7 @@ TokenCell.propTypes = { string: PropTypes.string, image: PropTypes.string, onClick: PropTypes.func.isRequired, + isERC721: PropTypes.bool, }; TokenCell.defaultProps = { diff --git a/ui/components/app/transaction-breakdown/transaction-breakdown.component.js b/ui/components/app/transaction-breakdown/transaction-breakdown.component.js index 4698819cf..cba7803c9 100644 --- a/ui/components/app/transaction-breakdown/transaction-breakdown.component.js +++ b/ui/components/app/transaction-breakdown/transaction-breakdown.component.js @@ -46,7 +46,7 @@ export default class TransactionBreakdown extends PureComponent { return (
{t('transaction')}
- + {typeof nonce === 'undefined' ? null : ( { txParams: { gas, gasPrice, value } = {}, txReceipt: { gasUsed } = {}, } = transaction; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); const gasLimit = typeof gasUsed === 'string' ? gasUsed : gas; @@ -22,7 +20,7 @@ const mapStateToProps = (state, ownProps) => { return { nativeCurrency: getNativeCurrency(state), - showFiat: isMainnet || Boolean(showFiatInTestnets), + showFiat: getShouldShowFiat(state), totalInHex, gas, gasPrice, diff --git a/ui/components/app/transaction-detail-item/index.js b/ui/components/app/transaction-detail-item/index.js new file mode 100644 index 000000000..e37141eed --- /dev/null +++ b/ui/components/app/transaction-detail-item/index.js @@ -0,0 +1 @@ +export { default } from './transaction-detail-item.component'; diff --git a/ui/components/app/transaction-detail-item/index.scss b/ui/components/app/transaction-detail-item/index.scss new file mode 100644 index 000000000..7359d694c --- /dev/null +++ b/ui/components/app/transaction-detail-item/index.scss @@ -0,0 +1,29 @@ +.transaction-detail-item { + color: $ui-4; + + &__row { + display: flex; + } + + &__title { + flex-grow: 1; + } + + .info-tooltip { + display: inline-block; + margin-inline-start: 4px; + } + + &__detail-text { + margin-inline-end: 20px !important; + } + + &__total { + font-weight: bold; + color: $ui-black; + } + + &__subtitle { + flex-grow: 1; + } +} diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js new file mode 100644 index 000000000..e08a912ab --- /dev/null +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Typography from '../../ui/typography/typography'; +import { + COLORS, + FONT_WEIGHT, + TYPOGRAPHY, +} from '../../../helpers/constants/design-system'; + +export default function TransactionDetailItem({ + detailTitle, + detailText, + detailTotal, + subTitle, + subText, +}) { + return ( +
+
+ + {detailTitle} + + {detailText && ( + + {detailText} + + )} + + {detailTotal} + +
+
+ {subTitle && ( + + {subTitle} + + )} + {subText} +
+
+ ); +} + +TransactionDetailItem.propTypes = { + detailTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + detailText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + detailTotal: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + subTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + subText: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), +}; + +TransactionDetailItem.defaultProps = { + detailTitle: '', + detailText: '', + detailTotal: '', + subTitle: '', + subText: '', +}; diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js b/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js new file mode 100644 index 000000000..a7d81e70a --- /dev/null +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.stories.js @@ -0,0 +1,32 @@ +import React from 'react'; +import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; +import TransactionDetailItem from '.'; + +export default { + title: 'Transaction Detail Item', +}; + +export const basic = () => { + return ( +
+ + Estimated gas fee + + + + + } + detailText="16565.30" + detailTotal="0.0089 ETH" + subTitle="Very likely in < 15 seconds" + subText={ + <> + From $16565 - $19000 + + } + /> +
+ ); +}; diff --git a/ui/components/app/transaction-detail/index.js b/ui/components/app/transaction-detail/index.js new file mode 100644 index 000000000..7ee797f13 --- /dev/null +++ b/ui/components/app/transaction-detail/index.js @@ -0,0 +1 @@ +export { default } from './transaction-detail.component'; diff --git a/ui/components/app/transaction-detail/index.scss b/ui/components/app/transaction-detail/index.scss new file mode 100644 index 000000000..61ddb3479 --- /dev/null +++ b/ui/components/app/transaction-detail/index.scss @@ -0,0 +1,11 @@ +.transaction-detail { + .transaction-detail-item { + padding: 20px 0; + border-bottom: 1px solid $ui-3; + + &:last-child { + padding-bottom: 0; + border-bottom: 0; + } + } +} diff --git a/ui/components/app/transaction-detail/transaction-detail.component.js b/ui/components/app/transaction-detail/transaction-detail.component.js new file mode 100644 index 000000000..6079f741c --- /dev/null +++ b/ui/components/app/transaction-detail/transaction-detail.component.js @@ -0,0 +1,15 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component'; + +export default function TransactionDetail({ rows }) { + return
{rows}
; +} + +TransactionDetail.propTypes = { + rows: PropTypes.arrayOf(TransactionDetailItem), +}; + +TransactionDetail.defaultProps = { + rows: [], +}; diff --git a/ui/components/app/transaction-detail/transaction-detail.stories.js b/ui/components/app/transaction-detail/transaction-detail.stories.js new file mode 100644 index 000000000..1cfc6e8fe --- /dev/null +++ b/ui/components/app/transaction-detail/transaction-detail.stories.js @@ -0,0 +1,50 @@ +import React from 'react'; +import InfoTooltip from '../../ui/info-tooltip/info-tooltip'; +import TransactionDetailItem from '../transaction-detail-item/transaction-detail-item.component'; +import TransactionDetail from '.'; + +export default { + title: 'Transaction Detail', +}; + +const rows = [ + + Estimated gas fee + + + + + } + detailText="0.00896 ETH" + detailTotal="$15.73" + subTitle="Very likely in < 15 seconds" + subText={ + <> + From $15.73 - $19.81 + + } + />, + + Up to $19.85 + + } + />, +]; + +export const basic = () => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/app/transaction-icon/transaction-icon.js b/ui/components/app/transaction-icon/transaction-icon.js index 6980a89cd..adec0de16 100644 --- a/ui/components/app/transaction-icon/transaction-icon.js +++ b/ui/components/app/transaction-icon/transaction-icon.js @@ -34,6 +34,7 @@ const COLOR_MAP = { [TRANSACTION_STATUSES.REJECTED]: FAIL_COLOR, [TRANSACTION_GROUP_STATUSES.CANCELLED]: FAIL_COLOR, [TRANSACTION_STATUSES.DROPPED]: FAIL_COLOR, + [TRANSACTION_STATUSES.SUBMITTED]: PENDING_COLOR, }; export default function TransactionIcon({ status, category }) { @@ -63,6 +64,8 @@ TransactionIcon.propTypes = { TRANSACTION_STATUSES.REJECTED, TRANSACTION_GROUP_STATUSES.CANCELLED, TRANSACTION_STATUSES.DROPPED, + TRANSACTION_STATUSES.CONFIRMED, + TRANSACTION_STATUSES.SUBMITTED, ]).isRequired, category: PropTypes.oneOf([ TRANSACTION_GROUP_CATEGORIES.APPROVAL, diff --git a/ui/components/app/transaction-total-banner/index.js b/ui/components/app/transaction-total-banner/index.js new file mode 100644 index 000000000..42ccf75f4 --- /dev/null +++ b/ui/components/app/transaction-total-banner/index.js @@ -0,0 +1 @@ +export { default } from './transaction-total-banner.component'; diff --git a/ui/components/app/transaction-total-banner/index.scss b/ui/components/app/transaction-total-banner/index.scss new file mode 100644 index 000000000..42979624c --- /dev/null +++ b/ui/components/app/transaction-total-banner/index.scss @@ -0,0 +1,7 @@ +.transaction-total-banner { + text-align: center; + + &__detail { + padding-bottom: 4px; + } +} diff --git a/ui/components/app/transaction-total-banner/transaction-total-banner.component.js b/ui/components/app/transaction-total-banner/transaction-total-banner.component.js new file mode 100644 index 000000000..40731186a --- /dev/null +++ b/ui/components/app/transaction-total-banner/transaction-total-banner.component.js @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Typography from '../../ui/typography/typography'; +import { COLORS, TYPOGRAPHY } from '../../../helpers/constants/design-system'; + +export default function TransactionTotalBanner({ total, detail, timing }) { + return ( +
+ + {total} + + {detail && ( + + {detail} + + )} + {timing && ( + + {timing} + + )} +
+ ); +} + +TransactionTotalBanner.propTypes = { + total: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + detail: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + timing: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), +}; + +TransactionTotalBanner.defaultProps = { + total: '', + detail: '', + timing: '', +}; diff --git a/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js new file mode 100644 index 000000000..46838ae37 --- /dev/null +++ b/ui/components/app/transaction-total-banner/transaction-total-banner.stories.js @@ -0,0 +1,20 @@ +import React from 'react'; +import TransactionTotalBanner from '.'; + +export default { + title: 'Transaction Total Banner', +}; + +export const basic = () => { + return ( + + Up to $19.81 (0.01234 ETH) + + } + timing="Very likely in < 15 seconds" + /> + ); +}; diff --git a/ui/components/app/wallet-overview/token-overview.js b/ui/components/app/wallet-overview/token-overview.js index 9bdb87533..e40003067 100644 --- a/ui/components/app/wallet-overview/token-overview.js +++ b/ui/components/app/wallet-overview/token-overview.js @@ -17,7 +17,7 @@ import { } from '../../../hooks/useMetricEvent'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; -import { updateSendToken } from '../../../ducks/send/send.duck'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; import { getAssetImages, @@ -85,12 +85,19 @@ const TokenOverview = ({ className, token }) => { className="token-overview__button" onClick={() => { sendTokenEvent(); - dispatch(updateSendToken(token)); - history.push(SEND_ROUTE); + dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: token, + }), + ).then(() => { + history.push(SEND_ROUTE); + }); }} Icon={SendIcon} label={t('send')} data-testid="eth-overview-send" + disabled={token.isERC721} /> { const { metamask: { nativeCurrency, currentCurrency, conversionRate }, } = state; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); + const showFiat = getShouldShowFiat(state); return { nativeCurrency, currentCurrency, conversionRate, - hideFiat: !isMainnet && !showFiatInTestnets, + hideFiat: !showFiat, }; }; diff --git a/ui/components/ui/list-item/index.scss b/ui/components/ui/list-item/index.scss index 5470a692c..dc58b90b5 100644 --- a/ui/components/ui/list-item/index.scss +++ b/ui/components/ui/list-item/index.scss @@ -110,3 +110,12 @@ '. actions actions actions actions mid mid mid mid right right right'; } } + +.list-item--single-content-row { + grid-template-areas: 'icon head head head head head head head right right right right'; + align-items: center; + + @media (min-width: 576px) { + grid-template-areas: 'icon head head head head mid mid mid mid right right right'; + } +} diff --git a/ui/components/ui/list-item/list-item.component.js b/ui/components/ui/list-item/list-item.component.js index e6b3da462..61f4c8a58 100644 --- a/ui/components/ui/list-item/list-item.component.js +++ b/ui/components/ui/list-item/list-item.component.js @@ -14,7 +14,11 @@ export default function ListItem({ className, 'data-testid': dataTestId, }) { - const primaryClassName = classnames('list-item', className); + const primaryClassName = classnames( + 'list-item', + className, + subtitle || children ? '' : 'list-item--single-content-row', + ); return (
+ onChange?.(Number(e.target.value))} + min="0" + /> + {detailText && ( + + {detailText} + + )} +
+ ); +} + +NumericInput.propTypes = { + value: PropTypes.number, + detailText: PropTypes.string, + onChange: PropTypes.func, + error: PropTypes.string, +}; + +NumericInput.defaultProps = { + value: 0, + detailText: '', + onChange: undefined, + error: '', +}; diff --git a/ui/components/ui/numeric-input/numeric-input.scss b/ui/components/ui/numeric-input/numeric-input.scss new file mode 100644 index 000000000..f3ac6877d --- /dev/null +++ b/ui/components/ui/numeric-input/numeric-input.scss @@ -0,0 +1,28 @@ +.numeric-input { + border: 1px solid $ui-3; + position: relative; + border-radius: 6px; + + &--error { + border-color: $error-1; + } + + input { + width: 100%; + border: 0; + padding: 10px; + border-radius: 6px; + + /* ensures the increment/decrement arrows always display */ + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + opacity: 1; + } + } + + span { + position: absolute; + right: 40px; + top: 7px; + } +} diff --git a/ui/components/ui/numeric-input/numeric-input.stories.js b/ui/components/ui/numeric-input/numeric-input.stories.js new file mode 100644 index 000000000..53576c2d6 --- /dev/null +++ b/ui/components/ui/numeric-input/numeric-input.stories.js @@ -0,0 +1,36 @@ +import React from 'react'; +import NumericInput from '.'; + +export default { + title: 'NumericInput', +}; + +const onChange = (e) => console.log('changed value: ', e.target.value); + +export const numericInput = () => { + return ( +
+ +
+ ); +}; + +export const numericInputWithDetail = () => { + return ( +
+ +
+ ); +}; + +export const numericInputWithError = () => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/ui/radio-group/index.js b/ui/components/ui/radio-group/index.js new file mode 100644 index 000000000..64f0fbf15 --- /dev/null +++ b/ui/components/ui/radio-group/index.js @@ -0,0 +1 @@ +export { default } from './radio-group.component'; diff --git a/ui/components/ui/radio-group/index.scss b/ui/components/ui/radio-group/index.scss new file mode 100644 index 000000000..7cc8e9534 --- /dev/null +++ b/ui/components/ui/radio-group/index.scss @@ -0,0 +1,49 @@ +.radio-group { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: 100px; + width: 300px; + + label { + cursor: pointer; + } + + &__column-recommended { + height: 20px; + } + + &__column-line { + width: 1px; + height: 5px; + background-color: $ui-2; + margin: 0 auto; + } + + &__column-horizontal-line { + height: 1px; + background-color: $ui-2; + width: 100%; + } + + &__column:first-child &__column-horizontal-line { + width: 50px; + margin-left: 50px; + } + + &__column:last-child &__column-horizontal-line { + width: 51px; + } + + &__column-radio { + margin-inline-end: 1px; + } + + &__column-radio, + &__column-label { + text-align: center; + } + + &__column-label { + padding-top: 6px; + } +} diff --git a/ui/components/ui/radio-group/radio-group.component.js b/ui/components/ui/radio-group/radio-group.component.js new file mode 100644 index 000000000..8665c5803 --- /dev/null +++ b/ui/components/ui/radio-group/radio-group.component.js @@ -0,0 +1,64 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { I18nContext } from '../../../contexts/i18n'; +import Typography from '../typography/typography'; +import { + COLORS, + FONT_WEIGHT, + TYPOGRAPHY, +} from '../../../helpers/constants/design-system'; + +export default function RadioGroup({ options, name, selectedValue, onChange }) { + const t = useContext(I18nContext); + + return ( +
+ {options.map((option) => { + return ( +
+ +
+ ); + })} +
+ ); +} + +RadioGroup.propTypes = { + options: PropTypes.array, + selectedValue: PropTypes.string, + name: PropTypes.string, + onChange: PropTypes.func, +}; + +RadioGroup.defaultProps = { + options: [], +}; diff --git a/ui/components/ui/radio-group/radio-group.stories.js b/ui/components/ui/radio-group/radio-group.stories.js new file mode 100644 index 000000000..1d0aef252 --- /dev/null +++ b/ui/components/ui/radio-group/radio-group.stories.js @@ -0,0 +1,22 @@ +import React from 'react'; +import RadioGroup from '.'; + +export default { + title: 'RadioGroup', +}; + +export const radioGroup = () => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/ui/token-input/token-input.component.test.js b/ui/components/ui/token-input/token-input.component.test.js index 33712be1d..febb08ba9 100644 --- a/ui/components/ui/token-input/token-input.component.test.js +++ b/ui/components/ui/token-input/token-input.component.test.js @@ -141,6 +141,7 @@ describe('TokenInput Component', () => { }} tokenExchangeRates={{ '0x1': 2 }} showFiat + currentCurrency="usd" /> , ); @@ -278,6 +279,7 @@ describe('TokenInput Component', () => { }} tokenExchangeRates={{ '0x1': 2 }} showFiat + currentCurrency="usd" /> , ); diff --git a/ui/components/ui/token-input/token-input.container.js b/ui/components/ui/token-input/token-input.container.js index a34c2d247..9f964feec 100644 --- a/ui/components/ui/token-input/token-input.container.js +++ b/ui/components/ui/token-input/token-input.container.js @@ -1,23 +1,17 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { - getIsMainnet, - getTokenExchangeRates, - getPreferences, -} from '../../../selectors'; +import { getTokenExchangeRates, getShouldShowFiat } from '../../../selectors'; import TokenInput from './token-input.component'; const mapStateToProps = (state) => { const { metamask: { currentCurrency }, } = state; - const { showFiatInTestnets } = getPreferences(state); - const isMainnet = getIsMainnet(state); return { currentCurrency, tokenExchangeRates: getTokenExchangeRates(state), - hideConversion: !isMainnet && !showFiatInTestnets, + hideConversion: !getShouldShowFiat(state), }; }; diff --git a/ui/components/ui/typography/typography.js b/ui/components/ui/typography/typography.js index b18b53deb..ab3203ddd 100644 --- a/ui/components/ui/typography/typography.js +++ b/ui/components/ui/typography/typography.js @@ -25,9 +25,11 @@ export default function Typography({ 'typography', className, `typography--${variant}`, - `typography--align-${align}`, - `typography--color-${color}`, `typography--weight-${fontWeight}`, + { + [`typography--align-${align}`]: Boolean(align), + [`typography--color-${color}`]: Boolean(color), + }, ); let Tag = tag ?? variant; diff --git a/ui/components/ui/ui-components.scss b/ui/components/ui/ui-components.scss index 96d0c684a..9f4616726 100644 --- a/ui/components/ui/ui-components.scss +++ b/ui/components/ui/ui-components.scss @@ -33,10 +33,12 @@ @import 'loading-indicator/loading-indicator'; @import 'loading-screen/index'; @import 'menu/menu'; +@import 'numeric-input/numeric-input'; @import 'page-container/index'; @import 'popover/index'; @import 'pulse-loader/index'; @import 'qr-code/index'; +@import 'radio-group/index'; @import 'readonly-input/index'; @import 'sender-to-recipient/index'; @import 'snackbar/index'; diff --git a/ui/components/ui/unit-input/unit-input.component.js b/ui/components/ui/unit-input/unit-input.component.js index 8eeb39e1b..78458cab6 100644 --- a/ui/components/ui/unit-input/unit-input.component.js +++ b/ui/components/ui/unit-input/unit-input.component.js @@ -1,7 +1,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { removeLeadingZeroes } from '../../../pages/send/send.utils'; + +function removeLeadingZeroes(str) { + return str.replace(/^0*(?=\d)/u, ''); +} /** * Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index 7a1cafd80..bb23afef8 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -15,10 +15,11 @@ import { getNumberOfAccounts, getNumberOfTokens, } from '../selectors/selectors'; -import { getSendToken } from '../selectors/send'; +import { getSendAsset, ASSET_TYPES } from '../ducks/send'; import { txDataSelector } from '../selectors/confirm-transaction'; import { getEnvironmentType } from '../../app/scripts/lib/util'; import { trackMetaMetricsEvent } from '../store/actions'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; export const MetaMetricsContext = createContext(() => { captureException( @@ -31,7 +32,8 @@ export const MetaMetricsContext = createContext(() => { export function MetaMetricsProvider({ children }) { const txData = useSelector(txDataSelector) || {}; const environmentType = getEnvironmentType(); - const activeCurrency = useSelector(getSendToken)?.symbol; + const activeAsset = useSelector(getSendAsset); + const nativeAssetSymbol = useSelector(getNativeCurrency); const accountType = useSelector(getAccountType); const confirmTransactionOrigin = txData.origin; const numberOfTokens = useSelector(getNumberOfTokens); @@ -72,7 +74,10 @@ export function MetaMetricsProvider({ children }) { action: eventOpts.action, number_of_tokens: numberOfTokens, number_of_accounts: numberOfAccounts, - active_currency: activeCurrency, + active_currency: + activeAsset.type === ASSET_TYPES.NATIVE + ? nativeAssetSymbol + : activeAsset?.details?.symbol, account_type: accountType, is_new_visit: config.is_new_visit, // the properties coming from this key will not match our standards for @@ -102,7 +107,8 @@ export function MetaMetricsProvider({ children }) { accountType, currentPath, confirmTransactionOrigin, - activeCurrency, + activeAsset, + nativeAssetSymbol, numberOfTokens, numberOfAccounts, environmentType, diff --git a/ui/css/design-system/attributes.scss b/ui/css/design-system/attributes.scss index 2176cddb9..72fdcb51f 100644 --- a/ui/css/design-system/attributes.scss +++ b/ui/css/design-system/attributes.scss @@ -68,5 +68,5 @@ $sizes-strings: $border-style: solid, double, none, dashed, dotted; $directions: top, right, bottom, left; $display: block, grid, flex, inline-block, inline-grid, inline-flex, list-item; -$text-align: left, right, center, justify; +$text-align: left, right, center, justify, end; $font-weight: bold, normal, 100, 200, 300, 400, 500, 600, 700, 800, 900; diff --git a/ui/css/itcss/components/send.scss b/ui/css/itcss/components/send.scss index 555692d81..9874b09ab 100644 --- a/ui/css/itcss/components/send.scss +++ b/ui/css/itcss/components/send.scss @@ -801,6 +801,7 @@ align-items: center; justify-content: center; color: #2f9ae0; + background: #fff; &__disabled { color: #b0d7f2; diff --git a/ui/ducks/confirm-transaction/confirm-transaction.duck.js b/ui/ducks/confirm-transaction/confirm-transaction.duck.js index 09e74a2e8..96baf311b 100644 --- a/ui/ducks/confirm-transaction/confirm-transaction.duck.js +++ b/ui/ducks/confirm-transaction/confirm-transaction.duck.js @@ -3,7 +3,7 @@ import { currentCurrencySelector, unconfirmedTransactionsHashSelector, } from '../../selectors'; -import { getNativeCurrency } from '../metamask/metamask'; +import { getNativeCurrency, getTokens } from '../metamask/metamask'; import { getValueFromWeiHex, @@ -18,12 +18,14 @@ import { import { getTokenData, sumHexes } from '../../helpers/utils/transactions.util'; import { conversionUtil } from '../../helpers/utils/conversion-util'; +import { getAveragePriceEstimateInHexWEI } from '../../selectors/custom-gas'; // Actions const createActionType = (action) => `metamask/confirm-transaction/${action}`; const UPDATE_TX_DATA = createActionType('UPDATE_TX_DATA'); const UPDATE_TOKEN_DATA = createActionType('UPDATE_TOKEN_DATA'); +const UPDATE_TOKEN_PROPS = createActionType('UPDATE_TOKEN_PROPS'); const CLEAR_CONFIRM_TRANSACTION = createActionType('CLEAR_CONFIRM_TRANSACTION'); const UPDATE_TRANSACTION_AMOUNTS = createActionType( 'UPDATE_TRANSACTION_AMOUNTS', @@ -36,6 +38,7 @@ const UPDATE_NONCE = createActionType('UPDATE_NONCE'); const initState = { txData: {}, tokenData: {}, + tokenProps: {}, fiatTransactionAmount: '', fiatTransactionFee: '', fiatTransactionTotal: '', @@ -65,6 +68,13 @@ export default function reducer(state = initState, action = {}) { ...action.payload, }, }; + case UPDATE_TOKEN_PROPS: + return { + ...state, + tokenProps: { + ...action.payload, + }, + }; case UPDATE_TRANSACTION_AMOUNTS: { const { fiatTransactionAmount, @@ -135,6 +145,13 @@ export function updateTokenData(tokenData) { }; } +export function updateTokenProps(tokenProps) { + return { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + }; +} + export function updateTransactionAmounts(amounts) { return { type: UPDATE_TRANSACTION_AMOUNTS, @@ -198,9 +215,14 @@ export function updateTxDataAndCalculate(txData) { dispatch(updateTxData(txData)); - const { - txParams: { value = '0x0', gas: gasLimit = '0x0', gasPrice = '0x0' } = {}, - } = txData; + const { txParams: { value = '0x0', gas: gasLimit = '0x0' } = {} } = txData; + + // if the gas price from our infura endpoint is null or undefined + // use the metaswap average price estimation as a fallback + let { txParams: { gasPrice } = {} } = txData; + if (!gasPrice) { + gasPrice = getAveragePriceEstimateInHexWEI(state) || '0x0'; + } const fiatTransactionAmount = getValueFromWeiHex({ value, @@ -290,9 +312,20 @@ export function setTransactionToConfirm(transactionId) { const { txParams } = transaction; if (txParams.data) { - const { data } = txParams; + const { to: tokenAddress, data } = txParams; const tokenData = getTokenData(data); + const tokens = getTokens(state); + const currentToken = tokens?.find( + ({ address }) => tokenAddress === address, + ); + + dispatch( + updateTokenProps({ + decimals: currentToken?.decimals, + symbol: currentToken?.symbol, + }), + ); dispatch(updateTokenData(tokenData)); } diff --git a/ui/ducks/confirm-transaction/confirm-transaction.duck.test.js b/ui/ducks/confirm-transaction/confirm-transaction.duck.test.js index 8df65c35a..f4ef16303 100644 --- a/ui/ducks/confirm-transaction/confirm-transaction.duck.test.js +++ b/ui/ducks/confirm-transaction/confirm-transaction.duck.test.js @@ -12,6 +12,7 @@ import ConfirmTransactionReducer, * as actions from './confirm-transaction.duck' const initialState = { txData: {}, tokenData: {}, + tokenProps: {}, fiatTransactionAmount: '', fiatTransactionFee: '', fiatTransactionTotal: '', @@ -307,8 +308,8 @@ describe('Confirm Transaction Duck', () => { nonce: '', tokenData: {}, tokenProps: { - tokenDecimals: '', - tokenSymbol: '', + decimals: '', + symbol: '', }, txData: { ...txData, diff --git a/ui/ducks/ens.js b/ui/ducks/ens.js new file mode 100644 index 000000000..de72739e6 --- /dev/null +++ b/ui/ducks/ens.js @@ -0,0 +1,197 @@ +import { createSlice } from '@reduxjs/toolkit'; +import ENS from 'ethjs-ens'; +import log from 'loglevel'; +import networkMap from 'ethereum-ens-network-map'; +import { isConfusing } from 'unicode-confusables'; +import { isHexString } from 'ethereumjs-util'; + +import { getCurrentChainId } from '../selectors'; +import { + CHAIN_ID_TO_NETWORK_ID_MAP, + MAINNET_NETWORK_ID, +} from '../../shared/constants/network'; +import { + CONFUSING_ENS_ERROR, + ENS_ILLEGAL_CHARACTER, + ENS_NOT_FOUND_ON_NETWORK, + ENS_NOT_SUPPORTED_ON_NETWORK, + ENS_NO_ADDRESS_FOR_NAME, + ENS_REGISTRATION_ERROR, + ENS_UNKNOWN_ERROR, +} from '../pages/send/send.constants'; +import { isValidDomainName } from '../helpers/utils/util'; +import { CHAIN_CHANGED } from '../store/actionConstants'; +import { + BURN_ADDRESS, + isBurnAddress, + isValidHexAddress, +} from '../../shared/modules/hexstring-utils'; + +// Local Constants +const ZERO_X_ERROR_ADDRESS = '0x'; + +const initialState = { + stage: 'UNINITIALIZED', + resolution: null, + error: null, + warning: null, + network: null, +}; + +export const ensInitialState = initialState; + +const name = 'ENS'; + +let ens = null; + +const slice = createSlice({ + name, + initialState, + reducers: { + ensLookup: (state, action) => { + // first clear out the previous state + state.resolution = null; + state.error = null; + state.warning = null; + const { address, ensName, error, network } = action.payload; + + if (error) { + if ( + isValidDomainName(ensName) && + error.message === 'ENS name not defined.' + ) { + state.error = + network === MAINNET_NETWORK_ID + ? ENS_NO_ADDRESS_FOR_NAME + : ENS_NOT_FOUND_ON_NETWORK; + } else if (error.message === 'Illegal Character for ENS.') { + state.error = ENS_ILLEGAL_CHARACTER; + } else { + log.error(error); + state.error = ENS_UNKNOWN_ERROR; + } + } else if (address) { + if (address === BURN_ADDRESS) { + state.error = ENS_NO_ADDRESS_FOR_NAME; + } else if (address === ZERO_X_ERROR_ADDRESS) { + state.error = ENS_REGISTRATION_ERROR; + } else { + state.resolution = address; + } + if (isValidDomainName(address) && isConfusing(address)) { + state.warning = CONFUSING_ENS_ERROR; + } + } + }, + enableEnsLookup: (state, action) => { + state.stage = 'INITIALIZED'; + state.error = null; + state.resolution = null; + state.warning = null; + state.network = action.payload; + }, + disableEnsLookup: (state) => { + state.stage = 'NO_NETWORK_SUPPORT'; + state.error = ENS_NOT_SUPPORTED_ON_NETWORK; + state.warning = null; + state.resolution = null; + state.network = null; + }, + resetResolution: (state) => { + state.resolution = null; + state.warning = null; + state.error = + state.stage === 'NO_NETWORK_SUPPORT' + ? ENS_NOT_SUPPORTED_ON_NETWORK + : null; + }, + }, + extraReducers: (builder) => { + builder.addCase(CHAIN_CHANGED, (state, action) => { + if (action.payload !== state.currentChainId) { + state.stage = 'UNINITIALIZED'; + ens = null; + } + }); + }, +}); + +const { reducer, actions } = slice; +export default reducer; + +const { + disableEnsLookup, + ensLookup, + enableEnsLookup, + resetResolution, +} = actions; +export { resetResolution }; + +export function initializeEnsSlice() { + return (dispatch, getState) => { + const state = getState(); + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + const networkIsSupported = Boolean(networkMap[network]); + if (networkIsSupported) { + ens = new ENS({ provider: global.ethereumProvider, network }); + dispatch(enableEnsLookup(network)); + } else { + ens = null; + dispatch(disableEnsLookup()); + } + }; +} + +export function lookupEnsName(ensName) { + return async (dispatch, getState) => { + const trimmedEnsName = ensName.trim(); + let state = getState(); + if (state[name].stage === 'UNINITIALIZED') { + await dispatch(initializeEnsSlice()); + } + state = getState(); + if ( + state[name].stage === 'NO_NETWORK_SUPPORT' && + !( + isBurnAddress(trimmedEnsName) === false && + isValidHexAddress(trimmedEnsName, { mixedCaseUseChecksum: true }) + ) && + !isHexString(trimmedEnsName) + ) { + await dispatch(resetResolution()); + } else { + log.info(`ENS attempting to resolve name: ${trimmedEnsName}`); + let address; + let error; + try { + address = await ens.lookup(trimmedEnsName); + } catch (err) { + error = err; + } + const chainId = getCurrentChainId(state); + const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + await dispatch( + ensLookup({ + ensName: trimmedEnsName, + address, + error, + chainId, + network, + }), + ); + } + }; +} + +export function getEnsResolution(state) { + return state[name].resolution; +} + +export function getEnsError(state) { + return state[name].error; +} + +export function getEnsWarning(state) { + return state[name].warning; +} diff --git a/ui/ducks/gas/gas-action-constants.js b/ui/ducks/gas/gas-action-constants.js new file mode 100644 index 000000000..19cb16ee7 --- /dev/null +++ b/ui/ducks/gas/gas-action-constants.js @@ -0,0 +1,14 @@ +// This file has been separated because it is required in both the gas and send +// slices. This created a circular dependency problem as both slices also +// import from the actions and selectors files. This easiest path for +// untangling is having the constants separate. + +// Actions +export const BASIC_GAS_ESTIMATE_STATUS = + 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; +export const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; +export const SET_BASIC_GAS_ESTIMATE_DATA = + 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; +export const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; +export const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; +export const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; diff --git a/ui/ducks/gas/gas-duck.test.js b/ui/ducks/gas/gas-duck.test.js index d4301f9b3..221e4dbd8 100644 --- a/ui/ducks/gas/gas-duck.test.js +++ b/ui/ducks/gas/gas-duck.test.js @@ -10,6 +10,14 @@ import GasReducer, { fetchBasicGasEstimates, } from './gas.duck'; +import { + BASIC_GAS_ESTIMATE_STATUS, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_PRICE, + SET_CUSTOM_GAS_LIMIT, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; + jest.mock('../../helpers/utils/storage-helpers.js', () => ({ getStorageItem: jest.fn(), setStorageItem: jest.fn(), @@ -61,13 +69,6 @@ describe('Gas Duck', () => { type: 'mainnet', }; - const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; - const SET_BASIC_GAS_ESTIMATE_DATA = - 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; - const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; - const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; - const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - describe('GasReducer()', () => { it('should initialize state', () => { expect(GasReducer(undefined, {})).toStrictEqual(initState); diff --git a/ui/ducks/gas/gas.duck.js b/ui/ducks/gas/gas.duck.js index e991e5e73..a41c313c5 100644 --- a/ui/ducks/gas/gas.duck.js +++ b/ui/ducks/gas/gas.duck.js @@ -10,6 +10,14 @@ import { } from '../../helpers/utils/conversions.util'; import { getIsMainnet, getCurrentChainId } from '../../selectors'; import fetchWithCache from '../../helpers/utils/fetch-with-cache'; +import { + BASIC_GAS_ESTIMATE_STATUS, + RESET_CUSTOM_DATA, + SET_BASIC_GAS_ESTIMATE_DATA, + SET_CUSTOM_GAS_LIMIT, + SET_CUSTOM_GAS_PRICE, + SET_ESTIMATE_SOURCE, +} from './gas-action-constants'; export const BASIC_ESTIMATE_STATES = { LOADING: 'LOADING', @@ -22,14 +30,6 @@ export const GAS_SOURCE = { ETHGASPRICE: 'eth_gasprice', }; -// Actions -const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; -const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; -const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; -const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; -const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; -const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; - const initState = { customData: { price: null, diff --git a/ui/ducks/index.js b/ui/ducks/index.js index bae560536..11b525e4c 100644 --- a/ui/ducks/index.js +++ b/ui/ducks/index.js @@ -2,7 +2,8 @@ import { combineReducers } from 'redux'; import { ALERT_TYPES } from '../../shared/constants/alerts'; import metamaskReducer from './metamask/metamask'; import localeMessagesReducer from './locale/locale'; -import sendReducer from './send/send.duck'; +import sendReducer from './send/send'; +import ensReducer from './ens'; import appStateReducer from './app/app'; import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck'; import gasReducer from './gas/gas.duck'; @@ -16,6 +17,7 @@ export default combineReducers({ activeTab: (s) => (s === undefined ? null : s), metamask: metamaskReducer, appState: appStateReducer, + ENS: ensReducer, history: historyReducer, send: sendReducer, confirmTransaction: confirmTransactionReducer, diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 21a0476ea..e72fb8ec7 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -33,7 +33,6 @@ export default function reduceMetamask(state = {}, action) { completedOnboarding: false, knownMethodData: {}, participateInMetaMetrics: null, - metaMetricsSendCount: 0, nextNonce: null, conversionRate: null, nativeCurrency: 'ETH', @@ -126,12 +125,6 @@ export default function reduceMetamask(state = {}, action) { participateInMetaMetrics: action.value, }; - case actionConstants.SET_METAMETRICS_SEND_COUNT: - return { - ...metamaskState, - metaMetricsSendCount: action.value, - }; - case actionConstants.SET_USE_BLOCKIE: return { ...metamaskState, @@ -245,3 +238,7 @@ export function getSendToAccounts(state) { export function getUnapprovedTxs(state) { return state.metamask.unapprovedTxs; } + +export function isEIP1559Network(state) { + return state.metamask.networkDetails.EIPS[1559] === true; +} diff --git a/ui/ducks/send/index.js b/ui/ducks/send/index.js new file mode 100644 index 000000000..d1ab99c82 --- /dev/null +++ b/ui/ducks/send/index.js @@ -0,0 +1 @@ +export * from './send'; diff --git a/ui/ducks/send/send-duck.test.js b/ui/ducks/send/send-duck.test.js deleted file mode 100644 index 7c05e8689..000000000 --- a/ui/ducks/send/send-duck.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import SendReducer, { - openToDropdown, - closeToDropdown, - updateSendErrors, - showGasButtonGroup, - hideGasButtonGroup, -} from './send.duck'; - -describe('Send Duck', () => { - const mockState = { - mockProp: 123, - }; - const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, - }; - const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; - const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; - const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; - const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; - const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; - const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; - - describe('SendReducer()', () => { - it('should initialize state', () => { - expect(SendReducer(undefined, {})).toStrictEqual(initState); - }); - - it('should return state unchanged if it does not match a dispatched actions type', () => { - expect( - SendReducer(mockState, { - type: 'someOtherAction', - value: 'someValue', - }), - ).toStrictEqual(mockState); - }); - - it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: OPEN_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: true, ...mockState }); - }); - - it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { - expect( - SendReducer(mockState, { - type: CLOSE_TO_DROPDOWN, - }), - ).toStrictEqual({ toDropdownOpen: false, ...mockState }); - }); - - it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer( - { ...mockState, gasButtonGroupShown: false }, - { type: SHOW_GAS_BUTTON_GROUP }, - ), - ).toStrictEqual({ gasButtonGroupShown: true, ...mockState }); - }); - - it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => { - expect( - SendReducer(mockState, { type: HIDE_GAS_BUTTON_GROUP }), - ).toStrictEqual({ gasButtonGroupShown: false, ...mockState }); - }); - - it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => { - const modifiedMockState = { - ...mockState, - errors: { - someError: false, - }, - }; - expect( - SendReducer(modifiedMockState, { - type: UPDATE_SEND_ERRORS, - value: { someOtherError: true }, - }), - ).toStrictEqual({ - ...modifiedMockState, - errors: { - someError: false, - someOtherError: true, - }, - }); - }); - - it('should return the initial state in response to a RESET_SEND_STATE action', () => { - expect( - SendReducer(mockState, { - type: RESET_SEND_STATE, - }), - ).toStrictEqual(initState); - }); - }); - - describe('Send Duck Actions', () => { - it('calls openToDropdown action', () => { - expect(openToDropdown()).toStrictEqual({ type: OPEN_TO_DROPDOWN }); - }); - - it('calls closeToDropdown action', () => { - expect(closeToDropdown()).toStrictEqual({ type: CLOSE_TO_DROPDOWN }); - }); - - it('calls showGasButtonGroup action', () => { - expect(showGasButtonGroup()).toStrictEqual({ - type: SHOW_GAS_BUTTON_GROUP, - }); - }); - - it('calls hideGasButtonGroup action', () => { - expect(hideGasButtonGroup()).toStrictEqual({ - type: HIDE_GAS_BUTTON_GROUP, - }); - }); - - it('calls updateSendErrors action', () => { - expect(updateSendErrors('mockErrorObject')).toStrictEqual({ - type: UPDATE_SEND_ERRORS, - value: 'mockErrorObject', - }); - }); - }); -}); diff --git a/ui/ducks/send/send.duck.js b/ui/ducks/send/send.duck.js deleted file mode 100644 index 82d9b9d82..000000000 --- a/ui/ducks/send/send.duck.js +++ /dev/null @@ -1,382 +0,0 @@ -import log from 'loglevel'; -import { estimateGas } from '../../store/actions'; -import { setCustomGasLimit } from '../gas/gas.duck'; -import { - estimateGasForSend, - calcTokenBalance, -} from '../../pages/send/send.utils'; - -// Actions -const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; -const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; -const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; -const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; -const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; -const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; -const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT'; -const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE'; -const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL'; -const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA'; -const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE'; -const UPDATE_SEND_TO = 'UPDATE_SEND_TO'; -const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT'; -const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE'; -const UPDATE_SEND = 'UPDATE_SEND'; -const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN'; -const CLEAR_SEND = 'CLEAR_SEND'; -const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED'; -const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED'; -const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION'; -const UPDATE_SEND_ENS_RESOLUTION_ERROR = 'UPDATE_SEND_ENS_RESOLUTION_ERROR'; - -const initState = { - toDropdownOpen: false, - gasButtonGroupShown: true, - errors: {}, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: '0x0', - from: '', - to: '', - amount: '0', - memo: '', - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - ensResolution: null, - ensResolutionError: '', - gasIsLoading: false, -}; - -// Reducer -export default function reducer(state = initState, action) { - switch (action.type) { - case OPEN_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: true, - }; - case CLOSE_TO_DROPDOWN: - return { - ...state, - toDropdownOpen: false, - }; - case UPDATE_SEND_ERRORS: - return { - ...state, - errors: { - ...state.errors, - ...action.value, - }, - }; - case SHOW_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: true, - }; - case HIDE_GAS_BUTTON_GROUP: - return { - ...state, - gasButtonGroupShown: false, - }; - case UPDATE_GAS_LIMIT: - return { - ...state, - gasLimit: action.value, - }; - case UPDATE_GAS_PRICE: - return { - ...state, - gasPrice: action.value, - }; - case RESET_SEND_STATE: - return { ...initState }; - case UPDATE_GAS_TOTAL: - return { - ...state, - gasTotal: action.value, - }; - case UPDATE_SEND_TOKEN_BALANCE: - return { - ...state, - tokenBalance: action.value, - }; - case UPDATE_SEND_HEX_DATA: - return { - ...state, - data: action.value, - }; - case UPDATE_SEND_TO: - return { - ...state, - to: action.value.to, - toNickname: action.value.nickname, - }; - case UPDATE_SEND_AMOUNT: - return { - ...state, - amount: action.value, - }; - case UPDATE_MAX_MODE: - return { - ...state, - maxModeOn: action.value, - }; - case UPDATE_SEND: - return Object.assign(state, action.value); - case UPDATE_SEND_TOKEN: { - const newSend = { - ...state, - token: action.value, - }; - // erase token-related state when switching back to native currency - if (newSend.editingTransactionId && !newSend.token) { - const unapprovedTx = - newSend?.unapprovedTxs?.[newSend.editingTransactionId] || {}; - const txParams = unapprovedTx.txParams || {}; - Object.assign(newSend, { - tokenBalance: null, - balance: '0', - from: unapprovedTx.from || '', - unapprovedTxs: { - ...newSend.unapprovedTxs, - [newSend.editingTransactionId]: { - ...unapprovedTx, - txParams: { - ...txParams, - data: '', - }, - }, - }, - }); - } - return Object.assign(state, newSend); - } - case UPDATE_SEND_ENS_RESOLUTION: - return { - ...state, - ensResolution: action.payload, - ensResolutionError: '', - }; - case UPDATE_SEND_ENS_RESOLUTION_ERROR: - return { - ...state, - ensResolution: null, - ensResolutionError: action.payload, - }; - case CLEAR_SEND: - return { - ...state, - gasLimit: null, - gasPrice: null, - gasTotal: null, - tokenBalance: null, - from: '', - to: '', - amount: '0x0', - memo: '', - errors: {}, - maxModeOn: false, - editingTransactionId: null, - toNickname: '', - }; - case GAS_LOADING_STARTED: - return { - ...state, - gasIsLoading: true, - }; - - case GAS_LOADING_FINISHED: - return { - ...state, - gasIsLoading: false, - }; - default: - return state; - } -} - -// Action Creators -export function openToDropdown() { - return { type: OPEN_TO_DROPDOWN }; -} - -export function closeToDropdown() { - return { type: CLOSE_TO_DROPDOWN }; -} - -export function showGasButtonGroup() { - return { type: SHOW_GAS_BUTTON_GROUP }; -} - -export function hideGasButtonGroup() { - return { type: HIDE_GAS_BUTTON_GROUP }; -} - -export function updateSendErrors(errorObject) { - return { - type: UPDATE_SEND_ERRORS, - value: errorObject, - }; -} - -export function resetSendState() { - return { type: RESET_SEND_STATE }; -} - -export function setGasLimit(gasLimit) { - return { - type: UPDATE_GAS_LIMIT, - value: gasLimit, - }; -} - -export function setGasPrice(gasPrice) { - return { - type: UPDATE_GAS_PRICE, - value: gasPrice, - }; -} - -export function setGasTotal(gasTotal) { - return { - type: UPDATE_GAS_TOTAL, - value: gasTotal, - }; -} - -export function updateGasData({ - gasPrice, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - data, -}) { - return (dispatch) => { - dispatch(gasLoadingStarted()); - return estimateGasForSend({ - estimateGasMethod: estimateGas, - blockGasLimit, - selectedAddress, - sendToken, - to, - value, - estimateGasPrice: gasPrice, - data, - }) - .then((gas) => { - dispatch(setGasLimit(gas)); - dispatch(setCustomGasLimit(gas)); - dispatch(updateSendErrors({ gasLoadingError: null })); - dispatch(gasLoadingFinished()); - }) - .catch((err) => { - log.error(err); - dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })); - dispatch(gasLoadingFinished()); - }); - }; -} - -export function gasLoadingStarted() { - return { - type: GAS_LOADING_STARTED, - }; -} - -export function gasLoadingFinished() { - return { - type: GAS_LOADING_FINISHED, - }; -} - -export function updateSendTokenBalance({ sendToken, tokenContract, address }) { - return (dispatch) => { - const tokenBalancePromise = tokenContract - ? tokenContract.balanceOf(address) - : Promise.resolve(); - return tokenBalancePromise - .then((usersToken) => { - if (usersToken) { - const newTokenBalance = calcTokenBalance({ sendToken, usersToken }); - dispatch(setSendTokenBalance(newTokenBalance)); - } - }) - .catch((err) => { - log.error(err); - updateSendErrors({ tokenBalance: 'tokenBalanceError' }); - }); - }; -} - -export function setSendTokenBalance(tokenBalance) { - return { - type: UPDATE_SEND_TOKEN_BALANCE, - value: tokenBalance, - }; -} - -export function updateSendHexData(value) { - return { - type: UPDATE_SEND_HEX_DATA, - value, - }; -} - -export function updateSendTo(to, nickname = '') { - return { - type: UPDATE_SEND_TO, - value: { to, nickname }, - }; -} - -export function updateSendAmount(amount) { - return { - type: UPDATE_SEND_AMOUNT, - value: amount, - }; -} - -export function setMaxModeTo(bool) { - return { - type: UPDATE_MAX_MODE, - value: bool, - }; -} - -export function updateSend(newSend) { - return { - type: UPDATE_SEND, - value: newSend, - }; -} - -export function updateSendToken(token) { - return { - type: UPDATE_SEND_TOKEN, - value: token, - }; -} - -export function clearSend() { - return { - type: CLEAR_SEND, - }; -} - -export function updateSendEnsResolution(ensResolution) { - return { - type: UPDATE_SEND_ENS_RESOLUTION, - payload: ensResolution, - }; -} - -export function updateSendEnsResolutionError(errorMessage) { - return { - type: UPDATE_SEND_ENS_RESOLUTION_ERROR, - payload: errorMessage, - }; -} diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js new file mode 100644 index 000000000..3ed41512b --- /dev/null +++ b/ui/ducks/send/send.js @@ -0,0 +1,1472 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import abi from 'human-standard-token-abi'; +import contractMap from '@metamask/contract-metadata'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix, toChecksumAddress } from 'ethereumjs-util'; +import { debounce } from 'lodash'; +import { + conversionGreaterThan, + conversionUtil, + multiplyCurrencies, + subtractCurrencies, +} from '../../helpers/utils/conversion-util'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + MIN_GAS_LIMIT_HEX, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; + +import { + addGasBuffer, + calcGasTotal, + generateTokenTransferData, + isBalanceSufficient, + isTokenBalanceSufficient, +} from '../../pages/send/send.utils'; +import { + getAddressBookEntry, + getAdvancedInlineGasShown, + getCurrentChainId, + getGasPriceInHexWei, + getIsMainnet, + getSelectedAddress, + getTargetAccount, +} from '../../selectors'; +import { + displayWarning, + estimateGas, + hideLoadingIndication, + showConfTxPage, + showLoadingIndication, + updateTokenType, + updateTransaction, +} from '../../store/actions'; +import { + fetchBasicGasEstimates, + setCustomGasLimit, + BASIC_ESTIMATE_STATES, +} from '../gas/gas.duck'; +import { + SET_BASIC_GAS_ESTIMATE_DATA, + BASIC_GAS_ESTIMATE_STATUS, +} from '../gas/gas-action-constants'; +import { + QR_CODE_DETECTED, + SELECTED_ACCOUNT_CHANGED, + ACCOUNT_CHANGED, + ADDRESS_BOOK_UPDATED, +} from '../../store/actionConstants'; +import { + calcTokenAmount, + getTokenAddressParam, + getTokenValueParam, +} from '../../helpers/utils/token-util'; +import { + checkExistingAddresses, + isDefaultMetaMaskChain, + isOriginContractAddress, + isValidDomainName, +} from '../../helpers/utils/util'; +import { getTokens, getUnapprovedTxs } from '../metamask/metamask'; +import { resetResolution } from '../ens'; +import { + isBurnAddress, + isValidHexAddress, +} from '../../../shared/modules/hexstring-utils'; + +// typedefs +/** + * @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction + */ + +const name = 'send'; + +/** + * The Stages that the send slice can be in + * 1. UNINITIALIZED - The send state is idle, and hasn't yet fetched required + * data for gasPrice and gasLimit estimations, etc. + * 2. ADD_RECIPIENT - The user is selecting which address to send an asset to + * 3. DRAFT - The send form is shown for a transaction yet to be sent to the + * Transaction Controller. + * 4. EDIT - The send form is shown for a transaction already submitted to the + * Transaction Controller but not yet confirmed. This happens when a + * confirmation is shown for a transaction and the 'edit' button in the header + * is clicked. + */ +export const SEND_STAGES = { + INACTIVE: 'INACTIVE', + ADD_RECIPIENT: 'ADD_RECIPIENT', + DRAFT: 'DRAFT', + EDIT: 'EDIT', +}; + +/** + * The status that the send slice can be in is either + * 1. VALID - the transaction is valid and can be submitted + * 2. INVALID - the transaction is invalid and cannot be submitted + * + * A number of cases would result in an invalid form + * 1. The recipient is not yet defined + * 2. The amount + gasTotal is greater than the user's balance when sending + * native currency + * 3. The gasTotal is greater than the user's *native* balance + * 4. The amount of sent asset is greater than the user's *asset* balance + * 5. Gas price estimates failed to load entirely + * 6. The gasLimit is less than 21000 (0x5208) + */ +export const SEND_STATUSES = { + VALID: 'VALID', + INVALID: 'INVALID', +}; + +/** + * Controls what is displayed in the send-gas-row component. + * 1. BASIC - Shows the basic estimate slow/avg/fast buttons when on mainnet + * and the metaswaps API request is successful. + * 2. INLINE - Shows inline gasLimit/gasPrice fields when on any other network + * or metaswaps API fails and we use eth_gasPrice + * 3. CUSTOM - Shows GasFeeDisplay component that is a read only display of the + * values the user has set in the advanced gas modal (stored in the gas duck + * under the customData key). + */ +export const GAS_INPUT_MODES = { + BASIC: 'BASIC', + INLINE: 'INLINE', + CUSTOM: 'CUSTOM', +}; + +/** + * The types of assets that a user can send + * 1. NATIVE - The native asset for the current network, such as ETH + * 2. TOKEN - An ERC20 token. + */ +export const ASSET_TYPES = { + NATIVE: 'NATIVE', + TOKEN: 'TOKEN', +}; + +/** + * The modes that the amount field can be set by + * 1. INPUT - the user provides the amount by typing in the field + * 2. MAX - The user selects the MAX button and amount is calculated based on + * balance - (amount + gasTotal) + */ +export const AMOUNT_MODES = { + INPUT: 'INPUT', + MAX: 'MAX', +}; + +export const RECIPIENT_SEARCH_MODES = { + MY_ACCOUNTS: 'MY_ACCOUNTS', + CONTACT_LIST: 'CONTACT_LIST', +}; + +async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + ...options +}) { + // blockGasLimit may be a falsy, but defined, value when we receive it from + // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. + const blockGasLimit = options.blockGasLimit || MIN_GAS_LIMIT_HEX; + // The parameters below will be sent to our background process to estimate + // how much gas will be used for a transaction. That background process is + // located in tx-gas-utils.js in the transaction controller folder. + const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; + + if (sendToken) { + if (!to) { + // if no to address is provided, we cannot generate the token transfer + // hexData. hexData in a transaction largely dictates how much gas will + // be consumed by a transaction. We must use our best guess, which is + // represented in the gas shared constants. + return GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + paramsForGasEstimate.value = '0x0'; + // We have to generate the erc20 contract call to transfer tokens in + // order to get a proper estimate for gasLimit. + paramsForGasEstimate.data = generateTokenTransferData({ + toAddress: to, + amount: value, + sendToken, + }); + paramsForGasEstimate.to = sendToken.address; + } else { + if (!data) { + // eth.getCode will return the compiled smart contract code at the + // address. If this returns 0x, 0x0 or a nullish value then the address + // is an externally owned account (NOT a contract account). For these + // types of transactions the gasLimit will always be 21,000 or 0x5208 + const contractCode = Boolean(to) && (await global.eth.getCode(to)); + // Geth will return '0x', and ganache-core v2.2.1 will return '0x0' + const contractCodeIsEmpty = + !contractCode || contractCode === '0x' || contractCode === '0x0'; + if (contractCodeIsEmpty) { + return GAS_LIMITS.SIMPLE; + } + } + + paramsForGasEstimate.data = data; + + if (to) { + paramsForGasEstimate.to = to; + } + + if (!value || value === '0') { + // TODO: Figure out what's going on here. According to eth_estimateGas + // docs this value can be zero, or undefined, yet we are setting it to a + // value here when the value is undefined or zero. For more context: + // https://github.com/MetaMask/metamask-extension/pull/6195 + paramsForGasEstimate.value = '0xff'; + } + } + + // If we do not yet have a gasLimit, we must call into our background + // process to get an estimate for gasLimit based on known parameters. + + paramsForGasEstimate.gas = addHexPrefix( + multiplyCurrencies(blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }), + ); + try { + // call into the background process that will simulate transaction + // execution on the node and return an estimate of gasLimit + const estimatedGasLimit = await estimateGas(paramsForGasEstimate); + const estimateWithBuffer = addGasBuffer( + estimatedGasLimit, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } catch (error) { + const simulationFailed = + error.message.includes('Transaction execution error.') || + error.message.includes( + 'gas required exceeds allowance or always failing transaction', + ); + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer( + paramsForGasEstimate.gas, + blockGasLimit, + 1.5, + ); + return addHexPrefix(estimateWithBuffer); + } + throw error; + } +} + +export async function getERC20Balance(token, accountAddress) { + const contract = global.eth.contract(abi).at(token.address); + const usersToken = (await contract.balanceOf(accountAddress)) ?? null; + if (!usersToken) { + return '0x0'; + } + const amount = calcTokenAmount( + usersToken.balance.toString(), + token.decimals, + ).toString(16); + return addHexPrefix(amount); +} + +// After modification of specific fields in specific circumstances we must +// recompute the gasLimit estimate to be as accurate as possible. the cases +// that necessitate this logic are listed below: +// 1. when the amount sent changes when sending a token due to the amount being +// part of the hex encoded data property of the transaction. +// 2. when updating the data property while sending NATIVE currency (ex: ETH) +// because the data parameter defines function calls that the EVM will have +// to execute which is where a large chunk of gas is potentially consumed. +// 3. when the recipient changes while sending a token due to the recipient's +// address being included in the hex encoded data property of the +// transaction +// 4. when the asset being sent changes due to the contract address and details +// of the token being included in the hex encoded data property of the +// transaction. If switching to NATIVE currency (ex: ETH), the gasLimit will +// change due to hex data being removed (unless supplied by user). +// This method computes the gasLimit estimate which is written to state in an +// action handler in extraReducers. +export const computeEstimatedGasLimit = createAsyncThunk( + 'send/computeEstimatedGasLimit', + async (_, thunkApi) => { + const { send, metamask } = thunkApi.getState(); + if (send.stage !== SEND_STAGES.EDIT) { + const gasLimit = await estimateGasLimitForSend({ + gasPrice: send.gas.gasPrice, + blockGasLimit: metamask.blockGasLimit, + selectedAddress: metamask.selectedAddress, + sendToken: send.asset.details, + to: send.recipient.address?.toLowerCase(), + value: send.amount.value, + data: send.draftTransaction.userInputHexData, + }); + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + return { + gasLimit, + }; + } + return null; + }, +); + +/** + * Responsible for initializing required state for the send slice. + * This method is dispatched from the send page in the componentDidMount + * method. It is also dispatched anytime the network changes to ensure that + * the slice remains valid with changing token and account balances. To do so + * it keys into state to get necessary values and computes a starting point for + * the send slice. It returns the values that might change from this action and + * those values are written to the slice in the `initializeSendState.fulfilled` + * action handler. + */ +export const initializeSendState = createAsyncThunk( + 'send/initializeSendState', + async (_, thunkApi) => { + const state = thunkApi.getState(); + const { + send: { asset, stage, recipient, amount, draftTransaction }, + metamask, + } = state; + // First determine the correct from address. For new sends this is always + // the currently selected account and switching accounts switches the from + // address. If editing an existing transaction (by clicking 'edit' on the + // send page), the fromAddress is always the address from the txParams. + const fromAddress = + stage === SEND_STAGES.EDIT + ? draftTransaction.txParams.from + : metamask.selectedAddress; + // We need the account's balance which is calculated from cachedBalances in + // the getMetaMaskAccounts selector. getTargetAccount consumes this + // selector and returns the account at the specified address. + const account = getTargetAccount(state, fromAddress); + // Initiate gas slices work to fetch gasPrice estimates. We need to get the + // new state after this is set to determine if initialization can proceed. + await thunkApi.dispatch(fetchBasicGasEstimates()); + const { + gas: { basicEstimateStatus, basicEstimates }, + } = thunkApi.getState(); + // Default gasPrice to 1 gwei if all estimation fails + const gasPrice = + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY + ? getGasPriceInHexWei(basicEstimates.average) + : '0x1'; + // Set a basic gasLimit in the event that other estimation fails + let gasLimit = + asset.type === ASSET_TYPES.TOKEN + ? GAS_LIMITS.BASE_TOKEN_ESTIMATE + : GAS_LIMITS.SIMPLE; + if ( + basicEstimateStatus === BASIC_ESTIMATE_STATES.READY && + stage !== SEND_STAGES.EDIT + ) { + // Run our estimateGasLimit logic to get a more accurate estimation of + // required gas. If this value isn't nullish, set it as the new gasLimit + const estimatedGasLimit = await estimateGasLimitForSend({ + gasPrice: getGasPriceInHexWei(basicEstimates.average), + blockGasLimit: metamask.blockGasLimit, + selectedAddress: fromAddress, + sendToken: asset.details, + to: recipient.address.toLowerCase(), + value: amount.value, + data: draftTransaction.userInputHexData, + }); + gasLimit = estimatedGasLimit || gasLimit; + } + // We have to keep the gas slice in sync with the draft send transaction + // so that it'll be initialized correctly if the gas modal is opened. + await thunkApi.dispatch(setCustomGasLimit(gasLimit)); + // We must determine the balance of the asset that the transaction will be + // sending. This is done by referencing the native balance on the account + // for native assets, and calling the balanceOf method on the ERC20 + // contract for token sends. + let { balance } = account; + if (asset.type === ASSET_TYPES.TOKEN) { + if (asset.details === null) { + // If we're sending a token but details have not been provided we must + // abort and set the send slice into invalid status. + throw new Error( + 'Send slice initialized as token send without token details', + ); + } + balance = await getERC20Balance(asset.details, fromAddress); + } + return { + address: fromAddress, + nativeBalance: account.balance, + assetBalance: balance, + chainId: getCurrentChainId(state), + tokens: getTokens(state), + gasPrice, + gasLimit, + gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), + }; + }, +); + +export const initialState = { + // which stage of the send flow is the user on + stage: SEND_STAGES.UNINITIALIZED, + // status of the send slice, either VALID or INVALID + status: SEND_STATUSES.VALID, + account: { + // from account address, defaults to selected account. will be the account + // the original transaction was sent from in the case of the EDIT stage + address: null, + // balance of the from account + balance: '0x0', + }, + gas: { + // indicate whether the gas estimate is loading + isGasEstimateLoading: true, + // has the user set custom gas in the custom gas modal + isCustomGasSet: false, + // maximum gas needed for tx + gasLimit: '0x0', + // price in gwei to pay per gas + gasPrice: '0x0', + // maximum total price in gwei to pay + gasTotal: '0x0', + // minimum supported gasLimit + minimumGasLimit: GAS_LIMITS.SIMPLE, + // error to display for gas fields + error: null, + }, + amount: { + // The mode to use when determining new amounts. For INPUT mode the + // provided payload is always used. For MAX it is calculated based on avail + // asset balance + mode: AMOUNT_MODES.INPUT, + // Current value of the transaction, how much of the asset are we sending + value: '0x0', + // error to display for amount field + error: null, + }, + asset: { + // type can be either NATIVE such as ETH or TOKEN for ERC20 tokens + type: ASSET_TYPES.NATIVE, + // the balance the user holds at the from address for this asset + balance: '0x0', + // In the case of tokens, the address, decimals and symbol of the token + // will be included in details + details: null, + }, + draftTransaction: { + // The metamask internal id of the transaction. Only populated in the EDIT + // stage. + id: null, + // The hex encoded data provided by the user who has enabled hex data field + // in advanced settings + userInputHexData: null, + // The txParams that should be submitted to the network once this + // transaction is confirmed. This object is computed on every write to the + // slice of fields that would result in the txParams changing + txParams: { + to: '', + from: '', + data: undefined, + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }, + }, + recipient: { + // Defines which mode to use for searching for matches in the input field + mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + // Partial, not yet validated, entry into the address field. Used to share + // user input amongst the AddRecipient and EnsInput components. + userInput: '', + // The address of the recipient + address: '', + // The nickname stored in the user's address book for the recipient address + nickname: '', + // Error to display on the address field + error: null, + // Warning to display on the address field + warning: null, + }, +}; + +const slice = createSlice({ + name, + initialState, + reducers: { + /** + * update current amount.value in state and run post update validation of + * the amount field and the send state. Recomputes the draftTransaction + */ + updateSendAmount: (state, action) => { + state.amount.value = addHexPrefix(action.payload); + // Once amount has changed, validate the field + slice.caseReducers.validateAmountField(state); + if (state.asset.type === ASSET_TYPES.NATIVE) { + // if sending the native asset the amount being sent will impact the + // gas field as well because the gas validation takes into + // consideration the available balance minus amount sent before + // checking if there is enough left to cover the gas fee. + slice.caseReducers.validateGasField(state); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * computes the maximum amount of asset that can be sent and then calls + * the updateSendAmount action above with the computed value, which will + * revalidate the field and form and recomputes the draftTransaction + */ + updateAmountToMax: (state) => { + let amount = '0x0'; + if (state.asset.type === ASSET_TYPES.TOKEN) { + const decimals = state.asset.details?.decimals ?? 0; + const multiplier = Math.pow(10, Number(decimals)); + + amount = multiplyCurrencies(state.asset.balance, multiplier, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }); + } else { + amount = subtractCurrencies( + addHexPrefix(state.asset.balance), + addHexPrefix(state.gas.gasTotal), + { + toNumericBase: 'hex', + aBase: 16, + bBase: 16, + }, + ); + } + slice.caseReducers.updateSendAmount(state, { + payload: amount, + }); + // draftTransaction update happens in updateSendAmount + }, + /** + * updates the draftTransaction.userInputHexData state key and then + * recomputes the draftTransaction if the user is currently sending the + * native asset. When sending ERC20 assets, this is unnecessary because the + * hex data used in the transaction will be that for interacting with the + * ERC20 contract + */ + updateUserInputHexData: (state, action) => { + state.draftTransaction.userInputHexData = action.payload; + if (state.asset.type === ASSET_TYPES.NATIVE) { + slice.caseReducers.updateDraftTransaction(state); + } + }, + /** + * Initiates the edit transaction flow by setting the stage to 'EDIT' and + * then pulling the details of the previously submitted transaction from + * the action payload. It also computes a new draftTransaction that will be + * used when updating the transaction in the provider + */ + editTransaction: (state, action) => { + state.stage = SEND_STAGES.EDIT; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.amount.value = action.payload.amount; + state.gas.error = null; + state.amount.error = null; + state.recipient.address = action.payload.address; + state.recipient.nickname = action.payload.nickname; + state.draftTransaction.id = action.payload.id; + state.draftTransaction.txParams.from = action.payload.from; + slice.caseReducers.updateDraftTransaction(state); + }, + /** + * gasTotal is computed based on gasPrice and gasLimit and set in state + * recomputes the maximum amount if the current amount mode is 'MAX' and + * sending the native token. ERC20 assets max amount is unaffected by + * gasTotal so does not need to be recomputed. Finally, validates the gas + * field and send state, then updates the draft transaction. + */ + calculateGasTotal: (state) => { + state.gas.gasTotal = addHexPrefix( + calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), + ); + if ( + state.amount.mode === AMOUNT_MODES.MAX && + state.asset.type === ASSET_TYPES.NATIVE + ) { + slice.caseReducers.updateAmountToMax(state); + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + // validate send state + slice.caseReducers.validateSendState(state); + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal. + */ + updateGasLimit: (state, action) => { + state.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the provided gasPrice in state and then recomputes the gasTotal + */ + updateGasPrice: (state, action) => { + state.gas.gasPrice = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + }, + /** + * sets the amount mode to the provided value as long as it is one of the + * supported modes (MAX|INPUT) + */ + updateAmountMode: (state, action) => { + if (Object.values(AMOUNT_MODES).includes(action.payload)) { + state.amount.mode = action.payload; + } + }, + updateAsset: (state, action) => { + state.asset.type = action.payload.type; + state.asset.balance = action.payload.balance; + if (state.asset.type === ASSET_TYPES.TOKEN) { + state.asset.details = action.payload.details; + } else { + // clear the details object when sending native currency + state.asset.details = null; + if (state.recipient.error === CONTRACT_ADDRESS_ERROR) { + // Errors related to sending tokens to their own contract address + // are no longer valid when sending native currency. + state.recipient.error = null; + } + + if (state.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING) { + // Warning related to sending tokens to a known contract address + // are no longer valid when sending native currency. + state.recipient.warning = null; + } + } + // if amount mode is MAX update amount to max of new asset, otherwise set + // to zero. This will revalidate the send amount field. + if (state.amount.mode === AMOUNT_MODES.MAX) { + slice.caseReducers.updateAmountToMax(state); + } else { + slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); + } + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateRecipient: (state, action) => { + state.recipient.error = null; + state.recipient.userInput = ''; + state.recipient.address = action.payload.address ?? ''; + state.recipient.nickname = action.payload.nickname ?? ''; + + if (state.recipient.address === '') { + // If address is null we are clearing the recipient and must return + // to the ADD_RECIPIENT stage. + state.stage = SEND_STAGES.ADD_RECIPIENT; + } else { + // if and address is provided and an id exists on the draft transaction, + // we progress to the EDIT stage, otherwise we progress to the DRAFT + // stage. We also reset the search mode for recipient search. + state.stage = + state.draftTransaction.id === null + ? SEND_STAGES.DRAFT + : SEND_STAGES.EDIT; + state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; + } + + // validate send state + slice.caseReducers.validateSendState(state); + }, + updateDraftTransaction: (state) => { + // We keep a copy of txParams in state that could be submitted to the + // network if the form state is valid. + if (state.status === SEND_STATUSES.VALID) { + state.draftTransaction.txParams.from = state.account.address; + switch (state.asset.type) { + case ASSET_TYPES.TOKEN: + // When sending a token the to address is the contract address of + // the token being sent. The value is set to '0x0' and the data + // is generated from the recipient address, token being sent and + // amount. + state.draftTransaction.txParams.to = state.asset.details.address; + state.draftTransaction.txParams.value = '0x0'; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = generateTokenTransferData({ + toAddress: state.recipient.address, + amount: state.amount.value, + sendToken: state.asset.details, + }); + break; + case ASSET_TYPES.NATIVE: + default: + // When sending native currency the to and value fields use the + // recipient and amount values and the data key is either null or + // populated with the user input provided in hex field. + state.draftTransaction.txParams.to = state.recipient.address; + state.draftTransaction.txParams.value = state.amount.value; + state.draftTransaction.txParams.gas = state.gas.gasLimit; + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.data = + state.draftTransaction.userInputHexData ?? undefined; + } + } + }, + useDefaultGas: (state) => { + // Show the default gas price/limit fields in the send page + state.gas.isCustomGasSet = false; + }, + useCustomGas: (state) => { + // Show the gas fees set in the custom gas modal (state.gas.customData) + state.gas.isCustomGasSet = true; + }, + updateRecipientUserInput: (state, action) => { + // Update the value in state to match what the user is typing into the + // input field + state.recipient.userInput = action.payload; + }, + validateRecipientUserInput: (state, action) => { + const { asset, recipient } = state; + + if ( + recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || + recipient.userInput === '' || + recipient.userInput === null + ) { + recipient.error = null; + recipient.warning = null; + } else { + const isSendingToken = asset.type === ASSET_TYPES.TOKEN; + const { chainId, tokens } = action.payload; + if ( + isBurnAddress(recipient.userInput) || + (!isValidHexAddress(recipient.userInput, { + mixedCaseUseChecksum: true, + }) && + !isValidDomainName(recipient.userInput)) + ) { + recipient.error = isDefaultMetaMaskChain(chainId) + ? INVALID_RECIPIENT_ADDRESS_ERROR + : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; + } else if ( + isSendingToken && + isOriginContractAddress(recipient.userInput, asset.details.address) + ) { + recipient.error = CONTRACT_ADDRESS_ERROR; + } else { + recipient.error = null; + } + + if ( + isSendingToken && + (toChecksumAddress(recipient.userInput) in contractMap || + checkExistingAddresses(recipient.userInput, tokens)) + ) { + recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; + } else { + recipient.warning = null; + } + } + }, + updateRecipientSearchMode: (state, action) => { + state.recipient.userInput = ''; + state.recipient.mode = action.payload; + }, + resetSendState: () => initialState, + validateAmountField: (state) => { + switch (true) { + // set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower + // than the total price of the transaction inclusive of gas fees. + case state.asset.type === ASSET_TYPES.NATIVE && + !isBalanceSufficient({ + amount: state.amount.value, + balance: state.asset.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }): + state.amount.error = INSUFFICIENT_FUNDS_ERROR; + break; + // set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower + // than the amount of token the user is attempting to send. + case state.asset.type === ASSET_TYPES.TOKEN && + !isTokenBalanceSufficient({ + tokenBalance: state.asset.balance ?? '0x0', + amount: state.amount.value, + decimals: state.asset.details.decimals, + }): + state.amount.error = INSUFFICIENT_TOKENS_ERROR; + break; + // if the amount is negative, set error to NEGATIVE_ETH_ERROR + // TODO: change this to NEGATIVE_ERROR and remove the currency bias. + case conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: state.amount.value, fromNumericBase: 'hex' }, + ): + state.amount.error = NEGATIVE_ETH_ERROR; + break; + // If none of the above are true, set error to null + default: + state.amount.error = null; + } + }, + validateGasField: (state) => { + // Checks if the user has enough funds to cover the cost of gas, always + // uses the native currency and does not take into account the amount + // being sent. If the user has enough to cover cost of gas but not gas + // + amount then the error will be displayed on the amount field. + const insufficientFunds = !isBalanceSufficient({ + amount: + state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0', + balance: state.account.balance, + gasTotal: state.gas.gasTotal ?? '0x0', + }); + + state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null; + }, + validateSendState: (state) => { + switch (true) { + // 1 + 2. State is invalid when either gas or amount fields have errors + // 3. State is invalid if asset type is a token and the token details + // are unknown. + // 4. State is invalid if no recipient has been added + // 5. State is invalid if the send state is uninitialized + // 6. State is invalid if gas estimates are loading + // 7. State is invalid if gasLimit is less than the minimumGasLimit + // 8. State is invalid if the selected asset is a ERC721 + case Boolean(state.amount.error): + case Boolean(state.gas.error): + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details === null: + case state.stage === SEND_STAGES.ADD_RECIPIENT: + case state.stage === SEND_STAGES.UNINITIALIZED: + case state.gas.isGasEstimateLoading: + case new BigNumber(state.gas.gasLimit, 16).lessThan( + new BigNumber(state.gas.minimumGasLimit), + ): + state.status = SEND_STATUSES.INVALID; + break; + case state.asset.type === ASSET_TYPES.TOKEN && + state.asset.details.isERC721 === true: + state.state = SEND_STATUSES.INVALID; + break; + default: + state.status = SEND_STATUSES.VALID; + // Recompute the draftTransaction object + slice.caseReducers.updateDraftTransaction(state); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(QR_CODE_DETECTED, (state, action) => { + // When data is received from the QR Code Scanner we set the recipient + // as long as a valid address can be pulled from the data. If an + // address is pulled but it is invalid, we display an error. + const qrCodeData = action.value; + if (qrCodeData) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (state.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } + }) + .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow the account we are keyed into will be the + // original 'from' account, which may differ from the selected account + if (state.stage !== SEND_STAGES.EDIT) { + // This event occurs when the user selects a new account from the + // account menu, or the currently active account's balance updates. + state.account.balance = action.payload.account.balance; + state.account.address = action.payload.account.address; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow then we need to watch for changes to the + // current account.address in state and keep balance updated + // appropriately + if ( + state.stage === SEND_STAGES.EDIT && + action.payload.account.address === state.account.address + ) { + // This event occurs when the user's account details update due to + // background state changes. If the account that is being updated is + // the current from account on the edit flow we need to update + // the balance for the account and revalidate the send state. + state.account.balance = action.payload.account.balance; + // We need to update the asset balance if the asset is the native + // network asset. Once we update the balance we recompute error state. + if (state.asset.type === ASSET_TYPES.NATIVE) { + state.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } + }) + .addCase(ADDRESS_BOOK_UPDATED, (state, action) => { + // When the address book updates from background state changes we need + // to check to see if an entry exists for the current address or if the + // entry changed. + const { addressBook } = action.payload; + if (addressBook[state.recipient.address]?.name) { + state.recipient.nickname = addressBook[state.recipient.address].name; + } + }) + .addCase(initializeSendState.pending, (state) => { + // when we begin initializing state, which can happen when switching + // chains even after loading the send flow, we set + // gas.isGasEstimateLoading as initialization will trigger a fetch + // for gasPrice estimates. + state.gas.isGasEstimateLoading = true; + }) + .addCase(initializeSendState.fulfilled, (state, action) => { + // writes the computed initialized state values into the slice and then + // calculates slice validity using the caseReducers. + state.account.address = action.payload.address; + state.account.balance = action.payload.nativeBalance; + state.asset.balance = action.payload.assetBalance; + state.gas.gasLimit = action.payload.gasLimit; + state.gas.gasPrice = action.payload.gasPrice; + state.gas.gasTotal = action.payload.gasTotal; + if (state.stage !== SEND_STAGES.UNINITIALIZED) { + slice.caseReducers.validateRecipientUserInput(state, { + payload: { + chainId: action.payload.chainId, + tokens: action.payload.tokens, + }, + }); + } + state.stage = + state.stage === SEND_STAGES.UNINITIALIZED + ? SEND_STAGES.ADD_RECIPIENT + : state.stage; + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit we should indicate we are loading + // a gas estimate. + state.gas.isGasEstimateLoading = true; + }) + .addCase(computeEstimatedGasLimit.fulfilled, (state, action) => { + // When we receive a new gasLimit from the computeEstimatedGasLimit + // thunk we need to update our gasLimit in the slice. We call into the + // caseReducer updateGasLimit to tap into the appropriate follow up + // checks and gasTotal calculation. First set isGasEstimateLoading to + // false. + state.gas.isGasEstimateLoading = false; + if (action.payload?.gasLimit) { + slice.caseReducers.updateGasLimit(state, { + payload: action.payload.gasLimit, + }); + } + }) + .addCase(SET_BASIC_GAS_ESTIMATE_DATA, (state, action) => { + // When we receive a new gasPrice via the gas duck we need to update + // the gasPrice in our slice. We call into the caseReducer + // updateGasPrice to also tap into the appropriate follow up checks + // and gasTotal calculation. + slice.caseReducers.updateGasPrice(state, { + payload: getGasPriceInHexWei(action.value.average), + }); + }) + .addCase(BASIC_GAS_ESTIMATE_STATUS, (state, action) => { + // When we fetch gas prices we should temporarily set the form invalid + // Once the price updates we get that value in the + // SET_BASIC_GAS_ESTIMATE_DATA extraReducer above. Finally as long as + // the state is 'READY' we will revalidate the form. + switch (action.value) { + case BASIC_ESTIMATE_STATES.FAILED: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.LOADING: + state.status = SEND_STATUSES.INVALID; + state.gas.isGasEstimateLoading = true; + break; + case BASIC_ESTIMATE_STATES.READY: + default: + state.gas.isGasEstimateLoading = false; + slice.caseReducers.validateSendState(state); + } + }); + }, +}); + +const { actions, reducer } = slice; + +export default reducer; + +const { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, + validateRecipientUserInput, + updateRecipientSearchMode, +} = actions; + +export { + useDefaultGas, + useCustomGas, + updateGasLimit, + updateGasPrice, + resetSendState, +}; + +// Action Creators + +/** + * Updates the amount the user intends to send and performs side effects. + * 1. If the current mode is MAX change to INPUT + * 2. If sending a token, recompute the gasLimit estimate + * @param {string} amount - hex string representing value + * @returns {void} + */ +export function updateSendAmount(amount) { + return async (dispatch, getState) => { + await dispatch(actions.updateSendAmount(amount)); + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + } + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * updates the asset to send to one of NATIVE or TOKEN and ensures that the + * asset balance is set. If sending a TOKEN also updates the asset details + * object with the appropriate ERC20 details including address, symbol and + * decimals. + * @param {Object} payload - action payload + * @param {string} payload.type - type of asset to send + * @param {Object} [payload.details] - ERC20 details if sending TOKEN asset + * @param {string} [payload.details.address] - contract address for ERC20 + * @param {string} [payload.details.decimals] - Number of token decimals + * @param {string} [payload.details.symbol] - asset symbol to display + * @returns {void} + */ +export function updateSendAsset({ type, details }) { + return async (dispatch, getState) => { + const state = getState(); + let { balance } = state.send.asset; + if (type === ASSET_TYPES.TOKEN) { + // if changing to a token, get the balance from the network. The asset + // overview page and asset list on the wallet overview page contain + // send buttons that call this method before initialization occurs. + // When this happens we don't yet have an account.address so default to + // the currently active account. In addition its possible for the balance + // check to take a decent amount of time, so we display a loading + // indication so that that immediate feedback is displayed to the user. + await dispatch(showLoadingIndication()); + balance = await getERC20Balance( + details, + state.send.account.address ?? getSelectedAddress(state), + ); + if (details && details.isERC721 === undefined) { + const updatedAssetDetails = await updateTokenType(details.address); + details.isERC721 = updatedAssetDetails.isERC721; + } + + await dispatch(hideLoadingIndication()); + } else { + // if changing to native currency, get it from the account key in send + // state which is kept in sync when accounts change. + balance = state.send.account.balance; + } + // update the asset in state which will re-run amount and gas validation + await dispatch(actions.updateAsset({ type, details, balance })); + await dispatch(computeEstimatedGasLimit()); + }; +} + +/** + * This method is for usage when validating user input so that validation + * is only run after a delay in typing of 300ms. Usage at callsites requires + * passing in both the dispatch method and the payload to dispatch, which makes + * it only applicable for use within action creators. + */ +const debouncedValidateRecipientUserInput = debounce((dispatch, payload) => { + dispatch(validateRecipientUserInput(payload)); +}, 300); + +/** + * This method is called to update the user's input into the ENS input field. + * Once the field is updated, the field will be validated using a debounced + * version of the validateRecipientUserInput action. This way validation only + * occurs once the user has stopped typing. + * @param {string} userInput - the value that the user is typing into the field + * @returns {void} + */ +export function updateRecipientUserInput(userInput) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipientUserInput(userInput)); + const state = getState(); + const chainId = getCurrentChainId(state); + const tokens = getTokens(state); + debouncedValidateRecipientUserInput(dispatch, { chainId, tokens }); + }; +} + +export function useContactListForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); + }; +} + +export function useMyAccountsForRecipientSearch() { + return (dispatch) => { + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); + }; +} + +/** + * Updates the recipient in state based on the input provided, and then will + * recompute gas limit when sending a TOKEN asset type. Changing the recipient + * address results in hex data changing because the recipient address is + * encoded in the data instead of being in the 'to' field. The to field in a + * token send will always be the token contract address. + * @param {Object} recipient - Recipient information + * @param {string} recipient.address - hex address to send the transaction to + * @param {string} [recipient.nickname] - Alias for the address to display + * to the user + * @returns {void} + */ +export function updateRecipient({ address, nickname }) { + return async (dispatch, getState) => { + await dispatch(actions.updateRecipient({ address, nickname })); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.TOKEN) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Clears out the recipient user input, ENS resolution and recipient validation + * @returns {void} + */ +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(updateRecipientUserInput('')); + await dispatch(updateRecipient({ address: '', nickname: '' })); + await dispatch(resetResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +/** + * When a user has enabled hex data field in advanced settings they will be + * able to supply hex data on a transaction. This method updates the user + * supplied data. Note, when sending native assets this will result in + * recomputing estimated gasLimit. When sending a ERC20 asset this is not done + * because the data sent in the transaction will be determined by the asset, + * recipient and value, NOT what the user has supplied. + * @param {string} hexData - hex encoded string representing transaction data + * @returns {void} + */ +export function updateSendHexData(hexData) { + return async (dispatch, getState) => { + await dispatch(actions.updateUserInputHexData(hexData)); + const state = getState(); + if (state.send.asset.type === ASSET_TYPES.NATIVE) { + await dispatch(computeEstimatedGasLimit()); + } + }; +} + +/** + * Toggles the amount.mode between INPUT and MAX modes. + * As a result, the amount.value will change to either '0x0' when moving from + * MAX to INPUT, or to the maximum allowable amount based on current asset when + * moving from INPUT to MAX. + * @returns {void} + */ +export function toggleSendMaxMode() { + return async (dispatch, getState) => { + const state = getState(); + if (state.send.amount.mode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + await dispatch(actions.updateSendAmount('0x0')); + } else { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); + await dispatch(actions.updateAmountToMax()); + } + }; +} + +/** + * Signs a transaction or updates a transaction in state if editing. + * This method is called when a user clicks the next button in the footer of + * the send page, signaling that a transaction should be executed. This method + * will create the transaction in state (by way of the various global provider + * constructs) which will eventually (and fairly quickly from user perspective) + * result in a confirmation window being displayed for the transaction. + * @returns {void} + */ +export function signTransaction() { + return async (dispatch, getState) => { + const state = getState(); + const { + asset, + stage, + draftTransaction: { id, txParams }, + recipient: { address }, + amount: { value }, + } = state[name]; + if (stage === SEND_STAGES.EDIT) { + // When dealing with the edit flow there is already a transaction in + // state that we must update, this branch is responsible for that logic. + // We first must grab the previous transaction object from state and then + // merge in the modified txParams. Once the transaction has been modified + // we can send that to the background to update the transaction in state. + const unapprovedTxs = getUnapprovedTxs(state); + const unapprovedTx = unapprovedTxs[id]; + const editingTx = { + ...unapprovedTx, + txParams: Object.assign(unapprovedTx.txParams, txParams), + }; + dispatch(updateTransaction(editingTx)); + } else if (asset.type === ASSET_TYPES.TOKEN) { + // When sending a token transaction we have to the token.transfer method + // on the token contract to construct the transaction. This results in + // the proper transaction data and properties being set and a new + // transaction being added to background state. Once the new transaction + // is added to state a subsequent confirmation will be queued. + try { + const token = global.eth.contract(abi).at(asset.details.address); + token.transfer(address, value, { + ...txParams, + to: undefined, + data: undefined, + }); + dispatch(showConfTxPage()); + dispatch(hideLoadingIndication()); + } catch (error) { + dispatch(hideLoadingIndication()); + dispatch(displayWarning(error.message)); + } + } else { + // When sending a native asset we use the ethQuery.sendTransaction method + // which will result in the transaction being added to background state + // and a subsequent confirmation will be queued. + global.ethQuery.sendTransaction(txParams, (err) => { + if (err) { + dispatch(displayWarning(err.message)); + } + }); + dispatch(showConfTxPage()); + } + }; +} + +export function editTransaction( + assetType, + transactionId, + tokenData, + assetDetails, +) { + return async (dispatch, getState) => { + const state = getState(); + const unapprovedTransactions = getUnapprovedTxs(state); + const transaction = unapprovedTransactions[transactionId]; + const { txParams } = transaction; + if (assetType === ASSET_TYPES.NATIVE) { + const { + from, + gas: gasLimit, + gasPrice, + to: address, + value: amount, + } = txParams; + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount, + address, + nickname, + }), + ); + } else if (!tokenData || !assetDetails) { + throw new Error( + `send/editTransaction dispatched with assetType 'TOKEN' but missing assetData or assetDetails parameter`, + ); + } else { + const { from, to: tokenAddress, gas: gasLimit, gasPrice } = txParams; + const tokenAmountInDec = getTokenValueParam(tokenData); + const address = getTokenAddressParam(tokenData); + const nickname = getAddressBookEntry(state, address)?.name ?? ''; + + const tokenAmountInHex = addHexPrefix( + conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }), + ); + + await dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { ...assetDetails, address: tokenAddress }, + }), + ); + + await dispatch( + actions.editTransaction({ + id: transactionId, + gasLimit, + gasPrice, + from, + amount: tokenAmountInHex, + address, + nickname, + }), + ); + } + }; +} + +// Selectors + +// Gas selectors +export function getGasLimit(state) { + return state[name].gas.gasLimit; +} + +export function getGasPrice(state) { + return state[name].gas.gasPrice; +} + +export function getGasTotal(state) { + return state[name].gas.gasTotal; +} + +export function gasFeeIsInError(state) { + return Boolean(state[name].gas.error); +} + +export function getMinimumGasLimitForSend(state) { + return state[name].gas.minimumGasLimit; +} + +export function getGasInputMode(state) { + const isMainnet = getIsMainnet(state); + const showAdvancedGasFields = getAdvancedInlineGasShown(state); + if (state[name].gas.isCustomGasSet) { + return GAS_INPUT_MODES.CUSTOM; + } + if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { + return GAS_INPUT_MODES.INLINE; + } + return GAS_INPUT_MODES.BASIC; +} + +// Asset Selectors + +export function getSendAsset(state) { + return state[name].asset; +} + +export function getSendAssetAddress(state) { + return getSendAsset(state)?.details?.address; +} + +export function getIsAssetSendable(state) { + if (state[name].asset.type === ASSET_TYPES.NATIVE) { + return true; + } + return state[name].asset.details.isERC721 === false; +} + +// Amount Selectors +export function getSendAmount(state) { + return state[name].amount.value; +} + +export function getIsBalanceInsufficient(state) { + return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; +} +export function getSendMaxModeState(state) { + return state[name].amount.mode === AMOUNT_MODES.MAX; +} + +export function getSendHexData(state) { + return state[name].draftTransaction.userInputHexData; +} + +export function sendAmountIsInError(state) { + return Boolean(state[name].amount.error); +} + +// Recipient Selectors + +export function getSendTo(state) { + return state[name].recipient.address; +} + +export function getIsUsingMyAccountForRecipientSearch(state) { + return state[name].recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; +} + +export function getRecipientUserInput(state) { + return state[name].recipient.userInput; +} + +export function getRecipient(state) { + return state[name].recipient; +} + +// Overall validity and stage selectors + +export function getSendErrors(state) { + return { + gasFee: state.send.gas.error, + amount: state.send.amount.error, + }; +} + +export function isSendStateInitialized(state) { + return state[name].stage !== SEND_STAGES.UNINITIALIZED; +} + +export function isSendFormInvalid(state) { + return state[name].status === SEND_STATUSES.INVALID; +} + +export function getSendStage(state) { + return state[name].stage; +} diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js new file mode 100644 index 000000000..b4918d01d --- /dev/null +++ b/ui/ducks/send/send.test.js @@ -0,0 +1,1808 @@ +import sinon from 'sinon'; +import createMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { ethers } from 'ethers'; +import { + CONTRACT_ADDRESS_ERROR, + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + KNOWN_RECIPIENT_ADDRESS_WARNING, + NEGATIVE_ETH_ERROR, +} from '../../pages/send/send.constants'; +import { BASIC_ESTIMATE_STATES } from '../gas/gas.duck'; +import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; +import sendReducer, { + initialState, + initializeSendState, + updateSendAmount, + updateSendAsset, + updateRecipientUserInput, + useContactListForRecipientSearch, + useMyAccountsForRecipientSearch, + updateRecipient, + resetRecipientInput, + updateSendHexData, + toggleSendMaxMode, + signTransaction, + SEND_STATUSES, + ASSET_TYPES, + SEND_STAGES, + AMOUNT_MODES, + RECIPIENT_SEARCH_MODES, + editTransaction, +} from './send'; + +const mockStore = createMockStore([thunk]); + +jest.mock('../../store/actions', () => { + const actual = jest.requireActual('../../store/actions'); + return { + ...actual, + estimateGas: jest.fn(() => Promise.resolve('0x0')), + updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })), + }; +}); + +jest.mock('./send', () => { + const actual = jest.requireActual('./send'); + return { + __esModule: true, + ...actual, + getERC20Balance: jest.fn(() => '0x0'), + }; +}); + +describe('Send Slice', () => { + describe('Reducers', () => { + describe('updateSendAmount', () => { + it('should', async () => { + const action = { type: 'send/updateSendAmount', payload: '0x1' }; + const result = sendReducer(initialState, action); + expect(result.amount.value).toStrictEqual('0x1'); + }); + }); + + describe('updateAmountToMax', () => { + it('should calculate the max amount based off of the asset balance and gas total then updates send amount value', () => { + const maxAmountState = { + amount: { + value: '', + }, + asset: { + balance: '0x56bc75e2d63100000', // 100000000000000000000 + }, + gas: { + gasLimit: '0x5208', // 21000 + gasTotal: '0x1319718a5000', // 21000000000000 + minimumGasLimit: '0x5208', + }, + }; + + const state = { ...initialState, ...maxAmountState }; + const action = { type: 'send/updateAmountToMax' }; + const result = sendReducer(state, action); + + expect(result.amount.value).toStrictEqual('0x56bc74b13f185b000'); // 99999979000000000000 + }); + }); + + describe('updateUserInputHexData', () => { + it('should', () => { + const action = { + type: 'send/updateUserInputHexData', + payload: 'TestData', + }; + const result = sendReducer(initialState, action); + + expect(result.draftTransaction.userInputHexData).toStrictEqual( + action.payload, + ); + }); + }); + + describe('updateGasLimit', () => { + const action = { + type: 'send/updateGasLimit', + payload: '0x5208', // 21000 + }; + + it('should', () => { + const result = sendReducer( + { + ...initialState, + stage: SEND_STAGES.DRAFT, + gas: { ...initialState.gas, isGasEstimateLoading: false }, + }, + action, + ); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + ...initialState, + gas: { + gasLimit: '0x0', + gasPrice: '0x3b9aca00', // 1000000000 + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload); + expect(result.gas.gasPrice).toStrictEqual(gasState.gas.gasPrice); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateGasPrice', () => { + const action = { + type: 'send/updateGasPrice', + payload: '0x3b9aca00', // 1000000000 + }; + + it('should update gas price and update draft transaction with validated state', () => { + const validSendState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + account: { + balance: '0x56bc75e2d63100000', + }, + asset: { + balance: '0x56bc75e2d63100000', + type: ASSET_TYPES.NATIVE, + }, + gas: { + isGasEstimateLoading: false, + gasTotal: '0x1319718a5000', // 21000000000000 + gasLimit: '0x5208', // 21000 + minimumGasLimit: '0x5208', + }, + }; + + const result = sendReducer(validSendState, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload, + ); + }); + + it('should recalculate gasTotal', () => { + const gasState = { + gas: { + gasLimit: '0x5208', // 21000, + gasPrice: '0x0', + }, + }; + + const state = { ...initialState, ...gasState }; + const result = sendReducer(state, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload); + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + }); + }); + + describe('updateAmountMode', () => { + it('should change to INPUT amount mode', () => { + const emptyAmountModeState = { + amount: { + mode: '', + }, + }; + + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.INPUT, + }; + const result = sendReducer(emptyAmountModeState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should change to MAX amount mode', () => { + const action = { + type: 'send/updateAmountMode', + payload: AMOUNT_MODES.MAX, + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).toStrictEqual(action.payload); + }); + + it('should', () => { + const action = { + type: 'send/updateAmountMode', + payload: 'RANDOM', + }; + const result = sendReducer(initialState, action); + + expect(result.amount.mode).not.toStrictEqual(action.payload); + }); + }); + + describe('updateAsset', () => { + it('should update asset type and balance from respective action payload', () => { + const updateAssetState = { + ...initialState, + asset: { + type: 'old type', + balance: 'old balance', + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'new type', + balance: 'new balance', + }, + }; + + const result = sendReducer(updateAssetState, action); + + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.balance).toStrictEqual(action.payload.balance); + }); + + it('should nullify old contract address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + error: CONTRACT_ADDRESS_ERROR, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.error).not.toStrictEqual( + recipientErrorState.recipient.error, + ); + expect(result.recipient.error).toBeNull(); + }); + + it('should nullify old known address error when asset types is not TOKEN', () => { + const recipientErrorState = { + ...initialState, + recipient: { + warning: KNOWN_RECIPIENT_ADDRESS_WARNING, + }, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/updateAsset', + payload: { + type: 'New Type', + }, + }; + + const result = sendReducer(recipientErrorState, action); + + expect(result.recipient.warning).not.toStrictEqual( + recipientErrorState.recipient.warning, + ); + expect(result.recipient.warning).toBeNull(); + }); + + it('should update asset type and details to TOKEN payload', () => { + const action = { + type: 'send/updateAsset', + payload: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 0, + symbol: 'TKN', + }, + }, + }; + + const result = sendReducer(initialState, action); + expect(result.asset.type).toStrictEqual(action.payload.type); + expect(result.asset.details).toStrictEqual(action.payload.details); + }); + }); + + describe('updateRecipient', () => { + it('should', () => { + const action = { + type: 'send/updateRecipient', + payload: { + address: '0xNewAddress', + }, + }; + + const result = sendReducer(initialState, action); + + expect(result.stage).toStrictEqual(SEND_STAGES.DRAFT); + expect(result.recipient.address).toStrictEqual(action.payload.address); + }); + }); + + describe('updateDraftTransaction', () => { + it('should', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: '', + }, + recipient: { + address: '0xRecipientAddress', + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.recipient.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + detailsForDraftTransactionState.amount.value, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + }); + + it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + }, + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.asset.details.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + expect(result.draftTransaction.txParams.data).toStrictEqual( + '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + ); + }); + }); + + describe('useDefaultGas', () => { + it('should', () => { + const action = { + type: 'send/useDefaultGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(false); + }); + }); + + describe('useCustomGas', () => { + it('should', () => { + const action = { + type: 'send/useCustomGas', + }; + + const result = sendReducer(initialState, action); + + expect(result.gas.isCustomGasSet).toStrictEqual(true); + }); + }); + + describe('updateRecipientUserInput', () => { + it('should update recipient user input with payload', () => { + const action = { + type: 'send/updateRecipientUserInput', + payload: 'user input', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.userInput).toStrictEqual(action.payload); + }); + }); + + describe('validateRecipientUserInput', () => { + it('should set recipient error and warning to null when user input is', () => { + const noUserInputState = { + recipient: { + mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + userInput: '', + error: 'someError', + warning: 'someWarning', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + }; + + const result = sendReducer(noUserInputState, action); + + expect(result.recipient.error).toBeNull(); + expect(result.recipient.warning).toBeNull(); + }); + + it('should error with an invalid address error when user input is not a valid hex string', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + // TODO: Expectation might change in the future + it('should error with an invalid network error when user input is not a valid hex string on a non default network', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0xValidateError', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x55', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual( + 'invalidAddressRecipientNotEthNetwork', + ); + }); + + it('should error with invalid address recipient when the user inputs the burn address', () => { + const tokenAssetTypeState = { + ...initialState, + recipient: { + userInput: '0x0000000000000000000000000000000000000000', + }, + }; + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + }); + + it('should error with same address recipient as a token', () => { + const tokenAssetTypeState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + recipient: { + userInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + expect(result.recipient.error).toStrictEqual('contractAddressError'); + }); + }); + + describe('updateRecipientSearchMode', () => { + it('should', () => { + const action = { + type: 'send/updateRecipientSearchMode', + payload: 'a-random-string', + }; + + const result = sendReducer(initialState, action); + + expect(result.recipient.mode).toStrictEqual(action.payload); + }); + }); + + describe('resetSendState', () => { + it('should', () => { + const action = { + type: 'send/resetSendState', + }; + + const result = sendReducer({}, action); + + expect(result).toStrictEqual(initialState); + }); + }); + + describe('validateAmountField', () => { + it('should error with insufficient funds when amount asset value plust gas is higher than asset balance', () => { + const nativeAssetState = { + ...initialState, + amount: { + value: '0x6fc23ac0', // 1875000000 + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0x77359400', // 2000000000 + }, + gas: { + gasTotal: '0x8f0d180', // 150000000 + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(nativeAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + + it('should error with insufficient tokens when amount value of tokens is higher than asset balance of token', () => { + const tokenAssetState = { + ...initialState, + amount: { + value: '0x77359400', // 2000000000 + }, + asset: { + type: ASSET_TYPES.TOKEN, + balance: '0x6fc23ac0', // 1875000000 + details: { + decimals: 0, + }, + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(tokenAssetState, action); + + expect(result.amount.error).toStrictEqual(INSUFFICIENT_TOKENS_ERROR); + }); + + it('should error negative value amount', () => { + const negativeAmountState = { + ...initialState, + amount: { + value: '-1', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(negativeAmountState, action); + + expect(result.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); + }); + + it('should not error for positive value amount', () => { + const otherState = { + ...initialState, + amount: { + error: 'someError', + value: '1', + }, + asset: { + type: '', + }, + }; + + const action = { + type: 'send/validateAmountField', + }; + + const result = sendReducer(otherState, action); + expect(result.amount.error).toBeNull(); + }); + }); + + describe('validateGasField', () => { + it('should error when total amount of gas is higher than account balance', () => { + const gasFieldState = { + ...initialState, + account: { + balance: '0x0', + }, + gas: { + gasTotal: '0x1319718a5000', // 21000000000000 + }, + }; + + const action = { + type: 'send/validateGasField', + }; + + const result = sendReducer(gasFieldState, action); + expect(result.gas.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + }); + }); + + describe('validateSendState', () => { + it('should set `INVALID` send state status when amount error is present', () => { + const amountErrorState = { + ...initialState, + amount: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(amountErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gas error is present', () => { + const gasErrorState = { + ...initialState, + gas: { + error: 'Some Amount Error', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when asset type is `TOKEN` without token details present', () => { + const assetErrorState = { + ...initialState, + asset: { + type: ASSET_TYPES.TOKEN, + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(assetErrorState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `INVALID` send state status when gasLimit is under the minimumGasLimit', () => { + const gasLimitErroState = { + ...initialState, + gas: { + gasLimit: '0x5207', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(gasLimitErroState, action); + expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + }); + + it('should set `VALID` send state status when conditionals have not been met', () => { + const validSendStatusState = { + ...initialState, + stage: SEND_STAGES.DRAFT, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x000', + }, + }, + gas: { + isGasEstimateLoading: false, + gasLimit: '0x5208', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'send/validateSendState', + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).toStrictEqual(SEND_STATUSES.VALID); + }); + }); + }); + + describe('extraReducers/externalReducers', () => { + describe('QR Code Detected', () => { + const qrCodestate = { + ...initialState, + recipient: { + address: '0xAddress', + }, + }; + + it('should set the recipient address to the scanned address value if they are not equal', () => { + const action = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, + }, + }; + + const result = sendReducer(qrCodestate, action); + expect(result.recipient.address).toStrictEqual( + action.value.values.address, + ); + }); + + it('should not set the recipient address to invalid scanned address and errors', () => { + const badQRAddressAction = { + type: 'UI_QR_CODE_DETECTED', + value: { + type: 'address', + values: { + address: '0xBadAddress', + }, + }, + }; + + const result = sendReducer(qrCodestate, badQRAddressAction); + + expect(result.recipient.address).toStrictEqual( + qrCodestate.recipient.address, + ); + expect(result.recipient.error).toStrictEqual( + INVALID_RECIPIENT_ADDRESS_ERROR, + ); + }); + }); + + describe('Selected Address Changed', () => { + it('should update selected account address and balance on non-edit stages', () => { + const olderState = { + ...initialState, + account: { + balance: '0x0', + address: '0xAddress', + }, + }; + + const action = { + type: 'SELECTED_ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(olderState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + expect(result.account.address).toStrictEqual( + action.payload.account.address, + ); + }); + }); + + describe('Account Changed', () => { + it('should', () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + + expect(result.account.balance).toStrictEqual( + action.payload.account.balance, + ); + }); + + it(`should not edit account balance if action payload address is not the same as state's address`, () => { + const accountsChangedState = { + ...initialState, + stage: SEND_STAGES.EDIT, + account: { + address: '0xAddress', + balance: '0x0', + }, + }; + + const action = { + type: 'ACCOUNT_CHANGED', + payload: { + account: { + address: '0xDifferentAddress', + balance: '0x1', + }, + }, + }; + + const result = sendReducer(accountsChangedState, action); + expect(result.account.address).not.toStrictEqual( + action.payload.account.address, + ); + expect(result.account.balance).not.toStrictEqual( + action.payload.account.balance, + ); + }); + }); + + describe('Initialize Pending Send State', () => { + let dispatchSpy; + let getState; + + beforeEach(() => { + dispatchSpy = jest.fn(); + }); + + it('should dispatch async action thunk first with pending, then finally fulfilling from minimal state', async () => { + getState = jest.fn().mockReturnValue({ + metamask: { + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + 0x4: { + '0xAddress': '0x0', + }, + }, + selectedAddress: '0xAddress', + provider: { + chainId: '0x4', + }, + }, + send: initialState, + gas: { + basicEstimateStatus: 'LOADING', + basicEstimatesStatus: { + safeLow: null, + average: null, + fast: null, + }, + }, + }); + + const action = initializeSendState(); + await action(dispatchSpy, getState, undefined); + + expect(dispatchSpy).toHaveBeenCalledTimes(4); + + expect(dispatchSpy.mock.calls[0][0].type).toStrictEqual( + 'send/initializeSendState/pending', + ); + expect(dispatchSpy.mock.calls[3][0].type).toStrictEqual( + 'send/initializeSendState/fulfilled', + ); + }); + }); + + describe('Set Basic Gas Estimate Data', () => { + it('should recalculate gas based off of average basic estimate data', () => { + const gasState = { + ...initialState, + gas: { + gasPrice: '0x0', + gasLimit: '0x5208', + gasTotal: '0x0', + minimumGasLimit: '0x5208', + }, + }; + + const action = { + type: 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA', + value: { + average: '1', + }, + }; + + const result = sendReducer(gasState, action); + + expect(result.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 + expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); + expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); + }); + }); + + describe('BASIC_GAS_ESTIMATE_STATUS', () => { + it('should invalidate the send status when status is LOADING', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.LOADING, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + + it('should invalidate the send status when status is FAILED and use INLINE gas input mode', () => { + const validSendStatusState = { + ...initialState, + status: SEND_STATUSES.VALID, + }; + + const action = { + type: 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS', + value: BASIC_ESTIMATE_STATES.FAILED, + }; + + const result = sendReducer(validSendStatusState, action); + + expect(result.status).not.toStrictEqual(validSendStatusState.status); + }); + }); + }); + + describe('Action Creators', () => { + describe('UpdateSendAmount', () => { + const defaultSendAmountState = { + send: { + amount: { + mode: undefined, + }, + asset: { + type: '', + }, + }, + }; + + it('should create an action to update send amount', async () => { + const store = mockStore(defaultSendAmountState); + + const newSendAmount = 'aNewSendAmount'; + + await store.dispatch(updateSendAmount(newSendAmount)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: 'aNewSendAmount' }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action to update send amount mode to `INPUT` when mode is `MAX`', async () => { + const maxModeSendState = { + send: { + ...defaultSendAmountState.send, + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + + const store = mockStore(maxModeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { type: 'send/updateSendAmount', payload: undefined }, + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create an action computeEstimateGasLimit and change states from pending to fulfilled with token asset types', async () => { + const tokenAssetTypeSendState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + ...defaultSendAmountState.send, + send: { + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenAssetTypeSendState); + + await store.dispatch(updateSendAmount()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateSendAmount'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('UpdateSendAsset', () => { + const defaultSendAssetState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: '', + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + it('should create actions for updateSendAsset', async () => { + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: '', + details: { + address: '', + symbol: '', + decimals: '', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + + expect(actionResult[0].type).toStrictEqual('send/updateAsset'); + expect(actionResult[0].payload).toStrictEqual({ + ...newSendAsset, + balance: '', + }); + + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + + it('should create actions for updateSendAsset with tokens', async () => { + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + }; + const store = mockStore(defaultSendAssetState); + + const newSendAsset = { + type: ASSET_TYPES.TOKEN, + details: { + address: 'tokenAddress', + symbol: 'tokenSymbol', + decimals: 'tokenDecimals', + }, + }; + + await store.dispatch(updateSendAsset(newSendAsset)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(6); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].payload).toStrictEqual({ + ...newSendAsset, + balance: '0x0', + }); + + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('updateRecipientUserInput', () => { + const updateRecipientUserInputState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + }; + + it('should create actions for updateRecipientUserInput and checks debounce for validation', async () => { + const clock = sinon.useFakeTimers(); + + const store = mockStore(updateRecipientUserInputState); + const newUserRecipientInput = 'newUserRecipientInput'; + + await store.dispatch(updateRecipientUserInput(newUserRecipientInput)); + + expect(store.getActions()).toHaveLength(1); + expect(store.getActions()[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(store.getActions()[0].payload).toStrictEqual( + newUserRecipientInput, + ); + + clock.tick(300); // debounce + + expect(store.getActions()).toHaveLength(2); + expect(store.getActions()[1].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + expect(store.getActions()[1].payload).toStrictEqual({ + chainId: '', + tokens: [], + }); + }); + }); + + describe('useContactListForRecipientSearch', () => { + it('should create action to change send recipient search to contact list', async () => { + const store = mockStore(); + + await store.dispatch(useContactListForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + }, + ]); + }); + }); + + describe('UseMyAccountsForRecipientSearch', () => { + it('should create action to change send recipient search to derived accounts', async () => { + const store = mockStore(); + + await store.dispatch(useMyAccountsForRecipientSearch()); + + const actionResult = store.getActions(); + + expect(actionResult).toStrictEqual([ + { + type: 'send/updateRecipientSearchMode', + payload: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + }, + ]); + }); + }); + + describe('UpdateRecipient', () => { + const recipient = { + address: '', + nickname: '', + }; + + it('should create an action to update recipient', async () => { + const updateRecipientState = { + send: { + asset: { + type: '', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { + type: 'send/updateRecipient', + payload: recipient, + }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectedActionResult); + }); + + it('should create actions to update recipient and recalculate gas limit if the asset is a token', async () => { + const tokenState = { + metamask: { + blockGasLimit: '', + selectedAddress: '', + }, + send: { + account: { + balance: '', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + }, + }; + + const store = mockStore(tokenState); + + await store.dispatch(updateRecipient(recipient)); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[1].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[2].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + }); + }); + + describe('ResetRecipientInput', () => { + it('should create actions to reset recipient input and ens then validates input', async () => { + const updateRecipientState = { + metamask: { + provider: { + chainId: '', + }, + tokens: [], + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(updateRecipientState); + + await store.dispatch(resetRecipientInput()); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(4); + expect(actionResult[0].type).toStrictEqual( + 'send/updateRecipientUserInput', + ); + expect(actionResult[0].payload).toStrictEqual(''); + expect(actionResult[1].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[2].type).toStrictEqual('ENS/resetResolution'); + expect(actionResult[3].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + }); + }); + + describe('UpdateSendHexData', () => { + const sendHexDataState = { + send: { + asset: { + type: '', + }, + }, + }; + + it('should create action to update hexData', async () => { + const hexData = '0x1'; + const store = mockStore(sendHexDataState); + + await store.dispatch(updateSendHexData(hexData)); + + const actionResult = store.getActions(); + + const expectActionResult = [ + { type: 'send/updateUserInputHexData', payload: hexData }, + ]; + + expect(actionResult).toHaveLength(1); + expect(actionResult).toStrictEqual(expectActionResult); + }); + }); + + describe('ToggleSendMaxMode', () => { + it('should create actions to toggle update max mode when send amount mode is not max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: '', + }, + }, + }; + + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX }, + { type: 'send/updateAmountToMax', payload: undefined }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + + it('should create actions to toggle off max mode when send amount mode is max', async () => { + const sendMaxModeState = { + send: { + amount: { + mode: AMOUNT_MODES.MAX, + }, + }, + }; + const store = mockStore(sendMaxModeState); + + await store.dispatch(toggleSendMaxMode()); + + const actionResult = store.getActions(); + + const expectedActionReslt = [ + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.INPUT }, + { type: 'send/updateSendAmount', payload: '0x0' }, + ]; + + expect(actionResult).toHaveLength(2); + expect(actionResult).toStrictEqual(expectedActionReslt); + }); + }); + + describe('SignTransaction', () => { + const signTransactionState = { + send: { + asset: {}, + stage: '', + draftTransaction: {}, + recipient: {}, + amount: {}, + }, + }; + + it('should show confirm tx page when no other conditions for signing have been met', async () => { + global.ethQuery = { + sendTransaction: sinon.stub(), + }; + + const store = mockStore(signTransactionState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('SHOW_CONF_TX_PAGE'); + }); + + it('should create actions for updateTransaction rejecting', async () => { + const editStageSignTxState = { + metamask: { + unapprovedTxs: { + 1: { + id: 1, + txParams: { + value: 'oldTxValue', + }, + }, + }, + }, + send: { + ...signTransactionState.send, + stage: SEND_STAGES.EDIT, + draftTransaction: { + id: 1, + txParams: { + value: 'newTxValue', + }, + }, + }, + }; + + jest.mock('../../store/actions.js'); + + const store = mockStore(editStageSignTxState); + + await store.dispatch(signTransaction()); + + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(5); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('UPDATE_TRANSACTION_PARAMS'); + expect(actionResult[2].type).toStrictEqual('HIDE_LOADING_INDICATION'); + }); + }); + + describe('editTransaction', () => { + it('should set up the appropriate state for editing a native asset transaction', async () => { + const editTransactionState = { + metamask: { + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xRecipientAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0xde0b6b3a7640000', // 1000000000000000000 + }, + }, + }, + }, + send: { + asset: { + type: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + const store = mockStore(editTransactionState); + + await store.dispatch(editTransaction(ASSET_TYPES.NATIVE, 1)); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(1); + expect(actionResult[0].type).toStrictEqual('send/editTransaction'); + expect(actionResult[0].payload).toStrictEqual({ + address: '0xRecipientAddress', + amount: '0xde0b6b3a7640000', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[0]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + + it('should set up the appropriate state for editing a token asset transaction', async () => { + const editTransactionState = { + metamask: { + blockGasLimit: '0x3a98', + selectedAddress: '', + provider: { + chainId: RINKEBY_CHAIN_ID, + }, + tokens: [], + addressBook: { + [RINKEBY_CHAIN_ID]: {}, + }, + identities: {}, + unapprovedTxs: { + 1: { + id: 1, + txParams: { + from: '0xAddress', + to: '0xTokenAddress', + gas: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', // 1000000000 + value: '0x0', + }, + }, + }, + }, + send: { + account: { + address: '0xAddress', + balance: '0x0', + }, + asset: { + type: '', + }, + gas: { + gasPrice: '', + }, + amount: { + value: '', + }, + draftTransaction: { + userInputHexData: '', + }, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }, + }; + + global.eth = { + contract: sinon.stub().returns({ + at: sinon.stub().returns({ + balanceOf: sinon.stub().returns(undefined), + }), + }), + getCode: jest.fn(() => '0xa'), + }; + + const store = mockStore(editTransactionState); + + await store.dispatch( + editTransaction( + ASSET_TYPES.TOKEN, + 1, + { + name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, + args: { + _to: '0xRecipientAddress', + _value: ethers.BigNumber.from(15000), + }, + }, + { address: '0xAddress', symbol: 'SYMB', decimals: 18 }, + ), + ); + const actionResult = store.getActions(); + + expect(actionResult).toHaveLength(7); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2].type).toStrictEqual('send/updateAsset'); + expect(actionResult[2].payload).toStrictEqual({ + balance: '0x0', + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + decimals: 18, + symbol: 'SYMB', + isERC721: false, + }, + }); + expect(actionResult[3].type).toStrictEqual( + 'send/computeEstimatedGasLimit/pending', + ); + expect(actionResult[4].type).toStrictEqual( + 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/computeEstimatedGasLimit/fulfilled', + ); + expect(actionResult[6].type).toStrictEqual('send/editTransaction'); + expect(actionResult[6].payload).toStrictEqual({ + address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase + amount: '0x3a98', + from: '0xAddress', + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + id: 1, + nickname: '', + }); + + const action = actionResult[6]; + + const result = sendReducer(initialState, action); + + expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + + expect(result.amount.value).toStrictEqual(action.payload.amount); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + action.payload.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + action.payload.amount, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + action.payload.gasLimit, + ); + }); + }); + }); +}); diff --git a/ui/helpers/constants/design-system.js b/ui/helpers/constants/design-system.js index 070ea2748..38e85f779 100644 --- a/ui/helpers/constants/design-system.js +++ b/ui/helpers/constants/design-system.js @@ -139,6 +139,7 @@ export const TEXT_ALIGN = { CENTER: 'center', RIGHT: 'right', JUSTIFY: 'justify', + END: 'end', }; export const FONT_WEIGHT = { diff --git a/ui/helpers/constants/error-keys.js b/ui/helpers/constants/error-keys.js index bfdb7474f..e51b87736 100644 --- a/ui/helpers/constants/error-keys.js +++ b/ui/helpers/constants/error-keys.js @@ -5,3 +5,4 @@ export const TRANSACTION_NO_CONTRACT_ERROR_KEY = 'transactionErrorNoContract'; export const ETH_GAS_PRICE_FETCH_WARNING_KEY = 'ethGasPriceFetchWarning'; export const GAS_PRICE_FETCH_FAILURE_ERROR_KEY = 'gasPriceFetchFailed'; export const GAS_PRICE_EXCESSIVE_ERROR_KEY = 'gasPriceExcessive'; +export const UNSENDABLE_ASSET_ERROR_KEY = 'unsendableAsset'; diff --git a/ui/helpers/utils/conversion-util.js b/ui/helpers/utils/conversion-util.js index 3d61f4410..0b550e67b 100644 --- a/ui/helpers/utils/conversion-util.js +++ b/ui/helpers/utils/conversion-util.js @@ -150,8 +150,11 @@ const conversionUtil = ( conversionRate, invertConversionRate, }, -) => - converter({ +) => { + if (fromCurrency !== toCurrency && !conversionRate) { + return 0; + } + return converter({ fromCurrency, toCurrency, fromNumericBase, @@ -163,6 +166,7 @@ const conversionUtil = ( invertConversionRate, value: value || '0', }); +}; const getBigNumber = (value, base) => { if (!isValidBase(base)) { diff --git a/ui/hooks/useCurrencyDisplay.js b/ui/hooks/useCurrencyDisplay.js index 6b770e38e..a1b67213e 100644 --- a/ui/hooks/useCurrencyDisplay.js +++ b/ui/hooks/useCurrencyDisplay.js @@ -10,6 +10,8 @@ import { getNativeCurrency, } from '../ducks/metamask/metamask'; +import { conversionUtil } from '../helpers/utils/conversion-util'; + /** * Defines the shape of the options parameter for useCurrencyDisplay * @typedef {Object} UseCurrencyOptions @@ -45,24 +47,37 @@ export function useCurrencyDisplay( const currentCurrency = useSelector(getCurrentCurrency); const nativeCurrency = useSelector(getNativeCurrency); const conversionRate = useSelector(getConversionRate); - - const toCurrency = currency || currentCurrency; + const isUserPreferredCurrency = currency === currentCurrency; const value = useMemo(() => { if (displayValue) { return displayValue; } - return formatCurrency( - getValueFromWeiHex({ - value: inputValue, - fromCurrency: nativeCurrency, - toCurrency, - conversionRate, + if ( + currency === nativeCurrency || + (!isUserPreferredCurrency && !nativeCurrency) + ) { + return conversionUtil(inputValue, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', numberOfDecimals: numberOfDecimals || 2, toDenomination: denomination, - }), - toCurrency, - ); + }); + } else if (isUserPreferredCurrency && conversionRate) { + return formatCurrency( + getValueFromWeiHex({ + value: inputValue, + fromCurrency: nativeCurrency, + toCurrency: currency, + conversionRate, + numberOfDecimals: numberOfDecimals || 2, + toDenomination: denomination, + }), + currency, + ); + } + return null; }, [ inputValue, nativeCurrency, @@ -70,13 +85,14 @@ export function useCurrencyDisplay( displayValue, numberOfDecimals, denomination, - toCurrency, + currency, + isUserPreferredCurrency, ]); let suffix; if (!opts.hideLabel) { - suffix = opts.suffix || toCurrency.toUpperCase(); + suffix = opts.suffix || currency?.toUpperCase(); } return [ diff --git a/ui/hooks/useTokenTracker.js b/ui/hooks/useTokenTracker.js index 601254932..abcf03233 100644 --- a/ui/hooks/useTokenTracker.js +++ b/ui/hooks/useTokenTracker.js @@ -23,11 +23,19 @@ export function useTokenTracker( const matchingTokens = hideZeroBalanceTokens ? tokenWithBalances.filter((token) => Number(token.balance) > 0) : tokenWithBalances; - setTokensWithBalances(matchingTokens); + // TODO: improve this pattern for adding this field when we improve support for + // EIP721 tokens. + const matchingTokensWithIsERC721Flag = matchingTokens.map((token) => { + const additionalTokenData = memoizedTokens.find( + (t) => t.address === token.address, + ); + return { ...token, isERC721: additionalTokenData?.isERC721 }; + }); + setTokensWithBalances(matchingTokensWithIsERC721Flag); setLoading(false); setError(null); }, - [hideZeroBalanceTokens], + [hideZeroBalanceTokens, memoizedTokens], ); const showError = useCallback((err) => { diff --git a/ui/hooks/useUserPreferencedCurrency.js b/ui/hooks/useUserPreferencedCurrency.js index 50b742abf..c41195345 100644 --- a/ui/hooks/useUserPreferencedCurrency.js +++ b/ui/hooks/useUserPreferencedCurrency.js @@ -1,5 +1,9 @@ import { useSelector } from 'react-redux'; -import { getPreferences, getShouldShowFiat } from '../selectors'; +import { + getPreferences, + getShouldShowFiat, + getCurrentCurrency, +} from '../selectors'; import { getNativeCurrency } from '../ducks/metamask/metamask'; import { PRIMARY, SECONDARY, ETH } from '../helpers/constants/common'; @@ -35,6 +39,7 @@ export function useUserPreferencedCurrency(type, opts = {}) { const nativeCurrency = useSelector(getNativeCurrency); const { useNativeCurrencyAsPrimaryCurrency } = useSelector(getPreferences); const showFiat = useSelector(getShouldShowFiat); + const currentCurrency = useSelector(getCurrentCurrency); let currency, numberOfDecimals; if ( @@ -50,6 +55,7 @@ export function useUserPreferencedCurrency(type, opts = {}) { (type === PRIMARY && !useNativeCurrencyAsPrimaryCurrency) ) { // Display Fiat + currency = currentCurrency; numberOfDecimals = opts.numberOfDecimals || opts.fiatNumberOfDecimals || 2; } diff --git a/ui/hooks/useUserPreferencedCurrency.test.js b/ui/hooks/useUserPreferencedCurrency.test.js index 5689dc11e..62030b7e3 100644 --- a/ui/hooks/useUserPreferencedCurrency.test.js +++ b/ui/hooks/useUserPreferencedCurrency.test.js @@ -1,7 +1,11 @@ import { renderHook } from '@testing-library/react-hooks'; import * as reactRedux from 'react-redux'; import sinon from 'sinon'; -import { getPreferences, getShouldShowFiat } from '../selectors'; +import { + getCurrentCurrency, + getPreferences, + getShouldShowFiat, +} from '../selectors'; import { useUserPreferencedCurrency } from './useUserPreferencedCurrency'; const tests = [ @@ -24,12 +28,13 @@ const tests = [ useNativeCurrencyAsPrimaryCurrency: false, nativeCurrency: 'ETH', showFiat: true, + currentCurrency: 'usd', }, params: { type: 'PRIMARY', }, result: { - currency: undefined, + currency: 'usd', numberOfDecimals: 2, }, }, @@ -116,6 +121,8 @@ function getFakeUseSelector(state) { return state; } else if (selector === getShouldShowFiat) { return state.showFiat; + } else if (selector === getCurrentCurrency) { + return state.currentCurrency; } return state.nativeCurrency; }; diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index 05bb4c77b..e8230a9f5 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -303,7 +303,7 @@ export default class ConfirmApproveContent extends Component { >
- View full transaction details + {t('viewFullTransactionDetails')}
- View full transaction details + {t('viewFullTransactionDetails')}
{ + const options = []; + const receiverOptions = { + 'Address 1': '0xaD6D458402F60fD3Bd25163575031ACDce07538D', + 'Address 2': '0x55e0bfb2d400e9be8cf9b114e38a40969a02f69a', + }; + const state = store.getState(); + const { identities } = state.metamask; + Object.keys(identities).forEach(function (key) { + options.push({ + label: identities[key].name, + address: key, + }); + }); + const sender = select('Sender', options, options[0]); + const receiver = select( + 'Receiver', + receiverOptions, + '0xaD6D458402F60fD3Bd25163575031ACDce07538D', + ); + + const confirmTransactionState = state.confirmTransaction.txData.txParams; + + useEffect(() => { + confirmTransactionState.from = sender.address; + store.dispatch(updateTransactionParams(id, confirmTransactionState)); + }, [sender, confirmTransactionState]); + + useEffect(() => { + confirmTransactionState.to = receiver; + store.dispatch(updateTransactionParams(id, confirmTransactionState)); + }, [receiver, confirmTransactionState]); + return children; +}; + +export const DeployContract = () => { + return ( + + + + ); +}; diff --git a/ui/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/pages/confirm-send-ether/confirm-send-ether.container.js index 475ee5213..5f7527226 100644 --- a/ui/pages/confirm-send-ether/confirm-send-ether.container.js +++ b/ui/pages/confirm-send-ether/confirm-send-ether.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; -import { updateSend } from '../../ducks/send/send.duck'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -18,22 +18,8 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { editTransaction: (txData) => { - const { id, txParams } = txData; - const { from, gas: gasLimit, gasPrice, to, value: amount } = txParams; - - dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - }), - ); - + const { id } = txData; + dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString())); dispatch(clearConfirmTransaction()); }, }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.container.js b/ui/pages/confirm-send-token/confirm-send-token.container.js index fd869a9a7..d823e25aa 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -3,13 +3,8 @@ import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { showSendTokenPage } from '../../store/actions'; -import { conversionUtil } from '../../helpers/utils/conversion-util'; -import { - getTokenValueParam, - getTokenAddressParam, -} from '../../helpers/utils/token-util'; +import { ASSET_TYPES, editTransaction } from '../../ducks/send'; import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors'; -import { updateSend } from '../../ducks/send/send.duck'; import ConfirmSendToken from './confirm-send-token.component'; const mapStateToProps = (state) => { @@ -22,35 +17,15 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - editTransaction: ({ txData, tokenData, tokenProps }) => { - const { - id, - txParams: { from, to: tokenAddress, gas: gasLimit, gasPrice } = {}, - } = txData; - - const to = getTokenValueParam(tokenData); - const tokenAmountInDec = getTokenAddressParam(tokenData); - - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }); - + editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => { + const { id } = txData; dispatch( - updateSend({ - from, - gasLimit, - gasPrice, - gasTotal: null, - to, - amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id?.toString(), - token: { - ...tokenProps, - address: tokenAddress, - }, - }), + editTransaction( + ASSET_TYPES.TOKEN, + id.toString(), + tokenData, + assetDetails, + ), ); dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); diff --git a/ui/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js b/ui/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js index b15a5f99e..557b6c237 100644 --- a/ui/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js +++ b/ui/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js @@ -23,6 +23,7 @@ export default function ConfirmTokenTransactionBase({ contractExchangeRate, conversionRate, currentCurrency, + onEdit, }) { const t = useContext(I18nContext); @@ -69,6 +70,7 @@ export default function ConfirmTokenTransactionBase({ return (
this.handleEditGas() } secondaryText={ - hideFiatConversion - ? this.context.t('noConversionRateAvailable') - : '' + hideFiatConversion ? t('noConversionRateAvailable') : '' } /> {advancedInlineGasShown || @@ -336,15 +333,15 @@ export default class ConfirmTransactionBase extends Component { } > @@ -353,7 +350,7 @@ export default class ConfirmTransactionBase extends Component {
- {this.context.t('nonceFieldHeading')} + {t('nonceFieldHeading')}
{ clearConfirmTransaction(); @@ -512,18 +490,12 @@ export default class ConfirmTransactionBase extends Component { } handleSubmit() { - const { metricsEvent } = this.context; const { - txData: { origin }, sendTransaction, clearConfirmTransaction, txData, history, - actionKey, mostRecentOverviewPage, - metaMetricsSendCount = 0, - setMetaMetricsSendCount, - methodData = {}, updateCustomNonce, } = this.props; const { submitting } = this.state; @@ -539,44 +511,27 @@ export default class ConfirmTransactionBase extends Component { }, () => { this._removeBeforeUnload(); - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Confirm Screen', - name: 'Transaction Completed', - }, - customVariables: { - recipientKnown: null, - functionType: - actionKey || - getMethodName(methodData.name) || - TRANSACTION_TYPES.CONTRACT_INTERACTION, - origin, - }, - }); - setMetaMetricsSendCount(metaMetricsSendCount + 1).then(() => { - sendTransaction(txData) - .then(() => { - clearConfirmTransaction(); - this.setState( - { - submitting: false, - }, - () => { - history.push(mostRecentOverviewPage); - updateCustomNonce(''); - }, - ); - }) - .catch((error) => { - this.setState({ + sendTransaction(txData) + .then(() => { + clearConfirmTransaction(); + this.setState( + { submitting: false, - submitError: error.message, - }); - updateCustomNonce(''); + }, + () => { + history.push(mostRecentOverviewPage); + updateCustomNonce(''); + }, + ); + }) + .catch((error) => { + this.setState({ + submitting: false, + submitError: error.message, }); - }); + updateCustomNonce(''); + }); }, ); } @@ -643,18 +598,7 @@ export default class ConfirmTransactionBase extends Component { } _beforeUnload = () => { - const { txData: { origin, id } = {}, cancelTransaction } = this.props; - const { metricsEvent } = this.context; - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Confirm Screen', - name: 'Cancel Tx Via Notification Close', - }, - customVariables: { - origin, - }, - }); + const { txData: { id } = {}, cancelTransaction } = this.props; cancelTransaction({ id }); }; diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index 6af7e49a9..09361ad93 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -10,7 +10,6 @@ import { cancelTxs, updateAndApproveTx, showModal, - setMetaMetricsSendCount, updateTransaction, getNextNonce, tryReverseResolveAddress, @@ -32,10 +31,10 @@ import { getKnownMethodData, getMetaMaskAccounts, getUseNonceField, - getPreferences, transactionFeeSelector, getNoGasPriceFetched, getIsEthGasPriceFetched, + getShouldShowFiat, } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { transactionMatchesNetwork } from '../../../shared/modules/transaction.utils'; @@ -65,7 +64,6 @@ const mapStateToProps = (state, ownProps) => { match: { params = {} }, } = ownProps; const { id: paramsTransactionId } = params; - const { showFiatInTestnets } = getPreferences(state); const isMainnet = getIsMainnet(state); const { confirmTransaction, metamask } = state; const { @@ -76,7 +74,6 @@ const mapStateToProps = (state, ownProps) => { assetImages, network, unapprovedTxs, - metaMetricsSendCount, nextNonce, provider: { chainId }, } = metamask; @@ -184,9 +181,8 @@ const mapStateToProps = (state, ownProps) => { useNonceField: getUseNonceField(state), customNonceValue, insufficientBalance, - hideSubtitle: !isMainnet && !showFiatInTestnets, - hideFiatConversion: !isMainnet && !showFiatInTestnets, - metaMetricsSendCount, + hideSubtitle: !getShouldShowFiat(state), + hideFiatConversion: !getShouldShowFiat(state), type, nextNonce, mostRecentOverviewPage: getMostRecentOverviewPage(state), @@ -234,7 +230,6 @@ export const mapDispatchToProps = (dispatch) => { cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), sendTransaction: (txData) => dispatch(updateAndApproveTx(customNonceMerge(txData))), - setMetaMetricsSendCount: (val) => dispatch(setMetaMetricsSendCount(val)), getNextNonce: () => dispatch(getNextNonce()), setDefaultHomeActiveTabName: (tabName) => dispatch(setDefaultHomeActiveTabName(tabName)), diff --git a/ui/pages/confirm-transaction/conf-tx.js b/ui/pages/confirm-transaction/conf-tx.js index 4f2028197..8990e21c5 100644 --- a/ui/pages/confirm-transaction/conf-tx.js +++ b/ui/pages/confirm-transaction/conf-tx.js @@ -12,6 +12,7 @@ import Loading from '../../components/ui/loading-screen'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; +import { getSendTo } from '../../ducks/send'; function mapStateToProps(state) { const { metamask, appState } = state; @@ -38,7 +39,7 @@ function mapStateToProps(state) { unapprovedMsgCount, unapprovedPersonalMsgCount, unapprovedTypedMessagesCount, - send: state.send, + sendTo: getSendTo(state), currentNetworkTxList: state.metamask.currentNetworkTxList, }; } @@ -68,9 +69,7 @@ class ConfirmTxScreen extends Component { history: PropTypes.object, identities: PropTypes.object, dispatch: PropTypes.func.isRequired, - send: PropTypes.shape({ - to: PropTypes.string, - }).isRequired, + sendTo: PropTypes.string, }; getUnapprovedMessagesTotal() { @@ -182,13 +181,13 @@ class ConfirmTxScreen extends Component { mostRecentOverviewPage, network, chainId, - send, + sendTo, } = this.props; const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId); if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { history.push(mostRecentOverviewPage); @@ -201,7 +200,7 @@ class ConfirmTxScreen extends Component { network, chainId, currentNetworkTxList, - send, + sendTo, history, match: { params: { id: transactionId } = {} }, mostRecentOverviewPage, @@ -241,7 +240,7 @@ class ConfirmTxScreen extends Component { if ( unconfTxList.length === 0 && - !send.to && + !sendTo && this.getUnapprovedMessagesTotal() === 0 ) { this.props.history.push(mostRecentOverviewPage); diff --git a/ui/pages/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirm-transaction/confirm-transaction.component.js index b88424cc7..5bc374edc 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirm-transaction/confirm-transaction.component.js @@ -35,7 +35,7 @@ export default class ConfirmTransaction extends Component { static propTypes = { history: PropTypes.object.isRequired, totalUnapprovedCount: PropTypes.number.isRequired, - send: PropTypes.object, + sendTo: PropTypes.string, setTransactionToConfirm: PropTypes.func, clearConfirmTransaction: PropTypes.func, fetchBasicGasEstimates: PropTypes.func, @@ -52,7 +52,7 @@ export default class ConfirmTransaction extends Component { componentDidMount() { const { totalUnapprovedCount = 0, - send = {}, + sendTo, history, mostRecentOverviewPage, transaction: { txParams: { data, to } = {} } = {}, @@ -64,7 +64,7 @@ export default class ConfirmTransaction extends Component { isTokenMethodAction, } = this.props; - if (!totalUnapprovedCount && !send.to) { + if (!totalUnapprovedCount && !sendTo) { history.replace(mostRecentOverviewPage); return; } diff --git a/ui/pages/confirm-transaction/confirm-transaction.container.js b/ui/pages/confirm-transaction/confirm-transaction.container.js index b04ee76fa..bf0020c64 100644 --- a/ui/pages/confirm-transaction/confirm-transaction.container.js +++ b/ui/pages/confirm-transaction/confirm-transaction.container.js @@ -15,17 +15,18 @@ import { } from '../../store/actions'; import { unconfirmedTransactionsListSelector } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; +import { getSendTo } from '../../ducks/send'; import ConfirmTransaction from './confirm-transaction.component'; const mapStateToProps = (state, ownProps) => { const { metamask: { unapprovedTxs }, - send, } = state; const { match: { params = {} }, } = ownProps; const { id } = params; + const sendTo = getSendTo(state); const unconfirmedTransactions = unconfirmedTransactionsListSelector(state); const totalUnconfirmed = unconfirmedTransactions.length; @@ -36,7 +37,7 @@ const mapStateToProps = (state, ownProps) => { return { totalUnapprovedCount: totalUnconfirmed, - send, + sendTo, unapprovedTxs, id, mostRecentOverviewPage: getMostRecentOverviewPage(state), diff --git a/ui/pages/mobile-sync/mobile-sync.component.js b/ui/pages/mobile-sync/mobile-sync.component.js index 683310ba0..e03e6eab2 100644 --- a/ui/pages/mobile-sync/mobile-sync.component.js +++ b/ui/pages/mobile-sync/mobile-sync.component.js @@ -296,7 +296,7 @@ export default class MobileSyncPage extends Component { const { t } = this.context; if (syncing) { - return ; + return ; } if (completed) { diff --git a/ui/pages/send/index.js b/ui/pages/send/index.js index 36fa285d4..2fc7580b7 100644 --- a/ui/pages/send/index.js +++ b/ui/pages/send/index.js @@ -1 +1 @@ -export { default } from './send.container'; +export { default } from './send'; diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.js index da7999c94..322dca677 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.js @@ -8,26 +8,28 @@ import RecipientGroup from '../../../../components/app/contact-list/recipient-gr import { ellipsify } from '../../send.utils'; import Button from '../../../../components/ui/button'; import Confusable from '../../../../components/ui/confusable'; -import { - isBurnAddress, - isValidHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; export default class AddRecipient extends Component { static propTypes = { - query: PropTypes.string, + userInput: PropTypes.string, ownedAccounts: PropTypes.array, addressBook: PropTypes.array, - updateGas: PropTypes.func, - updateSendTo: PropTypes.func, + updateRecipient: PropTypes.func, ensResolution: PropTypes.string, - toError: PropTypes.string, - toWarning: PropTypes.string, - ensResolutionError: PropTypes.string, + ensError: PropTypes.string, + ensWarning: PropTypes.string, addressBookEntryName: PropTypes.string, contacts: PropTypes.array, nonContacts: PropTypes.array, - setInternalSearch: PropTypes.func, + useMyAccountsForRecipientSearch: PropTypes.func, + useContactListForRecipientSearch: PropTypes.func, + isUsingMyAccountsForRecipientSearch: PropTypes.bool, + recipient: PropTypes.shape({ + address: PropTypes.string, + nickname: PropTypes.nickname, + error: PropTypes.string, + warning: PropTypes.string, + }), }; constructor(props) { @@ -61,60 +63,58 @@ export default class AddRecipient extends Component { metricsEvent: PropTypes.func, }; - state = { - isShowingTransfer: false, - }; - - selectRecipient = (to, nickname = '') => { - const { updateSendTo, updateGas } = this.props; - - updateSendTo(to, nickname); - updateGas({ to }); + selectRecipient = (address, nickname = '') => { + this.props.updateRecipient({ address, nickname }); }; searchForContacts = () => { - const { query, contacts } = this.props; + const { userInput, contacts } = this.props; let _contacts = contacts; - if (query) { + if (userInput) { this.contactFuse.setCollection(contacts); - _contacts = this.contactFuse.search(query); + _contacts = this.contactFuse.search(userInput); } return _contacts; }; searchForRecents = () => { - const { query, nonContacts } = this.props; + const { userInput, nonContacts } = this.props; let _nonContacts = nonContacts; - if (query) { + if (userInput) { this.recentFuse.setCollection(nonContacts); - _nonContacts = this.recentFuse.search(query); + _nonContacts = this.recentFuse.search(userInput); } return _nonContacts; }; render() { - const { ensResolution, query, addressBookEntryName } = this.props; - const { isShowingTransfer } = this.state; + const { + ensResolution, + recipient, + userInput, + addressBookEntryName, + isUsingMyAccountsForRecipientSearch, + } = this.props; let content; - if ( - !isBurnAddress(query) && - isValidHexAddress(query, { mixedCaseUseChecksum: true }) - ) { - content = this.renderExplicitAddress(query); + if (recipient.address) { + content = this.renderExplicitAddress( + recipient.address, + recipient.nickname, + ); } else if (ensResolution) { content = this.renderExplicitAddress( ensResolution, - addressBookEntryName || query, + addressBookEntryName || userInput, ); - } else if (isShowingTransfer) { + } else if (isUsingMyAccountsForRecipientSearch) { content = this.renderTransfer(); } @@ -150,15 +150,18 @@ export default class AddRecipient extends Component { renderTransfer() { let { ownedAccounts } = this.props; - const { query, setInternalSearch } = this.props; + const { + userInput, + useContactListForRecipientSearch, + isUsingMyAccountsForRecipientSearch, + } = this.props; const { t } = this.context; - const { isShowingTransfer } = this.state; - if (isShowingTransfer && query) { + if (isUsingMyAccountsForRecipientSearch && userInput) { ownedAccounts = ownedAccounts.filter( (item) => - item.name.toLowerCase().indexOf(query.toLowerCase()) > -1 || - item.address.toLowerCase().indexOf(query.toLowerCase()) > -1, + item.name.toLowerCase().indexOf(userInput.toLowerCase()) > -1 || + item.address.toLowerCase().indexOf(userInput.toLowerCase()) > -1, ); } @@ -167,10 +170,7 @@ export default class AddRecipient extends Component { @@ -219,30 +216,19 @@ export default class AddRecipient extends Component { } renderDialogs() { - const { - toError, - toWarning, - ensResolutionError, - ensResolution, - } = this.props; + const { ensError, recipient, ensWarning } = this.props; const { t } = this.context; - if (ensResolutionError) { + if (ensError || (recipient.error && recipient.error !== 'required')) { return ( - {ensResolutionError} + {t(ensError ?? recipient.error)} ); - } else if (toError && toError !== 'required' && !ensResolution) { - return ( - - {t(toError)} - - ); - } else if (toWarning) { + } else if (ensWarning || recipient.warning) { return ( - {t(toWarning)} + {t(ensWarning ?? recipient.warning)} ); } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js index ec2772f13..7c58d36fa 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.test.js @@ -5,30 +5,24 @@ import Dialog from '../../../../components/ui/dialog'; import AddRecipient from './add-recipient.component'; const propsMethodSpies = { - closeToDropdown: sinon.spy(), - openToDropdown: sinon.spy(), - updateGas: sinon.spy(), - updateSendTo: sinon.spy(), - updateSendToError: sinon.spy(), - updateSendToWarning: sinon.spy(), + updateRecipient: sinon.spy(), + useMyAccountsForRecipientSearch: sinon.spy(), + useContactListForRecipientSearch: sinon.spy(), }; describe('AddRecipient Component', () => { let wrapper; - let instance; beforeEach(() => { wrapper = shallow( { />, { context: { t: (str) => `${str}_t` } }, ); - instance = wrapper.instance(); }); afterEach(() => { - propsMethodSpies.closeToDropdown.resetHistory(); - propsMethodSpies.openToDropdown.resetHistory(); - propsMethodSpies.updateSendTo.resetHistory(); - propsMethodSpies.updateSendToError.resetHistory(); - propsMethodSpies.updateSendToWarning.resetHistory(); - propsMethodSpies.updateGas.resetHistory(); - }); - - describe('selectRecipient', () => { - it('should call updateSendTo', () => { - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(0); - instance.selectRecipient('mockTo2', 'mockNickname'); - expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.updateSendTo.getCall(0).args).toStrictEqual([ - 'mockTo2', - 'mockNickname', - ]); - }); - - it('should call updateGas if there is no to error', () => { - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(0); - instance.selectRecipient(false); - expect(propsMethodSpies.updateGas.callCount).toStrictEqual(1); - }); + propsMethodSpies.updateRecipient.resetHistory(); + propsMethodSpies.useMyAccountsForRecipientSearch.resetHistory(); + propsMethodSpies.useContactListForRecipientSearch.resetHistory(); }); describe('render', () => { @@ -104,6 +76,7 @@ describe('AddRecipient Component', () => { it('should render transfer', () => { wrapper.setProps({ + isUsingMyAccountsForRecipientSearch: true, ownedAccounts: [ { address: '0x123', name: '123' }, { address: '0x124', name: '124' }, @@ -163,7 +136,7 @@ describe('AddRecipient Component', () => { it('should render error when query has no results', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', contacts: [], nonContacts: [], }); @@ -178,8 +151,7 @@ describe('AddRecipient Component', () => { it('should render error when query has ens does not resolve', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', - ensResolutionError: 'very bad', + ensError: 'very bad', contacts: [], nonContacts: [], }); @@ -187,20 +159,20 @@ describe('AddRecipient Component', () => { const dialog = wrapper.find(Dialog); expect(dialog.props().type).toStrictEqual('error'); - expect(dialog.props().children).toStrictEqual('very bad'); + expect(dialog.props().children).toStrictEqual('very bad_t'); expect(dialog).toHaveLength(1); }); - it('should not render error when ens resolved', () => { + it('should render error when ens resolved but ens error exists', () => { wrapper.setProps({ addressBook: [], - toError: 'bad', + ensError: 'bad', ensResolution: '0x128', }); const dialog = wrapper.find(Dialog); - expect(dialog).toHaveLength(0); + expect(dialog).toHaveLength(1); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.js index c131ebb7f..27353e778 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.js @@ -1,19 +1,30 @@ import { connect } from 'react-redux'; import { - getSendEnsResolution, - getSendEnsResolutionError, accountsWithSendEtherInfoSelector, getAddressBook, getAddressBookEntry, } from '../../../../selectors'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; +import { + updateRecipient, + updateRecipientUserInput, + useMyAccountsForRecipientSearch, + useContactListForRecipientSearch, + getIsUsingMyAccountForRecipientSearch, + getRecipientUserInput, + getRecipient, +} from '../../../../ducks/send'; +import { + getEnsResolution, + getEnsError, + getEnsWarning, +} from '../../../../ducks/ens'; import AddRecipient from './add-recipient.component'; export default connect(mapStateToProps, mapDispatchToProps)(AddRecipient); function mapStateToProps(state) { - const ensResolution = getSendEnsResolution(state); + const ensResolution = getEnsResolution(state); let addressBookEntryName = ''; if (ensResolution) { @@ -32,14 +43,27 @@ function mapStateToProps(state) { addressBookEntryName, contacts: addressBook.filter(({ name }) => Boolean(name)), ensResolution, - ensResolutionError: getSendEnsResolutionError(state), + ensError: getEnsError(state), + ensWarning: getEnsWarning(state), nonContacts: addressBook.filter(({ name }) => !name), ownedAccounts, + isUsingMyAccountsForRecipientSearch: getIsUsingMyAccountForRecipientSearch( + state, + ), + userInput: getRecipientUserInput(state), + recipient: getRecipient(state), }; } function mapDispatchToProps(dispatch) { return { - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), + updateRecipient: ({ address, nickname }) => + dispatch(updateRecipient({ address, nickname })), + updateRecipientUserInput: (newInput) => + dispatch(updateRecipientUserInput(newInput)), + useMyAccountsForRecipientSearch: () => + dispatch(useMyAccountsForRecipientSearch()), + useContactListForRecipientSearch: () => + dispatch(useContactListForRecipientSearch()), }; } diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js index 1d8e05bdc..81db6cf28 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.container.test.js @@ -1,6 +1,3 @@ -import sinon from 'sinon'; -import { updateSendTo } from '../../../../ducks/send/send.duck'; - let mapStateToProps; let mapDispatchToProps; @@ -13,8 +10,6 @@ jest.mock('react-redux', () => ({ })); jest.mock('../../../../selectors', () => ({ - getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`, - getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`, getAddressBook: (s) => [{ name: `mockAddressBook:${s}` }], getAddressBookEntry: (s) => `mockAddressBookEntry:${s}`, accountsWithSendEtherInfoSelector: () => [ @@ -23,8 +18,26 @@ jest.mock('../../../../selectors', () => ({ ], })); -jest.mock('../../../../ducks/send/send.duck.js', () => ({ - updateSendTo: jest.fn(), +jest.mock('../../../../ducks/ens', () => ({ + getEnsResolution: (s) => `mockSendEnsResolution:${s}`, + getEnsError: (s) => `mockSendEnsResolutionError:${s}`, + getEnsWarning: (s) => `mockSendEnsResolutionWarning:${s}`, + useMyAccountsForRecipientSearch: (s) => + `useMyAccountsForRecipientSearch:${s}`, +})); + +jest.mock('../../../../ducks/send', () => ({ + updateRecipient: ({ address, nickname }) => + `{mockUpdateRecipient: {address: ${address}, nickname: ${nickname}}}`, + updateRecipientUserInput: (s) => `mockUpdateRecipientUserInput:${s}`, + useMyAccountsForRecipientSearch: (s) => + `mockUseMyAccountsForRecipientSearch:${s}`, + useContactListForRecipientSearch: (s) => + `mockUseContactListForRecipientSearch:${s}`, + getIsUsingMyAccountForRecipientSearch: (s) => + `mockGetIsUsingMyAccountForRecipientSearch:${s}`, + getRecipientUserInput: (s) => `mockRecipientUserInput:${s}`, + getRecipient: (s) => `mockRecipient:${s}`, })); require('./add-recipient.container.js'); @@ -34,29 +47,40 @@ describe('add-recipient container', () => { it('should map the correct properties to props', () => { expect(mapStateToProps('mockState')).toStrictEqual({ addressBook: [{ name: 'mockAddressBook:mockState' }], + addressBookEntryName: undefined, contacts: [{ name: 'mockAddressBook:mockState' }], ensResolution: 'mockSendEnsResolution:mockState', - ensResolutionError: 'mockSendEnsResolutionError:mockState', - ownedAccounts: [ - { name: `account1:mockState` }, - { name: `account2:mockState` }, - ], - addressBookEntryName: undefined, + ensError: 'mockSendEnsResolutionError:mockState', + ensWarning: 'mockSendEnsResolutionWarning:mockState', nonContacts: [], + ownedAccounts: [ + { name: 'account1:mockState' }, + { name: 'account2:mockState' }, + ], + isUsingMyAccountsForRecipientSearch: + 'mockGetIsUsingMyAccountForRecipientSearch:mockState', + userInput: 'mockRecipientUserInput:mockState', + recipient: 'mockRecipient:mockState', }); }); }); describe('mapDispatchToProps()', () => { - describe('updateSendTo()', () => { - const dispatchSpy = sinon.spy(); + describe('updateRecipient()', () => { + const dispatchSpy = jest.fn(); + const mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendTo).toHaveBeenCalled(); - expect(updateSendTo).toHaveBeenCalledWith('mockTo', 'mockNickname'); + mapDispatchToPropsObject.updateRecipient({ + address: 'mockAddress', + nickname: 'mockNickname', + }); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy.mock.calls[0][0]).toStrictEqual( + '{mockUpdateRecipient: {address: mockAddress, nickname: mockNickname}}', + ); }); }); }); diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.js b/ui/pages/send/send-content/add-recipient/add-recipient.js deleted file mode 100644 index 5141fda1d..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.js +++ /dev/null @@ -1,56 +0,0 @@ -import contractMap from '@metamask/contract-metadata'; -import { isConfusing } from 'unicode-confusables'; -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; - -import { - checkExistingAddresses, - isValidDomainName, - isOriginContractAddress, - isDefaultMetaMaskChain, -} from '../../../../helpers/utils/util'; -import { - isBurnAddress, - isValidHexAddress, - toChecksumHexAddress, -} from '../../../../../shared/modules/hexstring-utils'; - -export function getToErrorObject(to, sendTokenAddress, chainId) { - let toError = null; - if (!to) { - toError = REQUIRED_ERROR; - } else if ( - isBurnAddress(to) || - (!isValidHexAddress(to, { mixedCaseUseChecksum: true }) && - !isValidDomainName(to)) - ) { - toError = isDefaultMetaMaskChain(chainId) - ? INVALID_RECIPIENT_ADDRESS_ERROR - : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; - } else if (isOriginContractAddress(to, sendTokenAddress)) { - toError = CONTRACT_ADDRESS_ERROR; - } - - return { to: toError }; -} - -export function getToWarningObject(to, tokens = [], sendToken = null) { - let toWarning = null; - if ( - sendToken && - (toChecksumHexAddress(to) in contractMap || - checkExistingAddresses(to, tokens)) - ) { - toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR; - } else if (isValidDomainName(to) && isConfusing(to)) { - toWarning = CONFUSING_ENS_ERROR; - } - - return { to: toWarning }; -} diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js b/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js deleted file mode 100644 index 4a9605d32..000000000 --- a/ui/pages/send/send-content/add-recipient/add-recipient.utils.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - REQUIRED_ERROR, - INVALID_RECIPIENT_ADDRESS_ERROR, - KNOWN_RECIPIENT_ADDRESS_ERROR, - CONFUSING_ENS_ERROR, - CONTRACT_ADDRESS_ERROR, -} from '../../send.constants'; -import { getToErrorObject, getToWarningObject } from './add-recipient'; - -jest.mock('../../../../helpers/utils/util', () => ({ - isDefaultMetaMaskChain: jest.fn().mockReturnValue(true), - isEthNetwork: jest.fn().mockReturnValue(true), - checkExistingAddresses: jest.fn().mockReturnValue(true), - isValidDomainName: jest.requireActual('../../../../helpers/utils/util') - .isValidDomainName, - isOriginContractAddress: jest.requireActual('../../../../helpers/utils/util') - .isOriginContractAddress, -})); - -jest.mock('../../../../../shared/modules/hexstring-utils', () => ({ - isValidHexAddress: jest.fn((to) => - Boolean(to.match(/^[0xabcdef123456798]+$/u)), - ), - isBurnAddress: jest.fn(() => false), - toChecksumHexAddress: jest.fn((input) => input), -})); - -describe('add-recipient utils', () => { - describe('getToErrorObject()', () => { - it('should return a required error if "to" is falsy', () => { - expect(getToErrorObject(null)).toStrictEqual({ - to: REQUIRED_ERROR, - }); - }); - - it('should return an invalid recipient error if "to" is truthy but invalid', () => { - expect(getToErrorObject('mockInvalidTo')).toStrictEqual({ - to: INVALID_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should return null if "to" is truthy and valid', () => { - expect(getToErrorObject('0xabc123')).toStrictEqual({ - to: null, - }); - }); - - it('should return a contract address error if the recipient is the same as the tokens contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc123')).toStrictEqual({ - to: CONTRACT_ADDRESS_ERROR, - }); - }); - - it('should return null if the recipient address is not the token contract address', () => { - expect(getToErrorObject('0xabc123', '0xabc456')).toStrictEqual({ - to: null, - }); - }); - }); - - describe('getToWarningObject()', () => { - it('should return a known address recipient error if "to" is a token address', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }], { - address: '0xabc123', - }), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should null if "to" is a token address but sendToken is falsy', () => { - expect( - getToWarningObject('0xabc123', [{ address: '0xabc123' }]), - ).toStrictEqual({ - to: null, - }); - }); - - it('should return a known address recipient error if "to" is part of contract metadata', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - it('should null if "to" is part of contract metadata but sendToken is falsy', () => { - expect( - getToWarningObject( - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', - [{ address: '0xabc123' }], - { address: '0xabc123' }, - ), - ).toStrictEqual({ - to: KNOWN_RECIPIENT_ADDRESS_ERROR, - }); - }); - - it('should warn if name is a valid domain and confusable', () => { - expect(getToWarningObject('demo.eth')).toStrictEqual({ - to: CONFUSING_ENS_ERROR, - }); - }); - - it('should not warn if name is a valid domain and not confusable', () => { - expect(getToWarningObject('vitalik.eth')).toStrictEqual({ - to: null, - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/add-recipient/ens-input.component.js b/ui/pages/send/send-content/add-recipient/ens-input.component.js index 658ac9bde..bb1c7f3e7 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.component.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.component.js @@ -2,146 +2,39 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { debounce } from 'lodash'; -import copyToClipboard from 'copy-to-clipboard/index'; -import ENS from 'ethjs-ens'; -import networkMap from 'ethereum-ens-network-map'; -import log from 'loglevel'; -import { isHexString } from 'ethereumjs-util'; import { ellipsify } from '../../send.utils'; import { isValidDomainName } from '../../../../helpers/utils/util'; -import { MAINNET_NETWORK_ID } from '../../../../../shared/constants/network'; import { isBurnAddress, isValidHexAddress, } from '../../../../../shared/modules/hexstring-utils'; -// Local Constants -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -const ZERO_X_ERROR_ADDRESS = '0x'; - export default class EnsInput extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, }; static propTypes = { className: PropTypes.string, - network: PropTypes.string, selectedAddress: PropTypes.string, selectedName: PropTypes.string, - onChange: PropTypes.func, - updateEnsResolution: PropTypes.func, scanQrCode: PropTypes.func, - updateEnsResolutionError: PropTypes.func, onPaste: PropTypes.func, - onReset: PropTypes.func, onValidAddressTyped: PropTypes.func, - contact: PropTypes.object, - value: PropTypes.string, internalSearch: PropTypes.bool, - }; - - state = { - input: '', - toError: null, - ensResolution: undefined, + userInput: PropTypes.string, + onChange: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + lookupEnsName: PropTypes.func.isRequired, + initializeEnsSlice: PropTypes.func.isRequired, + resetEnsResolution: PropTypes.func.isRequired, }; componentDidMount() { - const { network, internalSearch } = this.props; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ ensResolution: ZERO_ADDRESS }); - - if (networkHasEnsSupport && !internalSearch) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - } + this.props.initializeEnsSlice(); } - componentDidUpdate(prevProps) { - const { input } = this.state; - const { network, value, internalSearch } = this.props; - - let newValue; - - // Set the value of our input based on QR code provided by parent - const newProvidedValue = input !== value && prevProps.value !== value; - if (newProvidedValue) { - newValue = value; - } - - if (prevProps.network !== network) { - if (getNetworkEnsSupport(network)) { - const provider = global.ethereumProvider; - this.ens = new ENS({ provider, network }); - this.checkName = debounce(this.lookupEnsName, 200); - if (!newProvidedValue) { - newValue = input; - } - } else { - // ens is null on mount on a network that does not have ens support - // this is intended to prevent accidental lookup of domains across - // networks - this.ens = null; - this.checkName = null; - } - } - - if (newValue !== undefined) { - this.onChange({ target: { value: newValue } }); - } - if (!internalSearch && prevProps.internalSearch) { - this.resetInput(); - } - } - - resetInput = () => { - const { - updateEnsResolution, - updateEnsResolutionError, - onReset, - } = this.props; - this.onChange({ target: { value: '' } }); - onReset(); - updateEnsResolution(''); - updateEnsResolutionError(''); - }; - - lookupEnsName = (ensName) => { - const { network } = this.props; - const recipient = ensName.trim(); - - log.info(`ENS attempting to resolve name: ${recipient}`); - this.ens - .lookup(recipient) - .then((address) => { - if (address === ZERO_ADDRESS) { - throw new Error(this.context.t('noAddressForName')); - } - if (address === ZERO_X_ERROR_ADDRESS) { - throw new Error(this.context.t('ensRegistrationError')); - } - this.props.updateEnsResolution(address); - }) - .catch((reason) => { - if ( - isValidDomainName(recipient) && - reason.message === 'ENS name not defined.' - ) { - this.props.updateEnsResolutionError( - network === MAINNET_NETWORK_ID - ? this.context.t('noAddressForName') - : this.context.t('ensNotFoundOnCurrentNetwork'), - ); - } else { - log.error(reason); - this.props.updateEnsResolutionError(reason.message); - } - }); - }; - onPaste = (event) => { event.clipboardData.items[0].getAsString((text) => { if ( @@ -155,40 +48,23 @@ export default class EnsInput extends Component { onChange = (e) => { const { - network, - onChange, - updateEnsResolution, - updateEnsResolutionError, onValidAddressTyped, internalSearch, + onChange, + lookupEnsName, + resetEnsResolution, } = this.props; const input = e.target.value; - const networkHasEnsSupport = getNetworkEnsSupport(network); - this.setState({ input }, () => onChange(input)); + onChange(input); if (internalSearch) { return null; } // Empty ENS state if input is empty // maybe scan ENS - if ( - !networkHasEnsSupport && - !( - isBurnAddress(input) === false && - isValidHexAddress(input, { mixedCaseUseChecksum: true }) - ) && - !isHexString(input) - ) { - updateEnsResolution(''); - updateEnsResolutionError( - networkHasEnsSupport ? '' : 'Network does not support ENS', - ); - return null; - } - if (isValidDomainName(input)) { - this.lookupEnsName(input); + lookupEnsName(input); } else if ( onValidAddressTyped && !isBurnAddress(input) && @@ -196,20 +72,16 @@ export default class EnsInput extends Component { ) { onValidAddressTyped(input); } else { - updateEnsResolution(''); - updateEnsResolutionError(''); + resetEnsResolution(); } return null; }; render() { const { t } = this.context; - const { className, selectedAddress } = this.props; - const { input } = this.state; + const { className, selectedAddress, selectedName, userInput } = this.props; - if (selectedAddress) { - return this.renderSelected(); - } + const hasSelectedAddress = Boolean(selectedAddress); return (
@@ -217,135 +89,61 @@ export default class EnsInput extends Component { className={classnames('ens-input__wrapper', { 'ens-input__wrapper__status-icon--error': false, 'ens-input__wrapper__status-icon--valid': false, + 'ens-input__wrapper--valid': hasSelectedAddress, })} > -
- -
-
- ); - } - - renderSelected() { - const { t } = this.context; - const { - className, - selectedAddress, - selectedName, - contact = {}, - } = this.props; - const name = contact.name || selectedName; - - return ( -
-
-
-
-
- {name || ellipsify(selectedAddress)} -
- {name && ( -
- {selectedAddress} + {hasSelectedAddress ? ( + <> +
+
+ {selectedName || ellipsify(selectedAddress)} +
+ {selectedName && ( +
+ {selectedAddress} +
+ )}
- )} -
-
+
+ + ) : ( + <> + +
); } - - ensIcon(recipient) { - const { hoverText } = this.state; - - return ( - - {this.ensIconContents(recipient)} - - ); - } - - ensIconContents() { - const { loadingEns, ensFailure, ensResolution, toError } = this.state; - - if (toError) { - return null; - } - - if (loadingEns) { - return ( - - ); - } - - if (ensFailure) { - return ; - } - - if (ensResolution && ensResolution !== ZERO_ADDRESS) { - return ( - { - event.preventDefault(); - event.stopPropagation(); - copyToClipboard(ensResolution); - }} - /> - ); - } - - return null; - } -} - -function getNetworkEnsSupport(network) { - return Boolean(networkMap[network]); } diff --git a/ui/pages/send/send-content/add-recipient/ens-input.container.js b/ui/pages/send/send-content/add-recipient/ens-input.container.js index 90d2c3ff4..ef61fce85 100644 --- a/ui/pages/send/send-content/add-recipient/ens-input.container.js +++ b/ui/pages/send/send-content/add-recipient/ens-input.container.js @@ -1,20 +1,18 @@ +import { debounce } from 'lodash'; import { connect } from 'react-redux'; -import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network'; import { - getSendTo, - getSendToNickname, - getAddressBookEntry, - getCurrentChainId, -} from '../../../../selectors'; + lookupEnsName, + initializeEnsSlice, + resetResolution, +} from '../../../../ducks/ens'; import EnsInput from './ens-input.component'; -export default connect((state) => { - const selectedAddress = getSendTo(state); - const chainId = getCurrentChainId(state); +function mapDispatchToProps(dispatch) { return { - network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], - selectedAddress, - selectedName: getSendToNickname(state), - contact: getAddressBookEntry(state, selectedAddress), + lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150), + initializeEnsSlice: () => dispatch(initializeEnsSlice()), + resetEnsResolution: debounce(() => dispatch(resetResolution()), 300), }; -})(EnsInput); +} + +export default connect(null, mapDispatchToProps)(EnsInput); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js deleted file mode 100644 index db520fcdc..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ /dev/null @@ -1,81 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; - -export default class AmountMaxButton extends Component { - static propTypes = { - balance: PropTypes.string, - buttonDataLoading: PropTypes.bool, - clearMaxAmount: PropTypes.func, - inError: PropTypes.bool, - gasTotal: PropTypes.string, - maxModeOn: PropTypes.bool, - sendToken: PropTypes.object, - setAmountToMax: PropTypes.func, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - }; - - static contextTypes = { - t: PropTypes.func, - metricsEvent: PropTypes.func, - }; - - setMaxAmount() { - const { - balance, - gasTotal, - sendToken, - setAmountToMax, - tokenBalance, - } = this.props; - - setAmountToMax({ - balance, - gasTotal, - sendToken, - tokenBalance, - }); - } - - onMaxClick = () => { - const { setMaxModeTo, clearMaxAmount, maxModeOn } = this.props; - const { metricsEvent } = this.context; - - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Clicked "Amount Max"', - }, - }); - if (maxModeOn) { - setMaxModeTo(false); - clearMaxAmount(); - } else { - setMaxModeTo(true); - this.setMaxAmount(); - } - }; - - render() { - const { maxModeOn, buttonDataLoading, inError } = this.props; - - return ( -
- -
- {this.context.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 deleted file mode 100644 index 8c38d3be5..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import AmountMaxButton from './amount-max-button.component'; - -describe('AmountMaxButton Component', () => { - let wrapper; - let instance; - - const propsMethodSpies = { - setAmountToMax: sinon.spy(), - setMaxModeTo: sinon.spy(), - }; - - const MOCK_EVENT = { preventDefault: () => undefined }; - - beforeAll(() => { - sinon.spy(AmountMaxButton.prototype, 'setMaxAmount'); - }); - - beforeEach(() => { - wrapper = shallow( - , - { - context: { - t: (str) => `${str}_t`, - metricsEvent: () => undefined, - }, - }, - ); - instance = wrapper.instance(); - }); - - afterEach(() => { - propsMethodSpies.setAmountToMax.resetHistory(); - propsMethodSpies.setMaxModeTo.resetHistory(); - AmountMaxButton.prototype.setMaxAmount.resetHistory(); - }); - - afterAll(() => { - sinon.restore(); - }); - - describe('setMaxAmount', () => { - it('should call setAmountToMax with the correct params', () => { - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(0); - instance.setMaxAmount(); - expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(1); - expect(propsMethodSpies.setAmountToMax.getCall(0).args).toStrictEqual([ - { - balance: 'mockBalance', - gasTotal: 'mockGasTotal', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }, - ]); - }); - }); - - describe('render', () => { - it('should render an element with a send-v2__amount-max class', () => { - expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1); - }); - - it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => { - const { onClick } = wrapper.find('.send-v2__amount-max').props(); - - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(0); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(0); - onClick(MOCK_EVENT); - expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(1); - expect(propsMethodSpies.setMaxModeTo.getCall(0).args).toStrictEqual([ - true, - ]); - }); - - it('should render the expected text when maxModeOn is false', () => { - wrapper.setProps({ maxModeOn: false }); - expect(wrapper.find('.send-v2__amount-max').text()).toStrictEqual( - 'max_t', - ); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js deleted file mode 100644 index a2fe64b94..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ /dev/null @@ -1,42 +0,0 @@ -import { connect } from 'react-redux'; -import { - getGasTotal, - getSendToken, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getBasicGasEstimateLoadingStatus, -} from '../../../../../selectors'; -import { - updateSendErrors, - updateSendAmount, - setMaxModeTo, -} from '../../../../../ducks/send/send.duck'; -import { calcMaxAmount } from './amount-max-button.utils'; -import AmountMaxButton from './amount-max-button.component'; - -export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton); - -function mapStateToProps(state) { - return { - balance: getSendFromBalance(state), - buttonDataLoading: getBasicGasEstimateLoadingStatus(state), - gasTotal: getGasTotal(state), - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, - clearMaxAmount: () => { - dispatch(updateSendAmount('0')); - }, - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), - }; -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js deleted file mode 100644 index cb86c88ff..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import sinon from 'sinon'; - -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../../ducks/send/send.duck'; - -let mapStateToProps; -let mapDispatchToProps; - -jest.mock('react-redux', () => ({ - connect: (ms, md) => { - mapStateToProps = ms; - mapDispatchToProps = md; - return () => ({}); - }, -})); - -jest.mock('../../../../../selectors', () => ({ - getGasTotal: (s) => `mockGasTotal:${s}`, - getSendToken: (s) => `mockSendToken:${s}`, - getSendFromBalance: (s) => `mockBalance:${s}`, - getTokenBalance: (s) => `mockTokenBalance:${s}`, - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, - getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`, -})); - -jest.mock('./amount-max-button.utils.js', () => ({ - calcMaxAmount: (mockObj) => mockObj.val + 1, -})); - -jest.mock('../../../../../ducks/send/send.duck', () => ({ - setMaxModeTo: jest.fn(), - updateSendAmount: jest.fn(), - updateSendErrors: jest.fn(), -})); - -require('./amount-max-button.container.js'); - -describe('amount-max-button container', () => { - describe('mapStateToProps()', () => { - it('should map the correct properties to props', () => { - expect(mapStateToProps('mockState')).toStrictEqual({ - balance: 'mockBalance:mockState', - buttonDataLoading: 'mockButtonDataLoading:mockState', - gasTotal: 'mockGasTotal:mockState', - maxModeOn: 'mockMaxModeOn:mockState', - sendToken: 'mockSendToken:mockState', - tokenBalance: 'mockTokenBalance:mockState', - }); - }); - }); - - describe('mapDispatchToProps()', () => { - let dispatchSpy; - let mapDispatchToPropsObject; - - beforeEach(() => { - dispatchSpy = sinon.spy(); - mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); - }); - - describe('setAmountToMax()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }); - expect(dispatchSpy.calledTwice).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ amount: null }); - expect(updateSendAmount).toHaveBeenCalled(); - expect(updateSendAmount).toHaveBeenCalledWith(12); - }); - }); - - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockVal'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalledWith('mockVal'); - }); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js new file mode 100644 index 000000000..7f143879b --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.js @@ -0,0 +1,49 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { getBasicGasEstimateLoadingStatus } from '../../../../../selectors'; +import { + getSendMaxModeState, + isSendFormInvalid, + toggleSendMaxMode, +} from '../../../../../ducks/send'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useMetricEvent } from '../../../../../hooks/useMetricEvent'; + +export default function AmountMaxButton() { + const buttonDataLoading = useSelector(getBasicGasEstimateLoadingStatus); + const isDraftTransactionInvalid = useSelector(isSendFormInvalid); + const maxModeOn = useSelector(getSendMaxModeState); + const dispatch = useDispatch(); + const trackClickedMax = useMetricEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Clicked "Amount Max"', + }, + }); + const t = useI18nContext(); + + const onMaxClick = () => { + trackClickedMax(); + dispatch(toggleSendMaxMode()); + }; + + return ( + + ); +} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js new file mode 100644 index 000000000..7f4482517 --- /dev/null +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { fireEvent } from '@testing-library/react'; +import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; +import { renderWithProvider } from '../../../../../../test/jest'; +import AmountMaxButton from './amount-max-button'; + +const middleware = [thunk]; + +describe('AmountMaxButton Component', () => { + describe('render', () => { + it('should render a "Max" button', () => { + const { getByText } = renderWithProvider( + , + configureMockStore(middleware)({ + send: initialState, + gas: { basicEstimateStatus: 'LOADING' }, + }), + ); + expect(getByText('Max')).toBeTruthy(); + }); + + it('should dispatch action to set mode to MAX', () => { + const store = configureMockStore(middleware)({ + send: { ...initialState, status: SEND_STATUSES.VALID }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'MAX' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + + it('should dispatch action to set amount mode to INPUT', () => { + const store = configureMockStore(middleware)({ + send: { + ...initialState, + status: SEND_STATUSES.VALID, + amount: { ...initialState.amount, mode: 'MAX' }, + }, + gas: { basicEstimateStatus: 'READY' }, + }); + const { getByText } = renderWithProvider(, store); + + const expectedActions = [ + { type: 'send/updateAmountMode', payload: 'INPUT' }, + ]; + + fireEvent.click(getByText('Max'), { bubbles: true }); + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + }); +}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js deleted file mode 100644 index 6826b5e39..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - multiplyCurrencies, - subtractCurrencies, -} from '../../../../../helpers/utils/conversion-util'; -import { addHexPrefix } from '../../../../../../app/scripts/lib/util'; - -export function calcMaxAmount({ balance, gasTotal, sendToken, tokenBalance }) { - const { decimals } = sendToken || {}; - const multiplier = Math.pow(10, Number(decimals || 0)); - - return sendToken - ? multiplyCurrencies(tokenBalance, multiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }) - : subtractCurrencies(addHexPrefix(balance), addHexPrefix(gasTotal), { - toNumericBase: 'hex', - aBase: 16, - bBase: 16, - }); -} diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js deleted file mode 100644 index 87b334386..000000000 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { calcMaxAmount } from './amount-max-button.utils'; - -describe('amount-max-button utils', () => { - describe('calcMaxAmount()', () => { - it('should calculate the correct amount when no sendToken defined', () => { - expect( - calcMaxAmount({ - balance: 'ffffff', - gasTotal: 'ff', - sendToken: false, - }), - ).toStrictEqual('ffff00'); - }); - - it('should calculate the correct amount when a sendToken is defined', () => { - expect( - calcMaxAmount({ - sendToken: { - decimals: 10, - }, - tokenBalance: '64', - }), - ).toStrictEqual('e8d4a51000'); - }); - }); -}); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js index 26d87ffb5..16657e95d 100644 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/index.js @@ -1 +1 @@ -export { default } from './amount-max-button.container'; +export { default } from './amount-max-button'; diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js index 3f3d64e45..7cf67fdd1 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.js @@ -1,111 +1,35 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; import SendRowWrapper from '../send-row-wrapper'; import UserPreferencedCurrencyInput from '../../../../components/app/user-preferenced-currency-input'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import AmountMaxButton from './amount-max-button'; export default class SendAmountRow extends Component { static propTypes = { amount: PropTypes.string, - balance: PropTypes.string, - conversionRate: PropTypes.number, - gasTotal: PropTypes.string, inError: PropTypes.bool, - primaryCurrency: PropTypes.string, - sendToken: PropTypes.object, - setMaxModeTo: PropTypes.func, - tokenBalance: PropTypes.string, - updateGasFeeError: PropTypes.func, + asset: PropTypes.object, updateSendAmount: PropTypes.func, - updateSendAmountError: PropTypes.func, - updateGas: PropTypes.func, - maxModeOn: PropTypes.bool, }; static contextTypes = { t: PropTypes.func, }; - componentDidUpdate(prevProps) { - const { maxModeOn: prevMaxModeOn, gasTotal: prevGasTotal } = prevProps; - const { maxModeOn, amount, gasTotal, sendToken } = this.props; - - if (maxModeOn && sendToken && !prevMaxModeOn) { - this.updateGas(amount); - } - - if (prevGasTotal !== gasTotal) { - this.validateAmount(amount); - } - } - - updateGas = debounce(this.updateGas.bind(this), 500); - - validateAmount(amount) { - const { - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - updateGasFeeError, - updateSendAmountError, - } = this.props; - - updateSendAmountError({ - amount, - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - - if (sendToken) { - updateGasFeeError({ - balance, - conversionRate, - gasTotal, - primaryCurrency, - sendToken, - tokenBalance, - }); - } - } - - updateAmount(amount) { - const { updateSendAmount, setMaxModeTo } = this.props; - - setMaxModeTo(false); - updateSendAmount(amount); - } - - updateGas(amount) { - const { sendToken, updateGas } = this.props; - - if (sendToken) { - updateGas({ amount }); - } - } - handleChange = (newAmount) => { - this.validateAmount(newAmount); - this.updateGas(newAmount); - this.updateAmount(newAmount); + this.props.updateSendAmount(newAmount); }; renderInput() { - const { amount, inError, sendToken } = this.props; + const { amount, inError, asset } = this.props; - return sendToken ? ( + return asset.type === ASSET_TYPES.TOKEN ? ( ) : ( @@ -118,7 +42,7 @@ export default class SendAmountRow extends Component { } render() { - const { gasTotal, inError } = this.props; + const { inError } = this.props; return ( - {gasTotal && } + {this.renderInput()} ); diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js index 8ed1a7438..c6e8e23be 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.component.test.js @@ -3,88 +3,13 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input'; +import { ASSET_TYPES } from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; -import AmountMaxButton from './amount-max-button/amount-max-button.container'; +import AmountMaxButton from './amount-max-button/amount-max-button'; describe('SendAmountRow Component', () => { - describe('validateAmount', () => { - it('should call updateSendAmountError with the correct params', () => { - const { - instance, - propsMethodSpies: { updateSendAmountError }, - } = shallowRenderSendAmountRow(); - - expect(updateSendAmountError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateSendAmountError.calledOnceWithExactly({ - amount: 'someAmount', - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call updateGasFeeError if sendToken is truthy', () => { - const { - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect( - updateGasFeeError.calledOnceWithExactly({ - balance: 'mockBalance', - conversionRate: 7, - gasTotal: 'mockGasTotal', - primaryCurrency: 'mockPrimaryCurrency', - sendToken: { address: 'mockTokenAddress' }, - tokenBalance: 'mockTokenBalance', - }), - ).toStrictEqual(true); - }); - - it('should call not updateGasFeeError if sendToken is falsey', () => { - const { - wrapper, - instance, - propsMethodSpies: { updateGasFeeError }, - } = shallowRenderSendAmountRow(); - - wrapper.setProps({ sendToken: null }); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - - instance.validateAmount('someAmount'); - - expect(updateGasFeeError.callCount).toStrictEqual(0); - }); - }); - describe('updateAmount', () => { - it('should call setMaxModeTo', () => { - const { - instance, - propsMethodSpies: { setMaxModeTo }, - } = shallowRenderSendAmountRow(); - - expect(setMaxModeTo.callCount).toStrictEqual(0); - - instance.updateAmount('someAmount'); - - expect(setMaxModeTo.calledOnceWithExactly(false)).toStrictEqual(true); - }); - it('should call updateSendAmount', () => { const { instance, @@ -93,7 +18,7 @@ describe('SendAmountRow Component', () => { expect(updateSendAmount.callCount).toStrictEqual(0); - instance.updateAmount('someAmount'); + instance.handleChange('someAmount'); expect( updateSendAmount.calledOnceWithExactly('someAmount'), @@ -136,10 +61,7 @@ describe('SendAmountRow Component', () => { }); it('should render the UserPreferencedTokenInput with the correct props', () => { - const { - wrapper, - instanceSpies: { updateGas, updateAmount, validateAmount }, - } = shallowRenderSendAmountRow(); + const { wrapper } = shallowRenderSendAmountRow(); const { onChange, error, value } = wrapper .find(SendRowWrapper) .childAt(1) @@ -147,67 +69,34 @@ describe('SendAmountRow Component', () => { expect(error).toStrictEqual(false); expect(value).toStrictEqual('mockAmount'); - expect(updateGas.callCount).toStrictEqual(0); - expect(updateAmount.callCount).toStrictEqual(0); - expect(validateAmount.callCount).toStrictEqual(0); onChange('mockNewAmount'); - - expect(updateGas.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect(updateAmount.calledOnceWithExactly('mockNewAmount')).toStrictEqual( - true, - ); - expect( - validateAmount.calledOnceWithExactly('mockNewAmount'), - ).toStrictEqual(true); }); }); }); function shallowRenderSendAmountRow() { - const setMaxModeTo = sinon.spy(); - const updateGasFeeError = sinon.spy(); const updateSendAmount = sinon.spy(); - const updateSendAmountError = sinon.spy(); const wrapper = shallow( undefined} />, { context: { t: (str) => `${str}_t` } }, ); const instance = wrapper.instance(); - const updateAmount = sinon.spy(instance, 'updateAmount'); - const updateGas = sinon.spy(instance, 'updateGas'); - const validateAmount = sinon.spy(instance, 'validateAmount'); return { instance, wrapper, propsMethodSpies: { - setMaxModeTo, - updateGasFeeError, updateSendAmount, - updateSendAmountError, - }, - instanceSpies: { - updateAmount, - updateGas, - validateAmount, }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js index ea76e87a4..261c91168 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.js @@ -1,21 +1,10 @@ import { connect } from 'react-redux'; import { - getGasTotal, - getPrimaryCurrency, - getSendToken, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - sendAmountIsInError, -} from '../../../../selectors'; -import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils'; -import { - updateSendErrors, - setMaxModeTo, updateSendAmount, -} from '../../../../ducks/send/send.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; + getSendAmount, + sendAmountIsInError, + getSendAsset, +} from '../../../../ducks/send'; import SendAmountRow from './send-amount-row.component'; export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); @@ -23,26 +12,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow); function mapStateToProps(state) { return { amount: getSendAmount(state), - balance: getSendFromBalance(state), - conversionRate: getConversionRate(state), - gasTotal: getGasTotal(state), inError: sendAmountIsInError(state), - primaryCurrency: getPrimaryCurrency(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - maxModeOn: getSendMaxModeState(state), + asset: getSendAsset(state), }; } function mapDispatchToProps(dispatch) { return { - setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), updateSendAmount: (newAmount) => dispatch(updateSendAmount(newAmount)), - updateGasFeeError: (amountDataObject) => { - dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))); - }, - updateSendAmountError: (amountDataObject) => { - dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))); - }, }; } diff --git a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js index edad05014..4911cb612 100644 --- a/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js +++ b/ui/pages/send/send-content/send-amount-row/send-amount-row.container.test.js @@ -1,10 +1,6 @@ import sinon from 'sinon'; -import { - updateSendErrors, - setMaxModeTo, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; +import { updateSendAmount } from '../../../../ducks/send'; let mapDispatchToProps; @@ -15,24 +11,7 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors/send.js', () => ({ - sendAmountIsInError: (s) => `mockInError:${s}`, -})); - -jest.mock('../../send.utils', () => ({ - getAmountErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockChange: true, - }), - getGasFeeErrorObject: (mockDataObject) => ({ - ...mockDataObject, - mockGasFeeErrorChange: true, - }), -})); - -jest.mock('../../../../ducks/send/send.duck', () => ({ - updateSendErrors: jest.fn(), - setMaxModeTo: jest.fn(), +jest.mock('../../../../ducks/send', () => ({ updateSendAmount: jest.fn(), })); @@ -48,15 +27,6 @@ describe('send-amount-row container', () => { mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); }); - describe('setMaxModeTo()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.setMaxModeTo('mockBool'); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(setMaxModeTo).toHaveBeenCalled(); - expect(setMaxModeTo).toHaveBeenCalledWith('mockBool'); - }); - }); - describe('updateSendAmount()', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.updateSendAmount('mockAmount'); @@ -65,29 +35,5 @@ describe('send-amount-row container', () => { expect(updateSendAmount).toHaveBeenCalledWith('mockAmount'); }); }); - - describe('updateGasFeeError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockGasFeeErrorChange: true, - }); - }); - }); - - describe('updateSendAmountError()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(updateSendErrors).toHaveBeenCalled(); - expect(updateSendErrors).toHaveBeenCalledWith({ - some: 'data', - mockChange: true, - }); - }); - }); }); }); diff --git a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js index 35c89a041..e6c1d9bb9 100644 --- a/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js +++ b/ui/pages/send/send-content/send-asset-row/send-asset-row.component.js @@ -5,6 +5,7 @@ import Identicon from '../../../../components/ui/identicon/identicon.component'; import TokenBalance from '../../../../components/ui/token-balance'; import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display'; import { ERC20, PRIMARY } from '../../../../helpers/constants/common'; +import { ASSET_TYPES } from '../../../../ducks/send'; export default class SendAssetRow extends Component { static propTypes = { @@ -18,8 +19,8 @@ export default class SendAssetRow extends Component { accounts: PropTypes.object.isRequired, assetImages: PropTypes.object, selectedAddress: PropTypes.string.isRequired, - sendTokenAddress: PropTypes.string, - setSendToken: PropTypes.func.isRequired, + sendAssetAddress: PropTypes.string, + updateSendAsset: PropTypes.func.isRequired, nativeCurrency: PropTypes.string, nativeCurrencyImage: PropTypes.string, }; @@ -31,13 +32,19 @@ export default class SendAssetRow extends Component { state = { isShowingDropdown: false, + sendableTokens: [], }; + async componentDidMount() { + const sendableTokens = this.props.tokens.filter((token) => !token.isERC721); + this.setState({ sendableTokens }); + } + openDropdown = () => this.setState({ isShowingDropdown: true }); closeDropdown = () => this.setState({ isShowingDropdown: false }); - selectToken = (token) => { + selectToken = (type, token) => { this.setState( { isShowingDropdown: false, @@ -53,7 +60,10 @@ export default class SendAssetRow extends Component { assetSelected: token ? ERC20 : this.props.nativeCurrency, }, }); - this.props.setSendToken(token); + this.props.updateSendAsset({ + type, + details: type === ASSET_TYPES.NATIVE ? null : token, + }); }, ); }; @@ -65,16 +75,18 @@ export default class SendAssetRow extends Component {
{this.renderSendToken()} - {this.props.tokens.length > 0 ? this.renderAssetDropdown() : null} + {this.state.sendableTokens.length > 0 + ? this.renderAssetDropdown() + : null}
); } renderSendToken() { - const { sendTokenAddress } = this.props; + const { sendAssetAddress } = this.props; const token = this.props.tokens.find( - ({ address }) => address === sendTokenAddress, + ({ address }) => address === sendAssetAddress, ); return (
{this.renderNativeCurrency(true)} - {this.props.tokens.map((token) => this.renderAsset(token, true))} + {this.state.sendableTokens.map((token) => + this.renderAsset(token, true), + )}
) @@ -119,11 +133,11 @@ export default class SendAssetRow extends Component { return (
0 + this.state.sendableTokens.length > 0 ? 'send-v2__asset-dropdown__asset' : 'send-v2__asset-dropdown__single-asset' } - onClick={() => this.selectToken()} + onClick={() => this.selectToken(ASSET_TYPES.NATIVE)} >
- {!insideDropdown && this.props.tokens.length > 0 && ( + {!insideDropdown && this.state.sendableTokens.length > 0 && ( )}
@@ -162,7 +176,7 @@ export default class SendAssetRow extends Component {
this.selectToken(token)} + onClick={() => this.selectToken(ASSET_TYPES.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 6f1a82b92..fc27aff25 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -6,6 +6,7 @@ import { ETH_GAS_PRICE_FETCH_WARNING_KEY, GAS_PRICE_FETCH_FAILURE_ERROR_KEY, GAS_PRICE_EXCESSIVE_ERROR_KEY, + UNSENDABLE_ASSET_ERROR_KEY, } from '../../../helpers/constants/error-keys'; import SendAmountRow from './send-amount-row'; import SendGasRow from './send-gas-row'; @@ -18,7 +19,7 @@ export default class SendContent extends Component { }; static propTypes = { - updateGas: PropTypes.func, + isAssetSendable: PropTypes.bool, showAddToAddressBookModal: PropTypes.func, showHexData: PropTypes.bool, contact: PropTypes.object, @@ -30,8 +31,6 @@ export default class SendContent extends Component { noGasPrice: PropTypes.bool, }; - updateGas = (updateData) => this.props.updateGas(updateData); - render() { const { warning, @@ -39,6 +38,7 @@ export default class SendContent extends Component { gasIsExcessive, isEthGasPrice, noGasPrice, + isAssetSendable, } = this.props; let gasError; @@ -50,15 +50,15 @@ export default class SendContent extends Component {
{gasError && this.renderError(gasError)} {isEthGasPrice && this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)} - {error && this.renderError()} + {isAssetSendable === false && + this.renderError(UNSENDABLE_ASSET_ERROR_KEY)} + {error && this.renderError(error)} {warning && this.renderWarning()} {this.maybeRenderAddContact()} - + - {this.props.showHexData && ( - - )} + {this.props.showHexData && }
); @@ -97,12 +97,11 @@ export default class SendContent extends Component { ); } - renderError(gasError = '') { + renderError(error) { const { t } = this.context; - const { error } = this.props; return ( - {gasError === '' ? t(error) : t(gasError)} + {t(error)} ); } diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index 3c99b3237..be623f937 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -1,12 +1,13 @@ import { connect } from 'react-redux'; import { - getSendTo, accountsWithSendEtherInfoSelector, getAddressBookEntry, getIsEthGasPriceFetched, getNoGasPriceFetched, } from '../../../selectors'; +import { getIsAssetSendable, getSendTo } from '../../../ducks/send'; + import * as actions from '../../../store/actions'; import SendContent from './send-content.component'; @@ -14,15 +15,16 @@ function mapStateToProps(state) { const ownedAccounts = accountsWithSendEtherInfoSelector(state); const to = getSendTo(state); return { + isAssetSendable: getIsAssetSendable(state), isOwnedAccount: Boolean( ownedAccounts.find( ({ address }) => address.toLowerCase() === to.toLowerCase(), ), ), contact: getAddressBookEntry(state, to), - to, isEthGasPrice: getIsEthGasPriceFetched(state), noGasPrice: getNoGasPriceFetched(state), + to, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js index 9cbe21629..b662261bb 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.js @@ -3,63 +3,42 @@ import PropTypes from 'prop-types'; import SendRowWrapper from '../send-row-wrapper'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; import AdvancedGasInputs from '../../../../components/app/gas-customization/advanced-gas-inputs'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; export default class SendGasRow extends Component { static propTypes = { - balance: PropTypes.string, gasFeeError: PropTypes.bool, gasLoadingError: PropTypes.bool, gasTotal: PropTypes.string, - maxModeOn: PropTypes.bool, showCustomizeGasModal: PropTypes.func, - sendToken: PropTypes.object, - setAmountToMax: PropTypes.func, - setGasPrice: PropTypes.func, - setGasLimit: PropTypes.func, - tokenBalance: PropTypes.string, + updateGasPrice: PropTypes.func, + updateGasLimit: PropTypes.func, + gasInputMode: PropTypes.oneOf(Object.values(GAS_INPUT_MODES)), gasPriceButtonGroupProps: PropTypes.object, - gasButtonGroupShown: PropTypes.bool, advancedInlineGasShown: PropTypes.bool, resetGasButtons: PropTypes.func, gasPrice: PropTypes.string, gasLimit: PropTypes.string, insufficientBalance: PropTypes.bool, - isMainnet: PropTypes.bool, - isEthGasPrice: PropTypes.bool, - noGasPrice: PropTypes.bool, + minimumGasLimit: PropTypes.string, }; static contextTypes = { t: PropTypes.func, - metricsEvent: PropTypes.func, + trackEvent: PropTypes.func, }; renderAdvancedOptionsButton() { - const { metricsEvent } = this.context; - const { - showCustomizeGasModal, - isMainnet, - isEthGasPrice, - noGasPrice, - } = this.props; - // Tests should behave in same way as mainnet, but are using Localhost - if (!isMainnet && !process.env.IN_TEST) { - return null; - } - if (isEthGasPrice || noGasPrice) { - return null; - } + const { trackEvent } = this.context; + const { showCustomizeGasModal } = this.props; return (
{ - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Clicked "Advanced Options"', - }, + trackEvent({ + category: 'Transactions', + event: 'Clicked "Advanced Options"', }); showCustomizeGasModal(); }} @@ -69,44 +48,22 @@ export default class SendGasRow extends Component { ); } - setMaxAmount() { - const { - balance, - gasTotal, - sendToken, - setAmountToMax, - tokenBalance, - } = this.props; - - setAmountToMax({ - balance, - gasTotal, - sendToken, - tokenBalance, - }); - } - renderContent() { const { gasLoadingError, gasTotal, showCustomizeGasModal, gasPriceButtonGroupProps, - gasButtonGroupShown, - advancedInlineGasShown, - maxModeOn, + gasInputMode, resetGasButtons, - setGasPrice, - setGasLimit, + updateGasPrice, + updateGasLimit, gasPrice, gasLimit, insufficientBalance, - isMainnet, - isEthGasPrice, - noGasPrice, + minimumGasLimit, } = this.props; - const { metricsEvent } = this.context; - const gasPriceFetchFailure = isEthGasPrice || noGasPrice; + const { trackEvent } = this.context; const gasPriceButtonGroup = (
@@ -115,17 +72,14 @@ export default class SendGasRow extends Component { showCheck={false} {...gasPriceButtonGroupProps} handleGasPriceSelection={async (opts) => { - metricsEvent({ - eventOpts: { - category: 'Transactions', - action: 'Edit Screen', - name: 'Changed Gas Button', + trackEvent({ + category: 'Transactions', + event: 'User Clicked Gas Estimate Button', + properties: { + gasEstimateType: opts.gasEstimateType.toLowerCase(), }, }); await gasPriceButtonGroupProps.handleGasPriceSelection(opts); - if (maxModeOn) { - this.setMaxAmount(); - } }} />
@@ -134,51 +88,38 @@ export default class SendGasRow extends Component { { - resetGasButtons(); - if (maxModeOn) { - this.setMaxAmount(); - } - }} - onClick={() => showCustomizeGasModal()} + onReset={resetGasButtons} + onClick={showCustomizeGasModal} /> ); const advancedGasInputs = (
- setGasPrice({ gasPrice: newGasPrice, gasLimit }) - } - updateCustomGasLimit={(newGasLimit) => - setGasLimit(newGasLimit, gasPrice) - } + updateCustomGasPrice={updateGasPrice} + updateCustomGasLimit={updateGasLimit} customGasPrice={gasPrice} customGasLimit={gasLimit} insufficientBalance={insufficientBalance} + minimumGasLimit={minimumGasLimit} customPriceIsSafe isSpeedUp={false} />
); // Tests should behave in same way as mainnet, but are using Localhost - if ( - advancedInlineGasShown || - (!isMainnet && !process.env.IN_TEST) || - gasPriceFetchFailure - ) { - return advancedGasInputs; - } else if (gasButtonGroupShown) { - return gasPriceButtonGroup; + switch (gasInputMode) { + case GAS_INPUT_MODES.BASIC: + return gasPriceButtonGroup; + case GAS_INPUT_MODES.INLINE: + return advancedGasInputs; + case GAS_INPUT_MODES.CUSTOM: + default: + return gasFeeDisplay; } - return gasFeeDisplay; } render() { - const { - gasFeeError, - gasButtonGroupShown, - advancedInlineGasShown, - } = this.props; + const { gasFeeError, gasInputMode, advancedInlineGasShown } = this.props; return ( <> @@ -189,7 +130,7 @@ export default class SendGasRow extends Component { > {this.renderContent()} - {gasButtonGroupShown || advancedInlineGasShown ? ( + {gasInputMode === GAS_INPUT_MODES.BASIC || advancedInlineGasShown ? ( {this.renderAdvancedOptionsButton()} ) : null} diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js index b6382f5bf..9c5cfa30f 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.component.test.js @@ -3,6 +3,7 @@ import { shallow } from 'enzyme'; import sinon from 'sinon'; import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component'; import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group'; +import { GAS_INPUT_MODES } from '../../../../ducks/send'; import SendGasRow from './send-gas-row.component'; import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'; @@ -24,7 +25,7 @@ describe('SendGasRow Component', () => { gasFeeError gasLoadingError={false} gasTotal="mockGasTotal" - gasButtonGroupShown={false} + gasInputMode={GAS_INPUT_MODES.CUSTOM} showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal} resetGasButtons={propsMethodSpies.resetGasButtons} gasPriceButtonGroupProps={{ @@ -32,7 +33,7 @@ describe('SendGasRow Component', () => { anotherGasPriceButtonGroupProp: 'bar', }} />, - { context: { t: (str) => `${str}_t`, metricsEvent: () => ({}) } }, + { context: { t: (str) => `${str}_t`, trackEvent: () => ({}) } }, ); wrapper.setProps({ isMainnet: true }); }); @@ -76,8 +77,8 @@ describe('SendGasRow Component', () => { expect(propsMethodSpies.resetGasButtons.callCount).toStrictEqual(1); }); - it('should render the GasPriceButtonGroup if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render the GasPriceButtonGroup if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).first().childAt(0); expect(wrapper.children()).toHaveLength(2); @@ -95,8 +96,8 @@ describe('SendGasRow Component', () => { ).toStrictEqual('bar'); }); - it('should render an advanced options button if gasButtonGroupShown is true', () => { - wrapper.setProps({ gasButtonGroupShown: true }); + it('should render an advanced options button if gasInputMode is BASIC', () => { + wrapper.setProps({ gasInputMode: GAS_INPUT_MODES.BASIC }); const rendered = wrapper.find(SendRowWrapper).last(); expect(wrapper.children()).toHaveLength(2); diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js index 84d6886fb..189fa95e4 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.js @@ -1,42 +1,30 @@ import { connect } from 'react-redux'; +import { + getBasicGasEstimateLoadingStatus, + getRenderableEstimateDataForSmallButtonsFromGWEI, + getDefaultActiveButtonIndex, + getAdvancedInlineGasShown, +} from '../../../../selectors'; import { getGasTotal, getGasPrice, getGasLimit, - getSendAmount, - getSendFromBalance, - getTokenBalance, - getSendMaxModeState, - getGasLoadingError, gasFeeIsInError, - getGasButtonGroupShown, - getAdvancedInlineGasShown, - getCurrentEthBalance, - getSendToken, - getBasicGasEstimateLoadingStatus, - getRenderableEstimateDataForSmallButtonsFromGWEI, - getDefaultActiveButtonIndex, - getIsMainnet, - getIsEthGasPriceFetched, - getNoGasPriceFetched, -} from '../../../../selectors'; -import { isBalanceSufficient, calcGasTotal } from '../../send.utils'; -import { calcMaxAmount } from '../send-amount-row/amount-max-button/amount-max-button.utils'; -import { - showGasButtonGroup, - updateSendErrors, - setGasPrice, - setGasLimit, - setGasTotal, - updateSendAmount, -} from '../../../../ducks/send/send.duck'; + getGasInputMode, + updateGasPrice, + updateGasLimit, + isSendStateInitialized, + getIsBalanceInsufficient, + getMinimumGasLimitForSend, + useDefaultGas, +} from '../../../../ducks/send'; import { resetCustomData, setCustomGasPrice, setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { showModal } from '../../../../store/actions'; +import { hexToDecimal } from '../../../../helpers/utils/conversions.util'; import SendGasRow from './send-gas-row.component'; export default connect( @@ -55,40 +43,25 @@ function mapStateToProps(state) { ); const gasTotal = getGasTotal(state); - const conversionRate = getConversionRate(state); - const balance = getCurrentEthBalance(state); - const insufficientBalance = !isBalanceSufficient({ - amount: getSendToken(state) ? '0x0' : getSendAmount(state), - gasTotal, - balance, - conversionRate, - }); - const isEthGasPrice = getIsEthGasPriceFetched(state); - const noGasPrice = getNoGasPriceFetched(state); + const minimumGasLimit = getMinimumGasLimitForSend(state); return { - balance: getSendFromBalance(state), gasTotal, + minimumGasLimit: hexToDecimal(minimumGasLimit), gasFeeError: gasFeeIsInError(state), - gasLoadingError: getGasLoadingError(state), + gasLoadingError: isSendStateInitialized(state), gasPriceButtonGroupProps: { buttonDataLoading: getBasicGasEstimateLoadingStatus(state), defaultActiveButtonIndex: 1, newActiveButtonIndex: activeButtonIndex > -1 ? activeButtonIndex : null, gasButtonInfo, }, - gasButtonGroupShown: getGasButtonGroupShown(state), advancedInlineGasShown: getAdvancedInlineGasShown(state), + gasInputMode: getGasInputMode(state), gasPrice, gasLimit, - insufficientBalance, - maxModeOn: getSendMaxModeState(state), - sendToken: getSendToken(state), - tokenBalance: getTokenBalance(state), - isMainnet: getIsMainnet(state), - isEthGasPrice, - noGasPrice, + insufficientBalance: getIsBalanceInsufficient(state), }; } @@ -96,26 +69,16 @@ function mapDispatchToProps(dispatch) { return { showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS', hideBasic: true })), - setGasPrice: ({ gasPrice, gasLimit }) => { - dispatch(setGasPrice(gasPrice)); + updateGasPrice: (gasPrice) => { + dispatch(updateGasPrice(gasPrice)); dispatch(setCustomGasPrice(gasPrice)); - if (gasLimit) { - dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))); - } }, - setGasLimit: (newLimit, gasPrice) => { - dispatch(setGasLimit(newLimit)); + updateGasLimit: (newLimit) => { + dispatch(updateGasLimit(newLimit)); dispatch(setCustomGasLimit(newLimit)); - if (gasPrice) { - dispatch(setGasTotal(calcGasTotal(newLimit, gasPrice))); - } }, - setAmountToMax: (maxAmountDataObject) => { - dispatch(updateSendErrors({ amount: null })); - dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); - }, - showGasButtonGroup: () => dispatch(showGasButtonGroup()), resetCustomData: () => dispatch(resetCustomData()), + useDefaultGas: () => dispatch(useDefaultGas()), }; } @@ -123,8 +86,8 @@ function mergeProps(stateProps, dispatchProps, ownProps) { const { gasPriceButtonGroupProps } = stateProps; const { gasButtonInfo } = gasPriceButtonGroupProps; const { - setGasPrice: dispatchSetGasPrice, - showGasButtonGroup: dispatchShowGasButtonGroup, + updateGasPrice: dispatchUpdateGasPrice, + useDefaultGas: dispatchUseDefaultGas, resetCustomData: dispatchResetCustomData, ...otherDispatchProps } = dispatchProps; @@ -135,13 +98,14 @@ function mergeProps(stateProps, dispatchProps, ownProps) { ...ownProps, gasPriceButtonGroupProps: { ...gasPriceButtonGroupProps, - handleGasPriceSelection: dispatchSetGasPrice, + handleGasPriceSelection: ({ gasPrice }) => + dispatchUpdateGasPrice(gasPrice), }, resetGasButtons: () => { dispatchResetCustomData(); - dispatchSetGasPrice(gasButtonInfo[1].priceInHexWei); - dispatchShowGasButtonGroup(); + dispatchUpdateGasPrice(gasButtonInfo[1].priceInHexWei); + dispatchUseDefaultGas(); }, - setGasPrice: dispatchSetGasPrice, + updateGasPrice: dispatchUpdateGasPrice, }; } diff --git a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js index 80757f230..b7aa23c86 100644 --- a/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js +++ b/ui/pages/send/send-content/send-gas-row/send-gas-row.container.test.js @@ -8,12 +8,7 @@ import { setCustomGasLimit, } from '../../../../ducks/gas/gas.duck'; -import { - showGasButtonGroup, - setGasPrice, - setGasTotal, - setGasLimit, -} from '../../../../ducks/send/send.duck'; +import { updateGasPrice, updateGasLimit } from '../../../../ducks/send'; let mapDispatchToProps; let mergeProps; @@ -26,9 +21,15 @@ jest.mock('react-redux', () => ({ }, })); -jest.mock('../../../../selectors', () => ({ - getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, -})); +jest.mock('../../../../ducks/send', () => { + const original = jest.requireActual('../../../../ducks/send'); + return { + ...original, + getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, + updateGasPrice: jest.fn(), + updateGasLimit: jest.fn(), + }; +}); jest.mock('../../send.utils.js', () => ({ isBalanceSufficient: ({ amount, gasTotal, balance, conversionRate }) => @@ -41,13 +42,6 @@ jest.mock('../../../../store/actions', () => ({ showModal: jest.fn(), })); -jest.mock('../../../../ducks/send/send.duck', () => ({ - showGasButtonGroup: jest.fn(), - setGasPrice: jest.fn(), - setGasTotal: jest.fn(), - setGasLimit: jest.fn(), -})); - jest.mock('../../../../ducks/gas/gas.duck', () => ({ resetCustomData: jest.fn(), setCustomGasPrice: jest.fn(), @@ -77,36 +71,21 @@ describe('send-gas-row container', () => { }); }); - describe('setGasPrice()', () => { + describe('updateGasPrice()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasPrice({ - gasPrice: 'mockNewPrice', - gasLimit: 'mockLimit', - }); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasPrice).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasPrice('mockNewPrice'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasPrice).toHaveBeenCalled(); expect(setCustomGasPrice).toHaveBeenCalledWith('mockNewPrice'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockLimitmockNewPrice'); }); }); - describe('setGasLimit()', () => { + describe('updateGasLimit()', () => { it('should dispatch an action', () => { - mapDispatchToPropsObject.setGasLimit('mockNewLimit', 'mockPrice'); - expect(dispatchSpy.calledThrice).toStrictEqual(true); - expect(setGasLimit).toHaveBeenCalled(); + mapDispatchToPropsObject.updateGasLimit('mockNewLimit'); + expect(dispatchSpy.calledTwice).toStrictEqual(true); + expect(updateGasLimit).toHaveBeenCalled(); expect(setCustomGasLimit).toHaveBeenCalledWith('mockNewLimit'); - expect(setGasTotal).toHaveBeenCalled(); - expect(setGasTotal).toHaveBeenCalledWith('mockNewLimitmockPrice'); - }); - }); - - describe('showGasButtonGroup()', () => { - it('should dispatch an action', () => { - mapDispatchToPropsObject.showGasButtonGroup(); - expect(dispatchSpy.calledOnce).toStrictEqual(true); - expect(showGasButtonGroup).toHaveBeenCalled(); }); }); @@ -129,7 +108,7 @@ describe('send-gas-row container', () => { someOtherStateProp: 'baz', }; const dispatchProps = { - setGasPrice: sinon.spy(), + updateGasPrice: sinon.spy(), someOtherDispatchProp: sinon.spy(), }; const ownProps = { someOwnProp: 123 }; @@ -144,9 +123,11 @@ describe('send-gas-row container', () => { ).toStrictEqual('bar'); expect(result.someOwnProp).toStrictEqual(123); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(0); - result.gasPriceButtonGroupProps.handleGasPriceSelection(); - expect(dispatchProps.setGasPrice.callCount).toStrictEqual(1); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(0); + result.gasPriceButtonGroupProps.handleGasPriceSelection({ + gasPrice: undefined, + }); + expect(dispatchProps.updateGasPrice.callCount).toStrictEqual(1); expect(dispatchProps.someOtherDispatchProp.callCount).toStrictEqual(0); result.someOtherDispatchProp(); diff --git a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js index 080b97b37..bcb6530f8 100644 --- a/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js +++ b/ui/pages/send/send-content/send-hex-data-row/send-hex-data-row.component.js @@ -6,7 +6,6 @@ export default class SendHexDataRow extends Component { static propTypes = { inError: PropTypes.bool, updateSendHexData: PropTypes.func.isRequired, - updateGas: PropTypes.func.isRequired, }; static contextTypes = { @@ -14,10 +13,9 @@ export default class SendHexDataRow extends Component { }; onInput = (event) => { - const { updateSendHexData, updateGas } = this.props; + const { updateSendHexData } = this.props; const data = event.target.value.replace(/\n/gu, '') || null; updateSendHexData(data); - updateGas({ data }); }; render() { @@ -32,7 +30,7 @@ export default class SendHexDataRow extends Component { >