From 249e03521327f8d7fb87fda5cc5564b8ba8ddd65 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Wed, 15 Jan 2025 20:45:32 +0100 Subject: [PATCH 01/11] fix: Fix `send` flow deeplinks (#12965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to fix deeplinks using `Send` flow. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12689 ## **Manual testing steps** 1. Open safari/chrome in the testing device - navigate https://metamask.github.io/metamask-deeplinks/ 2. Click "Payment Request" then "Eth Payment" 3. Fill the form - you dont need to put chain id 4. Click "Generate url" 5. Click generated url 6. See that it opens send flow page ## **Screenshots/Recordings** https://github.com/user-attachments/assets/ec6324b0-188f-4079-90ee-3d30b7e6056d ### **Before** ### **After** ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Views/confirmations/Send/index.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/components/Views/confirmations/Send/index.js b/app/components/Views/confirmations/Send/index.js index ad5fb240be57..906ef236b580 100644 --- a/app/components/Views/confirmations/Send/index.js +++ b/app/components/Views/confirmations/Send/index.js @@ -185,8 +185,8 @@ class Send extends PureComponent { * Resets gas and gasPrice of transaction */ async reset() { - const { transaction } = this.props; - const { gas, gasPrice } = await estimateGas(transaction); + const { networkClientId, transaction } = this.props; + const { gas, gasPrice } = await estimateGas(transaction, networkClientId); this.props.setTransactionObject({ gas: hexToBN(gas), gasPrice: hexToBN(gasPrice), @@ -320,8 +320,6 @@ class Send extends PureComponent { handleNewTxMeta = async ({ target_address, action, - chain_id = null, - function_name = null, // eslint-disable-line no-unused-vars parameters = null, }) => { const { addressBook, chainId, internalAccounts, selectedAddress } = @@ -408,7 +406,10 @@ class Send extends PureComponent { // if gas and gasPrice is not defined in the deeplink, we should define them if (!gas && !gasPrice) { - const { gas, gasPrice } = await estimateGas(this.props.transaction); + const { gas, gasPrice } = await estimateGas( + this.props.transaction, + this.props.networkClientId, + ); newTxMeta = { ...newTxMeta, gas, From 4dcc21f342480249cc5e1dc43bef9176c3c8fb3a Mon Sep 17 00:00:00 2001 From: Davide Brocchetto Date: Wed, 15 Jan 2025 12:32:23 -0800 Subject: [PATCH 02/11] test: Refactor e2e Swap test to use Tenderly Virtual TestNets (#11670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Improvements: - refactored the code to use Virtual TestNets instead of Forks on Tenderly network - switched to Tenderly corporate account as previously we were using my personal - added swap teston different networks: Polygon because of the high volume and Optimism as we specifically handle that network differently - added swap test on Mainnet after changing networks, source of bugs in the past - added swap test of unapproved tokens - added swap of ERC20->ERC20 - fixed token chart swap test flakiness - re-enabled tests on Bitrise Did the following runs on Bitrise and all passed: Smoke: https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/735ec2e0-176b-4b27-833b-b4b92a824f2f https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/b2808861-82a0-41d1-996d-9a1e7aa91624 https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/170c45e8-0a23-45a3-9f27-80e1f5a3e919 Regression: https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/41c57b42-ba59-4091-a774-590e767b6346 https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/a8abe55d-e9cd-4ab3-b891-ff98b4378952 https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/fe20c226-8a5e-475e-908e-7b41017c25e9 ## **Related issues** Fixes: ## **Manual testing steps** yarn test:e2e:ios:debug:run e2e/specs/swaps/swap-action-smoke.spec.js yarn test:e2e:ios:debug:run e2e/specs/swaps/swap-action-regression.spec.js yarn test:e2e:ios:debug:run e2e/specs/swaps/swap-token-chart.spec.js ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Base/StatusText.js | 3 +- .../__snapshots__/index.test.tsx.snap | 1 - bitrise.yml | 5 +- e2e/pages/Transactions/ActivitiesView.js | 44 +++++- e2e/pages/swaps/QuoteView.js | 4 +- e2e/pages/swaps/SwapView.js | 27 ++-- e2e/resources/networks.e2e.js | 42 ++++- e2e/selectors/Common.selectors.js | 2 +- .../Transactions/ActivitiesView.selectors.js | 1 + e2e/selectors/swaps/SwapsView.selectors.js | 3 +- .../quarantine/send-to-contact.failing.js | 77 +++++++++ e2e/specs/ramps/offramp.spec.js | 2 +- .../swaps/swap-action-regression.spec.js | 146 ++++++++++++------ e2e/specs/swaps/swap-action-smoke.spec.js | 146 ++++++++++++------ .../swap-token-chart.spec.js} | 85 +++++----- e2e/specs/swaps/token-details.spec.js | 13 +- e2e/tenderly.js | 27 ++++ 17 files changed, 454 insertions(+), 174 deletions(-) create mode 100644 e2e/specs/quarantine/send-to-contact.failing.js rename e2e/specs/{quarantine/swap-token-chart.failing.js => swaps/swap-token-chart.spec.js} (57%) create mode 100644 e2e/tenderly.js diff --git a/app/components/Base/StatusText.js b/app/components/Base/StatusText.js index 7f337970011c..dff6bae8d501 100644 --- a/app/components/Base/StatusText.js +++ b/app/components/Base/StatusText.js @@ -17,7 +17,7 @@ const styles = StyleSheet.create({ export const ConfirmedText = (props) => ( { const { colors } = useTheme(); return ( getSentUnitMessage(unit), SET_APPROVAL_FOR_ALL_METHOD: enContent.transactions.set_approval_for_all, SWAP: enContent.swaps.transaction_label.swap, + APPROVE: enContent.swaps.transaction_label.approve, TITLE: enContent.transactions_view.title, }; diff --git a/e2e/selectors/swaps/SwapsView.selectors.js b/e2e/selectors/swaps/SwapsView.selectors.js index 257643747fb3..d379e8c4a200 100644 --- a/e2e/selectors/swaps/SwapsView.selectors.js +++ b/e2e/selectors/swaps/SwapsView.selectors.js @@ -1,10 +1,9 @@ import enContent from '../../../locales/languages/en.json'; export const SwapsViewSelectors = { - SWIPE_TO_SWAP_BUTTON: 'swipe-to-swap-button', + SWAP_BUTTON: 'swap-button', QUOTE_SUMMARY: 'swap-quote-summary', GAS_FEE: 'swap-gas-fee', - SWAP_BUTTON: 'swap-button', }; export const SwapViewSelectorsTexts = { diff --git a/e2e/specs/quarantine/send-to-contact.failing.js b/e2e/specs/quarantine/send-to-contact.failing.js new file mode 100644 index 000000000000..8e5427cab2e4 --- /dev/null +++ b/e2e/specs/quarantine/send-to-contact.failing.js @@ -0,0 +1,77 @@ +'use strict'; + +import { SmokeConfirmations } from '../../tags'; + +import AmountView from '../../pages/Send/AmountView'; +import SendView from '../../pages/Send/SendView'; +import TransactionConfirmationView from '../../pages/Send/TransactionConfirmView'; +import { loginToApp } from '../../viewHelper'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; +import enContent from '../../../locales/languages/en.json'; +import FixtureBuilder from '../../fixtures/fixture-builder'; +import { + loadFixture, + startFixtureServer, + stopFixtureServer, +} from '../../fixtures/fixture-helper'; +import { CustomNetworks } from '../../resources/networks.e2e'; +import TestHelpers from '../../helpers'; +import FixtureServer from '../../fixtures/fixture-server'; +import { getFixturesServerPort } from '../../fixtures/utils'; +import Assertions from '../../utils/Assertions'; + +const fixtureServer = new FixtureServer(); + +describe(SmokeConfirmations('Send ETH'), () => { + const TOKEN_NAME = enContent.unit.eth; + const AMOUNT = '0.12345'; + + beforeEach(async () => { + await TestHelpers.reverseServerPort(); + const fixture = new FixtureBuilder() + .withNetworkController(CustomNetworks.Tenderly) + .withAddressBookController({ + addressBook: { + '0x1': { + '0x2f318C334780961FB129D2a6c30D0763d9a5C970': { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: 'Test Name 1', + }, + }, + }, + }) + .build(); + await startFixtureServer(fixtureServer); + await loadFixture(fixtureServer, { fixture }); + await device.launchApp({ + permissions: { notifications: 'YES' }, + launchArgs: { fixtureServerPort: `${getFixturesServerPort()}` }, + }); + await loginToApp(); + }); + + afterAll(async () => { + await stopFixtureServer(fixtureServer); + }); + + it('should send ETH to a contact from inside the wallet', async () => { + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapSendButton(); + await SendView.scrollToSavedAccount(); + + await SendView.tapAccountName('Test Name 1'); + + await SendView.tapNextButton(); + + await AmountView.typeInTransactionAmount(AMOUNT); + await AmountView.tapNextButton(); + await Assertions.checkIfTextIsDisplayed('Test Name 1'); + await TransactionConfirmationView.tapConfirmButton(); + await TabBarComponent.tapActivity(); + await Assertions.checkIfTextIsDisplayed(`${AMOUNT} ${TOKEN_NAME}`); + }); +}); diff --git a/e2e/specs/ramps/offramp.spec.js b/e2e/specs/ramps/offramp.spec.js index 027c6051f294..dab60443aba9 100644 --- a/e2e/specs/ramps/offramp.spec.js +++ b/e2e/specs/ramps/offramp.spec.js @@ -38,7 +38,7 @@ describe(SmokeRamps('Off-Ramp'), () => { beforeAll(async () => { await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); diff --git a/e2e/specs/swaps/swap-action-regression.spec.js b/e2e/specs/swaps/swap-action-regression.spec.js index 4f70e048cb6d..86b0dd32e64e 100644 --- a/e2e/specs/swaps/swap-action-regression.spec.js +++ b/e2e/specs/swaps/swap-action-regression.spec.js @@ -1,11 +1,10 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; -import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; import SwapView from '../../pages/swaps/SwapView'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; import WalletView from '../../pages/wallet/WalletView'; import FixtureBuilder from '../../fixtures/fixture-builder'; @@ -15,20 +14,38 @@ import { stopFixtureServer, } from '../../fixtures/fixture-helper'; import { CustomNetworks } from '../../resources/networks.e2e'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; -import { SmokeSwaps } from '../../tags'; +import { Regression } from '../../tags'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; + +import Tenderly from '../../tenderly'; const fixtureServer = new FixtureServer(); +const firstElement = 0; + +describe(Regression('Multiple Swaps from Actions'), () => { + let educationModalTapped = false; + let currentNetwork = CustomNetworks.Tenderly.Mainnet.providerConfig.nickname; + const wallet = ethers.Wallet.createRandom(); -describe(SmokeSwaps('Multiple Swaps from Actions'), () => { - let swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. beforeAll(async () => { + jest.setTimeout(2500000); + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); + await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -43,43 +60,65 @@ describe(SmokeSwaps('Multiple Swaps from Actions'), () => { await stopFixtureServer(fixtureServer); }); - beforeEach(async () => { - jest.setTimeout(150000); + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); }); + it.each` - quantity | sourceTokenSymbol | destTokenSymbol - ${'1'} | ${'ETH'} | ${'WETH'} - ${'1'} | ${'WETH'} | ${'ETH'} + type | quantity | sourceTokenSymbol | destTokenSymbol | network + ${'native'} | ${'.03'} | ${'ETH'} | ${'DAI'} | ${CustomNetworks.Tenderly.Mainnet} + ${'unapproved'} | ${'3'} | ${'DAI'} | ${'USDC'} | ${CustomNetworks.Tenderly.Mainnet} + ${'erc20'} | ${'10'} | ${'DAI'} | ${'ETH'} | ${CustomNetworks.Tenderly.Mainnet} `( - "should Swap $quantity '$sourceTokenSymbol' to '$destTokenSymbol'", - async ({ quantity, sourceTokenSymbol, destTokenSymbol }) => { + "should swap $type token '$sourceTokenSymbol' to '$destTokenSymbol' on '$network.providerConfig.nickname'", + async ({ type, quantity, sourceTokenSymbol, destTokenSymbol, network }) => { await TabBarComponent.tapWallet(); + + if (network.providerConfig.nickname !== currentNetwork) { + await WalletView.tapNetworksButtonOnNavBar(); + await Assertions.checkIfToggleIsOn(NetworkListModal.testNetToggle); + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, + ); + await NetworkEducationModal.tapGotItButton(); + await TestHelpers.delay(3000); + currentNetwork = network.providerConfig.nickname; + } + await Assertions.checkIfVisible(WalletView.container); await TabBarComponent.tapActions(); await WalletActionsBottomSheet.tapSwapButton(); - if (!swapOnboarded) { - await Onboarding.tapStartSwapping(); - swapOnboarded = true; - } await Assertions.checkIfVisible(QuoteView.getQuotes); - //Select source token, if ETH then can skip because already selected - if (sourceTokenSymbol !== 'ETH') { + //Select source token, if native token can skip because already selected + if (type !== 'native' && type !== 'wrap') { await QuoteView.tapOnSelectSourceToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); + await QuoteView.selectToken(sourceTokenSymbol); } await QuoteView.enterSwapAmount(quantity); //Select destination token await QuoteView.tapOnSelectDestToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(destTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(destTokenSymbol); + if (destTokenSymbol !== 'ETH') { + await QuoteView.tapSearchToken(); + await QuoteView.typeSearchToken(destTokenSymbol); + await TestHelpers.delay(2000); + await QuoteView.selectToken(destTokenSymbol); + } else await QuoteView.selectToken(destTokenSymbol, firstElement); //Make sure slippage is zero for wrapped tokens if (sourceTokenSymbol === 'WETH' || destTokenSymbol === 'WETH') { @@ -89,50 +128,59 @@ describe(SmokeSwaps('Multiple Swaps from Actions'), () => { ); } await QuoteView.tapOnGetQuotes(); - await Assertions.checkIfVisible(SwapView.fetchingQuotes); await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await SwapView.tapSwapButton(); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), - ); - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.firstTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + // Check the token approval completed + if (type === 'unapproved') { + await Assertions.checkIfVisible( + ActivitiesView.tokenApprovalActivity(sourceTokenSymbol), ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.secondTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); } - await Assertions.checkIfVisible(DetailsBottomSheet.title); - await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + // TODO: The following hack is needed to update the token balance until bug is fixed + await TabBarComponent.tapWallet(); + await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.changeNetworkTo('Localhost', false); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + } + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + educationModalTapped = true; + } }, ); }); diff --git a/e2e/specs/swaps/swap-action-smoke.spec.js b/e2e/specs/swaps/swap-action-smoke.spec.js index 7fbb29baba47..db50f431aa70 100644 --- a/e2e/specs/swaps/swap-action-smoke.spec.js +++ b/e2e/specs/swaps/swap-action-smoke.spec.js @@ -1,33 +1,49 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; -import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; import SwapView from '../../pages/swaps/SwapView'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import WalletView from '../../pages/wallet/WalletView'; import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; import FixtureBuilder from '../../fixtures/fixture-builder'; +import Tenderly from '../../tenderly'; import { loadFixture, startFixtureServer, stopFixtureServer, } from '../../fixtures/fixture-helper'; import { CustomNetworks } from '../../resources/networks.e2e'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; import { SmokeSwaps } from '../../tags'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; const fixtureServer = new FixtureServer(); +const firstElement = 0; describe(SmokeSwaps('Swap from Actions'), () => { - let swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. + let educationModalTapped = false; + let currentNetwork = CustomNetworks.Tenderly.Mainnet.providerConfig.nickname; + const wallet = ethers.Wallet.createRandom(); + beforeAll(async () => { + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); + await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -43,42 +59,66 @@ describe(SmokeSwaps('Swap from Actions'), () => { }); beforeEach(async () => { - jest.setTimeout(150000); + jest.setTimeout(120000); + }); + + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); }); it.each` - quantity | sourceTokenSymbol | destTokenSymbol - ${'.05'} | ${'ETH'} | ${'USDT'} - ${'100'} | ${'USDT'} | ${'ETH'} + type | quantity | sourceTokenSymbol | destTokenSymbol | network + ${'wrap'} | ${'.03'} | ${'ETH'} | ${'WETH'} | ${CustomNetworks.Tenderly.Mainnet} + ${'unwrap'} | ${'.01'} | ${'WETH'} | ${'ETH'} | ${CustomNetworks.Tenderly.Mainnet} `( - "should Swap $quantity '$sourceTokenSymbol' to '$destTokenSymbol'", - async ({ quantity, sourceTokenSymbol, destTokenSymbol }) => { + "should swap $type token '$sourceTokenSymbol' to '$destTokenSymbol' on '$network.providerConfig.nickname'", + async ({ type, quantity, sourceTokenSymbol, destTokenSymbol, network }) => { await TabBarComponent.tapWallet(); - await TabBarComponent.tapActions(); - await WalletActionsBottomSheet.tapSwapButton(); - if (!swapOnboarded) { - await Onboarding.tapStartSwapping(); - swapOnboarded = true; + if (network.providerConfig.nickname !== currentNetwork) { + await WalletView.tapNetworksButtonOnNavBar(); + await Assertions.checkIfToggleIsOn(NetworkListModal.testNetToggle); + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, + ); + await NetworkEducationModal.tapGotItButton(); + await TestHelpers.delay(3000); + currentNetwork = network.providerConfig.nickname; } + + await Assertions.checkIfVisible(WalletView.container); + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapSwapButton(); await Assertions.checkIfVisible(QuoteView.getQuotes); - //Select source token, if ETH then can skip because already selected - if (sourceTokenSymbol !== 'ETH') { + //Select source token, if native tiken can skip because already selected + if (type !== 'native' && type !== 'wrap') { await QuoteView.tapOnSelectSourceToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); + await QuoteView.selectToken(sourceTokenSymbol); } await QuoteView.enterSwapAmount(quantity); //Select destination token await QuoteView.tapOnSelectDestToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(destTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(destTokenSymbol); + if (destTokenSymbol !== 'ETH') { + await QuoteView.tapSearchToken(); + await QuoteView.typeSearchToken(destTokenSymbol); + await TestHelpers.delay(2000); + await QuoteView.selectToken(destTokenSymbol); + } else await QuoteView.selectToken(destTokenSymbol, firstElement); //Make sure slippage is zero for wrapped tokens if (sourceTokenSymbol === 'WETH' || destTokenSymbol === 'WETH') { @@ -92,44 +132,58 @@ describe(SmokeSwaps('Swap from Actions'), () => { await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await Assertions.checkIfVisible(SwapView.swapButton); + await TestHelpers.delay(2000); + await SwapView.tapSwapButton(); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), - ); - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.firstTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, + // Check the token approval completed + if (type === 'unapproved') { + await Assertions.checkIfVisible( + ActivitiesView.tokenApprovalActivity(sourceTokenSymbol), ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); + // TODO: Commenting this out until Tenderly issue is resolved + //await Assertions.checkIfElementToHaveText(ActivitiesView.secondTransactionStatus, ActivitiesViewSelectorsText.CONFIRM_TEXT, 60000); + } + + // TODO: The following hack is needed to update the token balance until bug is fixed + await TabBarComponent.tapWallet(); + await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.changeNetworkTo('Localhost', false); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); } - await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + await NetworkListModal.changeNetworkTo( + network.providerConfig.nickname, + false, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); + if (!educationModalTapped) { + await NetworkEducationModal.tapGotItButton(); + educationModalTapped = true; + } }, ); }); diff --git a/e2e/specs/quarantine/swap-token-chart.failing.js b/e2e/specs/swaps/swap-token-chart.spec.js similarity index 57% rename from e2e/specs/quarantine/swap-token-chart.failing.js rename to e2e/specs/swaps/swap-token-chart.spec.js index 707df3d608ba..c3ed9183b9ef 100644 --- a/e2e/specs/quarantine/swap-token-chart.failing.js +++ b/e2e/specs/swaps/swap-token-chart.spec.js @@ -1,4 +1,5 @@ 'use strict'; +import { ethers } from 'ethers'; import { loginToApp } from '../../viewHelper'; import Onboarding from '../../pages/swaps/OnBoarding'; import QuoteView from '../../pages/swaps/QuoteView'; @@ -17,20 +18,29 @@ import TestHelpers from '../../helpers'; import FixtureServer from '../../fixtures/fixture-server'; import { getFixturesServerPort } from '../../fixtures/utils'; import { Regression } from '../../tags'; +import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; +import ImportAccountView from '../../pages/importAccount/ImportAccountView'; +import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; import Assertions from '../../utils/Assertions'; +import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import DetailsBottomSheet from '../../pages/Transactions/TransactionDetailsModal'; +import { ActivitiesViewSelectorsText } from '../../selectors/Transactions/ActivitiesView.selectors'; +import Tenderly from '../../tenderly'; const fixtureServer = new FixtureServer(); -const sourceTokenSymbol = 'USDT'; -const destTokenSymbol = 'DAI'; describe(Regression('Swap from Token view'), () => { const swapOnboarded = true; // TODO: Set it to false once we show the onboarding page again. + const wallet = ethers.Wallet.createRandom(); + beforeAll(async () => { + await Tenderly.addFunds( + CustomNetworks.Tenderly.Mainnet.providerConfig.rpcUrl, + wallet.address, + ); await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly) + .withNetworkController(CustomNetworks.Tenderly.Mainnet) .build(); await startFixtureServer(fixtureServer); await loadFixture(fixtureServer, { fixture }); @@ -49,7 +59,22 @@ describe(Regression('Swap from Token view'), () => { jest.setTimeout(150000); }); - it('should complete a USDT to ETH swap from the token chart', async () => { + it('should be able to import account', async () => { + await WalletView.tapIdenticon(); + await Assertions.checkIfVisible(AccountListBottomSheet.accountList); + await AccountListBottomSheet.tapAddAccountButton(); + await AddAccountBottomSheet.tapImportAccount(); + await Assertions.checkIfVisible(ImportAccountView.container); + await ImportAccountView.enterPrivateKey(wallet.privateKey); + await Assertions.checkIfVisible(SuccessImportAccountView.container); + await SuccessImportAccountView.tapCloseButton(); + await AccountListBottomSheet.swipeToDismissAccountsModal(); + await Assertions.checkIfVisible(WalletView.container); + }); + + it('should complete a USDC to DAI swap from the token chart', async () => { + const sourceTokenSymbol = 'ETH', + destTokenSymbol = 'DAI'; await TabBarComponent.tapWallet(); await Assertions.checkIfVisible(WalletView.container); await WalletView.tapOnToken('Ethereum'); @@ -58,12 +83,7 @@ describe(Regression('Swap from Token view'), () => { await TokenOverview.tapSwapButton(); if (!swapOnboarded) await Onboarding.tapStartSwapping(); await Assertions.checkIfVisible(QuoteView.getQuotes); - await QuoteView.tapOnSelectSourceToken(); - await QuoteView.tapSearchToken(); - await QuoteView.typeSearchToken(sourceTokenSymbol); - await TestHelpers.delay(1000); - await QuoteView.selectToken(sourceTokenSymbol); - await QuoteView.enterSwapAmount('10'); + await QuoteView.enterSwapAmount('.5'); await QuoteView.tapOnSelectDestToken(); await QuoteView.tapSearchToken(); await QuoteView.typeSearchToken(destTokenSymbol); @@ -74,43 +94,34 @@ describe(Regression('Swap from Token view'), () => { await Assertions.checkIfVisible(SwapView.quoteSummary); await Assertions.checkIfVisible(SwapView.gasFee); await SwapView.tapIUnderstandPriceWarning(); - await SwapView.swipeToSwap(); + await SwapView.tapSwapButton(); + await TestHelpers.delay(2000); + //Wait for Swap to complete try { - await Assertions.checkIfVisible( - SwapView.swapCompleteLabel(sourceTokenSymbol, destTokenSymbol), - 100000, + await Assertions.checkIfTextIsDisplayed( + SwapView.generateSwapCompleteLabel( + sourceTokenSymbol, + destTokenSymbol, + ), + 30000, ); } catch (e) { // eslint-disable-next-line no-console - console.log(`Toast message is slow to appear or did not appear: ${e}`); + console.log(`Swap complete didn't pop up: ${e}`); } await device.enableSynchronization(); - await TestHelpers.delay(5000); - await TokenOverview.tapBackButton(); + await TestHelpers.delay(10000); + + // Check the swap activity completed await TabBarComponent.tapActivity(); await Assertions.checkIfVisible(ActivitiesView.title); await Assertions.checkIfVisible( - ActivitiesView.swapActivity(sourceTokenSymbol, destTokenSymbol), + ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); - await ActivitiesView.tapOnSwapActivity(sourceTokenSymbol, destTokenSymbol); - - try { - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } catch (e) { - await ActivitiesView.tapOnSwapActivity( - sourceTokenSymbol, - destTokenSymbol, - ); - await Assertions.checkIfVisible(DetailsBottomSheet.title); - } - - await Assertions.checkIfVisible(DetailsBottomSheet.title); await Assertions.checkIfElementToHaveText( - DetailsBottomSheet.title, - DetailsBottomSheet.generateExpectedTitle(sourceTokenSymbol, destTokenSymbol), + ActivitiesView.firstTransactionStatus, + ActivitiesViewSelectorsText.CONFIRM_TEXT, + 60000, ); - await Assertions.checkIfVisible(DetailsBottomSheet.statusConfirmed); - await DetailsBottomSheet.tapOnCloseIcon(); - await Assertions.checkIfNotVisible(DetailsBottomSheet.title); }); }); diff --git a/e2e/specs/swaps/token-details.spec.js b/e2e/specs/swaps/token-details.spec.js index c40493153fc0..4c7407e6a5f9 100644 --- a/e2e/specs/swaps/token-details.spec.js +++ b/e2e/specs/swaps/token-details.spec.js @@ -6,10 +6,9 @@ import { importWalletWithRecoveryPhrase, switchToSepoliaNetwork, } from '../../viewHelper'; -import { CustomNetworks } from '../../resources/networks.e2e'; import Assertions from '../../utils/Assertions'; import CommonView from '../../pages/CommonView'; -import TestHelpers from '../../helpers' +import TestHelpers from '../../helpers'; describe(SmokeSwaps('Token Chart Tests'), () => { beforeAll(async () => { @@ -23,7 +22,10 @@ describe(SmokeSwaps('Token Chart Tests'), () => { it('should view the token chart', async () => { await WalletView.tapOnToken(); - await Assertions.checkIfElementNotToHaveText(TokenOverview.tokenPrice, '$0'); + await Assertions.checkIfElementNotToHaveText( + TokenOverview.tokenPrice, + '$0', + ); await TokenOverview.tapChartPeriod1d(); await Assertions.checkIfVisible(TokenOverview.chartPeriod1d); @@ -46,9 +48,10 @@ describe(SmokeSwaps('Token Chart Tests'), () => { }); it('should not display the chart when using Sepolia test network', async () => { + const sepoliaTokenSymbol = 'S'; await switchToSepoliaNetwork(); - await WalletView.tapOnToken(CustomNetworks.Sepolia.providerConfig.ticker); - await Assertions.checkIfNotVisible(TokenOverview.noChartData); + await WalletView.tapOnToken(sepoliaTokenSymbol); + await Assertions.checkIfVisible(TokenOverview.noChartData, 60000); await Assertions.checkIfElementToHaveText(TokenOverview.tokenPrice, '$0'); }); }); diff --git a/e2e/tenderly.js b/e2e/tenderly.js new file mode 100644 index 000000000000..3212112001b3 --- /dev/null +++ b/e2e/tenderly.js @@ -0,0 +1,27 @@ +import axios from 'axios'; + +export default class Tenderly { + + static async addFunds(rpcURL, account, amount = '0xDE0B6B3A764000000') { + const data = { + jsonrpc: '2.0', + method: 'tenderly_setBalance', + params: [[account], amount], + id: '1234', + }; + + const response = await axios.post(rpcURL, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.error) { + // eslint-disable-next-line no-console + console.log( + `ERROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, + ); + return null; + } + } +} From 77438ce98244021bb711b5e06765f168afa420d7 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 15 Jan 2025 15:40:43 -0600 Subject: [PATCH 03/11] chore: reorganize stake button tests (#13017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This moves the stake button tests from Tokens test to StakeButton.test.tsx for relevant code ownership. This also adds a test to navigate to stake screen when on unsupported network. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../StakeButton/StakeButton.test.tsx | 79 ++++++++++++++-- app/components/UI/Tokens/index.test.tsx | 93 ------------------- 2 files changed, 70 insertions(+), 102 deletions(-) diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 2d4374e5c4bc..2aada3df5b42 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -8,6 +8,8 @@ import { MOCK_STAKED_ETH_ASSET } from '../../__mocks__/mockData'; import { useMetrics } from '../../../../hooks/useMetrics'; import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; import { mockNetworkState } from '../../../../../util/test/network'; +import AppConstants from '../../../../../core/AppConstants'; +import useStakingEligibility from '../../hooks/useStakingEligibility'; const mockNavigate = jest.fn(); @@ -40,6 +42,7 @@ jest.mock('../../../../hooks/useMetrics'); jest.mock('../../../../../core/Engine', () => ({ context: { NetworkController: { + setActiveNetwork: jest.fn(() => Promise.resolve()), getNetworkClientById: () => ({ configuration: { chainId: '0x1', @@ -55,14 +58,21 @@ jest.mock('../../../../../core/Engine', () => ({ jest.mock('../../hooks/useStakingEligibility', () => ({ __esModule: true, - default: () => ({ + default: jest.fn(() => ({ isEligible: true, - loading: false, - error: null, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValueOnce({ isEligible: true }), - }), + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest.fn().mockResolvedValue({ + isEligible: true, + }), + error: false, + })), +})); + +jest.mock('../../hooks/useStakingChain', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isStakingSupportedChain: true, + })), })); const STATE_MOCK = { @@ -77,9 +87,9 @@ const STATE_MOCK = { }, }; -const renderComponent = () => +const renderComponent = (state = STATE_MOCK) => renderWithProvider(, { - state: STATE_MOCK, + state, }); describe('StakeButton', () => { @@ -92,11 +102,62 @@ describe('StakeButton', () => { expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); }); + it('navigates to Web view when stake button is pressed and user is not eligible', async () => { + (useStakingEligibility as jest.Mock).mockReturnValue({ + isEligible: false, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest + .fn() + .mockResolvedValue({ isEligible: false }), + error: false, + }); + const { getByTestId } = renderComponent(); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + params: { + newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, + timestamp: expect.any(Number), + }, + screen: Routes.BROWSER.VIEW, + }); + }); + }); + it('navigates to Stake Input screen when stake button is pressed and user is eligible', async () => { + (useStakingEligibility as jest.Mock).mockReturnValue({ + isEligible: true, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest + .fn() + .mockResolvedValue({ isEligible: true }), + error: false, + }); const { getByTestId } = renderComponent(); fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + screen: Routes.STAKING.STAKE, + }); + }); + }); + it('navigates to Stake Input screen when on unsupported network', async () => { + const UNSUPPORTED_NETWORK_STATE = { + engine: { + backgroundState: { + NetworkController: { + ...mockNetworkState({ + chainId: '0x89', // Polygon + }), + }, + }, + }, + }; + const { getByTestId } = renderComponent(UNSUPPORTED_NETWORK_STATE); + fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 838143c7b21b..a68c483475ca 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -8,12 +8,9 @@ import { createStackNavigator } from '@react-navigation/stack'; import { getAssetTestId } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; import { backgroundState } from '../../../util/test/initial-root-state'; import { strings } from '../../../../locales/i18n'; -import AppConstants from '../../../../app/core/AppConstants'; -import Routes from '../../../../app/constants/navigation/Routes'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import Engine from '../../../core/Engine'; import { createTokensBottomSheetNavDetails } from './TokensBottomSheet'; -import useStakingEligibility from '../Stake/hooks/useStakingEligibility'; // eslint-disable-next-line import/no-namespace import * as networks from '../../../util/networks'; // eslint-disable-next-line import/no-namespace @@ -414,54 +411,6 @@ describe('Tokens', () => { ).toBeDefined(); }); - it('renders stake button correctly', () => { - const { getByTestId } = renderComponent(initialState); - - expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); - }); - - it('navigates to Web view when stake button is pressed and user is not eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: false, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: false }), - error: false, - }); - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - params: { - newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, - timestamp: 123, - }, - screen: Routes.BROWSER.VIEW, - }); - }); - }); - - it('navigates to Stake Input screen when stake button is pressed and user is eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: true, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: true }), - error: false, - }); - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - it('should refresh tokens and call necessary controllers', async () => { const { getByTestId } = renderComponent(initialState); @@ -513,48 +462,6 @@ describe('Tokens', () => { }); }); - it('navigates to Stake Input screen only when eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: true, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: true }), - error: false, - }); - - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - - it('does not navigate to Stake Input screen if not eligible', async () => { - (useStakingEligibility as jest.Mock).mockReturnValue({ - isEligible: false, - isLoadingEligibility: false, - refreshPooledStakingEligibility: jest - .fn() - .mockResolvedValue({ isEligible: false }), - error: false, - }); - - const { getByTestId } = renderComponent(initialState); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockNavigate).not.toHaveBeenCalledWith('StakeScreens', { - screen: Routes.STAKING.STAKE, - }); - }); - }); - it('calls onRefresh and updates state', async () => { const { getByTestId } = renderComponent(initialState); From 2c8e3cbc1b2dc6996b93c69ddb944aa3bf9d929d Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:55:56 -0800 Subject: [PATCH 04/11] feat: Create Permit Simulation (#12606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Creates Permit Simulation UI to be shown on V3 and V4 signTypedData confirmations. Signature Decoding logic to be added following this PR. ### Add - create ButtonPill component - create PermitSimulation component - add to TypedSignV3V4 component - create constants/signatures.ts file - create hooks/useGetTokenStandardAndDetails files - create hooks/useTrackERC20WithoutDecimalInformation.ts file - create utils/signature.ts file - create utils/token.ts file ### Update - add style param to UI/Name component - add noBorder param to Box component - add TokenStandard enum - add handleCloseOnBackdropClick param to confirmation BottomModal component - add skipCharacterInEnd param to shortenString common util. This mimics the same logic in extension ### Designs Simulations MVP https://www.figma.com/design/8DrinrQI4hs76Grm2F34xK/Simulations-MVP?node-id=1283-18713&node-type=canvas&t=FbRsIUjxMDxC3Get-0 Confirmations Redesign V5 https://www.figma.com/design/wcXUl6AH5KNFwKdAIv49kh/Confirmation-redesign-V5?node-id=4157-7776&node-type=canvas&m=dev ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12432 Related: https://github.com/MetaMask/metamask-mobile/pull/12994 (Signature Decoding Simulation - follow-up PR) ###: Follow-up - Related: https://github.com/MetaMask/metamask-mobile/issues/12910 (Create storybook page + unit tests for ButtonPill) - Related: https://github.com/MetaMask/metamask-mobile/issues/13013 (Do not use Ramp/Box. Use View instead) - Related: https://github.com/MetaMask/metamask-mobile/issues/13016 (Move shortenString helper location) ## **Manual testing steps** 1. Test Permit test-dapp button ## **Screenshots/Recordings** ### **Before** ### **After** ![CleanShot 2025-01-15 at 12 41 45](https://github.com/user-attachments/assets/76549a51-7254-41b1-8ab9-44cceedaefd7) ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../Buttons/ButtonPill/ButtonPill.styles.ts | 49 +++++ .../Buttons/ButtonPill/ButtonPill.test.tsx | 15 ++ .../Buttons/ButtonPill/ButtonPill.tsx | 69 ++++++ .../__snapshots__/ButtonPill.test.tsx.snap | 24 ++ .../Buttons/ButtonPill/index.ts | 1 + app/components/UI/Name/Name.tsx | 12 +- app/components/UI/Name/Name.types.ts | 3 +- .../UI/Name/__snapshots__/Name.test.tsx.snap | 75 ++++--- .../__snapshots__/BuildQuote.test.tsx.snap | 14 ++ .../__snapshots__/OrderDetails.test.tsx.snap | 9 + .../PaymentMethods.test.tsx.snap | 18 ++ .../Quotes/__snapshots__/Quotes.test.tsx.snap | 8 + .../__snapshots__/Regions.test.tsx.snap | 9 + app/components/UI/Ramp/components/Box.tsx | 6 + app/components/UI/SimulationDetails/types.ts | 11 + .../Views/confirmations/Confirm/Confirm.tsx | 2 +- .../TypedSignPermit/TypedSignPermit.test.tsx | 27 +++ .../TypedSignPermit/TypedSignPermit.tsx | 123 +++++++++++ .../Simulation/TypedSignPermit/index.ts | 1 + .../ValueDisplay/ValueDisplay.styles.ts | 89 ++++++++ .../ValueDisplay/ValueDisplay.test.tsx | 162 ++++++++++++++ .../components/ValueDisplay/ValueDisplay.tsx | 208 ++++++++++++++++++ .../components/ValueDisplay/index.ts | 1 + .../Info/TypedSignV3V4/TypedSignV3V4.tsx | 12 +- .../components/UI/BottomModal/BottomModal.tsx | 9 +- .../__snapshots__/Address.test.tsx.snap | 25 ++- .../confirmations/constants/signatures.ts | 36 +++ .../hooks/useGetTokenStandardAndDetails.ts | 50 +++++ .../useTrackERC20WithoutDecimalInformation.ts | 50 +++++ .../confirmations/utils/signature.test.ts | 72 ++++++ .../Views/confirmations/utils/signature.ts | 55 +++++ .../Views/confirmations/utils/token.ts | 99 +++++++++ app/util/address/index.ts | 2 + app/util/notifications/methods/common.test.ts | 28 +++ app/util/notifications/methods/common.ts | 23 +- app/util/test/confirm-data-helpers.ts | 61 ++++- locales/languages/en.json | 5 +- 37 files changed, 1398 insertions(+), 65 deletions(-) create mode 100644 app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts create mode 100644 app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx create mode 100644 app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx create mode 100644 app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap create mode 100644 app/component-library/components-temp/Buttons/ButtonPill/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts create mode 100644 app/components/Views/confirmations/constants/signatures.ts create mode 100644 app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts create mode 100644 app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts create mode 100644 app/components/Views/confirmations/utils/signature.test.ts create mode 100644 app/components/Views/confirmations/utils/signature.ts create mode 100644 app/components/Views/confirmations/utils/token.ts diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts new file mode 100644 index 000000000000..a5d937a948c4 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.styles.ts @@ -0,0 +1,49 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../../util/theme/models'; + +/** + * Style sheet input parameters. + */ +export interface ButtonPillStyleSheetVars { + isDisabled: boolean; + isPressed: boolean; +} + +/** + * Style sheet function for ButtonPill component + * + * @param params Style sheet params + * @param params.theme Theme object + * @param params.vars Arbitrary inputs this style sheet depends on + * @returns StyleSheet object + */ +const styleSheet = (params: { + theme: Theme; + vars: ButtonPillStyleSheetVars; +}) => { + const { + theme: { colors }, + vars: { isDisabled, isPressed } + } = params; + + return StyleSheet.create({ + base: { + backgroundColor: colors.background.alternative, + color: colors.text.default, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 99, + opacity: isDisabled ? 0.5 : 1, + ...(isPressed && { + backgroundColor: colors.background.alternativePressed, + }), + }, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx new file mode 100644 index 000000000000..41933943f636 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.test.tsx @@ -0,0 +1,15 @@ +// Third party dependencies. +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// Internal dependencies. +import ButtonPill from './ButtonPill'; + +describe('ButtonPill', () => { + it('should render correctly', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx new file mode 100644 index 000000000000..2b158cdce6df --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/ButtonPill.tsx @@ -0,0 +1,69 @@ +// Third party dependencies. +import React, { useCallback, useState } from 'react'; +import { GestureResponderEvent, TouchableOpacity, TouchableOpacityProps } from 'react-native'; + +// External dependencies. +import { useStyles } from '../../../hooks'; + +// Internal dependencies. +import stylesheet from './ButtonPill.styles'; + +/** + * ButtonPill component props. + */ +export interface ButtonPillProps extends TouchableOpacityProps { + /** + * Optional param to disable the button. + */ + isDisabled?: boolean; +} + +const ButtonPill = ({ + onPress, + onPressIn, + onPressOut, + style, + isDisabled = false, + children, + ...props +}: ButtonPillProps) => { + const [isPressed, setIsPressed] = useState(false); + const { styles } = useStyles(stylesheet, { + style, + isPressed, + isDisabled, + }); + + const triggerOnPressedIn = useCallback( + (e: GestureResponderEvent) => { + setIsPressed(true); + onPressIn?.(e); + }, + [setIsPressed, onPressIn], + ); + + const triggerOnPressedOut = useCallback( + (e: GestureResponderEvent) => { + setIsPressed(false); + onPressOut?.(e); + }, + [setIsPressed, onPressOut], + ); + + return ( + + {children} + + ); +}; + +export default ButtonPill; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap b/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap new file mode 100644 index 000000000000..c3e3b781a54c --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/__snapshots__/ButtonPill.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ButtonPill should render correctly 1`] = ` + +`; diff --git a/app/component-library/components-temp/Buttons/ButtonPill/index.ts b/app/component-library/components-temp/Buttons/ButtonPill/index.ts new file mode 100644 index 000000000000..d983c349bc52 --- /dev/null +++ b/app/component-library/components-temp/Buttons/ButtonPill/index.ts @@ -0,0 +1 @@ +export { default } from './ButtonPill'; diff --git a/app/components/UI/Name/Name.tsx b/app/components/UI/Name/Name.tsx index 9c554d8759ae..bddd22806d4c 100644 --- a/app/components/UI/Name/Name.tsx +++ b/app/components/UI/Name/Name.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TextProps, View } from 'react-native'; +import { TextProps, View, ViewStyle } from 'react-native'; import { useStyles } from '../../../component-library/hooks'; import Text, { @@ -34,11 +34,12 @@ const NameLabel: React.FC<{ ); }; -const UnknownEthereumAddress: React.FC<{ address: string }> = ({ address }) => { +const UnknownEthereumAddress: React.FC<{ address: string, style?: ViewStyle }> = ({ address, style }) => { const displayNameVariant = DisplayNameVariant.Unknown; const { styles } = useStyles(styleSheet, { displayNameVariant }); + return ( - + {renderShortAddress(address, 5)} @@ -52,6 +53,7 @@ const Name: React.FC = ({ type, value, variation, + style, }) => { if (type !== NameType.EthereumAddress) { throw new Error('Unsupported NameType: ' + type); @@ -69,11 +71,11 @@ const Name: React.FC = ({ }); if (variant === DisplayNameVariant.Unknown) { - return ; + return ; } return ( - + @@ -2793,6 +2794,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "padding": 0, }, undefined, + undefined, ] } > @@ -4192,6 +4194,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, + undefined, ] } > @@ -4381,6 +4384,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, + undefined, ] } > @@ -5780,6 +5784,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, + undefined, ] } > @@ -5969,6 +5974,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, + undefined, ] } > @@ -7368,6 +7374,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, + undefined, ] } > @@ -7557,6 +7564,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, + undefined, ] } > @@ -8503,6 +8511,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -8833,6 +8842,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -9127,6 +9137,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -11609,6 +11620,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -11939,6 +11951,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -12081,6 +12094,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index 6ab703e40dac..88387a8f98cd 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -585,6 +585,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2082,6 +2083,7 @@ exports[`OrderDetails renders a completed order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3593,6 +3595,7 @@ exports[`OrderDetails renders a created order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -5020,6 +5023,7 @@ exports[`OrderDetails renders a failed order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6531,6 +6535,7 @@ exports[`OrderDetails renders a pending order 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -9065,6 +9070,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -10603,6 +10609,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -12134,6 +12141,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription "padding": 0, }, undefined, + undefined, ] } > @@ -13608,6 +13616,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap index 2e25a8d696df..e5a9d3b334fe 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap +++ b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap @@ -495,6 +495,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -780,6 +781,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1082,6 +1084,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1942,6 +1945,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2227,6 +2231,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -2529,6 +2534,7 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3385,6 +3391,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3634,6 +3641,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3885,6 +3893,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6582,6 +6591,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6831,6 +6841,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -7082,6 +7093,7 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -7811,6 +7823,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -8096,6 +8109,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -8398,6 +8412,7 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, + undefined, ] } > @@ -9949,6 +9964,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > @@ -10234,6 +10250,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > @@ -10536,6 +10553,7 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 3ad1e4f304e8..97f6fd0365aa 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -35,6 +35,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -276,6 +277,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -414,6 +416,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3543,6 +3546,7 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -3815,6 +3819,7 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -5018,6 +5023,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > @@ -5290,6 +5296,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > @@ -5589,6 +5596,7 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap index d181f8b85429..cffaf29508dc 100644 --- a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap @@ -566,6 +566,7 @@ exports[`Regions View renders correctly 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -1345,6 +1346,7 @@ exports[`Regions View renders correctly while loading 1`] = ` undefined, undefined, undefined, + undefined, ] } > @@ -2573,6 +2575,7 @@ exports[`Regions View renders correctly with no data 1`] = ` undefined, undefined, undefined, + undefined, ] } > @@ -3822,6 +3825,7 @@ exports[`Regions View renders correctly with selectedRegion 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -4620,6 +4624,7 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -4944,6 +4949,7 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` undefined, undefined, undefined, + undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -5793,6 +5799,7 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` "padding": 0, }, undefined, + undefined, ] } > @@ -6117,6 +6124,7 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` undefined, undefined, undefined, + undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -6966,6 +6974,7 @@ exports[`Regions View renders regions modal when pressing select button 1`] = ` "padding": 0, }, undefined, + undefined, ] } > diff --git a/app/components/UI/Ramp/components/Box.tsx b/app/components/UI/Ramp/components/Box.tsx index 41974701101b..54eb1b9cd0ef 100644 --- a/app/components/UI/Ramp/components/Box.tsx +++ b/app/components/UI/Ramp/components/Box.tsx @@ -21,6 +21,9 @@ const createStyles = (colors: Colors) => label: { marginVertical: 8, }, + noBorder: { + borderWidth: 0, + }, highlighted: { borderColor: colors.primary.default, }, @@ -38,6 +41,7 @@ interface Props { style?: StyleProp; thin?: boolean; activeOpacity?: number; + noBorder?: boolean; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any onPress?: () => any; @@ -56,6 +60,7 @@ const Box: React.FC = ({ accessible, accessibilityLabel, compact, + noBorder, ...props }: Props) => { const { colors } = useTheme(); @@ -77,6 +82,7 @@ const Box: React.FC = ({ thin && styles.thin, highlighted && styles.highlighted, compact && styles.compact, + noBorder && styles.noBorder, style, ]} {...props} diff --git a/app/components/UI/SimulationDetails/types.ts b/app/components/UI/SimulationDetails/types.ts index 934687454727..d409c22ebf47 100644 --- a/app/components/UI/SimulationDetails/types.ts +++ b/app/components/UI/SimulationDetails/types.ts @@ -39,6 +39,17 @@ export type AssetIdentifier = Readonly< NativeAssetIdentifier | TokenAssetIdentifier >; +export enum TokenStandard { + /** A token that conforms to the ERC20 standard. */ + ERC20 = 'ERC20', + /** A token that conforms to the ERC721 standard. */ + ERC721 = 'ERC721', + /** A token that conforms to the ERC1155 standard. */ + ERC1155 = 'ERC1155', + /** Not a token, but rather the base asset of the selected chain. */ + none = 'NONE', +} + /** * Describes a change in an asset's balance to a user's wallet. */ diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index 8149098a80a0..a13ec947e8d0 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -20,7 +20,7 @@ const Confirm = () => { } return ( - + diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx new file mode 100644 index 000000000000..2b814f9629b5 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; +import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; +import PermitSimulation from './TypedSignPermit'; + +jest.mock('../../../../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: () => 'mainnet', + }, + }, +})); + +describe('PermitSimulation', () => { + it('should render correctly for personal sign', async () => { + const { getByText } = renderWithProvider(<PermitSimulation />, { + state: typedSignV4ConfirmationState, + }); + + expect(getByText('Estimated changes')).toBeDefined(); + expect(getByText('You’re giving the spender permission to spend this many tokens from your account.')).toBeDefined(); + expect(getByText('Spending cap')).toBeDefined(); + expect(getByText('3,000')).toBeDefined(); + expect(getByText('0xCcCCc...ccccC')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx new file mode 100644 index 000000000000..bb326cb14634 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Hex } from '@metamask/utils'; + +import { strings } from '../../../../../../../../../../locales/i18n'; +import { useStyles } from '../../../../../../../../../component-library/hooks'; +import Engine from '../../../../../../../../../core/Engine'; +import { safeToChecksumAddress } from '../../../../../../../../../util/address'; +import { PrimaryType } from '../../../../../../constants/signatures'; +import { useSignatureRequest } from '../../../../../../hooks/useSignatureRequest'; +import { parseTypedDataMessage } from '../../../../../../utils/signature'; +import InfoRow from '../../../../../UI/InfoRow'; +import InfoSection from '../../../../../UI/InfoRow/InfoSection'; +import PermitSimulationValueDisplay from '../components/ValueDisplay'; + +const styleSheet = () => + StyleSheet.create({ + permitValues: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + }); + +function extractTokenDetailsByPrimaryType( + message: Record<string, unknown>, + primaryType: PrimaryType, +): object[] | unknown { + let tokenDetails; + + switch (primaryType) { + case PrimaryType.PermitBatch: + case PrimaryType.PermitSingle: + tokenDetails = message?.details; + break; + case PrimaryType.PermitBatchTransferFrom: + case PrimaryType.PermitTransferFrom: + tokenDetails = message?.permitted; + break; + default: + break; + } + + const isNonArrayObject = tokenDetails && !Array.isArray(tokenDetails); + return isNonArrayObject ? [tokenDetails] : tokenDetails; +} + +const PermitSimulation = () => { + const { NetworkController } = Engine.context; + const { styles } = useStyles(styleSheet, {}); + + const signatureRequest = useSignatureRequest(); + + const chainId = signatureRequest?.chainId as Hex; + const msgData = signatureRequest?.messageParams?.data; + + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + if (!msgData) { + return null; + } + + const { + domain: { verifyingContract }, + message, + message: { tokenId }, + primaryType, + } = parseTypedDataMessage(msgData as string); + + const tokenDetails = extractTokenDetailsByPrimaryType(message, primaryType); + + const isNFT = tokenId !== undefined; + const labelChangeType = isNFT + ? strings('confirm.simulation.label_change_type_permit_nft') + : strings('confirm.simulation.label_change_type_permit'); + + return ( + <InfoSection> + <InfoRow + label={strings('confirm.simulation.title')} + tooltip={strings('confirm.simulation.tooltip')} + > + {strings('confirm.simulation.info_permit')} + </InfoRow> + + <InfoRow label={labelChangeType}> + {Array.isArray(tokenDetails) ? ( + <View style={styles.permitValues}> + {tokenDetails.map( + ( + { token, amount }: { token: string; amount: string }, + i: number, + ) => ( + <PermitSimulationValueDisplay + key={`${token}-${i}`} + labelChangeType={labelChangeType} + networkClientId={networkClientId} + primaryType={primaryType} + tokenContract={safeToChecksumAddress(token)} + value={amount} + chainId={chainId} + /> + ), + )} + </View> + ) : ( + <PermitSimulationValueDisplay + labelChangeType={labelChangeType} + networkClientId={networkClientId} + tokenContract={verifyingContract} + value={message.value} + tokenId={message.tokenId} + chainId={chainId} + /> + )} + </InfoRow> + </InfoSection> + ); +}; + +export default PermitSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts new file mode 100644 index 000000000000..2dd1a7b5f597 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/index.ts @@ -0,0 +1 @@ +export { default } from './TypedSignPermit'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts new file mode 100644 index 000000000000..6414e76e0bad --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts @@ -0,0 +1,89 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '@metamask/design-tokens'; +import { fontStyles, colors as importedColors } from '../../../../../../../../../../styles/common'; + +const styleSheet = (colors: Theme['colors']) => + StyleSheet.create({ + wrapper: { + marginLeft: 'auto', + maxWidth: '100%', + alignSelf: 'flex-end', + justifyContent: 'flex-end', + borderWidth: 0, + padding: 0, + }, + + flexRowTokenValueAndAddress: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + borderColor: importedColors.transparent, + borderWidth: 0, + padding: 0, + }, + tokenAddress: { + marginStart: 4, + }, + tokenValueTooltipContent: { + borderRadius: 12, + paddingHorizontal: 8, + paddingTop: 4, + paddingBottom: 4, + textAlign: 'center', + }, + valueAndAddress: { + paddingVertical: 4, + paddingLeft: 8, + paddingRight: 8, + gap: 5, + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + }, + valueIsCredit: { + backgroundColor: colors.success.muted, + color: colors.success.default, + }, + valueIsDebit: { + backgroundColor: colors.error.muted, + color: colors.error.default, + }, + valueModal: { + backgroundColor: colors.background.alternative, + paddingTop: 24, + paddingBottom: 34, + paddingHorizontal: 16, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + valueModalHeader: { + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + paddingBottom: 16, + position: 'relative', + textAlign: 'center', + width: '100%', + }, + valueModalHeaderIcon: { + position: 'absolute', + top: 0, + left: 0, + }, + valueModalHeaderText: { + color: colors.text.default, + ...fontStyles.bold, + fontSize: 14, + fontWeight: '700', + textAlign: 'center', + width: '100%', + // height of header icon + minHeight: 24, + }, + valueModalText: { + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx new file mode 100644 index 000000000000..8decf038ea1c --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx @@ -0,0 +1,162 @@ + +import React from 'react'; +import { act } from '@testing-library/react-native'; +import SimulationValueDisplay from './ValueDisplay'; + +import { memoizedGetTokenStandardAndDetails } from '../../../../../../../utils/token'; +import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; +import { TokenStandard } from '../../../../../../../../../UI/SimulationDetails/types'; +import { getTokenDetails } from '../../../../../../../../../../util/address'; +import { backgroundState } from '../../../../../../../../../../util/test/initial-root-state'; +import renderWithProvider from '../../../../../../../../../../util/test/renderWithProvider'; +import { useMetrics } from '../../../../../../../../../hooks/useMetrics'; +import { MetricsEventBuilder } from '../../../../../../../../../../core/Analytics/MetricsEventBuilder'; + +const mockInitialState = { + engine: { + backgroundState, + }, +}; + +const mockTrackEvent = jest.fn(); + +jest.mock('../../../../../../../../../hooks/useMetrics'); +jest.mock('../../../../../../../hooks/useGetTokenStandardAndDetails'); + + +jest.mock('../../../../../../../../../../util/address', () => ({ + getTokenDetails: jest.fn(), + renderShortAddress: jest.requireActual('../../../../../../../../../../util/address').renderShortAddress +})); + +describe('SimulationValueDisplay', () => { + beforeEach(() => { + (useMetrics as jest.MockedFn<typeof useMetrics>).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: MetricsEventBuilder.createEventBuilder, + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + getDeleteRegulationId: jest.fn(), + isDataRecorded: jest.fn(), + isEnabled: jest.fn(), + getMetaMetricsId: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('renders component correctly', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + const { findByText } = renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(await findByText('0.432')).toBeDefined(); + }); + + it('should invoke method to track missing decimal information for ERC20 tokens only once', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: undefined, + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('should not invoke method to track missing decimal information for ERC20 tokens', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, + }); + + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + describe('when token is an ERC721 token', () => { + beforeEach(() => { + jest.mocked(getTokenDetails).mockResolvedValue({ + name: 'TST', + symbol: 'TST', + standard: TokenStandard.ERC721, + }); + }); + + it('should not invoke method to track missing decimal information', async () => { + renderWithProvider( + <SimulationValueDisplay + labelChangeType={'Withdraw'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + tokenId={'1234'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx new file mode 100644 index 000000000000..e59dcf7d43e5 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -0,0 +1,208 @@ +import React, { useMemo, useState } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { NetworkClientId } from '@metamask/network-controller'; +import { Hex } from '@metamask/utils'; + +import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; +import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; +import ButtonIcon from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon'; +import { IconName , IconColor } from '../../../../../../../../../../component-library/components/Icons/Icon'; +import Text from '../../../../../../../../../../component-library/components/Texts/Text'; + +import { IndividualFiatDisplay } from '../../../../../../../../../UI/SimulationDetails/FiatDisplay/FiatDisplay'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../../../../../UI/SimulationDetails/formatAmount'; + +import Box from '../../../../../../../../../UI/Ramp/components/Box'; +import Address from '../../../../../../UI/InfoRow/InfoValue/Address/Address'; + +import { selectContractExchangeRates } from '../../../../../../../../../../selectors/tokenRatesController'; + +import Logger from '../../../../../../../../../../util/Logger'; +import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { useTheme } from '../../../../../../../../../../util/theme'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; + +import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; +import useTrackERC20WithoutDecimalInformation from '../../../../../../../hooks/useTrackERC20WithoutDecimalInformation'; +import { TokenDetailsERC20 } from '../../../../../../../utils/token'; +import BottomModal from '../../../../../../UI/BottomModal'; + +import styleSheet from './ValueDisplay.styles'; + +interface SimulationValueDisplayParams { + /** ID of the associated chain. */ + chainId: Hex; + + /** Change type to be displayed in value tooltip */ + labelChangeType: string; + + /** The network client ID */ + networkClientId?: NetworkClientId; + + /** + * The ethereum token contract address. It is expected to be in hex format. + * We currently accept strings since we have a patch that accepts a custom string + * {@see .yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch} + */ + tokenContract: Hex | string | undefined; + + // Optional + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; + + /** The primaryType of the typed sign message */ + primaryType?: string; + + /** The tokenId for NFT */ + tokenId?: string; + + /** The token amount */ + value?: number | string; +} + +const SimulationValueDisplay: React.FC< + SimulationValueDisplayParams +> = ({ + chainId, + labelChangeType, + networkClientId, + primaryType, + tokenContract, + tokenId, + value, + credit, + debit, +}) => { + const [hasValueModalOpen, setHasValueModalOpen] = useState(false); + + const { colors } = useTheme(); + const styles = styleSheet(colors); + + const contractExchangeRates = useSelector(selectContractExchangeRates); + const exchangeRate = + tokenContract && contractExchangeRates + ? contractExchangeRates[tokenContract as `0x${string}`]?.price + : undefined; + + const tokenDetails = useGetTokenStandardAndDetails(tokenContract, networkClientId); + const { decimalsNumber: tokenDecimals } = tokenDetails; + + useTrackERC20WithoutDecimalInformation( + chainId, + tokenContract, + tokenDetails as TokenDetailsERC20, + ); + + const fiatValue = useMemo(() => { + if (exchangeRate && value && !tokenId) { + const tokenAmount = calcTokenAmount(value, tokenDecimals); + return tokenAmount.multipliedBy(exchangeRate).toNumber(); + } + return undefined; + }, [exchangeRate, tokenDecimals, tokenId, value]); + + const { tokenValue, tokenValueMaxPrecision } = useMemo(() => { + if (!value || tokenId) { + return { tokenValue: null, tokenValueMaxPrecision: null }; + } + + const tokenAmount = calcTokenAmount(value, tokenDecimals); + + return { + tokenValue: formatAmount('en-US', tokenAmount), + tokenValueMaxPrecision: formatAmountMaxPrecision('en-US', tokenAmount), + }; + }, [tokenDecimals, tokenId, value]); + + /** Temporary error capturing as we are building out Permit Simulations */ + if (!tokenContract) { + Logger.error( + new Error( + `SimulationValueDisplay: Token contract address is missing where primaryType === ${primaryType}`, + ), + ); + return null; + } + + function handlePressTokenValue() { + setHasValueModalOpen(true); + } + + return ( + <Box style={styles.wrapper}> + <Box style={styles.flexRowTokenValueAndAddress}> + <View style={styles.valueAndAddress}> + <ButtonPill + onPress={handlePressTokenValue} + onPressIn={handlePressTokenValue} + onPressOut={handlePressTokenValue} + style={[credit && styles.valueIsCredit, debit && styles.valueIsDebit]} + > + <Text> + {credit && '+ '} + {debit && '- '} + {tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + {tokenId && `#${tokenId}`} + </Text> + </ButtonPill> + <Box compact noBorder style={styles.tokenAddress}> + <Address address={tokenContract} chainId={chainId} /> + </Box> + </View> + </Box> + <Box compact noBorder> + {/* + TODO - add fiat shorten prop after tooltip logic has been updated + {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} + */} + {fiatValue && <IndividualFiatDisplay fiatAmount={fiatValue} /* shorten*/ />} + </Box> + {hasValueModalOpen && ( + /** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ + <BottomModal onClose={() => setHasValueModalOpen(false)}> + <TouchableOpacity + activeOpacity={1} + onPress={() => setHasValueModalOpen(false)} + > + <View style={styles.valueModal} > + <View style={styles.valueModalHeader}> + <ButtonIcon + iconColor={IconColor.Default} + size={ButtonIconSizes.Sm} + style={styles.valueModalHeaderIcon} + onPress={() => setHasValueModalOpen(false)} + iconName={IconName.ArrowLeft} + /> + <Text style={styles.valueModalHeaderText}> + {labelChangeType} + </Text> + </View> + <Text style={styles.valueModalText}> + {tokenValueMaxPrecision} + </Text> + </View> + </TouchableOpacity> + </BottomModal> + )} + </Box> + ); + }; + +export default SimulationValueDisplay; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts new file mode 100644 index 000000000000..33cde05cb627 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './ValueDisplay'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx index 1fd23c2fbeb8..57ba75524283 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx @@ -1,19 +1,27 @@ import React from 'react'; - +import { useSelector } from 'react-redux'; +import { selectUseTransactionSimulations } from '../../../../../../../selectors/preferencesController'; import useApprovalRequest from '../../../../hooks/useApprovalRequest'; +import { isRecognizedPermit } from '../../../../utils/signature'; import InfoRowOrigin from '../Shared/InfoRowOrigin'; +import PermitSimulation from './Simulation/TypedSignPermit'; import Message from './Message'; const TypedSignV3V4 = () => { const { approvalRequest } = useApprovalRequest(); + const useSimulation = useSelector( + selectUseTransactionSimulations, + ); if (!approvalRequest) { return null; } + const isPermit = isRecognizedPermit(approvalRequest); + return ( <> - {/* SIMULATION TO BE ADDED */} + {isPermit && useSimulation && <PermitSimulation />} <InfoRowOrigin /> <Message /> </> diff --git a/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx b/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx index adef046bb012..725d332f1327 100644 --- a/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx +++ b/app/components/Views/confirmations/components/UI/BottomModal/BottomModal.tsx @@ -8,15 +8,21 @@ import styleSheet from './BottomModal.styles'; const OPAQUE_GRAY = '#414141'; interface BottomModalProps { + canCloseOnBackdropClick?: boolean; children: ReactChild; onClose?: () => void; hideBackground?: boolean; } +/** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ const BottomModal = ({ + canCloseOnBackdropClick = true, children, hideBackground, - onClose, + onClose }: BottomModalProps) => { const { colors } = useTheme(); const { styles } = useStyles(styleSheet, {}); @@ -32,6 +38,7 @@ const BottomModal = ({ animationInTiming={600} animationOutTiming={600} onBackButtonPress={onClose} + onBackdropPress={canCloseOnBackdropClick ? onClose : undefined} onSwipeComplete={onClose} swipeDirection={'down'} propagateSwipe diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap index c0a8749fcc18..6d6fbd986dc5 100644 --- a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap +++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap @@ -3,17 +3,20 @@ exports[`InfoAddress should match snapshot 1`] = ` <View style={ - { - "alignItems": "center", - "alignSelf": "center", - "backgroundColor": "#f2f4f6", - "borderRadius": 99, - "flexDirection": "row", - "gap": 5, - "paddingLeft": 8, - "paddingRight": 8, - "paddingVertical": 4, - } + [ + { + "alignItems": "center", + "alignSelf": "center", + "backgroundColor": "#f2f4f6", + "borderRadius": 99, + "flexDirection": "row", + "gap": 5, + "paddingLeft": 8, + "paddingRight": 8, + "paddingVertical": 4, + }, + undefined, + ] } > <SvgMock diff --git a/app/components/Views/confirmations/constants/signatures.ts b/app/components/Views/confirmations/constants/signatures.ts new file mode 100644 index 000000000000..79a6dfdf1840 --- /dev/null +++ b/app/components/Views/confirmations/constants/signatures.ts @@ -0,0 +1,36 @@ +/** + * The contents of this file have been taken verbatim from + * metamask-extension/shared/constants/signatures.ts + * + * If updating, please be mindful of this or delete this comment. + */ + +export enum PrimaryTypeOrder { + Order = 'Order', + OrderComponents = 'OrderComponents', +} + +export enum PrimaryTypePermit { + Permit = 'Permit', + PermitBatch = 'PermitBatch', + PermitBatchTransferFrom = 'PermitBatchTransferFrom', + PermitSingle = 'PermitSingle', + PermitTransferFrom = 'PermitTransferFrom', +} + +/** + * EIP-712 Permit PrimaryTypes + */ +export const PrimaryType = { + ...PrimaryTypeOrder, + ...PrimaryTypePermit, +} as const; + +// Create a type from the const object +export type PrimaryType = (typeof PrimaryType)[keyof typeof PrimaryType]; + +export const PRIMARY_TYPES_ORDER: PrimaryTypeOrder[] = + Object.values(PrimaryTypeOrder); +export const PRIMARY_TYPES_PERMIT: PrimaryTypePermit[] = + Object.values(PrimaryTypePermit); +export const PRIMARY_TYPES: PrimaryType[] = Object.values(PrimaryType); diff --git a/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts b/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts new file mode 100644 index 000000000000..a361c9fbcb5c --- /dev/null +++ b/app/components/Views/confirmations/hooks/useGetTokenStandardAndDetails.ts @@ -0,0 +1,50 @@ +import { NetworkClientId } from '@metamask/network-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../UI/SimulationDetails/types'; +import { useAsyncResult } from '../../../hooks/useAsyncResult'; +import { + ERC20_DEFAULT_DECIMALS, + parseTokenDetailDecimals, + memoizedGetTokenStandardAndDetails, + TokenDetailsERC20, +} from '../utils/token'; + +/** + * Returns token details for a given token contract + * + * @param tokenAddress + * @returns + */ +const useGetTokenStandardAndDetails = ( + tokenAddress?: Hex | string | undefined, + networkClientId?: NetworkClientId, +) => { + const { value: details } = + useAsyncResult<TokenDetailsERC20 | null>(async () => { + if (!tokenAddress) { + return Promise.resolve(null); + } + + return (await memoizedGetTokenStandardAndDetails({ + tokenAddress, + networkClientId, + })) as TokenDetailsERC20; + }, [tokenAddress]); + + if (!details) { + return { decimalsNumber: undefined }; + } + + const { decimals, standard } = details || {}; + + if (standard === TokenStandard.ERC20) { + const parsedDecimals = + parseTokenDetailDecimals(decimals) ?? ERC20_DEFAULT_DECIMALS; + details.decimalsNumber = parsedDecimals; + } + + return details; +}; + +export default useGetTokenStandardAndDetails; diff --git a/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts b/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts new file mode 100644 index 000000000000..6bf37651a5c3 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { Hex } from '@metamask/utils'; + +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { TokenStandard } from '../../../../components/UI/SimulationDetails/types'; +import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { parseTokenDetailDecimals, TokenDetailsERC20 } from '../utils/token'; + +/** + * Track event that number of decimals in ERC20 is not obtained + * + * @param chainId + * @param tokenAddress + * @param tokenDetails + * @param metricLocation + */ +const useTrackERC20WithoutDecimalInformation = ( + chainId: Hex, + tokenAddress: Hex | string | undefined, + tokenDetails?: TokenDetailsERC20, + metricLocation: string = 'signature_confirmation', +) => { + const { trackEvent, createEventBuilder } = useMetrics(); + + useEffect(() => { + if (chainId === undefined || tokenDetails === undefined) { + return; + } + const { decimals, standard } = tokenDetails || {}; + + if (standard !== TokenStandard.ERC20) { return; } + + const parsedDecimals = parseTokenDetailDecimals(decimals); + + if (parsedDecimals === undefined) { + trackEvent(createEventBuilder(MetaMetricsEvents.INCOMPLETE_ASSET_DISPLAYED) + .addProperties({ + token_decimals_available: false, + asset_address: tokenAddress, + asset_type: TokenStandard.ERC20, + chain_id: chainId, + location: metricLocation, + ui_customizations: ['redesigned_confirmation'], + }) + .build()); + } + }, [chainId, tokenAddress, tokenDetails, metricLocation, trackEvent, createEventBuilder]); +}; + +export default useTrackERC20WithoutDecimalInformation; diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts new file mode 100644 index 000000000000..13be13895918 --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -0,0 +1,72 @@ +import { ApprovalRequest } from '@metamask/approval-controller'; +import { parseTypedDataMessage, isRecognizedPermit } from './signature'; +import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; + +describe('Signature Utils', () => { + describe('parseTypedDataMessage', () => { + it('should parse typed data message correctly', () => { + const data = JSON.stringify({ + message: { + value: '123' + } + }); + const result = parseTypedDataMessage(data); + expect(result).toEqual({ + message: { + value: '123' + } + }); + }); + + it('parses message.value as a string', () => { + const result = parseTypedDataMessage( + '{"test": "dummy", "message": { "value": 3000123} }', + ); + expect(result.message.value).toBe('3000123'); + }); + + + it('should handle large message values. This prevents native JS number coercion when the value is greater than Number.MAX_SAFE_INTEGER.', () => { + const largeValue = '123456789012345678901234567890'; + const data = JSON.stringify({ + message: { + value: largeValue + } + }); + const result = parseTypedDataMessage(data); + expect(result.message.value).toBe(largeValue); + }); + + it('throw error for invalid typedDataMessage', () => { + expect(() => { + parseTypedDataMessage(''); + }).toThrow(new Error('Unexpected end of JSON input')); + }); + }); + + describe('isRecognizedPermit', () => { + it('should return true for recognized permit types', () => { + const mockRequest: ApprovalRequest<{ data: string }> = { + requestData: { + data: JSON.stringify({ + primaryType: PRIMARY_TYPES_PERMIT[0] + }) + } + } as ApprovalRequest<{ data: string }>; + + expect(isRecognizedPermit(mockRequest)).toBe(true); + }); + + it('should return false for unrecognized permit types', () => { + const mockRequest: ApprovalRequest<{ data: string }> = { + requestData: { + data: JSON.stringify({ + primaryType: 'UnrecognizedType' + }) + } + } as ApprovalRequest<{ data: string }>; + + expect(isRecognizedPermit(mockRequest)).toBe(false); + }); + }); +}); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts new file mode 100644 index 000000000000..73c19e2b9b7f --- /dev/null +++ b/app/components/Views/confirmations/utils/signature.ts @@ -0,0 +1,55 @@ +import { ApprovalRequest } from '@metamask/approval-controller'; +import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; + +/** + * The contents of this file have been taken verbatim from + * metamask-extension/shared/modules/transaction.utils.ts + * + * If updating, please be mindful of this or delete this comment. + */ + +const REGEX_MESSAGE_VALUE_LARGE = /"message"\s*:\s*\{[^}]*"value"\s*:\s*(\d{15,})/u; + +function extractLargeMessageValue(dataToParse: string): string | undefined { + if (typeof dataToParse !== 'string') { + return undefined; + } + return dataToParse.match(REGEX_MESSAGE_VALUE_LARGE)?.[1]; +} + +/** + * JSON.parse has a limitation which coerces values to scientific notation if numbers are greater than + * Number.MAX_SAFE_INTEGER. This can cause a loss in precision. + * + * Aside from precision concerns, if the value returned was a large number greater than 15 digits, + * e.g. 3.000123123123121e+26, passing the value to BigNumber will throw the error: + * Error: new BigNumber() number type has more than 15 significant digits + * + * Note that using JSON.parse reviver cannot help since the value will be coerced by the time it + * reaches the reviver function. + * + * This function has a workaround to extract the large value from the message and replace + * the message value with the string value. + * + * @param dataToParse + * @returns + */ +export const parseTypedDataMessage = (dataToParse: string) => { + const result = JSON.parse(dataToParse); + + const messageValue = extractLargeMessageValue(dataToParse); + if (result.message?.value) { + result.message.value = messageValue || String(result.message.value); + } + return result; +}; + +/** + * Returns true if the request is a recognized Permit Typed Sign signature request + * + * @param request - The confirmation request to check + */ +export const isRecognizedPermit = (approvalRequest: ApprovalRequest<{ data: string }>) => { + const { primaryType } = parseTypedDataMessage(approvalRequest.requestData.data); + return PRIMARY_TYPES_PERMIT.includes(primaryType); +}; diff --git a/app/components/Views/confirmations/utils/token.ts b/app/components/Views/confirmations/utils/token.ts new file mode 100644 index 000000000000..4014dd1f528d --- /dev/null +++ b/app/components/Views/confirmations/utils/token.ts @@ -0,0 +1,99 @@ +import { memoize } from 'lodash'; +import { Hex } from '@metamask/utils'; +import { AssetsContractController } from '@metamask/assets-controllers'; +import { NetworkClientId } from '@metamask/network-controller'; +import { getTokenDetails } from '../../../../util/address'; + +export type TokenDetailsERC20 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC20Standard']>['getDetails'] + > +> & { decimalsNumber: number }; + +export type TokenDetailsERC721 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC721Standard']>['getDetails'] + > +>; + +export type TokenDetailsERC1155 = Awaited< + ReturnType< + ReturnType<AssetsContractController['getERC1155Standard']>['getDetails'] + > +>; + +export type TokenDetails = + | TokenDetailsERC20 + | TokenDetailsERC721 + | TokenDetailsERC1155; + +export const ERC20_DEFAULT_DECIMALS = 18; + +export const parseTokenDetailDecimals = ( + decStr?: string, +): number | undefined => { + if (!decStr) { + return undefined; + } + + for (const radix of [10, 16]) { + const parsedDec = parseInt(decStr, radix); + if (isFinite(parsedDec)) { + return parsedDec; + } + } + return undefined; +}; + +export const memoizedGetTokenStandardAndDetails = memoize( + async ({ + tokenAddress, + tokenId, + userAddress, + networkClientId, + }: { + tokenAddress?: Hex | string; + userAddress?: string; + tokenId?: string; + networkClientId?: NetworkClientId; + }): Promise<TokenDetails | Record<string, never>> => { + try { + if (!tokenAddress) { + return {}; + } + + return (await getTokenDetails( + tokenAddress, + userAddress, + tokenId, + networkClientId, + )) as TokenDetails; + } catch { + return {}; + } + }, +); + +/** + * Fetches the decimals for the given token address. + * + * @param address - The ethereum token contract address. It is expected to be in hex format. + * We currently accept strings since we have a patch that accepts a custom string + * {@see .yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch} + */ +export const fetchErc20Decimals = async ( + address: Hex | string, + networkClientId?: NetworkClientId, +): Promise<number> => { + try { + const { decimals: decStr } = (await memoizedGetTokenStandardAndDetails({ + tokenAddress: address, + networkClientId, + })) as TokenDetailsERC20; + const decimals = parseTokenDetailDecimals(decStr); + + return decimals ?? ERC20_DEFAULT_DECIMALS; + } catch { + return ERC20_DEFAULT_DECIMALS; + } +}; diff --git a/app/util/address/index.ts b/app/util/address/index.ts index c3ce4fc20435..a961ed6cda50 100644 --- a/app/util/address/index.ts +++ b/app/util/address/index.ts @@ -640,12 +640,14 @@ export const getTokenDetails = async ( tokenAddress: string, userAddress?: string, tokenId?: string, + networkClientId?: NetworkClientId, ) => { const { AssetsContractController } = Engine.context; const tokenData = await AssetsContractController.getTokenStandardAndDetails( tokenAddress, userAddress, tokenId, + networkClientId, ); const { standard, name, symbol, decimals } = tokenData; if (standard === ERC721 || standard === ERC1155) { diff --git a/app/util/notifications/methods/common.test.ts b/app/util/notifications/methods/common.test.ts index a60317e5ec12..d5ea5ddc2859 100644 --- a/app/util/notifications/methods/common.test.ts +++ b/app/util/notifications/methods/common.test.ts @@ -1,6 +1,7 @@ import { formatMenuItemDate, parseNotification, + shortenString, getLeadingZeroCount, formatAmount, getUsdAmount, @@ -218,3 +219,30 @@ describe('parseNotification', () => { }); }); }); + +describe('shortenString', () => { + it('should return the same string if it is shorter than TRUNCATED_NAME_CHAR_LIMIT', () => { + expect(shortenString('string')).toStrictEqual('string'); + }); + + it('should return the shortened string according to the specified options', () => { + expect( + shortenString('0x1234567890123456789012345678901234567890', { + truncatedCharLimit: 10, + truncatedStartChars: 4, + truncatedEndChars: 4, + }), + ).toStrictEqual('0x12...7890'); + }); + + it('should shorten the string and remove all characters from the end if skipCharacterInEnd is true', () => { + expect( + shortenString('0x1234567890123456789012345678901234567890', { + truncatedCharLimit: 10, + truncatedStartChars: 4, + truncatedEndChars: 4, + skipCharacterInEnd: true, + }), + ).toStrictEqual('0x12...'); + }); +}); diff --git a/app/util/notifications/methods/common.ts b/app/util/notifications/methods/common.ts index 5c90b4d5b5d2..2ee0ce6e18d7 100644 --- a/app/util/notifications/methods/common.ts +++ b/app/util/notifications/methods/common.ts @@ -313,20 +313,25 @@ export const TRUNCATED_ADDRESS_END_CHARS = 5; */ export function shortenString( stringToShorten = '', - { truncatedCharLimit, truncatedStartChars, truncatedEndChars } = { - truncatedCharLimit: TRUNCATED_NAME_CHAR_LIMIT, - truncatedStartChars: TRUNCATED_ADDRESS_START_CHARS, - truncatedEndChars: TRUNCATED_ADDRESS_END_CHARS, - }, + { + truncatedCharLimit = TRUNCATED_NAME_CHAR_LIMIT, + truncatedStartChars = TRUNCATED_ADDRESS_START_CHARS, + truncatedEndChars = TRUNCATED_ADDRESS_END_CHARS, + skipCharacterInEnd = false, + }: { + truncatedCharLimit?: number; + truncatedStartChars?: number; + truncatedEndChars?: number; + skipCharacterInEnd?: boolean; + } = {}, ) { if (stringToShorten.length < truncatedCharLimit) { return stringToShorten; } - return `${stringToShorten.slice( - 0, - truncatedStartChars, - )}...${stringToShorten.slice(-truncatedEndChars)}`; + return `${stringToShorten.slice(0, truncatedStartChars)}...${ + skipCharacterInEnd ? '' : stringToShorten.slice(-truncatedEndChars) + }`; } export const sortNotifications = ( diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index 408f70b87880..535b3cf4d4ab 100644 --- a/app/util/test/confirm-data-helpers.ts +++ b/app/util/test/confirm-data-helpers.ts @@ -1,4 +1,8 @@ -import { MessageParamsTyped } from '@metamask/signature-controller'; +import { + MessageParamsTyped, + SignatureRequestStatus, + SignatureRequestType +} from '@metamask/signature-controller'; import { backgroundState } from './initial-root-state'; import { Hex } from '@metamask/utils'; @@ -209,6 +213,61 @@ export const typedSignV3ConfirmationState = { }, }; +export const typedSignV4ConfirmationState = { + engine: { + backgroundState: { + ...backgroundState, + ApprovalController: { + pendingApprovals: { + 'fb2029e1-b0ab-11ef-9227-05a11087c334': { + id: 'fb2029e1-b0ab-11ef-9227-05a11087c334', + origin: 'metamask.github.io', + type: 'eth_signTypedData', + time: 1733143817088, + requestData: { + data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + requestId: 14, + signatureMethod: 'eth_signTypedData_v4', + origin: 'https://metamask.github.io', + metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', + meta: { + url: 'https://metamask.github.io/test-dapp/', + title: 'E2E Test Dapp', + icon: { uri: 'https://metamask.github.io/metamask-fox.svg' }, + analytics: { request_source: 'In-App-Browser' }, + }, + }, + requestState: null, + expectsResult: true, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + SignatureController: { + signatureRequests: { + 'fb2029e1-b0ab-11ef-9227-05a11087c334': { + id: 'fb2029e1-b0ab-11ef-9227-05a11087c334', + chainId: '0x1' as Hex, + type: SignatureRequestType.TypedSign, + messageParams: { + data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', + origin: 'https://metamask.github.io' + }, + networkClientId: '1', + status: SignatureRequestStatus.Unapproved, + time: 1733143817088 + }, + }, + }, + }, + }, +}; + export const securityAlertResponse = { block: 21572398, result_type: 'Malicious', diff --git a/locales/languages/en.json b/locales/languages/en.json index ef1821c1fff1..861594b3a495 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3607,8 +3607,11 @@ "balance": "Balance", "network": "Network", "simulation": { - "title": "Estimated changes", + "info_permit": "You’re giving the spender permission to spend this many tokens from your account.", + "label_change_type_permit": "Spending cap", + "label_change_type_permit_nft": "Withdraw", "personal_sign_info": "You’re signing into a site and there are no predicted changes to your account.", + "title": "Estimated changes", "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." } }, From 1f2c1a2550a44b9cc47827056d4ded77b9665d0d Mon Sep 17 00:00:00 2001 From: Jyoti Puri <jyotipuri@gmail.com> Date: Thu, 16 Jan 2025 04:24:55 +0530 Subject: [PATCH 05/11] fix: fix old signature e2e by mocking launch darkly api (#13002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix old signature pages e2e. by mocking launch darkly api. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3916 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../hooks/useConfirmationRedesignEnabled.ts | 1 - e2e/api-mocking/mock-config/mock-events.js | 55 ++++++++++++------- e2e/api-specs/json-rpc-coverage.js | 6 ++ .../signatures/ethereum-sign.spec.js | 6 ++ .../signatures/personal-sign.spec.js | 6 ++ .../security-alert-signatures.mock.spec.js | 11 +++- .../signatures/typed-sign-v3.spec.js | 6 ++ .../signatures/typed-sign-v4.spec.js | 6 ++ .../signatures/typed-sign.spec.js | 6 ++ jest.config.js | 2 - 10 files changed, 81 insertions(+), 24 deletions(-) diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts index 140c58dcde41..43ec8489268b 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts @@ -22,7 +22,6 @@ const useConfirmationRedesignEnabled = () => { const isRedesignedEnabled = useMemo( () => (confirmation_redesign as Record<string, string>)?.signatures && - process.env.REDESIGNED_SIGNATURE_REQUEST === 'true' && // following condition will ensure that user is redirected to old designs is using QR scan aware hardware !isSyncingQRHardware && !isSigningQRObject && diff --git a/e2e/api-mocking/mock-config/mock-events.js b/e2e/api-mocking/mock-config/mock-events.js index 0e3423416b18..755fe4d39da7 100644 --- a/e2e/api-mocking/mock-config/mock-events.js +++ b/e2e/api-mocking/mock-config/mock-events.js @@ -38,21 +38,37 @@ export const mockEvents = { securityAlertApiSupportedChains: { urlEndpoint: 'https://security-alerts.api.cx.metamask.io/supportedChains', response: [ - '0xa4b1', - '0xa86a', - '0x2105', - '0x138d5', - '0x38', - '0xe708', - '0x1', - '0x1b6e6', - '0xcc', - '0xa', - '0x89', - '0x82750', - '0xaa36a7', - '0x144' - ], + '0xa4b1', + '0xa86a', + '0x2105', + '0x138d5', + '0x38', + '0xe708', + '0x1', + '0x1b6e6', + '0xcc', + '0xa', + '0x89', + '0x82750', + '0xaa36a7', + '0x144', + ], + responseCode: 200, + }, + + remoteFeatureFlags: { + urlEndpoint: + 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=dev', + response: [ + { + mobileMinimumVersions: { + appMinimumBuild: 1243, + appleMinimumOS: 6, + androidMinimumAPIVersion: 21, + }, + }, + { confirmation_redesign: { signatures: false } }, + ], responseCode: 200, }, }, @@ -77,7 +93,8 @@ export const mockEvents = { }, securityAlertApiValidate: { - urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', + urlEndpoint: + 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', response: { block: 20733513, result_type: 'Benign', @@ -93,9 +110,9 @@ export const mockEvents = { { from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', to: '0x50587e46c5b96a3f6f9792922ec647f13e6efae4', - value: '0x0' - } - ] + value: '0x0', + }, + ], }, responseCode: 201, }, diff --git a/e2e/api-specs/json-rpc-coverage.js b/e2e/api-specs/json-rpc-coverage.js index ca5b30f828af..bcc75bc049c7 100644 --- a/e2e/api-specs/json-rpc-coverage.js +++ b/e2e/api-specs/json-rpc-coverage.js @@ -22,6 +22,7 @@ import ConfirmationsRejectRule from './ConfirmationsRejectionRule'; import { createDriverTransport } from './helpers'; import { BrowserViewSelectorsIDs } from '../selectors/Browser/BrowserView.selectors'; import { getGanachePort } from '../fixtures/utils'; +import { mockEvents } from '../api-mocking/mock-config/mock-events'; const port = getGanachePort(8545, process.pid); const chainId = 1337; @@ -156,6 +157,10 @@ const main = async () => { const server = mockServer(port, openrpcDocument); server.start(); + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + await withFixtures( { dapp: true, @@ -163,6 +168,7 @@ const main = async () => { ganacheOptions: defaultGanacheOptions, disableGanache: true, restartDevice: true, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/ethereum-sign.spec.js b/e2e/specs/confirmations/signatures/ethereum-sign.spec.js index 4be24f172ca0..b51be2c81b61 100644 --- a/e2e/specs/confirmations/signatures/ethereum-sign.spec.js +++ b/e2e/specs/confirmations/signatures/ethereum-sign.spec.js @@ -12,6 +12,7 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Ethereum Sign'), () => { beforeAll(async () => { @@ -20,6 +21,10 @@ describe(SmokeConfirmations('Ethereum Sign'), () => { }); it('Sign in with Ethereum', async () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + await withFixtures( { dapp: true, @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Ethereum Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/personal-sign.spec.js b/e2e/specs/confirmations/signatures/personal-sign.spec.js index 0e3acf0579cd..4fc37496141e 100644 --- a/e2e/specs/confirmations/signatures/personal-sign.spec.js +++ b/e2e/specs/confirmations/signatures/personal-sign.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Personal Sign'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Personal Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js index ff4a19e0137a..77f0f3b37218 100644 --- a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js +++ b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.js @@ -60,7 +60,10 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { it('should sign typed message', async () => { const testSpecificMock = { - GET: [mockEvents.GET.securityAlertApiSupportedChains], + GET: [ + mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, + ], POST: [ { ...mockEvents.POST.securityAlertApiValidate, @@ -83,7 +86,10 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { it('should show security alert for malicious request', async () => { const testSpecificMock = { - GET: [mockEvents.GET.securityAlertApiSupportedChains], + GET: [ + mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, + ], POST: [ { ...mockEvents.POST.securityAlertApiValidate, @@ -108,6 +114,7 @@ describe(SmokeConfirmations('Security Alert API - Signature'), () => { const testSpecificMock = { GET: [ mockEvents.GET.securityAlertApiSupportedChains, + mockEvents.GET.remoteFeatureFlags, { urlEndpoint: 'https://static.cx.metamask.io/api/v1/confirmations/ppom/ppom_version.json', diff --git a/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js b/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js index 86d958aa5611..26cf84334ae2 100644 --- a/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign-v3.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign V3'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign V3'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js b/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js index 4de3dd9b006c..c82b6e7a17b0 100644 --- a/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign-v4.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign V4'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign V4'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/e2e/specs/confirmations/signatures/typed-sign.spec.js b/e2e/specs/confirmations/signatures/typed-sign.spec.js index bee5b2a5e8a2..c6b9cfed353c 100644 --- a/e2e/specs/confirmations/signatures/typed-sign.spec.js +++ b/e2e/specs/confirmations/signatures/typed-sign.spec.js @@ -12,8 +12,13 @@ import { import { SmokeConfirmations } from '../../../tags'; import TestHelpers from '../../../helpers'; import Assertions from '../../../utils/Assertions'; +import { mockEvents } from '../../../api-mocking/mock-config/mock-events'; describe(SmokeConfirmations('Typed Sign'), () => { + const testSpecificMock = { + GET: [mockEvents.GET.remoteFeatureFlags], + }; + beforeAll(async () => { jest.setTimeout(2500000); await TestHelpers.reverseServerPort(); @@ -29,6 +34,7 @@ describe(SmokeConfirmations('Typed Sign'), () => { .build(), restartDevice: true, ganacheOptions: defaultGanacheOptions, + testSpecificMock, }, async () => { await loginToApp(); diff --git a/jest.config.js b/jest.config.js index bbc092b33f56..d54cc45b1f88 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,8 +9,6 @@ process.env.MM_SECURITY_ALERTS_API_ENABLED = 'true'; process.env.PORTFOLIO_VIEW = 'true'; process.env.SECURITY_ALERTS_API_URL = 'https://example.com'; -process.env.REDESIGNED_SIGNATURE_REQUEST = 'true'; - process.env.LAUNCH_DARKLY_URL = 'https://client-config.dev-api.cx.metamask.io/v1'; From 435c9e6e90e3a4f9c0231866391464cdc8f43d3e Mon Sep 17 00:00:00 2001 From: OGPoyraz <omergoktugpoyraz@gmail.com> Date: Thu, 16 Jan 2025 15:35:54 +0100 Subject: [PATCH 06/11] fix: Fix disappearing security alert response (#13027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR aims to retain `securityAlertResponses` on `SET_TRANSACTION_OBJECT` action. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3903 ## **Manual testing steps** 1. Open up our [[MetaMask E2E test dapp](https://metamask.github.io/test-dapp/)](https://metamask.github.io/test-dapp/) and attempt to prompt a malicious approval for BUSD in our PPOM section. 2. Notice that the malicious warning might popup and stay on screen. ## **Screenshots/Recordings** https://github.com/user-attachments/assets/60eb5398-c914-4498-8592-4acea5caa163 ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/reducers/transaction/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/reducers/transaction/index.js b/app/reducers/transaction/index.js index 5d1ab2efb94f..3b37c6201502 100644 --- a/app/reducers/transaction/index.js +++ b/app/reducers/transaction/index.js @@ -110,6 +110,8 @@ const transactionReducer = (state = initialState, action) => { ...getTxData(action.transaction), }, ...txMeta, + // Retain the securityAlertResponses from the old state + securityAlertResponses: state.securityAlertResponses, }; } case 'SET_TOKENS_TRANSACTION': { From ff19cedd830fe63fad0117b7d912dabf2d3be65d Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Thu, 16 Jan 2025 07:37:26 -0800 Subject: [PATCH 07/11] feat: Redesign Signature Decoding Simulation (#12994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add Signature Decoding Simulation UI This logic mirrors the extension with slight modifications due to mobile/extension parity differences. In addition: - updated ValueDisplay value logic to no longer use useMemo Todo in follow-up PRs: - Add additional tests https://github.com/MetaMask/metamask-mobile/issues/13023 - Add "Unlimited" text support https://github.com/MetaMask/metamask-mobile/issues/13022 - Investigate "useExternalServices" setting. This does not seem to exist in mobile https://github.com/MetaMask/metamask-mobile/issues/13024 ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3876 Related: https://github.com/MetaMask/metamask-mobile/pull/12994 (Replaces Ramp/Box usages with View) ## **Manual testing steps** 1. Set REDESIGNED_SIGNATURE_REQUEST to true in js.env 2. Enable confirmation_redesign in Launch Darkly 3. Turn on Improved Signatures setting 4. Turn on Simulation setting 5. Test various v3 and v4 signTypedData signatures - Example dapp: https://develop.d3bkcslj57l47p.amplifyapp.com/ → "Permit 2 - Single" button ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="320" src="https://github.com/user-attachments/assets/4b511681-66d3-47c4-8807-5ea227a9eaa9"> <img width="320" src="https://github.com/user-attachments/assets/439477e6-b02f-4b46-9660-dd1e77d4df2a"> The values in this screenshot will be replaced by "Unlimited" <img width="320" src="https://github.com/user-attachments/assets/0f37d8cd-7817-4d88-a4ab-79356268265d"> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .js.env.example | 3 + .../Views/confirmations/Confirm/Confirm.tsx | 2 +- .../TypedSignV3V4/Simulation/Simulation.tsx | 31 +++ .../Simulation/Static/Static.tsx | 55 +++++ .../TypedSignV3V4/Simulation/Static/index.ts | 1 + .../TypedSignDecoded.test.tsx | 167 ++++++++++++++ .../TypedSignDecoded/TypedSignDecoded.tsx | 213 ++++++++++++++++++ .../Simulation/TypedSignDecoded/index.ts | 1 + .../NativeValueDisplay/NativeValueDisplay.tsx | 153 +++++++++++++ .../components/NativeValueDisplay/index.ts | 1 + .../ValueDisplay/ValueDisplay.styles.ts | 7 +- .../components/ValueDisplay/ValueDisplay.tsx | 51 ++--- .../Info/TypedSignV3V4/Simulation/index.ts | 1 + .../Info/TypedSignV3V4/TypedSignV3V4.tsx | 23 +- .../components/SignatureRequest/Root/Root.tsx | 2 +- .../confirmations/hooks/useApprovalRequest.ts | 6 +- .../useConfirmationRedesignEnabled.test.ts | 2 +- .../hooks/useConfirmationRedesignEnabled.ts | 4 +- .../hooks/useTypedSignSimulationEnabled.ts | 66 ++++++ .../confirmations/utils/signature.test.ts | 14 +- .../Views/confirmations/utils/signature.ts | 14 +- app/core/AppConstants.ts | 1 + app/core/Engine/Engine.ts | 4 + app/selectors/currencyRateController.test.ts | 36 +++ app/selectors/currencyRateController.ts | 25 +- app/util/number/index.js | 18 +- app/util/number/index.test.ts | 37 +++ locales/languages/en.json | 11 +- 28 files changed, 873 insertions(+), 76 deletions(-) create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts create mode 100644 app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts diff --git a/.js.env.example b/.js.env.example index 38daeab2a2cd..0d1d27648618 100644 --- a/.js.env.example +++ b/.js.env.example @@ -66,6 +66,9 @@ export SEGMENT_FLUSH_INTERVAL="1" # example for flush when 1 event is queued export SEGMENT_FLUSH_EVENT_LIMIT="1" +# URL of the decoding API used to provide additional data from signature requests +export DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' + # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index a13ec947e8d0..4a18a99d7d1a 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -8,7 +8,7 @@ import Footer from '../components/Confirm/Footer'; import Info from '../components/Confirm/Info'; import SignatureBlockaidBanner from '../components/Confirm/SignatureBlockaidBanner'; import Title from '../components/Confirm/Title'; -import useConfirmationRedesignEnabled from '../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../hooks/useConfirmationRedesignEnabled'; import styleSheet from './Confirm.styles'; const Confirm = () => { diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx new file mode 100644 index 000000000000..981a93559113 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { useTypedSignSimulationEnabled } from '../../../../../hooks/useTypedSignSimulationEnabled'; +import { isRecognizedPermit } from '../../../../../utils/signature'; +import { useSignatureRequest } from '../../../../../hooks/useSignatureRequest'; +import DecodedSimulation from './TypedSignDecoded'; +import PermitSimulation from './TypedSignPermit'; + +const TypedSignV3V4Simulation: React.FC<object> = () => { + const signatureRequest = useSignatureRequest(); + const isPermit = signatureRequest && isRecognizedPermit(signatureRequest); + const isSimulationSupported = useTypedSignSimulationEnabled(); + + if (!isSimulationSupported || !signatureRequest) { + return null; + } + + const { decodingData, decodingLoading } = signatureRequest; + const hasDecodingData = !( + (!decodingLoading && decodingData === undefined) || + decodingData?.error + ); + + if (!hasDecodingData && isPermit) { + return <PermitSimulation />; + } + + return <DecodedSimulation />; +}; + +export default TypedSignV3V4Simulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx new file mode 100644 index 000000000000..febb2b711158 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useStyles } from '../../../../../../../../../component-library/hooks'; +import InfoRow from '../../../../../UI/InfoRow'; +import InfoSection from '../../../../../UI/InfoRow/InfoSection'; +import Loader from '../../../../../../../../../component-library/components-temp/Loader'; + +const styleSheet = () => StyleSheet.create({ + base: { + display: 'flex', + justifyContent: 'space-between', + }, + loaderContainer: { + display: 'flex', + justifyContent: 'center', + }, +}); + +const StaticSimulation: React.FC<{ + title: string; + titleTooltip: string; + description?: string; + simulationElements: React.ReactNode; + isLoading?: boolean; + isCollapsed?: boolean; +}> = ({ + title, + titleTooltip, + description, + simulationElements, + isLoading, + isCollapsed = false, +}) => { + const { styles } = useStyles(styleSheet, {}); + + return( + <View style={isCollapsed ? styles.base : {}}> + <InfoSection> + <InfoRow label={title} tooltip={titleTooltip}> + {description} + </InfoRow> + {isLoading ? ( + <View style={styles.loaderContainer}> + <Loader size={'small'} /> + </View> + ) : ( + simulationElements + )} + </InfoSection> + </View> + ); +}; + +export default StaticSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts new file mode 100644 index 000000000000..580150128272 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/index.ts @@ -0,0 +1 @@ +export { default } from './Static'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx new file mode 100644 index 000000000000..c37fc11bc81a --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { + DecodingDataChangeType, + DecodingDataStateChanges, + SignatureRequest, +} from '@metamask/signature-controller'; + +import { strings } from '../../../../../../../../../../locales/i18n'; +import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; +import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; +import TypedSignDecoded, { getStateChangeToolip, getStateChangeType, StateChangeType } from './TypedSignDecoded'; + +const stateChangesApprove = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '12345', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, +]; + +const stateChangesListingERC1155: DecodingDataStateChanges = [ + { + assetType: 'NATIVE', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'ERC1155', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '77789', + }, +]; + +const stateChangesNftListing: DecodingDataStateChanges = [ + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '22222', + }, + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const stateChangesNftBidding: DecodingDataStateChanges = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Bidding, + address: '', + amount: '', + contractAddress: '0x922dC160f2ab743312A6bB19DD5152C1D3Ecca33', + tokenID: '189', + }, + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '950000000000000000', + contractAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +]; + +const mockState = (mockStateChanges: DecodingDataStateChanges, stubDecodingLoading: boolean = false) => { + const clonedMockState = cloneDeep(typedSignV4ConfirmationState); + const request = clonedMockState.engine.backgroundState.SignatureController.signatureRequests['fb2029e1-b0ab-11ef-9227-05a11087c334'] as SignatureRequest; + request.decodingLoading = stubDecodingLoading; + request.decodingData = { + stateChanges: mockStateChanges, + }; + + return clonedMockState; +}; + +describe('DecodedSimulation', () => { + it('renders for ERC20 approval', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesApprove), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('12,345')).toBeDefined(); + }); + + it('renders for ERC712 token', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesNftListing), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Listing price')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#22222')).toBeDefined(); + }); + + it('renders for ERC1155 token', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState(stateChangesListingERC1155), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('You receive')).toBeDefined(); + expect(await getByText('You list')).toBeDefined(); + expect(await getByText('#77789')).toBeDefined(); + }); + + it('renders label only once if there are multiple state changes of same changeType', async () => { + const { getAllByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([stateChangesApprove[0], stateChangesApprove[0], stateChangesApprove[0]]), + }); + + expect(await getAllByText('12,345')).toHaveLength(3); + expect(await getAllByText('Spending cap')).toHaveLength(1); + }); + + it('renders unavailable message if no state change is returned', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Unavailable')).toBeDefined(); + }); + + describe('getStateChangeToolip', () => { + it('return correct tooltip when permit is for listing NFT', () => { + const tooltip = getStateChangeToolip( + StateChangeType.NFTListingReceive, + ); + expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_list_nft')); + }); + + it('return correct tooltip when permit is for bidding NFT', () => { + const tooltip = getStateChangeToolip( + StateChangeType.NFTBiddingReceive, + ); + expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_bid_nft')); + }); + }); + + describe('getStateChangeType', () => { + it('return correct state change type for NFT listing receive', () => { + const stateChange = getStateChangeType(stateChangesNftListing, stateChangesNftListing[1]); + expect(stateChange).toBe(StateChangeType.NFTListingReceive); + }); + + it('return correct state change type for NFT bidding receive', () => { + const stateChange = getStateChangeType(stateChangesNftBidding, stateChangesNftBidding[1]); + expect(stateChange).toBe(StateChangeType.NFTBiddingReceive); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx new file mode 100644 index 000000000000..3e712edf440d --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx @@ -0,0 +1,213 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { + DecodingDataChangeType, + DecodingDataStateChange, + DecodingDataStateChanges, +} from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../../../../../../UI/SimulationDetails/types'; +import Text from '../../../../../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../../../../../locales/i18n'; +import { useSignatureRequest } from '../../../../../../hooks/useSignatureRequest'; +import InfoRow from '../../../../../UI/InfoRow'; +import NativeValueDisplay from '../components/NativeValueDisplay'; +import SimulationValueDisplay from '../components/ValueDisplay'; +import StaticSimulation from '../Static'; + +const styles = { + unavailableContainer: { + paddingHorizontal: 8, + paddingBottom: 8, + }, +}; + +export enum StateChangeType { + NFTListingReceive = 'NFTListingReceive', + NFTBiddingReceive = 'NFTBiddingReceive', +} + +export const getStateChangeType = ( + stateChangeList: DecodingDataStateChanges | null, + stateChange: DecodingDataStateChange, +): StateChangeType | undefined => { + if (stateChange.changeType === DecodingDataChangeType.Receive) { + if ( + stateChangeList?.some( + (change) => + change.changeType === DecodingDataChangeType.Listing && + change.assetType === TokenStandard.ERC721, + ) + ) { + return StateChangeType.NFTListingReceive; + } + if ( + stateChange.assetType === TokenStandard.ERC721 && + stateChangeList?.some( + (change) => change.changeType === DecodingDataChangeType.Bidding, + ) + ) { + return StateChangeType.NFTBiddingReceive; + } + } + return undefined; +}; + +export const getStateChangeToolip = ( + nftTransactionType: StateChangeType | undefined, +): string | undefined => { + if (nftTransactionType === StateChangeType.NFTListingReceive) { + return strings('confirm.simulation.decoded_tooltip_list_nft'); + } else if (nftTransactionType === StateChangeType.NFTBiddingReceive) { + return strings('confirm.simulation.decoded_tooltip_bid_nft'); + } + return undefined; +}; + +const stateChangeOrder = { + [DecodingDataChangeType.Transfer]: 1, + [DecodingDataChangeType.Listing]: 2, + [DecodingDataChangeType.Approve]: 3, + [DecodingDataChangeType.Revoke]: 4, + [DecodingDataChangeType.Bidding]: 5, + [DecodingDataChangeType.Receive]: 6, +}; + +const getStateChangeLabelMap = ( + changeType: string, + stateChangeType?: StateChangeType, +) => ({ + [DecodingDataChangeType.Transfer]: strings('confirm.simulation.label_change_type_transfer'), + [DecodingDataChangeType.Receive]: + stateChangeType === StateChangeType.NFTListingReceive + ? strings('confirm.simulation.label_change_type_nft_listing') + : strings('confirm.simulation.label_change_type_receive'), + [DecodingDataChangeType.Approve]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Revoke]: strings('confirm.simulation.label_change_type_permit'), + [DecodingDataChangeType.Bidding]: strings('confirm.simulation.label_change_type_bidding'), + [DecodingDataChangeType.Listing]: strings('confirm.simulation.label_change_type_listing'), + }[changeType]); + +const StateChangeRow = ({ + stateChangeList, + stateChange, + chainId, + shouldDisplayLabel, +}: { + stateChangeList: DecodingDataStateChanges | null; + stateChange: DecodingDataStateChange; + chainId: Hex; + shouldDisplayLabel: boolean; +}) => { + const { assetType, changeType, amount, contractAddress, tokenID } = + stateChange; + const nftTransactionType = getStateChangeType(stateChangeList, stateChange); + const tooltip = shouldDisplayLabel ? getStateChangeToolip(nftTransactionType) : undefined; + // todo: add + // const canDisplayValueAsUnlimited = + // assetType === TokenStandard.ERC20 && + // (changeType === DecodingDataChangeType.Approve || + // changeType === DecodingDataChangeType.Revoke); + + const changeLabel = shouldDisplayLabel + ? getStateChangeLabelMap(changeType, nftTransactionType) + : ''; + + return ( + <InfoRow + label={changeLabel} + tooltip={tooltip} + > + {(assetType === TokenStandard.ERC20 || + assetType === TokenStandard.ERC721 || + assetType === TokenStandard.ERC1155) && ( + <SimulationValueDisplay + labelChangeType={changeType} + tokenContract={contractAddress} + value={amount} + chainId={chainId} + tokenId={tokenID} + credit={ + nftTransactionType !== StateChangeType.NFTListingReceive && + changeType === DecodingDataChangeType.Receive + } + debit={changeType === DecodingDataChangeType.Transfer} + // todo: add + // canDisplayValueAsUnlimited={canDisplayValueAsUnlimited} + /> + )} + {assetType === 'NATIVE' && ( + <NativeValueDisplay + value={amount} + chainId={chainId} + credit={ + nftTransactionType !== StateChangeType.NFTListingReceive && + changeType === DecodingDataChangeType.Receive + } + debit={changeType === DecodingDataChangeType.Transfer} + labelChangeType={changeLabel} + /> + )} + </InfoRow> + ); +}; + +const DecodedSimulation: React.FC<object> = () => { + const signatureRequest = useSignatureRequest(); + + const chainId = signatureRequest?.chainId as Hex; + const { decodingLoading, decodingData } = signatureRequest ?? {}; + + const stateChangeFragment = useMemo(() => { + const orderedStateChanges = [...(decodingData?.stateChanges ?? [])].sort((c1, c2) => + stateChangeOrder[c1.changeType] > stateChangeOrder[c2.changeType] + ? 1 + : -1, + ); + const stateChangesGrouped: Record<string, DecodingDataStateChange[]> = ( + orderedStateChanges ?? [] + ).reduce<Record<string, DecodingDataStateChange[]>>( + (result, stateChange) => { + result[stateChange.changeType] = [ + ...(result[stateChange.changeType] ?? []), + stateChange, + ]; + return result; + }, + {}, + ); + + return Object.entries(stateChangesGrouped).flatMap(([_, changeList]) => + changeList.map((change: DecodingDataStateChange, index: number) => ( + <StateChangeRow + key={`${change.changeType}-${index}`} + stateChangeList={decodingData?.stateChanges ?? []} + stateChange={change} + chainId={chainId} + shouldDisplayLabel={index === 0} + /> + )), + ); + }, [chainId, decodingData?.stateChanges]); + + return ( + <StaticSimulation + title={strings('confirm.simulation.title')} + titleTooltip={strings('confirm.simulation.tooltip')} + simulationElements={ + stateChangeFragment.length ? ( + stateChangeFragment + ) : ( + <View style={styles.unavailableContainer}> + <Text>{strings('confirm.simulation.unavailable')}</Text> + </View> + ) + } + isLoading={decodingLoading} + isCollapsed={decodingLoading || !stateChangeFragment.length} + /> + ); +}; + +export default DecodedSimulation; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts new file mode 100644 index 000000000000..f4bd7dcc0267 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/index.ts @@ -0,0 +1 @@ +export { default } from './TypedSignDecoded'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx new file mode 100644 index 000000000000..54ad3cd28e80 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { Text,TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { RootState } from '../../../../../../../../../../reducers'; +import { selectConversionRateByChainId } from '../../../../../../../../../../selectors/currencyRateController'; +import { useTheme } from '../../../../../../../../../../util/theme'; + +import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; +import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; +import ButtonIcon from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon'; +import { IconName , IconColor } from '../../../../../../../../../../component-library/components/Icons/Icon'; + +import AssetPill from '../../../../../../../../../UI/SimulationDetails/AssetPill/AssetPill'; +import { IndividualFiatDisplay } from '../../../../../../../../../UI/SimulationDetails/FiatDisplay/FiatDisplay'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../../../../../UI/SimulationDetails/formatAmount'; +import { AssetType } from '../../../../../../../../../UI/SimulationDetails/types'; +import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; +import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; +import BottomModal from '../../../../../../UI/BottomModal'; + +/** + * Reusing ValueDisplay styles for now. See issue to handle abstracting UI + * @see {@link https://github.com/MetaMask/metamask-mobile/issues/12974} + */ +import styleSheet from '../ValueDisplay/ValueDisplay.styles'; + +const NATIVE_DECIMALS = 18; + +interface PermitSimulationValueDisplayParams { + /** ID of the associated chain. */ + chainId: Hex; + + /** Change type to be displayed in value tooltip */ + labelChangeType: string; + + /** The token amount */ + value: number | string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; +} + +const NativeValueDisplay: React.FC<PermitSimulationValueDisplayParams> = ({ + chainId, + credit, + debit, + labelChangeType, + value, +}) => { + const [hasValueModalOpen, setHasValueModalOpen] = useState(false); + + const { colors } = useTheme(); + const styles = styleSheet(colors); + + const conversionRate = useSelector((state: RootState) => + selectConversionRateByChainId(state, chainId), + ); + + const tokenAmount = isNumberValue(value) ? calcTokenAmount(value, NATIVE_DECIMALS) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; + + const fiatValue = isValidTokenAmount && conversionRate + ? tokenAmount.times(String(conversionRate)).toNumber() + : undefined; + + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; + + function handlePressTokenValue() { + setHasValueModalOpen(true); + } + + return ( + <View style={styles.wrapper}> + <View style={styles.flexRowTokenValueAndAddress}> + <View style={styles.valueAndAddress}> + <ButtonPill + onPress={handlePressTokenValue} + onPressIn={handlePressTokenValue} + onPressOut={handlePressTokenValue} + style={[credit && styles.valueIsCredit, debit && styles.valueIsDebit]} + > + <Text> + {credit && '+ '} + {debit && '- '} + {tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + </Text> + </ButtonPill> + <View style={styles.marginStart4}> + <AssetPill asset={{ chainId, type: AssetType.Native }} /> + </View> + </View> + </View> + <View style={styles.fiatDisplay}> + {/** + TODO - add fiat shorten prop after tooltip logic has been updated + {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} + */} + {fiatValue !== undefined && ( + <IndividualFiatDisplay fiatAmount={fiatValue} /> + )} + </View> + {hasValueModalOpen && ( + /** + * TODO replace BottomModal instances with BottomSheet + * {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656}} + */ + <BottomModal onClose={() => setHasValueModalOpen(false)}> + <TouchableOpacity + activeOpacity={1} + onPress={() => setHasValueModalOpen(false)} + > + <View style={styles.valueModal} > + <View style={styles.valueModalHeader}> + <ButtonIcon + iconColor={IconColor.Default} + size={ButtonIconSizes.Sm} + style={styles.valueModalHeaderIcon} + onPress={() => setHasValueModalOpen(false)} + iconName={IconName.ArrowLeft} + /> + <Text style={styles.valueModalHeaderText}> + {labelChangeType} + </Text> + </View> + <Text style={styles.valueModalText}> + {tokenValueMaxPrecision} + </Text> + </View> + </TouchableOpacity> + </BottomModal> + )} + </View> + ); +}; + +export default NativeValueDisplay; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts new file mode 100644 index 000000000000..1e6534cfb1c4 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './NativeValueDisplay'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts index 6414e76e0bad..9a1da0579cc3 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.styles.ts @@ -12,7 +12,9 @@ const styleSheet = (colors: Theme['colors']) => borderWidth: 0, padding: 0, }, - + fiatDisplay: { + paddingEnd: 8, + }, flexRowTokenValueAndAddress: { display: 'flex', flexDirection: 'row', @@ -22,7 +24,7 @@ const styleSheet = (colors: Theme['colors']) => borderWidth: 0, padding: 0, }, - tokenAddress: { + marginStart4: { marginStart: 4, }, tokenValueTooltipContent: { @@ -35,7 +37,6 @@ const styleSheet = (colors: Theme['colors']) => valueAndAddress: { paddingVertical: 4, paddingLeft: 8, - paddingRight: 8, gap: 5, flexDirection: 'row', alignItems: 'center', diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx index e59dcf7d43e5..dddcc95d8de7 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -1,8 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import { NetworkClientId } from '@metamask/network-controller'; import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import ButtonPill from '../../../../../../../../../../component-library/components-temp/Buttons/ButtonPill/ButtonPill'; import { ButtonIconSizes } from '../../../../../../../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types'; @@ -16,13 +17,13 @@ import { formatAmountMaxPrecision, } from '../../../../../../../../../UI/SimulationDetails/formatAmount'; -import Box from '../../../../../../../../../UI/Ramp/components/Box'; import Address from '../../../../../../UI/InfoRow/InfoValue/Address/Address'; import { selectContractExchangeRates } from '../../../../../../../../../../selectors/tokenRatesController'; import Logger from '../../../../../../../../../../util/Logger'; import { shortenString } from '../../../../../../../../../../util/notifications/methods/common'; +import { isNumberValue } from '../../../../../../../../../../util/number'; import { useTheme } from '../../../../../../../../../../util/theme'; import { calcTokenAmount } from '../../../../../../../../../../util/transactions'; @@ -101,26 +102,15 @@ const SimulationValueDisplay: React.FC< tokenDetails as TokenDetailsERC20, ); - const fiatValue = useMemo(() => { - if (exchangeRate && value && !tokenId) { - const tokenAmount = calcTokenAmount(value, tokenDecimals); - return tokenAmount.multipliedBy(exchangeRate).toNumber(); - } - return undefined; - }, [exchangeRate, tokenDecimals, tokenId, value]); + const tokenAmount = isNumberValue(value) && !tokenId ? calcTokenAmount(value, tokenDecimals) : null; + const isValidTokenAmount = tokenAmount !== null && tokenAmount !== undefined && tokenAmount instanceof BigNumber; - const { tokenValue, tokenValueMaxPrecision } = useMemo(() => { - if (!value || tokenId) { - return { tokenValue: null, tokenValueMaxPrecision: null }; - } + const fiatValue = isValidTokenAmount && exchangeRate && !tokenId + ? tokenAmount.multipliedBy(exchangeRate).toNumber() + : undefined; - const tokenAmount = calcTokenAmount(value, tokenDecimals); - - return { - tokenValue: formatAmount('en-US', tokenAmount), - tokenValueMaxPrecision: formatAmountMaxPrecision('en-US', tokenAmount), - }; - }, [tokenDecimals, tokenId, value]); + const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; + const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; /** Temporary error capturing as we are building out Permit Simulations */ if (!tokenContract) { @@ -137,10 +127,11 @@ const SimulationValueDisplay: React.FC< } return ( - <Box style={styles.wrapper}> - <Box style={styles.flexRowTokenValueAndAddress}> + <View style={styles.wrapper}> + <View style={styles.flexRowTokenValueAndAddress}> <View style={styles.valueAndAddress}> <ButtonPill + isDisabled={!!tokenId || tokenId === '0'} onPress={handlePressTokenValue} onPressIn={handlePressTokenValue} onPressOut={handlePressTokenValue} @@ -149,7 +140,7 @@ const SimulationValueDisplay: React.FC< <Text> {credit && '+ '} {debit && '- '} - {tokenValue !== null && + {tokenValue !== null && shortenString(tokenValue || '', { truncatedCharLimit: 15, truncatedStartChars: 15, @@ -159,18 +150,18 @@ const SimulationValueDisplay: React.FC< {tokenId && `#${tokenId}`} </Text> </ButtonPill> - <Box compact noBorder style={styles.tokenAddress}> + <View style={styles.marginStart4}> <Address address={tokenContract} chainId={chainId} /> - </Box> + </View> </View> - </Box> - <Box compact noBorder> - {/* + </View> + <View style={styles.fiatDisplay}> + {/** TODO - add fiat shorten prop after tooltip logic has been updated {@see {@link https://github.com/MetaMask/metamask-mobile/issues/12656} */} {fiatValue && <IndividualFiatDisplay fiatAmount={fiatValue} /* shorten*/ />} - </Box> + </View> {hasValueModalOpen && ( /** * TODO replace BottomModal instances with BottomSheet @@ -201,7 +192,7 @@ const SimulationValueDisplay: React.FC< </TouchableOpacity> </BottomModal> )} - </Box> + </View> ); }; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts new file mode 100644 index 000000000000..50cee91255fc --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/index.ts @@ -0,0 +1 @@ +export { default } from './Simulation'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx index 57ba75524283..2caee661061d 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/TypedSignV3V4.tsx @@ -1,31 +1,14 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import { selectUseTransactionSimulations } from '../../../../../../../selectors/preferencesController'; -import useApprovalRequest from '../../../../hooks/useApprovalRequest'; -import { isRecognizedPermit } from '../../../../utils/signature'; import InfoRowOrigin from '../Shared/InfoRowOrigin'; -import PermitSimulation from './Simulation/TypedSignPermit'; import Message from './Message'; +import TypedSignV3V4Simulation from './Simulation'; -const TypedSignV3V4 = () => { - const { approvalRequest } = useApprovalRequest(); - const useSimulation = useSelector( - selectUseTransactionSimulations, - ); - - if (!approvalRequest) { - return null; - } - - const isPermit = isRecognizedPermit(approvalRequest); - - return ( +const TypedSignV3V4 = () => ( <> - {isPermit && useSimulation && <PermitSimulation />} + <TypedSignV3V4Simulation /> <InfoRowOrigin /> <Message /> </> ); -}; export default TypedSignV3V4; diff --git a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx index f610b170a579..9e8bb0437557 100644 --- a/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx +++ b/app/components/Views/confirmations/components/SignatureRequest/Root/Root.tsx @@ -6,7 +6,7 @@ import { useSelector } from 'react-redux'; import setSignatureRequestSecurityAlertResponse from '../../../../../../actions/signatureRequest'; import { store } from '../../../../../../store'; import { useTheme } from '../../../../../../util/theme'; -import useConfirmationRedesignEnabled from '../../../hooks/useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from '../../../hooks/useConfirmationRedesignEnabled'; import PersonalSign from '../../PersonalSign'; import TypedSign from '../../TypedSign'; import { MessageParams } from '../types'; diff --git a/app/components/Views/confirmations/hooks/useApprovalRequest.ts b/app/components/Views/confirmations/hooks/useApprovalRequest.ts index 0eb31b4a4680..61128e7895cb 100644 --- a/app/components/Views/confirmations/hooks/useApprovalRequest.ts +++ b/app/components/Views/confirmations/hooks/useApprovalRequest.ts @@ -1,10 +1,10 @@ -import Engine from '../../../../core/Engine'; import { useCallback, useMemo } from 'react'; -import { providerErrors } from '@metamask/rpc-errors'; import { useSelector } from 'react-redux'; -import { selectPendingApprovals } from '../../../../selectors/approvalController'; import { cloneDeep, isEqual } from 'lodash'; import { ApprovalRequest } from '@metamask/approval-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../../../core/Engine'; +import { selectPendingApprovals } from '../../../../selectors/approvalController'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts index ffa031048836..20640e9606b1 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.test.ts @@ -5,7 +5,7 @@ import { personalSignatureConfirmationState } from '../../../../util/test/confir // eslint-disable-next-line import/no-namespace import * as QRHardwareAwareness from './useQRHardwareAwareness'; -import useConfirmationRedesignEnabled from './useConfirmationRedesignEnabled'; +import { useConfirmationRedesignEnabled } from './useConfirmationRedesignEnabled'; jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: () => ({ tokenFiat: 10 }), diff --git a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts index 43ec8489268b..9ca0e9919ccf 100644 --- a/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts +++ b/app/components/Views/confirmations/hooks/useConfirmationRedesignEnabled.ts @@ -7,7 +7,7 @@ import { selectRemoteFeatureFlags } from '../../../../selectors/featureFlagContr import useApprovalRequest from './useApprovalRequest'; import useQRHardwareAwareness from './useQRHardwareAwareness'; -const useConfirmationRedesignEnabled = () => { +export const useConfirmationRedesignEnabled = () => { const { approvalRequest } = useApprovalRequest(); const { isSigningQRObject, isSyncingQRHardware } = useQRHardwareAwareness(); const { confirmation_redesign } = useSelector(selectRemoteFeatureFlags); @@ -42,5 +42,3 @@ const useConfirmationRedesignEnabled = () => { return { isRedesignedEnabled }; }; - -export default useConfirmationRedesignEnabled; diff --git a/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts new file mode 100644 index 000000000000..0553f8fcae2a --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTypedSignSimulationEnabled.ts @@ -0,0 +1,66 @@ +import { useSelector } from 'react-redux'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { MessageParamsTyped, SignatureRequest, SignatureRequestType } from '@metamask/signature-controller'; +import { selectUseTransactionSimulations } from '../../../../selectors/preferencesController'; +import { isRecognizedPermit, parseTypedDataMessage } from '../utils/signature'; +import { useSignatureRequest } from './useSignatureRequest'; + +const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ + { + domainName: 'Seaport', + primaryTypeList: ['BulkOrder'], + versionList: ['1.4', '1.5', '1.6'], + }, + { + domainName: 'Seaport', + primaryTypeList: ['OrderComponents'], + }, +]; + +const isNonPermitSupportedByDecodingAPI = ( + signatureRequest: SignatureRequest, +) => { + const data = signatureRequest.messageParams?.data as string; + if (!data) { return false; } + + const { + domain: { name, version }, + primaryType, + } = parseTypedDataMessage(data); + + return NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( + ({ domainName, primaryTypeList, versionList }) => + name === domainName && + primaryTypeList.includes(primaryType) && + (!versionList || versionList.includes(version)), + ); +}; + +export function useTypedSignSimulationEnabled() { + const signatureRequest = useSignatureRequest(); + const useTransactionSimulations = useSelector( + selectUseTransactionSimulations, + ); + + if (!signatureRequest) { + return undefined; + } + + const requestType = signatureRequest.type; + const signatureMethod = (signatureRequest.messageParams as MessageParamsTyped)?.version; + + const isTypedSignV3V4 = requestType === SignatureRequestType.TypedSign && ( + signatureMethod === SignTypedDataVersion.V3 || + signatureMethod === SignTypedDataVersion.V4 + ); + const isPermit = isRecognizedPermit(signatureRequest); + + const nonPermitSupportedByDecodingAPI: boolean = + isTypedSignV3V4 && isNonPermitSupportedByDecodingAPI(signatureRequest); + + return ( + useTransactionSimulations && + isTypedSignV3V4 && + (isPermit || nonPermitSupportedByDecodingAPI) + ); +} diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts index 13be13895918..08472ef3f27f 100644 --- a/app/components/Views/confirmations/utils/signature.test.ts +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -1,6 +1,6 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; import { parseTypedDataMessage, isRecognizedPermit } from './signature'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; +import { SignatureRequest } from '@metamask/signature-controller'; describe('Signature Utils', () => { describe('parseTypedDataMessage', () => { @@ -46,25 +46,25 @@ describe('Signature Utils', () => { describe('isRecognizedPermit', () => { it('should return true for recognized permit types', () => { - const mockRequest: ApprovalRequest<{ data: string }> = { - requestData: { + const mockRequest: SignatureRequest = { + messageParams: { data: JSON.stringify({ primaryType: PRIMARY_TYPES_PERMIT[0] }) } - } as ApprovalRequest<{ data: string }>; + } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(true); }); it('should return false for unrecognized permit types', () => { - const mockRequest: ApprovalRequest<{ data: string }> = { - requestData: { + const mockRequest: SignatureRequest = { + messageParams: { data: JSON.stringify({ primaryType: 'UnrecognizedType' }) } - } as ApprovalRequest<{ data: string }>; + } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(false); }); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts index 73c19e2b9b7f..9035a2b30871 100644 --- a/app/components/Views/confirmations/utils/signature.ts +++ b/app/components/Views/confirmations/utils/signature.ts @@ -1,4 +1,4 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; +import { SignatureRequest } from '@metamask/signature-controller'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; /** @@ -47,9 +47,15 @@ export const parseTypedDataMessage = (dataToParse: string) => { /** * Returns true if the request is a recognized Permit Typed Sign signature request * - * @param request - The confirmation request to check + * @param request - The signature request to check */ -export const isRecognizedPermit = (approvalRequest: ApprovalRequest<{ data: string }>) => { - const { primaryType } = parseTypedDataMessage(approvalRequest.requestData.data); +export const isRecognizedPermit = (request: SignatureRequest) => { + if (!request) { + return false; + } + + const data = (request as SignatureRequest).messageParams?.data as string; + + const { primaryType } = parseTypedDataMessage(data); return PRIMARY_TYPES_PERMIT.includes(primaryType); }; diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 7d1326635765..7f48671fa749 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -137,6 +137,7 @@ export default { 'https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/', STAKING_RISK_DISCLOSURE: 'https://consensys.io/staking-risk-disclosures', }, + DECODING_API_URL: process.env.DECODING_API_URL || 'https://signature-insights.api.cx.metamask.io/v1', ERRORS: { INFURA_BLOCKED_MESSAGE: 'EthQuery - RPC Error - This service is not available in your country', diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index a0fe49693c3b..740c50e7b456 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1441,6 +1441,10 @@ export class Engine { }), // This casting expected due to mismatch of browser and react-native version of Sentry traceContext trace: trace as unknown as SignatureControllerOptions['trace'], + decodingApiUrl: AppConstants.DECODING_API_URL, + // TODO: check preferences useExternalServices + isDecodeSignatureRequestEnabled: () => + preferencesController.state.useTransactionSimulations, }), LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) diff --git a/app/selectors/currencyRateController.test.ts b/app/selectors/currencyRateController.test.ts index e48f6b957a46..61a29d381377 100644 --- a/app/selectors/currencyRateController.test.ts +++ b/app/selectors/currencyRateController.test.ts @@ -2,6 +2,7 @@ import { selectConversionRate, selectCurrentCurrency, selectCurrencyRates, + selectConversionRateByChainId, } from './currencyRateController'; import { isTestNet } from '../../app/util/networks'; import { CurrencyRateState } from '@metamask/assets-controllers'; @@ -64,6 +65,41 @@ describe('CurrencyRateController Selectors', () => { }); }); + describe('selectConversionRateByChainId', () => { + const mockChainId = '1'; + const mockNativeCurrency = 'ETH'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns undefined if on a testnet and fiat is disabled', () => { + (isTestNet as jest.Mock).mockReturnValue(true); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + false, + mockNativeCurrency, + ); + + expect(result).toBeUndefined(); + }); + + it('returns the conversion rate for the native currency of the chain id', () => { + (isTestNet as jest.Mock).mockReturnValue(false); + + const result = selectConversionRateByChainId.resultFunc( + mockCurrencyRateState.currencyRates as unknown as CurrencyRateState['currencyRates'], + mockChainId as `0x${string}`, + true, + mockNativeCurrency, + ); + + expect(result).toBe(3000); + }); + }); + describe('selectCurrentCurrency', () => { it('returns the current currency from the state', () => { const result = selectCurrentCurrency.resultFunc( diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 715ebeb4e8b0..296cf3eedd5e 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -1,7 +1,11 @@ import { createSelector } from 'reselect'; import { CurrencyRateState } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; -import { selectChainId, selectTicker } from './networkController'; +import { + selectChainId, + selectNativeCurrencyByChainId, + selectTicker, +} from './networkController'; import { isTestNet } from '../../app/util/networks'; const selectCurrencyRateControllerState = (state: RootState) => @@ -56,3 +60,22 @@ export const selectConversionRateFoAllChains = createSelector( (currencyRateControllerState: CurrencyRateState) => currencyRateControllerState?.currencyRates, ); + +export const selectConversionRateByChainId = createSelector( + selectConversionRateFoAllChains, + (_state: RootState, chainId: string) => chainId, + (state: RootState) => state.settings.showFiatOnTestnets, + selectNativeCurrencyByChainId, + ( + currencyRates: CurrencyRateState['currencyRates'], + chainId, + showFiatOnTestnets, + nativeCurrency, + ) => { + if (isTestNet(chainId) && !showFiatOnTestnets) { + return undefined; + } + + return currencyRates?.[nativeCurrency]?.conversionRate; + }, +); diff --git a/app/util/number/index.js b/app/util/number/index.js index 894716562aaf..05e8cbececd5 100644 --- a/app/util/number/index.js +++ b/app/util/number/index.js @@ -349,7 +349,7 @@ export function isBN(value) { /** * Determines if a string is a valid decimal * - * @param {string} value - String to check + * @param {number | string} value - String to check * @returns {boolean} - True if the string is a valid decimal */ export function isDecimal(value) { @@ -380,6 +380,22 @@ export function isNumber(str) { return regex.number.test(str); } +/** + * Determines if a value is a number + * + * @param {number | string | null | undefined} value - Value to check + * @returns {boolean} - True if the value is a valid number + */ +export function isNumberValue(value) { + if (value === null || value === undefined) { return false; } + + if (typeof value === 'number') { + return !Number.isNaN(value) && Number.isFinite(value); + } + + return isDecimal(value); +} + export const dotAndCommaDecimalFormatter = (value) => { const valueStr = String(value); diff --git a/app/util/number/index.test.ts b/app/util/number/index.test.ts index 436f18c0c29f..5849713f223e 100644 --- a/app/util/number/index.test.ts +++ b/app/util/number/index.test.ts @@ -20,6 +20,7 @@ import { isBN, isDecimal, isNumber, + isNumberValue, isNumberScientificNotationWhenString, isZeroValue, limitToMaximumDecimalPlaces, @@ -925,6 +926,42 @@ describe('Number utils :: isNumber', () => { }); }); +describe('Number utils :: isNumberValue', () => { + it('should return true for valid number types', () => { + expect(isNumberValue(1650.7)).toBe(true); + expect(isNumberValue(1000)).toBe(true); + expect(isNumberValue(0.0001)).toBe(true); + expect(isNumberValue(-0.0001)).toBe(true); + expect(isNumberValue(1)).toBe(true); + expect(isNumberValue(1e-10)).toBe(true); + }); + + it('should be a valid number string types', () => { + expect(isNumberValue('1650.7')).toBe(true); + expect(isNumberValue('1000')).toBe(true); + expect(isNumberValue('.01')).toBe(true); + expect(isNumberValue('0.0001')).toBe(true); + expect(isNumberValue('0001')).toBe(true); + expect(isNumberValue('-0.0001')).toBe(true); + expect(isNumberValue('1')).toBe(true); + expect(isNumberValue('1e-10')).toBe(true); + }); + + it('should not be a valid number ', () => { + expect(isNumberValue('..7')).toBe(false); + expect(isNumberValue('1..1')).toBe(false); + expect(isNumberValue('0..')).toBe(false); + expect(isNumberValue('a.0001')).toBe(false); + expect(isNumberValue('00a01')).toBe(false); + expect(isNumberValue('1,.')).toBe(false); + expect(isNumberValue('1,')).toBe(false); + expect(isNumberValue('.')).toBe(false); + expect(isNumberValue('a¡1')).toBe(false); + expect(isNumberValue(undefined)).toBe(false); + expect(isNumberValue(null)).toBe(false); + }); +}); + describe('Number utils :: dotAndCommaDecimalFormatter', () => { it('should return the number if it does not contain a dot or comma', () => { expect(dotAndCommaDecimalFormatter('1650')).toBe('1650'); diff --git a/locales/languages/en.json b/locales/languages/en.json index 861594b3a495..f9bb4d1f0c57 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3607,12 +3607,21 @@ "balance": "Balance", "network": "Network", "simulation": { + "decoded_tooltip_bid_nft": "The NFT will be reflected in your wallet, when the bid is accepted.", + "decoded_tooltip_list_nft": "Expect changes only if someone buys your NFTs.", "info_permit": "You’re giving the spender permission to spend this many tokens from your account.", + "label_change_type_bidding": "You bid", + "label_change_type_listing": "You list", + "label_change_type_nft_listing": "Listing price", "label_change_type_permit": "Spending cap", "label_change_type_permit_nft": "Withdraw", + "label_change_type_receive": "You receive", + "label_change_type_revoke": "Revoke", + "label_change_type_transfer": "You send", "personal_sign_info": "You’re signing into a site and there are no predicted changes to your account.", "title": "Estimated changes", - "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." + "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", + "unavailable": "Unavailable" } }, "change_in_simulation_modal": { From f27dbe60a4083b0a95e544379c23493027d5033a Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Thu, 16 Jan 2025 08:10:30 -0800 Subject: [PATCH 08/11] refactor: revert noBorder Ramp Box param and replace Ramp Box usage in SimulationValueDisplay with View (#13033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** revert noBorder Ramp Box param and replace Ramp Box usage in SimulationValueDisplay with View ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/13013 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../__snapshots__/BuildQuote.test.tsx.snap | 14 -------------- .../__snapshots__/OrderDetails.test.tsx.snap | 9 --------- .../__snapshots__/PaymentMethods.test.tsx.snap | 18 ------------------ .../Quotes/__snapshots__/Quotes.test.tsx.snap | 8 -------- .../__snapshots__/Regions.test.tsx.snap | 9 --------- app/components/UI/Ramp/components/Box.tsx | 6 ------ 6 files changed, 64 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 52e3ff03e42e..1abf3c09e473 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -2604,7 +2604,6 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "padding": 0, }, undefined, - undefined, ] } > @@ -2794,7 +2793,6 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "padding": 0, }, undefined, - undefined, ] } > @@ -4194,7 +4192,6 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, - undefined, ] } > @@ -4384,7 +4381,6 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "padding": 0, }, undefined, - undefined, ] } > @@ -5784,7 +5780,6 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, - undefined, ] } > @@ -5974,7 +5969,6 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "padding": 0, }, undefined, - undefined, ] } > @@ -7374,7 +7368,6 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, - undefined, ] } > @@ -7564,7 +7557,6 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "padding": 0, }, undefined, - undefined, ] } > @@ -8511,7 +8503,6 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -8842,7 +8833,6 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -9137,7 +9127,6 @@ exports[`BuildQuote View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -11620,7 +11609,6 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -11951,7 +11939,6 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -12094,7 +12081,6 @@ exports[`BuildQuote View renders correctly 2`] = ` "padding": 0, }, undefined, - undefined, ] } > diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index 88387a8f98cd..6ab703e40dac 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -585,7 +585,6 @@ exports[`OrderDetails renders a cancelled order 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -2083,7 +2082,6 @@ exports[`OrderDetails renders a completed order 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3595,7 +3593,6 @@ exports[`OrderDetails renders a created order 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -5023,7 +5020,6 @@ exports[`OrderDetails renders a failed order 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -6535,7 +6531,6 @@ exports[`OrderDetails renders a pending order 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -9070,7 +9065,6 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -10609,7 +10603,6 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -12141,7 +12134,6 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription "padding": 0, }, undefined, - undefined, ] } > @@ -13616,7 +13608,6 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending "padding": 0, }, undefined, - undefined, ] } > diff --git a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap index e5a9d3b334fe..2e25a8d696df 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap +++ b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap @@ -495,7 +495,6 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -781,7 +780,6 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -1084,7 +1082,6 @@ exports[`PaymentMethods View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -1945,7 +1942,6 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -2231,7 +2227,6 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -2534,7 +2529,6 @@ exports[`PaymentMethods View renders correctly for sell 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3391,7 +3385,6 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3641,7 +3634,6 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3893,7 +3885,6 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -6591,7 +6582,6 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -6841,7 +6831,6 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -7093,7 +7082,6 @@ exports[`PaymentMethods View renders correctly with null data 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -7823,7 +7811,6 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, - undefined, ] } > @@ -8109,7 +8096,6 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, - undefined, ] } > @@ -8412,7 +8398,6 @@ exports[`PaymentMethods View renders correctly with payment method with disclaim "padding": 0, }, undefined, - undefined, ] } > @@ -9964,7 +9949,6 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, - undefined, ] } > @@ -10250,7 +10234,6 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, - undefined, ] } > @@ -10553,7 +10536,6 @@ exports[`PaymentMethods View renders correctly with show back button false 1`] = "padding": 0, }, undefined, - undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 97f6fd0365aa..3ad1e4f304e8 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -35,7 +35,6 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -277,7 +276,6 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -416,7 +414,6 @@ exports[`LoadingQuotes component renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3546,7 +3543,6 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -3819,7 +3815,6 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -5023,7 +5018,6 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, - undefined, ] } > @@ -5296,7 +5290,6 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, - undefined, ] } > @@ -5596,7 +5589,6 @@ exports[`Quotes renders correctly after animation with quotes and expanded 2`] = "padding": 0, }, undefined, - undefined, ] } > diff --git a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap index cffaf29508dc..d181f8b85429 100644 --- a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap @@ -566,7 +566,6 @@ exports[`Regions View renders correctly 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -1346,7 +1345,6 @@ exports[`Regions View renders correctly while loading 1`] = ` undefined, undefined, undefined, - undefined, ] } > @@ -2575,7 +2573,6 @@ exports[`Regions View renders correctly with no data 1`] = ` undefined, undefined, undefined, - undefined, ] } > @@ -3825,7 +3822,6 @@ exports[`Regions View renders correctly with selectedRegion 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -4624,7 +4620,6 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -4949,7 +4944,6 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` undefined, undefined, undefined, - undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -5799,7 +5793,6 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` "padding": 0, }, undefined, - undefined, ] } > @@ -6124,7 +6117,6 @@ exports[`Regions View renders correctly with unsupportedRegion 2`] = ` undefined, undefined, undefined, - undefined, { "backgroundColor": "#ffffff", "borderWidth": 0, @@ -6974,7 +6966,6 @@ exports[`Regions View renders regions modal when pressing select button 1`] = ` "padding": 0, }, undefined, - undefined, ] } > diff --git a/app/components/UI/Ramp/components/Box.tsx b/app/components/UI/Ramp/components/Box.tsx index 54eb1b9cd0ef..41974701101b 100644 --- a/app/components/UI/Ramp/components/Box.tsx +++ b/app/components/UI/Ramp/components/Box.tsx @@ -21,9 +21,6 @@ const createStyles = (colors: Colors) => label: { marginVertical: 8, }, - noBorder: { - borderWidth: 0, - }, highlighted: { borderColor: colors.primary.default, }, @@ -41,7 +38,6 @@ interface Props { style?: StyleProp<ViewStyle>; thin?: boolean; activeOpacity?: number; - noBorder?: boolean; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any onPress?: () => any; @@ -60,7 +56,6 @@ const Box: React.FC<Props> = ({ accessible, accessibilityLabel, compact, - noBorder, ...props }: Props) => { const { colors } = useTheme(); @@ -82,7 +77,6 @@ const Box: React.FC<Props> = ({ thin && styles.thin, highlighted && styles.highlighted, compact && styles.compact, - noBorder && styles.noBorder, style, ]} {...props} From d044c1c680096446c7c75cd656e2d342fabd172b Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Thu, 16 Jan 2025 08:20:48 -0800 Subject: [PATCH 09/11] feat: "Unlimited" value Decoding Simulation and account and message modal UI/UX updates (#13030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Feat: - Support "Unlimited" display values in decoding simulation - Update account detail and message modals to have transparent backgrounds and enable click to close on background Note: It might be helpful to walk through the commits to review ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/13022 Relates to: https://github.com/MetaMask/metamask-mobile/pull/12994 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** ![CleanShot 2025-01-16 at 03 23 30](https://github.com/user-attachments/assets/fd192b87-c5b7-4bb8-9d35-509b8c6bd55a) ![CleanShot 2025-01-16 at 04 12 30](https://github.com/user-attachments/assets/3468d59b-f045-4f5a-8669-09de78b7b7d1) ### **After** ![CleanShot 2025-01-16 at 03 22 54](https://github.com/user-attachments/assets/4a02032e-92bb-4579-aebf-311dab8e3184) ![CleanShot 2025-01-16 at 03 20 50](https://github.com/user-attachments/assets/9dd7b37c-8ced-4f50-b247-fdd7cd698247) ### **Before without "Unlimited" support** <img width="320" src="https://github.com/user-attachments/assets/cd0864e5-5497-4e5a-8507-3e5a2ef0b812"> ### **After "Unlimited" support** <img width="320" src="https://github.com/user-attachments/assets/99d68e50-e381-42cd-b88f-724278fbd597"> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../TypedSignV3V4/Simulation/Simulation.tsx | 4 +-- .../TypedSignDecoded.test.tsx | 13 +++++++ .../TypedSignDecoded/TypedSignDecoded.tsx | 13 ++++--- .../ValueDisplay/ValueDisplay.test.tsx | 34 ++++++++++++------- .../components/ValueDisplay/ValueDisplay.tsx | 15 ++++++-- .../ExpandableSection/ExpandableSection.tsx | 2 +- .../Views/confirmations/utils/confirm.ts | 3 ++ locales/languages/en.json | 3 +- 8 files changed, 62 insertions(+), 25 deletions(-) diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx index 981a93559113..37265d0d0cdc 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.tsx @@ -16,12 +16,12 @@ const TypedSignV3V4Simulation: React.FC<object> = () => { } const { decodingData, decodingLoading } = signatureRequest; - const hasDecodingData = !( + const hasValidDecodingData = !( (!decodingLoading && decodingData === undefined) || decodingData?.error ); - if (!hasDecodingData && isPermit) { + if (!hasValidDecodingData && isPermit) { return <PermitSimulation />; } diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx index c37fc11bc81a..b974d9514432 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx @@ -97,6 +97,19 @@ describe('DecodedSimulation', () => { expect(await getByText('12,345')).toBeDefined(); }); + it('renders "Unlimited" for large values', async () => { + const { getByText } = renderWithProvider(<TypedSignDecoded />, { + state: mockState([{ + ...stateChangesApprove[0], + amount: '1461501637330902918203684832716283019655932542975', + }]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('Unlimited')).toBeDefined(); + }); + it('renders for ERC712 token', async () => { const { getByText } = renderWithProvider(<TypedSignDecoded />, { state: mockState(stateChangesNftListing), diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx index 3e712edf440d..2adc5d40fece 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.tsx @@ -104,11 +104,11 @@ const StateChangeRow = ({ stateChange; const nftTransactionType = getStateChangeType(stateChangeList, stateChange); const tooltip = shouldDisplayLabel ? getStateChangeToolip(nftTransactionType) : undefined; - // todo: add - // const canDisplayValueAsUnlimited = - // assetType === TokenStandard.ERC20 && - // (changeType === DecodingDataChangeType.Approve || - // changeType === DecodingDataChangeType.Revoke); + + const canDisplayValueAsUnlimited = + assetType === TokenStandard.ERC20 && + (changeType === DecodingDataChangeType.Approve || + changeType === DecodingDataChangeType.Revoke); const changeLabel = shouldDisplayLabel ? getStateChangeLabelMap(changeType, nftTransactionType) @@ -133,8 +133,7 @@ const StateChangeRow = ({ changeType === DecodingDataChangeType.Receive } debit={changeType === DecodingDataChangeType.Transfer} - // todo: add - // canDisplayValueAsUnlimited={canDisplayValueAsUnlimited} + canDisplayValueAsUnlimited={canDisplayValueAsUnlimited} /> )} {assetType === 'NATIVE' && ( diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx index 8decf038ea1c..04d399656f5a 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx @@ -23,7 +23,6 @@ const mockTrackEvent = jest.fn(); jest.mock('../../../../../../../../../hooks/useMetrics'); jest.mock('../../../../../../../hooks/useGetTokenStandardAndDetails'); - jest.mock('../../../../../../../../../../util/address', () => ({ getTokenDetails: jest.fn(), renderShortAddress: jest.requireActual('../../../../../../../../../../util/address').renderShortAddress @@ -72,11 +71,30 @@ describe('SimulationValueDisplay', () => { { state: mockInitialState }, ); - await act(async () => { - await Promise.resolve(); + expect(await findByText('0.432')).toBeDefined(); + }); + + it('renders "Unlimited" for large values when canDisplayValueAsUnlimited is true', async () => { + (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: TokenStandard.ERC20, + decimalsNumber: 4, }); - expect(await findByText('0.432')).toBeDefined(); + const { findByText } = renderWithProvider( + <SimulationValueDisplay + canDisplayValueAsUnlimited + labelChangeType={'Spending Cap'} + tokenContract={'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'} + value={'1461501637330902918203684832716283019655932542975'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + expect(await findByText('Unlimited')).toBeDefined(); }); it('should invoke method to track missing decimal information for ERC20 tokens only once', async () => { @@ -98,10 +116,6 @@ describe('SimulationValueDisplay', () => { { state: mockInitialState }, ); - await act(async () => { - await Promise.resolve(); - }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); @@ -124,10 +138,6 @@ describe('SimulationValueDisplay', () => { { state: mockInitialState }, ); - await act(async () => { - await Promise.resolve(); - }); - expect(mockTrackEvent).not.toHaveBeenCalled(); }); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx index dddcc95d8de7..44622075f16c 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.tsx @@ -29,10 +29,12 @@ import { calcTokenAmount } from '../../../../../../../../../../util/transactions import useGetTokenStandardAndDetails from '../../../../../../../hooks/useGetTokenStandardAndDetails'; import useTrackERC20WithoutDecimalInformation from '../../../../../../../hooks/useTrackERC20WithoutDecimalInformation'; +import { TOKEN_VALUE_UNLIMITED_THRESHOLD } from '../../../../../../../utils/confirm'; import { TokenDetailsERC20 } from '../../../../../../../utils/token'; import BottomModal from '../../../../../../UI/BottomModal'; import styleSheet from './ValueDisplay.styles'; +import { strings } from '../../../../../../../../../../../locales/i18n'; interface SimulationValueDisplayParams { /** ID of the associated chain. */ @@ -53,6 +55,9 @@ interface SimulationValueDisplayParams { // Optional + /** Whether a large amount can be substituted by "Unlimited" */ + canDisplayValueAsUnlimited?: boolean; + /** True if value is being credited to wallet */ credit?: boolean; @@ -81,6 +86,7 @@ const SimulationValueDisplay: React.FC< value, credit, debit, + canDisplayValueAsUnlimited = false, }) => { const [hasValueModalOpen, setHasValueModalOpen] = useState(false); @@ -112,6 +118,9 @@ const SimulationValueDisplay: React.FC< const tokenValue = isValidTokenAmount ? formatAmount('en-US', tokenAmount) : null; const tokenValueMaxPrecision = isValidTokenAmount ? formatAmountMaxPrecision('en-US', tokenAmount) : null; + const shouldShowUnlimitedValue = canDisplayValueAsUnlimited && + Number(value) > TOKEN_VALUE_UNLIMITED_THRESHOLD; + /** Temporary error capturing as we are building out Permit Simulations */ if (!tokenContract) { Logger.error( @@ -140,8 +149,10 @@ const SimulationValueDisplay: React.FC< <Text> {credit && '+ '} {debit && '- '} - {tokenValue !== null && - shortenString(tokenValue || '', { + {shouldShowUnlimitedValue + ? strings('confirm.unlimited') + : tokenValue !== null && + shortenString(tokenValue || '', { truncatedCharLimit: 15, truncatedStartChars: 15, truncatedEndChars: 0, diff --git a/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx b/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx index 0417a1c9ce2c..4c738c73648d 100644 --- a/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx +++ b/app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.tsx @@ -61,7 +61,7 @@ const ExpandableSection = ({ </View> </TouchableOpacity> {expanded && ( - <BottomModal hideBackground> + <BottomModal onClose={() => setExpanded(false)} canCloseOnBackdropClick> <View style={styles.modalContent}> <View style={styles.modalHeader}> <ButtonIcon diff --git a/app/components/Views/confirmations/utils/confirm.ts b/app/components/Views/confirmations/utils/confirm.ts index 6f9b61bdf5c0..f4e414f15830 100644 --- a/app/components/Views/confirmations/utils/confirm.ts +++ b/app/components/Views/confirmations/utils/confirm.ts @@ -1,8 +1,11 @@ import { ApprovalTypes } from '../../../../core/RPCMethods/RPCMethodMiddleware'; +export const TOKEN_VALUE_UNLIMITED_THRESHOLD = 10 ** 15; + export function isSignatureRequest(requestType: string) { return [ ApprovalTypes.PERSONAL_SIGN, ApprovalTypes.ETH_SIGN_TYPED_DATA, ].includes(requestType as ApprovalTypes); } + diff --git a/locales/languages/en.json b/locales/languages/en.json index f9bb4d1f0c57..666c154faf33 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3622,7 +3622,8 @@ "title": "Estimated changes", "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", "unavailable": "Unavailable" - } + }, + "unlimited": "Unlimited" }, "change_in_simulation_modal": { "title": "Results have changed", From f7e30cad4ca46df85860c746cffa2423c538aff4 Mon Sep 17 00:00:00 2001 From: digiwand <20778143+digiwand@users.noreply.github.com> Date: Thu, 16 Jan 2025 08:37:43 -0800 Subject: [PATCH 10/11] test: Redesign Signature Decoding Simulation (#13026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Create Additional Tests for new Decoding Simulation feat ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/13023 Relates to: https://github.com/MetaMask/metamask-mobile/pull/12994 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../confirmations/Confirm/Confirm.test.tsx | 2 +- .../Info/TypedSignV1/TypedSignV1.test.tsx | 2 +- .../Simulation/Simulation.test.tsx | 142 ++++++++++++++++++ .../Simulation/Static/Static.test.tsx | 41 +++++ .../TypedSignDecoded.test.tsx | 44 ++++-- .../TypedSignPermit/TypedSignPermit.test.tsx | 6 +- .../NativeValueDisplay.test.tsx | 43 ++++++ .../ValueDisplay/ValueDisplay.test.tsx | 31 ++-- .../NoChangeSimulation.test.tsx | 2 +- app/util/test/confirm-data-helpers.ts | 11 +- locales/languages/en.json | 4 +- 11 files changed, 300 insertions(+), 28 deletions(-) create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx create mode 100644 app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx diff --git a/app/components/Views/confirmations/Confirm/Confirm.test.tsx b/app/components/Views/confirmations/Confirm/Confirm.test.tsx index 8007a4c98669..6f6da173c48f 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.test.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.test.tsx @@ -57,7 +57,7 @@ describe('Confirm', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account.", ), ).toBeDefined(); expect(getByText('Request from')).toBeDefined(); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx index 0c533f651c9c..31af8bd89551 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV1/TypedSignV1.test.tsx @@ -12,7 +12,7 @@ describe('TypedSignV1', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account.", ), ).toBeDefined(); expect(getByText('Request from')).toBeDefined(); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx new file mode 100644 index 000000000000..6f015ab9b160 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Simulation.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { + DecodingData, + DecodingDataChangeType, + DecodingDataStateChanges, + SignatureRequest, +} from '@metamask/signature-controller'; +import useGetTokenStandardAndDetails from '../../../../../hooks/useGetTokenStandardAndDetails'; +import { typedSignV4ConfirmationState } from '../../../../../../../../util/test/confirm-data-helpers'; +import renderWithProvider from '../../../../../../../../util/test/renderWithProvider'; +import { memoizedGetTokenStandardAndDetails } from '../../../../../utils/token'; +import TypedSignV3V4Simulation from './Simulation'; + +jest.mock('../../../../../hooks/useGetTokenStandardAndDetails'); + +jest.mock('../../../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: () => 'mainnet', + }, + }, +})); + +const stateChangesApprove = [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '12345', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, +]; + +const mockState = ( + mockStateChanges: DecodingDataStateChanges, + { + mockDecodingDataProps, + stubDecodingLoading = false, + }: { + mockDecodingDataProps?: Partial<DecodingData>; + stubDecodingLoading?: boolean; + } = { + mockDecodingDataProps: {}, + stubDecodingLoading: false, + }, +) => { + const clonedMockState = cloneDeep(typedSignV4ConfirmationState); + const request = clonedMockState.engine.backgroundState.SignatureController + .signatureRequests[ + 'fb2029e1-b0ab-11ef-9227-05a11087c334' + ] as SignatureRequest; + + request.decodingLoading = stubDecodingLoading; + request.decodingData = { + ...mockDecodingDataProps, + stateChanges: mockStateChanges, + }; + + return clonedMockState; +}; + +describe('PermitSimulation', () => { + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('renders DecodedSimulation loader if decodingLoading is true', async () => { + const { queryByTestId } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState(stateChangesApprove, { + stubDecodingLoading: true, + }), + }); + + expect(await queryByTestId('confirm-v3v4-simulation-loader')).toBeDefined(); + }); + + it('renders DecodingSimulation with "Unavailable" if decoding data is empty', async () => { + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState([]), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Unavailable')).toBeDefined(); + }); + + it('renders DecodingSimulation for permits', async () => { + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ + symbol: 'TST', + decimals: '4', + balance: undefined, + standard: 'ERC20', + decimalsNumber: 4, + }); + + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState(stateChangesApprove), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('1.235')).toBeDefined(); + }); + + it('renders PermitSimulation if decoding api returns error', async () => { + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ + symbol: 'TST', + decimals: '2', + balance: undefined, + standard: 'ERC20', + decimalsNumber: 4, + }); + + const { getByText } = renderWithProvider(<TypedSignV3V4Simulation />, { + state: mockState([], { + mockDecodingDataProps: { + error: { message: 'some error', type: 'SOME_ERROR' }, + } as Partial<DecodingData>, + }), + }); + + expect(await getByText('Estimated changes')).toBeDefined(); + expect(await getByText('Spending cap')).toBeDefined(); + expect(await getByText('0.3')).toBeDefined(); + expect( + await getByText( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx new file mode 100644 index 000000000000..237ceffd2263 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/Static/Static.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import StaticSimulation from './Static'; + +const mockProps = { + title: 'Test Title', + titleTooltip: 'Test Tooltip', + description: 'Test Description', + simulationElements: <></>, +}; + +describe('StaticSimulation', () => { + it('renders correctly with basic props', () => { + const { getByText } = render(<StaticSimulation {...mockProps} />); + + expect(getByText('Test Title')).toBeDefined(); + expect(getByText('Test Description')).toBeDefined(); + }); + + it('shows loader when isLoading is true', () => { + const { queryByTestId } = render( + <StaticSimulation {...mockProps} isLoading />, + ); + + expect(queryByTestId('confirm-v3v4-simulation-loader')).toBeDefined(); + }); + + it('shows simulation elements when not loading', () => { + const simulationElements = <Text>Test Simulation</Text>; + const { getByText } = render( + <StaticSimulation + {...mockProps} + simulationElements={simulationElements} + isLoading={false} + />, + ); + + expect(getByText('Test Simulation')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx index b974d9514432..cc9da83b409b 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignDecoded/TypedSignDecoded.test.tsx @@ -9,7 +9,11 @@ import { import { strings } from '../../../../../../../../../../locales/i18n'; import { typedSignV4ConfirmationState } from '../../../../../../../../../util/test/confirm-data-helpers'; import renderWithProvider from '../../../../../../../../../util/test/renderWithProvider'; -import TypedSignDecoded, { getStateChangeToolip, getStateChangeType, StateChangeType } from './TypedSignDecoded'; +import TypedSignDecoded, { + getStateChangeToolip, + getStateChangeType, + StateChangeType, +} from './TypedSignDecoded'; const stateChangesApprove = [ { @@ -75,9 +79,15 @@ const stateChangesNftBidding: DecodingDataStateChanges = [ }, ]; -const mockState = (mockStateChanges: DecodingDataStateChanges, stubDecodingLoading: boolean = false) => { +const mockState = ( + mockStateChanges: DecodingDataStateChanges, + stubDecodingLoading: boolean = false, +) => { const clonedMockState = cloneDeep(typedSignV4ConfirmationState); - const request = clonedMockState.engine.backgroundState.SignatureController.signatureRequests['fb2029e1-b0ab-11ef-9227-05a11087c334'] as SignatureRequest; + const request = clonedMockState.engine.backgroundState.SignatureController + .signatureRequests[ + 'fb2029e1-b0ab-11ef-9227-05a11087c334' + ] as SignatureRequest; request.decodingLoading = stubDecodingLoading; request.decodingData = { stateChanges: mockStateChanges, @@ -134,7 +144,11 @@ describe('DecodedSimulation', () => { it('renders label only once if there are multiple state changes of same changeType', async () => { const { getAllByText } = renderWithProvider(<TypedSignDecoded />, { - state: mockState([stateChangesApprove[0], stateChangesApprove[0], stateChangesApprove[0]]), + state: mockState([ + stateChangesApprove[0], + stateChangesApprove[0], + stateChangesApprove[0], + ]), }); expect(await getAllByText('12,345')).toHaveLength(3); @@ -152,28 +166,34 @@ describe('DecodedSimulation', () => { describe('getStateChangeToolip', () => { it('return correct tooltip when permit is for listing NFT', () => { - const tooltip = getStateChangeToolip( - StateChangeType.NFTListingReceive, + const tooltip = getStateChangeToolip(StateChangeType.NFTListingReceive); + expect(tooltip).toBe( + strings('confirm.simulation.decoded_tooltip_list_nft'), ); - expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_list_nft')); }); it('return correct tooltip when permit is for bidding NFT', () => { - const tooltip = getStateChangeToolip( - StateChangeType.NFTBiddingReceive, + const tooltip = getStateChangeToolip(StateChangeType.NFTBiddingReceive); + expect(tooltip).toBe( + strings('confirm.simulation.decoded_tooltip_bid_nft'), ); - expect(tooltip).toBe(strings('confirm.simulation.decoded_tooltip_bid_nft')); }); }); describe('getStateChangeType', () => { it('return correct state change type for NFT listing receive', () => { - const stateChange = getStateChangeType(stateChangesNftListing, stateChangesNftListing[1]); + const stateChange = getStateChangeType( + stateChangesNftListing, + stateChangesNftListing[1], + ); expect(stateChange).toBe(StateChangeType.NFTListingReceive); }); it('return correct state change type for NFT bidding receive', () => { - const stateChange = getStateChangeType(stateChangesNftBidding, stateChangesNftBidding[1]); + const stateChange = getStateChangeType( + stateChangesNftBidding, + stateChangesNftBidding[1], + ); expect(stateChange).toBe(StateChangeType.NFTBiddingReceive); }); }); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx index 2b814f9629b5..e7ff8ddf91d0 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/TypedSignPermit/TypedSignPermit.test.tsx @@ -19,7 +19,11 @@ describe('PermitSimulation', () => { }); expect(getByText('Estimated changes')).toBeDefined(); - expect(getByText('You’re giving the spender permission to spend this many tokens from your account.')).toBeDefined(); + expect( + getByText( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).toBeDefined(); expect(getByText('Spending cap')).toBeDefined(); expect(getByText('3,000')).toBeDefined(); expect(getByText('0xCcCCc...ccccC')).toBeDefined(); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx new file mode 100644 index 000000000000..f0ab367e2eee --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/NativeValueDisplay/NativeValueDisplay.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import NativeValueDisplay from './NativeValueDisplay'; +import { backgroundState } from '../../../../../../../../../../util/test/initial-root-state'; +import renderWithProvider from '../../../../../../../../../../util/test/renderWithProvider'; +import { fireEvent } from '@testing-library/react-native'; + +const mockInitialState = { + engine: { + backgroundState, + }, +}; + +describe('NativeValueDisplay', () => { + it('renders component correctly', async () => { + const { findByText } = renderWithProvider( + <NativeValueDisplay + labelChangeType={'Spending Cap'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + expect(await findByText('< 0.000001')).toBeDefined(); + expect(await findByText('ETH')).toBeDefined(); + }); + + it('displays modal when clicking on the value', async () => { + const { findByText } = renderWithProvider( + <NativeValueDisplay + labelChangeType={'Spending Cap'} + value={'4321'} + chainId={'0x1'} + />, + { state: mockInitialState }, + ); + + const button = await findByText('< 0.000001'); + fireEvent.press(button); + + expect(await findByText('Spending Cap')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx index 04d399656f5a..8cf784909dc8 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/TypedSignV3V4/Simulation/components/ValueDisplay/ValueDisplay.test.tsx @@ -1,4 +1,3 @@ - import React from 'react'; import { act } from '@testing-library/react-native'; import SimulationValueDisplay from './ValueDisplay'; @@ -25,7 +24,9 @@ jest.mock('../../../../../../../hooks/useGetTokenStandardAndDetails'); jest.mock('../../../../../../../../../../util/address', () => ({ getTokenDetails: jest.fn(), - renderShortAddress: jest.requireActual('../../../../../../../../../../util/address').renderShortAddress + renderShortAddress: jest.requireActual( + '../../../../../../../../../../util/address', + ).renderShortAddress, })); describe('SimulationValueDisplay', () => { @@ -53,7 +54,11 @@ describe('SimulationValueDisplay', () => { }); it('renders component correctly', async () => { - (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ symbol: 'TST', decimals: '4', balance: undefined, @@ -98,7 +103,11 @@ describe('SimulationValueDisplay', () => { }); it('should invoke method to track missing decimal information for ERC20 tokens only once', async () => { - (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ symbol: 'TST', decimals: undefined, balance: undefined, @@ -120,7 +129,11 @@ describe('SimulationValueDisplay', () => { }); it('should not invoke method to track missing decimal information for ERC20 tokens', async () => { - (useGetTokenStandardAndDetails as jest.MockedFn<typeof useGetTokenStandardAndDetails>).mockReturnValue({ + ( + useGetTokenStandardAndDetails as jest.MockedFn< + typeof useGetTokenStandardAndDetails + > + ).mockReturnValue({ symbol: 'TST', decimals: '4', balance: undefined, @@ -144,10 +157,10 @@ describe('SimulationValueDisplay', () => { describe('when token is an ERC721 token', () => { beforeEach(() => { jest.mocked(getTokenDetails).mockResolvedValue({ - name: 'TST', - symbol: 'TST', - standard: TokenStandard.ERC721, - }); + name: 'TST', + symbol: 'TST', + standard: TokenStandard.ERC721, + }); }); it('should not invoke method to track missing decimal information', async () => { diff --git a/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx b/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx index 0f11ada192e1..838361aee88a 100644 --- a/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/NoChangeSimulation/NoChangeSimulation.test.tsx @@ -12,7 +12,7 @@ describe('NoChangeSimulation', () => { expect(getByText('Estimated changes')).toBeDefined(); expect( getByText( - 'You’re signing into a site and there are no predicted changes to your account.', + "You're signing into a site and there are no predicted changes to your account." ), ).toBeDefined(); }); diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index 535b3cf4d4ab..485228a065a7 100644 --- a/app/util/test/confirm-data-helpers.ts +++ b/app/util/test/confirm-data-helpers.ts @@ -255,8 +255,17 @@ export const typedSignV4ConfirmationState = { messageParams: { data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + requestId: 14, + signatureMethod: 'eth_signTypedData_v4', + origin: 'https://metamask.github.io', metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', - origin: 'https://metamask.github.io' + meta: { + url: 'https://metamask.github.io/test-dapp/', + title: 'E2E Test Dapp', + icon: { uri: 'https://metamask.github.io/metamask-fox.svg' }, + analytics: { request_source: 'In-App-Browser' }, + }, }, networkClientId: '1', status: SignatureRequestStatus.Unapproved, diff --git a/locales/languages/en.json b/locales/languages/en.json index 666c154faf33..b993d227b59c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3609,7 +3609,7 @@ "simulation": { "decoded_tooltip_bid_nft": "The NFT will be reflected in your wallet, when the bid is accepted.", "decoded_tooltip_list_nft": "Expect changes only if someone buys your NFTs.", - "info_permit": "You’re giving the spender permission to spend this many tokens from your account.", + "info_permit": "You're giving the spender permission to spend this many tokens from your account.", "label_change_type_bidding": "You bid", "label_change_type_listing": "You list", "label_change_type_nft_listing": "Listing price", @@ -3618,7 +3618,7 @@ "label_change_type_receive": "You receive", "label_change_type_revoke": "Revoke", "label_change_type_transfer": "You send", - "personal_sign_info": "You’re signing into a site and there are no predicted changes to your account.", + "personal_sign_info": "You're signing into a site and there are no predicted changes to your account.", "title": "Estimated changes", "tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", "unavailable": "Unavailable" From d1946d2037399356b7b582b07e09b0528a68e0ac Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Thu, 16 Jan 2025 20:41:58 +0400 Subject: [PATCH 11/11] feat: log-merge-group-failure (#12936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR integrates the new reusable workflow (https://github.com/MetaMask/github-tools/pull/31) that can be used to write data to google sheets when the merge group event fails. Environment variables (= GitHub secrets) are required to configure: - GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_SERVICE_ACCOUNT: The google service account that is used for authentication with the google sheets api. - The service account needs to have access to the google sheet you intend to operate on. To give access to the google sheet, you need to click the "Share" button in the top-right corner. Enter the service account's email (can be found on google cloud console), give "Editor" permissions. After this, the service account will have access to this spreadsheet, allowing read and write operations. - SPREADSHEET_ID: unique identifier that can be found in the url when you open the google sheet - SHEET_NAME: name of a sheet in a spreadsheet that can be found on the bottom Short summary of what happens in the workflow: 1. Google api authentication setup 2. Check if current date exists in the spreadsheet 3. If current date exists, increment number of PRs by 1 4. If current date does not exist, create a new row with the current date, and the number 1 (so that it can be incremented later, if more merge group events fail on the same day). Spreadsheet here: https://docs.google.com/spreadsheets/d/11niHgT_E2YzzXHXQSxX5LNdA6i0GG5aa-OZWvn7_-o4 Dashboard here: https://lookerstudio.google.com/u/1/reporting/e7e8f90e-72aa-4128-ae01-6305bf3393f4/page/p_pz1dszarmd ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3400 ## **Manual testing steps** 1. When the workflow runs, depending on the state of the spreadsheet: - If the current date already exists, the number of PRs removed from the merge queue should be incremented by 1. - If the current date does not exist, a new row should be added with the current date, and the number of PRs should be set to 1. I created a private repository to test it, and there it worked. The actual production usage can only be tested if we merge and see if anything gets removed from the merge queue, in this case the spreadsheet should get updated. ## **Screenshots/Recordings** Not applicable ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d9eedf316c3..f42424127463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,3 +335,16 @@ jobs: echo "All jobs passed step skipped. Block PR." exit 1 fi + + log-merge-group-failure: + name: Log merge group failure + # Only run this job if the merge group event fails, skip on forks + if: ${{ github.event_name == 'merge_group' && failure() && !github.event.repository.fork }} + needs: + - check-all-jobs-pass + uses: metamask/github-tools/.github/workflows/log-merge-group-failure.yml@6bbad335a01fce1a9ec1eabd9515542c225d46c0 + secrets: + GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + GOOGLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + SPREADSHEET_ID: ${{ secrets.GOOGLE_MERGE_QUEUE_SPREADSHEET_ID }} + SHEET_NAME: ${{ secrets.GOOGLE_MERGE_QUEUE_SHEET_NAME }}