From 74fa6fa18703287dd3f2918ff94bfdbeb6e5442f Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 15 Sep 2021 10:59:51 -0500 Subject: [PATCH] Fix gas api overcalling (#12069) * Move gasEstimation calls onto edit-gas-popover instead of transaction list item * delete useCancelTransaction and useRetryTransaction hooks consolidate gasFee calls into cancel button component. * add tests * update component name * addressing feedback * fix failing e2e test * followup fix e2e tests * change useIncrementedGasFees to accept single transaction rather than transactionGroup as argument * remove unnecessary change to fixture * only ever pass primary transaction to useIncrementedGasFees * remove unnecessary optional chaining --- test/data/mock-pending-transaction-data.json | 234 +++++++++++++++ test/data/transaction-data.json | 266 ++++++++++++++++++ test/e2e/fixtures/send-edit/state.json | 17 ++ .../app/cancel-button/cancel-button.js | 64 +++++ ui/components/app/cancel-button/index.js | 1 + .../edit-gas-popover.component.js | 37 ++- ...transaction-list-item-details.component.js | 43 +-- .../transaction-list-item.component.js | 125 ++++---- .../transaction-list-item.component.test.js | 139 +++++++++ ui/hooks/useCancelTransaction.js | 55 ---- ui/hooks/useCancelTransaction.test.js | 114 -------- ui/hooks/useIncrementedGasFees.js | 20 +- ui/hooks/useRetryTransaction.js | 48 ---- ui/hooks/useRetryTransaction.test.js | 77 ----- 14 files changed, 823 insertions(+), 417 deletions(-) create mode 100644 test/data/mock-pending-transaction-data.json create mode 100644 ui/components/app/cancel-button/cancel-button.js create mode 100644 ui/components/app/cancel-button/index.js create mode 100644 ui/components/app/transaction-list-item/transaction-list-item.component.test.js delete mode 100644 ui/hooks/useCancelTransaction.js delete mode 100644 ui/hooks/useCancelTransaction.test.js delete mode 100644 ui/hooks/useRetryTransaction.js delete mode 100644 ui/hooks/useRetryTransaction.test.js diff --git a/test/data/mock-pending-transaction-data.json b/test/data/mock-pending-transaction-data.json new file mode 100644 index 000000000..46c4452f5 --- /dev/null +++ b/test/data/mock-pending-transaction-data.json @@ -0,0 +1,234 @@ +{ + "hasCancelled": false, + "hasRetried": false, + "initialTransaction": { + "id": 6854191329910881, + "time": 1631558469046, + "status": "approved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": false, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther", + "history": [ + { + "id": 6854191329910881, + "time": 1631558469046, + "status": "unapproved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": true, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther" + }, + [ + { + "op": "replace", + "path": "/loadingDefaults", + "value": false, + "note": "Added new unapproved transaction.", + "timestamp": 1631558469059 + }, + { + "op": "add", + "path": "/userFeeLevel", + "value": "medium" + } + ], + [ + { + "op": "add", + "path": "/estimatedBaseFee", + "value": "0", + "note": "confTx: user approved transaction", + "timestamp": 1631558472917 + } + ], + [ + { + "op": "replace", + "path": "/status", + "value": "approved", + "note": "txStateManager: setting status to approved", + "timestamp": 1631558472925 + } + ] + ], + "userFeeLevel": "medium", + "estimatedBaseFee": "0" + }, + "primaryTransaction": { + "id": 6854191329910881, + "time": 1631558469046, + "status": "approved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": false, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther", + "history": [ + { + "id": 6854191329910881, + "time": 1631558469046, + "status": "unapproved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": true, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther" + }, + [ + { + "op": "replace", + "path": "/loadingDefaults", + "value": false, + "note": "Added new unapproved transaction.", + "timestamp": 1631558469059 + }, + { + "op": "add", + "path": "/userFeeLevel", + "value": "medium" + } + ], + [ + { + "op": "add", + "path": "/estimatedBaseFee", + "value": "0", + "note": "confTx: user approved transaction", + "timestamp": 1631558472917 + } + ], + [ + { + "op": "replace", + "path": "/status", + "value": "approved", + "note": "txStateManager: setting status to approved", + "timestamp": 1631558472925 + } + ] + ], + "userFeeLevel": "medium", + "estimatedBaseFee": "0" + }, + "transactions": [ + { + "id": 6854191329910881, + "time": 1631558469046, + "status": "approved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": false, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther", + "history": [ + { + "id": 6854191329910881, + "time": 1631558469046, + "status": "unapproved", + "metamaskNetworkId": "42", + "chainId": "0x2a", + "loadingDefaults": true, + "dappSuggestedGasFees": null, + "txParams": { + "from": "0x0853dccd30e0582df80b16ec014092160b48e797", + "to": "0x8d09d17af2e20f51a9b598cb9edd01489a26ae27", + "value": "0x38d7ea4c68000", + "gas": "0x5208", + "maxFeePerGas": "0x47868c00", + "maxPriorityFeePerGas": "0x47868c00", + "type": "0x2" + }, + "origin": "metamask", + "type": "sentEther" + }, + [ + { + "op": "replace", + "path": "/loadingDefaults", + "value": false, + "note": "Added new unapproved transaction.", + "timestamp": 1631558469059 + }, + { + "op": "add", + "path": "/userFeeLevel", + "value": "medium" + } + ], + [ + { + "op": "add", + "path": "/estimatedBaseFee", + "value": "0", + "note": "confTx: user approved transaction", + "timestamp": 1631558472917 + } + ], + [ + { + "op": "replace", + "path": "/status", + "value": "approved", + "note": "txStateManager: setting status to approved", + "timestamp": 1631558472925 + } + ] + ], + "userFeeLevel": "medium", + "estimatedBaseFee": "0" + } + ] +} diff --git a/test/data/transaction-data.json b/test/data/transaction-data.json index 37e7659dd..740e522d1 100644 --- a/test/data/transaction-data.json +++ b/test/data/transaction-data.json @@ -61,6 +61,68 @@ "transactionIndex": "0" } }, + "transactions": [ + { + "id": 4243712234858512, + "time": 1589314601567, + "status": "confirmed", + "metamaskNetworkId": "4", + "loadingDefaults": false, + "txParams": { + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "to": "0xffe5bc4e8f1f969934d773fa67da095d2e491a97", + "nonce": "0xc", + "value": "0xde0b6b3a7640000", + "gas": "0x5208", + "gasPrice": "0x2540be400" + }, + "origin": "metamask", + "type": "sentEther", + "nonceDetails": { + "params": { + "highestLocallyConfirmed": 12, + "highestSuggested": 12, + "nextNetworkNonce": 12 + }, + "local": { + "name": "local", + "nonce": 12, + "details": { + "startPoint": 12, + "highest": 12 + } + }, + "network": { + "name": "network", + "nonce": 12, + "details": { + "blockNumber": "0x62d5dc", + "baseCount": 12 + } + } + }, + "r": "0xe0b79a8e33b15460ea79b05a5fb16bc067a796592eeb4edc5007c88615c12595", + "s": "0x1c834a25f1df07af5122996a40e99e554a40dc971a25041bc6e31638846c4f58", + "v": "0x2c", + "rawTx": "0xf86c0c8502540be40082520894ffe5bc4e8f1f969934d773fa67da095d2e491a97880de0b6b3a7640000802ca0e0b79a8e33b15460ea79b05a5fb16bc067a796592eeb4edc5007c88615c12595a01c834a25f1df07af5122996a40e99e554a40dc971a25041bc6e31638846c4f58", + "hash": "0x06bb79b856f5eb67025e4c4ffff44bca26ae135d1c3e6bd9a4193f422dcecca2", + "submittedTime": 1589314602908, + "txReceipt": { + "blockHash": "0xb9d2d71153b66146fde74b14b1c1ffc0588eb4a02ff464e32a4db9ae4bbfad8a", + "blockNumber": "62d5de", + "contractAddress": null, + "cumulativeGasUsed": "5208", + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "gasUsed": "5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xffe5bc4e8f1f969934d773fa67da095d2e491a97", + "transactionHash": "0x06bb79b856f5eb67025e4c4ffff44bca26ae135d1c3e6bd9a4193f422dcecca2", + "transactionIndex": "0" + } + } + ], "primaryTransaction": { "id": 4243712234858512, "time": 1589314601567, @@ -186,6 +248,68 @@ "transactionIndex": "0" } }, + "transactions": [ + { + "id": 4243712234858507, + "time": 1589314355872, + "status": "confirmed", + "metamaskNetworkId": "4", + "loadingDefaults": false, + "txParams": { + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "to": "0x0ccc8aeeaf5ce790f3b448325981a143fdef8848", + "nonce": "0xb", + "value": "0x1bc16d674ec80000", + "gas": "0x5208", + "gasPrice": "0x2540be400" + }, + "origin": "metamask", + "type": "sentEther", + "nonceDetails": { + "params": { + "highestLocallyConfirmed": 0, + "highestSuggested": 10, + "nextNetworkNonce": 10 + }, + "local": { + "name": "local", + "nonce": 11, + "details": { + "startPoint": 10, + "highest": 11 + } + }, + "network": { + "name": "network", + "nonce": 10, + "details": { + "blockNumber": "0x62d5cc", + "baseCount": 10 + } + } + }, + "r": "0xe6828baea0a93a52779ffa5ea55e927781fb7d4be58107a29c75d314d433d055", + "s": "0x10613f984c57b8928d8ed9fce16ddda5746767e5f68f4c8fc29542e86a61f458", + "v": "0x2b", + "rawTx": "0xf86c0b8502540be400825208940ccc8aeeaf5ce790f3b448325981a143fdef8848881bc16d674ec80000802ba0e6828baea0a93a52779ffa5ea55e927781fb7d4be58107a29c75d314d433d055a010613f984c57b8928d8ed9fce16ddda5746767e5f68f4c8fc29542e86a61f458", + "hash": "0x2ccb9e2c0c64399ebc5c4ac70bebc5b537248458dee6cbce32df4b50c9e73bbd", + "submittedTime": 1589314356907, + "txReceipt": { + "blockHash": "0xfa3c8b63aaba2ef64ab328af72811dd5110a7641bd435cc6fbdfd9ea0d334542", + "blockNumber": "62d5ce", + "contractAddress": null, + "cumulativeGasUsed": "5208", + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "gasUsed": "5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x0ccc8aeeaf5ce790f3b448325981a143fdef8848", + "transactionHash": "0x2ccb9e2c0c64399ebc5c4ac70bebc5b537248458dee6cbce32df4b50c9e73bbd", + "transactionIndex": "0" + } + } + ], "primaryTransaction": { "id": 4243712234858507, "time": 1589314355872, @@ -312,6 +436,69 @@ "transactionIndex": "1" } }, + "transactions": [ + { + "id": 4243712234858506, + "time": 1589314345433, + "status": "confirmed", + "metamaskNetworkId": "4", + "loadingDefaults": false, + "txParams": { + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "to": "0xffe5bc4e8f1f969934d773fa67da095d2e491a97", + "nonce": "0xa", + "value": "0x1bc16d674ec80000", + "gas": "0x5208", + "gasPrice": "0x306dc4200" + }, + "origin": "metamask", + "type": "sentEther", + "nonceDetails": { + "params": { + "highestLocallyConfirmed": 0, + "highestSuggested": 10, + "nextNetworkNonce": 10 + }, + "local": { + "name": "local", + "nonce": 10, + "details": { + "startPoint": 10, + "highest": 10 + } + }, + "network": { + "name": "network", + "nonce": 10, + "details": { + "blockNumber": "0x62d5cb", + "baseCount": 10 + } + } + }, + "r": "0x94b120a1df80be3dfad057b7ccac866b6b7583b63d61e5b021811c8b7ffc9a3b", + "s": "0x1778de08e29a4c8dfc3aa3e2c2338e98494ebd2c380c901d0dfba95126dde65f", + "v": "0x2c", + "rawTx": "0xf86c0a850306dc420082520894ffe5bc4e8f1f969934d773fa67da095d2e491a97881bc16d674ec80000802ca094b120a1df80be3dfad057b7ccac866b6b7583b63d61e5b021811c8b7ffc9a3ba01778de08e29a4c8dfc3aa3e2c2338e98494ebd2c380c901d0dfba95126dde65f", + "hash": "0x52604fd8d329894a747d8cf521cbbc4adb35eb9a91e3a3ba3ee32d8729c16536", + "submittedTime": 1589314348235, + "firstRetryBlockNumber": "0x62d5cc", + "txReceipt": { + "blockHash": "0x3d61a8d8a0e79e0e7a3a9206bf62f9a8e47791c527cd85cb4fcf800609234115", + "blockNumber": "62d5cd", + "contractAddress": null, + "cumulativeGasUsed": "a810", + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "gasUsed": "5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xffe5bc4e8f1f969934d773fa67da095d2e491a97", + "transactionHash": "0x52604fd8d329894a747d8cf521cbbc4adb35eb9a91e3a3ba3ee32d8729c16536", + "transactionIndex": "1" + } + } + ], "primaryTransaction": { "id": 4243712234858506, "time": 1589314345433, @@ -394,6 +581,25 @@ "hash": "0x5ca26d1cdcabef1ac2ad5b2b38604c9ced65d143efc7525f848c46f28e0e4116", "type": "incoming" }, + "transactions": [ + { + "blockNumber": "6477257", + "id": 4243712234858505, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1589314295000, + "txParams": { + "from": "0x31b98d14007bdee637298086988a0bbd31184523", + "gas": "0x5208", + "gasPrice": "0x3b9aca00", + "nonce": "0x56540", + "to": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "value": "0x1043561a882930000" + }, + "hash": "0x5ca26d1cdcabef1ac2ad5b2b38604c9ced65d143efc7525f848c46f28e0e4116", + "type": "incoming" + } + ], "primaryTransaction": { "blockNumber": "6477257", "id": 4243712234858505, @@ -432,6 +638,25 @@ "hash": "0xa42b2b433e5bd2616b52e30792aedb6a3c374a752a95d43d99e2a8b143937889", "type": "incoming" }, + "transactions": [ + { + "blockNumber": "6454493", + "id": 4243712234858475, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1588972833000, + "txParams": { + "from": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "gas": "0x5208", + "gasPrice": "0x24e160300", + "nonce": "0x8", + "to": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "value": "0x0" + }, + "hash": "0xa42b2b433e5bd2616b52e30792aedb6a3c374a752a95d43d99e2a8b143937889", + "type": "incoming" + } + ], "primaryTransaction": { "blockNumber": "6454493", "id": 4243712234858475, @@ -470,6 +695,25 @@ "hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a", "type": "incoming" }, + "transactions": [ + { + "blockNumber": "6195526", + "id": 4243712234858466, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1585087013000, + "txParams": { + "from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb", + "gas": "0x5208", + "gasPrice": "0x77359400", + "nonce": "0x3", + "to": "0x9eca64466f257793eaa52fcfff5066894b76a149", + "value": "0xde0b6b3a7640000" + }, + "hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a", + "type": "incoming" + } + ], "primaryTransaction": { "blockNumber": "6195526", "id": 4243712234858466, @@ -510,6 +754,28 @@ "sourceTokenSymbol": "ETH", "type": "swap" }, + "transactions": [ + { + "blockNumber": "6195527", + "id": 4243712234858467, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1585088013000, + "txParams": { + "from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb", + "gas": "0x5208", + "gasPrice": "0x77359400", + "nonce": "0x3", + "to": "0xabca64466f257793eaa52fcfff5066894b76a149", + "value": "0xde0b6b3a7640000" + }, + "hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a", + "type": "swap", + "destinationTokenSymbol": "ABC", + "destinationTokenAddress": "0xabca64466f257793eaa52fcfff5066894b76a149", + "sourceTokenSymbol": "ETH" + } + ], "primaryTransaction": { "blockNumber": "6195527", "id": 4243712234858467, diff --git a/test/e2e/fixtures/send-edit/state.json b/test/e2e/fixtures/send-edit/state.json index 6c9658c28..3070c06a6 100644 --- a/test/e2e/fixtures/send-edit/state.json +++ b/test/e2e/fixtures/send-edit/state.json @@ -130,6 +130,23 @@ "transactions": { "4046084157914634": { "chainId": "0x539", + "primaryTransaction": { + "chainId": "0x539", + "id": 4046084157914634, + "loadingDefaults": true, + "metamaskNetworkId": "1337", + "origin": "metamask", + "status": "unapproved", + "time": 1617228030067, + "txParams": { + "from": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1", + "gas": "0x61a8", + "gasPrice": "0x2540be400", + "to": "0x2f318C334780961FB129D2a6c30D0763d9a5C970", + "value": "0xde0b6b3a7640000" + }, + "type": "sentEther" + }, "history": [ { "chainId": "0x539", diff --git a/ui/components/app/cancel-button/cancel-button.js b/ui/components/app/cancel-button/cancel-button.js new file mode 100644 index 000000000..978af4076 --- /dev/null +++ b/ui/components/app/cancel-button/cancel-button.js @@ -0,0 +1,64 @@ +import { Tooltip } from '@material-ui/core'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import classnames from 'classnames'; +import Button from '../../ui/button'; +import { getMaximumGasTotalInHexWei } from '../../../../shared/modules/gas.utils'; +import { getConversionRate } from '../../../ducks/metamask/metamask'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useIncrementedGasFees } from '../../../hooks/useIncrementedGasFees'; +import { isBalanceSufficient } from '../../../pages/send/send.utils'; +import { getSelectedAccount } from '../../../selectors'; + +export default function CancelButton({ + cancelTransaction, + transaction, + detailsModal, +}) { + const t = useI18nContext(); + + const customCancelGasSettings = useIncrementedGasFees(transaction); + + const selectedAccount = useSelector(getSelectedAccount); + const conversionRate = useSelector(getConversionRate); + + const hasEnoughCancelGas = isBalanceSufficient({ + amount: '0x0', + gasTotal: getMaximumGasTotalInHexWei(customCancelGasSettings), + balance: selectedAccount.balance, + conversionRate, + }); + + const btn = ( + + ); + return hasEnoughCancelGas ? ( + btn + ) : ( + +
{btn}
+
+ ); +} + +CancelButton.propTypes = { + transaction: PropTypes.object, + cancelTransaction: PropTypes.func, + detailsModal: PropTypes.bool, +}; diff --git a/ui/components/app/cancel-button/index.js b/ui/components/app/cancel-button/index.js new file mode 100644 index 000000000..ae48b25c8 --- /dev/null +++ b/ui/components/app/cancel-button/index.js @@ -0,0 +1 @@ +export { default } from './cancel-button'; diff --git a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js index 9f0cbdb05..64280984c 100644 --- a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js +++ b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js @@ -28,6 +28,7 @@ import { } from '../../../store/actions'; import LoadingHeartBeat from '../../ui/loading-heartbeat'; import { checkNetworkAndAccountSupports1559 } from '../../../selectors'; +import { useIncrementedGasFees } from '../../../hooks/useIncrementedGasFees'; export default function EditGasPopover({ popoverTitle = '', @@ -62,6 +63,19 @@ export default function EditGasPopover({ ] = useState(false); const minimumGasLimitDec = hexToDecimal(minimumGasLimit); + const updatedCustomGasSettings = useIncrementedGasFees(transaction); + + let updatedTransaction = transaction; + if (mode === EDIT_GAS_MODES.SPEED_UP || mode === EDIT_GAS_MODES.CANCEL) { + updatedTransaction = { + ...transaction, + userFeeLevel: 'custom', + txParams: { + ...transaction.txParams, + ...updatedCustomGasSettings, + }, + }; + } const { maxPriorityFeePerGas, @@ -89,10 +103,15 @@ export default function EditGasPopover({ balanceError, estimatesUnavailableWarning, estimatedBaseFee, - } = useGasFeeInputs(defaultEstimateToUse, transaction, minimumGasLimit, mode); + } = useGasFeeInputs( + defaultEstimateToUse, + updatedTransaction, + minimumGasLimit, + mode, + ); const txParamsHaveBeenCustomized = - estimateToUse === 'custom' || txParamsAreDappSuggested(transaction); + estimateToUse === 'custom' || txParamsAreDappSuggested(updatedTransaction); /** * Temporary placeholder, this should be managed by the parent component but @@ -109,7 +128,7 @@ export default function EditGasPopover({ }, [onClose, dispatch]); const onSubmit = useCallback(() => { - if (!transaction || !mode) { + if (!updatedTransaction || !mode) { closePopover(); } @@ -128,14 +147,14 @@ export default function EditGasPopover({ gasPrice: decGWEIToHexWEI(gasPrice), }; - const cleanTransactionParams = { ...transaction.txParams }; + const cleanTransactionParams = { ...updatedTransaction.txParams }; if (networkAndAccountSupport1559) { delete cleanTransactionParams.gasPrice; } const updatedTxMeta = { - ...transaction, + ...updatedTransaction, userFeeLevel: estimateToUse || 'custom', txParams: { ...cleanTransactionParams, @@ -146,14 +165,14 @@ export default function EditGasPopover({ switch (mode) { case EDIT_GAS_MODES.CANCEL: dispatch( - createCancelTransaction(transaction.id, newGasSettings, { + createCancelTransaction(updatedTransaction.id, newGasSettings, { estimatedBaseFee, }), ); break; case EDIT_GAS_MODES.SPEED_UP: dispatch( - createSpeedUpTransaction(transaction.id, newGasSettings, { + createSpeedUpTransaction(updatedTransaction.id, newGasSettings, { estimatedBaseFee, }), ); @@ -174,7 +193,7 @@ export default function EditGasPopover({ closePopover(); }, [ - transaction, + updatedTransaction, mode, dispatch, closePopover, @@ -259,7 +278,7 @@ export default function EditGasPopover({ estimatedMaximumFiat={estimatedMaximumFiat} onEducationClick={() => setShowEducationContent(true)} mode={mode} - transaction={transaction} + transaction={updatedTransaction} gasErrors={gasErrors} gasWarnings={gasWarnings} onManualChange={onManualChange} diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 7d4bafb9d..1e0266573 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -9,6 +9,7 @@ import TransactionBreakdown from '../transaction-breakdown'; import Button from '../../ui/button'; import Tooltip from '../../ui/tooltip'; import Copy from '../../ui/icon/copy-icon.component'; +import CancelButton from '../cancel-button'; import Popover from '../../ui/popover'; import { SECOND } from '../../../../shared/constants/time'; import { TRANSACTION_TYPES } from '../../../../shared/constants/transaction'; @@ -32,7 +33,6 @@ export default class TransactionListItemDetails extends PureComponent { showSpeedUp: PropTypes.bool, showRetry: PropTypes.bool, isEarliestNonce: PropTypes.bool, - cancelDisabled: PropTypes.bool, primaryCurrency: PropTypes.string, transactionGroup: PropTypes.object, title: PropTypes.string.isRequired, @@ -114,38 +114,6 @@ export default class TransactionListItemDetails extends PureComponent { } } - renderCancel() { - const { t } = this.context; - const { showCancel, cancelDisabled } = this.props; - - if (!showCancel) { - return null; - } - - return cancelDisabled ? ( - -
- -
-
- ) : ( - - ); - } - render() { const { t } = this.context; const { justCopied } = this.state; @@ -163,6 +131,7 @@ export default class TransactionListItemDetails extends PureComponent { title, onClose, recipientNickname, + showCancel, } = this.props; const { primaryTransaction: transaction, @@ -185,7 +154,13 @@ export default class TransactionListItemDetails extends PureComponent { {t('speedUp')} )} - {this.renderCancel()} + {showCancel && ( + + )} { + event.stopPropagation(); + setShowRetryEditGasPopover(true); + speedUpMetricsEvent(); + }, + [speedUpMetricsEvent], + ); + + const cancelTransaction = useCallback( + (event) => { + event.stopPropagation(); + setShowCancelEditGasPopover(true); + cancelMetricsEvent(); + }, + [cancelMetricsEvent], + ); + const shouldShowSpeedUp = useShouldShowSpeedUp( transactionGroup, isEarliestNonce, @@ -90,37 +116,6 @@ export default function TransactionListItem({ setShowDetails((prev) => !prev); }, [isUnapproved, history, id]); - const cancelButton = useMemo(() => { - const btn = ( - - ); - if (hasCancelled || !isPending || isUnapproved) { - return null; - } - - return hasEnoughCancelGas ? ( - btn - ) : ( - -
{btn}
-
- ); - }, [ - isPending, - t, - isUnapproved, - hasEnoughCancelGas, - cancelTransaction, - hasCancelled, - ]); - const speedUpButton = useMemo(() => { if (!shouldShowSpeedUp || !isPending || isUnapproved) { return null; @@ -140,11 +135,13 @@ export default function TransactionListItem({ isUnapproved, t, isPending, - retryTransaction, hasCancelled, + retryTransaction, cancelTransaction, ]); + const showCancelButton = !hasCancelled && isPending && !isUnapproved; + return ( <>
{speedUpButton} - {cancelButton} + {showCancelButton && ( + + )}
{showDetails && ( @@ -211,35 +213,20 @@ export default function TransactionListItem({ isEarliestNonce={isEarliestNonce} onCancel={cancelTransaction} showCancel={isPending && !hasCancelled} - cancelDisabled={!hasEnoughCancelGas} /> )} {showRetryEditGasPopover && ( setShowRetryEditGasPopover(false)} mode={EDIT_GAS_MODES.SPEED_UP} - transaction={{ - ...transactionGroup.primaryTransaction, - userFeeLevel: 'custom', - txParams: { - ...transactionGroup.primaryTransaction?.txParams, - ...customRetryGasSettings, - }, - }} + transaction={transactionGroup.primaryTransaction} /> )} {showCancelEditGasPopover && ( setShowCancelEditGasPopover(false)} mode={EDIT_GAS_MODES.CANCEL} - transaction={{ - ...transactionGroup.primaryTransaction, - userFeeLevel: 'custom', - txParams: { - ...transactionGroup.primaryTransaction?.txParams, - ...customCancelGasSettings, - }, - }} + transaction={transactionGroup.primaryTransaction} /> )} diff --git a/ui/components/app/transaction-list-item/transaction-list-item.component.test.js b/ui/components/app/transaction-list-item/transaction-list-item.component.test.js new file mode 100644 index 000000000..0d7da539e --- /dev/null +++ b/ui/components/app/transaction-list-item/transaction-list-item.component.test.js @@ -0,0 +1,139 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { fireEvent } from '@testing-library/react'; +import transactionGroup from '../../../../test/data/mock-pending-transaction-data.json'; +import { + getConversionRate, + getSelectedAccount, + getTokenExchangeRates, + getPreferences, + getShouldShowFiat, +} from '../../../selectors'; +import { + renderWithProvider, + setBackgroundConnection, +} from '../../../../test/jest'; + +import { useGasFeeEstimates } from '../../../hooks/useGasFeeEstimates'; +import { GAS_ESTIMATE_TYPES } from '../../../../shared/constants/gas'; +import TransactionListItem from './transaction-list-item.component'; + +const FEE_MARKET_ESTIMATE_RETURN_VALUE = { + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates: { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', + }, + estimatedGasFeeTimeBounds: {}, +}; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + + return { + ...actual, + useSelector: jest.fn(), + useDispatch: jest.fn(), + }; +}); + +jest.mock('../../../hooks/useGasFeeEstimates', () => ({ + useGasFeeEstimates: jest.fn(), +})); + +setBackgroundConnection({ + getGasFeeTimeEstimate: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), +}); + +const generateUseSelectorRouter = (opts) => (selector) => { + if (selector === getConversionRate) { + return 1; + } else if (selector === getSelectedAccount) { + return { + balance: opts.balance ?? '2AA1EFB94E0000', + }; + } else if (selector === getTokenExchangeRates) { + return opts.tokenExchangeRates ?? {}; + } else if (selector === getPreferences) { + return ( + opts.preferences ?? { + useNativeCurrencyAsPrimaryCurrency: true, + } + ); + } else if (selector === getShouldShowFiat) { + return opts.shouldShowFiat ?? false; + } + return undefined; +}; + +describe('TransactionListItem', () => { + describe('when account has insufficient balance to cover gas', function () { + beforeAll(function () { + useGasFeeEstimates.mockImplementation( + () => FEE_MARKET_ESTIMATE_RETURN_VALUE, + ); + }); + + afterAll(function () { + useGasFeeEstimates.restore(); + }); + + it(`should indicate account has insufficient funds to cover gas price for cancellation of pending transaction`, function () { + useSelector.mockImplementation( + generateUseSelectorRouter({ + balance: '0x3', + }), + ); + const { queryByTestId } = renderWithProvider( + , + ); + expect(queryByTestId('not-enough-gas__tooltip')).toBeInTheDocument(); + }); + + it('should not disable "cancel" button when user has sufficient funds', function () { + useSelector.mockImplementation( + generateUseSelectorRouter({ + balance: '2AA1EFB94E0000', + }), + ); + const { queryByTestId } = renderWithProvider( + , + ); + expect(queryByTestId('not-enough-gas__tooltip')).not.toBeInTheDocument(); + }); + + it(`should open the edit gas popover when cancel is clicked`, function () { + useSelector.mockImplementation( + generateUseSelectorRouter({ + balance: '2AA1EFB94E0000', + }), + ); + const { getByText, queryByText } = renderWithProvider( + , + ); + expect(queryByText('Cancel transaction')).not.toBeInTheDocument(); + + const cancelButton = getByText('Cancel'); + fireEvent.click(cancelButton); + expect(getByText('Cancel transaction')).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/hooks/useCancelTransaction.js b/ui/hooks/useCancelTransaction.js deleted file mode 100644 index b62cff96a..000000000 --- a/ui/hooks/useCancelTransaction.js +++ /dev/null @@ -1,55 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useCallback, useState } from 'react'; -import { isBalanceSufficient } from '../pages/send/send.utils'; -import { getSelectedAccount } from '../selectors'; -import { getConversionRate } from '../ducks/metamask/metamask'; - -import { getMaximumGasTotalInHexWei } from '../../shared/modules/gas.utils'; -import { useIncrementedGasFees } from './useIncrementedGasFees'; - -/** - * Determine whether a transaction can be cancelled and provide a method to - * kick off the process of cancellation. - * - * Provides a reusable hook that, given a transactionGroup, will return - * whether or not the account has enough funds to cover the gas cancellation - * fee, and a method for beginning the cancellation process - * @param {Object} transactionGroup - * @return {[boolean, Function]} - */ -export function useCancelTransaction(transactionGroup) { - const { primaryTransaction } = transactionGroup; - - const customCancelGasSettings = useIncrementedGasFees(transactionGroup); - - const selectedAccount = useSelector(getSelectedAccount); - const conversionRate = useSelector(getConversionRate); - - const [showCancelEditGasPopover, setShowCancelEditGasPopover] = useState( - false, - ); - - const closeCancelEditGasPopover = () => setShowCancelEditGasPopover(false); - - const cancelTransaction = useCallback((event) => { - event.stopPropagation(); - return setShowCancelEditGasPopover(true); - }, []); - - const hasEnoughCancelGas = - primaryTransaction.txParams && - isBalanceSufficient({ - amount: '0x0', - gasTotal: getMaximumGasTotalInHexWei(customCancelGasSettings), - balance: selectedAccount.balance, - conversionRate, - }); - - return { - hasEnoughCancelGas, - customCancelGasSettings, - cancelTransaction, - showCancelEditGasPopover, - closeCancelEditGasPopover, - }; -} diff --git a/ui/hooks/useCancelTransaction.test.js b/ui/hooks/useCancelTransaction.test.js deleted file mode 100644 index fea8c9064..000000000 --- a/ui/hooks/useCancelTransaction.test.js +++ /dev/null @@ -1,114 +0,0 @@ -import * as reactRedux from 'react-redux'; -import { renderHook } from '@testing-library/react-hooks'; -import sinon from 'sinon'; -import transactions from '../../test/data/transaction-data.json'; -import { getConversionRate, getSelectedAccount } from '../selectors'; -import { increaseLastGasPrice } from '../helpers/utils/confirm-tx.util'; -import { useCancelTransaction } from './useCancelTransaction'; - -jest.mock('../store/actions', () => ({ - disconnectGasFeeEstimatePoller: jest.fn(), - getGasFeeEstimatesAndStartPolling: jest - .fn() - .mockImplementation(() => Promise.resolve()), - addPollingTokenToAppState: jest.fn(), -})); - -describe('useCancelTransaction', function () { - let useSelector; - const dispatch = sinon.spy(); - - beforeAll(function () { - sinon.stub(reactRedux, 'useDispatch').returns(dispatch); - }); - - afterEach(function () { - dispatch.resetHistory(); - }); - - afterAll(function () { - sinon.restore(); - }); - - describe('when account has insufficient balance to cover gas', function () { - beforeAll(function () { - useSelector = sinon.stub(reactRedux, 'useSelector'); - useSelector.callsFake((selector) => { - if (selector === getConversionRate) { - return 280.46; - } else if (selector === getSelectedAccount) { - return { - balance: '0x3', - }; - } - return undefined; - }); - }); - afterAll(function () { - useSelector.restore(); - }); - transactions.forEach((transactionGroup) => { - const originalGasPrice = - transactionGroup.primaryTransaction.txParams?.gasPrice; - const gasPrice = - originalGasPrice && increaseLastGasPrice(originalGasPrice); - const transactionId = transactionGroup.initialTransaction.id; - it(`should indicate account has insufficient funds to cover ${gasPrice} gas price`, function () { - const { result } = renderHook(() => - useCancelTransaction(transactionGroup), - ); - expect(result.current.hasEnoughCancelGas).toStrictEqual(false); - }); - it(`should return a function that kicks off cancellation for id ${transactionId}`, function () { - const { result } = renderHook(() => - useCancelTransaction(transactionGroup), - ); - expect(typeof result.current.cancelTransaction).toStrictEqual( - 'function', - ); - }); - }); - }); - - describe('when account has sufficient balance to cover gas', function () { - beforeAll(function () { - useSelector = sinon.stub(reactRedux, 'useSelector'); - useSelector.callsFake((selector) => { - if (selector === getConversionRate) { - return 280.46; - } else if (selector === getSelectedAccount) { - return { - balance: '0x9C2007651B2500000', - }; - } - return undefined; - }); - }); - - afterAll(function () { - useSelector.restore(); - }); - - transactions.forEach((transactionGroup) => { - const originalGasPrice = - transactionGroup.primaryTransaction.txParams?.gasPrice; - const gasPrice = - originalGasPrice && increaseLastGasPrice(originalGasPrice); - const transactionId = transactionGroup.initialTransaction.id; - it(`should indicate account has funds to cover ${gasPrice} gas price`, function () { - const { result } = renderHook(() => - useCancelTransaction(transactionGroup), - ); - expect(result.current.hasEnoughCancelGas).toStrictEqual(true); - }); - it(`should return a function that opens the gas popover onsubmit kicks off cancellation for id ${transactionId}`, function () { - const { result } = renderHook(() => - useCancelTransaction(transactionGroup), - ); - expect(typeof result.current.cancelTransaction).toStrictEqual( - 'function', - ); - }); - }); - }); -}); diff --git a/ui/hooks/useIncrementedGasFees.js b/ui/hooks/useIncrementedGasFees.js index b00043e35..6466d63e5 100644 --- a/ui/hooks/useIncrementedGasFees.js +++ b/ui/hooks/useIncrementedGasFees.js @@ -50,14 +50,12 @@ function getHighestIncrementedFee(originalFee, currentEstimate) { * discarded by the network to avoid DoS attacks. This hook returns an object * that either has gasPrice or maxFeePerGas/maxPriorityFeePerGas specified. In * addition the gasLimit will also be included. - * @param {} transactionGroup + * @param {} transaction * @returns {import( * '../../app/scripts/controllers/transactions' * ).CustomGasSettings} - Gas settings for cancellations/speed ups */ -export function useIncrementedGasFees(transactionGroup) { - const { primaryTransaction } = transactionGroup; - +export function useIncrementedGasFees(transaction) { const { gasFeeEstimates = {} } = useGasFeeEstimates(); // We memoize this value so that it can be relied upon in other hooks. @@ -68,8 +66,8 @@ export function useIncrementedGasFees(transactionGroup) { // do not have txParams. This is why we use optional chaining on the // txParams object in this hook. const temporaryGasSettings = { - gasLimit: primaryTransaction.txParams?.gas, - gas: primaryTransaction.txParams?.gas, + gasLimit: transaction.txParams?.gas, + gas: transaction.txParams?.gas, }; const suggestedMaxFeePerGas = @@ -77,10 +75,10 @@ export function useIncrementedGasFees(transactionGroup) { const suggestedMaxPriorityFeePerGas = gasFeeEstimates?.medium?.suggestedMaxPriorityFeePerGas ?? '0'; - if (isEIP1559Transaction(primaryTransaction)) { - const transactionMaxFeePerGas = primaryTransaction.txParams?.maxFeePerGas; + if (isEIP1559Transaction(transaction)) { + const transactionMaxFeePerGas = transaction.txParams?.maxFeePerGas; const transactionMaxPriorityFeePerGas = - primaryTransaction.txParams?.maxPriorityFeePerGas; + transaction.txParams?.maxPriorityFeePerGas; temporaryGasSettings.maxFeePerGas = transactionMaxFeePerGas === undefined || @@ -99,7 +97,7 @@ export function useIncrementedGasFees(transactionGroup) { suggestedMaxPriorityFeePerGas, ); } else { - const transactionGasPrice = primaryTransaction.txParams?.gasPrice; + const transactionGasPrice = transaction.txParams?.gasPrice; temporaryGasSettings.gasPrice = transactionGasPrice === undefined || transactionGasPrice.startsWith('-') ? '0x0' @@ -109,7 +107,7 @@ export function useIncrementedGasFees(transactionGroup) { ); } return temporaryGasSettings; - }, [primaryTransaction, gasFeeEstimates]); + }, [transaction, gasFeeEstimates]); return customGasSettings; } diff --git a/ui/hooks/useRetryTransaction.js b/ui/hooks/useRetryTransaction.js deleted file mode 100644 index e580a9da8..000000000 --- a/ui/hooks/useRetryTransaction.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useMetricEvent } from './useMetricEvent'; -import { useIncrementedGasFees } from './useIncrementedGasFees'; - -/** - * @typedef {Object} RetryTransactionReturnValue - * @property {(event: Event) => void} retryTransaction - open edit gas popover - * to begin setting retry gas fees - * @property {boolean} showRetryEditGasPopover - Whether to show the popover - * @property {() => void} closeRetryEditGasPopover - close the popover. - */ - -/** - * Provides a reusable hook that, given a transactionGroup, will return - * a method for beginning the retry process - * @param {Object} transactionGroup - the transaction group - * @return {RetryTransactionReturnValue} - */ - -export function useRetryTransaction(transactionGroup) { - const customRetryGasSettings = useIncrementedGasFees(transactionGroup); - const trackMetricsEvent = useMetricEvent({ - eventOpts: { - category: 'Navigation', - action: 'Activity Log', - name: 'Clicked "Speed Up"', - }, - }); - const [showRetryEditGasPopover, setShowRetryEditGasPopover] = useState(false); - - const closeRetryEditGasPopover = () => setShowRetryEditGasPopover(false); - - const retryTransaction = useCallback( - async (event) => { - event.stopPropagation(); - setShowRetryEditGasPopover(true); - trackMetricsEvent(); - }, - [trackMetricsEvent], - ); - - return { - retryTransaction, - showRetryEditGasPopover, - closeRetryEditGasPopover, - customRetryGasSettings, - }; -} diff --git a/ui/hooks/useRetryTransaction.test.js b/ui/hooks/useRetryTransaction.test.js deleted file mode 100644 index f2457ca1d..000000000 --- a/ui/hooks/useRetryTransaction.test.js +++ /dev/null @@ -1,77 +0,0 @@ -import * as reactRedux from 'react-redux'; -import { renderHook, act } from '@testing-library/react-hooks'; -import sinon from 'sinon'; -import transactions from '../../test/data/transaction-data.json'; -import { getIsMainnet } from '../selectors'; -import * as methodDataHook from './useMethodData'; -import * as metricEventHook from './useMetricEvent'; -import { useRetryTransaction } from './useRetryTransaction'; - -jest.mock('./useGasFeeEstimates', () => ({ - useGasFeeEstimates: jest.fn().mockImplementation(() => Promise.resolve({})), -})); - -describe('useRetryTransaction', () => { - describe('when transaction meets retry enabled criteria', () => { - let useSelector; - const dispatch = sinon.spy(() => Promise.resolve({ blockTime: 0 })); - const trackEvent = sinon.spy(); - const event = { - preventDefault: () => undefined, - stopPropagation: () => undefined, - }; - beforeAll(() => { - sinon.stub(reactRedux, 'useDispatch').returns(dispatch); - sinon.stub(methodDataHook, 'useMethodData').returns({}); - sinon.stub(metricEventHook, 'useMetricEvent').returns(trackEvent); - useSelector = sinon.stub(reactRedux, 'useSelector'); - useSelector.callsFake((selector) => { - if (selector === getIsMainnet) { - return true; - } - return undefined; - }); - }); - - afterEach(() => { - dispatch.resetHistory(); - trackEvent.resetHistory(); - }); - - afterAll(() => { - sinon.restore(); - }); - - const retryEnabledTransaction = { - ...transactions[0], - transactions: [ - { - submittedTime: new Date() - 5001, - }, - ], - hasRetried: false, - }; - - it('retryTransaction function should track metrics', () => { - const { result } = renderHook(() => - useRetryTransaction(retryEnabledTransaction, true), - ); - const { retryTransaction } = result.current; - act(() => { - retryTransaction(event); - }); - expect(trackEvent.calledOnce).toStrictEqual(true); - }); - - it('retryTransaction function should show retry popover', async () => { - const { result } = renderHook(() => - useRetryTransaction(retryEnabledTransaction, true), - ); - const { retryTransaction } = result.current; - await act(async () => { - await retryTransaction(event); - }); - expect(result.current.showRetryEditGasPopover).toStrictEqual(true); - }); - }); -});