From 30d7f1c6d0d5386eab3368691144c7625375b60c Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Mon, 21 Oct 2024 18:44:41 +0000 Subject: [PATCH 01/56] Version v12.6.0 --- CHANGELOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b33f07fb3d5..fda366ce5558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,219 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.6.0] +### Uncategorized +- ci: reduced Sentry frequency on CircleCI develop ([#27912](https://github.com/MetaMask/metamask-extension/pull/27912)) +- chore:Master sync ([#27935](https://github.com/MetaMask/metamask-extension/pull/27935)) +- Merge origin/develop into master-sync +- test: Completing missing step for import ERC1155 token origin dapp in existing E2E test ([#27680](https://github.com/MetaMask/metamask-extension/pull/27680)) +- fix: error in navigating between transaction when one of the transaction is approve all ([#27985](https://github.com/MetaMask/metamask-extension/pull/27985)) +- fix: Automatically expand first insight ([#27872](https://github.com/MetaMask/metamask-extension/pull/27872)) +- feat(metametrics): use specific `account_hardware_type` for OneKey devices ([#27296](https://github.com/MetaMask/metamask-extension/pull/27296)) +- feat: add migration 131 ([#27364](https://github.com/MetaMask/metamask-extension/pull/27364)) +- fix(snaps): Remove arrows of custom UI inputs ([#27953](https://github.com/MetaMask/metamask-extension/pull/27953)) +- chore: Disable account syncing in prod ([#27943](https://github.com/MetaMask/metamask-extension/pull/27943)) +- test: Remove delays from onboarding tests ([#27961](https://github.com/MetaMask/metamask-extension/pull/27961)) +- perf: Create custom trace to measure performance of opening the account list ([#27907](https://github.com/MetaMask/metamask-extension/pull/27907)) +- feat: add BTC send flow ([#27964](https://github.com/MetaMask/metamask-extension/pull/27964)) +- fix: flaky test `Confirmation Redesign ERC721 Approve Component Submit an Approve transaction @no-mmi Sends a type 2 transaction (EIP1559)` ([#27928](https://github.com/MetaMask/metamask-extension/pull/27928)) +- fix: lint-lockfile flaky job by changing resources from medium to medium-plus ([#27950](https://github.com/MetaMask/metamask-extension/pull/27950)) +- feat: add “Incomplete Asset Displayed” metric & fix: should only set default decimals if ERC20 ([#27494](https://github.com/MetaMask/metamask-extension/pull/27494)) +- feat: Convert AppStateController to typescript ([#27572](https://github.com/MetaMask/metamask-extension/pull/27572)) +- chore(deps): upgrade from json-rpc-engine to @metamask/json-rpc-engine ([#22875](https://github.com/MetaMask/metamask-extension/pull/22875)) +- feat: dapp initiated token transfer ([#27875](https://github.com/MetaMask/metamask-extension/pull/27875)) +- chore: bump signature controller to remove message managers ([#27787](https://github.com/MetaMask/metamask-extension/pull/27787)) +- chore: add testing-library/dom dependency ([#27493](https://github.com/MetaMask/metamask-extension/pull/27493)) +- test: [POM] Migrate contract interaction with snap account e2e tests to page object modal ([#27924](https://github.com/MetaMask/metamask-extension/pull/27924)) +- fix: bump message signing snap to support portfolio automatic connections ([#27936](https://github.com/MetaMask/metamask-extension/pull/27936)) +- fix: hide options menu that was being shown for preinstalled Snaps ([#27937](https://github.com/MetaMask/metamask-extension/pull/27937)) +- fix: bump `@metamask/ppom-validator` from `0.34.0` to `0.35.1` ([#27939](https://github.com/MetaMask/metamask-extension/pull/27939)) +- fix: add APE network icon ([#27841](https://github.com/MetaMask/metamask-extension/pull/27841)) +- feat: NFT permit simulations ([#27825](https://github.com/MetaMask/metamask-extension/pull/27825)) +- fix: fix currency display when tokenToFiatConversion rate is not avai… ([#27893](https://github.com/MetaMask/metamask-extension/pull/27893)) +- feat: convert AlertController to typescript ([#27764](https://github.com/MetaMask/metamask-extension/pull/27764)) +- feat(TXL-435): turn smart transactions on by default for new users ([#27885](https://github.com/MetaMask/metamask-extension/pull/27885)) +- feat: Add transaction flow and details sections ([#27654](https://github.com/MetaMask/metamask-extension/pull/27654)) +- fix: flaky test `Vault Decryptor Page is able to decrypt the vault pasting the text in the vault-decryptor webapp` ([#27921](https://github.com/MetaMask/metamask-extension/pull/27921)) +- chore: bump `@metamask/eth-snap-keyring` to version 4.4.0 ([#27864](https://github.com/MetaMask/metamask-extension/pull/27864)) +- fix: flaky tests `Add existing token using search renders the balance for the chosen token` ([#27853](https://github.com/MetaMask/metamask-extension/pull/27853)) +- feat(logging): add extension request logging and retrieval ([#27655](https://github.com/MetaMask/metamask-extension/pull/27655)) +- test: Update test-dapp to verison 8.7.0 ([#27816](https://github.com/MetaMask/metamask-extension/pull/27816)) +- fix: fall back to bundled chainlist ([#23392](https://github.com/MetaMask/metamask-extension/pull/23392)) +- fix: SonarCloud for forks ([#27700](https://github.com/MetaMask/metamask-extension/pull/27700)) +- fix(deps): update from eth-rpc-errors to @metamask/rpc-errors (cause edition) ([#24496](https://github.com/MetaMask/metamask-extension/pull/24496)) +- fix: swapQuotesError as a property in the reported metric ([#27712](https://github.com/MetaMask/metamask-extension/pull/27712)) +- chore: Bump Snaps packages ([#27376](https://github.com/MetaMask/metamask-extension/pull/27376)) +- chore: update @metamask/bitcoin-wallet-snap to 0.7.0 ([#27730](https://github.com/MetaMask/metamask-extension/pull/27730)) +- fix: Onboarding: Code style nits ([#27767](https://github.com/MetaMask/metamask-extension/pull/27767)) +- fix: updated edit modals ([#27623](https://github.com/MetaMask/metamask-extension/pull/27623)) +- feat: use asset pickers with network dropdown in cross-chain swaps page ([#27522](https://github.com/MetaMask/metamask-extension/pull/27522)) +- test: set ENABLE_MV3 automatically ([#27748](https://github.com/MetaMask/metamask-extension/pull/27748)) +- feat: Adding typed sign support for NFT permit ([#27796](https://github.com/MetaMask/metamask-extension/pull/27796)) +- fix: Contract Interaction - cannot read the property `text_signature` ([#27686](https://github.com/MetaMask/metamask-extension/pull/27686)) +- feat: Use requested permissions as default selected values for AmonHenV2 connection flow with case insensitive address comparison ([#27517](https://github.com/MetaMask/metamask-extension/pull/27517)) +- test: [POM] Migrate signature with snap account e2e tests to page object modal ([#27829](https://github.com/MetaMask/metamask-extension/pull/27829)) +- fix: flaky test `ERC1155 NFTs testdapp interaction should batch transfers ERC1155 token` ([#27897](https://github.com/MetaMask/metamask-extension/pull/27897)) +- chore: Master sync following v12.4.1 ([#27793](https://github.com/MetaMask/metamask-extension/pull/27793)) +- fix: flaky test `Permissions sets permissions and connect to Dapp` ([#27888](https://github.com/MetaMask/metamask-extension/pull/27888)) +- fix: flaky test `ERC721 NFTs testdapp interaction should prompt users to add their NFTs to their wallet (all at once)` ([#27889](https://github.com/MetaMask/metamask-extension/pull/27889)) +- fix: flaky test `Wallet Revoke Permissions should revoke eth_accounts permissions via test dapp` ([#27894](https://github.com/MetaMask/metamask-extension/pull/27894)) +- fix: flaky test `Snap Account Signatures and Disconnects can connect to the Test Dapp, then #signTypedDataV3, disconnect then connect, then #signTypedDataV4 (async flow approve)` ([#27887](https://github.com/MetaMask/metamask-extension/pull/27887)) +- test(mock-e2e): add private domains logic for the privacy report ([#27844](https://github.com/MetaMask/metamask-extension/pull/27844)) +- fix: SENTRY_DSN_FAKE problem ([#27881](https://github.com/MetaMask/metamask-extension/pull/27881)) +- chore: remove unused swaps code ([#27679](https://github.com/MetaMask/metamask-extension/pull/27679)) +- test(TXL-308): initial e2e for stx using swaps ([#27215](https://github.com/MetaMask/metamask-extension/pull/27215)) +- feat: upgrade assets-controllers to v38.3.0 ([#27755](https://github.com/MetaMask/metamask-extension/pull/27755)) +- fix: nonce value when there are multiple transactions in parallel ([#27874](https://github.com/MetaMask/metamask-extension/pull/27874)) +- fix: phishing test to not check c2 domains ([#27846](https://github.com/MetaMask/metamask-extension/pull/27846)) +- feat: use messenger in AccountTracker to get Preferences state ([#27711](https://github.com/MetaMask/metamask-extension/pull/27711)) +- fix: "Update Network: should update added rpc url for exis..." flaky tests ([#27437](https://github.com/MetaMask/metamask-extension/pull/27437)) +- feat: update copy for 'Default settings' ([#27821](https://github.com/MetaMask/metamask-extension/pull/27821)) +- fix: updated permissions flow copy changes ([#27658](https://github.com/MetaMask/metamask-extension/pull/27658)) +- fix: flaky test `Add account should not affect public address when using secret recovery phrase to recover account with non-zero balance @no-mmi` ([#27834](https://github.com/MetaMask/metamask-extension/pull/27834)) +- fix: hackily wait longer for linea swap approval tx to increase chance of success ([#27810](https://github.com/MetaMask/metamask-extension/pull/27810)) +- fix: flaky test `MultiRpc: should select rpc from settings @no-mmi` ([#27858](https://github.com/MetaMask/metamask-extension/pull/27858)) +- perf: include custom traces in benchmark results ([#27701](https://github.com/MetaMask/metamask-extension/pull/27701)) +- fix: Reset nonce as network is switched ([#27789](https://github.com/MetaMask/metamask-extension/pull/27789)) +- fix: dismiss addToken modal for mmi ([#27855](https://github.com/MetaMask/metamask-extension/pull/27855)) +- fix(multichain): fix eth send flow (from dapp) when a btc account is selected ([#27566](https://github.com/MetaMask/metamask-extension/pull/27566)) +- chore: Add react-beautiful-dnd to deprecated packages list ([#27856](https://github.com/MetaMask/metamask-extension/pull/27856)) +- feat: Create a quality gate for typescript coverage ([#27717](https://github.com/MetaMask/metamask-extension/pull/27717)) +- feat: preferences controller to base controller v2 ([#27398](https://github.com/MetaMask/metamask-extension/pull/27398)) +- revert: use networkClientId to resolve chainId in PPOM Middleware ([#27570](https://github.com/MetaMask/metamask-extension/pull/27570)) +- feat: Added metrics for edit networks and accounts ([#27820](https://github.com/MetaMask/metamask-extension/pull/27820)) +- fix: no connected state for permissions page ([#27660](https://github.com/MetaMask/metamask-extension/pull/27660)) +- feat: remove phishing detection from onboarding Security group ([#27819](https://github.com/MetaMask/metamask-extension/pull/27819)) +- ci: Revert minimum E2E timeout to 20 minutes ([#27827](https://github.com/MetaMask/metamask-extension/pull/27827)) +- fix: disable balance checker for Sepolia in account tracker ([#27763](https://github.com/MetaMask/metamask-extension/pull/27763)) +- ci: Improve validation for `sentry:publish` script ([#26580](https://github.com/MetaMask/metamask-extension/pull/26580)) +- test: Fix Vault Decryptor Page e2e test on develop branch ([#27794](https://github.com/MetaMask/metamask-extension/pull/27794)) +- chore: remove old token details page ([#27774](https://github.com/MetaMask/metamask-extension/pull/27774)) +- chore: remove token list display component ([#27772](https://github.com/MetaMask/metamask-extension/pull/27772)) +- chore: update Trezor Connect to v9.4.0, remove workarounds ([#27112](https://github.com/MetaMask/metamask-extension/pull/27112)) +- test: [POM] Migrate transaction with snap account e2e tests to page object modal ([#27760](https://github.com/MetaMask/metamask-extension/pull/27760)) +- fix(snaps): Restore confirmation switching on routed confirmation ([#27753](https://github.com/MetaMask/metamask-extension/pull/27753)) +- Merge origin/develop into master-sync +- test: Onboarding: Fix vault-decryption-chrome.spec.js ([#27779](https://github.com/MetaMask/metamask-extension/pull/27779)) +- feat: support gas fee flows in standard swaps ([#27612](https://github.com/MetaMask/metamask-extension/pull/27612)) +- feat: Token send heading component ([#27562](https://github.com/MetaMask/metamask-extension/pull/27562)) +- feat: adds the new default settings view to onboarding ([#24562](https://github.com/MetaMask/metamask-extension/pull/24562)) +- chore(3212): remove alert settings ([#27709](https://github.com/MetaMask/metamask-extension/pull/27709)) +- docs: remove outdated Medium link, update "Twitter" to "X" ([#26692](https://github.com/MetaMask/metamask-extension/pull/26692)) +- fix: Replace 'transaction fees' with 'network fees' in the insufficie… ([#27762](https://github.com/MetaMask/metamask-extension/pull/27762)) +- fix: issue with Snap title in Snap Authorship Header ([#27752](https://github.com/MetaMask/metamask-extension/pull/27752)) +- fix: SIWE signature page displays parsed URI instead of domain ([#27754](https://github.com/MetaMask/metamask-extension/pull/27754)) +- fix: updated toasts component and copy ([#27656](https://github.com/MetaMask/metamask-extension/pull/27656)) +- feat: add network picker to AssetPicker ([#26559](https://github.com/MetaMask/metamask-extension/pull/26559)) +- fix(btc): fix jazzicons generations ([#27662](https://github.com/MetaMask/metamask-extension/pull/27662)) +- feat: Release Chain Permissions ([#27561](https://github.com/MetaMask/metamask-extension/pull/27561)) +- feat: upgrade assets-controllers to v38.2.0 ([#27629](https://github.com/MetaMask/metamask-extension/pull/27629)) +- ci: followup to CircleCI Sentry reporting ([#27548](https://github.com/MetaMask/metamask-extension/pull/27548)) +- chore: Master sync ([#27729](https://github.com/MetaMask/metamask-extension/pull/27729)) +- fix(multichain): fix getMultichainCurrentCurrency selector ([#27726](https://github.com/MetaMask/metamask-extension/pull/27726)) +- fix: Limit amount of decimals on spending cap modal ([#27672](https://github.com/MetaMask/metamask-extension/pull/27672)) +- Merge origin/develop into master-sync +- test: [POM] Migrate create snap account e2e tests to page object modal ([#27697](https://github.com/MetaMask/metamask-extension/pull/27697)) +- fix: Prefer token symbol to token name ([#27693](https://github.com/MetaMask/metamask-extension/pull/27693)) +- fix(btc): fetch btc balance right after account creation ([#27628](https://github.com/MetaMask/metamask-extension/pull/27628)) +- fix: UI startup with no Sentry DSN ([#27714](https://github.com/MetaMask/metamask-extension/pull/27714)) +- feat: Sort/Import Tokens in Extension ([#27184](https://github.com/MetaMask/metamask-extension/pull/27184)) +- ci: make git-diff-develop work for PRs from foreign repos ([#27268](https://github.com/MetaMask/metamask-extension/pull/27268)) +- test: Convert json-rpc e2e tests to TypeScript ([#27659](https://github.com/MetaMask/metamask-extension/pull/27659)) +- fix: allow getAddTransactionRequest to pass through other params ([#27117](https://github.com/MetaMask/metamask-extension/pull/27117)) +- perf: add tags to UI startup trace ([#27550](https://github.com/MetaMask/metamask-extension/pull/27550)) +- fix: Disable redirecting Extension users using beta & flask build and dev env to the existing offboarding page ([#27226](https://github.com/MetaMask/metamask-extension/pull/27226)) +- feat(NOTIFY-1193): add profile sync dev menu ([#27666](https://github.com/MetaMask/metamask-extension/pull/27666)) +- refactor: Typescript conversion of log-web3-shim-usage.js ([#23732](https://github.com/MetaMask/metamask-extension/pull/23732)) +- test: removing race condition for asserting inner values (PR-#2) ([#27664](https://github.com/MetaMask/metamask-extension/pull/27664)) +- fix(btc): fix address validation ([#27690](https://github.com/MetaMask/metamask-extension/pull/27690)) +- chore: Update coverage.json ([#27696](https://github.com/MetaMask/metamask-extension/pull/27696)) +- fix: test coverage quality gate ([#27691](https://github.com/MetaMask/metamask-extension/pull/27691)) +- fix: banner alert to render multiple general alerts ([#27339](https://github.com/MetaMask/metamask-extension/pull/27339)) +- refactor: routes constants ([#27078](https://github.com/MetaMask/metamask-extension/pull/27078)) +- fix: Test coverage quality gate ([#27581](https://github.com/MetaMask/metamask-extension/pull/27581)) +- feat: Adding delete metametrics data to security and privacy tab ([#24571](https://github.com/MetaMask/metamask-extension/pull/24571)) +- feat(stx): animations and cosmetic changes to smart transaction status page ([#27650](https://github.com/MetaMask/metamask-extension/pull/27650)) +- build: add lottie-web dependency to extension ([#27632](https://github.com/MetaMask/metamask-extension/pull/27632)) +- fix(btc): do not show percentage for tokens ([#27637](https://github.com/MetaMask/metamask-extension/pull/27637)) +- feat: support Etherscan API keys ([#27611](https://github.com/MetaMask/metamask-extension/pull/27611)) +- feat: change survey timeout time from a week to a day ([#27603](https://github.com/MetaMask/metamask-extension/pull/27603)) +- fix: Design papercuts for redesigned transactions ([#27605](https://github.com/MetaMask/metamask-extension/pull/27605)) +- test: removing race condition for asserting inner values (PR-#1) ([#27606](https://github.com/MetaMask/metamask-extension/pull/27606)) +- test: [POM] Migrate Snap Simple Keyring page and Snap List page to page object modal ([#27327](https://github.com/MetaMask/metamask-extension/pull/27327)) +- fix: fix sentry reading undefined ([#27584](https://github.com/MetaMask/metamask-extension/pull/27584)) +- fix: fix sentry reading null ([#27582](https://github.com/MetaMask/metamask-extension/pull/27582)) +- fix(btc): disable balanceIsCached flag ([#27636](https://github.com/MetaMask/metamask-extension/pull/27636)) +- chore: update accounts related packages ([#27284](https://github.com/MetaMask/metamask-extension/pull/27284)) +- chore: set bridge src network, tokens and top assets ([#26214](https://github.com/MetaMask/metamask-extension/pull/26214)) +- test: [Snaps E2E] add delay to installed snaps test to reduce flaking ([#27521](https://github.com/MetaMask/metamask-extension/pull/27521)) +- chore: set bridge dest network, tokens and top assets ([#26213](https://github.com/MetaMask/metamask-extension/pull/26213)) +- fix: fix reading address from market data ([#27604](https://github.com/MetaMask/metamask-extension/pull/27604)) +- feat: Migrate AccountTrackerController to BaseController v2 ([#27258](https://github.com/MetaMask/metamask-extension/pull/27258)) +- fix: disable transaction data decode if deployment ([#27586](https://github.com/MetaMask/metamask-extension/pull/27586)) +- fix: revert jest collect coverage patterns ([#27583](https://github.com/MetaMask/metamask-extension/pull/27583)) +- fix: add amount row for contract deployment ([#27594](https://github.com/MetaMask/metamask-extension/pull/27594)) +- fix: "Dapp viewed Event @no-mmi is sent when refreshing da..." flaky test ([#27381](https://github.com/MetaMask/metamask-extension/pull/27381)) +- chore: fix deps audit ([#27620](https://github.com/MetaMask/metamask-extension/pull/27620)) +- fix: Max approval and array value spending cap bugs ([#27573](https://github.com/MetaMask/metamask-extension/pull/27573)) +- feat: add power users survey support ([#27361](https://github.com/MetaMask/metamask-extension/pull/27361)) +- fix: Recreate offscreen document if it already exists ([#27596](https://github.com/MetaMask/metamask-extension/pull/27596)) +- fix: flaky test `Block Explorer links to the token tracker in the explorer` ([#27599](https://github.com/MetaMask/metamask-extension/pull/27599)) +- fix(snaps): `Copyable` more button color ([#27600](https://github.com/MetaMask/metamask-extension/pull/27600)) +- fix: flaky test `Import flow allows importing multiple tokens from search` ([#27567](https://github.com/MetaMask/metamask-extension/pull/27567)) +- fix(27428): fix if we type enter anything followed by a \ in settings search ([#27432](https://github.com/MetaMask/metamask-extension/pull/27432)) +- fix: flaky test `Address Book Edit entry in address book` due to race condition with mmi menu ([#27557](https://github.com/MetaMask/metamask-extension/pull/27557)) +- refactor: Typescript conversion of get-provider-state.js ([#23635](https://github.com/MetaMask/metamask-extension/pull/23635)) +- chore: Use "gas_included" event prop ([#27559](https://github.com/MetaMask/metamask-extension/pull/27559)) +- fix: mock locale in unit test ([#27574](https://github.com/MetaMask/metamask-extension/pull/27574)) +- feat: codefence Account Watcher for flask ([#27543](https://github.com/MetaMask/metamask-extension/pull/27543)) +- chore: start upgrade to React Router v6 ([#27185](https://github.com/MetaMask/metamask-extension/pull/27185)) +- fix: AmonHenV2 connection flow incremental permitted chain approval and account address case comparison ([#27518](https://github.com/MetaMask/metamask-extension/pull/27518)) +- fix: flaky test `Backup and Restore should backup the account settings` ([#27565](https://github.com/MetaMask/metamask-extension/pull/27565)) +- fix: Apply flex to Snaps buttons only when containing images and icons ([#27564](https://github.com/MetaMask/metamask-extension/pull/27564)) +- feat: aggregated balance feature ([#27097](https://github.com/MetaMask/metamask-extension/pull/27097)) +- feat: Add redesign integration tests ([#27259](https://github.com/MetaMask/metamask-extension/pull/27259)) +- fix: flaky test `4byte setting does not try to get contract method name from 4byte when the setting is off` ([#27560](https://github.com/MetaMask/metamask-extension/pull/27560)) +- feat: add merge queue ([#26871](https://github.com/MetaMask/metamask-extension/pull/26871)) +- feat: remove squiggle animation from swaps smart transactions ([#27264](https://github.com/MetaMask/metamask-extension/pull/27264)) +- feat: Enable gas included swaps ([#27427](https://github.com/MetaMask/metamask-extension/pull/27427)) +- fix(snaps): Fix custom UI buttons submitting forms ([#27531](https://github.com/MetaMask/metamask-extension/pull/27531)) +- chore: Master sync following v12.3.1 ([#27538](https://github.com/MetaMask/metamask-extension/pull/27538)) +- Merge origin/develop into master-sync +- fix(NOTIFY-1171): account syncing performance and bug fixes ([#27529](https://github.com/MetaMask/metamask-extension/pull/27529)) +- fix: genUnapprovedApproveConfirmation import path ([#27530](https://github.com/MetaMask/metamask-extension/pull/27530)) +- fix(snaps): Keep focus on input if interface re-renders ([#27429](https://github.com/MetaMask/metamask-extension/pull/27429)) +- fix: Allow state updates in Snaps interfaces to state values that are falsy ([#27488](https://github.com/MetaMask/metamask-extension/pull/27488)) +- fix: updated ui for connect and review page ([#27478](https://github.com/MetaMask/metamask-extension/pull/27478)) +- feat: Custom header for wallet initiated confirmations ([#27391](https://github.com/MetaMask/metamask-extension/pull/27391)) +- feat: convert account tracker to typescript ([#27231](https://github.com/MetaMask/metamask-extension/pull/27231)) +- fix: Fix snaps permission connection for `CHAIN_PERMISSIONS` feature flag ([#27459](https://github.com/MetaMask/metamask-extension/pull/27459)) +- fix: flaky test `Navigation Signature - Different signature types initiates multiple signatures and rejects all` ([#27481](https://github.com/MetaMask/metamask-extension/pull/27481)) +- feat: Double Sentry performance trace sample rate ([#27468](https://github.com/MetaMask/metamask-extension/pull/27468)) +- ci: Expand github bot policy update comment to be more actionable ([#27242](https://github.com/MetaMask/metamask-extension/pull/27242)) +- chore: Add `useLedgerConnection` unit tests ([#27358](https://github.com/MetaMask/metamask-extension/pull/27358)) +- ci: Sentry reporting only on develop branch, with Git message overrides ([#27412](https://github.com/MetaMask/metamask-extension/pull/27412)) +- test: Fix flaky permit test ([#27450](https://github.com/MetaMask/metamask-extension/pull/27450)) +- fix: removed closeMenu for ConnectedAccountsMenu ([#27460](https://github.com/MetaMask/metamask-extension/pull/27460)) +- fix(snaps): Set proper text color for secondary button ([#27335](https://github.com/MetaMask/metamask-extension/pull/27335)) +- chore: set bridge selected tokens and amount ([#26212](https://github.com/MetaMask/metamask-extension/pull/26212)) +- fix: flaky test `Add account should not affect public address when using secret recovery phrase to recover account with non-zero balance @no-mmi`aded ([#27420](https://github.com/MetaMask/metamask-extension/pull/27420)) +- fix: flaky test `Responsive UI Send Transaction from responsive window` ([#27417](https://github.com/MetaMask/metamask-extension/pull/27417)) +- fix: flaky test `Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx should queue send tx after switch network confirmation and transaction should target the correct network after switch is confirmed` ([#27352](https://github.com/MetaMask/metamask-extension/pull/27352)) +- fix: Change speed key color ([#27416](https://github.com/MetaMask/metamask-extension/pull/27416)) +- feat: Display setApprovalForAll and revoke setApprovalForAll to users… ([#27401](https://github.com/MetaMask/metamask-extension/pull/27401)) +- fix: "Warning: Invalid argument supplied to oneOfType" ([#27267](https://github.com/MetaMask/metamask-extension/pull/27267)) +- feat: Editing flow ([#26635](https://github.com/MetaMask/metamask-extension/pull/26635)) +- chore: bump profile-sync-controller to 0.9.3 ([#27415](https://github.com/MetaMask/metamask-extension/pull/27415)) +- fix: Remove duplication ([#27421](https://github.com/MetaMask/metamask-extension/pull/27421)) +- fix: Confirm Page test failing in CI/CD ([#27423](https://github.com/MetaMask/metamask-extension/pull/27423)) +- feat: Display approve, increaseAllowance and revoke approval to users… ([#26985](https://github.com/MetaMask/metamask-extension/pull/26985)) +- feat: Add performance metrics for signature requests ([#26967](https://github.com/MetaMask/metamask-extension/pull/26967)) +- fix: Permit DataTree token decimals ([#27328](https://github.com/MetaMask/metamask-extension/pull/27328)) +- fix: alert system and refine SIWE and contract interaction alerts ([#27205](https://github.com/MetaMask/metamask-extension/pull/27205)) +- fix(NOTIFY-1166): rename account sync event names ([#27413](https://github.com/MetaMask/metamask-extension/pull/27413)) +- feat: ERC20 Revoke Allowance ([#26906](https://github.com/MetaMask/metamask-extension/pull/26906)) + ## [12.5.0] ### Added - New UI and functionality for adding and managing networks ([#26433](https://github.com/MetaMask/metamask-extension/pull/26433)), ([#27085](https://github.com/MetaMask/metamask-extension/pull/27085)) @@ -5223,7 +5436,8 @@ Update styles and spacing on the critical error page ([#20350](https://github.c - Added the ability to restore accounts from seed words. -[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.5.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.6.0...HEAD +[12.6.0]: https://github.com/MetaMask/metamask-extension/compare/v12.5.0...v12.6.0 [12.5.0]: https://github.com/MetaMask/metamask-extension/compare/v12.4.2...v12.5.0 [12.4.2]: https://github.com/MetaMask/metamask-extension/compare/v12.4.1...v12.4.2 [12.4.1]: https://github.com/MetaMask/metamask-extension/compare/v12.4.0...v12.4.1 diff --git a/package.json b/package.json index c3b60bfa1e48..fec8f2ffb498 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "12.5.0", + "version": "12.6.0", "private": true, "repository": { "type": "git", From b940d04e8c9bef42841aa3816a91d7e8c1a7bea9 Mon Sep 17 00:00:00 2001 From: martahj Date: Wed, 23 Oct 2024 15:56:53 -0500 Subject: [PATCH 02/56] fix: adjust spacing of quote rate in swaps (#28051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry-pick of https://github.com/MetaMask/metamask-extension/pull/28016 into v12.6 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28016?quickstart=1) ## **Manual testing steps** 1. Start a swap 2. Notice that the quote rate is back on one line and the value is left-aligned ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-10-11 at 11 32 09 AM](https://github.com/user-attachments/assets/aae5da2f-ae66-46f5-9168-6c6ed496a2a8) ### **After** Screenshot 2024-10-22 at 12 13 50 PM ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ui/pages/swaps/prepare-swap-page/index.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/pages/swaps/prepare-swap-page/index.scss b/ui/pages/swaps/prepare-swap-page/index.scss index 60e24c6cdbce..b443006330d3 100644 --- a/ui/pages/swaps/prepare-swap-page/index.scss +++ b/ui/pages/swaps/prepare-swap-page/index.scss @@ -263,7 +263,7 @@ } &__exchange-rate-display { - color: var(--color-text-alternative); + width: auto !important; } } From 77cbfe8a01f00c5b9a95cf58c6fb9494ddd999fa Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 24 Oct 2024 19:00:01 +0100 Subject: [PATCH 03/56] fix: cherry-pick: Gas changes for low Max base fee and Priority fee (#28037) (#28073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick: https://github.com/MetaMask/metamask-extension/pull/28037 ## **Description** Previously, if the Max base fee and Priority fee were reduced to very low values, the Network fee wouldn't update accordingly. This is a discrepancy with the gas calculations in the old flows. What fixes it is, for low enough values of `maxFeePerGas` (low enough to be lower than `minimumFeePerGas`), the Network fee becomes the Max fee -- `maxFeePerGas` times `gasLimit` directly. Apart from fixing the symptom explained above, this ensures that the Network fee is never higher than the Max fee. The PR also fixes this calculation when it comes to the L2 fees (inside `useTransactionGasFeeEstimate`). It also adds a missing override of `dappSuggestedFees` for both `maxFeePerGas` and `maxPriorityFeePerGas` (inside `useEIP1559TxFees`). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28037?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/27802 ## **Manual testing steps** See original report above. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28073?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../confirm/info/hooks/useEIP1559TxFees.ts | 5 ++++- .../confirm/info/hooks/useFeeCalculations.ts | 13 ++++++++++++- .../info/hooks/useTransactionGasFeeEstimate.ts | 12 +++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useEIP1559TxFees.ts b/ui/pages/confirmations/components/confirm/info/hooks/useEIP1559TxFees.ts index 40aca7cf2d31..e4bfaad8d779 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useEIP1559TxFees.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useEIP1559TxFees.ts @@ -8,8 +8,11 @@ export const useEIP1559TxFees = ( maxFeePerGas: string; maxPriorityFeePerGas: string; } => { - const hexMaxFeePerGas = transactionMeta?.txParams?.maxFeePerGas; + const hexMaxFeePerGas = + transactionMeta.dappSuggestedGasFees?.maxFeePerGas || + transactionMeta?.txParams?.maxFeePerGas; const hexMaxPriorityFeePerGas = + transactionMeta.dappSuggestedGasFees?.maxPriorityFeePerGas || transactionMeta?.txParams?.maxPriorityFeePerGas; return useMemo(() => { diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts index ceb8a4b2d248..587d70c9c9ef 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts @@ -12,6 +12,7 @@ import { getValueFromWeiHex, multiplyHexes, } from '../../../../../../../shared/modules/conversion.utils'; +import { Numeric } from '../../../../../../../shared/modules/Numeric'; import { getConversionRate } from '../../../../../../ducks/metamask/metamask'; import { useFiatFormatter } from '../../../../../../hooks/useFiatFormatter'; import { useGasFeeEstimates } from '../../../../../../hooks/useGasFeeEstimates'; @@ -114,11 +115,21 @@ export function useFeeCalculations(transactionMeta: TransactionMeta) { } // Logic for any network without L1 and L2 fee components - const minimumFeePerGas = addHexes( + let minimumFeePerGas = addHexes( decGWEIToHexWEI(estimatedBaseFee) || HEX_ZERO, decimalToHex(maxPriorityFeePerGas), ); + // `minimumFeePerGas` should never be higher than the `maxFeePerGas` + if ( + new Numeric(minimumFeePerGas, 16).greaterThan( + decimalToHex(maxFeePerGas), + 16, + ) + ) { + minimumFeePerGas = decimalToHex(maxFeePerGas); + } + const estimatedFee = multiplyHexes( supportsEIP1559 ? (minimumFeePerGas as Hex) : (gasPrice as Hex), gasLimit as Hex, diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useTransactionGasFeeEstimate.ts b/ui/pages/confirmations/components/confirm/info/hooks/useTransactionGasFeeEstimate.ts index 31802eb22feb..f5866a283935 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useTransactionGasFeeEstimate.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useTransactionGasFeeEstimate.ts @@ -5,6 +5,7 @@ import { addHexes, multiplyHexes, } from '../../../../../../../shared/modules/conversion.utils'; +import { Numeric } from '../../../../../../../shared/modules/Numeric'; import { useGasFeeEstimates } from '../../../../../../hooks/useGasFeeEstimates'; import { HEX_ZERO } from '../shared/constants'; @@ -28,15 +29,24 @@ export function useTransactionGasFeeEstimate( transactionMeta.dappSuggestedGasFees?.maxPriorityFeePerGas || transactionMeta.txParams?.maxPriorityFeePerGas || HEX_ZERO; + const maxFeePerGas = + transactionMeta.dappSuggestedGasFees?.maxFeePerGas || + transactionMeta.txParams?.maxFeePerGas || + HEX_ZERO; let gasEstimate: Hex; if (supportsEIP1559) { // Minimum Total Fee = (estimatedBaseFee + maxPriorityFeePerGas) * gasLimit - const minimumFeePerGas = addHexes( + let minimumFeePerGas = addHexes( estimatedBaseFee || HEX_ZERO, maxPriorityFeePerGas, ); + // `minimumFeePerGas` should never be higher than the `maxFeePerGas` + if (new Numeric(minimumFeePerGas, 16).greaterThan(maxFeePerGas, 16)) { + minimumFeePerGas = maxFeePerGas; + } + gasEstimate = multiplyHexes(minimumFeePerGas as Hex, gasLimit as Hex); } else { gasEstimate = multiplyHexes(gasPrice as Hex, gasLimit as Hex); From d1da8609213d4ca682a2e972b5c3db15d0abf256 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 24 Oct 2024 19:01:35 +0100 Subject: [PATCH 04/56] fix: Cherry-pick Support dynamic native token name on gas component (#28048) (#28071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick https://github.com/MetaMask/metamask-extension/pull/28048 ## **Description** Uses the multinetwork ticker. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28048?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28001 ## **Manual testing steps** See original ticket linked above. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-10-23 at 16 16 19 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28071?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../transactions/contract-deployment.test.tsx | 4 ++-- .../transactions/contract-interaction.test.tsx | 4 ++-- .../confirm/info/hooks/useFeeCalculations.test.ts | 4 ++-- .../confirm/info/hooks/useFeeCalculations.ts | 12 ++++++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/test/integration/confirmations/transactions/contract-deployment.test.tsx b/test/integration/confirmations/transactions/contract-deployment.test.tsx index ecef04f30861..c2625e06e3e7 100644 --- a/test/integration/confirmations/transactions/contract-deployment.test.tsx +++ b/test/integration/confirmations/transactions/contract-deployment.test.tsx @@ -283,7 +283,7 @@ describe('Contract Deployment Confirmation', () => { expect(editGasFeesRow).toHaveTextContent(tEn('networkFee') as string); const firstGasField = within(editGasFeesRow).getByTestId('first-gas-field'); - expect(firstGasField).toHaveTextContent('0.0001 ETH'); + expect(firstGasField).toHaveTextContent('0.0001 SepoliaETH'); const editGasFeeNativeCurrency = within(editGasFeesRow).getByTestId('native-currency'); expect(editGasFeeNativeCurrency).toHaveTextContent('$0.47'); @@ -371,7 +371,7 @@ describe('Contract Deployment Confirmation', () => { const maxFee = screen.getByTestId('gas-fee-details-max-fee'); expect(gasFeesSection).toContainElement(maxFee); expect(maxFee).toHaveTextContent(tEn('maxFee') as string); - expect(maxFee).toHaveTextContent('0.0023 ETH'); + expect(maxFee).toHaveTextContent('0.0023 SepoliaETH'); expect(maxFee).toHaveTextContent('$7.72'); const nonceSection = screen.getByTestId('advanced-details-nonce-section'); diff --git a/test/integration/confirmations/transactions/contract-interaction.test.tsx b/test/integration/confirmations/transactions/contract-interaction.test.tsx index 1102cb21c67d..b77e48f1d660 100644 --- a/test/integration/confirmations/transactions/contract-interaction.test.tsx +++ b/test/integration/confirmations/transactions/contract-interaction.test.tsx @@ -301,7 +301,7 @@ describe('Contract Interaction Confirmation', () => { expect(editGasFeesRow).toHaveTextContent(tEn('networkFee') as string); const firstGasField = within(editGasFeesRow).getByTestId('first-gas-field'); - expect(firstGasField).toHaveTextContent('0.0001 ETH'); + expect(firstGasField).toHaveTextContent('0.0001 SepoliaETH'); const editGasFeeNativeCurrency = within(editGasFeesRow).getByTestId('native-currency'); expect(editGasFeeNativeCurrency).toHaveTextContent('$0.47'); @@ -402,7 +402,7 @@ describe('Contract Interaction Confirmation', () => { const maxFee = screen.getByTestId('gas-fee-details-max-fee'); expect(gasFeesSection).toContainElement(maxFee); expect(maxFee).toHaveTextContent(tEn('maxFee') as string); - expect(maxFee).toHaveTextContent('0.0023 ETH'); + expect(maxFee).toHaveTextContent('0.0023 SepoliaETH'); expect(maxFee).toHaveTextContent('$7.72'); const nonceSection = screen.getByTestId('advanced-details-nonce-section'); diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts index 911cdb20118c..17c8ab8dd8f6 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts @@ -22,13 +22,13 @@ describe('useFeeCalculations', () => { expect(result.current).toMatchInlineSnapshot(` { "estimatedFeeFiat": "$0.00", - "estimatedFeeNative": "0 WEI", + "estimatedFeeNative": "0 ETH", "l1FeeFiat": "", "l1FeeNative": "", "l2FeeFiat": "", "l2FeeNative": "", "maxFeeFiat": "$0.00", - "maxFeeNative": "0 WEI", + "maxFeeNative": "0 ETH", } `); }); diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts index 587d70c9c9ef..70bd2c0e3af2 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts @@ -8,7 +8,6 @@ import { addHexes, decGWEIToHexWEI, decimalToHex, - getEthConversionFromWeiHex, getValueFromWeiHex, multiplyHexes, } from '../../../../../../../shared/modules/conversion.utils'; @@ -17,6 +16,7 @@ import { getConversionRate } from '../../../../../../ducks/metamask/metamask'; import { useFiatFormatter } from '../../../../../../hooks/useFiatFormatter'; import { useGasFeeEstimates } from '../../../../../../hooks/useGasFeeEstimates'; import { getCurrentCurrency } from '../../../../../../selectors'; +import { getMultichainNetwork } from '../../../../../../selectors/multichain'; import { HEX_ZERO } from '../shared/constants'; import { useEIP1559TxFees } from './useEIP1559TxFees'; import { useSupportsEIP1559 } from './useSupportsEIP1559'; @@ -33,14 +33,18 @@ export function useFeeCalculations(transactionMeta: TransactionMeta) { const conversionRate = useSelector(getConversionRate); const fiatFormatter = useFiatFormatter(); + const multichainNetwork = useSelector(getMultichainNetwork); + const ticker = multichainNetwork?.network?.ticker; + const getFeesFromHex = useCallback( (hexFee: string) => { - const nativeCurrencyFee = - getEthConversionFromWeiHex({ + const nativeCurrencyFee = `${ + getValueFromWeiHex({ value: hexFee, fromCurrency: EtherDenomination.GWEI, numberOfDecimals: 4, - }) || `0 ${EtherDenomination.ETH}`; + }) || 0 + } ${ticker}`; const currentCurrencyFee = fiatFormatter( Number( From a0c0e9165c7b960999d0f0d40c02774028054d1a Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 24 Oct 2024 19:03:26 +0100 Subject: [PATCH 05/56] fix: cherry-pick: Fall back to token list for the token symbol (#28003) (#28078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick: https://github.com/MetaMask/metamask-extension/pull/28003 ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28003?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/27970 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-10-22 at 11 19 10 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28078?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ...-image.test.ts => useTokenDetails.test.ts} | 32 +++++++++++++------ ...{use-token-image.ts => useTokenDetails.ts} | 13 ++++++-- .../info/shared/send-heading/send-heading.tsx | 13 ++++---- 3 files changed, 38 insertions(+), 20 deletions(-) rename ui/pages/confirmations/components/confirm/info/hooks/{use-token-image.test.ts => useTokenDetails.test.ts} (73%) rename ui/pages/confirmations/components/confirm/info/hooks/{use-token-image.ts => useTokenDetails.ts} (65%) diff --git a/ui/pages/confirmations/components/confirm/info/hooks/use-token-image.test.ts b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts similarity index 73% rename from ui/pages/confirmations/components/confirm/info/hooks/use-token-image.test.ts rename to ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts index 23e4cc3c1bda..efdf2b66ac56 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/use-token-image.test.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts @@ -2,9 +2,9 @@ import { TransactionMeta } from '@metamask/transaction-controller'; import { genUnapprovedTokenTransferConfirmation } from '../../../../../../../test/data/confirmations/token-transfer'; import mockState from '../../../../../../../test/data/mock-state.json'; import { renderHookWithProvider } from '../../../../../../../test/lib/render-helpers'; -import { useTokenImage } from './use-token-image'; +import { useTokenDetails } from './useTokenDetails'; -describe('useTokenImage', () => { +describe('useTokenDetails', () => { it('returns iconUrl from selected token if it exists', () => { const transactionMeta = genUnapprovedTokenTransferConfirmation( {}, @@ -19,11 +19,14 @@ describe('useTokenImage', () => { }; const { result } = renderHookWithProvider( - () => useTokenImage(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), mockState, ); - expect(result.current).toEqual({ tokenImage: 'iconUrl' }); + expect(result.current).toEqual({ + tokenImage: 'iconUrl', + tokenSymbol: 'symbol', + }); }); it('returns selected token image if no iconUrl is included', () => { @@ -39,11 +42,14 @@ describe('useTokenImage', () => { }; const { result } = renderHookWithProvider( - () => useTokenImage(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), mockState, ); - expect(result.current).toEqual({ tokenImage: 'image' }); + expect(result.current).toEqual({ + tokenImage: 'image', + tokenSymbol: 'symbol', + }); }); it('returns token list icon url if no image is included in the token', () => { @@ -58,7 +64,7 @@ describe('useTokenImage', () => { }; const { result } = renderHookWithProvider( - () => useTokenImage(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), { ...mockState, metamask: { @@ -72,7 +78,10 @@ describe('useTokenImage', () => { }, ); - expect(result.current).toEqual({ tokenImage: 'tokenListIconUrl' }); + expect(result.current).toEqual({ + tokenImage: 'tokenListIconUrl', + tokenSymbol: 'symbol', + }); }); it('returns undefined if no image is found', () => { @@ -87,10 +96,13 @@ describe('useTokenImage', () => { }; const { result } = renderHookWithProvider( - () => useTokenImage(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), mockState, ); - expect(result.current).toEqual({ tokenImage: undefined }); + expect(result.current).toEqual({ + tokenImage: undefined, + tokenSymbol: 'symbol', + }); }); }); diff --git a/ui/pages/confirmations/components/confirm/info/hooks/use-token-image.ts b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts similarity index 65% rename from ui/pages/confirmations/components/confirm/info/hooks/use-token-image.ts rename to ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts index 5817d08028ab..be9578496205 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/use-token-image.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts @@ -1,20 +1,27 @@ import { TokenListMap } from '@metamask/assets-controllers'; import { TransactionMeta } from '@metamask/transaction-controller'; import { useSelector } from 'react-redux'; +import { useI18nContext } from '../../../../../../hooks/useI18nContext'; import { getTokenList } from '../../../../../../selectors'; import { SelectedToken } from '../shared/selected-token'; -export const useTokenImage = ( +export const useTokenDetails = ( transactionMeta: TransactionMeta, selectedToken: SelectedToken, ) => { + const t = useI18nContext(); + const tokenList = useSelector(getTokenList) as TokenListMap; - // TODO: Add support for NFT images in one of the following tasks const tokenImage = selectedToken?.iconUrl || selectedToken?.image || tokenList[transactionMeta?.txParams?.to as string]?.iconUrl; - return { tokenImage }; + const tokenSymbol = + selectedToken?.symbol || + tokenList[transactionMeta?.txParams?.to as string]?.symbol || + t('unknown'); + + return { tokenImage, tokenSymbol }; }; diff --git a/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx index 2806c33936c0..40c571d4bc75 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx @@ -16,22 +16,23 @@ import { TextColor, TextVariant, } from '../../../../../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; import { getWatchedToken } from '../../../../../../../selectors'; import { MultichainState } from '../../../../../../../selectors/multichain'; import { useConfirmContext } from '../../../../../context/confirm'; -import { useTokenImage } from '../../hooks/use-token-image'; +import { useTokenDetails } from '../../hooks/useTokenDetails'; import { useTokenValues } from '../../hooks/use-token-values'; import { ConfirmLoader } from '../confirm-loader/confirm-loader'; const SendHeading = () => { - const t = useI18nContext(); const { currentConfirmation: transactionMeta } = useConfirmContext(); const selectedToken = useSelector((state: MultichainState) => getWatchedToken(transactionMeta)(state), ); - const { tokenImage } = useTokenImage(transactionMeta, selectedToken); + const { tokenImage, tokenSymbol } = useTokenDetails( + transactionMeta, + selectedToken, + ); const { decodedTransferValue, fiatDisplayValue, pending } = useTokenValues(transactionMeta); @@ -57,9 +58,7 @@ const SendHeading = () => { variant={TextVariant.headingLg} color={TextColor.inherit} marginTop={3} - >{`${decodedTransferValue || ''} ${ - selectedToken?.symbol || t('unknown') - }`} + >{`${decodedTransferValue || ''} ${tokenSymbol}`} {fiatDisplayValue && ( {fiatDisplayValue} From 40febb615430484c219ee25285c4b4b5a62b378e Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Thu, 24 Oct 2024 23:00:08 +0000 Subject: [PATCH 06/56] Version v12.5.1 --- CHANGELOG.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b33f07fb3d5..9fc65a61eae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.5.1] + ## [12.5.0] ### Added - New UI and functionality for adding and managing networks ([#26433](https://github.com/MetaMask/metamask-extension/pull/26433)), ([#27085](https://github.com/MetaMask/metamask-extension/pull/27085)) @@ -5223,7 +5225,8 @@ Update styles and spacing on the critical error page ([#20350](https://github.c - Added the ability to restore accounts from seed words. -[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.5.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.5.1...HEAD +[12.5.1]: https://github.com/MetaMask/metamask-extension/compare/v12.5.0...v12.5.1 [12.5.0]: https://github.com/MetaMask/metamask-extension/compare/v12.4.2...v12.5.0 [12.4.2]: https://github.com/MetaMask/metamask-extension/compare/v12.4.1...v12.4.2 [12.4.1]: https://github.com/MetaMask/metamask-extension/compare/v12.4.0...v12.4.1 diff --git a/package.json b/package.json index f1b3fc502664..913ebf677d7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "12.5.0", + "version": "12.5.1", "private": true, "repository": { "type": "git", From 157b377e98630fd9ab041cb77d26adee5a4aec73 Mon Sep 17 00:00:00 2001 From: AugmentedMode <31675118+AugmentedMode@users.noreply.github.com> Date: Fri, 25 Oct 2024 09:10:17 -0400 Subject: [PATCH 07/56] fix: Cherry-pick: Fix c2 detection bypass by supporting all network requests types (#28087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry pick: #28057 This update addresses a bypass that allowed scammers to bypass C2 detection by using alternative network request types to communicate with their Command and Control (C2) servers. Previously, we only listened for a limited set of request types (e.g., main_frame, sub_frame, xmlhttprequest), which left the system exposed to other methods of calling C2s. With this fix, we now listen to all network request types and cross-check them against our client-side blocklist, ensuring better coverage and preventing these types of bypasses. Changes: Updated maybeDetectPhishing in background.js to listen for all network requests by removing restrictions on request types. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28057?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to a website known to be on the C2 domain blocklist. For now we made our test website https://develop.d3bkcslj57l47p.amplifyapp.com/ have a malicious C2 Request that is on our blocklist. 2. Attempt to interact with the site. 3. Verify that on visiting the website you get redirected to the Metamask phishing page. 4. Repeat with a site that is not on the blocklist to confirm normal operation. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/scripts/background.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/scripts/background.js b/app/scripts/background.js index 9f203b35661d..ad6e3b6f22c2 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -324,7 +324,6 @@ function maybeDetectPhishing(theController) { return {}; }, { - types: ['main_frame', 'sub_frame', 'xmlhttprequest'], urls: ['http://*/*', 'https://*/*'], }, isManifestV2 ? ['blocking'] : [], From f37388c180158e5af057f2d8b050cde79885ef13 Mon Sep 17 00:00:00 2001 From: AugmentedMode <31675118+AugmentedMode@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:47:28 -0400 Subject: [PATCH 08/56] feat: Cherry-pick: Please view the attached issue (#28133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** *Please view the attached issue within MetaMask planning for details regarding this PR* [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28133?quickstart=1) ## **Related issues** Fixes: *Please view the attached issue within MetaMask planning for details regarding this PR* ## **Manual testing steps** 1. Go to a website known to be on the C2 domain blocklist. For now we made our test website https://develop.d3bkcslj57l47p.amplifyapp.com/ have a malicious C2 Request that is on our blocklist. 2. Attempt to interact with the site. 3. Verify that on visiting the website you get redirected to the Metamask phishing page. 4. Repeat with a site that is not on the blocklist to confirm normal operation. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- lavamoat/browserify/beta/policy.json | 1 + lavamoat/browserify/flask/policy.json | 1 + lavamoat/browserify/main/policy.json | 1 + lavamoat/browserify/mmi/policy.json | 1 + package.json | 2 +- yarn.lock | 23 +++++++++++------------ 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 9cbdda6ac03e..b9b6062a30d7 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2044,6 +2044,7 @@ "globals": { "TextEncoder": true, "URL": true, + "console.error": true, "fetch": true }, "packages": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 9cbdda6ac03e..b9b6062a30d7 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2044,6 +2044,7 @@ "globals": { "TextEncoder": true, "URL": true, + "console.error": true, "fetch": true }, "packages": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 9cbdda6ac03e..b9b6062a30d7 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2044,6 +2044,7 @@ "globals": { "TextEncoder": true, "URL": true, + "console.error": true, "fetch": true }, "packages": { diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index fae253f8b9d5..073882b78df5 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2136,6 +2136,7 @@ "globals": { "TextEncoder": true, "URL": true, + "console.error": true, "fetch": true }, "packages": { diff --git a/package.json b/package.json index fec8f2ffb498..767646ae91ed 100644 --- a/package.json +++ b/package.json @@ -339,7 +339,7 @@ "@metamask/obs-store": "^9.0.0", "@metamask/permission-controller": "^10.0.0", "@metamask/permission-log-controller": "^2.0.1", - "@metamask/phishing-controller": "^12.0.1", + "@metamask/phishing-controller": "^12.3.0", "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.35.1", "@metamask/preinstalled-example-snap": "^0.2.0", diff --git a/yarn.lock b/yarn.lock index af059f8960e9..54b160bc821e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5015,9 +5015,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0": - version: 11.3.0 - resolution: "@metamask/controller-utils@npm:11.3.0" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0": + version: 11.4.0 + resolution: "@metamask/controller-utils@npm:11.4.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" @@ -5028,7 +5028,7 @@ __metadata: bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/3200228d1f4ea5fa095228db4e5050529caf0470e072382eb8f7571bb9b07515516ca9e846b7751388399d9ae967e4985dafd6120902ef6c998e98f4eb36d964 + checksum: 10/f34d24880eab264bddaa5bef21afaecb206db6978364565d0f7b7a54b1d411f129eb84175041df3be8a66394c2d49e83b6648b5cbde6f34662a60fc553c31458 languageName: node linkType: hard @@ -5962,19 +5962,18 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^12.0.1, @metamask/phishing-controller@npm:^12.0.2": - version: 12.0.2 - resolution: "@metamask/phishing-controller@npm:12.0.2" +"@metamask/phishing-controller@npm:^12.0.2, @metamask/phishing-controller@npm:^12.3.0": + version: 12.3.0 + resolution: "@metamask/phishing-controller@npm:12.3.0" dependencies: - "@metamask/base-controller": "npm:^7.0.0" - "@metamask/controller-utils": "npm:^11.2.0" + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.4.0" "@noble/hashes": "npm:^1.4.0" "@types/punycode": "npm:^2.1.0" - eth-phishing-detect: "npm:^1.2.0" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" punycode: "npm:^2.1.1" - checksum: 10/78781e1b781c838e303677157616fb3b5e581030fe8f0ed8913f6b75fbcb7ee2ba59a44831936cc68cca8b295ef6546761b40ea3277d810b68d8ed39a58d0e29 + checksum: 10/15e64adff57996486c36d0c73747a76543e8f7ad79020fc2746726f81f3858251b2e256c04e8d9caf1daf71c41f7ddf575c901d2a46174a5884d2836c60a3b2d languageName: node linkType: hard @@ -26143,7 +26142,7 @@ __metadata: "@metamask/obs-store": "npm:^9.0.0" "@metamask/permission-controller": "npm:^10.0.0" "@metamask/permission-log-controller": "npm:^2.0.1" - "@metamask/phishing-controller": "npm:^12.0.1" + "@metamask/phishing-controller": "npm:^12.3.0" "@metamask/phishing-warning": "npm:^4.0.0" "@metamask/post-message-stream": "npm:^8.0.0" "@metamask/ppom-validator": "npm:0.35.1" From d7108ed070a632b0fb4dcd75389b3fb97c640dd8 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Tue, 29 Oct 2024 14:38:05 -0230 Subject: [PATCH 09/56] fix (cherry-pick): 0 token balance in send flow (#28136) (#28151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picks https://github.com/MetaMask/metamask-extension/pull/28136 (1fd9510). Original PR description: ## **Description** Fixes an issue where token balance showed as 0 during send flow. This occurred when clicking the token in the token list, then clicking the send button from the token details page. When going send first and then picking a token, picking `decimals` was a number: ![image](https://github.com/user-attachments/assets/95d32a68-1076-4050-b129-187672694794) But when going token first and then clicking send , `decimals` was a string and skipped calculating the balance. ![image](https://github.com/user-attachments/assets/99a8fcd4-bd29-4969-b98d-6639d50553ff) `calcTokenAmount` seems to work with either string or number, so changing logic from https://github.com/MetaMask/metamask-extension/pull/27083 which introduced the number check [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28136?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28112 ## **Manual testing steps** 1. Click on an erc20 token on the token list 2. Click the send button on the token details page 3. Choose a destination account 4. The balance under the asset picker should be accurate ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28151?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. Co-authored-by: Brian Bergeron --- ui/ducks/send/send.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index cdbe7d2daa86..fe8f7b087431 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -2669,7 +2669,7 @@ export function updateSendAsset( if (details.standard === TokenStandard.ERC20) { asset.balance = - details.balance && typeof details.decimals === 'number' + details.balance && details.decimals !== undefined ? addHexPrefix( calcTokenAmount(details.balance, details.decimals).toString(16), ) From f6ccbfc6390e9b96618468f1b28645360a75267b Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Wed, 30 Oct 2024 10:04:12 +0000 Subject: [PATCH 10/56] feat: enable security alerts api (#28040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to enable the Security Alerts API. The environment variable `SECURITY_ALERTS_API_ENABLED` will be maintained and removed in a separate PR in a future release. There is a fallback mechanism that uses the local PPOM to validate the request in the case of an issue with the API. This safeguard is designed to prevent any disruption or impact on the user experience. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27828?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/2516 ## **Manual testing steps** - Go to test dapp and trigger one of the malicious signatures - To verify in Chrome go to dev tools > network. Search for `security-alerts` and find the call to the API service. ## **Screenshots/Recordings** [test-security-alerts-api.webm](https://github.com/user-attachments/assets/b89c966a-0378-4487-bee9-849ab8949bb0) ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/scripts/lib/ppom/ppom-middleware.ts | 5 +- .../lib/ppom/security-alerts-api.test.ts | 16 +- builds.yml | 4 +- privacy-snapshot.json | 2 + test/e2e/helpers.js | 6 +- test/e2e/mock-e2e.js | 28 +++ test/e2e/tests/ppom/constants.ts | 5 + ...ppom-blockaid-alert-erc20-transfer.spec.js | 165 ++++-------------- .../ppom-blockaid-alert-simple-send.spec.js | 119 ++++++------- .../dapp1-send-dapp2-signTypedData.spec.js | 12 +- 10 files changed, 152 insertions(+), 210 deletions(-) create mode 100644 test/e2e/tests/ppom/constants.ts diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 7eb8dc0cc5a2..ebfdbe3f04d7 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -86,12 +86,11 @@ export function createPPOMMiddleware< return; } - const isSupportedChain = await isChainSupported(chainId); - if ( !securityAlertsEnabled || !CONFIRMATION_METHODS.includes(req.method) || - !isSupportedChain + // Do not move this call above this check because it will result in unnecessary calls + !(await isChainSupported(chainId)) ) { return; } diff --git a/app/scripts/lib/ppom/security-alerts-api.test.ts b/app/scripts/lib/ppom/security-alerts-api.test.ts index 9d2d97652d4f..460139c1d359 100644 --- a/app/scripts/lib/ppom/security-alerts-api.test.ts +++ b/app/scripts/lib/ppom/security-alerts-api.test.ts @@ -27,6 +27,8 @@ const RESPONSE_MOCK = { description: 'Test Description', }; +const BASE_URL = 'https://example.com'; + describe('Security Alerts API', () => { const fetchMock = jest.fn(); @@ -40,7 +42,7 @@ describe('Security Alerts API', () => { json: async () => RESPONSE_MOCK, }); - process.env.SECURITY_ALERTS_API_URL = 'https://example.com'; + process.env.SECURITY_ALERTS_API_URL = BASE_URL; }); describe('validateWithSecurityAlertsAPI', () => { @@ -54,8 +56,14 @@ describe('Security Alerts API', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `https://example.com/validate/${CHAIN_ID_MOCK}`, - expect.any(Object), + `${BASE_URL}/validate/${CHAIN_ID_MOCK}`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(REQUEST_MOCK), + headers: { + 'Content-Type': 'application/json', + }, + }), ); }); @@ -101,7 +109,7 @@ describe('Security Alerts API', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( - `https://example.com/supportedChains`, + `${BASE_URL}/supportedChains`, undefined, ); }); diff --git a/builds.yml b/builds.yml index 3bd1606e5ec4..c9c122312fba 100644 --- a/builds.yml +++ b/builds.yml @@ -276,9 +276,9 @@ env: # Enables use of test gas fee flow to debug gas fee estimation - TEST_GAS_FEE_FLOWS: false # Temporary mechanism to enable security alerts API prior to release - - SECURITY_ALERTS_API_ENABLED: '' + - SECURITY_ALERTS_API_ENABLED: 'true' # URL of security alerts API used to validate dApp requests - - SECURITY_ALERTS_API_URL: 'http://localhost:3000' + - SECURITY_ALERTS_API_URL: 'https://security-alerts.api.cx.metamask.io' # API key to authenticate Etherscan requests to avoid rate limiting - ETHERSCAN_API_KEY: '' diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 41b04a9b5210..589504ea2cc7 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -48,6 +48,8 @@ "raw.githubusercontent.com", "registry.npmjs.org", "responsive-rpc.test", + "security-alerts.api.cx.metamask.io", + "security-alerts.dev-api.cx.metamask.io", "sentry.io", "snaps.metamask.io", "sourcify.dev", diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 02e9e0583a31..5eaf14b8360b 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -909,7 +909,8 @@ const sendScreenToConfirmScreen = async ( quantity, ) => { await openActionMenuAndStartSendFlow(driver); - await driver.fill('[data-testid="ens-input"]', recipientAddress); + await driver.waitForSelector('[data-testid="ens-input"]'); + await driver.pasteIntoField('[data-testid="ens-input"]', recipientAddress); await driver.fill('.unit-input__input', quantity); // check if element exists and click it @@ -928,7 +929,8 @@ const sendTransaction = async ( isAsyncFlow = false, ) => { await openActionMenuAndStartSendFlow(driver); - await driver.fill('[data-testid="ens-input"]', recipientAddress); + await driver.waitForSelector('[data-testid="ens-input"]'); + await driver.pasteIntoField('[data-testid="ens-input"]', recipientAddress); await driver.fill('.unit-input__input', quantity); await driver.clickElement({ diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index 12d0fb293e15..cc49b55f192a 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -1,5 +1,8 @@ const fs = require('fs'); +const { + SECURITY_PROVIDER_SUPPORTED_CHAIN_IDS, +} = require('../../shared/constants/security-provider'); const { BRIDGE_DEV_API_BASE_URL, BRIDGE_PROD_API_BASE_URL, @@ -13,6 +16,7 @@ const { SWAPS_API_V2_BASE_URL, TOKEN_API_BASE_URL, } = require('../../shared/constants/swaps'); +const { SECURITY_ALERTS_PROD_API_BASE_URL } = require('./tests/ppom/constants'); const { DEFAULT_FEATURE_FLAGS_RESPONSE: BRIDGE_DEFAULT_FEATURE_FLAGS_RESPONSE, } = require('./tests/bridge/constants'); @@ -151,6 +155,30 @@ async function setupMocking( }; }); + await server + .forGet(`${SECURITY_ALERTS_PROD_API_BASE_URL}/supportedChains`) + .thenCallback(() => { + return { + statusCode: 200, + json: SECURITY_PROVIDER_SUPPORTED_CHAIN_IDS, + }; + }); + + await server + .forPost(`${SECURITY_ALERTS_PROD_API_BASE_URL}/validate/${chainId}`) + .thenCallback(() => { + return { + statusCode: 200, + json: { + block: 20733513, + result_type: 'Benign', + reason: '', + description: '', + features: [], + }, + }; + }); + await server .forPost( 'https://arbitrum-mainnet.infura.io/v3/00000000000000000000000000000000', diff --git a/test/e2e/tests/ppom/constants.ts b/test/e2e/tests/ppom/constants.ts new file mode 100644 index 000000000000..7794e8738a76 --- /dev/null +++ b/test/e2e/tests/ppom/constants.ts @@ -0,0 +1,5 @@ +export const SECURITY_ALERTS_DEV_API_BASE_URL = + 'https://security-alerts.dev-api.cx.metamask.io'; + +export const SECURITY_ALERTS_PROD_API_BASE_URL = + 'https://security-alerts.api.cx.metamask.io'; diff --git a/test/e2e/tests/ppom/ppom-blockaid-alert-erc20-transfer.spec.js b/test/e2e/tests/ppom/ppom-blockaid-alert-erc20-transfer.spec.js index 01d90da9324c..4f6fcf819f94 100644 --- a/test/e2e/tests/ppom/ppom-blockaid-alert-erc20-transfer.spec.js +++ b/test/e2e/tests/ppom/ppom-blockaid-alert-erc20-transfer.spec.js @@ -6,154 +6,57 @@ const { unlockWallet, withFixtures, } = require('../../helpers'); +const { SECURITY_ALERTS_PROD_API_BASE_URL } = require('./constants'); const { mockServerJsonRpc } = require('./mocks/mock-server-json-rpc'); -const selectedAddress = '0x5cfe73b6021e818b776b421b1c4db2474086a7e1'; -const selectedAddressWithoutPrefix = '5cfe73b6021e818b776b421b1c4db2474086a7e1'; +const SELECTED_ADDRESS = '0x5cfe73b6021e818b776b421b1c4db2474086a7e1'; -const CONTRACT_ADDRESS = { - BalanceChecker: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', - FiatTokenV2_1: '0xa2327a938febf5fec13bacfb16ae10ecbc4cbdcf', - OffChainOracle: '0x52cbe0f49ccdd4dc6e9c13bab024eabd2842045b', - USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', -}; +const CONTRACT_ADDRESS_USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; async function mockInfura(mockServer) { await mockServerJsonRpc(mockServer, [ ['eth_blockNumber'], - [ - 'eth_call', - { - methodResultVariant: 'balanceChecker', - params: [{ to: CONTRACT_ADDRESS.BalanceChecker }], - }, - ], - [ - 'eth_call', - { - methodResultVariant: 'offchainOracle', - params: [{ to: CONTRACT_ADDRESS.OffChainOracle }], - }, - ], - [ - 'eth_call', - { - methodResultVariant: 'balance', - params: [ - { - accessList: [], - data: `0x70a08231000000000000000000000000${selectedAddressWithoutPrefix}`, - to: CONTRACT_ADDRESS.USDC, - }, - ], - }, - ], + ['eth_call'], ['eth_estimateGas'], ['eth_feeHistory'], ['eth_gasPrice'], ['eth_getBalance'], ['eth_getBlockByNumber'], - [ - 'eth_getCode', - { - methodResultVariant: 'USDC', - params: [CONTRACT_ADDRESS.USDC], - }, - ], + ['eth_getCode'], ['eth_getTransactionCount'], ]); +} - await mockServer - .forPost() - .withJsonBodyIncluding({ - method: 'debug_traceCall', - params: [{ accessList: [], data: '0x00000000' }], - }) - .thenCallback(async (req) => { - return { - statusCode: 200, - json: { - jsonrpc: '2.0', - id: (await req.body.getJson()).id, - result: { - calls: [ - { - error: 'execution reverted', - from: CONTRACT_ADDRESS.USDC, - gas: '0x1d55c2c7', - gasUsed: '0xf0', - input: '0x00000000', - to: CONTRACT_ADDRESS.FiatTokenV2_1, - type: 'DELEGATECALL', - value: '0x0', - }, - ], - error: 'execution reverted', - from: '0x0000000000000000000000000000000000000000', - gas: '0x1dcd6500', - gasUsed: '0x6f79', - input: '0x00000000', - to: CONTRACT_ADDRESS.USDC, - type: 'CALL', - value: '0x0', - }, - }, - }; - }); +const maliciousTransferAlert = { + block: 1, + result_type: 'Malicious', + reason: 'transfer_farming', + description: + 'Transfer to 0x5fbdb2315678afecb367f032d93f642f64180aa3, classification: A known malicious address is involved in the transaction', + features: ['A known malicious address is involved in the transaction'], +}; - await mockServer - .forPost() +async function mockRequest(server, response) { + await server + .forPost(`${SECURITY_ALERTS_PROD_API_BASE_URL}/validate/0x1`) .withJsonBodyIncluding({ - method: 'debug_traceCall', - params: [{ from: selectedAddress }], - }) - .thenCallback(async (req) => { - const mockFakePhishingAddress = - '5fbdb2315678afecb367f032d93f642f64180aa3'; - - return { - statusCode: 200, - json: { - jsonrpc: '2.0', - id: (await req.body.getJson()).id, - result: { - calls: [ - { - from: CONTRACT_ADDRESS.USDC, - gas: '0x2923d', - gasUsed: '0x4cac', - input: `0xa9059cbb000000000000000000000000${mockFakePhishingAddress}0000000000000000000000000000000000000000000000000000000000000064`, - logs: [ - { - address: CONTRACT_ADDRESS.USDC, - data: '0x0000000000000000000000000000000000000000000000000000000000000064', - topics: [ - '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', - `0x000000000000000000000000${selectedAddressWithoutPrefix}`, - `0x000000000000000000000000${mockFakePhishingAddress}`, - ], - }, - ], - output: - '0x0000000000000000000000000000000000000000000000000000000000000001', - to: CONTRACT_ADDRESS.FiatTokenV2_1, - type: 'DELEGATECALL', - value: '0x0', - }, - ], - from: selectedAddress, - gas: '0x30d40', - gasUsed: '0xbd69', - input: `0xa9059cbb000000000000000000000000${mockFakePhishingAddress}0000000000000000000000000000000000000000000000000000000000000064`, - output: - '0x0000000000000000000000000000000000000000000000000000000000000001', - to: CONTRACT_ADDRESS.USDC, - type: 'CALL', - value: '0x0', - }, + method: 'eth_sendTransaction', + params: [ + { + from: SELECTED_ADDRESS, + data: '0xa9059cbb0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa30000000000000000000000000000000000000000000000000000000000000064', + to: CONTRACT_ADDRESS_USDC, + value: '0x0', }, - }; - }); + ], + }) + .thenJson(201, response); +} + +async function mockInfuraWithMaliciousResponses(mockServer) { + await mockInfura(mockServer); + + await mockRequest(mockServer, maliciousTransferAlert); } describe('PPOM Blockaid Alert - Malicious ERC20 Transfer @no-mmi', function () { @@ -173,7 +76,7 @@ describe('PPOM Blockaid Alert - Malicious ERC20 Transfer @no-mmi', function () { }) .build(), defaultGanacheOptions, - testSpecificMock: mockInfura, + testSpecificMock: mockInfuraWithMaliciousResponses, title: this.test.fullTitle(), }, diff --git a/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js b/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js index e4ce73bcb615..c1c7323671f5 100644 --- a/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js +++ b/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js @@ -3,11 +3,12 @@ const FixtureBuilder = require('../../fixture-builder'); const { defaultGanacheOptions, - logInWithBalanceValidation, + withFixtures, sendScreenToConfirmScreen, + logInWithBalanceValidation, WINDOW_TITLES, - withFixtures, } = require('../../helpers'); +const { SECURITY_ALERTS_PROD_API_BASE_URL } = require('./constants'); const { mockServerJsonRpc } = require('./mocks/mock-server-json-rpc'); const bannerAlertSelector = '[data-testid="security-provider-banner-alert"]'; @@ -18,6 +19,18 @@ const expectedMaliciousTitle = 'This is a deceptive request'; const expectedMaliciousDescription = 'If you approve this request, a third party known for scams will take all your assets.'; +const SEND_REQUEST_BASE_MOCK = { + method: 'eth_sendTransaction', + params: [ + { + from: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + data: '0x', + to: mockMaliciousAddress, + value: '0xde0b6b3a7640000', + }, + ], +}; + async function mockInfura(mockServer) { await mockServerJsonRpc(mockServer, [ ['eth_blockNumber'], @@ -32,85 +45,63 @@ async function mockInfura(mockServer) { ]); } +async function mockRequest(server, request, response) { + await server + .forPost(`${SECURITY_ALERTS_PROD_API_BASE_URL}/validate/0x1`) + .withJsonBodyIncluding(request) + .thenJson(response.statusCode ?? 201, response); +} + async function mockInfuraWithBenignResponses(mockServer) { await mockInfura(mockServer); - await mockServer - .forPost() - .withJsonBodyIncluding({ - method: 'debug_traceCall', - }) - .thenCallback(async (req) => { - return { - statusCode: 200, - json: { - jsonrpc: '2.0', - id: (await req.body.getJson()).id, - result: { - type: 'CALL', - from: '0x0000000000000000000000000000000000000000', - to: '0xd46e8dd67c5d32be8058bb8eb970870f07244567', - value: '0xde0b6b3a7640000', - gas: '0x16c696eb7', - gasUsed: '0x0', - input: '0x', - output: '0x', - }, - }, - }; - }); + await mockRequest(mockServer, SEND_REQUEST_BASE_MOCK, { + block: 20733513, + result_type: 'Benign', + reason: '', + description: '', + features: [], + }); } async function mockInfuraWithMaliciousResponses(mockServer) { await mockInfura(mockServer); - await mockServer - .forPost() - .withJsonBodyIncluding({ - method: 'debug_traceCall', - params: [{ accessList: [], data: '0x00000000' }], - }) - .thenCallback(async (req) => { - return { - statusCode: 200, - json: { - jsonrpc: '2.0', - id: (await req.body.getJson()).id, - result: { - calls: [ - { - error: 'execution reverted', - from: '0x0000000000000000000000000000000000000000', - gas: '0x1d55c2cb', - gasUsed: '0x39c', - input: '0x00000000', - to: mockMaliciousAddress, - type: 'DELEGATECALL', - value: '0x0', - }, - ], - error: 'execution reverted', - from: '0x0000000000000000000000000000000000000000', - gas: '0x1dcd6500', - gasUsed: '0x721e', - input: '0x00000000', - to: mockMaliciousAddress, - type: 'CALL', - value: '0x0', - }, - }, - }; - }); + await mockRequest(mockServer, SEND_REQUEST_BASE_MOCK, { + block: 20733277, + result_type: 'Malicious', + reason: 'transfer_farming', + description: '', + features: ['Interaction with a known malicious address'], + }); } async function mockInfuraWithFailedResponses(mockServer) { await mockInfura(mockServer); + await mockRequest( + mockServer, + { + ...SEND_REQUEST_BASE_MOCK, + params: [ + { + from: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + data: '0x', + to: '0xb8c77482e45f1f44de1745f52c74426c631bdd52', + value: '0xf43fc2c04ee0000', + }, + ], + }, + { statusCode: 500, message: 'Internal server error' }, + ); + + // Retained this mock to support fallback to the local PPOM await mockServer .forGet( 'https://static.cx.metamask.io/api/v1/confirmations/ppom/ppom_version.json', ) .thenCallback(() => { + console.log('mocked ppom_version.json'); return { statusCode: 500, }; @@ -144,7 +135,7 @@ describe('Simple Send Security Alert - Blockaid @no-mmi', function () { await logInWithBalanceValidation(driver); await sendScreenToConfirmScreen(driver, mockBenignAddress, '1'); - // await driver.delay(100000) + const isPresent = await driver.isElementPresent(bannerAlertSelector); assert.equal(isPresent, false, `Banner alert unexpectedly found.`); }, diff --git a/test/e2e/tests/request-queuing/dapp1-send-dapp2-signTypedData.spec.js b/test/e2e/tests/request-queuing/dapp1-send-dapp2-signTypedData.spec.js index d52d45701563..5814d8a60a2b 100644 --- a/test/e2e/tests/request-queuing/dapp1-send-dapp2-signTypedData.spec.js +++ b/test/e2e/tests/request-queuing/dapp1-send-dapp2-signTypedData.spec.js @@ -9,6 +9,7 @@ const { defaultGanacheOptions, tempToggleSettingRedesignedConfirmations, WINDOW_TITLES, + largeDelayMs, } = require('../../helpers'); describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { @@ -90,7 +91,7 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { `window.ethereum.request(${switchEthereumChainRequest})`, ); - await driver.findElement({ + await driver.waitForSelector({ css: '[id="chainId"]', text: '0x53a', }); @@ -111,7 +112,7 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { await driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); - await driver.findElement({ + await driver.waitForSelector({ css: '[id="chainId"]', text: '0x3e8', }); @@ -132,21 +133,24 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); // Check correct network on the send confirmation. - await driver.findElement({ + await driver.waitForSelector({ css: '[data-testid="network-display"]', text: 'Localhost 7777', }); await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.delay(largeDelayMs); await driver.waitUntilXWindowHandles(4); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); // Check correct network on the signTypedData confirmation. - await driver.findElement({ + await driver.waitForSelector({ css: '[data-testid="signature-request-network-display"]', text: 'Localhost 8546', }); + + await driver.clickElement({ text: 'Reject', tag: 'button' }); }, ); }); From 41d7505e69a259e6a466fa67750566a65cf9778c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 30 Oct 2024 10:34:47 +0000 Subject: [PATCH 11/56] chore: upgrade signature controller to remove global network (#28063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Upgrade `@metamask/signature-controller` to remove the global network usage. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28063?quickstart=1) ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../controllers/mmi-controller.test.ts | 2 - app/scripts/metamask-controller.js | 4 +- lavamoat/browserify/beta/policy.json | 45 ++++++++++++++++++- lavamoat/browserify/flask/policy.json | 45 ++++++++++++++++++- lavamoat/browserify/main/policy.json | 45 ++++++++++++++++++- lavamoat/browserify/mmi/policy.json | 45 ++++++++++++++++++- package.json | 2 +- .../hooks/useCurrentConfirmation.ts | 10 +---- yarn.lock | 33 ++++++++++---- 9 files changed, 200 insertions(+), 31 deletions(-) diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 7fb87c6d143b..64bc46132724 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -237,8 +237,6 @@ describe('MMIController', function () { messenger: mockMessenger, }), isEthSignEnabled: jest.fn(), - getAllState: jest.fn(), - getCurrentChainId: jest.fn(), }), appStateController: new AppStateController({ addUnlockListener: jest.fn(), diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e95f08ecd6d2..bec7ed74905f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1979,11 +1979,9 @@ export default class MetamaskController extends EventEmitter { `${this.keyringController.name}:signPersonalMessage`, `${this.keyringController.name}:signTypedMessage`, `${this.loggingController.name}:add`, + `${this.networkController.name}:getNetworkClientById`, ], }), - getAllState: this.getState.bind(this), - getCurrentChainId: () => - getCurrentChainId({ metamask: this.networkController.state }), trace, }); diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 0e7ea6a201fe..624e424d650d 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2332,16 +2332,57 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller": true, "@metamask/logging-controller": true, "@metamask/message-manager>jsonschema": true, - "@metamask/utils": true, + "@metamask/signature-controller>@metamask/eth-sig-util": true, + "@metamask/signature-controller>@metamask/utils": true, "browserify>buffer": true, "uuid": true, "webpack>events": true } }, + "@metamask/signature-controller>@metamask/eth-sig-util": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, + "@metamask/signature-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/smart-transactions-controller": { "globals": { "URLSearchParams": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 0e7ea6a201fe..624e424d650d 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2332,16 +2332,57 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller": true, "@metamask/logging-controller": true, "@metamask/message-manager>jsonschema": true, - "@metamask/utils": true, + "@metamask/signature-controller>@metamask/eth-sig-util": true, + "@metamask/signature-controller>@metamask/utils": true, "browserify>buffer": true, "uuid": true, "webpack>events": true } }, + "@metamask/signature-controller>@metamask/eth-sig-util": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, + "@metamask/signature-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/smart-transactions-controller": { "globals": { "URLSearchParams": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 0e7ea6a201fe..624e424d650d 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2332,16 +2332,57 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller": true, "@metamask/logging-controller": true, "@metamask/message-manager>jsonschema": true, - "@metamask/utils": true, + "@metamask/signature-controller>@metamask/eth-sig-util": true, + "@metamask/signature-controller>@metamask/utils": true, "browserify>buffer": true, "uuid": true, "webpack>events": true } }, + "@metamask/signature-controller>@metamask/eth-sig-util": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, + "@metamask/signature-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/smart-transactions-controller": { "globals": { "URLSearchParams": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index bb0e150744bf..6704d51c0928 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2424,16 +2424,57 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller": true, "@metamask/logging-controller": true, "@metamask/message-manager>jsonschema": true, - "@metamask/utils": true, + "@metamask/signature-controller>@metamask/eth-sig-util": true, + "@metamask/signature-controller>@metamask/utils": true, "browserify>buffer": true, "uuid": true, "webpack>events": true } }, + "@metamask/signature-controller>@metamask/eth-sig-util": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, + "@metamask/signature-controller>@metamask/eth-sig-util>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, + "@metamask/signature-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/smart-transactions-controller": { "globals": { "URLSearchParams": true, diff --git a/package.json b/package.json index 3d5ee3b946a9..c1b7520c2279 100644 --- a/package.json +++ b/package.json @@ -338,7 +338,7 @@ "@metamask/safe-event-emitter": "^3.1.1", "@metamask/scure-bip39": "^2.0.3", "@metamask/selected-network-controller": "^18.0.2", - "@metamask/signature-controller": "^20.0.0", + "@metamask/signature-controller": "^21.0.0", "@metamask/smart-transactions-controller": "^13.0.0", "@metamask/snaps-controllers": "^9.11.1", "@metamask/snaps-execution-environments": "^6.9.1", diff --git a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts index 8ce37261aa8e..cf5e8a1383a2 100644 --- a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts +++ b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts @@ -8,7 +8,6 @@ import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { ApprovalsMetaMaskState, - getCurrentChainId, getIsRedesignedConfirmationsDeveloperEnabled, getRedesignedConfirmationsEnabled, getRedesignedTransactionsEnabled, @@ -36,9 +35,6 @@ const useCurrentConfirmation = () => { const oldestPendingApproval = useSelector(oldestPendingConfirmationSelector); const confirmationId = paramsConfirmationId ?? oldestPendingApproval?.id; - // TODO: Temporary pending chain ID persisted in signature requests. - const globalChainId = useSelector(getCurrentChainId); - const isRedesignedSignaturesUserSettingEnabled = useSelector( getRedesignedConfirmationsEnabled, ); @@ -106,12 +102,10 @@ const useCurrentConfirmation = () => { } const currentConfirmation = - transactionMetadata ?? - (signatureMessage && { ...signatureMessage, chainId: globalChainId }) ?? - undefined; + transactionMetadata ?? signatureMessage ?? undefined; return { currentConfirmation }; - }, [transactionMetadata, signatureMessage, shouldUseRedesign, globalChainId]); + }, [transactionMetadata, signatureMessage, shouldUseRedesign]); }; export default useCurrentConfirmation; diff --git a/yarn.lock b/yarn.lock index 1eb362ce3a2c..fc235be2429c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5248,6 +5248,20 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-sig-util@npm:^8.0.0": + version: 8.0.0 + resolution: "@metamask/eth-sig-util@npm:8.0.0" + dependencies: + "@ethereumjs/util": "npm:^8.1.0" + "@metamask/abi-utils": "npm:^2.0.4" + "@metamask/utils": "npm:^9.0.0" + "@scure/base": "npm:~1.1.3" + ethereum-cryptography: "npm:^2.1.2" + tweetnacl: "npm:^1.0.3" + checksum: 10/5de92bc59df31bcf417ecbdfd2b47f15c21b29454f45108513c55d9c005b7cb51373e9d254bd97533603ab7c7758fdf8fc5159612f366b05f92ebe5beb6d75d8 + languageName: node + linkType: hard + "@metamask/eth-simple-keyring@npm:^6.0.5": version: 6.0.5 resolution: "@metamask/eth-simple-keyring@npm:6.0.5" @@ -6101,14 +6115,14 @@ __metadata: languageName: node linkType: hard -"@metamask/signature-controller@npm:^20.0.0": - version: 20.0.0 - resolution: "@metamask/signature-controller@npm:20.0.0" +"@metamask/signature-controller@npm:^21.0.0": + version: 21.0.0 + resolution: "@metamask/signature-controller@npm:21.0.0" dependencies: - "@metamask/base-controller": "npm:^7.0.1" - "@metamask/controller-utils": "npm:^11.3.0" - "@metamask/eth-sig-util": "npm:^7.0.1" - "@metamask/utils": "npm:^9.1.0" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.1" + "@metamask/eth-sig-util": "npm:^8.0.0" + "@metamask/utils": "npm:^10.0.0" jsonschema: "npm:^1.2.4" lodash: "npm:^4.17.21" uuid: "npm:^8.3.2" @@ -6116,7 +6130,8 @@ __metadata: "@metamask/approval-controller": ^7.0.0 "@metamask/keyring-controller": ^17.0.0 "@metamask/logging-controller": ^6.0.0 - checksum: 10/5647e362b4478d9cdb9f04027d7bad950efbe310496fc0347a92649a084bb92fc92a7fc5f911f8835e0d6b4e7ed6cf572594a79a57a31240948b87dd2267cdf8 + "@metamask/network-controller": ^22.0.0 + checksum: 10/4c1b1cbf909004099adb3f0d2b01c8fe640ae9a13a8e53ffbcf05c7a1a23384f6077b96b845c22c4edf3bceaaff2a705769d4623f37affac7e429ab0dae06912 languageName: node linkType: hard @@ -25966,7 +25981,7 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.1.1" "@metamask/scure-bip39": "npm:^2.0.3" "@metamask/selected-network-controller": "npm:^18.0.2" - "@metamask/signature-controller": "npm:^20.0.0" + "@metamask/signature-controller": "npm:^21.0.0" "@metamask/smart-transactions-controller": "npm:^13.0.0" "@metamask/snaps-controllers": "npm:^9.11.1" "@metamask/snaps-execution-environments": "npm:^6.9.1" From d6c1df45f78584dcf3a0c3f2b9e51b54cebe1afb Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 30 Oct 2024 11:52:32 +0100 Subject: [PATCH 12/56] feat(snaps): Add `useDisplayName` hook (#27868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds a hook to get the display name or the address book entry for an address. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27868?quickstart=1) ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../snaps/snap-ui-address/snap-ui-address.tsx | 18 +++-- ui/hooks/snaps/useDisplayName.ts | 54 +++++++++++++ ui/selectors/snaps/accounts.ts | 38 +++++++++ ui/selectors/snaps/address-book.ts | 80 +++++++++++++++++++ ui/selectors/snaps/index.ts | 2 + 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 ui/hooks/snaps/useDisplayName.ts create mode 100644 ui/selectors/snaps/accounts.ts create mode 100644 ui/selectors/snaps/address-book.ts create mode 100644 ui/selectors/snaps/index.ts diff --git a/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx b/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx index 35f1af2ad414..c75b172a616e 100644 --- a/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx +++ b/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx @@ -13,6 +13,7 @@ import { import { shortenAddress } from '../../../../helpers/utils/util'; import { toChecksumHexAddress } from '../../../../../shared/modules/hexstring-utils'; import { SnapUIAvatar } from '../snap-ui-avatar'; +import { useDisplayName } from '../../../../hooks/snaps/useDisplayName'; export type SnapUIAddressProps = { // The address must be a CAIP-10 string. @@ -40,12 +41,15 @@ export const SnapUIAddress: React.FunctionComponent = ({ [caipIdentifier], ); - // For EVM addresses, we make sure they are checksummed. - const transformedAddress = - parsed.chain.namespace === 'eip155' - ? toChecksumHexAddress(parsed.address) - : parsed.address; - const shortenedAddress = shortenAddress(transformedAddress); + const displayName = useDisplayName(parsed); + + const value = + displayName ?? + shortenAddress( + parsed.chain.namespace === 'eip155' + ? toChecksumHexAddress(parsed.address) + : parsed.address, + ); return ( = ({ gap={2} > - {shortenedAddress} + {value} ); }; diff --git a/ui/hooks/snaps/useDisplayName.ts b/ui/hooks/snaps/useDisplayName.ts new file mode 100644 index 000000000000..6a6d3d7e6b51 --- /dev/null +++ b/ui/hooks/snaps/useDisplayName.ts @@ -0,0 +1,54 @@ +import { NamespaceId } from '@metamask/snaps-utils'; +import { CaipChainId, KnownCaipNamespace } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { + getMemoizedAccountName, + getAddressBookEntryByNetwork, + AddressBookMetaMaskState, + AccountsMetaMaskState, +} from '../../selectors/snaps'; +import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; +import { decimalToHex } from '../../../shared/modules/conversion.utils'; + +export type UseDisplayNameParams = { + chain: { + namespace: NamespaceId; + reference: string; + }; + chainId: CaipChainId; + address: string; +}; + +/** + * Get the display name for an address. + * This will look for an account name in the state, and if not found, it will look for an address book entry. + * + * @param params - The parsed CAIP-10 ID. + * @returns The display name for the address. + */ +export const useDisplayName = ( + params: UseDisplayNameParams, +): string | undefined => { + const { + address, + chain: { namespace, reference }, + } = params; + + const isEip155 = namespace === KnownCaipNamespace.Eip155; + + const parsedAddress = isEip155 ? toChecksumHexAddress(address) : address; + + const accountName = useSelector((state: AccountsMetaMaskState) => + getMemoizedAccountName(state, parsedAddress), + ); + + const addressBookEntry = useSelector((state: AddressBookMetaMaskState) => + getAddressBookEntryByNetwork( + state, + parsedAddress, + `0x${decimalToHex(isEip155 ? reference : `0`)}`, + ), + ); + + return accountName || (isEip155 && addressBookEntry?.name) || undefined; +}; diff --git a/ui/selectors/snaps/accounts.ts b/ui/selectors/snaps/accounts.ts new file mode 100644 index 000000000000..b47f33726429 --- /dev/null +++ b/ui/selectors/snaps/accounts.ts @@ -0,0 +1,38 @@ +import { createSelector } from 'reselect'; +import { AccountsControllerState } from '@metamask/accounts-controller'; +import { getAccountName, getInternalAccounts } from '../selectors'; +import { createDeepEqualSelector } from '../util'; + +/** + * The Metamask state for the accounts controller. + */ +export type AccountsMetaMaskState = { + metamask: AccountsControllerState; +}; + +/** + * Get the account name for an address. + * + * @param _state - The Metamask state for the accounts controller. + * @param address - The address to get the display name for. + * @returns The account name for the address. + */ +export const getAccountNameFromState = createSelector( + [ + getInternalAccounts, + (_state: AccountsMetaMaskState, address: string) => address, + ], + getAccountName, +); + +/** + * Get the memoized account name for an address. + * + * @param state - The Metamask state for the accounts controller. + * @param address - The address to get the display name for. + * @returns The account name for the address. + */ +export const getMemoizedAccountName = createDeepEqualSelector( + [getAccountNameFromState], + (accountName: string) => accountName, +); diff --git a/ui/selectors/snaps/address-book.ts b/ui/selectors/snaps/address-book.ts new file mode 100644 index 000000000000..e002153d9e61 --- /dev/null +++ b/ui/selectors/snaps/address-book.ts @@ -0,0 +1,80 @@ +import { AddressBookController } from '@metamask/address-book-controller'; +import { createDeepEqualSelector } from '../util'; +import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; + +/** + * The Metamask state for the address book controller. + */ +export type AddressBookMetaMaskState = { + metamask: { + addressBook: AddressBookController['state']['addressBook']; + }; +}; + +/** + * Get the full address book. + * + * @param state - The Metamask state for the address book controller. + * @returns The full address book. + */ +export const getFullAddressBook = (state: AddressBookMetaMaskState) => + state.metamask.addressBook; + +/** + * Get the memoized full address book. + * + * @param state - The Metamask state for the address book controller. + * @returns The full address book. + */ +export const getMemoizedFullAddressBook = createDeepEqualSelector( + [getFullAddressBook], + (addressBook) => addressBook, +); + +/** + * Get the address book for a network. + * + * @param _state - The Metamask state for the address book controller. + * @param chainId - The chain ID to get the address book for. + * @returns The address book for the network. + */ +export const getAddressBookByNetwork = createDeepEqualSelector( + [ + getMemoizedFullAddressBook, + (_state: AddressBookMetaMaskState, chainId: `0x${string}`) => chainId, + ], + (addressBook, chainId) => { + if (!addressBook[chainId]) { + return []; + } + return Object.values(addressBook[chainId]); + }, +); + +/* eslint-disable jsdoc/require-param */ +/* eslint-disable jsdoc/check-param-names */ +/** + * Get an address book entry for an address on a network. + * + * @param state - The Metamask state for the address book controller. + * @param address - The address to get the entry for. + * @param chainId - The chain ID to get the entry for. + * @returns The address book entry for the address on the network. + */ +/* eslint-enable jsdoc/require-param */ +/* eslint-enable jsdoc/check-param-names */ +export const getAddressBookEntryByNetwork = createDeepEqualSelector( + [ + ( + state: AddressBookMetaMaskState, + _address: string, + chainId: `0x${string}`, + ) => getAddressBookByNetwork(state, chainId), + (_state, address) => address, + ], + (addressBook, address) => { + return addressBook.find((contact) => + isEqualCaseInsensitive(contact.address, address), + ); + }, +); diff --git a/ui/selectors/snaps/index.ts b/ui/selectors/snaps/index.ts new file mode 100644 index 000000000000..84a134e4550f --- /dev/null +++ b/ui/selectors/snaps/index.ts @@ -0,0 +1,2 @@ +export * from './address-book'; +export * from './accounts'; From 62cc4600a925927c3baca72125a6bb8a966bfbc3 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 30 Oct 2024 12:26:51 +0000 Subject: [PATCH 13/56] chore: update confirmations code ownership (#27862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add additional entries for confirmations team in code ownership to encompass: - PPOM - Client infrastructure for the signature and transaction controller. - Transaction data decoding support used by redesigned confirmations. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27862?quickstart=1) ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/CODEOWNERS | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e14d27619a07..f37a101e6cb2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -52,7 +52,12 @@ privacy-snapshot.json @MetaMask/extension-privacy-reviewers .devcontainer/ @MetaMask/library-admins @HowardBraham @plasmacorral # Confirmations team to own code for confirmations on UI. -ui/pages/confirmations @MetaMask/confirmations +app/scripts/lib/ppom @MetaMask/confirmations +app/scripts/lib/signature @MetaMask/confirmations +app/scripts/lib/transaction/decode @MetaMask/confirmations +app/scripts/lib/transaction/metrics.* @MetaMask/confirmations +app/scripts/lib/transaction/util.* @MetaMask/confirmations +ui/pages/confirmations @MetaMask/confirmations # MMI team is responsible for code related with Institutioanl version of MetaMask ui/pages/institutional @MetaMask/mmi From 05da3f720c1527a61c09ca616b68793e15029ce3 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Wed, 30 Oct 2024 09:01:29 -0400 Subject: [PATCH 14/56] feat: add privacy mode (#28021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a privacy mode toggle (an eye icon next to the main balance) that hides all sensitive information/token balances **UPDATE** Here is feedback from @amandaye0h and has been currently implemented in this PR [Figma](https://www.figma.com/design/aMYisczaJyEsYl1TYdcPUL/Portfolio-View?node-id=6219-62460&t=aeTv5cenoUPUrg1c-4) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28021?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3416 https://github.com/MetaMask/MetaMask-planning/issues/3418 https://github.com/MetaMask/MetaMask-planning/issues/3419 ## **Manual testing steps** 1. Go to the Wallet page 2. Click on the new Eye icon next to the balance 3. All balances should be hidden 4. Click on the Eye icon once again 5. All balances should be shown ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/2950ac0c-593d-4daa-aa5d-3e6c3a2d5598 https://github.com/user-attachments/assets/6371c2a2-04fa-48a3-8744-991a1540d5f2 Screenshot 2024-10-22 at 18 43 19 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: vinnyhoward Co-authored-by: David Walsh --- app/scripts/constants/sentry-state.ts | 1 + .../preferences-controller.test.ts | 2 + .../controllers/preferences-controller.ts | 2 + app/scripts/fixtures/with-preferences.js | 1 + test/e2e/fixture-builder.js | 1 + test/e2e/tests/metrics/errors.spec.js | 1 + .../tests/privacy-mode/privacy-mode.spec.js | 106 ++++++++++++++++++ .../data/onboarding-completion-route.json | 3 +- .../asset-list/native-token/native-token.tsx | 4 +- .../app/assets/token-cell/token-cell.test.tsx | 5 +- .../app/assets/token-cell/token-cell.tsx | 4 +- .../row/__snapshots__/currency.test.tsx.snap | 2 + .../__snapshots__/currency-input.test.js.snap | 4 + ...transaction-gas-fee.component.test.js.snap | 2 + ...-preferenced-currency-display.test.js.snap | 1 + .../aggregated-percentage-overview.test.tsx | 4 + .../aggregated-percentage-overview.tsx | 21 +++- .../app/wallet-overview/coin-overview.tsx | 64 +++++++---- ui/components/app/wallet-overview/index.scss | 6 +- .../sensitive-text/sensitive-text.types.ts | 1 - .../account-list-item.test.js.snap | 3 + .../asset-balance-text.test.tsx.snap | 1 + .../connect-accounts-modal.test.tsx.snap | 2 + .../__snapshots__/connections.test.tsx.snap | 2 + .../send/__snapshots__/send.test.js.snap | 2 + .../__snapshots__/your-accounts.test.tsx.snap | 12 ++ .../token-list-item/token-list-item.tsx | 22 +++- .../currency-display.component.test.js.snap | 3 + .../currency-display.component.js | 24 ++-- ui/ducks/metamask/metamask.js | 1 + .../prepare-bridge-page.test.tsx.snap | 4 + .../confirm-gas-display.test.js.snap | 2 + .../confirm-legacy-gas-display.test.js.snap | 3 + .../confirm-detail-row.component.test.js.snap | 2 + .../multi-layer-fee-message.test.js.snap | 2 + .../confirm-send-ether.test.js.snap | 3 + .../confirm-transaction-base.test.js.snap | 3 + .../remove-snap-account.test.js.snap | 2 + ui/store/actions.ts | 4 + 39 files changed, 284 insertions(+), 48 deletions(-) create mode 100644 test/e2e/tests/privacy-mode/privacy-mode.spec.js diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 864f6045b340..1c5dfcfa7ceb 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -243,6 +243,7 @@ export const SENTRY_BACKGROUND_STATE = { showNativeTokenAsMainBalance: true, petnamesEnabled: true, showConfirmationAdvancedDetails: true, + privacyMode: false, }, useExternalServices: false, selectedAddress: false, diff --git a/app/scripts/controllers/preferences-controller.test.ts b/app/scripts/controllers/preferences-controller.test.ts index 9c28ed7c43a0..9215ff8571a7 100644 --- a/app/scripts/controllers/preferences-controller.test.ts +++ b/app/scripts/controllers/preferences-controller.test.ts @@ -730,6 +730,7 @@ describe('preferences controller', () => { expect(controller.state.preferences).toStrictEqual({ autoLockTimeLimit: undefined, showExtensionInFullSizeView: false, + privacyMode: false, showFiatInTestnets: false, showTestNetworks: false, smartTransactionsOptInStatus: null, @@ -764,6 +765,7 @@ describe('preferences controller', () => { useNativeCurrencyAsPrimaryCurrency: true, hideZeroBalanceTokens: false, petnamesEnabled: true, + privacyMode: false, redesignedConfirmationsEnabled: true, redesignedTransactionsEnabled: true, shouldShowAggregatedBalancePopover: true, diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index 536ec33b34eb..ee18403c210b 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -112,6 +112,7 @@ export type Preferences = { redesignedTransactionsEnabled: boolean; featureNotificationsEnabled: boolean; showMultiRpcModal: boolean; + privacyMode: boolean; isRedesignedConfirmationsDeveloperEnabled: boolean; showConfirmationAdvancedDetails: boolean; tokenSortConfig: { @@ -214,6 +215,7 @@ export const getDefaultPreferencesControllerState = isRedesignedConfirmationsDeveloperEnabled: false, showConfirmationAdvancedDetails: false, showMultiRpcModal: false, + privacyMode: false, shouldShowAggregatedBalancePopover: true, // by default user should see popover; tokenSortConfig: { key: 'tokenFiatAmount', diff --git a/app/scripts/fixtures/with-preferences.js b/app/scripts/fixtures/with-preferences.js index 8d1e4293e8a4..c3a482ef8f94 100644 --- a/app/scripts/fixtures/with-preferences.js +++ b/app/scripts/fixtures/with-preferences.js @@ -13,6 +13,7 @@ export const FIXTURES_PREFERENCES = { showNftAutodetectModal: false, isRedesignedConfirmationsDeveloperEnabled: false, showConfirmationAdvancedDetails: false, + privacyMode: false, }, featureFlags: { sendHexData: true, diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 0cd05c1dde13..4d7e1873bff4 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -75,6 +75,7 @@ function onboardingFixture() { hideZeroBalanceTokens: false, showExtensionInFullSizeView: false, showFiatInTestnets: false, + privacyMode: false, showTestNetworks: false, smartTransactionsOptInStatus: false, showNativeTokenAsMainBalance: true, diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index 142810394c28..66de2461abc7 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -881,6 +881,7 @@ describe('Sentry errors', function () { preferences: { autoLockTimeLimit: true, // Initialized as undefined showConfirmationAdvancedDetails: true, + privacyMode: false, }, smartTransactionsState: { fees: { diff --git a/test/e2e/tests/privacy-mode/privacy-mode.spec.js b/test/e2e/tests/privacy-mode/privacy-mode.spec.js new file mode 100644 index 000000000000..a4d2c2245752 --- /dev/null +++ b/test/e2e/tests/privacy-mode/privacy-mode.spec.js @@ -0,0 +1,106 @@ +const { strict: assert } = require('assert'); +const { + withFixtures, + unlockWallet, + defaultGanacheOptions, +} = require('../../helpers'); +const FixtureBuilder = require('../../fixture-builder'); + +describe('Privacy Mode', function () { + it('should activate privacy mode, then deactivate it', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder().withPreferencesController().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + async function checkForHeaderValue(value) { + const balanceElement = await driver.findElement( + '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + ); + const surveyText = await balanceElement.getText(); + assert.equal( + surveyText, + value, + `Header balance should be "${value}"`, + ); + } + + async function checkForTokenValue(value) { + const balanceElement = await driver.findElement( + '[data-testid="multichain-token-list-item-secondary-value"]', + ); + const surveyText = await balanceElement.getText(); + assert.equal(surveyText, value, `Token balance should be "${value}"`); + } + + async function checkForPrivacy() { + await checkForHeaderValue('••••••'); + await checkForTokenValue('•••••••••'); + } + + async function checkForNoPrivacy() { + await checkForHeaderValue('25'); + await checkForTokenValue('25 ETH'); + } + + async function togglePrivacy() { + const balanceElement = await driver.findElement( + '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + ); + const initialText = await balanceElement.getText(); + + await driver.clickElement('[data-testid="sensitive-toggle"]'); + await driver.wait(async () => { + const currentText = await balanceElement.getText(); + return currentText !== initialText; + }, 2e3); + } + + await unlockWallet(driver); + await checkForNoPrivacy(); + await togglePrivacy(); + await checkForPrivacy(); + await togglePrivacy(); + await checkForNoPrivacy(); + }, + ); + }); + + it('should hide fiat balance and token balance when privacy mode is activated', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().withPreferencesController().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + async function togglePrivacy() { + const balanceElement = await driver.findElement( + '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + ); + const initialText = await balanceElement.getText(); + + await driver.clickElement('[data-testid="sensitive-toggle"]'); + await driver.wait(async () => { + const currentText = await balanceElement.getText(); + return currentText !== initialText; + }, 2e3); + } + + await togglePrivacy(); + await driver.clickElement('[data-testid="account-menu-icon"]'); + const valueText = await driver.findElement( + '[data-testid="account-value-and-suffix"]', + ); + const valueTextContent = await valueText.getText(); + + assert.equal(valueTextContent, '••••••'); + }, + ); + }); +}); diff --git a/test/integration/data/onboarding-completion-route.json b/test/integration/data/onboarding-completion-route.json index e651e9c2ce29..e47d1379b2eb 100644 --- a/test/integration/data/onboarding-completion-route.json +++ b/test/integration/data/onboarding-completion-route.json @@ -227,7 +227,8 @@ "hideZeroBalanceTokens": false, "petnamesEnabled": true, "redesignedConfirmationsEnabled": true, - "featureNotificationsEnabled": false + "featureNotificationsEnabled": false, + "privacyMode": false }, "preventPollingOnNetworkRestart": false, "previousAppVersion": "", diff --git a/ui/components/app/assets/asset-list/native-token/native-token.tsx b/ui/components/app/assets/asset-list/native-token/native-token.tsx index cf0191b3de66..e63a2902a552 100644 --- a/ui/components/app/assets/asset-list/native-token/native-token.tsx +++ b/ui/components/app/assets/asset-list/native-token/native-token.tsx @@ -8,11 +8,11 @@ import { getMultichainIsMainnet, getMultichainSelectedAccountCachedBalance, } from '../../../../../selectors/multichain'; +import { getPreferences } from '../../../../../selectors'; import { TokenListItem } from '../../../../multichain'; import { useIsOriginalNativeTokenSymbol } from '../../../../../hooks/useIsOriginalNativeTokenSymbol'; import { AssetListProps } from '../asset-list'; import { useNativeTokenBalance } from './use-native-token-balance'; -// import { getPreferences } from '../../../../../selectors'; const NativeToken = ({ onClickAsset }: AssetListProps) => { const nativeCurrency = useSelector(getMultichainNativeCurrency); @@ -20,6 +20,7 @@ const NativeToken = ({ onClickAsset }: AssetListProps) => { const { chainId, ticker, type, rpcUrl } = useSelector( getMultichainCurrentNetwork, ); + const { privacyMode } = useSelector(getPreferences); const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( chainId, ticker, @@ -52,6 +53,7 @@ const NativeToken = ({ onClickAsset }: AssetListProps) => { isNativeCurrency isStakeable={isStakeable} showPercentage + privacyMode={privacyMode} /> ); }; diff --git a/ui/components/app/assets/token-cell/token-cell.test.tsx b/ui/components/app/assets/token-cell/token-cell.test.tsx index 882c80964d5b..5cb4b30aea49 100644 --- a/ui/components/app/assets/token-cell/token-cell.test.tsx +++ b/ui/components/app/assets/token-cell/token-cell.test.tsx @@ -5,7 +5,7 @@ import { fireEvent } from '@testing-library/react'; import { useSelector } from 'react-redux'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; -import { getTokenList } from '../../../../selectors'; +import { getTokenList, getPreferences } from '../../../../selectors'; import { getMultichainCurrentChainId, getMultichainIsEvm, @@ -98,6 +98,9 @@ describe('Token Cell', () => { }; const useSelectorMock = useSelector; (useSelectorMock as jest.Mock).mockImplementation((selector) => { + if (selector === getPreferences) { + return { privacyMode: false }; + } if (selector === getTokenList) { return MOCK_GET_TOKEN_LIST; } diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 5f5b43d6c098..3a042de1ebb8 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { getTokenList } from '../../../../selectors'; +import { getTokenList, getPreferences } from '../../../../selectors'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; import { TokenListItem } from '../../../multichain'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; @@ -23,6 +23,7 @@ export default function TokenCell({ onClick, }: TokenCellProps) { const tokenList = useSelector(getTokenList); + const { privacyMode } = useSelector(getPreferences); const tokenData = Object.values(tokenList).find( (token) => isEqualCaseInsensitive(token.symbol, symbol) && @@ -51,6 +52,7 @@ export default function TokenCell({ isOriginalTokenSymbol={isOriginalTokenSymbol} address={address} showPercentage + privacyMode={privacyMode} /> ); } diff --git a/ui/components/app/confirm/info/row/__snapshots__/currency.test.tsx.snap b/ui/components/app/confirm/info/row/__snapshots__/currency.test.tsx.snap index e98ec1921081..9f7014dea03e 100644 --- a/ui/components/app/confirm/info/row/__snapshots__/currency.test.tsx.snap +++ b/ui/components/app/confirm/info/row/__snapshots__/currency.test.tsx.snap @@ -12,6 +12,7 @@ exports[`ConfirmInfoRowCurrency should display in currency passed 1`] = ` > $82.65 @@ -37,6 +38,7 @@ exports[`ConfirmInfoRowCurrency should display value in user preferred currency > 0.14861879 diff --git a/ui/components/app/currency-input/__snapshots__/currency-input.test.js.snap b/ui/components/app/currency-input/__snapshots__/currency-input.test.js.snap index f9823b5af9ac..2482a916a5a9 100644 --- a/ui/components/app/currency-input/__snapshots__/currency-input.test.js.snap +++ b/ui/components/app/currency-input/__snapshots__/currency-input.test.js.snap @@ -36,6 +36,7 @@ exports[`CurrencyInput Component rendering should disable unit input 1`] = ` > $0.00 @@ -89,6 +90,7 @@ exports[`CurrencyInput Component rendering should render properly with a fiat va > 0.004327880204275946 @@ -183,6 +185,7 @@ exports[`CurrencyInput Component rendering should render properly with an ETH va > $231.06 @@ -237,6 +240,7 @@ exports[`CurrencyInput Component rendering should render properly without a suff > $0.00 diff --git a/ui/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/__snapshots__/cancel-transaction-gas-fee.component.test.js.snap b/ui/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/__snapshots__/cancel-transaction-gas-fee.component.test.js.snap index 179a3821cad4..f98b3a231970 100644 --- a/ui/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/__snapshots__/cancel-transaction-gas-fee.component.test.js.snap +++ b/ui/components/app/modals/cancel-transaction/cancel-transaction-gas-fee/__snapshots__/cancel-transaction-gas-fee.component.test.js.snap @@ -11,6 +11,7 @@ exports[`CancelTransactionGasFee Component should render 1`] = ` > <0.000001 @@ -26,6 +27,7 @@ exports[`CancelTransactionGasFee Component should render 1`] = ` > <0.000001 diff --git a/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap b/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap index b29efce542e3..4a9fc4d3cf7a 100644 --- a/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap +++ b/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap @@ -8,6 +8,7 @@ exports[`UserPreferencedCurrencyDisplay Component rendering should match snapsho > 0 diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx index 95e0d92fa2b8..8da096151908 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx @@ -7,6 +7,7 @@ import { getSelectedAccount, getShouldHideZeroBalanceTokens, getTokensMarketData, + getPreferences, } from '../../../selectors'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; @@ -22,6 +23,7 @@ jest.mock('../../../ducks/locale/locale', () => ({ jest.mock('../../../selectors', () => ({ getCurrentCurrency: jest.fn(), getSelectedAccount: jest.fn(), + getPreferences: jest.fn(), getShouldHideZeroBalanceTokens: jest.fn(), getTokensMarketData: jest.fn(), })); @@ -32,6 +34,7 @@ jest.mock('../../../hooks/useAccountTotalFiatBalance', () => ({ const mockGetIntlLocale = getIntlLocale as unknown as jest.Mock; const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; +const mockGetPreferences = getPreferences as jest.Mock; const mockGetSelectedAccount = getSelectedAccount as unknown as jest.Mock; const mockGetShouldHideZeroBalanceTokens = getShouldHideZeroBalanceTokens as jest.Mock; @@ -159,6 +162,7 @@ describe('AggregatedPercentageOverview', () => { beforeEach(() => { mockGetIntlLocale.mockReturnValue('en-US'); mockGetCurrentCurrency.mockReturnValue('USD'); + mockGetPreferences.mockReturnValue({ privacyMode: false }); mockGetSelectedAccount.mockReturnValue(selectedAccountMock); mockGetShouldHideZeroBalanceTokens.mockReturnValue(false); mockGetTokensMarketData.mockReturnValue(marketDataMock); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx index 94555d3bc0cd..8c609610daa1 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx @@ -7,6 +7,7 @@ import { getSelectedAccount, getShouldHideZeroBalanceTokens, getTokensMarketData, + getPreferences, } from '../../../selectors'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; @@ -19,7 +20,7 @@ import { TextColor, TextVariant, } from '../../../helpers/constants/design-system'; -import { Box, Text } from '../../component-library'; +import { Box, SensitiveText } from '../../component-library'; import { getCalculatedTokenAmount1dAgo } from '../../../helpers/utils/util'; // core already has this exported type but its not yet available in this version @@ -34,6 +35,7 @@ export const AggregatedPercentageOverview = () => { useSelector(getTokensMarketData); const locale = useSelector(getIntlLocale); const fiatCurrency = useSelector(getCurrentCurrency); + const { privacyMode } = useSelector(getPreferences); const selectedAccount = useSelector(getSelectedAccount); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, @@ -110,7 +112,7 @@ export const AggregatedPercentageOverview = () => { let color = TextColor.textDefault; - if (isValidAmount(amountChange)) { + if (!privacyMode && isValidAmount(amountChange)) { if ((amountChange as number) === 0) { color = TextColor.textDefault; } else if ((amountChange as number) > 0) { @@ -118,26 +120,33 @@ export const AggregatedPercentageOverview = () => { } else { color = TextColor.errorDefault; } + } else { + color = TextColor.textAlternative; } + return ( - {formattedAmountChange} - - + {formattedPercentChange} - + ); }; diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index 2de787ef23c0..9f267c96a53d 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -28,6 +28,7 @@ import { JustifyContent, TextAlign, TextVariant, + IconColor, } from '../../../helpers/constants/design-system'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; @@ -61,7 +62,10 @@ import Spinner from '../../ui/spinner'; import { PercentageAndAmountChange } from '../../multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change'; import { getMultichainIsEvm } from '../../../selectors/multichain'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; -import { setAggregatedBalancePopoverShown } from '../../../store/actions'; +import { + setAggregatedBalancePopoverShown, + setPrivacyMode, +} from '../../../store/actions'; import { useTheme } from '../../../hooks/useTheme'; import { getSpecificSettingsRoute } from '../../../helpers/utils/settings-search'; import { useI18nContext } from '../../../hooks/useI18nContext'; @@ -128,7 +132,7 @@ export const CoinOverview = ({ const shouldShowPopover = useSelector(getShouldShowAggregatedBalancePopover); const isTestnet = useSelector(getIsTestnet); - const { showFiatInTestnets } = useSelector(getPreferences); + const { showFiatInTestnets, privacyMode } = useSelector(getPreferences); const selectedAccount = useSelector(getSelectedAccount); const shouldHideZeroBalanceTokens = useSelector( @@ -163,6 +167,10 @@ export const CoinOverview = ({ dispatch(setAggregatedBalancePopoverShown()); }; + const handleSensitiveToggle = () => { + dispatch(setPrivacyMode(!privacyMode)); + }; + const [referenceElement, setReferenceElement] = useState(null); const setBoxRef = (ref: HTMLSpanElement | null) => { @@ -253,26 +261,38 @@ export const CoinOverview = ({ ref={setBoxRef} > {balanceToDisplay ? ( - + <> + + + ) : ( )} diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss index 4759af1ffa8c..a790e8b7ba2e 100644 --- a/ui/components/app/wallet-overview/index.scss +++ b/ui/components/app/wallet-overview/index.scss @@ -70,7 +70,8 @@ display: flex; max-width: inherit; justify-content: center; - flex-wrap: wrap; + align-items: center; + flex-wrap: nowrap; } &__primary-balance { @@ -134,7 +135,8 @@ display: flex; max-width: inherit; justify-content: center; - flex-wrap: wrap; + align-items: center; + flex-wrap: nowrap; } &__primary-balance { diff --git a/ui/components/component-library/sensitive-text/sensitive-text.types.ts b/ui/components/component-library/sensitive-text/sensitive-text.types.ts index 3834190df864..1ea8270d377f 100644 --- a/ui/components/component-library/sensitive-text/sensitive-text.types.ts +++ b/ui/components/component-library/sensitive-text/sensitive-text.types.ts @@ -30,7 +30,6 @@ export type SensitiveTextProps = Omit< * @default false */ isHidden?: boolean; - /** * Determines the length of the hidden text (number of asterisks). * Can be a predefined SensitiveTextLength or a custom string number. diff --git a/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap b/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap index 51f6f2e905f9..e320bd1de0e3 100644 --- a/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap +++ b/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap @@ -242,6 +242,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam > $100,000.00 @@ -538,6 +539,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam > 0.006 @@ -581,6 +583,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam > 0.006 diff --git a/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap b/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap index 9c0bd9c49482..a0c808186082 100644 --- a/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap +++ b/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap @@ -8,6 +8,7 @@ exports[`AssetBalanceText matches snapshot 1`] = ` > prefix-fiat value diff --git a/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap b/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap index d53c8e7d8d8a..b4a4836db2d6 100644 --- a/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap +++ b/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap @@ -358,6 +358,7 @@ exports[`Connect More Accounts Modal should render correctly 1`] = ` > 0 @@ -401,6 +402,7 @@ exports[`Connect More Accounts Modal should render correctly 1`] = ` > 0 diff --git a/ui/components/multichain/pages/connections/__snapshots__/connections.test.tsx.snap b/ui/components/multichain/pages/connections/__snapshots__/connections.test.tsx.snap index afd02098086f..ad2dc490d7c0 100644 --- a/ui/components/multichain/pages/connections/__snapshots__/connections.test.tsx.snap +++ b/ui/components/multichain/pages/connections/__snapshots__/connections.test.tsx.snap @@ -297,6 +297,7 @@ exports[`Connections Content should render correctly 1`] = ` > 966.988 @@ -340,6 +341,7 @@ exports[`Connections Content should render correctly 1`] = ` > 966.988 diff --git a/ui/components/multichain/pages/send/__snapshots__/send.test.js.snap b/ui/components/multichain/pages/send/__snapshots__/send.test.js.snap index 814dc934fc9a..7b0605b7ea60 100644 --- a/ui/components/multichain/pages/send/__snapshots__/send.test.js.snap +++ b/ui/components/multichain/pages/send/__snapshots__/send.test.js.snap @@ -474,6 +474,7 @@ exports[`SendPage render and initialization should render correctly even when a > $0.00 @@ -517,6 +518,7 @@ exports[`SendPage render and initialization should render correctly even when a > 0 diff --git a/ui/components/multichain/pages/send/components/__snapshots__/your-accounts.test.tsx.snap b/ui/components/multichain/pages/send/components/__snapshots__/your-accounts.test.tsx.snap index 9fbf7e29879b..71431a330f94 100644 --- a/ui/components/multichain/pages/send/components/__snapshots__/your-accounts.test.tsx.snap +++ b/ui/components/multichain/pages/send/components/__snapshots__/your-accounts.test.tsx.snap @@ -248,6 +248,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 966.988 @@ -291,6 +292,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 966.988 @@ -545,6 +547,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -588,6 +591,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -842,6 +846,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -885,6 +890,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1148,6 +1154,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1191,6 +1198,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1445,6 +1453,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1488,6 +1497,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1755,6 +1765,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 @@ -1798,6 +1809,7 @@ exports[`SendPageYourAccounts render renders correctly 1`] = ` > 0 diff --git a/ui/components/multichain/token-list-item/token-list-item.tsx b/ui/components/multichain/token-list-item/token-list-item.tsx index 0c3c46114541..bf3968963465 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -34,6 +34,8 @@ import { ModalFooter, ModalHeader, ModalOverlay, + SensitiveText, + SensitiveTextLength, Text, } from '../../component-library'; import { @@ -82,6 +84,7 @@ type TokenListItemProps = { address?: string | null; showPercentage?: boolean; isPrimaryTokenSymbolHidden?: boolean; + privacyMode?: boolean; }; export const TokenListItem = ({ @@ -99,6 +102,7 @@ export const TokenListItem = ({ isStakeable = false, address = null, showPercentage = false, + privacyMode = false, }: TokenListItemProps) => { const t = useI18nContext(); const isEvm = useSelector(getMultichainIsEvm); @@ -375,17 +379,19 @@ export const TokenListItem = ({ ariaLabel={''} /> - {primary}{' '} {isNativeCurrency || isPrimaryTokenSymbolHidden ? '' : tokenSymbol} - + ) : ( - {secondary} - - + {primary}{' '} {isNativeCurrency || isPrimaryTokenSymbolHidden ? '' : tokenSymbol} - + )} diff --git a/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap b/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap index 44ba7be60b6f..eeb40144894b 100644 --- a/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap +++ b/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap @@ -8,6 +8,7 @@ exports[`CurrencyDisplay Component should match default snapshot 1`] = ` > @@ -21,6 +22,7 @@ exports[`CurrencyDisplay Component should render text with a className 1`] = ` > $123.45 @@ -36,6 +38,7 @@ exports[`CurrencyDisplay Component should render text with a prefix 1`] = ` > - $123.45 diff --git a/ui/components/ui/currency-display/currency-display.component.js b/ui/components/ui/currency-display/currency-display.component.js index ca9322661d79..a0bb114409f6 100644 --- a/ui/components/ui/currency-display/currency-display.component.js +++ b/ui/components/ui/currency-display/currency-display.component.js @@ -1,9 +1,11 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { useCurrencyDisplay } from '../../../hooks/useCurrencyDisplay'; import { EtherDenomination } from '../../../../shared/constants/common'; -import { Text, Box } from '../../component-library'; +import { getPreferences } from '../../../selectors'; +import { SensitiveText, Box } from '../../component-library'; import { AlignItems, Display, @@ -35,6 +37,7 @@ export default function CurrencyDisplay({ isAggregatedFiatOverviewBalance = false, ...props }) { + const { privacyMode } = useSelector(getPreferences); const [title, parts] = useCurrencyDisplay(value, { account, displayValue, @@ -68,26 +71,33 @@ export default function CurrencyDisplay({ {prefixComponent} ) : null} - {parts.prefix} {parts.value} - + {parts.suffix ? ( - {parts.suffix} - + ) : null} ); diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 05cc6d46cb27..d7fa8211b3b7 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -50,6 +50,7 @@ const initialState = { smartTransactionsOptInStatus: false, petnamesEnabled: true, featureNotificationsEnabled: false, + privacyMode: false, showMultiRpcModal: false, }, firstTimeFlowType: null, diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap index b406cafe0941..4284c1893d7c 100644 --- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap +++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap @@ -107,6 +107,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = > $0.00 @@ -191,6 +192,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = > $0.00 @@ -316,6 +318,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` > $0.00 @@ -444,6 +447,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` > $0.00 diff --git a/ui/pages/confirmations/components/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap b/ui/pages/confirmations/components/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap index b13f1f6d31e9..e35c865829b8 100644 --- a/ui/pages/confirmations/components/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap +++ b/ui/pages/confirmations/components/confirm-gas-display/__snapshots__/confirm-gas-display.test.js.snap @@ -51,6 +51,7 @@ exports[`ConfirmGasDisplay should match snapshot 1`] = ` > 0.001197 @@ -113,6 +114,7 @@ exports[`ConfirmGasDisplay should match snapshot 1`] = ` > 0.00147 diff --git a/ui/pages/confirmations/components/confirm-gas-display/confirm-legacy-gas-display/__snapshots__/confirm-legacy-gas-display.test.js.snap b/ui/pages/confirmations/components/confirm-gas-display/confirm-legacy-gas-display/__snapshots__/confirm-legacy-gas-display.test.js.snap index f6e40da8118c..db005f8c02e0 100644 --- a/ui/pages/confirmations/components/confirm-gas-display/confirm-legacy-gas-display/__snapshots__/confirm-legacy-gas-display.test.js.snap +++ b/ui/pages/confirmations/components/confirm-gas-display/confirm-legacy-gas-display/__snapshots__/confirm-legacy-gas-display.test.js.snap @@ -51,6 +51,7 @@ exports[`ConfirmLegacyGasDisplay should match snapshot 1`] = ` > 0.000021 @@ -67,6 +68,7 @@ exports[`ConfirmLegacyGasDisplay should match snapshot 1`] = ` > 0.000021 @@ -100,6 +102,7 @@ exports[`ConfirmLegacyGasDisplay should match snapshot 1`] = ` > 0.000021 diff --git a/ui/pages/confirmations/components/confirm-page-container/confirm-detail-row/__snapshots__/confirm-detail-row.component.test.js.snap b/ui/pages/confirmations/components/confirm-page-container/confirm-detail-row/__snapshots__/confirm-detail-row.component.test.js.snap index 8a3053f67d88..073d23aebf88 100644 --- a/ui/pages/confirmations/components/confirm-page-container/confirm-detail-row/__snapshots__/confirm-detail-row.component.test.js.snap +++ b/ui/pages/confirmations/components/confirm-page-container/confirm-detail-row/__snapshots__/confirm-detail-row.component.test.js.snap @@ -27,6 +27,7 @@ exports[`Confirm Detail Row Component should match snapshot 1`] = ` 0 @@ -47,6 +48,7 @@ exports[`Confirm Detail Row Component should match snapshot 1`] = ` 0 diff --git a/ui/pages/confirmations/components/multilayer-fee-message/__snapshots__/multi-layer-fee-message.test.js.snap b/ui/pages/confirmations/components/multilayer-fee-message/__snapshots__/multi-layer-fee-message.test.js.snap index 362926d71ce8..bf7516245e8b 100644 --- a/ui/pages/confirmations/components/multilayer-fee-message/__snapshots__/multi-layer-fee-message.test.js.snap +++ b/ui/pages/confirmations/components/multilayer-fee-message/__snapshots__/multi-layer-fee-message.test.js.snap @@ -105,6 +105,7 @@ exports[`Multi layer fee message when balance and token price checker is enabled > $0.00 @@ -152,6 +153,7 @@ exports[`Multi layer fee message when balance and token price checker is enabled > $0.56 diff --git a/ui/pages/confirmations/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap b/ui/pages/confirmations/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap index 2bbceca19ec8..0da1e036c9f0 100644 --- a/ui/pages/confirmations/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap +++ b/ui/pages/confirmations/confirm-send-ether/__snapshots__/confirm-send-ether.test.js.snap @@ -306,6 +306,7 @@ exports[`ConfirmSendEther should render correct information for for confirm send 0 @@ -469,6 +470,7 @@ exports[`ConfirmSendEther should render correct information for for confirm send > 0.000021 @@ -522,6 +524,7 @@ exports[`ConfirmSendEther should render correct information for for confirm send > 0.00021 diff --git a/ui/pages/confirmations/confirm-transaction-base/__snapshots__/confirm-transaction-base.test.js.snap b/ui/pages/confirmations/confirm-transaction-base/__snapshots__/confirm-transaction-base.test.js.snap index c89fa090cd3d..3bd2313850a4 100644 --- a/ui/pages/confirmations/confirm-transaction-base/__snapshots__/confirm-transaction-base.test.js.snap +++ b/ui/pages/confirmations/confirm-transaction-base/__snapshots__/confirm-transaction-base.test.js.snap @@ -264,6 +264,7 @@ exports[`Confirm Transaction Base should match snapshot 1`] = ` > 0.0001 @@ -407,6 +408,7 @@ exports[`Confirm Transaction Base should match snapshot 1`] = ` > 0.000021 @@ -440,6 +442,7 @@ exports[`Confirm Transaction Base should match snapshot 1`] = ` > 0.000021 diff --git a/ui/pages/confirmations/confirmation/templates/__snapshots__/remove-snap-account.test.js.snap b/ui/pages/confirmations/confirmation/templates/__snapshots__/remove-snap-account.test.js.snap index a541bb5f7ae3..317071e6be26 100644 --- a/ui/pages/confirmations/confirmation/templates/__snapshots__/remove-snap-account.test.js.snap +++ b/ui/pages/confirmations/confirmation/templates/__snapshots__/remove-snap-account.test.js.snap @@ -334,6 +334,7 @@ exports[`remove-snap-account confirmation should match snapshot 1`] = ` > 966.988 @@ -377,6 +378,7 @@ exports[`remove-snap-account confirmation should match snapshot 1`] = ` > 966.988 diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 82054a80f3cd..f8e232f3a519 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -3089,6 +3089,10 @@ export function setRedesignedConfirmationsEnabled(value: boolean) { return setPreference('redesignedConfirmationsEnabled', value); } +export function setPrivacyMode(value: boolean) { + return setPreference('privacyMode', value, false); +} + export function setRedesignedTransactionsEnabled(value: boolean) { return setPreference('redesignedTransactionsEnabled', value); } From 8a695e4f7c4ebedb560cdf2a73cf8e1509dfc04a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Oct 2024 14:04:16 +0100 Subject: [PATCH 15/56] chore (cherry-pick): ignore warning for ethereumjs-wallet (#28145) (#28162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry picks https://github.com/MetaMask/metamask-extension/pull/28145 ## **Description** Silent deprecation audit warning for `ethereumjs-wallet`. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28162?quickstart=1) ## **Related issues** N/A ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. Co-authored-by: sahar-fehri --- .yarnrc.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index f4d8fc7fa471..cc0c959e2722 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -117,7 +117,8 @@ npmAuditIgnoreAdvisories: # Currently in use for the network list drag and drop functionality. # Maintenance has stopped and the project will be archived in 2025. - 'react-beautiful-dnd (deprecation)' - + # New package name format for new versions: @ethereumjs/wallet. + - 'ethereumjs-wallet (deprecation)' npmRegistries: 'https://npm.pkg.github.com': npmAlwaysAuth: true From 9cc7b41c45400293656f64c0bd71e23fcca80536 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Wed, 30 Oct 2024 08:11:01 -0500 Subject: [PATCH 16/56] fix: Fix #28097 - Prevent redirect after adding network in Onboarding Settings (#28165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Prevents the post-add-network redirect from happening if the user is on the onboarding screen. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28097 ## **Manual testing steps** STR's are in https://github.com/MetaMask/metamask-extension/issues/28097 ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/ed136a3f-e06c-4c78-a76f-fd08a9865390 ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [ ] 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-extension/blob/develop/.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. --- .../confirmation/templates/add-ethereum-chain.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js b/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js index 24d66b8785fb..1df546b06003 100644 --- a/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js +++ b/ui/pages/confirmations/confirmation/templates/add-ethereum-chain.js @@ -11,7 +11,10 @@ import { Severity, TypographyVariant, } from '../../../../helpers/constants/design-system'; -import { DEFAULT_ROUTE } from '../../../../helpers/constants/routes'; +import { + DEFAULT_ROUTE, + ONBOARDING_PRIVACY_SETTINGS_ROUTE, +} from '../../../../helpers/constants/routes'; import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; import { isValidASCIIURL, toPunycodeURL } from '../../utils/confirm'; @@ -381,7 +384,13 @@ function getValues(pendingApproval, t, actions, history, data) { nickname: pendingApproval.requestData.chainName, }); - history.push(DEFAULT_ROUTE); + const locationPath = document.location.hash.replace('#', '/'); + const isOnboardingRoute = + locationPath === ONBOARDING_PRIVACY_SETTINGS_ROUTE; + + if (!isOnboardingRoute) { + history.push(DEFAULT_ROUTE); + } } return []; }, From 3d4bab3e3e831a4025fc34da858d3e700b61c7f4 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 30 Oct 2024 13:56:50 +0000 Subject: [PATCH 17/56] fix: Prevent coercing small spending caps to zero (#28179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Previously there was a bug that affected the approve screen. When users had a small spending cap (between 0.001 and 0.0001 or smaller), it was coerced to 0. This was caused by the method `new Intl.NumberFormat(locale).format(spendingCap)` that applied the `1,000` large number formatting, so the fix is to bypass it entirely for values smaller than 1. Additionally, these unformatted small numbers are presented in scientific notation, so we leverage `toNonScientificString(spendingCap)` to prevent that. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28179?quickstart=1) ## **Related issues** Fixes: [#28117](https://github.com/MetaMask/metamask-extension/issues/28117) ## **Manual testing steps** See original bug report. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../use-approve-token-simulation.test.ts | 68 ++++++++++++++++++- .../hooks/use-approve-token-simulation.ts | 6 +- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts index 4173d21910c5..0178e2ffff62 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts @@ -65,7 +65,7 @@ describe('useApproveTokenSimulation', () => { expect(result.current).toMatchInlineSnapshot(` { - "formattedSpendingCap": 7, + "formattedSpendingCap": "7", "pending": undefined, "spendingCap": "#7", "value": { @@ -155,4 +155,70 @@ describe('useApproveTokenSimulation', () => { } `); }); + + it('returns correct small decimal number token amount for fungible tokens', async () => { + const useIsNFTMock = jest.fn().mockImplementation(() => ({ isNFT: false })); + + const useDecodedTransactionDataMock = jest.fn().mockImplementation(() => ({ + pending: false, + value: { + data: [ + { + name: 'approve', + params: [ + { + type: 'address', + value: '0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4', + }, + { + type: 'uint256', + value: 10 ** 5, + }, + ], + }, + ], + source: 'FourByte', + }, + })); + + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); + (useDecodedTransactionData as jest.Mock).mockImplementation( + useDecodedTransactionDataMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result } = renderHookWithProvider( + () => useApproveTokenSimulation(transactionMeta, '18'), + mockState, + ); + + expect(result.current).toMatchInlineSnapshot(` + { + "formattedSpendingCap": "0.0000000000001", + "pending": undefined, + "spendingCap": "0.0000000000001", + "value": { + "data": [ + { + "name": "approve", + "params": [ + { + "type": "address", + "value": "0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4", + }, + { + "type": "uint256", + "value": 100000, + }, + ], + }, + ], + "source": "FourByte", + }, + } + `); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts index 19f26c9c9300..ce264285bc13 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts @@ -6,6 +6,7 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { getIntlLocale } from '../../../../../../../ducks/locale/locale'; import { SPENDING_CAP_UNLIMITED_MSG } from '../../../../../constants'; +import { toNonScientificString } from '../../hooks/use-token-values'; import { useDecodedTransactionData } from '../../hooks/useDecodedTransactionData'; import { useIsNFT } from './use-is-nft'; @@ -46,8 +47,9 @@ export const useApproveTokenSimulation = ( }, [value, decimals]); const formattedSpendingCap = useMemo(() => { - return isNFT - ? decodedSpendingCap + // formatting coerces small numbers to 0 + return isNFT || decodedSpendingCap < 1 + ? toNonScientificString(decodedSpendingCap) : new Intl.NumberFormat(locale).format(decodedSpendingCap); }, [decodedSpendingCap, isNFT, locale]); From 7e765c36092c05631c85f67f31ad951b982008f3 Mon Sep 17 00:00:00 2001 From: micaelae <100321200+micaelae@users.noreply.github.com> Date: Wed, 30 Oct 2024 07:45:46 -0700 Subject: [PATCH 18/56] chore: poll for bridge quotes (#28029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Changes: - Import the polling controller package and update lavamoat - Modify the BridgeController to extend the StaticIntervalPollingController. This is needed for polling bridge-api quotes once a valid quote request has been entered by a user [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27237?quickstart=1) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMS-1448 ## **Manual testing steps** 1. Set `BRIDGE_USE_DEV_APIS=1` in .metamaskrc 2. Open background network tab 3. Request bridge quotes in the extension 4. Verify that a `getQuote` network request is made with the specified parameters ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/acb927af-b04e-46cb-9b93-e2cdeb219722 ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/scripts/constants/sentry-state.ts | 3 + .../bridge/bridge-controller.test.ts | 188 ++++++++++++++++++ .../controllers/bridge/bridge-controller.ts | 90 ++++++++- app/scripts/controllers/bridge/constants.ts | 9 + app/scripts/controllers/bridge/types.ts | 17 +- app/scripts/metamask-controller.js | 2 +- lavamoat/browserify/beta/policy.json | 56 +++++- lavamoat/browserify/flask/policy.json | 56 +++++- lavamoat/browserify/main/policy.json | 56 +++++- lavamoat/browserify/mmi/policy.json | 56 +++++- package.json | 1 + test/e2e/tests/metrics/errors.spec.js | 2 + ...rs-after-init-opt-in-background-state.json | 1 + .../errors-after-init-opt-in-ui-state.json | 1 + yarn.lock | 17 ++ 15 files changed, 502 insertions(+), 53 deletions(-) diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 1c5dfcfa7ceb..3125016ea0b5 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -116,6 +116,9 @@ export const SENTRY_BACKGROUND_STATE = { destTokenAddress: true, srcTokenAmount: true, }, + quotes: [], + quotesLastFetched: true, + quotesLoadingStatus: true, }, }, CronjobController: { diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 86fa6b513dbd..35449cb40764 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -2,6 +2,10 @@ import nock from 'nock'; import { BRIDGE_API_BASE_URL } from '../../../../shared/constants/bridge'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { SWAPS_API_V2_BASE_URL } from '../../../../shared/constants/swaps'; +import { flushPromises } from '../../../../test/lib/timer-helpers'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import * as bridgeUtil from '../../../../ui/pages/bridge/bridge.util'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; @@ -26,6 +30,8 @@ describe('BridgeController', function () { beforeEach(() => { jest.clearAllMocks(); + jest.clearAllTimers(); + nock(BRIDGE_API_BASE_URL) .get('/getAllFeatureFlags') .reply(200, { @@ -78,10 +84,28 @@ describe('BridgeController', function () { }; expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + const setIntervalLengthSpy = jest.spyOn( + bridgeController, + 'setIntervalLength', + ); + await bridgeController.setBridgeFeatureFlags(); expect(bridgeController.state.bridgeState.bridgeFeatureFlags).toStrictEqual( expectedFeatureFlagsResponse, ); + expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); + expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); + + bridgeController.resetState(); + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + bridgeFeatureFlags: expectedFeatureFlagsResponse, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); }); it('selectDestNetwork should set the bridge dest tokens and top assets', async function () { @@ -204,4 +228,168 @@ describe('BridgeController', function () { walletAddress: undefined, }); }); + + it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeController, + 'startPollingByNetworkClientId', + ); + messengerMock.call.mockReturnValue({ address: '0x123' } as never); + + const fetchBridgeQuotesSpy = jest + .spyOn(bridgeUtil, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([1, 2, 3] as never); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([5, 6, 7] as never); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_, reject) => { + return setTimeout(() => { + reject(new Error('Network error')); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith( + '1', + quoteRequest, + ); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith(quoteRequest); + + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched ?? 0; + expect(firstFetchTime).toBeGreaterThan(0); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: [1, 2, 3], + quotesLoadingStatus: 1, + }), + ); + expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( + firstFetchTime, + ); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: [5, 6, 7], + quotesLoadingStatus: 1, + }), + ); + const secondFetchTime = + bridgeController.state.bridgeState.quotesLastFetched; + expect(secondFetchTime).toBeGreaterThan(firstFetchTime); + + // After 3nd fetch throws an error + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: [5, 6, 7], + quotesLoadingStatus: 2, + }), + ); + expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( + secondFetchTime, + ); + }); + + it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', function () { + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeController, + 'startPollingByNetworkClientId', + ); + messengerMock.call.mockReturnValueOnce({ address: '0x123' } as never); + + bridgeController.updateBridgeQuoteRequestParams({ + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + }); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: undefined, + destChainId: 10, + destTokenAddress: '0x123', + }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }); }); diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 0129dab0fac2..1d20e6f404e4 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -1,7 +1,10 @@ -import { BaseController, StateMetadata } from '@metamask/base-controller'; +import { StateMetadata } from '@metamask/base-controller'; import { Hex } from '@metamask/utils'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { NetworkClientId } from '@metamask/network-controller'; import { fetchBridgeFeatureFlags, + fetchBridgeQuotes, fetchBridgeTokens, // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths @@ -9,14 +12,24 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { fetchTopAssetsList } from '../../../../ui/pages/swaps/swaps.util'; +import { decimalToHex } from '../../../../shared/modules/conversion.utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { QuoteRequest } from '../../../../ui/pages/bridge/types'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { isValidQuoteRequest } from '../../../../ui/pages/bridge/utils/quote'; import { BRIDGE_CONTROLLER_NAME, DEFAULT_BRIDGE_CONTROLLER_STATE, + REFRESH_INTERVAL_MS, + RequestStatus, } from './constants'; -import { BridgeControllerState, BridgeControllerMessenger } from './types'; +import { + BridgeControllerState, + BridgeControllerMessenger, + BridgeFeatureFlagsKey, +} from './types'; const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = { bridgeState: { @@ -25,7 +38,7 @@ const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = { }, }; -export default class BridgeController extends BaseController< +export default class BridgeController extends StaticIntervalPollingController< typeof BRIDGE_CONTROLLER_NAME, { bridgeState: BridgeControllerState }, BridgeControllerMessenger @@ -35,9 +48,13 @@ export default class BridgeController extends BaseController< name: BRIDGE_CONTROLLER_NAME, metadata, messenger, - state: { bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE }, + state: { + bridgeState: DEFAULT_BRIDGE_CONTROLLER_STATE, + }, }); + this.setIntervalLength(REFRESH_INTERVAL_MS); + this.messagingSystem.registerActionHandler( `${BRIDGE_CONTROLLER_NAME}:setBridgeFeatureFlags`, this.setBridgeFeatureFlags.bind(this), @@ -56,7 +73,15 @@ export default class BridgeController extends BaseController< ); } + _executePoll = async ( + _: NetworkClientId, + updatedQuoteRequest: QuoteRequest, + ) => { + await this.#fetchBridgeQuotes(updatedQuoteRequest); + }; + updateBridgeQuoteRequestParams = (paramsToUpdate: Partial) => { + this.stopAllPolling(); const { bridgeState } = this.state; const updatedQuoteRequest = { ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, @@ -66,17 +91,31 @@ export default class BridgeController extends BaseController< this.update((_state) => { _state.bridgeState = { ...bridgeState, - quoteRequest: { - ...updatedQuoteRequest, - }, + quoteRequest: updatedQuoteRequest, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, }; }); + + if (isValidQuoteRequest(updatedQuoteRequest)) { + const walletAddress = this.#getSelectedAccount().address; + this.startPollingByNetworkClientId( + decimalToHex(updatedQuoteRequest.srcChainId), + { ...updatedQuoteRequest, walletAddress }, + ); + } }; resetState = () => { + this.stopAllPolling(); this.update((_state) => { _state.bridgeState = { + ..._state.bridgeState, ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotes: [], + bridgeFeatureFlags: _state.bridgeState.bridgeFeatureFlags, }; }); }; @@ -87,6 +126,9 @@ export default class BridgeController extends BaseController< this.update((_state) => { _state.bridgeState = { ...bridgeState, bridgeFeatureFlags }; }); + this.setIntervalLength( + bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].refreshRate, + ); }; selectSrcNetwork = async (chainId: Hex) => { @@ -99,6 +141,36 @@ export default class BridgeController extends BaseController< await this.#setTokens(chainId, 'destTokens'); }; + #fetchBridgeQuotes = async (request: QuoteRequest) => { + const { bridgeState } = this.state; + this.update((_state) => { + _state.bridgeState = { + ...bridgeState, + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.LOADING, + }; + }); + + try { + const quotes = await fetchBridgeQuotes(request); + this.update((_state) => { + _state.bridgeState = { + ..._state.bridgeState, + quotes, + quotesLoadingStatus: RequestStatus.FETCHED, + }; + }); + } catch (error) { + console.log('Failed to fetch bridge quotes', error); + this.update((_state) => { + _state.bridgeState = { + ...bridgeState, + quotesLoadingStatus: RequestStatus.ERROR, + }; + }); + } + }; + #setTopAssets = async ( chainId: Hex, stateKey: 'srcTopAssets' | 'destTopAssets', @@ -117,4 +189,8 @@ export default class BridgeController extends BaseController< _state.bridgeState = { ...bridgeState, [stateKey]: tokens }; }); }; + + #getSelectedAccount() { + return this.messagingSystem.call('AccountsController:getSelectedAccount'); + } } diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index 9506a8cc5073..a4aa3264fdc8 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -6,6 +6,12 @@ export const REFRESH_INTERVAL_MS = 30 * 1000; const DEFAULT_MAX_REFRESH_COUNT = 5; const DEFAULT_SLIPPAGE = 0.5; +export enum RequestStatus { + LOADING, + FETCHED, + ERROR, +} + export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { bridgeFeatureFlags: { [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: { @@ -25,4 +31,7 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { srcTokenAddress: zeroAddress(), slippage: DEFAULT_SLIPPAGE, }, + quotes: [], + quotesLastFetched: undefined, + quotesLoadingStatus: undefined, }; diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 15257ff6ec4b..10c2d8646545 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -3,12 +3,13 @@ import { RestrictedControllerMessenger, } from '@metamask/base-controller'; import { Hex } from '@metamask/utils'; +import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { SwapsTokenObject } from '../../../../shared/constants/swaps'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { QuoteRequest } from '../../../../ui/pages/bridge/types'; +import { QuoteRequest, QuoteResponse } from '../../../../ui/pages/bridge/types'; import BridgeController from './bridge-controller'; -import { BRIDGE_CONTROLLER_NAME } from './constants'; +import { BRIDGE_CONTROLLER_NAME, RequestStatus } from './constants'; export enum BridgeFeatureFlagsKey { EXTENSION_CONFIG = 'extensionConfig', @@ -34,6 +35,9 @@ export type BridgeControllerState = { destTokens: Record; destTopAssets: { address: string }[]; quoteRequest: Partial; + quotes: QuoteResponse[]; + quotesLastFetched?: number; + quotesLoadingStatus?: RequestStatus; }; export enum BridgeUserAction { @@ -62,13 +66,16 @@ type BridgeControllerEvents = ControllerStateChangeEvent< BridgeControllerState >; +type AllowedActions = AccountsControllerGetSelectedAccountAction['type']; +type AllowedEvents = never; + /** * The messenger for the BridgeController. */ export type BridgeControllerMessenger = RestrictedControllerMessenger< typeof BRIDGE_CONTROLLER_NAME, - BridgeControllerActions, + BridgeControllerActions | AccountsControllerGetSelectedAccountAction, BridgeControllerEvents, - never, - never + AllowedActions, + AllowedEvents >; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index bec7ed74905f..5eba6f25c12e 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2109,7 +2109,7 @@ export default class MetamaskController extends EventEmitter { const bridgeControllerMessenger = this.controllerMessenger.getRestricted({ name: BRIDGE_CONTROLLER_NAME, - allowedActions: [], + allowedActions: ['AccountsController:getSelectedAccount'], allowedEvents: [], }); this.bridgeController = new BridgeController({ diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 624e424d650d..09a0999ef6b0 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1226,24 +1226,24 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller": { + "@metamask/gas-fee-controller>@metamask/base-controller": { "globals": { - "clearTimeout": true, - "console.error": true, "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": true, - "@metamask/snaps-utils>fast-json-stable-stringify": true, - "uuid": true + "immer": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": { + "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { + "clearTimeout": true, + "console.error": true, "setTimeout": true }, "packages": { - "immer": true + "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true } }, "@metamask/jazzicon": { @@ -2000,6 +2000,18 @@ "punycode": true } }, + "@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/post-message-stream": { "globals": { "MessageEvent.prototype": true, @@ -2396,9 +2408,9 @@ "@ethersproject/bytes": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller": true, "@metamask/smart-transactions-controller>bignumber.js": true, "browserify>buffer": true, @@ -2444,6 +2456,18 @@ "crypto.getRandomValues": true } }, + "@metamask/smart-transactions-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/smart-transactions-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller": { "globals": { "clearTimeout": true, @@ -2955,9 +2979,9 @@ "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/transaction-controller": true, "@metamask/user-operation-controller>@metamask/base-controller": true, + "@metamask/user-operation-controller>@metamask/polling-controller": true, "@metamask/user-operation-controller>@metamask/rpc-errors": true, "@metamask/user-operation-controller>@metamask/utils": true, "bn.js": true, @@ -2975,6 +2999,18 @@ "immer": true } }, + "@metamask/user-operation-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@metamask/user-operation-controller>@metamask/base-controller": true, + "uuid": true + } + }, "@metamask/user-operation-controller>@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 624e424d650d..09a0999ef6b0 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1226,24 +1226,24 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller": { + "@metamask/gas-fee-controller>@metamask/base-controller": { "globals": { - "clearTimeout": true, - "console.error": true, "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": true, - "@metamask/snaps-utils>fast-json-stable-stringify": true, - "uuid": true + "immer": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": { + "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { + "clearTimeout": true, + "console.error": true, "setTimeout": true }, "packages": { - "immer": true + "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true } }, "@metamask/jazzicon": { @@ -2000,6 +2000,18 @@ "punycode": true } }, + "@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/post-message-stream": { "globals": { "MessageEvent.prototype": true, @@ -2396,9 +2408,9 @@ "@ethersproject/bytes": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller": true, "@metamask/smart-transactions-controller>bignumber.js": true, "browserify>buffer": true, @@ -2444,6 +2456,18 @@ "crypto.getRandomValues": true } }, + "@metamask/smart-transactions-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/smart-transactions-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller": { "globals": { "clearTimeout": true, @@ -2955,9 +2979,9 @@ "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/transaction-controller": true, "@metamask/user-operation-controller>@metamask/base-controller": true, + "@metamask/user-operation-controller>@metamask/polling-controller": true, "@metamask/user-operation-controller>@metamask/rpc-errors": true, "@metamask/user-operation-controller>@metamask/utils": true, "bn.js": true, @@ -2975,6 +2999,18 @@ "immer": true } }, + "@metamask/user-operation-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@metamask/user-operation-controller>@metamask/base-controller": true, + "uuid": true + } + }, "@metamask/user-operation-controller>@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 624e424d650d..09a0999ef6b0 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1226,24 +1226,24 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller": { + "@metamask/gas-fee-controller>@metamask/base-controller": { "globals": { - "clearTimeout": true, - "console.error": true, "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": true, - "@metamask/snaps-utils>fast-json-stable-stringify": true, - "uuid": true + "immer": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": { + "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { + "clearTimeout": true, + "console.error": true, "setTimeout": true }, "packages": { - "immer": true + "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true } }, "@metamask/jazzicon": { @@ -2000,6 +2000,18 @@ "punycode": true } }, + "@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/post-message-stream": { "globals": { "MessageEvent.prototype": true, @@ -2396,9 +2408,9 @@ "@ethersproject/bytes": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller": true, "@metamask/smart-transactions-controller>bignumber.js": true, "browserify>buffer": true, @@ -2444,6 +2456,18 @@ "crypto.getRandomValues": true } }, + "@metamask/smart-transactions-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/smart-transactions-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller": { "globals": { "clearTimeout": true, @@ -2955,9 +2979,9 @@ "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/transaction-controller": true, "@metamask/user-operation-controller>@metamask/base-controller": true, + "@metamask/user-operation-controller>@metamask/polling-controller": true, "@metamask/user-operation-controller>@metamask/rpc-errors": true, "@metamask/user-operation-controller>@metamask/utils": true, "bn.js": true, @@ -2975,6 +2999,18 @@ "immer": true } }, + "@metamask/user-operation-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@metamask/user-operation-controller>@metamask/base-controller": true, + "uuid": true + } + }, "@metamask/user-operation-controller>@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 6704d51c0928..b94fc59f9574 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1318,24 +1318,24 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller": { + "@metamask/gas-fee-controller>@metamask/base-controller": { "globals": { - "clearTimeout": true, - "console.error": true, "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": true, - "@metamask/snaps-utils>fast-json-stable-stringify": true, - "uuid": true + "immer": true } }, - "@metamask/gas-fee-controller>@metamask/polling-controller>@metamask/base-controller": { + "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { + "clearTimeout": true, + "console.error": true, "setTimeout": true }, "packages": { - "immer": true + "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true } }, "@metamask/jazzicon": { @@ -2092,6 +2092,18 @@ "punycode": true } }, + "@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/post-message-stream": { "globals": { "MessageEvent.prototype": true, @@ -2488,9 +2500,9 @@ "@ethersproject/bytes": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller": true, "@metamask/smart-transactions-controller>bignumber.js": true, "browserify>buffer": true, @@ -2536,6 +2548,18 @@ "crypto.getRandomValues": true } }, + "@metamask/smart-transactions-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/smart-transactions-controller>@metamask/base-controller": true, + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller": { "globals": { "clearTimeout": true, @@ -3047,9 +3071,9 @@ "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, - "@metamask/gas-fee-controller>@metamask/polling-controller": true, "@metamask/transaction-controller": true, "@metamask/user-operation-controller>@metamask/base-controller": true, + "@metamask/user-operation-controller>@metamask/polling-controller": true, "@metamask/user-operation-controller>@metamask/rpc-errors": true, "@metamask/user-operation-controller>@metamask/utils": true, "bn.js": true, @@ -3067,6 +3091,18 @@ "immer": true } }, + "@metamask/user-operation-controller>@metamask/polling-controller": { + "globals": { + "clearTimeout": true, + "console.error": true, + "setTimeout": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@metamask/user-operation-controller>@metamask/base-controller": true, + "uuid": true + } + }, "@metamask/user-operation-controller>@metamask/rpc-errors": { "packages": { "@metamask/rpc-errors>fast-safe-stringify": true, diff --git a/package.json b/package.json index c1b7520c2279..43513828d7fe 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,7 @@ "@metamask/permission-controller": "^10.0.0", "@metamask/permission-log-controller": "^2.0.1", "@metamask/phishing-controller": "^12.3.0", + "@metamask/polling-controller": "^10.0.1", "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.35.1", "@metamask/preinstalled-example-snap": "^0.2.0", diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index 66de2461abc7..e9fae5d6323c 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -873,6 +873,8 @@ describe('Sentry errors', function () { srcTokenAmount: true, walletAddress: false, }, + quotesLastFetched: true, + quotesLoadingStatus: true, }, currentPopupId: false, // Initialized as undefined // Part of transaction controller store, but missing from the initial diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index d95e3dd4313a..80ea24c8cc3a 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -73,6 +73,7 @@ "slippage": 0.5, "srcTokenAddress": "0x0000000000000000000000000000000000000000" }, + "quotes": {}, "srcTokens": {}, "srcTopAssets": {} } diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index d7167a2849ec..6574204ec2bf 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -268,6 +268,7 @@ "slippage": 0.5, "srcTokenAddress": "0x0000000000000000000000000000000000000000" }, + "quotes": {}, "srcTokens": {}, "srcTopAssets": {} }, diff --git a/yarn.lock b/yarn.lock index fc235be2429c..175f874647f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5890,6 +5890,22 @@ __metadata: languageName: node linkType: hard +"@metamask/polling-controller@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/polling-controller@npm:10.0.1" + dependencies: + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/utils": "npm:^9.1.0" + "@types/uuid": "npm:^8.3.0" + fast-json-stable-stringify: "npm:^2.1.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/network-controller": ^21.0.0 + checksum: 10/25c11e65eeccb08a2b4b7dec21ccabb4b797907edb03a1534ebacb87d0754a3ade52aad061aad8b3ac23bfc39917c0d61b9734e32bc748c210b2997410ae45a9 + languageName: node + linkType: hard + "@metamask/polling-controller@npm:^11.0.0": version: 11.0.0 resolution: "@metamask/polling-controller@npm:11.0.0" @@ -25969,6 +25985,7 @@ __metadata: "@metamask/permission-log-controller": "npm:^2.0.1" "@metamask/phishing-controller": "npm:^12.3.0" "@metamask/phishing-warning": "npm:^4.1.0" + "@metamask/polling-controller": "npm:^10.0.1" "@metamask/post-message-stream": "npm:^8.0.0" "@metamask/ppom-validator": "npm:0.35.1" "@metamask/preferences-controller": "npm:^13.0.2" From 5bb3885c4ecbb41f8b3127f160c13a0822b631ce Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 30 Oct 2024 14:48:55 +0000 Subject: [PATCH 19/56] feat: Native asset send (#27979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implements redesigned native asset transfer (Simple send) both for wallet initiated and dApp initiated confirmations. Includes e2e tests. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27979?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3028 https://github.com/MetaMask/MetaMask-planning/issues/3027 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-10-21 at 10 32 05 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/scripts/lib/transaction/metrics.test.ts | 65 ++- test/e2e/page-objects/pages/test-dapp.ts | 6 + test/e2e/tests/confirmations/helpers.ts | 4 +- .../tests/confirmations/navigation.spec.ts | 32 +- .../transactions/native-send-redesign.spec.ts | 113 +++++ .../components/confirm/header/header.tsx | 4 +- .../info/hooks/useTokenDetails.test.ts | 119 +++++- .../confirm/info/hooks/useTokenDetails.ts | 13 +- .../components/confirm/info/info.tsx | 10 +- .../native-transfer.test.tsx.snap | 402 ++++++++++++++++++ .../native-transfer.stories.tsx | 42 ++ .../native-transfer/native-transfer.test.tsx | 41 ++ .../info/native-transfer/native-transfer.tsx | 37 ++ .../native-send-heading.tsx | 118 +++++ .../info/shared/send-heading/send-heading.tsx | 24 +- .../token-transfer/token-details-section.tsx | 7 +- .../transaction-flow-section.tsx | 12 +- ui/pages/confirmations/utils/confirm.ts | 1 + 18 files changed, 970 insertions(+), 80 deletions(-) create mode 100644 test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts create mode 100644 ui/pages/confirmations/components/confirm/info/native-transfer/__snapshots__/native-transfer.test.tsx.snap create mode 100644 ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.stories.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.test.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/shared/native-send-heading/native-send-heading.tsx diff --git a/app/scripts/lib/transaction/metrics.test.ts b/app/scripts/lib/transaction/metrics.test.ts index 1ff509da3c09..f3dfa87799fc 100644 --- a/app/scripts/lib/transaction/metrics.test.ts +++ b/app/scripts/lib/transaction/metrics.test.ts @@ -147,6 +147,7 @@ describe('Transaction metrics', () => { eip_1559_version: '0', gas_edit_attempted: 'none', gas_estimation_failed: false, + is_smart_transaction: undefined, gas_edit_type: 'none', network: mockNetworkId, referrer: ORIGIN_METAMASK, @@ -155,8 +156,8 @@ describe('Transaction metrics', () => { token_standard: TokenStandard.none, transaction_speed_up: false, transaction_type: TransactionType.simpleSend, - ui_customizations: null, - transaction_advanced_view: null, + ui_customizations: ['redesigned_confirmation'], + transaction_advanced_view: undefined, transaction_contract_method: undefined, }; @@ -233,7 +234,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['gas_estimation_failed'], + ui_customizations: [ + 'gas_estimation_failed', + 'redesigned_confirmation', + ], gas_estimation_failed: true, }, sensitiveProperties: expectedSensitiveProperties, @@ -263,7 +267,10 @@ describe('Transaction metrics', () => { ...expectedProperties, security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], ppom_eth_call_count: 5, ppom_eth_getCode_count: 3, }, @@ -353,7 +360,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -370,7 +380,10 @@ describe('Transaction metrics', () => { { properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -490,7 +503,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -510,7 +526,10 @@ describe('Transaction metrics', () => { { properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -687,7 +706,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -709,7 +731,10 @@ describe('Transaction metrics', () => { { properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -820,7 +845,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -841,7 +869,10 @@ describe('Transaction metrics', () => { { properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -947,7 +978,10 @@ describe('Transaction metrics', () => { persist: true, properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, @@ -964,7 +998,10 @@ describe('Transaction metrics', () => { { properties: { ...expectedProperties, - ui_customizations: ['flagged_as_malicious'], + ui_customizations: [ + 'flagged_as_malicious', + 'redesigned_confirmation', + ], security_alert_reason: BlockaidReason.maliciousDomain, security_alert_response: 'Malicious', ppom_eth_call_count: 5, diff --git a/test/e2e/page-objects/pages/test-dapp.ts b/test/e2e/page-objects/pages/test-dapp.ts index 9da41bcb22d5..afff2f37e57e 100644 --- a/test/e2e/page-objects/pages/test-dapp.ts +++ b/test/e2e/page-objects/pages/test-dapp.ts @@ -34,6 +34,8 @@ class TestDapp { tag: 'button', }; + private readonly simpleSendButton = '#sendButton'; + private readonly erc721MintButton = '#mintButton'; private readonly erc721TransferFromButton = '#transferFromButton'; @@ -186,6 +188,10 @@ class TestDapp { }); } + async clickSimpleSendButton() { + await this.driver.clickElement(this.simpleSendButton); + } + async clickERC721MintButton() { await this.driver.clickElement(this.erc721MintButton); } diff --git a/test/e2e/tests/confirmations/helpers.ts b/test/e2e/tests/confirmations/helpers.ts index ff467f42c320..355f664ec61c 100644 --- a/test/e2e/tests/confirmations/helpers.ts +++ b/test/e2e/tests/confirmations/helpers.ts @@ -46,8 +46,8 @@ export function withRedesignConfirmationFixtures( transactionEnvelopeType === TransactionEnvelopeType.legacy ? defaultGanacheOptions : defaultGanacheOptionsForType2Transactions, - smartContract, - testSpecificMock: mocks, + ...(smartContract && { smartContract }), + ...(mocks && { testSpecificMock: mocks }), title, }, testFunction, diff --git a/test/e2e/tests/confirmations/navigation.spec.ts b/test/e2e/tests/confirmations/navigation.spec.ts index 747ba15872b3..38d29ad3ad77 100644 --- a/test/e2e/tests/confirmations/navigation.spec.ts +++ b/test/e2e/tests/confirmations/navigation.spec.ts @@ -66,10 +66,15 @@ describe('Navigation Signature - Different signature types', function (this: Sui '[data-testid="confirm-nav__next-confirmation"]', ); - // Verify Transaction Sending ETH is displayed - await verifyTransaction(driver, 'Sending ETH'); + // Verify simple send transaction is displayed + await driver.waitForSelector({ + tag: 'h3', + text: 'Transfer request', + }); - await driver.clickElement('[data-testid="next-page"]'); + await driver.clickElement( + '[data-testid="confirm-nav__next-confirmation"]', + ); // Verify Sign Typed Data v3 confirmation is displayed await verifySignedTypeV3Confirmation(driver); @@ -78,10 +83,15 @@ describe('Navigation Signature - Different signature types', function (this: Sui '[data-testid="confirm-nav__previous-confirmation"]', ); - // Verify Sign Typed Data v3 confirmation is displayed - await verifyTransaction(driver, 'Sending ETH'); + // Verify simple send transaction is displayed + await driver.waitForSelector({ + tag: 'h3', + text: 'Transfer request', + }); - await driver.clickElement('[data-testid="previous-page"]'); + await driver.clickElement( + '[data-testid="confirm-nav__previous-confirmation"]', + ); // Verify Sign Typed Data v3 confirmation is displayed await verifySignTypedData(driver); @@ -179,13 +189,3 @@ async function queueSignaturesAndTransactions(driver: Driver) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await driver.waitForSelector(By.xpath("//div[normalize-space(.)='1 of 3']")); } - -async function verifyTransaction( - driver: Driver, - expectedTransactionType: string, -) { - await driver.waitForSelector({ - tag: 'span', - text: expectedTransactionType, - }); -} diff --git a/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts new file mode 100644 index 000000000000..e8226977d019 --- /dev/null +++ b/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +import { TransactionEnvelopeType } from '@metamask/transaction-controller'; +import { DAPP_URL } from '../../../constants'; +import { + unlockWallet, + veryLargeDelayMs, + WINDOW_TITLES, +} from '../../../helpers'; +import TokenTransferTransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/token-transfer-confirmation'; +import HomePage from '../../../page-objects/pages/homepage'; +import SendTokenPage from '../../../page-objects/pages/send/send-token-page'; +import TestDapp from '../../../page-objects/pages/test-dapp'; +import { Driver } from '../../../webdriver/driver'; +import { withRedesignConfirmationFixtures } from '../helpers'; +import { TestSuiteArguments } from './shared'; + +const TOKEN_RECIPIENT_ADDRESS = '0x2f318C334780961FB129D2a6c30D0763d9a5C970'; + +describe('Confirmation Redesign Native Send @no-mmi', function () { + describe('Wallet initiated', async function () { + it('Sends a type 0 transaction (Legacy)', async function () { + await withRedesignConfirmationFixtures( + this.test?.fullTitle(), + TransactionEnvelopeType.legacy, + async ({ driver }: TestSuiteArguments) => { + await createWalletInitiatedTransactionAndAssertDetails(driver); + }, + ); + }); + + it('Sends a type 2 transaction (EIP1559)', async function () { + await withRedesignConfirmationFixtures( + this.test?.fullTitle(), + TransactionEnvelopeType.feeMarket, + async ({ driver }: TestSuiteArguments) => { + await createWalletInitiatedTransactionAndAssertDetails(driver); + }, + ); + }); + }); + + describe('dApp initiated', async function () { + it('Sends a type 0 transaction (Legacy)', async function () { + await withRedesignConfirmationFixtures( + this.test?.fullTitle(), + TransactionEnvelopeType.legacy, + async ({ driver }: TestSuiteArguments) => { + await createDAppInitiatedTransactionAndAssertDetails(driver); + }, + ); + }); + + it('Sends a type 2 transaction (EIP1559)', async function () { + await withRedesignConfirmationFixtures( + this.test?.fullTitle(), + TransactionEnvelopeType.feeMarket, + async ({ driver }: TestSuiteArguments) => { + await createDAppInitiatedTransactionAndAssertDetails(driver); + }, + ); + }); + }); +}); + +async function createWalletInitiatedTransactionAndAssertDetails( + driver: Driver, +) { + await unlockWallet(driver); + + const testDapp = new TestDapp(driver); + + await testDapp.openTestDappPage({ contractAddress: null, url: DAPP_URL }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + const homePage = new HomePage(driver); + await homePage.startSendFlow(); + const sendToPage = new SendTokenPage(driver); + await sendToPage.check_pageIsLoaded(); + await sendToPage.fillRecipient(TOKEN_RECIPIENT_ADDRESS); + await sendToPage.fillAmount('1'); + await sendToPage.goToNextScreen(); + + const tokenTransferTransactionConfirmation = + new TokenTransferTransactionConfirmation(driver); + await tokenTransferTransactionConfirmation.check_walletInitiatedHeadingTitle(); + await tokenTransferTransactionConfirmation.check_networkParagraph(); + await tokenTransferTransactionConfirmation.check_networkFeeParagraph(); + + await tokenTransferTransactionConfirmation.clickFooterConfirmButton(); +} + +async function createDAppInitiatedTransactionAndAssertDetails(driver: Driver) { + await unlockWallet(driver); + + const testDapp = new TestDapp(driver); + + await testDapp.openTestDappPage({ contractAddress: null, url: DAPP_URL }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + + await testDapp.clickSimpleSendButton(); + + await driver.delay(veryLargeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const tokenTransferTransactionConfirmation = + new TokenTransferTransactionConfirmation(driver); + await tokenTransferTransactionConfirmation.check_dappInitiatedHeadingTitle(); + await tokenTransferTransactionConfirmation.check_networkParagraph(); + await tokenTransferTransactionConfirmation.check_networkFeeParagraph(); + + await tokenTransferTransactionConfirmation.clickScrollToBottomButton(); + await tokenTransferTransactionConfirmation.clickFooterConfirmButton(); +} diff --git a/ui/pages/confirmations/components/confirm/header/header.tsx b/ui/pages/confirmations/components/confirm/header/header.tsx index 6c7c0e1cdd7f..278b21f85cbb 100644 --- a/ui/pages/confirmations/components/confirm/header/header.tsx +++ b/ui/pages/confirmations/components/confirm/header/header.tsx @@ -3,6 +3,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import React from 'react'; +import { ORIGIN_METAMASK } from '../../../../../../shared/constants/app'; import { AvatarNetwork, AvatarNetworkSize, @@ -30,6 +31,7 @@ const CONFIRMATIONS_WITH_NEW_HEADER = [ TransactionType.tokenMethodTransfer, TransactionType.tokenMethodTransferFrom, TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, ]; const Header = () => { @@ -88,7 +90,7 @@ const Header = () => { currentConfirmation?.type && CONFIRMATIONS_WITH_NEW_HEADER.includes(currentConfirmation.type); const isWalletInitiated = - (currentConfirmation as TransactionMeta)?.origin === 'metamask'; + (currentConfirmation as TransactionMeta)?.origin === ORIGIN_METAMASK; if (isConfirmationWithNewHeader && isWalletInitiated) { return ; } else if (isConfirmationWithNewHeader && !isWalletInitiated) { diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts index efdf2b66ac56..9011569bac3e 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.test.ts @@ -1,11 +1,39 @@ import { TransactionMeta } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; import { genUnapprovedTokenTransferConfirmation } from '../../../../../../../test/data/confirmations/token-transfer'; import mockState from '../../../../../../../test/data/mock-state.json'; import { renderHookWithProvider } from '../../../../../../../test/lib/render-helpers'; +import { getTokenList } from '../../../../../../selectors'; import { useTokenDetails } from './useTokenDetails'; +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const ICON_SYMBOL = 'FROG'; +const ICON_URL = + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a2c375553e6965b42c135bb8b15a8914b08de0c.png'; +const MOCK_TOKEN_LIST = (transactionMeta: TransactionMeta) => ({ + [transactionMeta.txParams.to as string]: { + address: transactionMeta.txParams.to, + aggregators: ['CoinGecko', 'Socket', 'Coinmarketcap'], + decimals: 9, + iconUrl: ICON_URL, + name: 'Frog on ETH', + occurrences: 3, + symbol: ICON_SYMBOL, + }, +}); + describe('useTokenDetails', () => { - it('returns iconUrl from selected token if it exists', () => { + const useSelectorMock = useSelector as jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns token details from selected token if it exists', () => { const transactionMeta = genUnapprovedTokenTransferConfirmation( {}, ) as TransactionMeta; @@ -18,8 +46,17 @@ describe('useTokenDetails', () => { image: 'image', }; + useSelectorMock.mockImplementation((selector) => { + if (selector === getTokenList) { + return MOCK_TOKEN_LIST(transactionMeta); + } else if (selector?.toString().includes('getWatchedToken')) { + return TEST_SELECTED_TOKEN; + } + return undefined; + }); + const { result } = renderHookWithProvider( - () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta), mockState, ); @@ -29,6 +66,37 @@ describe('useTokenDetails', () => { }); }); + it('returns token details from the token list if it exists', () => { + const transactionMeta = genUnapprovedTokenTransferConfirmation( + {}, + ) as TransactionMeta; + + const TEST_SELECTED_TOKEN = { + address: 'address', + decimals: 18, + }; + + useSelectorMock.mockImplementation((selector) => { + if (selector === getTokenList) { + return MOCK_TOKEN_LIST(transactionMeta); + } else if (selector?.toString().includes('getWatchedToken')) { + return TEST_SELECTED_TOKEN; + } + + return undefined; + }); + + const { result } = renderHookWithProvider( + () => useTokenDetails(transactionMeta), + mockState, + ); + + expect(result.current).toEqual({ + tokenImage: ICON_URL, + tokenSymbol: ICON_SYMBOL, + }); + }); + it('returns selected token image if no iconUrl is included', () => { const transactionMeta = genUnapprovedTokenTransferConfirmation( {}, @@ -41,8 +109,17 @@ describe('useTokenDetails', () => { image: 'image', }; + useSelectorMock.mockImplementation((selector) => { + if (selector === getTokenList) { + return MOCK_TOKEN_LIST(transactionMeta); + } else if (selector?.toString().includes('getWatchedToken')) { + return TEST_SELECTED_TOKEN; + } + return undefined; + }); + const { result } = renderHookWithProvider( - () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta), mockState, ); @@ -63,23 +140,22 @@ describe('useTokenDetails', () => { symbol: 'symbol', }; + useSelectorMock.mockImplementation((selector) => { + if (selector === getTokenList) { + return MOCK_TOKEN_LIST(transactionMeta); + } else if (selector?.toString().includes('getWatchedToken')) { + return TEST_SELECTED_TOKEN; + } + return undefined; + }); + const { result } = renderHookWithProvider( - () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), - { - ...mockState, - metamask: { - ...mockState.metamask, - tokenList: { - '0x076146c765189d51be3160a2140cf80bfc73ad68': { - iconUrl: 'tokenListIconUrl', - }, - }, - }, - }, + () => useTokenDetails(transactionMeta), + mockState, ); expect(result.current).toEqual({ - tokenImage: 'tokenListIconUrl', + tokenImage: ICON_URL, tokenSymbol: 'symbol', }); }); @@ -95,8 +171,17 @@ describe('useTokenDetails', () => { symbol: 'symbol', }; + useSelectorMock.mockImplementation((selector) => { + if (selector === getTokenList) { + return {}; + } else if (selector?.toString().includes('getWatchedToken')) { + return TEST_SELECTED_TOKEN; + } + return undefined; + }); + const { result } = renderHookWithProvider( - () => useTokenDetails(transactionMeta, TEST_SELECTED_TOKEN), + () => useTokenDetails(transactionMeta), mockState, ); diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts index be9578496205..da2faacaeb5f 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useTokenDetails.ts @@ -2,15 +2,14 @@ import { TokenListMap } from '@metamask/assets-controllers'; import { TransactionMeta } from '@metamask/transaction-controller'; import { useSelector } from 'react-redux'; import { useI18nContext } from '../../../../../../hooks/useI18nContext'; -import { getTokenList } from '../../../../../../selectors'; -import { SelectedToken } from '../shared/selected-token'; +import { getTokenList, getWatchedToken } from '../../../../../../selectors'; +import { MultichainState } from '../../../../../../selectors/multichain'; -export const useTokenDetails = ( - transactionMeta: TransactionMeta, - selectedToken: SelectedToken, -) => { +export const useTokenDetails = (transactionMeta: TransactionMeta) => { const t = useI18nContext(); - + const selectedToken = useSelector((state: MultichainState) => + getWatchedToken(transactionMeta)(state), + ); const tokenList = useSelector(getTokenList) as TokenListMap; const tokenImage = diff --git a/ui/pages/confirmations/components/confirm/info/info.tsx b/ui/pages/confirmations/components/confirm/info/info.tsx index 67bac40d7e61..f283cbfc2e61 100644 --- a/ui/pages/confirmations/components/confirm/info/info.tsx +++ b/ui/pages/confirmations/components/confirm/info/info.tsx @@ -4,19 +4,23 @@ import { useConfirmContext } from '../../../context/confirm'; import { SignatureRequestType } from '../../../types/confirm'; import ApproveInfo from './approve/approve'; import BaseTransactionInfo from './base-transaction-info/base-transaction-info'; +import NativeTransferInfo from './native-transfer/native-transfer'; +import NFTTokenTransferInfo from './nft-token-transfer/nft-token-transfer'; import PersonalSignInfo from './personal-sign/personal-sign'; import SetApprovalForAllInfo from './set-approval-for-all-info/set-approval-for-all-info'; import TokenTransferInfo from './token-transfer/token-transfer'; import TypedSignV1Info from './typed-sign-v1/typed-sign-v1'; import TypedSignInfo from './typed-sign/typed-sign'; -import NFTTokenTransferInfo from './nft-token-transfer/nft-token-transfer'; const Info = () => { const { currentConfirmation } = useConfirmContext(); const ConfirmationInfoComponentMap = useMemo( () => ({ + [TransactionType.contractInteraction]: () => BaseTransactionInfo, + [TransactionType.deployContract]: () => BaseTransactionInfo, [TransactionType.personalSign]: () => PersonalSignInfo, + [TransactionType.simpleSend]: () => NativeTransferInfo, [TransactionType.signTypedData]: () => { const { version } = (currentConfirmation as SignatureRequestType)?.msgParams ?? {}; @@ -25,15 +29,13 @@ const Info = () => { } return TypedSignInfo; }, - [TransactionType.contractInteraction]: () => BaseTransactionInfo, - [TransactionType.deployContract]: () => BaseTransactionInfo, [TransactionType.tokenMethodApprove]: () => ApproveInfo, [TransactionType.tokenMethodIncreaseAllowance]: () => ApproveInfo, + [TransactionType.tokenMethodSafeTransferFrom]: () => NFTTokenTransferInfo, [TransactionType.tokenMethodSetApprovalForAll]: () => SetApprovalForAllInfo, [TransactionType.tokenMethodTransfer]: () => TokenTransferInfo, [TransactionType.tokenMethodTransferFrom]: () => NFTTokenTransferInfo, - [TransactionType.tokenMethodSafeTransferFrom]: () => NFTTokenTransferInfo, }), [currentConfirmation], ); diff --git a/ui/pages/confirmations/components/confirm/info/native-transfer/__snapshots__/native-transfer.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/native-transfer/__snapshots__/native-transfer.test.tsx.snap new file mode 100644 index 000000000000..234c0b704d5c --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/native-transfer/__snapshots__/native-transfer.test.tsx.snap @@ -0,0 +1,402 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NativeTransferInfo renders correctly 1`] = ` +
+
+
+ G +
+

+ 0 ETH +

+

+ 0 +

+
+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+ +
+
+
+
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+
+
+
+
+

+ You send +

+
+
+
+
+
+

+ - 4 +

+
+
+
+
+
+
+ E +
+

+ ETH +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Network +

+
+
+
+
+ G +
+

+ Goerli +

+
+
+
+
+
+

+ Interacting with +

+
+
+
+
+ +

+ 0x07614...3ad68 +

+
+
+
+
+
+
+
+
+

+ Network fee +

+
+
+ +
+
+
+
+
+

+ 0.0001 ETH +

+

+ $0.04 +

+ +
+
+
+
+
+

+ Speed +

+
+
+
+
+

+ 🦊 Market +

+

+ + ~ + 0 sec + +

+
+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.stories.tsx b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.stories.tsx new file mode 100644 index 000000000000..1de48b2bb2a0 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.stories.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { getMockTokenTransferConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { Box } from '../../../../../../components/component-library'; +import { + AlignItems, + Display, + FlexDirection, + JustifyContent, +} from '../../../../../../helpers/constants/design-system'; +import configureStore from '../../../../../../store/store'; +import { ConfirmContextProvider } from '../../../../context/confirm'; +import NativeTransferInfo from './native-transfer'; + +const store = configureStore(getMockTokenTransferConfirmState({})); + +const Story = { + title: 'Components/App/Confirm/info/NativeTransferInfo', + component: NativeTransferInfo, + decorators: [ + (story: () => any) => ( + + + + {story()} + + + + ), + ], +}; + +export default Story; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'Default'; diff --git a/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.test.tsx b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.test.tsx new file mode 100644 index 000000000000..f4b2b4afab50 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.test.tsx @@ -0,0 +1,41 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockTokenTransferConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; +import { tEn } from '../../../../../../../test/lib/i18n-helpers'; +import NativeTransferInfo from './native-transfer'; + +jest.mock( + '../../../../../../components/app/alert-system/contexts/alertMetricsContext', + () => ({ + useAlertMetrics: jest.fn(() => ({ + trackAlertMetrics: jest.fn(), + })), + }), +); + +jest.mock('../../../../../../store/actions', () => ({ + ...jest.requireActual('../../../../../../store/actions'), + getGasFeeTimeEstimate: jest.fn().mockResolvedValue({ + lowerTimeBound: 0, + upperTimeBound: 60000, + }), +})); + +describe('NativeTransferInfo', () => { + it('renders correctly', async () => { + const state = getMockTokenTransferConfirmState({}); + const mockStore = configureMockStore([])(state); + const { container } = renderWithConfirmContextProvider( + , + mockStore, + ); + + await waitFor(() => { + expect(screen.getByText(tEn('networkFee') as string)).toBeInTheDocument(); + }); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.tsx b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.tsx new file mode 100644 index 000000000000..a2dd3ceaaa05 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/native-transfer/native-transfer.tsx @@ -0,0 +1,37 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import React from 'react'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { useConfirmContext } from '../../../../context/confirm'; +import { SimulationDetails } from '../../../simulation-details'; +import { AdvancedDetails } from '../shared/advanced-details/advanced-details'; +import { GasFeesSection } from '../shared/gas-fees-section/gas-fees-section'; +import NativeSendHeading from '../shared/native-send-heading/native-send-heading'; +import { TokenDetailsSection } from '../token-transfer/token-details-section'; +import { TransactionFlowSection } from '../token-transfer/transaction-flow-section'; + +const NativeTransferInfo = () => { + const { currentConfirmation: transactionMeta } = + useConfirmContext(); + + const isWalletInitiated = transactionMeta.origin === 'metamask'; + + return ( + <> + + + {!isWalletInitiated && ( + + + + )} + + + + + ); +}; + +export default NativeTransferInfo; diff --git a/ui/pages/confirmations/components/confirm/info/shared/native-send-heading/native-send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/native-send-heading/native-send-heading.tsx new file mode 100644 index 000000000000..a3c2b91c9f8e --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shared/native-send-heading/native-send-heading.tsx @@ -0,0 +1,118 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../../../shared/constants/network'; +import { + AvatarToken, + AvatarTokenSize, + Box, + Text, +} from '../../../../../../../components/component-library'; +import Tooltip from '../../../../../../../components/ui/tooltip'; +import { getIntlLocale } from '../../../../../../../ducks/locale/locale'; +import { getConversionRate } from '../../../../../../../ducks/metamask/metamask'; +import { + AlignItems, + Display, + FlexDirection, + JustifyContent, + TextColor, + TextVariant, +} from '../../../../../../../helpers/constants/design-system'; +import { MIN_AMOUNT } from '../../../../../../../hooks/useCurrencyDisplay'; +import { useFiatFormatter } from '../../../../../../../hooks/useFiatFormatter'; +import { getMultichainNetwork } from '../../../../../../../selectors/multichain'; +import { useConfirmContext } from '../../../../../context/confirm'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../simulation-details/formatAmount'; +import { toNonScientificString } from '../../hooks/use-token-values'; + +const NativeSendHeading = () => { + const { currentConfirmation: transactionMeta } = + useConfirmContext(); + + const nativeAssetTransferValue = new BigNumber( + transactionMeta.txParams.value as string, + ).dividedBy(new BigNumber(10).pow(18)); + + const conversionRate = useSelector(getConversionRate); + const fiatValue = + conversionRate && + nativeAssetTransferValue && + new BigNumber(conversionRate) + .times(nativeAssetTransferValue, 10) + .toNumber(); + const fiatFormatter = useFiatFormatter(); + const fiatDisplayValue = + fiatValue && fiatFormatter(fiatValue, { shorten: true }); + + const multichainNetwork = useSelector(getMultichainNetwork); + const ticker = multichainNetwork?.network?.ticker; + + const locale = useSelector(getIntlLocale); + const roundedTransferValue = formatAmount(locale, nativeAssetTransferValue); + + const transferValue = toNonScientificString( + nativeAssetTransferValue.toNumber(), + ); + + const NetworkImage = ( + + ); + + const NativeAssetAmount = + roundedTransferValue === + `<${formatAmountMaxPrecision(locale, MIN_AMOUNT)}` ? ( + + + {`${roundedTransferValue} ${ticker}`} + + + ) : ( + + {`${roundedTransferValue} ${ticker}`} + + ); + + const NativeAssetFiatConversion = ( + + {fiatDisplayValue} + + ); + + return ( + + {NetworkImage} + {NativeAssetAmount} + {NativeAssetFiatConversion} + + ); +}; + +export default NativeSendHeading; diff --git a/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx index b6aff206a26a..3f6bd429d20b 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/send-heading/send-heading.tsx @@ -19,8 +19,7 @@ import { TextVariant, } from '../../../../../../../helpers/constants/design-system'; import { MIN_AMOUNT } from '../../../../../../../hooks/useCurrencyDisplay'; -import { getWatchedToken } from '../../../../../../../selectors'; -import { MultichainState } from '../../../../../../../selectors/multichain'; +import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; import { useConfirmContext } from '../../../../../context/confirm'; import { formatAmountMaxPrecision } from '../../../../simulation-details/formatAmount'; import { useTokenValues } from '../../hooks/use-token-values'; @@ -28,16 +27,11 @@ import { useTokenDetails } from '../../hooks/useTokenDetails'; import { ConfirmLoader } from '../confirm-loader/confirm-loader'; const SendHeading = () => { + const t = useI18nContext(); const { currentConfirmation: transactionMeta } = useConfirmContext(); const locale = useSelector(getIntlLocale); - const selectedToken = useSelector((state: MultichainState) => - getWatchedToken(transactionMeta)(state), - ); - const { tokenImage, tokenSymbol } = useTokenDetails( - transactionMeta, - selectedToken, - ); + const { tokenImage, tokenSymbol } = useTokenDetails(transactionMeta); const { decodedTransferValue, displayTransferValue, @@ -48,15 +42,17 @@ const SendHeading = () => { const TokenImage = ( ); diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/token-details-section.tsx b/ui/pages/confirmations/components/confirm/info/token-transfer/token-details-section.tsx index 6d686873ea1c..629d7d64df3a 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/token-details-section.tsx +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/token-details-section.tsx @@ -1,4 +1,7 @@ -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import React from 'react'; import { useSelector } from 'react-redux'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../../shared/constants/network'; @@ -61,7 +64,7 @@ export const TokenDetailsSection = () => { ); - const tokenRow = ( + const tokenRow = transactionMeta.type !== TransactionType.simpleSend && ( { const addresses = value?.data[0].params.filter( (param) => param.type === 'address', ); - // sometimes there's more than one address, in which case we want the last one - const recipientAddress = addresses?.[addresses.length - 1].value; + const recipientAddress = + transactionMeta.type === TransactionType.simpleSend + ? transactionMeta.txParams.to + : // sometimes there's more than one address, in which case we want the last one + addresses?.[addresses.length - 1].value; if (pending) { return ; diff --git a/ui/pages/confirmations/utils/confirm.ts b/ui/pages/confirmations/utils/confirm.ts index e33ff79d6f01..a007ca0aa0b2 100644 --- a/ui/pages/confirmations/utils/confirm.ts +++ b/ui/pages/confirmations/utils/confirm.ts @@ -25,6 +25,7 @@ export const REDESIGN_USER_TRANSACTION_TYPES = [ TransactionType.tokenMethodTransfer, TransactionType.tokenMethodTransferFrom, TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, ]; export const REDESIGN_DEV_TRANSACTION_TYPES = [ From 5227d6f3f7a75570384ce4a0b169f491ef412eb2 Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:52:00 +0100 Subject: [PATCH 20/56] fix: flaky test `BTC Account - Overview has balance` (#28181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Anti-pattern where we assert the balance BTC balance is 1BTC using getText. We should rather find the element by the expected balance to avoid race conditions [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28181?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28182 ## **Manual testing steps** 1. Check ci ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../flask/btc/btc-account-overview.spec.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/test/e2e/flask/btc/btc-account-overview.spec.ts b/test/e2e/flask/btc/btc-account-overview.spec.ts index f32a48d9c4a8..418c9d736078 100644 --- a/test/e2e/flask/btc/btc-account-overview.spec.ts +++ b/test/e2e/flask/btc/btc-account-overview.spec.ts @@ -1,4 +1,3 @@ -import { strict as assert } from 'assert'; import { Suite } from 'mocha'; import { DEFAULT_BTC_BALANCE } from '../../constants'; import { withBtcAccountSnap } from './common-btc'; @@ -46,17 +45,19 @@ describe('BTC Account - Overview', function (this: Suite) { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - // Wait for the balance to load up - await driver.delay(2000); - - const balanceElement = await driver.findElement( - '.coin-overview__balance', - ); - const balanceText = await balanceElement.getText(); + await driver.waitForSelector({ + testId: 'account-value-and-suffix', + text: `${DEFAULT_BTC_BALANCE}`, + }); + await driver.waitForSelector({ + css: '.currency-display-component__suffix', + text: 'BTC', + }); - const [balance, unit] = balanceText.split('\n'); - assert(Number(balance) === DEFAULT_BTC_BALANCE); - assert(unit === 'BTC'); + await driver.waitForSelector({ + tag: 'p', + text: `${DEFAULT_BTC_BALANCE} BTC`, + }); }, ); }); From c04c1191bbe702e4612326196440b67c51492a7a Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Wed, 30 Oct 2024 09:05:46 -0700 Subject: [PATCH 21/56] feat: Token Network Filter UI [Extension] (#27884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds Token network filter controls. Note that this is not fully functional, and is currently blocked by two PRs before it can be fully integrated: 1. https://github.com/MetaMask/metamask-extension/pull/27785 2. https://github.com/MetaMask/core/pull/4832 In the meantime, this PR is set behind a feature flag `FILTER_TOKENS_TOGGLE` and can be run as follows: `FILTER_TOKENS_TOGGLE=1 yarn webpack --watch` Alternatively: `FILTER_TOKENS_TOGGLE=1 yarn start` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27884?quickstart=1) Included in this PR: 1. Adds new `tokenNetworkFilter` preference to PreferencesController to manage which chains should be considered when filtering tokens 2. Adds an action to update this preference by All Networks (no filters) and Current Network `{ [chainId]: true) }` this is meant to be flexible enough to support multiple chains in the future. 3. Adds `filterAssets` function in a similar style to `sortAssets` it should be configuration based, and should be extensible enough to support filtering assets by deeply nested values (NFT traits), and to also support complex filter types (like price ranges). 4. Dropdown should show the balance for the selected network Not included in this PR: 1. Aggregated balance across chains. Blocked by https://github.com/MetaMask/core/pull/4832 and currently hardcoded to $1000 2. Token lists will not be filtered in this PR. Blocked by https://github.com/MetaMask/metamask-extension/pull/27785 ## **Related issues** https://github.com/orgs/MetaMask/projects/85/views/35?pane=issue&itemId=82217837 https://consensyssoftware.atlassian.net/browse/MMASSETS-430 ## **Manual testing steps** Token Filter selection should persist through refresh Current chain balance should reflect the balance of the current chain Should visibly match designs: https://www.figma.com/design/aMYisczaJyEsYl1TYdcPUL/Portfolio-View?node-id=5750-47217&node-type=canvas&t=EjOUPnqy7tWZE6sV-0 ## **Screenshots/Recordings** https://github.com/user-attachments/assets/4b132e47-0dcf-4e9c-8755-ccb2be1d5dc1 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: Salim TOUBAL --- app/_locales/en/messages.json | 8 + .../preferences-controller.test.ts | 2 + .../controllers/preferences-controller.ts | 2 + builds.yml | 2 + .../asset-list-control-bar.tsx | 116 ++++++++++---- .../asset-list-control-bar/index.scss | 5 + .../asset-list/network-filter/index.scss | 27 ++++ .../assets/asset-list/network-filter/index.ts | 1 + .../network-filter/network-filter.tsx | 144 ++++++++++++++++++ .../asset-list/sort-control/sort-control.tsx | 8 +- .../app/assets/token-list/token-list.tsx | 4 +- ui/components/app/assets/util/filter.test.ts | 98 ++++++++++++ ui/components/app/assets/util/filter.ts | 62 ++++++++ ui/store/actions.ts | 4 + 14 files changed, 450 insertions(+), 33 deletions(-) create mode 100644 ui/components/app/assets/asset-list/network-filter/index.scss create mode 100644 ui/components/app/assets/asset-list/network-filter/index.ts create mode 100644 ui/components/app/assets/asset-list/network-filter/network-filter.tsx create mode 100644 ui/components/app/assets/util/filter.test.ts create mode 100644 ui/components/app/assets/util/filter.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 61dce2e639a1..573af5830661 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -497,6 +497,10 @@ "allCustodianAccountsConnectedTitle": { "message": "No accounts available to connect" }, + "allNetworks": { + "message": "All Networks", + "description": "Speicifies to token network filter to filter by all Networks" + }, "allOfYour": { "message": "All of your $1", "description": "$1 is the symbol or name of the token that the user is approving spending" @@ -1355,6 +1359,10 @@ "currentLanguage": { "message": "Current language" }, + "currentNetwork": { + "message": "Current Network", + "description": "Speicifies to token network filter to filter by current Network. Will render when network nickname is not available" + }, "currentRpcUrlDeprecated": { "message": "The current rpc url for this network has been deprecated." }, diff --git a/app/scripts/controllers/preferences-controller.test.ts b/app/scripts/controllers/preferences-controller.test.ts index 9215ff8571a7..74daf39e17ad 100644 --- a/app/scripts/controllers/preferences-controller.test.ts +++ b/app/scripts/controllers/preferences-controller.test.ts @@ -750,6 +750,7 @@ describe('preferences controller', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }); }); @@ -779,6 +780,7 @@ describe('preferences controller', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }); }); }); diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index ee18403c210b..f6537952d651 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -120,6 +120,7 @@ export type Preferences = { order: string; sortCallback: string; }; + tokenNetworkFilter: Record; shouldShowAggregatedBalancePopover: boolean; }; @@ -222,6 +223,7 @@ export const getDefaultPreferencesControllerState = order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }, // ENS decentralized website resolution ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, diff --git a/builds.yml b/builds.yml index c9c122312fba..824e08e32167 100644 --- a/builds.yml +++ b/builds.yml @@ -273,6 +273,8 @@ env: - BARAD_DUR: '' # Determines if feature flagged Chain permissions - CHAIN_PERMISSIONS: '' + # Determines if feature flagged Filter toggle + - FILTER_TOKENS_TOGGLE: '' # Enables use of test gas fee flow to debug gas fee estimation - TEST_GAS_FEE_FLOWS: false # Temporary mechanism to enable security alerts API prior to release diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx index 696c3ca7c89f..de771976e677 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx @@ -1,4 +1,6 @@ import React, { useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; import { Box, ButtonBase, @@ -25,6 +27,7 @@ import { ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_POPUP, } from '../../../../../../shared/constants/app'; +import NetworkFilter from '../network-filter'; type AssetListControlBarProps = { showTokensLinks?: boolean; @@ -32,55 +35,116 @@ type AssetListControlBarProps = { const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const t = useI18nContext(); - const controlBarRef = useRef(null); // Create a ref - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const popoverRef = useRef(null); + const currentNetwork = useSelector(getCurrentNetwork); + const { tokenNetworkFilter } = useSelector(getPreferences); + const [isTokenSortPopoverOpen, setIsTokenSortPopoverOpen] = useState(false); + const [isNetworkFilterPopoverOpen, setIsNetworkFilterPopoverOpen] = + useState(false); + + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; const windowType = getEnvironmentType(); const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && windowType !== ENVIRONMENT_TYPE_POPUP; - const handleOpenPopover = () => { - setIsPopoverOpen(!isPopoverOpen); + const toggleTokenSortPopover = () => { + setIsNetworkFilterPopoverOpen(false); + setIsTokenSortPopoverOpen(!isTokenSortPopoverOpen); + }; + + const toggleNetworkFilterPopover = () => { + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(!isNetworkFilterPopoverOpen); }; const closePopover = () => { - setIsPopoverOpen(false); + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(false); }; return ( - - {t('sortBy')} - - + {process.env.FILTER_TOKENS_TOGGLE && ( + + {allNetworksFilterShown + ? currentNetwork?.nickname ?? t('currentNetwork') + : t('allNetworks')} + + )} + + + {t('sortBy')} + + + + + + + + void; +}; + +const NetworkFilter = ({ handleClose }: SortControlProps) => { + const t = useI18nContext(); + const dispatch = useDispatch(); + const chainId = useSelector(getCurrentChainId); + const selectedAccount = useSelector(getSelectedInternalAccount); + const currentNetwork = useSelector(getCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const isTestnet = useSelector(getIsTestnet); + const { tokenNetworkFilter, showNativeTokenAsMainBalance } = + useSelector(getPreferences); + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + + const { totalFiatBalance: selectedAccountBalance } = + useAccountTotalFiatBalance(selectedAccount, shouldHideZeroBalanceTokens); + + // TODO: fetch balances across networks + // const multiNetworkAccountBalance = useMultichainAccountBalance() + + const handleFilter = (chainFilters: Record) => { + dispatch(setTokenNetworkFilter(chainFilters)); + + // TODO Add metrics + handleClose(); + }; + + return ( + <> + handleFilter({})} + > + + + + {t('allNetworks')} + + + {/* TODO: Should query cross chain account balance */} + $1,000.00 + + + + {Object.values(allNetworks) + .slice(0, 5) // only show a max of 5 icons overlapping + .map((network, index) => { + const networkImageUrl = + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + network.chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ]; + return ( + + ); + })} + + + + handleFilter({ [chainId]: true })} + > + + + + {t('currentNetwork')} + + + + + + + + ); +}; + +export default NetworkFilter; diff --git a/ui/components/app/assets/asset-list/sort-control/sort-control.tsx b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx index c45a5488f1a6..8e216b5ed6c2 100644 --- a/ui/components/app/assets/asset-list/sort-control/sort-control.tsx +++ b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx @@ -1,13 +1,11 @@ import React, { ReactNode, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; -import { Box, Text } from '../../../../component-library'; +import { Box } from '../../../../component-library'; import { SortOrder, SortingCallbacksT } from '../../util/sort'; import { BackgroundColor, BorderRadius, - TextColor, - TextVariant, } from '../../../../../helpers/constants/design-system'; import { setTokenSortConfig } from '../../../../../store/actions'; import { MetaMetricsContext } from '../../../../../contexts/metametrics'; @@ -45,9 +43,7 @@ export const SelectableListItem = ({ })} onClick={onClick} > - - {children} - + {children} {isSelected && ( { + // TODO filter assets by networkTokenFilter before sorting return sortAssets( [nativeTokenWithBalance, ...tokensWithBalances], tokenSortConfig, @@ -59,6 +60,7 @@ export default function TokenList({ }, [ tokensWithBalances, tokenSortConfig, + tokenNetworkFilter, conversionRate, contractExchangeRates, ]); diff --git a/ui/components/app/assets/util/filter.test.ts b/ui/components/app/assets/util/filter.test.ts new file mode 100644 index 000000000000..fd5a612d590b --- /dev/null +++ b/ui/components/app/assets/util/filter.test.ts @@ -0,0 +1,98 @@ +import { filterAssets, FilterCriteria } from './filter'; + +describe('filterAssets function - balance and chainId filtering', () => { + type MockToken = { + name: string; + symbol: string; + chainId: string; // Updated to string (e.g., '0x01', '0x89') + balance: number; + }; + + const mockTokens: MockToken[] = [ + { name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 }, + { name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 }, + { name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 }, + { name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 }, + ]; + + test('filters by inclusive chainId', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89' + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(3); // Should include 3 tokens with chainId '0x01' and '0x89' + expect(filtered.map((token) => token.chainId)).toEqual([ + '0x01', + '0x01', + '0x89', + ]); + }); + + test('filters tokens with balance between 100 and 150 inclusive', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 100, max: 150 }, // Balance between 100 and 150 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(2); // Token1 and Token4 + expect(filtered.map((token) => token.balance)).toEqual([100, 150]); + }); + + test('filters by inclusive chainId and balance range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89' + filterCallback: 'inclusive', + }, + { + key: 'balance', + opts: { min: 100, max: 150 }, // Balance between 100 and 150 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(2); // Token1 and Token4 meet both criteria + }); + + test('returns no tokens if no chainId matches', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x04': true }, // No token with chainId '0x04' + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(0); // No matching tokens + }); + + test('returns no tokens if balance is not within range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 300, max: 400 }, // No token with balance between 300 and 400 + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered.length).toBe(0); // No matching tokens + }); +}); diff --git a/ui/components/app/assets/util/filter.ts b/ui/components/app/assets/util/filter.ts new file mode 100644 index 000000000000..20ca7cebcc58 --- /dev/null +++ b/ui/components/app/assets/util/filter.ts @@ -0,0 +1,62 @@ +import { get } from 'lodash'; + +export type FilterCriteria = { + key: string; + opts: Record; // Use opts for range, inclusion, etc. + filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc. +}; + +export type FilterType = string | number | boolean | Date; +type FilterCallbackKeys = keyof FilterCallbacksT; + +export type FilterCallbacksT = { + inclusive: (value: string, opts: Record) => boolean; + range: (value: number, opts: Record) => boolean; +}; + +const filterCallbacks: FilterCallbacksT = { + inclusive: (value: string, opts: Record) => { + if (Object.entries(opts).length === 0) { + return false; + } + return opts[value]; + }, + range: (value: number, opts: Record) => + value >= opts.min && value <= opts.max, +}; + +function getNestedValue(obj: T, keyPath: string): FilterType { + return get(obj, keyPath); +} + +export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { + if (criteria.length === 0) { + return assets; + } + + return assets.filter((asset) => + criteria.every(({ key, opts, filterCallback }) => { + const nestedValue = getNestedValue(asset, key); + + // If there's no callback or options, exit early and don't filter based on this criterion. + if (!filterCallback || !opts) { + return true; + } + + switch (filterCallback) { + case 'inclusive': + return filterCallbacks.inclusive( + nestedValue as string, + opts as Record, + ); + case 'range': + return filterCallbacks.range( + nestedValue as number, + opts as { min: number; max: number }, + ); + default: + return true; + } + }), + ); +} diff --git a/ui/store/actions.ts b/ui/store/actions.ts index f8e232f3a519..06b892db0b1c 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -3113,6 +3113,10 @@ export function setTokenSortConfig(value: SortCriteria) { return setPreference('tokenSortConfig', value, false); } +export function setTokenNetworkFilter(value: Record) { + return setPreference('tokenNetworkFilter', value, false); +} + export function setSmartTransactionsPreferenceEnabled( value: boolean, ): ThunkAction { From d61b77ec7db92c2f87d2952b9a3e71f9d7f61c9a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 30 Oct 2024 10:59:18 -0600 Subject: [PATCH 22/56] feat: Copy updates to satisfy UK regulation requirements (#28157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates copy on certain ramps components to satisfy UK regulatory requirements. The copy changes have been reviewed and approved by legal and the user experience content team. The Ramps Banner has been removed on the NFT tab for similar reasons. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28157?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-10-29 at 9 55 31 AM Screenshot 2024-10-29 at 9 55 20 AM Screenshot 2024-10-29 at 9 55 09 AM ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/_locales/de/messages.json | 27 --------- app/_locales/el/messages.json | 27 --------- app/_locales/en/messages.json | 55 +++++------------- app/_locales/es/messages.json | 27 --------- app/_locales/fr/messages.json | 27 --------- app/_locales/hi/messages.json | 27 --------- app/_locales/id/messages.json | 27 --------- app/_locales/ja/messages.json | 27 --------- app/_locales/ko/messages.json | 27 --------- app/_locales/pt/messages.json | 27 --------- app/_locales/ru/messages.json | 27 --------- app/_locales/tl/messages.json | 27 --------- app/_locales/tr/messages.json | 27 --------- app/_locales/vi/messages.json | 27 --------- app/_locales/zh_CN/messages.json | 27 --------- app/images/ramps-card-nft-illustration.png | Bin 308256 -> 0 bytes .../asset-list/asset-list.ramps-card.test.js | 4 +- .../app/assets/asset-list/asset-list.tsx | 2 +- .../app/assets/nfts/nfts-tab/nfts-tab.js | 33 ----------- .../app/assets/nfts/nfts-tab/nfts-tab.test.js | 24 -------- .../funding-method-modal.test.tsx | 4 +- .../funding-method-modal.tsx | 4 +- .../multichain/ramps-card/ramps-card.js | 31 ++++------ .../ramps-card/ramps-card.stories.js | 6 -- .../__snapshots__/asset-page.test.tsx.snap | 18 +++--- 25 files changed, 40 insertions(+), 519 deletions(-) delete mode 100644 app/images/ramps-card-nft-illustration.png diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 8d1ed215aa92..9af24022bcb3 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Jetzt kaufen" }, - "buyToken": { - "message": "$1 kaufen", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Funktionstyp" }, - "fundYourWallet": { - "message": "Versehen Sie Ihre Wallet mit Geldern" - }, - "fundYourWalletDescription": { - "message": "Legen Sie los, indem Sie Ihrer Wallet $1 hinzufügen.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gas" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Konto auf $1 ansehen" }, - "getStartedWithNFTs": { - "message": "Erhalten Sie $1 für den Kauf von NFTs", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Legen Sie mit NFTs los, indem Sie Ihrer Wallet $1 hinzufügen.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Zurück" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Anteil" }, - "startYourJourney": { - "message": "Beginnen Sie Ihre Reise mit $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Legen Sie mit web3 los, indem Sie Ihrer Wallet $1 hinzufügen.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Fehler beim Abfragen der Statusprotokolle." }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 7ccc23b123bb..308099b1c2b1 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Αγοράστε Τώρα" }, - "buyToken": { - "message": "Αγορά $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Τύπος λειτουργίας" }, - "fundYourWallet": { - "message": "Χρηματοδοτήστε το πορτοφόλι σας" - }, - "fundYourWalletDescription": { - "message": "Ξεκινήστε προσθέτοντας περίπου $1 στο πορτοφόλι σας.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Τέλος συναλλαγής" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Προβολή λογαριασμού σε $1" }, - "getStartedWithNFTs": { - "message": "Λάβετε $1 για να αγοράσετε NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Ξεκινήστε με NFT προσθέτοντας περίπου $1 στο πορτοφόλι σας.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Πηγαίνετε πίσω" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Stake" }, - "startYourJourney": { - "message": "Ξεκινήστε το ταξίδι σας με $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Ξεκινήστε με Web3 προσθέτοντας περίπου $1 στο πορτοφόλι σας.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Σφάλμα κατά την ανάκτηση αρχείων καταγραφής κατάστασης." }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 573af5830661..512eb3b0ee36 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -875,12 +875,6 @@ "message": "Buy $1", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" }, - "buyCrypto": { - "message": "Buy crypto" - }, - "buyFirstCrypto": { - "message": "Buy your first crypto with a debit or credit card." - }, "buyMoreAsset": { "message": "Buy more $1", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" @@ -888,10 +882,6 @@ "buyNow": { "message": "Buy Now" }, - "buyToken": { - "message": "Buy $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1522,6 +1512,9 @@ "dcent": { "message": "D'Cent" }, + "debitCreditPurchaseOptions": { + "message": "Debit or credit card purchase options" + }, "decimal": { "message": "Token decimal" }, @@ -2114,12 +2107,8 @@ "functionType": { "message": "Function type" }, - "fundYourWallet": { - "message": "Fund your wallet" - }, - "fundYourWalletDescription": { - "message": "Get started by adding some $1 to your wallet.", - "description": "$1 is the token symbol" + "fundingMethod": { + "message": "Funding method" }, "gas": { "message": "Gas" @@ -2210,20 +2199,6 @@ "genericExplorerView": { "message": "View account on $1" }, - "getStarted": { - "message": "Get Started" - }, - "getStartedByFundingWallet": { - "message": "Get started by adding some crypto to your wallet." - }, - "getStartedWithNFTs": { - "message": "Get $1 to buy NFTs", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Get started with NFTs by adding some $1 to your wallet.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Go back" }, @@ -4768,9 +4743,6 @@ "selectEnableDisplayMediaPrivacyPreference": { "message": "Turn on Display NFT Media" }, - "selectFundingMethod": { - "message": "Select a funding method" - }, "selectHdPath": { "message": "Select HD path" }, @@ -5418,14 +5390,6 @@ "stake": { "message": "Stake" }, - "startYourJourney": { - "message": "Start your journey with $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Get started with web3 by adding some $1 to your wallet.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Error in retrieving state logs." }, @@ -6029,6 +5993,12 @@ "tips": { "message": "Tips" }, + "tipsForUsingAWallet": { + "message": "Tips for using a wallet" + }, + "tipsForUsingAWalletDescription": { + "message": "Adding tokens unlocks more ways to use web3." + }, "to": { "message": "To" }, @@ -6081,6 +6051,9 @@ "tokenList": { "message": "Token lists" }, + "tokenMarketplace": { + "message": "Token marketplace" + }, "tokenScamSecurityRisk": { "message": "token scams and security risks" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index a2578d1f4137..ada162b9a12b 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -802,10 +802,6 @@ "buyNow": { "message": "Comprar ahora" }, - "buyToken": { - "message": "Comprar $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1880,13 +1876,6 @@ "functionType": { "message": "Tipo de función" }, - "fundYourWallet": { - "message": "Agregar fondos a su monedero" - }, - "fundYourWalletDescription": { - "message": "Comience agregando $1 a su monedero.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gas" }, @@ -1970,14 +1959,6 @@ "genericExplorerView": { "message": "Ver cuenta en $1" }, - "getStartedWithNFTs": { - "message": "Obtenga $1 para comprar NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Comience con los NFT agregando $1 a su monedero.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Volver" }, @@ -4991,14 +4972,6 @@ "stake": { "message": "Staking" }, - "startYourJourney": { - "message": "Comience su recorrido con $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Comience con la web3 agregando $1 a su monedero.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Error al recuperar los registros de estado." }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index c3151d3ca53f..856638ba2b8a 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Achetez maintenant" }, - "buyToken": { - "message": "Acheter des $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Octets" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Type de fonction" }, - "fundYourWallet": { - "message": "Approvisionnez votre portefeuille" - }, - "fundYourWalletDescription": { - "message": "Commencez par ajouter quelques $1 à votre portefeuille.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Carburant" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Voir le compte sur $1" }, - "getStartedWithNFTs": { - "message": "Obtenez des $1 pour acheter des NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Débutez avec les NFT en ajoutant quelques $1 à votre portefeuille.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Retour" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Staker" }, - "startYourJourney": { - "message": "Lancez-vous dans les $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Lancez-vous dans le Web3 en ajoutant quelques $1 à votre portefeuille.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Erreur lors du chargement des journaux d’état." }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index c69c7117d4d0..45e64a972e17 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "अभी खरीदें" }, - "buyToken": { - "message": "$1 खरीदें", - "description": "$1 is the token symbol" - }, "bytes": { "message": "बाइट" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "फ़ंक्शन का प्रकार" }, - "fundYourWallet": { - "message": "अपने वॉलेट को फंड करें" - }, - "fundYourWalletDescription": { - "message": "अपने वॉलेट में कुछ $1 जोड़कर शुरुआत करें।", - "description": "$1 is the token symbol" - }, "gas": { "message": "गैस" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "$1 पर अकाउंट देखें" }, - "getStartedWithNFTs": { - "message": "NFTs खरीदने के लिए $1 प्राप्त करें", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "अपने वॉलेट में कुछ $1 जोड़कर NFTs से शुरुआत करें।", - "description": "$1 is the token symbol" - }, "goBack": { "message": "वापस जाएं" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "हिस्सेदारी" }, - "startYourJourney": { - "message": "$1 से अपनी यात्रा शुरू करें", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "अपने वॉलेट में कुछ $1 जोड़कर Web3 से शुरुआत करें।", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "स्टेट लॉग को पुनर्प्राप्त करने में गड़बड़ी।" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 53e7fd923fda..6314d9ed3468 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Beli Sekarang" }, - "buyToken": { - "message": "Beli $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Byte" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Jenis fungsi" }, - "fundYourWallet": { - "message": "Danai dompet Anda" - }, - "fundYourWalletDescription": { - "message": "Mulailah dengan menambahkan sejumlah $1 ke dompet Anda.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gas" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Lihat akun di $1" }, - "getStartedWithNFTs": { - "message": "Dapatkan $1 untuk membeli NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Mulailah menggunakan NFT dengan menambahkan sejumlah $1 ke dompet Anda.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Kembali" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Stake" }, - "startYourJourney": { - "message": "Mulailah perjalanan Anda dengan $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Mulailah dengan web3 dengan menambahkan sejumlah $1 ke dompet Anda.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Terjadi kesalahan pada log status pengambilan." }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index c26ec67990b2..61730b2bc325 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "今すぐ購入" }, - "buyToken": { - "message": "$1を購入", - "description": "$1 is the token symbol" - }, "bytes": { "message": "バイト" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "機能の種類" }, - "fundYourWallet": { - "message": "ウォレットへの入金" - }, - "fundYourWalletDescription": { - "message": "ウォレットに$1を追加して開始します。", - "description": "$1 is the token symbol" - }, "gas": { "message": "ガス" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "$1でアカウントを表示" }, - "getStartedWithNFTs": { - "message": "$1を入手してNFTを購入", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "ウォレットに$1を追加してNFTの利用を開始します。", - "description": "$1 is the token symbol" - }, "goBack": { "message": "戻る" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "ステーク" }, - "startYourJourney": { - "message": "$1で利用開始", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "ウォレットに$1を追加してWeb3の利用を開始します。", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "ステートログの取得中にエラーが発生しました。" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index ad4959a459b9..05c04fbd17a9 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "지금 구매" }, - "buyToken": { - "message": "$1 구매", - "description": "$1 is the token symbol" - }, "bytes": { "message": "바이트" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "기능 유형" }, - "fundYourWallet": { - "message": "지갑에 자금 추가" - }, - "fundYourWalletDescription": { - "message": "지갑에 $1의 자금을 추가하여 시작하세요.", - "description": "$1 is the token symbol" - }, "gas": { "message": "가스" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "$1에서 계정 보기" }, - "getStartedWithNFTs": { - "message": "$1 받고 NFT 구매하기", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "지갑에 $1의 자금을 추가하여 시작하세요.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "뒤로 가기" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "스테이크" }, - "startYourJourney": { - "message": "$1 토큰으로 여정을 시작하세요", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "지갑에 $1 토큰을 추가하여 웹3를 시작하세요.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "상태 로그를 가져오는 도중 오류가 발생했습니다." }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 7831e080500b..4c02a9dc223e 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Comprar agora" }, - "buyToken": { - "message": "Comprar $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Tipo de função" }, - "fundYourWallet": { - "message": "Adicione valores à sua carteira" - }, - "fundYourWalletDescription": { - "message": "Comece adicionando $1 à sua carteira.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gás" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Ver conta na $1" }, - "getStartedWithNFTs": { - "message": "Adquira $1 para comprar NFTs", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Comece sua jornada com NFTs adicionando $1 à sua carteira.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Voltar" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Stake" }, - "startYourJourney": { - "message": "Comece sua jornada com $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Comece sua jornada na web3 adicionando $1 à sua conta.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Erro ao recuperar os logs de estado." }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index d32a603b367b..f1e5d27589c5 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Купить сейчас" }, - "buyToken": { - "message": "Купить $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Байты" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Тип функции" }, - "fundYourWallet": { - "message": "Пополните свой кошелек" - }, - "fundYourWalletDescription": { - "message": "Начните с добавления $1 в свой кошелек.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Газ" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Посмотреть счет на $1" }, - "getStartedWithNFTs": { - "message": "Получите $1 для покупки NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Начните использовать NFT, добавив $1 в свой кошелек.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Назад" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Выполнить стейкинг" }, - "startYourJourney": { - "message": "Начните свое путешествие с $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Начните использовать Web3, добавив $1 в свой кошелек.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Ошибка при получении журналов состояния." }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 0c3675d78754..76e91829fc2c 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Bilhin Ngayon" }, - "buyToken": { - "message": "Bumili ng $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bytes" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Uri ng Function" }, - "fundYourWallet": { - "message": "Pondohan ang iyong wallet" - }, - "fundYourWalletDescription": { - "message": "Magsimula sa pamamagitan ng pagdagdag ng $1 sa iyong wallet.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gas" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Tingnan ang account sa $1" }, - "getStartedWithNFTs": { - "message": "Kumuha ng $1 para bumili ng mga NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Magsimula sa mga NFT sa pamamagitan ng pagdagdag ng $1 sa iyong wallet.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Bumalik" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Mag-stake" }, - "startYourJourney": { - "message": "Simulan ang iyong paglalakbay sa $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Magsimula sa web3 sa pamamagitan ng pagdagdag ng $1 sa iyong wallet.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Error sa pagkuha ng mga log ng estado." }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 253e00845388..3b1899614d70 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Şimdi Satın Al" }, - "buyToken": { - "message": "$1 Al", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Bayt" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "İşlev türü" }, - "fundYourWallet": { - "message": "Cüzdanınıza para ekleyin" - }, - "fundYourWalletDescription": { - "message": "Cüzdanınıza biraz $1 ekleyerek başlayın.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gaz" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Hesabı $1 üzerinde görüntüleyin" }, - "getStartedWithNFTs": { - "message": "NFT satın almak için $1 edinin", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Cüzdanınıza biraz $1 ekleyerek NFT'lere başlayın.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Geri git" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Pay" }, - "startYourJourney": { - "message": "$1 ile yolculuğunuza başlayın", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Cüzdanınıza biraz $1 ekleyerek web3'e başlayın.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Durum günlükleri alınırken hata." }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 538108c0677d..4bfcba6dac1f 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "Mua ngay" }, - "buyToken": { - "message": "Mua $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "Byte" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "Loại chức năng" }, - "fundYourWallet": { - "message": "Nạp tiền vào ví của bạn" - }, - "fundYourWalletDescription": { - "message": "Hãy bắt đầu bằng cách nạp một ít $1 vào ví của bạn.", - "description": "$1 is the token symbol" - }, "gas": { "message": "Gas" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "Xem tài khoản trên $1" }, - "getStartedWithNFTs": { - "message": "Nhận $1 để mua NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "Hãy bắt đầu với NFT bằng cách nạp một ít $1 vào ví của bạn.", - "description": "$1 is the token symbol" - }, "goBack": { "message": "Quay Lại" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "Stake" }, - "startYourJourney": { - "message": "Bắt đầu hành trình của bạn với $1", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "Hãy bắt đầu với Web3 bằng cách nạp một ít $1 vào ví của bạn.", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "Lỗi khi truy xuất nhật ký trạng thái." }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index d3a4af11220a..80a31d532482 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -805,10 +805,6 @@ "buyNow": { "message": "立即购买" }, - "buyToken": { - "message": "购买 $1", - "description": "$1 is the token symbol" - }, "bytes": { "message": "字节" }, @@ -1883,13 +1879,6 @@ "functionType": { "message": "功能类型" }, - "fundYourWallet": { - "message": "向您的钱包存入资金" - }, - "fundYourWalletDescription": { - "message": "将一些 $1 添加到您的钱包并开始使用", - "description": "$1 is the token symbol" - }, "gas": { "message": "燃料" }, @@ -1973,14 +1962,6 @@ "genericExplorerView": { "message": "在$1查看账户" }, - "getStartedWithNFTs": { - "message": "获取 $1 以购买 NFT", - "description": "$1 is the token symbol" - }, - "getStartedWithNFTsDescription": { - "message": "将一些 $1 添加到您的钱包并开始使用 NFT", - "description": "$1 is the token symbol" - }, "goBack": { "message": "返回" }, @@ -4994,14 +4975,6 @@ "stake": { "message": "质押" }, - "startYourJourney": { - "message": "从 $1 开始您的旅程", - "description": "$1 is the token symbol" - }, - "startYourJourneyDescription": { - "message": "将一些 $1 添加到您的钱包并开始使用 Web3", - "description": "$1 is the token symbol" - }, "stateLogError": { "message": "检索状态日志时出错。" }, diff --git a/app/images/ramps-card-nft-illustration.png b/app/images/ramps-card-nft-illustration.png deleted file mode 100644 index 1cbc824592f8d1a27acee59049c3fc72aa91632e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 308256 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

~EE6I4fMZh2HZB-R&HGCW3f{+nK;h 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

*|gHg9! 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%0Ad^V$%n( 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�khK<;m$*{QgS>=(F+kc=j?qS zXpLEy>dLuhs*xFuj&5X)J2@&5XTG-;k1G~jb(k=fPAp`Pi5ur6Qm0CibQBOT%u#;c z=Ti_^t&AZPO@CB5N=}34#HLanm#xZ2*^`*FTE)C9Q@w)*l%yQq(FHcH78SlG$ZoODY$hzsLCu45=4xYI9`u(t3@l=)&&WP1<)Gb8MFav~A}l4^D79 zqHqlcnI;Trk#6nf@G@QY1lKPyP{JGen8jPDY0Q9n_UO4AFRK+7Bp8geckSvvPL9bAj zdZ%O13dgzav6#3+)M*Q5m1Gv?TP$+S(H6H3FNuw`KV-+31-lVtaiW6L;rr>~E%F>A z{?z6cpT7gB-;1T6$$w6Wx1-|oQ*ynD*%3salLbcU>VDq;jT{=2@|ezOg8mjTeJor@ zSCgRU%Lla^bg@KcbBQ~UG;6=a@oJ?+eP9uFaD~&fOFbeSV;mE$oOV-Xk4&Ks6Xzmp zq?95pPqi;*6Bl);$>2KKMx`P1=F7YwNxk{Gj=)+=rHhkDc{BU|RJRe1Oqosm&$$0*Ct#iOzLxzxmjnLd-xshhjlby`jW%#Tcl%D6;>I);bPd3Xb zh$r{pg^n`pOWse17n8( z*R0CxMJ57t8!6@neVd{a?h81_t7iW2rcRm~D0nUfy9*S<-4QF)!u&2`so zW7buxcu4#jFrxA|#JZw$&FPgS8o6O%PJ`Qb#$?IJIZ?4RKKI{o`yb3bmijw@vJVQV z#`|CVHI5OhUEYZX*7@!lET{3u{l1#q4bIk~(bA0DrnV#I%n7{n9`FroqlwQ?O%n6) z0bJ#sqf>}hd9YHnNoQmF_C)c#by`?L=pi>!Cn}ZdMRLKUF~|2G$w-s=!7a6&)L2Kg zqf!TfQRN^(J*o{qmvyO14QWRU5@>BHqBrPXV!;)eHDqZ4LLGKS{=!j3sc^@sIplCH z4Sr+p;1YJVn!_g`p5U|MnlMWC6f*}2rV$+H!T{fgKGurbDG>=@;xL(uCVe-7Ej?xY znKN;qz2(&IvDeRxy&t(V%32qf5M$2Zn*P=Y%&!fwhP`8PEx+IGDOvZn1-Oo5JB|H9 ztoRa((uYQzLI_QY^`krOQy#14C_G*VZ>8VTxe0mB1jW(VMrAlLXqo#QT42N>~|xAM5;Z_l>=nt807cF4F`Cdh9xJ#}Jh|%K>Ahvxs}MiF>;qUz$hp2XhJLdNV$^m*I6< z`lDIm?6e)&iKVFiMH;f%19YRmZLr9D<+Tzb5tUd_16X%WmDUSuabIL29e`xLaO$I6 ztFF0LrW?P-3kW4pq_TxYWTyUT<8VIrNl90=1G=CT&}srMzmU zy(^*%{g>O~b~=m=YrK7l&vw1204@Fb>6cAuNna8avCqM4g-9~lk^*8f=y#d`4KOTj zB_dmlCkn;htHzrzlbE&HCd*qGf5kN>`K+y2?m2O!GDd4i^UQWkXvB$G*8tXdwDhe+ z^L9hDdlsY#y=CpkX*x*4>V!qvFfp1SO2(vwL3|7Ct2^W>C4h1wsp!h(N-q+}DV_05 z;L@di^V*{qb}5NWT*N5U;mZ7^!u9$)E7vMrL^kqI)c46}rU1HoEj_qz zfBNhGIocndO<#UWD}UjH{O0qAAp4v>Us^R|?@D_q{Z}h|mD|z^IAfHNfSSo<-{sej zq9HVx$kYXdneOZ>e`d~_!LuYp+XG2R`91%xfc8uykF%1VVUh8>{~e*qRwv9Y<_OFQ zwxQ)FW~gCnSnmfxPGc7s(1sl*4jh;TDCiY{@~#4dJ=yBWh+`n-1~g=+u&n%e2`=J# z;STR5cX+xOkALEaF4d;nMjAplcf!#993FM}}V(5jz0@xGxJ1PNnLl+8?FW%DE+1O`!JW`Z!6L4m3OgsUEvek8?6oJxaA%5Es#97$w~KKrWXLN+RtCeV<(0{=HrB0E1;Z<$d}(( zd5;|1ED<4-_Ue^0$0z9|lVXx7uLWX*du7$v`h-~Z0ZdqCYFI0b92?P$&sAVuh4IL< zgh^6y1_!gsjP2TNyZ6k^;sbNjJT}|*apH4a)KOmKRVJJn)}y(IclKg@m9E1Ju+sq7 z;d|`GZR-4;X=z0Xzy-8r>dx5?U~UN-b&a^g)nc@(oi~?tC0@~lmSm(iOk`}GT>^EB zT%^-PM`2G@ypvr-1)3Vo4+$AX0$ficX$DE$4CUI&a)whfx6wGqTV{K5nwU2+qS2hkg(@q*qzvDc9fZbfvy3p-9F~RFF_GH zlg+tU$6~n68f{7J45KI&mUz6u@VUGL*d~MB8?ib(wisRLAYwI>m22#lMQ-Z)BqI?< zyUt~%WI*ted?~zu7yNHT53YesuH4f&8{>--jT>Y2Sbh^_JN3xYR9+GeaoyoJQcv$i zN^^~M=rtB%8@gMmPM^$k((>uOBv(%evIea3Bt%P_#*@3t>1>|(MNe~2szuBX{hK*Inc^@gidkdrGbWP-WF{(he?!P1!I z$c3+1^<{~Ouy z@L#I4fyhL*3=;pfRLM;(KsG~g7jy`5J0Q{(9ms*Hj%LwxiCl3S?09WL)rSTkZ}h#N zgkh?h1ZH~?7RP?NbbegcwA1@w-(|P@4*_FhF)Z@kD^y>Go z&c}VpDrJi_u~WRhXRhQip!f)G^>jJO|WguyenJc?n(p-)nTjUj3#3edm( zO8TP}H-@JYc2~(qX)9x&zclvA`}y%}VW@3|_uU;bC%(7Oxs-0kcu-}hKn zjC;&kJ-&6{Q0h=o@dp+gAQU6>5oGfLw-bU2eI*ja6g`zSmimbalpu3OsR5aiu>Ew7 zA>%iSYXBZ&vK$AT*9qXj*H&$7`_xHUhMw*r%3O;+ag@cIC*XE^+=V#~Z%d@9W%3nc zF98JZbUu_(Cwo?F%3^d{)F+W|rtBxGt(B(#pLE%gg~K@PL$Azh}W4BeJvuPS!>Lzo=$0YlnY>@$2iaX>s`z70TvGjZe(m zOP+%YdwO{4fe*1{*;bz3+YTuOa|4K1;IHb zBC5mI#)33hZHiW#q48<2)gOxnJG+?;VUW)VzM43FcvZpBWd^-;TVL6m_Lt_ic|YBD zkIf!zc3|mCR&iu8Bmg;)H(`U90X<@TDVpnR{4L}6_Ol2J-@J5h(o@6WVz6xRwp=JC{5MDrj?lU3E&7Go0tHkkB&YpycbKxI8Qnk zw9c34ps6soc2*@{VT_VT!B2HqQ1}7Y?J=s9ODGc(q2)(wbK=a zFx*jWLG_Y}S`dvA!d$dc4qc05*D%SI8u6fT7;!GkbtGk~?_t6I2)*JlzF^}z5uY(} zlk+8s8wKn^LbG$J7L#m^mVDQJ3fV)8XYfT&MO;mXz7o@!IO0gNyV9(Gwqffto-~e>;jQx8+_P+pf z`h!YxyeAbYptUgB=JRx7g1$7$@Dwd{VVNI>&tK>Jy!{VGy{hMpz~t+Ezifl8xPp9{ zAAe>{2G?_)^(plG?-o@5MEezv!zbA>gwApAe%pi-y6U3R)UALG2yBkqt;<@kmpj(JZ(xcOh_#-vgO!_h%PAA33R7z>L7y{ zdFyGEidQja%cqb9gGS2l+L~5RgkEHJ#3sq*iRr}JSW_3{+k)1N4qjv`hzkS#NhT>! zCV|G`2rf9JMx0~uo_!W8-eDkp4rXqB9nFU`V9ml=1Snqmx2^;NDb)LIv!*#A)r>-DD zfw57mt%PNdRbS>5?$M{ts3cwj#HSn|oHiLN#79^Bb307vy}U;?lGI05N#yfccX^5{ zcwHPhhQPg~qFI`ef$!H!Z}~gMNNA-V$CF(nvfiR6bW<4H0Cql-?TjO48o$8pOME`d z6uvPsaXrso&tH5Y9`bK*1zfXzmY<{Mn#E(Nqrb(T0K(#;!dht8V2#+>fZljKfX58o zN%CRd0<``4SWCnunfsMQ$)|-`8iuY@EXxG~ z;Nd$b@Tsm9p{`A;usWh6)JuiFtg7r?{lxs(K1naT$M&wh1W@a==m~qBrJM{5bl795wKb2J#29}t zo9PF;8GlS|_z~FT4qw#^V8to2uG29FYj~bhOJ6Q z3SefnN9o|g_>TGdK$MByq1Iwq1mQ=og<(V?Rf~2)oO7659%t$xCOak<)ne4?f>c@Y zT0riiX=%r37JgwHvgR4&k?F?(WMh_+B&MwAO1M5JhKo#PbVe;^RO&&L%er7v(RT=< z{dIKW=m@@zARzwUz;S0)tK0M+uw#(w z?%w78TZ<+;O`1buM<aZ9rsur_^iEJsuD# zY%p5&Q?ghNnQl6~MHa$R>14FGdY_h-3G>V^P z09@LaCMQD9Kz9+<|5*mk@6X;}0Q6@MzWgrV{w8nZb786Vef-{%z<6e?|Db?%IXC$E z>DON?Klgha@87*P@p3!jy8GT7o2O~~?v?S&YcSb&7$|LekJg45X4c1@t^3L@HyG$F zll;7Gse|8&cq3iEw~k8;hzmr>R_&{J0hTMhZ_?v~p>?&@JYj!e9EH13O19!DfEGMq&z zh7-i6lw>6bzFPdQy|5m_-0+SodfQnkN~x2ZGz6`HoTb--kNspO8ct*S-h_IZQ zDUE&_cfOhqmG646(YJK~J6)wcRum(6Ffi3MNLV1X6bBkt1BL-m160#&g_SO8{2UL= zxuFDOVhLkmibPvY9VNA$L2tP*sU>krs5DKY;%(40N1r7hrQEp?Z)FXFW5^U8<14AI z7uCcoqvDhJ*F@1t+jNqM6kr%e)qGl6!O7xCmMm0-N0oY@e_7U%inCh4zOY67e$Mc$ zoSBR3vU;p}k^3G|D69OSK*PofQ3(OC~WJ@&+0oN}XMc=pX;5%2PWf-We6jD1Sqy3_J7T2T7zI z1lMdoPReLA$&qXsUt^J(>lw(_QtYg2op3btc~%7-Gsxy`XKh)4YlI=Uqfz-*#dUBm zG6M`;^fUsay)?(_N*KR`U0|vD$lm(*>`lD^xL$(2u7%0uVo{(t!1X%bm`CGFb2U9r zmjKtx@kMG!FxRxS#Ot)X^ZCHD%n2)%Dwsv@1R`vg(OhCYyT;#$Be|V4=YT_78#P+w zglUxm-Xd$6$i$8=pB){CGOm#z!F>-JP6MQd2fl_>5baw(oJ>O2k>8A=c^U+hq4NzwY&I8jF8h1_7GUd z2y*_4C1KvA`i$yYun%2Gsyk~w_jmQ1GxV3OW?Mgu|;rQ zDmnf-k2%lq#m~~oqVH{POaEN$57VY^-o7}o#`hDzNj`ypVH7m~dA@ya zQm%%UX5deHb6`y;7CR?ZDoAD-X)>u* z6uAS(`iXx4*yA)!!=Blcu~A`yIBakVJsb}cJzCi9q!T*&>X%v8)1bxut5x!y9TUs`$@hHW{uL_WrL zVCL=ZX?swa767Y?x(fAix+kzIy`n}5n7SX*KLA9dY@@!MvYWFH`PAwTR%=q z1hUum=s`2t6`*uixeB~*tOGiLr?iHci$27R2`N8hPyeNp?4x|Ul|s*2MenS)EnwT9 z!Nk2LVgKy+K1y~gx#(7WD8^8W`6MMjHTu2?I};rsv66IHI`4;}uZ|6tZnWjz-mrYh z&2)LdPHY}U(*j(7^PTR)=7T5>A@AjBuqvTw0__J3_H()9tXPRj=$iuU*UH2#Gsr%Y zsmo=d2`okyqOn@lvC6*}A@vmY0seI}^83m`Z%G z$C~BojpPXkV=vdNR+*$eo8{kG=f{*vhepbz``kadxdsFI5)=%oGHgbqJknm%fGFpY2-kF%2fnh)2Hw3&yP^4*g7r% zhNU!pkSdVE>hDPa;D_JEy*W-yZln@Bh3w$W>27; zA*gT6TiY*l(WozRaekg9B4n`5=jZQG^rg2&a$BDJ8QLEtzT}lQQ(EIs=3F|kr zQ$is9iFrE+2b*+y?|B>NvgT$6uAiAV%6I-DgX_}D`LPPJBda@JZ$!HlhX3-7`PD}s z#T!ifkM9OY*+}#Jx&0$tluy(f*F0hf?)~r0lksU1wa4|f`M&<%K79f<`uF;tC)6B& zV^nMXJ7eC^&vOp`-lzw5zDlgWv$FUN^hD=WX2cSX)&>OHqZ_g34Xs&OzI~#ySZaHc zsmsm~6N0Ic=#^=BtV0=Cs#vwcGFgnunvPBlN@5|MOX_@n!7!nQ6bap48*t!*rDxLw zWGU=5sB2)>QNpB%XxpY)2%Q3S4ZOEbFEG&W?|Nq5q{B;0dDYV1gpJ$3KKcq%!I5Huj#mcRR2k0}qEQATeR20C9~I3`|jQOwCbLFgUv1ui6XxDWlJoJS*?R4*_j z>b^*^;mVTP(Y+f@CP`G=$P#1cb}$DACyUdP*6t4vM=*^#Dvu!L~`Y%Ums+b zDpTzme^)K0Fs$3?H;gn_saaC8OUpnXXwH+tw3Fm2=gKr?GpJ7P43?c9Epu8v=4FhY z4B5^3KAQ|W%H?lz;+i5ilD;&RNXllDtmCW1Qp(IFL3f<~$HQo<=GeG!Ff@{D&2IhV zpv)Ut=|%oFy03Omqxtq*E3&WakCOSDZ!{E*(AB9-dI8z5=@8;(^1!4F_zL^TFt;ct zL<;1Dr881wMnS?n0iJ=Vs%Np5gcTB&qLtajP1;s5?VAcQpC#f$OBOnp~HJv0-#te8{faVUTp~m2XJ;B1F2FRucE);r`=#h~q>V_rTkKXS1 z(O5RxNu_W$sad0CJ_1jyV((P76?%FtQN6ZxrfVD9Hdua&XJ;vF@^Hbb92eKwyuxhG zs*oObvLDTqGXde~rQ)kpqsR9@5H zB(Se;aIIhH^TJ@`>^^nsAX1WzSt@<^kuE<(;)T~(D*gE#!SzpLrgGI5aBXGK`q?nn zA1s6Ymc&OR$+GDubUI(p?thWP55>^Dtd5L1gXk~rt)0w6AbrwU=WqKtPx`-wRzNln z=dX0368_>#KjGXdNAklDO_CBd)l+%=?RVeW4?p-Y zjo*z{++4DRRVKfpwJzX#?w~>7%@GERsF!zA#m;V48?$}6W%7t!QIL-iTQ5=hL4o*C zV_=rT=i5h08%Bad`88LG4safEX{aPt9E*72}#2Vkbdu1&Yw z-VP&+cz5pgP47T`SBxlA)^g}sjCrb}?J*C=s%ino&7oR#M{oV1G6NIgZopUEg2dT_ zwLY>iu%iUTk|)7lMKIQ0Q`s51l)`)jO^%j{!Y&D0mP`iMG*=LS&Eqe%e<_=DrMx!g zGi7h|p`)CcNsw3^@CQe*M3qdBEGDcHcR&SCO?#Hn;DDD|)8_keh1&?F&rE7k2M10IV*s z6hU0nE{R{GxJ$Sdw!Mpqlx-Vh|02YvAAgWOyfo>n!zY>~{h98GnYS_)srFi}tri(v z7v{Q^ALVx29Xf;W!mwYpRka4Ezrl}Kc^0oHxVBn8?&RD%8LgOS6>OU~xgd36Ka0`z zz2*Vfp1=Pd&*aMC7o!Oy9>4%36Ba-8Kmg+R$-_OkwUqq$LtRL};v^1`{r|voTx?(C`EQGh2o~k$dn+;zT2a0biHPpx zBD2|yY*+?w1T_J1d$aI} zZD9Fz>9+vbcjhYoU@yT~uc@^@z66%NF{}v~1z<q)N5*QMaqN#0^?JLmTw}_%J~Q?UBLH{ zaUfdv8AwSasg~>ANr`i;)kf;{yje3^7urPCSKHLKYumH}l3a9N*lPm2Q=yacbMkGj zza^xk?nzLPaW88@pBDjB$&HAo{N~abi!Qp8msNHV zE9T}f(T1fLNQN*mQa{+^nB0NS*y1?y?CiyzM2pS@0eN}(8P$NLoHLhIHLRPn#%~8! z$Ds;huc#xB2+L1YH-R@RdoH;^kpeePuGBlcXODhycifvpn)1TGmNj6F0h6pa=a`oM z+D@g3qNm5$r2C?ymdHrxvuJ%@&?@`D5N*@a&WpLj2>UV_loAo*@0+MDB4n|7eYV!B zoYAP1YQnf?a`G1S*hfM~XIT2fY!N>VM;v({OuEJ_0%iBDA zVuj8A)my-};VdwxxKBNZB;x>vmEgOOKq#A4UTazUD zskFI0gXJJGO*Xq)fk%*X+_x1gPnAJoE)pBInUKU z##2z!j+rr1P6c3r#W*BtJJ}dDh#01=9b=E%qXyfhN&~irNmH2UDye)y!scE9HvOy@ zD{x$(_w6MB8pCrR2^2oUVEVNqgYny?0gb1f64XQ78W1$@mEXyQgFWU?^x} zkOFI?0D2;@4&ufk+Vjb#f|!Y%NkR^7EF1v*&V-oKH1oGQpd%^cC2x<9@su(rZnGjdw&_*wzik{R{-@Y-L6t7>Id zHTp0j(0+klx5PyU=mT&q#mh@w^V!fBQF9~5OC1x=tS!$s>qN~?lNln@Dp0R>Q>xhU{e06x0t{2yM;(M$02?zu~ zG-6c=Ryo%Z%Re|*VN#H7L^UfC5o*S+jX98YyS0+!STT8Pt3KGR?0z0=vOGl93&Qk) z15Vv?fiTYji?2;c%;TVWKeI+~{nX>$Y`n16X^ow^axcw=d!4TAE3*W+Ca|spn7g=@ zg*m<0Wg)12F4NGpSTD(9j}~pmE#6mKFW}XQQwW*SmEGgN9T>_Ty^+Oeh~a{+0xKFi zeAilR>!Rz$&1B5+ATEu^NY$9fipOdH$Ee|!EMyXG#n?wIl}tq!f~&ET>{LoQ5a%-u z){?Z{WM(7_sSqSCDGkwU+2pIyx>-UaJr22j@oi2yg2IG$g2SMR$6}k`tILS=;-D@m z!p!nft=OARxff;j>m1A59vM?;t7ao5y)tA_bU0<8`!Peek zXDBJ&OCqRlBb44A&dbetesD{PHGV1rvT2R|Ww9I{6ONr@&|+GgAGjY2SEU6>1?O!^ z>OPJNu3t~R-D2^{YK@|pM|`nCq-}|)#2P%$C5}UfCxc&zk?Iq0V_!C%{)Jd%nw$3%D*J-Pe*LPl<@T8}9tR+qhxe`-(n)p{qOq=K&mo zk7G3$OFQOjNK=Ket^(AnFlZ51TaWOsl(}_6V3H?IwCzrX;Qa!gTXJ0IJwJxjMI4;T=D^oK4q(uk4}|FQ5IjIOM$VyPJp((9tf}TJN6|Gn;n;(J;@@by$S)baZ$}^k zh5`pmH>T*RE5_<&8mzD^w6&UPsTp9jw2Tf4pnVkYaJ8>9nZhY8Rmyr8owV_qFfzhh zN>~-)YHA;qsS{lwP2yxdqRy{!0rI|fqx@rkSTN{ zR$<5B{Jw44eVF>*HSO3lpK~E(KP#EWkN;HUX_18qfBkEdzA)y#{V16lmAGkgc1ddF zV)7#akpiv(v^;38>Np-7c4nZFg{41_wRG7Qj?XB($;Z^^j4=uGrZn`L~$@M_y+yAYpuKN$h7Awu}1O$Qn-y^1{}+O zcVt_!ubb5&4)ts6tGykjy+G#{`?8p7(*%|*Ioej(05aJNtei2vcrS~ANmM*e0#LF@ z#y&X7G*s6fz1&G#SGcOZTsvAVfJ@lkE`ZS1Irl_VZ*&2w4z|T2+WWDbVPRjL*1wf( zmU93^pCd~hb;YIMNdjV_`fBF9aQu<#heqjP9usbdNy(D>mb;T;g6%veEOE7`JPh)`+F%l;xB>dShm;i~hQt zFJ2Q|P*Ik}e-5u_2lG)*ASTGhS>!cGtIcvPvXUbk8SEk_paz>cQe>_rR5xp-nqx5t zC1!XgmtuM+5&2ir?t$MqpiVi&@Q;*typwS_L& zxmWb*jrN^67ZuWkMgi5Ywy)fsy;dT6ZQQHPrrL%>b!c7H)twYb8HWkf2q=LB#*F6I zGz~^@HIfpM0?q{4h?*iUipZO(Cb9q_PIz;|OxU}F74|3A7^6D1sm7>MOWkP)qyuok z@kYve2Vs%`98wrLMqs|gmxQ`y5dquR(e414ci=jP{UYApBzrhi>39?Z%kB>d1P@>z zFv9m^0ND&`VvJRY11O=fYN}CGl-~5-V@E4H+R9IEQTMj(fc2^g)FxOc4zoR3Tjo;& zQ+rCllgMU;r3PbC5|PNbNI@Nfo?Nl56fY=B>N2e)4&kXjOR6G)u>x&5na-R{o_$WT z5vII^q9uK2%tbqbBxfm8WW|zCfbyv>v8oS5l41%$l|eDXy#(DQ(Whki6F1ZTmQ!t= zQdTG`>n(_z3hjxSd0Ps&r%-D>2{1oYpa-+SLI|L`eiRP~^bgf?I=F6hVa1~I2sD@@ z$V@Da`kl4y@q-Ic%WE?|-We9BiZsPzqOUj@VWCNDzQ6a9DTPs()cB)uFA%|hjBa+- zTuH@EY_00Xt{&5&#ra}+N(x69__inh*qjb}{)dhf_m%1D#$qzyJ3VF1OvRe5zw*I8 z+Z0N`Tu;E8O!nX3?(NYKu*smZNINq&qL$I%UkSBG)&crlqHlO10uqyIga z=?^|gVq5q-Zs=RxCUdI{-A2CC$7HU_$`J(o0v9OrJ%*VCt}$F2FK>2TZT65;$kop_He4`{4-*BKm^NAceX~Tq4KX%-Q3uE`O4JGEe_EZIEgyz z$%?kMRDP+wI1;f)62fgbTyoPnsrv3*&NuMh7uH*s)_4UmNlPoeN36(+ zW4Co3x=pmNT$^st@3)Cv@~n53{q-_@$a6e&2r`IWA{Rg_Tp!CiFzSJYjDYM!-5n+V z7nQS1jy4#eK$Muh?bB;SB3_L*_68t(8*Tuu5x=Q}MAw!Gfn8(K^9KYz=|JTGtxHkw~Rc9v9n)^&bq^nJzGeC}n=($IfwH2PaByZ5Sl<@~|> zA1?t^tDPUKmH+W@tVST`Z2+;0r9PA1fr=XHKrrglsTUv{d|3m+n15bj=)h z)4SI8wy6QEL8moMkIA{WT|1f%;FGQ+H*sFf)S z#Nz$}_A(e7vp!PlK3J7h2;QxnG3NvrC#B^*ZL8CnEG<+>u?Q%GCbma$3p43mk(h8Y zYV-T+%r5&=ViE`3V72TrWhZC90BWBtv-nQTcBoXJ&7JXCUtFe~m?wAY#RX%cds1!~ z#n2BiV%C$KnmEJ-rpIoY{KYhk?}3?Kj(u2;F}B0dMG)0E!w6LX-5fU+mN4~Qe~g<+ ziiC5ah*_GqU!fm=`rORjh|akmoK%>6{iUHLpX#nNxTf6z=kc4yxXapT7E^DkA?&mt zo2ueTz)a6FA?U7!`84*Nz?i}GJq>k+C@0-(P)W!e6&!Oi@hF& zm>;(GwcXcuW*F|!SN2Jk_878RjdRsZ_A&a#`^#V+t&-g!07`J(@!Y!wb3SnFSGd|2 z=&eA=tnHp%L{2)E(ycM}Hq5X0P3otkX*I_{TuVBZJ=o`tUaFX~LORiAeW9MC;uedj zk3@kexdMZ|d5NEP31EFnC_ax39~rIBKnD9)xc~bMuGzkPp5CZ0Q^0i@E&c_-HQPIY z{jU9+YDFq?(w&yjPYI3B!S!cGD`XZXZ(hBwFxO1@B3tzu;A8mP>Ie>HSl6{{#`+kl zX6uhNxTE?I+G^k0nu+EH&qeAzsaJ@AmlXgtMs_E2jeu*LG);?XcVJn8!L7haTU8D9 z9nLgMW-D5$?nQttWtK+@0I8)DR}E(4gR#OGb!(Sq2cijp`?xoCe-tJt9%}@eJ9}`K zZXdg9J9TNhckXUyQokD$6DS#dCZN_V@L;O|C~l=%A{eh8yADw8M%%QwMR=B>$cDfg zqc$SgiC`N?#MZ2NLaj4`y)jkTWG(yPCdyv;>u9Yts@>B$Vw%BGmQF<0VypFBNEJ4t zxjLeI<`RIfvF4eT&^N-(GDy1cpcA;$)=goWBup!;nF59K4*X&(46;;dR93uLaq*;u z>@Z@mNO3g8bylnq$%1?D&rTJ#I>&FzK8P;@EwJe4tbvP>-qw-RS{(*VpHoxpk@_E33j|nkc>m~^n=yd7e)^t?UoTAjgdpuR zV?O;dPaF>ylkEG9;1s`WwvyDC9Ye0YfK-t@0j#_J$V!@GrcePWu|IR|Xhr8Pa*i-N zk>y9nGedm>(eA9xE=6?av$1xBHz&>c{@=fE@4b(!X_@EGnT;wVl7c1Wb7z+lm6BjZ zOU06H@RM4wzG62Brp*g`fBQ0;Up_JBuipajfA_6^$1b~wNHs?ulSlep1lMHeirWmG z5e`BP90KsR==xW-0xXICFr{HT09NDtq?@B34!s*E3P=FwuwbvT zLTcKncn(!(~i<2Cvz6#&!#RQR)X4YJaxdz6c2WIfq#%q6v}6^>NDp_b zcILj6C}!|#(@COb6f|SCm$?7Tgz}}+h^$6GEJ6~I;g{%#veDtx#r(-fI%MsImE=RG zG8K#gk`5r_3>UxAYCeUP{YQ7_!YC<&&GoWZ$2xv}r7+HkM3;yoznkh6S7c;( z*9PIw$N0O!Is6`fSlF7EJhtNIq6wLl)ku<|6M>Pu=P{Uy$5>)LZkboB4l=CHnc^#q z*T=*$n(O9EY^Ng$A>P^q;dfXu-`H;4rqyB#4uxfmSa855bR~D$R`GE+m=8A2Jl-U8 ziEgs&=(FD8SgtW1FA;=X#c5onX~f7+r&V15h)tg)hDLHkAPqc7ozN#-T-ib7C>LNi zwknIFJZf&q#~&wAoXlXCw#0G&A9!!`5-TrCuabJslSDf5I>hAs%{TZ6hR(ms*D51; z&$B#)jT&t8*_$$v(jJZ|KO@+F2e99@f7Pw%;2&z>$zq=}gTKhPXM03@vL-9wnpu#P zZq30pfyW1ip`}VzwbrooCcSUp|%H*>4%5~zz|%2IIz@A0=mNvFkzBl8jLtVH0gHN?nRba)d0=l z(>wt-*y+w60gw^yt8*HjWAYS0xCRZAmK`@@$2JN@*h&#UtS=-iKUORS-in1(t zmJgf~hODX{?OB&6K-G!It9lw+lOX|^m#p>8@`R9W_QFXQZBEQn2_O4HFGqjsDTT#S zA6V8Ll-lJoBi61?s-@<7C&>&Syg6^D(56yqlErGgRA&r$A9!D~)l>-~LQ5C#hPB)f z7g)q8uES(j)4?nz(2)JHUX5dQJs7Oqyj%5cw-}?}3{`Ushy#xot(nF<6PhPxy4b&n zWA`|G@JHCmy@2Z^EVeOka}7C98zl#Uy6ag|Dm52m;w%L2weAZXb_oB+xN6$hYZ6WJ{T)NL*$A=re5329I;_b;G`26IVS-Yo1 z$+uHfMBc>84B!&mrW&@#s-ds;HGn4zE&cc+nWvA>HoL#ex0m?7oABno^kCRF@6Dcg zZBEr3+3H+)hKXI%tfC|TpNe%KIt&2M#y4?q_R}%N;oxc?roo3EL%<09-naD>Lp1;y z9s%5sn`nkO;j1QGmt0h2UAh^QfLIX_YjT>r0N~|2@WgE;x8-$h6t+$(8Rjq)=-O7!{lo?(80*N@>`@2G8Bgh zP2?Q~`U(o(IOe~`&;$T#Z@}X8xU_q_1aPN4 zpNn4R40>>1;{CxKQ_~#Hbstq>A=gI&VG_WI_x4d2&3mgVJz9VvU+}W%qO0gpvJPq3 zm|?p}hiz*Px7H2=#!R}B;5#abN&#M@FR*elIw#_%O%ityd{M{#o;r2OnO~}m01;&t z23x9U&OkP0*vx5Iy;gR*HTTGDFpS4-{L*F(9EA>y!icBs>6O#!1Y_oq!(## z(p)*|1V5v(#5;ifuKlZTZ*gZStewrETGyygGHcEDnbE=;Y-Fx~E8%i`O7?mM;DKBE7)3~@~ptT}&?EJzVKxu#(rB|4pw05BA z)|e0yBx%u~Tqmqrl+pIWW_L4;?gXRMIM*qh@r#5ct0Gh8KP+N#9L;{)nfmo;x*LGt zZExHb;I}`DMHnKuX#$=fIr`BIJ07qTqg>dSs>x0Zi0*h>tK@^uB;NL5)2D<#VXg5Y zaPNIi2AYiKMg_4L)_K$!&1O=;g^^Nu*))pwR|zyLE4@(WzI`-qYB{tX*)CRb$F9odQ(zTzOUT* z31BYuGS$OG=*_8Wx(0wXx^8D=iHuGTApP8$1^44AtvQo`YysCF>f~ND*lg`19cS)U zb3~YXxW%?MNrE%u&Pu@8OX5Gm&XcGoiJ!m5@6=wk{} z_!WH_7x58YV836RyKR;3?f~eI_s3l4ix5lMa|AomDI(!c*9pxfPm=4Dbg?A>lq=dg zS$#>-InQ5Z)fUrcbv0AN%X#%~mIPSIC=e?zi56CwX(KkBWqjfFkBEhgu_^mDE4bGv zlX!KCM1>?n#X*|RLs?>Ig{9VH+Uzjm&i%Wv(kGBEV}lCA!R}i+oY0X3yBP`IOP|FG z1FP>|Y}YTb8o#bPb62;iWw|bni2&J6V#x?8f3ba0uTyAJ zxLca~#mf1^x-r9Y<9$~RzNxCluMqzoT?1IUVivvaF@sK^s7B#U_9#iNY=z&*w`3FpiKZ32kr8h9D^nw)?l&aukOKo^a9>ljc&Ab8X?POH4RaUP6Vl5 zPSCCTOiQLu%2JdBMNPMJX*R{@mW3d)UXdzsR^_<4Vq6s6r2q++*)Cdp(NgceezG!B z3r@a{CF{Y&iF0`V)v|9+8Ea3b8i(7{lh98zCiGyW4+!@s*N!oD086WBm^KrpOuSr= zp{0q{V)To}RBaldymq2(M0c?WtM^zC(xmI-MFcQrls7#gYdz)uo^{u!pZr+{XLF*a zREK5TWZiY?^JtHsK9+>W3wAq1kiigDX4f}^w=FvF0t|i=Y(U(F^RQR7Vk`hS_sBUL zU5=c8z$iO!?#n`)vV3~1(>akb%oLG@)y^7g1@l@y{d|HlfOh$QJZWchlH^+^ORt-Z z;yA&Oh~vq^;?;GF4xoCQ_1cf}XPJbNb=aqd@btGIiW{HU^wGxeZ=Po-k!;o1TqpC2 z=>`|LsP3GAc9UcMyx5rtNm&S4Y|->v)luUbK6ZxHe1ho~EI#EXp0^Q%yKQMihoy4W z(#!?850SGY%Tyxz(Aa>7;1OvJnjWqz>e`F-oU5}vA3?WMd%BafXStN?Jz(sJ0DWWV z(l9jX&@bd~yY~XHc`oQmAw@>OnpfsTDO2+;U^$Ah|9JtSr9E-&cmXg1hEFdQwpubA zc#R3jw#xh}=yo}QXtMja=4xGPQbBq_md zkarkpALX&^ZUCU$!HlckUM?Ck3SgD%w8YO`;#?p|VKz9s*Ta}Lj*pLKk?sJjcg9Uy z90n||!cj_^l8$BMjKXcM9f+gJy4U$qtx2&HWXIZQ=_AZWBK4`v5jZG^LR(3FA-bb8c8O8Gk?E?N$XxG} zkRbD6p-*oIvdmXl-GDs6n_pe#YL1U^orEY+H!ffo`MvbVe)UmO20Lr8CDG)ovkv>O zzBFff$O9I8E=>FN?EcRr6L|-)-?e`=?I(-;l%hVl8fw0V^u1qUX!|rLG14uB6IGFe zEb|l%aQ*%2d-t?@YG2-9{uyrkcNmV_m9K8YOFw2?$_iaCOegt_(GONrZkbmE@bG z06?mdu!ed~Ktj;mV}K@0eS?Yc)o7+4!GydVZQL1QwAKEO+F`I+G64ZVH$g#lU4Zpb zfVA97NSW4lV}>b-#RH^b8QFH(ckH)XUZ|WxZK|@922qMmwL0nKa^)b_u-ml^vd5<)4ZIV$ZrtsvKK~MPGvf4E9tFkX5Q0&u74yO=BzEU5y|Y2 zC%AonpB&=Zvr&vil)v$OqIKshPVQNUQM)Mt5X}=KYcu{sH#C|^%7)1+36QJ_m8h+ zjinVvSzYwM;O|do%r)C*0;1=0$9dxVjj^Zep(Hhar|h*9li4ERfpyh-p_Y7I=m5Xl z)*i>Mt}IBWsq+;mQ7SB{UbXj_>B3T;LG3AX^#s5*acmC6PrxJtI}xnTq$57d1WqR> zyDIn@qwVIuHR!(Zb8jeC9sv9DIZ1@e4+1FqlVCcx=_>!G=W^gr?efE9ZoakZ4)$%{ zKEU_)Igfyej(n~GINxCtt6QfskxQJjJ0ooP(J7s`%Tf`xwNYJm(uzIG_BB$KypJncm*l{3Is+*K zZK(#~R3;J$!83S{az!b#ZS#WGxsnqRZ8`69%KTYbZL-wF%sy)=laWYUF_Db9`XI{{ z=GTn?@FdK778&NxWci9v?lvdh%KKG6PKs7M?XB8xs-%QjHU#51Md_5IVdQYiewTgJ z+o6s!_}2omjmSXQzA>tI zB*1uX?DNa#_NjkrU;mHSzV5)|rX!}2svZ#=#T5_c`nX8$IYybgwGTI7uwO6SwCilu zgYdMRK+VE~@T&>P0H%G5v8BWCwY0~u1VF`eZYf}zCAvea#Ta@@H(ZCocnQXv>^7^G zy9}#+ElH&Gg0!u1y0fGUp$zBs&*8-G}xkvz6X~6zk z*=pZVPYwLX0x(QUA#E4WR#Wt?jbPWgALFQDV_~3U2G&8rzfNUm*2IF$nOB&UFG<*~-9p%K25mV>o#@OYGD%So&Sar&xi z!rX_61)$o4skW;jHnbOSN8han@7JK`I|MP7)6!9geHqq%5xX@(b_Uf1SZkT_lDSfz z=P~z#NpO>ZX(N)50w`d2l2=WCFm0E*ZVclB%yn1$ zx&=V$zhJ_a-fA(WN8=*AIQD>9i(xnIYXnAW-*wHZ1=em_5Yo0GH>{Br7OfHsw&Y!2$bbky zP$EjG=#-_Gj}P5EqDmxU08_r8(yfVgx0vc?5jGuG2k0eaLdW8e62j>)#-I|OP++~I zo3zu?FG)cyhmWM5DRL4kt1$-0_fob|`O|ucA*<4*VwRjtEUz9WW*Z6>F3*`PIXSKU zvM?k|OyVg@@yV3u z@5y-qW$u!SlFrx?l_BWo38Qa#Tr}2WPHJ}n3#!qy7an8cD8Aq|NR#S-AT*9yMO`L+767sk&`1Pg0>ryNIv|K2(G)P z2Or76F`u6-!M1Zj3cr+@jHEbO=6cQ z6A{%R(TOZ%3XM%ul};0v9I7B+7^B*G1s*#A%-HAZC?;H?$O7_rsq28V7|fG`Y_gAD z;=BH&9o6RNth85aFn&h%fDVE=pG&;WIO3n;&NN4)Zn~mP0fOu`m6P~cL~hlT>Lw@? z8-ymE5}2flW&<<{X{oar4eJQ`i=j_`1l$>;znG@YYHAi0;@8)~0kiU_D5`jWS`d{mxL8pa7eFnIP;F(ORE%$whDd>)A!~g_$$g=VCHV?*C-6 z3v=Cox1YTlXFlx|rHvAZP2ja+NM*!{4DpNq zfg%vkiI0^O0(I?_V!NtSgKds1^0I2;5m0j&*W*B0^?I7@atk2*3P-)h@=xehr@rHr z6KMoSwz{ru-I3_7tmv=3f8zvUp-I7V!lEzKt7-yrpVq6({?XMMv6nUCaXw80w^pNuy?r!GcB`Lu5c=nley3$Pw1ib~WPQ}R zv@Xw&rJAEISjcjp?7U|QwfBNJ^P}v=-H@4!lK2^|%7&&!Joj9+2YbE&pnfgVHd&Ac zFxZDaScEMo`@=zvEL)Gk6JL%a1{7R zW@jz;ElZsuT0sLWjQ>(h7@Q7(y>&-SbJz_+YL3*gMd)*FZGYL?{;Kl*6#}-4$xTf! z)fq*9EhW;YGaq1bmp3;liCl_*B!}*Rnk!y+cljE~RUBU$byTJN>Jrx&E2>_6pQ|{2 zn9fNciQz;{WG@7llj_7k1FFxRT7rr+=9 zSyN3`h``}*@b|yt{{NbF)of($ie*nN6Q%&`R{$Bi;nrV*)H!~4tfscEQa@C&icRQu zt#9$n;ck)4>tz$Ri!{7m*lF9uVPD16yO;)HNi_jZ!`z`(TQ{8sRIVXK0GOR$cUHK- z0BG7_5XS(zF-r_WdjRY;n1W+EFk@dpEoq`7KbvTo)zLO<+$YD8uHRcA^TdH~kHFj> zta)3vlocnsdvp!x$m(FgN8+|mDhr|Bne0vRh>luijetH>_IPL$Kz;(iN2qrNu%S+6XT9zbe&x__+PHRC<8a0OE7ng7<@-5$(tyEFsi;bGAjaDr&ubs@z_OjSl~d z)FV_H0IJ)~qY1N1a4YnhD#Z2G{|%V+HeD<(BBzWi*5+=6oi^93DA#AEx>OM@8fz9u z*ysDD;wZtj`dq6U&es`ae}QdkK7w8+Wzo5rT?6!WzcfzR)dRqF1?IZ%5sYrJp3X5GfN3GOSt^|CrFhTp~Je#@IRX09OYiv4Cuu?QPhYYzgLhBT23m=kvV(C(#*IW zq^hHW>}%??zcXi#-j`V>Vpwk+M--rXxTZHY&NTqUX3d1hyfZr^rKGvQDpHYA76(x> z;E2KcWH)U+^{q{7Y;162oTS#Oo3`~0-nRhWsu0AoOwEk4CZ(qh`oBkH%{R4m9j>0X z=&$S9Nz9e?g2>Z&i#2WqQorlLTK8ss+1HOQ#@k~XHmn4Blze6`R;`pD$C;hxhc+tk zmiq-vWWF?K#UU*tj`x1Vq+@TOVo504cu{Mf==PJ5`zfg zNpN7XWeKk7m7-ZLwL}S+RuAHwqrIK5FVp08@qw>v>S|h5uD@#S@w#?LjNJp`wiBYi zvBlR)G)UY-lIm*4T)nX*IKGE{;59a9uz8IgqAp}#kjbV8_LYL`$41K+T+GIy>u(ik zvu%HCmBs!_i^rYx*K7~8*U#=hlsqnuoj)^+{SIKiYyV>HUWw<@J~3yDz0b}4WW{p7 zHCo>MBLNlq{H<{>UmH>@6!zKe;no3jx@O@K^6CLWLltj_217Au=%Hb*hXI|7D|+Eg zznF$2&5B!=S&jq7yO8Ta9|K~ ze`@Gl5b$jFacfzASlC_ir$ASA_)#vw`~p|2LUZs^z%BngpVnwENt zX0HI-0ZECqUZ3aGz>4aUK`ssfJskp;X@6V-5Vs;{QD~~-3~IAMnOH6ooH#-7cO}6~ z0=IKT9|;u$f{sAFBNi3=aV6W+@O>?n4r>y|EO8vwt$-}I=~@7eg=Fh-S~PY~i3`49 z%4LO(V&($GsNakV{JD=ZLM59@kqT{IBp)ej3{7DwvF||&!L*T8;0dEnA7<1Hp{@1tYr3-OPa|`a1F|}shE2_ zkbuRn4`MBLL6$GeLk{1aKl@_=`18i_!guGA=>!5Va6L+4jQ7ON$EQ8Dq3R>zoEU*!G>4y5HpL`2~yO}_4##GZ5 zQA(w&AGf%Mv{D9Kk{rv0rQZ`(jl+HotPGaG4_)7+^$~EMeli0V@Z;*qI;7hw9+s16 zr@57ZWzFLxV<9&??$1X>pI0CcQd7Wn<*LN{Q}#~|`8|BE%|IR9&8i0xD+QMK1x*4M zG|KCkV~+xMk%(B0dl|*!^t+dRVsc^u-H6hW(G^3@aDp~;=0`M<06%FWv>XwxnmNGn zW%-RkIy0-X1aSo{EWFA`3M;QnJE5`uZUXdTmoEUF364pnEotr`!|$Y}>0aDF&}XPn zGok^Xu-L+ncV@_Pxe$d_U=#s^lQ^a*=v7uv`i^rvkBde>Hw21$Vjeo>DALqsC z*TCcpYku{KUi6=R2BP6heeXkw$QMS>+Osozt;^eYE+X&RA4aopJT6vyU**~1x42#A z_f?s(tF!#$*4UT-1FP|caknHvdZ~zOf23>D?gB9G;L~ovELBTUn6W-Km^sAIdQtgy z@S$xh*SX2H7{?k1e0`WEC)xUBTEJTdGQd-dVTqofPD+3Kz?t)C=uItL2Cd+*fe~PJ zBtJ-&y9-)k%PGTMvjjR5S3+yDvQBi=b-GP{+6lmwDr=no!SK?8DflL_K18)hX`~Wk zqz|k~T{DP&j~eW@Wj4HiPWC0Jn;!Mm`(a_X0M~cNwdwnn0$%EjL6Nkci9WRFo5Zfq z2Q}Rj!*N8BNnH1mcvy=(WDN2g81PB9z2ka7q%t+e&`!WZU1#>3%#ReJKx{FHp_Gw7 zx2In+ARbt3x^%FJjb!1+T*#qPP%UMPjf6zC*o$GA9?Nt@N9w0i=U4)FFbYt67HJgGZ}D=vS<|F#!lnb+-inf_WSunn^%yy9`Xwq3QSGK*I%W zuWI9}TEx31##OtDuK|euWO1oZ9}5A3I#_>*+ix>VO_21GKr2fQ2nxQ=+UobsS$_Bs zShJnA*6ALgKHu7tJmjnV>_2ucPDwyV+$8YoZW`DP=$yPS*%$A>u z)cE}Qdu(?S_3LT>lRjqwu5sCzral719)Xa05Xr2}jSJ>7$hqkAcU%W$qHi&ovn2Dw zc0IG|qYj(r?eY6y=U%JL7@IKJpyw{hPIECyD%=^d?5i@9S3ve85e_S}?<5zUm2AQ? z9`ZK_sn{KxRO6sDW9X}DsO!nLEJ4+P3mu4*yp%?#31*f9cu8u^&LL`4Es|&f*%AEe zP!DF;_2$qV{oV5D$FBDPkU($0;c~oMtjCc<nYF$*HfP28b zluM@zu%A?7lH4i{=_rc0B;&A{TAvtqk)aF(`3+71#yW9G%CeCedri3uB43ukUVC(9 zNv=mnz+TT7d?pmq8-$7L%C=MfB4mWH^D37q+av&wSr)^m@f(P333D;81X+Akbb_K6 zX~S)J*@k>xoPg>Kt_y>YNI|6wfYa5+LOK;;W01@h*?-wjS>)!t8qH$SOV-I%!ZnMy ztw`}046;%az{CaCNNsC}M~&?-8+UjDko~BN$BU*OK~+R%2z1n1OR^$u_A-M#FDw&s zRQ{V+zWtYPTM(PVV3%T8XZHGMX0U(euY1@2(Axv25PMHcTA1sHc4PCxFl>D8VEpSl zV}H0aPNwk2byc621g|p6V`| zt^wO!#Uz6+Gx+0DbkAC}-~cB4784eLx*ep%xaQGl0-%x#rjT) z!W;!$*A4)?;-oW)9(sH9c7Ir;o13+{*>B9gUkbP;R;{yrUn^3VK9?3B#lwmu2*+r3 z76JUnXIKl2-%In73CCsvJJGY+dbq?G3iRkM4S*`Zel$yhes|;^lPuN@d^%BM>RK-G zdbLZ-#lfn>$tZsyOlQfWht%jmGF6RsxQ> z^$X?#g)8->rh&xO3{+E3U8G6h;JH6GTF2vCV+C*z8B{N%ziQ&*m-+qAXMpv70^=Ew zJ_NPpdf_7U{R~xSrOJOYR#Sr@fq&*VJ@9$E5y*mhDNB8E>=J^T&^LKTbgVBTclT9=kBu zl!;J>O(wfkb!1!JSdob=?yLZW1rC2lu(;FLS!{+No41Y4V_JT^v}*H5_=Tkq9(z7N zIMABQLv^Tr4?^IZ<-YwtHlus7vh}_T?$EGEY`v^aw^`ZtdX<)sE)l0#Fws$u1;I6q z-AA18sT<6`J(~UEU|xW^e!V=J?bT=wn^7zwxfqT{r7Y~@0N6udf@Uw?%QS5s2EN#+ z1U&<7mdn=rTv-ZTk0<+lD%sZ*l*>o645_J_BA2p*(QOjQGd;V`!2IM7S}VjSa2^JI z()z}7UmEO_fcU09IiE~%%n=bV*CRhSS^@7unrG<@$1?)_Nz@(8t_>*9`mg;1q}_K7CZ6YU_U{66I_rTCc3@)_>ilr`R&iI1_!Qj9&xZ7_II zBNtS1eQz-kC80D}GS{MGFO?QaTo`4vm8n-YJZb@{lhLU${*5?LP{W@{Ss)0Uu&CUy z>dg4K^ZjKV`^Q}Z&`#58AIDZp21Etc2aE`<*o&S(HX9l2!d@TCZOl$0)L?^Qprop~ z#Gqu?Hz;tuKg_SfgI#MxF{g|Go!^}J~dhxhCt=x9Qv+gvKL*oyEX3nSH=%p<6pdx zauM|{*3Yci5D`qp!0HnJ*?YCJfz20EkSb%^SrQRG$yrgg)DEYXwI!JjL{;NNWhApaF~23(PTntu7;ySsJ*D+} z!kG#U*lFLqhY0%)@Vkw>RRB}d#>IQ@YXTy`^bP(l*O%#4c$Ka%uhYvPUjigOQb4^i z)JroXUV$^gG#TpumisXQ^=Z(~^i^oXw0H4QZb=&F*?=vkDRUWzeqsU%AkpnYPnm5HVU2%SK}X>aD> z`n-Sl61}A7=KYnpc~Uh}X2snDhY(?@^9JJEoJMBsxto2(y_GSXXWDGvH_7>w#7DDFlHu2Q zDrKv%$3k+Ps^Ds>UMmurvCgN36$NwIJ)CBWu=M$c ztUi7dP2CDZeUu;CV4>L$t$s>XLp&B%w#n~dEp`HNZo2}A&!<2XI3`1Iuh9&-^@{b( zLX-TO1%Q4(aAhT*698zXv@hwysY<&z|Zo7nd_ zIO3YRzzqF2h>H{%-S^Xj@sXBcIebTO{VV*QzkJIW`jFdxrtgu8WaeFx)&SPn)vNyz7G)bx2zrxU_Ajyzj$uy9r!6+w+&cpu7_J^rwYt#4WgJ@ z16u3>mDN?n`hJ8BEY6Gjxn?=jK4@r)F{nkze1R!zwBnJVnlv%Z=Rn0|KtjaYpCANP ztBF+$d$PNRGK`gZ1*Z7!NCyqf?wDULNdh9AMt7^KVvvu?{~W+Ms(?7FsZSaZ=}D2xzg$bcUT zX1StfF(xU>h}W2`$VA9~R6u?_(!#HirG$EWZ|IPc2zy5^KKhZ#I8tDui2%!WE9Kyw z4EimIVKU?uzkw!Vsm{Qt#hrHHWM9bCdFzw1EJ8yAUH}_2lgCnQLu=|OvgmxEyO*g! zU{9DKnh{(#_5utwz%x$pKphT?jR8keGc%=7PeavAP1Q|tu@1}SW!T4U>cLL?=1HuF zn;0;LKN;5108EQu`tm9gOh3AOBna?&x)w0~Xz@q^HQQg~_Je%?8)Jm|{1|`#6+Ma8 zdN=vCd1078H*e~y=U&!d%)lnDhlk8cUX-TUb)!d<*U60QUOPR-{yZzvFtX zs~V;{da2DKXNXe)|dkXX#1ylNM_mF)kLk1|S&+^snB#dW*4Ohsl2{@q-t_#|McpJiG!a$ zd?3`a&r0ZAyzT%L$lTGIER;ybUf(RuSA)?E{*NT1gcBV15u$WbeDl$+Q%fd+)4a4!lxhJ^BHhilQ z9WQ4j31}k{ImoGYWW?v)rt!rxFeL%w0O1YYC7TSeGbrw{#IA)FIe*`}BGbNAAl*<6 z1Q71k|C;PKZ^^8$_#L4-%U?9>TNW0}^+G{;hT{C3JX%YE8DZu}0n!*HU6)4IyO-aF zg$1HvqaRK%PsnMq?Z?qJ!pvtax@#=i^CG7k1!OmJp9OzZuRaQ!P6mXaymECCIZV{o zr;u54g7s69Yn65IRwRbSkV(FZm&+ttaoUDRIrLSFMcg!mi^hdZ;PDl~^)W?8VKq5B zOF~!@9gLQ_iE7O!NxT_c&l^GZF}sJf7xr+k$QFhP`QzR3`8fpiL-%bMzsifv2N5jfGt$ zBu?z?IJPooYAp-(T1kj8tl}l|3%+xtvNtKn6{nD-g#(U&>b>o%JAlTmICrp(lu>}~ z-2%|wPK&gSEwjk&+6kmFcxYpXm^EwVIRf9#XyI@pVQoq&=$%a_R|~?>5^og1ERqz} zr3mXS5(*~#fu-(Q02u3rh&U&PMkCe408+(c~0sNUPO2@HvvNM(#U zDIE~%>X5pyXR7)*K-u;#!I8Q3RS4Kjg=Ya?47FiU&z^Kl50jEF-QcnV#Ixd04x zB^p$&NN~Ry5UaqU-Ap2r^mSY|t7%sUFfotl3zMox%h23j3W^7rjZ?qW!S%^ye1Y)C z*JzG0G~K7R(&MqVRB{hy42;Svm7CUuCvBmqLH?$k39{N|)L(U)4w{38M<%3dP zw;->~w9RCjv5lyl4*;8~TYaA_#7>^l_W^VE1vY13om3BPvbK9B)OO{yH=?s=cDmd- zRe%iXbc~!<$rDct`HujbOl_RxqLDz19}s-$5wbrR`f%2MXAV)+);HD7bh*EbI7U$t zv7`^+_U}I8Ldlr_FDCFGe`^I)OSQP)>hGYV2v<}qyic^)SubjySaA}O%8oB}zqhOm z35xD9i_aJ%;qVK*?G{f@V6<8B(JT>b+o@E9A@NqM51oJ(S$t*?6IkY*byzU00m0Q> z6%NA+S7wF2UkBHKGp@u+hvjFC5v@oSE7zzj1d$y-;o=NUZ*H2S*)9&|muSoQYR_f0COBu+MC{=}WhfYPbT3+a%j;$qcv6`7PY+fdGAV zzJ9RpO~S~P8F^8&sK1~l))oZnRT^V-g=)-+xvxdKzb@P31#5e=_8H{!{anammm5&_ zL&d&a+ta>Rt!}4LNymw%=P_7Tc9;fxI&Oi0x)<#VV-w(d|jVdk_|0QhXMy8a&dfi7SZ;#whzHC`lzZ#7I9b?EJ}7&yi*?MG^=og`CEfUm4;Gg3}6bwIR> zL@lP&reQ|9Yel;2)mT-)5ch~0?j^u-$3iz=4fn93Am^MUU$S=M!`gZU*Ifr-y&yiO zBVH2Dy0sN{0gQC4=voLIO^HXEJiv8bFGg_f6M-~v`hdm3SUGIK@+8styJc~g(J!!a za!f{~ei~d&yNHh$V5h@nT65Cc8r7BZ@H8{PjOQQ!*<)a*Zxi($PyhU>K2K))iP1TI zm`(h1w-3I~DCL*hwFOvfpVnl?+~tjtg`__I!ps!``rLi-$314{EsIgrUT^Xn3UjCq z*7`?r)IqwJ7gHD-it&tzo!6MbZBX!=Bo0IJRDn0&Sqj65E#8iy`MZx5SiT34{g>30PO+ckr$h&dTg@UjFQ}00nP=_99MccR=W&l@uLThUP)z0 z)Meum1h%q5x)C&v0M7=|nX~|mBrEMFOB+e*s45T4UGaKPlCs9?GpX&(-Vfo>9Q<~5 zFh4Gi=EoI|#djuBKi)ejdc*6QfSQ~`RTgI{GtWjnzEw3B z%{cpbSEM8W+68QrrPqW{DoDb$sXZWNiWH_1CnfFz~Ga;v~awtBk+q zE>Jq-Wm`EaAkhIU+bOJeDAz?R`_@XR`1@_pV4yWS80gNqO?Ja%%>2mmcuMPuJ!Ug?J;82Kiu{yvGux-DAkiB%d~O={!` zj#wBS@}&_a%Az~SZv7+fUuAg+8^@~y7Mj(qE>1;zjq8HZ9<~?y(`8Iu=AZjF;U_@w zCj{13e6UM<{w2o6&))w2XQ$=4UT4<)?Cl-Ee%F5frhq}i=3ikbnuF^fjeBu4etU-@ z=xFnwt zg=rA<_oK{8nwLgKnnhydqpai2VvtqJ4g$i1YtkZq-PoGDu#|+%-9Swr5DGZIfrF?N zn`sj@)dc4!LkRA3%(+2M6A{@OH`%TpQdf`p7=pB$YAr!dDM?P72cedH3&NzGBAxZx z%233EJ+xB~aP56Hjf0W>_A8>C)~5K(i%@v=o+_ zkDs50Yz1iZWW`_KAI@bKOv%16$p1rw{ulZ7NsjW+lJ7m-zBH#&=)zuC#_e~qHX{oL zzPIs}I9mq=){>|gRJSV5#=JU$CN72&YIhj~j%RYqIR#dyg`PP(i<}b^=TZ)Pk_gNz zH9l-<7D`U%?J@Eph3TCr?voe0#VOFwTmbb%v$Hgz!%P*}TyN^mH*?!A0mh@qnr1&vtoDS%kzpMQhbE-#Ha6ebwJnzm&(h^BNq^?>IR zlb<^u)3YYK$7ZV)xXl)*x*40$jIpc6=n>9u0J1%}1v1yJLENbU^ag9)gI3)y4(Tn$ZA@Ac+xF`8R|?-46w_V+#mO{SW9mq42+gJgOp|`4I ziDy=w`z0D>_OtwCn_2Bb*yFbKz1lx2@CI1!ZFBEfrHtP4G*YQChp_G;FGMicH4Sb7 zo_kqX9A&N65PSzcZwY)-qjuXE0}mLV+w+)NjxEoFo-gc2TM`;`LhOvS?lZV9ZDaJ@ zQ5rJuC%iVAr0lhMjo+EaYkKqDem6Up9~mi+{Cmgc9L*7iB6^FAj}fmt}?yffVrlh$jaOR=nl4`tD9}Hz+WwTyWRj$ zc9N+CvME&It+aO-2DU8MXj)_K@;S~Kf9Zv9z0Gh?s zqh|sowR>JP)yM3B!smOupR6@C)^~I}8Q1dq#Bu-7v$`4`zp!wik%4FWlza~tCZg=- z@20#n7qM|h9YB=5)=6Td^KX#G=4Eu3p-VnBZd+`);7yNB3SCh)#H#lu2E9D1WWzu#jAAv$JcU_ zpNHoOeaSreU!Ft>BNhhwgBj4KZkihZxes+|pU&=onQx2R$4`|iS1Cv-0CRqil~X3V zzyF!Ada{=|fk1V34U7O@ z5S_$M**&Br6qcIRTCr2L*v@FOK`jv%kt#%n;%hTwCFj6-#=+;N-q!iSJ-3o1-xIVp zAg@?iot0!otJP_Yn8pNvY9=fyQ;i5t5TQ6bUKB5D1h3G0(2t~ujKxZiWn>7rk8!#| zaO83U)_O2%skqBKM5gkPBO7dA)A9>p=dYMF_38OHydvoS#EATc?P*i)P*W<>e;U%QYbRa`5B&VD{a?++7^T=j*<{x$MoW%hB|> zkZ)5wEj&X8P*V=)7bdy@)-k=M-{MUJz*a-1c%e^C*?w@|%5#f+f8GdyO=te$Ms(Jh zllGhU%f|>_eS7{+0`BAS?VBN}B|zkU6d+ypcbYNX<$gGx_m@=)%i>}*=lc)$asC_2 zwyOMH_rQE!T+H`Po;T_ASO9$92ZDH69Q0?VTi9q@EZ} zbY!E02n##SD>zbv{V}CmUng-8VHL;k^7j{zJ$Dd!X70)F9=3Nr*zelEtCscG=O;eT zlQKZVZvn19Cb+&c?)T44z272$^x8DLmjKhiaQpqZ8jf}mCOqzK#}a&)87On}G!3P~ zm^J^bxG2Tbd`=KB=W$ zRICBqStArH;d7!*r{1%5MTy#ulmlhl)=>^YoiX;bYY19N1$KffL@Q|&up^8ojBt$E z-;_v$={Z5liqi;%lza-F_#qOiVcR*fS(_Zy2mGcMd>@6*9L07Uv}Glk^GS+D*4cwv zisiJD3CGXX#9V+hZB;9qLAN{XMVqVUv2NHzjkxIZjL2o^$Z8Q928Iz2ZFwT*Ff?PBoW4MT9Ful(4x%kdanNldH)*zCmx zLI43Qb%^}{KEHWCKAy4E0*+{xhw$R{``1yKXp^3Bq`olfd_m^9SmjMKK+U1|Z3&5N zzd3>FqNlc}{`%|uo-f`qaOdY_;GV-x`+q*6<_&4cxniR^MR`i&1Pj)BQ*YcM94gk) zwvM?|IO%q(f$e=@UCnv!B>P)cN|0xnhB!{l{IHAT%}Fkjok3zO7Jld76n=uFk#hI^ zOj4#hMtwX=S5HzvhIy{SgVC|qPpq?zMF9djj-`HBLy&r+Ng+qaYK+r>4ncc`#;(Vj zpBFdBIGMT&IG^FBxe1pM|1ktqO4AP}oq#nP`_HSdk^t)86IFh#zmLgNzX#Z682VNI z9V~7kOg5*=kBnAz^cgtw$AQH@K39-U>%I-1qjjGEi3?@338V`7tQO3Cb+hl5-xnpWo<>bxtc|3f|=^x$L=`o z+C#Nl@6C_z9qJ#O(HwB>_IRC)bpS{T#~dyf-L=Yb$UcuVHd1cQeVG(n)2W|%B*@Nv zl@W)S`zPBzZ+kF>WmW0RGXG8yO(%7nXdZy?Inb@~U7}6Tw?_Y$g#armE@tnS*V{8V zE{gyHWd2%efa|1IeSGf;-&4+T%s@7W7zoydwHI(~?&V>Wlm%+{Ne zuDg78zMay_^}%D5;#q4gRealY0@ggQ!)!tqM&6vY*V5RbHhRlk1d+(lH~u2O&b);y zBgLA`BP_%y(VG=~BDmgs1TrMM`UtqjwJB8`@%LAEv-kdn(I+i3^URnxsyNQtI}P@` z_V254OtdrowE_oQKtly^{r&HZ|8F2kZ+~yviRmvpv)Jv%6)37@>g&ZAf+#d72VuS+ z#<)t$z0&Da{5X*UT1YaNS^}cb)}ej#gYXC{|?-9e`?bLt;{1MA$U| zmbgwC=fDlsWUa-E8K4|9JJsG;@e_ov0XCOX*FlpJ3507}@6}#LJDq(pz~wePE8%Ei z7e9s+gpDqid!k)Vgl5Nhw4E@0vYNOzFlezhJkRXOVQ%E8Z*tCL@ve#rl$6i{Hcavqa?!F*i_|9lFVmyT%tF40=e{?p*Q6r^B_A*I{gZ}x@kNVfMp8NHw z&)x&p_Y!t9mwQiF{ZH8_NBOgyqmU(3lKk69K*nx|tzVmUwHO!8khUFGMa#*>B`&!Z z{ic!97gD@Fw;V}1Nj)C=TZ@&XkpwGwD1Dgv>~YqYio=K3ewIwi36CWyQMyOgY^MUE zLyyH0UF)gwtf&)aek)ZUEFjGagEmN-COPM7iQ9qbU&@;c&_;J{_~q3SV6sIA-lgrU z6xWZ|w2K$N$IIqY;2%vtKtRV+0_vQ#X8UuNS(%m zI-GL3w4A*j-(bnD0CrgvCrzSLjdcNkIeoWHBBhq7$_&77F&|to@sg4Uu?)0gL)et^ z^tqLL!rHe?!ZRo4mwb&%Al|&1*+W=`ii(AC#=@7y37;CMZ#VWHh-V`(rSwIf5??}q^JKV|ocyk|x$nQ?W)6R_kDk9CF79{>mp|J2pE1xjyM)z~d#?`sQy z-3?t=4b#F+%dW-Oj#JgH@Z+0Mt){TLd>rm>Qrc88Fp|Ex-o%#xzK@Ep^v5PXsvjvx zdMYfaGS%N0BL!gQrN@=EO!wp-Yy{VHxd++ll92et>0S4=dUH^nZJthy^qigkCmOg< zQYr=Xy5-L$(xa_1n*4j0_1Ekow>Y~i!0pO(_Smj$3M+KqWxzUzPF}&O>7DH3+nhk= zMPvj}L{u9+HKS#vOC~zuN}7B{$%mU*<;T$E zfI<&Ts-3XOPnvwzOlItZS;cUi@IHW}t^zylCor(9$mCwRVAs58S1k0cz!{^%^SkFM z{utBK&C@7=__sNVLF>KuO%%BYC+0t&{r*q0ETn{-f63dgtgzd^GOEiijaqCr>b742 z!5;y#Klo0M%abPx6z-fd*)MRP;DIdNNcQ?tkGn`mZnei;+$zymqaPjdIo9Clcli|U zm?4eN9k`f^0^EC06`&aWz8&3ib(G))OGw$JN!tZj?e)PN!Cb#s^!4uj!TgA&G?{BA z?1?M{C~{g5Tq|%Q8(WGr5>RptP!bQjtf?(hhrSfkN$OurhL*3b^5+X%9rNpL2C4n* z_?1eQcLi`hV6RKH%f{MM(UR?K`^K0P(0E!8)s3i!n{kg>wxDM zX8>tV-=E}t5@~3YfN*-LKV;ww_~do6fAHFrneDnT?nXg$Y2+cMRYlvD!8I4tQBbLZ z#+Y^3!peqoayzfGid6z>Hw&~Iyvj`L7Knf0Eo;IOL`N_n0FcRp1mZkMFfCOIX+4Lr zNXmmdVabz#^D+nu5TPbI(EUujqAB)psb)bLx^UyOK&CO{a5C)7CmZA0-HZ|t0^Xp% zn0{KdDFzNkGN8B}artPg#NG#K?c_4LGUG<~qcezg546)Gc(#ZWIT36KixV+Po) zmrf)A&n_cix^*rDP8@^mGGRGLBgcz(L#W;S~79)=cG+Ab$J5a zIstGp{UkG`UuBk>K>E&Dx&7RnlMxF;tpGOWXv*I^*v<#zx!mKQ+DmojvXgQWaM50$ z9d_6VvYA`Q>jylq>s&P)hXsK325Y=E!Y0cQhoxuz4l98aFA27c^l&B0Gv|CxP$X{i z_B0{6Y||Hl-m?EkBBa{PEwAP3lVnLeTj2r_CzvpKf@H7iUe>Dj6`h^2Mys)kM9744 z`>Jwd1*Uo!0k*>sQd>{xmZ5cFGEK)4;_McZ_`5e#EOR7_c0x@em^G`o!nZ?Zm;M!#dTN3HaOg^1;y4&)e4A^?w zX7285p&tVCdHX5ELYBLL{Q~d{5aja@b>MUMeSUvh=sq+Vu%CW=X6%QPbi%$BxR=j} zzBx1TRR-rnx-YLFc%5q}2_~Caw0ECxV`jYx=ua{mqmzIXC@RO!C~&UdqA@RRoXI#u zC64+&kk8A*J|UmOzCF)=*F2nLusJuDvgjqTk*&STeebz0N+(2t zzQYCGB9AL+^)m}A#g6J1aPFdIw7^kk};Sr0xgNWe5% zAxnu9DD40c9>dJ>;!L#@ z0%um19L1T9rQd;SsFTg~KVKJ!jRAk0q{MLI>v5z*nu}9l;ffY-i!4iyP(7@*~f)Z;4){a3!v7VG%Y<|WFMblTg6+wfNQqTv&%*XU>G`$tKL@Q;KfN~E%0$@9f+1D;=TQ3pYAsk!Kvyjmqqt)XST$kp2 zk#m^yQZ3Arg3h<0u-9S6ov9(axT>S?87rbKUSI>jI+s8_Iu5gkS;GR;Y@*rOSamF4 zzL@%{$69L{8h6CfnT{515Ea$kE(t3QNWQ2RN#sH3Cu_2*kBw@#=T0wwl6UDFqm>aU z8zTOl>#WIm%^B+gu0N4Qmyu)hNq+6?86&2^`~3J9zqJ`;W5UM>b%*PcVeAj_49l!j zE<%vaq(^%B5-77W-CZuC!7|Y1Du>6f%xT+AW{sCX5mH|LCJFP`kqN-I-pkG+%|d~( zU6{kJ4mNGZYT;}>+~V>aya)J37-KT8m!p~98_g@Q0sRGroqd)rWX2LZDd5v21$F{t z0qEmSZQa5eBXOpT3o}^^pSR}Cc6(ztLc3GP&3-gp7=KU$KM{D=z?dh@6g+ZSI`!4^UHgAiK&)??h+)$}*TU;4Z&Aq_ftVjmMtJjGPNuOhzpEvMfZideQB#bn$Zn*YPc5 z>~~B=H&W=_Nq*6F|@B zuPeX4s?ydp{bbtVU>4(HTpb6y0*+oJFmoZXoFJm0h&|Ja&j!}{v)N}f^To`F5-boh z6HCB2O2HPB6C6cD?CBIjcaTvXLfRuDin!lRb_r1w1C8?+wL>24I{s53ox00d4nxUDd0c2)u#|iJiF5%!-!=j~eiXED&85XN{ zsMlfa+G<>_Yk;d5+Ka?uzv2Bq`2|(C=@Rfyq#qljrBJSL`@K247I4rn)e`?JZvS6K zbOzS>H7&*pP9GjlZ%e}XDpffQ*Gw3BM(pazr^V-{_xA} z8q#Jdt7*CfhGrRujdw;X8QF+XL(Fy*vVgNi0%X+9!<-%$yFR&3 z0H2v8?`ObTD?YLwxs`s8hzVUlDy-TPE|lcJF^j=FChRqgqcyk0p`y?2=GGgvx~GiD%y!?IVAhLG`c9ec$pT4WcCI zli8g2o4gwP)e~#}^0RZ;ne14Z$SD$RoxZDRo&wDBmkMYBB&!i+nnFIn1BYv)tbrGtV3QhZ+lrbh)Tdok7!> zAD8Nt<5nMwem#GVfQ*fGu3>W$Y%^JyNshe(@&>}RLH`w8di=Vz~tc?YonRks2F=DMMW z8lpEq^?ilq`O!HXe*Ya0+w9en%=Lyl!asKZ@!4PF2Ym9mv0o!h|H)?vev(7m8vl50 zaAT@9sn9`!Ne#lt&8{C8(>N_+9~N#H7vnT8FyA+0m>QP-v&jJtSWU;1>B41j9Yu~| zg33aI2%G8W+l#Z8_j^+V3$)$QtZ-W|4t5EEy<7~56$V>6f6xJ=4#K0?_)@Uf)_dCd z#Rp8aQ3g-HUfUZ0o6ToN9RPc3?3lU~JODOAx}gCpjW4XptoQ>W`(W_GUbe#uwBw;c z9KE520uU7}bt14Ct$I!ebH0^Qk48!*`wXx%r5_~x4;Gr5YXIwEV1>6T4wEB@2Kuk3 zAUQE0!+oTXB?b)Q1NiA-@aAy9F`jTBK=n^kO;%3HDl(fZsP-(R78A{3<6lHsUPl_q%nK}&e$JhqJHLL41_c`n5Eo02nR1ChoARdQqO02h9dDOxeP?>N zt?Xha@d$9K$LSat9$M6LLB{R4jcXPG8YSse*xOg=6W5dU3nP|#Y$b`cNQBttedQyg zfO~GW_}8+y($9aHKU1O@MQ{I!IqU8V&@Lcb?u!e7UeT-&cm0*#|K-A}7W)&eC`ZdM zCSEw3%E+fR*OoAkUzBXDlnzx=J?hLZo zC>J3(-K-T*QKP#&xP5x*mKOv=PBpty0H)JpME8-hXx(ANZNS!U3946_Nz7on0O31o zW$B}KtDe=12Y^$)-`s!gr@;8hq7Fcd)hi1FS+;NHkIVM)Ji&Sa-}C3@K>lq_euFuK z`-hF&^gE}(8^3?rWuT7ZdAvKb>t(+)c6{saV&i#v2+GSk;h|mOG)o9QG418}TN@RK!c>Bv&Dr0ca0Vq!{9C66>Tqp*M~|TZ+=u;)C6C(^g6;3JWept%VV%m@VjU zXJtoYlkSqW^#};gLb-LRsqQKJjAXybR%3sGpKYY6p2Z>U2Sb@GfOQpOs#xw4P@0US z!_TRh>;lY0IZYkL(T+7(>Z${R7Yf6Df-Vq*c< z&!x_hve=cnOz!XY^>VSS#1tpdnw{_Tl^CR3pUa}N`4R?BGnq&}PV-8Nv-=`H3g?0+ zwDm*(^^$PQ+yP3KjC4pR>62h~{UR{~8J%Pp!Z0*-Ly^SPtvdnMlznhySzoV}sjf1B z1{l1)&cK;$8v6!IAkzVa{o*16Z@&ItaQh#OF0!=P;(eLWlfrD7``+ch&cAsMuGz}{ zUuj)?(Q(SHQ6~Gd&+s^hq-otJAoyzluute}pdkD18~yBur+=s=wdR6g@9C^RQfvA| zA1iwo&CaTCF_%L-GTZL~oC9TFU$ZjaxuL>m=77{G6d)^ZxP*~6&-rq9P}S%5|pyd z;?7bS)%p9wDi+IbNgO4!e#|R`<82bVL_R0}&4w&(x(Dv(4E!YxV(1KL+9t#Wc=})x-9J}n)f1;k?&sRjb{+g zTLN$ z=JU)5{mS_DkH&6ATm9VA2>I%lk9?=@xK*t79?-U;(W~tbT^jexX}n!kL%$5;Zbg41 zBc2{L_}<3#lebLNJ5DsmjgpKP>6AgI5EpWh7%^KsX7Y07@WCsz|$q2ScYmPO9efn{Ha zTqL+PU{GV-qE7WDm~OcTz}_4BO9w0#;t}le9?WwO`j3^tQmbZ!C1y`6?U4Cve7VR~ zJbk$x$i+(cn+C|Kih;VB=mQQ@NRykU0f*T(<8bif7%K+-Foo5VuUVwr^Dw4iTO}Oh zI7PZa_zA#$OaRu4Rh+7>3H1V?y6dLZBndH z`rX_5)=#q#w%q*dVySU#(@~6-EDiecHI5t5RKadT@Rpma8xA z>=L6DHs_jqwz3H0nDy-Mndb`dE&!W@q5|ymdkC`0ViRZ=>%LOu@#8EPA?vpO)`>Qo zj5gWqA{Swkf;4QoC{2-#l-B3>tuinaX~=7RZE+1IJQt_A%%3HY90>l_n*7`5{i5um z0c$$|F!FY;lb+uvGy47Oq#L|Zf8ruXxx6{!VSPexq)O%O`B?7pndR+2fFW312m4-c z&fi@g%Wc$s5Dj{Mov+Owm-dhZe_Ok_c#|mSV{VVYn}DD1nKFo%qCanGbB2Cn{-H0; zx3_P8zI>Joj}z-VWABTMrnG;Ev45le1m#ZVew_9DB9o~K@ZM@vBNw>ItaUb#C%9%CFe{b_PLYMYj!A)amZO!zGT-M_N0EmVaLq=5 zO=$?*XXS@|7Ejmkv;5QyVv7PGla&7w^TIm@`(LoVg_V}O_Y$2n5$C>jS7BtH1hR;2F zrcC=Me`)Ni-+z_h`Isr_|d!$^L259p-RA z@@4NTPwlmH6ZpE?bm=fqTihAi(G5qiv=acXn=Dfb8v<*}KLDgRi{7^N&MY@fV(f*% zt_6Tk0N2z%M{)m<280|aM~<~Zjp7L`s%Kj8@n=%c%yi3?d@jbpgvt;>4@cExj{@|a z9;=LJ3OEkp%S~gl-jwQjQeTFk*tM+Gf`NDv&{JpM>R5-wq(mlAhS<;{1i+|fDF9OD zDFkuK7J2T-R8A8407^i$zpkOjp1AEz;sr>dHx5iyO$5r$v_o)qN`A>asZg!mR590d ztm6oVsP0ml0DdaBo)A6=Rf^%_`th^`FpA5&^heDmFuZ?pDL^`jrRhT0$tbMGL+6jT z0j0YcNFreRjr6s2S~MDC?#Y^F3%h@Pe;#U+(kS0l^h8Yj-9RTqjCF2H~*N)gNUCkSR0M-2Ct4_s0CYwOsutc@Mvu z{e;3&v+3A&CIvn}Ez0i8gv$8&h3xU0I{Z?!{@IDi)-jcmktd&XB0q!dUka0*KM4B# zSJ=tF1aq%DYo#ED0m%O7BbBgFM@o$GfzNUrv<1L9Wh4B#1lx2PDPX35X@%7$2x0h& z(ve3@=)2T?q}P2~=g;0!VglxSqjD4Oew&XOfp-LeI^O7G-tv9_|MuQ&SB@jg7Tjj$ zQ%q+f4=JXUnS@%EKr(SJaG|$;xkSI!2dI9BhhNh`@&oFv!9q8>`-P~7hL}}Zgo;Wr zMUjU*Q%pXYS?_IwySYaMsiG!I@2a2y>w#~ZJX=dn=VO!o3 zcp}S^;;qv-o-f&Nh~%HsaX9Np?yesx_(q}j5kaopb-?G;p1(#oe|>4o8gKQ5?aWvy z%O^asHsSaVYzNc%OKt2@9=C{HyTme{T>IbA@^&xIAV3p6gz)gxc6#^L5KFGR6=^qS zp065yGAVgbuzw<$CX4Q)$^O_wWxCBI*Ddp~_r1-we2+fsuYWI$c6`@&dM`7pA_9cU zPkhxo4cD)#=L^5Bd zWwjW^(bW|47qeKbrcqpsksBtODWQ@Ps?~z!3VB6ty~PzZ!BxKFUuBf*aatOJouYD` zImMci$z+t7k+K$2auidum5q`IicA8Pt5!2;M$Tk$8TT#jGv=1Zb zJ6wXz34#w&VIzVl>U6lKex5Os zcwss}lE0cVy*MW}@@yevtUb_UJx$UQM=DgfD^#q@vZ`y&kXW-(sR~{To>pr-AyvjQ zN@J<2R4JiV8f9vML}($G`e=WoGBK62@s(BuUWwU(KAgG>P=Go;19nXIk0UoPR3dk& zk6x+vttqXS8s3;OvAN%VzNEU3fMDC3$EQRmg8f&#rT6hOHeEs!yr$GvFsfKJiKBRo zHK&Q`)L6Sfezl?344MQPLFZdg+lH+5Xe--s*pP46=}~l5Y-l=1D$`^`3bu?zA(Cva zBUG>!7~j>>$9ijrm9+a? zo{1*_PF!e}>{~ef$Uh|AgSAPe-czzYmQg$2bb>@uK_q+R2vn15o#2%1nfJ~*pLPSB z7@s4#cInr_5#S7oQNE|m~G-3TSg@txBSv4FL%;BT9( z3uI?G?Cz(tjo$Qx$g|sD|2W?ry{vw!1+dpW_5m_gK?6U_F3Ewrofqlp0FT3-g zH$8H#352Gz-z7Ave>LB2r6Dv5JL?kfZP+NYMbh^z1=W_0o8A-ekF#0*%5Iz1HN?Ev znn346op1fMNP-GL=(F}9OUNrfPV=$J%9CrKv5~knacqhj68f$Qq+V0xp5jv3-`k^B zz24TbF~mtwneJVIA&ST^96O?Xbmw(Rb-V0QuiQ4m90ttq&oil9w~!WJ_Dfv;`!^6G zk5EA(&`a*5s}T7J4Q8<+*wp;`85|?YK842|F89R8`ya9T=qySl=NEDvO-4neWE@FY zqZ+pw>HKUOiK|(hEM^d`#&K4~T$q%Ftga)|@vo{PMnbSnaDjA{lEy?e%c4@JM5d9F zBf+M4*2a@<(w>_ml!0g`tj&Xb)ZF@}r6JY04^8RDk!a&GLB4adz62XJq@O*Kz`-}J zi+<-v2a4)pjzXnIqlOrzK1#ceBwLJyRiiQ+#gB6`Cf7)q$o@iEfWc&4qMPf)ei6dFyb zM5{@vm6kKD<)uDHc7FSJ2l~uZvd%!&NBX+=%!VD%d@5?6)PwG$b?;vDq?#&icTG6G zHsXgTJv-u3|B?uGA5F`Z_g>${&l9IakMI^-0!5RK@fwfH5~hj3SER&t;RI=SZJec~ zBgQ}xsBOGPr{vL}kYHD=i6F9-aD%oc-)=iVdV~>28p3_ks@r*B4KOLB zuLUb!N3we6$hRZ8p63$jP94!%^+MSZ!7&*n@z2qbRmZDCNd z+Y6CyBEg2d`-8`s3sgC$zO(0fOX~YXoW#HN=o>@?bw|6G8p& z4|RuJKX{Ca^*z{%d+wcb0<3AF`iWXQ2aCv$fDK|DT0p!%+7b_I&3Fi1LOn7s+s$~E zglAxTmJ`9b?dC;WNcHokdDr5%Zb%(2NOh#=#~9j(4k20hWPT00Ij&DZ;roZ(y#qS5 zjP%bJPgCE&|Ja@`wWyxo(GP)sRL`018ohwu%x*%BG@-87z_`+@3MuX=L48)#EJAu1 zGo%kV7E|zNN*iw64yb2yqxN#@`lxFia|)?fX=?!?)}c^ktFiH1vOuT?L#oNC!Ay{S z8?Meoy09v->8D79+C?c5NVKI-pG#!egafqxRkFCoB1kSn;-2{-Yt}{?aa3H%CIX3c zvAOBEOcygE%e3!0v01_>kqs%^C_bBQxUkt2dJU}2TvK8kPDfQRJTesBOGOTm9M2~g zkmh)WinvIM8pVEr^1X^i6_*pNlVFWR2|3#z$+^^eZ*Qh9$nG({(6v0kD{`ifj*fKo z>J>Fv>S_e~=JAmu7|?b=^GRRAq#ifGc6{-j6#L%)yNKIMT1L=&P+2(iQj$(Db|}%*N!J(V;zjJOk#rHk@g%p<+`ajDw_<$M9QP< z+W4T{py|lSY-h->Rix8K>qCf`xasD=sn4zCihhU;pFC9ci}Pd@7t58bC#0oMtTn_$ zj3FQPv1`%zN3bnsl){`kzuq;TYw8faiVpHS-c*Hu*)ReZlQEl(Eo>3y&bk3tsY-g>>Z9Cr+ZSOgyERd9% z{7_kvO&}sd-c<5!R|n*uw)08c&R^Mh#n-Rf&}RiI-}Qk_$xb#6>I%JXlxMpSQo*Ui zp0SWnJYr>=|3mLL!K;l#lDzszWoW%u0V(RQ=cA%50opjz^deF1c4?c2lp*;_ka$-} zq-fZD8CdqWSYcQmSW8)A;wB@>!r_@K6B2z|mA3Z*4!?2ho#D2B%WalUPtpl5_Ef|5 z$q7*cYdpeqEe~mQKR6-U-+nu!Us|K~2RG6N?{}o#*khbxU%Gh-SyFmrpS<&41vYjZ z{1|df^YH(2X17DB(UXhd*c8CFrV7u6&H91L?6-LVmI#;l?l3-jJTL3B*NsX~))7Ob z&wKj=XwsbfbEhIkv38Y?nM^`1Y+d%VMmFvuhSX;E@72uy;Q?U=>@84&4?%Xl^IDUN0Zomyb{{5K0E?@JysH1T-7Ijuf z=`{%>X4y&2VLCi2L+F`}z6ds?UM0!zjcPV!ZH%3Coswk)#rj0CIF&|KugEe|k-xh( zJ;#lrU2xFybDslDZePD)ROUJgfwTwQUpZ??ZPe*8Ielw&tTE*`@>@SZigKU`a00WL`zlRm_qrRKpfnU(Sts zy~3T4ZOHqqOV_Fq+JpL?67K~R*iCoBEJ9oP^4IU%+&>*Mu7i$GCxL=}60AT9HYKrm zU=Om>>9KDYF;)=*sRuT^iAl*$T~3K?-*i?HY68EB^PfJ&>k-&TDEi3Z^X_|ieTst0 zf7cE1CYZR|&^Sp9@-Uf82rQKUJCdFw-E=N{6KI>K!8DD3jB5F-U>=d{SHX~w`t83& z!Y`PaF3y5;AqD<5MQ3LwHMSWN5qsTTPQ%oZb-#~q3sUpFYU&9+RT6xsQ6IsA2<|rZ zo>b>hl{JZd-g3SCIz;RZf%g*rQ7hn=u9d+M+MvbZJoM!ROg)@+zUA1$`7~bktT&$2 z6PCd_)BN-Ec04TKZzS;BH^)rj0crmx^JqY-Lj;Pok8SOd^&*G(IM?R5oadUbtyAM~ z9J&5Y@O)_OA%Qi7c+@`yWi;q*xetEIcg#Sre*~BZyl?)tDUh{!p%U)8LOvxjfz)G9 zqDgrswV8-Cg|Ir^L9`LC#4C0kpH62NXN;QjIW9lCws}0~m-vQ3k`yj8&z{#bJcEM+ z`$N&vs!ZyrymX@e0jE>a@Q&I$^Z96oYtrO6D>y^@{v}{375qP>IG`LWgMw>6tfbQY*elz{D8G? z*ywaNOMKm4Pam6cnbWo23Zlsu~z3Y4O48J=f)f&k(eZM#R2|S)c8=IQ2 z%_~drq`FU%UTTlFZQafI{;953r&6CY1f!+$3pU=aYPT}1Yf+*v|y;LvweyxTk%EZ*R?H}ItR zJKiUqJ}<4p<%i6)9R8_yEd9tL1W&X>s|cYX+63EEv$c%?G(_9rHh>T4?UijJ9TY#C z64&I;MsmxSzXcmGiG%H}eUGJo^r8G<|}<3w`2Gq@BnyDfl#=xS=89hb>7agf<#K zZv6DLJeYZSZnwAVxXhuuV>c}wA*tS>SeyOIb~|*RQw)6HU!jvw#>c!y>7J}1j~@9B z#kXS!$mSXCeU}6p5}}T_ zkZDq^F7V@?5k!{h=W!t}m~XOS!j-+WtAvgx@nNvfzO=S~Ti5@0h18QMO$cDFY%s zkRshuXUgWt8RxbZ?l&o%plJIzMppSYn-Z4f3*nBD+nkK#^&mAHtS?&$f!iedRGB7z zRTWU>C)&uk zb;nVD=(cWkV16ZRGg8d=2t%?@p!NBO`?|klZ=PhI9^012hX9=rexag$KI)NdBn%%U zHqClSw!zs)I=J1EZ3?ML}a|jI~<1~Vneu$;&oqz zN@LIolk6s*e1{mh_}+Ujm|14Rt+@(7sJQpBjof>LB>&&t@z5sMfvuvcDBy5*YO`d9 zvJ;D*ZMPIrro~gpKmjs`H^zYnDm7&Yrv8*RMfC^8Jh2+bDbsKQWEzUjakMEPiN)LQ44!Ty?7i-1hiwS0!>{n3#< z?@mTW*Po#8wIy1VssU&B($zcER^Y?hh@KHBwL{*XaTB zK2)KuaC}sPB2rGITRG+P0BfPAc4%Ir73x=HBdL*$ebOUkkK7Mt|K&c<7N=Zf35#Y( zu15)9U7SYP%*=&MMWHG&Nn=5IxH+;+iLx$|%4A)vMl#Pw)oL-4)zw5mk#aeYQxt16 zvQZ%8FdZ0ehM0zEOJ!AJ&LSg~T0&vb5@|4*4~@r~RJ%}hHnFgYj0@Y3Qc(v(B+NQ8 z^C)bdw}b$986KU*#3YpzM4V~6wmd0{B8s)k2WfXf8Q^ipRYl2i%+cTwi4c*VDhG*Q zio#*Fnj^tp;e(Zu)X)u<;Hp{8#UxMDgGDr&&Se}gG>_LwI?iX4aV2G0kETab&dOTl zGj$jpN+QqCuRX_#(JlRv50U1xCzAg+w0qnI*yw;3#!>ewLN7yTzXRH@S*6~EUU)uc zD%<-`#U}N7Zid37n@NqM`M^}Gt%+n}nmm}hi7G>f^>#JeF>WH*)*hlw>}#4fUTSFw zwV*9<{E1T}cg7_}dlc9}A|qbcg+g+T$~Cs%=!jRbh>Nn4Rg&$m*LWvMsLJW|W`T;e z#7ez1QDjyoT%!(Rn}_#6Tk-ml?~T4)B=BRiO{<7gwHqZHY+Ch)U`aN;{5xsYJpxtR z6KuidvVB{K34Mu!uTi-U6>CCBuqo!Xw{Q7(&k4B5N3hw)0tSNpi$o_IqqT-GLrkb6 z6ZtIZ`D;5fSu2QFtVj65#QXN_i!*+N)ya!BaYJB>1o-Q}F9Z?k^O~Wci@I)!BpS(h z88KFtIg;#*agvviOs5%u@!8gUXb;S!dOHm@ruq>8OTn82EqZT1= ztjj`<)3ioCO3DZuevwzE3DUYtB-=C`J9Qhm-)kh<(~%+L8q4crR7G#y1pV}hBisLg z71jTE0-yZDC-C&yQ+7Lz*o(;-=VuW!1X~r6DDn=$CK0;=8D%;WD~ZzVYMm|@>+#}Z zJ+9Wvv8vW1uGVQ5ulXb?S(em{Li3?=%i_rXpiJ6uPC9X$P~4DQ+nI~ShB%v&#$RoS zw1G=A4xXsHX=T;x3ar{}sJgU7-7DH6b}jZu=&38F**Z^+pOy32v02d=NGQXja#DAd zW7B*=LnPC=W!qJ;0Og1^DcGtgfLD~EQecH4l~Ft`(&@FO9v#fH$>CL$WlKFCVXY%8 zlQga+tK^txa{v18B;H%{`IktnuS+D>AE~YWX}UMN(LTDN^xW;e5$&V*aBFpnXE>pRY#@Yq;Ftb%>!hHDe1FEMWH(gf_I#;OCn$tc)XVk{yiN20TXwCc6B`Y4kc zM!5tsNTf>;+L^Fv$Z91yRYQ4ADzz~74~}(g9131Vv8pQ*LWC4+Rcl7ZBvI)ss<0wg zk031pRc%<#D4vPL<25m`jkSpU{14q-;afTGL!&nRR4=|xL=le@U`-s|6X=p`4OksSQN3n?frd`!yr9oPz^Y@?#>LzMY(pN^ey^Apy1Rt@=f$!tO|tHEtI zd>V|y<~+Y{NYbg(SwFO(CrKe^@0WWQbRwxZcoOf4xmWI&&%fQiLQ>y{QR;6}md$P% zKfh7;Lk0ho?UdYUd6-0K8$98p?c~v>#U`vscm;naf%iDJYO%NGbZj|pTMdDCGQ79y zcqQM@>{xhu6@k#|xZrHnh>hjzVhoMh7h*2P7MV67lw__OJNp8a4*s@bY5hvAe9Lf~sDUm`=B%8udWDywqh)nEo zoFX9)Y$VdD+Ugi60g5)y=CANKN2(T{>+=`SSu~IM@hz+@EUxf;mz*at=kq#3mMn5v z2_6ZGI~3KWir2M@mn)XOI9-h|U#%vKRXNsqmGL@HvuGX7(hBycB}~UQ{`Cl(-V!9* zHUp!zeh|s#KBPx0VVi+)HdacBE}Jy(1>I%yBQmf?}zt^Q9)f%VGg)a&nQDNjokw~P7 z#&tA1%Hz?sc{IB=pB#TM*NI-S>mTHY@nLnrFXZI`U~4e@zaKGStk>)SVOE8jWw z)7C$2V-9 zq!<*j@fmr=S~{heNHq~`vU+gial+}+mZWAEsPQ+BBwE$j7NV<&RBEh@VU?HKr?3G< ztC)hwW0`_#fk>`Xyyvi*Uau-8$FY?2d@N@PZfkTPDIU7%Fzsz2K07CLDrhDN1w#Fl z-utbgQo{_aP#C(|SKj`u9?uutwo7^3YxGB_Ls%KKK8{w;Xn zeEEIsQtiR*dPU2h>&=RV+g?~1G0{L=rO01f6VsaTG}z~D>$j%lG1P+dP4G)QbmM=; z;UDmK5{Bd>*vKK9_K?qkHSepQdTp$T@cy)(4g~vqMkiW`SFS%ZO~9=s zxU(Wkq2LA7NWg2{i;StuT}(kai9*1mGM;@m3l>rHInNUjxR5n zEOFUlPOb5v@)fe30)#9i3WZ2@Dv3h77D*!GbXrE)^>sEoUW~4Pa24$xF4(c?{uw(qb-@F~{`{f?2; zOzuOG^~p#GV>O|XATdW@2{(kC2(a^e`)2u2EGMk7oVTXT92tMIg|ySMCr()pLFCDk zsJ^x3ZSbT$BDR`$o^6ur z7f7ySYy$rKt4k!x`(mCiqjJIHOIb#!O2>JLix)CkD;d`XkCAN0NYtWQ@uG1YS(%02ub(( z;@oPS0<4G=48;Q^<)Kxm-g>Oc2-=PMZIg#{h3I@V!`iqYZd~igf zB0`NNYtp45(^8jlon}}imMLy!jkVHD88V&Xt&fD-_`0(z-0PklSBAt}m5P$b6mHx{ z6Y|RSL+E#YZHV=I6FEkkQ1>jHZhiv{y1r}z4etb%P=Y7gl$hrHQ)OcL zhM*>ITGqj*-N}uxe}24&J^?3!9TFIVSHnY4xxbaV+3X@|k0*@fO=v<4B2fy7_5A7f z_{9yW4?wG3!}+fr*(Y>}`V(m8Lomy)>&LwUPXzqmuq7{4^xufvxXv?2+Ht%?&Y!l$ zo-A4?WWUZOA`v#Hh@-x@AozB2epT+*d4i z9V?+p5QFz#$5&02vnQMD(RcVw;I*bc31kT|Ejlh*$aC0Y3n93WeEr{@$4M~H6Km}{ z(+>pumxGJlVYWkvgS!qDQt|b zlA??wsT6)#gj|i|I5JYa;hAbY51wEfYE8ru$+zN25N!@ZYZ0NYOthQ|<1!O+IR%#4 zjNR1fQD+lSGM52vKDazUl zJxZs_1j!!H^laMwfl$7d#d6MOE5_CmReE(Us=SISEkwSO@p7q?%N1m&uhsbVr5ayd zvGIB>va*QrnlM4)_n3}3masRe*#5Xu`{NZ{+ppFBVW}sR3eSgYk!mgk9x0wIPXrT0 zsGUFBT&UPa86iS#rXy~Gk{Ko2NBSTdfoiu&RbEmTF+o-sld9ZyN+g9E=T){(83k`3 zLTzGIS0o@JsV=G0Vqwhcwos1CkPRVgB6(3&9T6V+Pbf7+XtMHS(iB2qb1%QA^| znQ~FaChvP%McKHB_paxoTYGsl*(06*XQD-Pp$RqQt^Px*N zLh-$i_fylH+*URFZ$;KovHi;P$EX4As9Nys21+@#2bA^tWpLlQjMegvJ4l0LeE zj}IKq^JPqDN+ix-+@aq)YstAi=uSivgEx?Ok8Pyg&m;2wyQCfWwwAEGnIeb*1bCC&==-}7F-PXf?Du)ilXFHRrl z+D8T;>^o&@hC>RyHwN)`DBsnQMryXRGG3WSID4rEGrgK>cF~I zLZP%;lfH_#R*l?;yIYKQfSNTko%-lFYROQx)^@p|e1iC1i;-jtWKFSAUs27n>k^+t z(w`)_v)EWXARFWFECWSEdxRuANufsKU1lj1u|O?7f_#)jI1YuN!q6EgMBj7 zI-wM?aYQu@9%l&?iLr$+Pp%Dnj$`txl0U{o*5eeijK?Ow29ak{tFdaqYwBZI2v(Ge z+mz)%9m{lOB8=ena0G>DW)j)pxJFW47YeB|HNq>~>5TS=MA{NG+K zgRT-;-Z|5GEQ&Rc>h&n9^C*&K9E*~rNu9+>CDKtfogEjW8>51RczSK4 zK;kgRN^myKBvyy3M%&FXcA%meO(MN$=9S*~L%;PT3>yc~1 z`+9%v$h6zO#O=4#c7XJsKL^TMU(F^w0PW&yw*;bZA=!qFs3Z=dy~M-#S8@B^Rng8t$YgBaFXP@9d*`maTq~4SEX+Qj;EBAk4 zxqk@ed4YcJwh<;DGV44I)cD(A2y?THuHjR={#KIf$~RK-Q`ib`y)i{^x8*#+@7r}k zt4=uXcje265kcr$>>}4@J9z(@4#1Dv3g_2uyicqNq37CKLi}$}Z?rgpna|igrrsx3 zI}q&eh>Z?QUc`6=yh$AM5-3X>KgAd2F}^VWZ849(Ipgu^m-XnyI-f3=a+a@PTqsQq zw<&UDfz7%x<+G-UbrgJ7p=zzEm!dXrDHbMfqxf>1au=JviAHtR5h>babugX$G8;+5 zr1^F+qFv`aBG%ju4apu`-ibq%j!Y*;iKH7zHihL9#gqb2$90*KvdtqD$582xQM9c~ zIm(in`t2cilztC{ZF=K_~#3A=f$Z`IU-`r4V%~ zM1j-gn);Cv$yPQJn&uSZP6$C;?2II6SBHQe2$oE%Q%L3|)z%s`nWKt5~U9n(~= zJ3iPRxgM+@KOKU^CpHeYk1*t)89t(j58LVOA{0~Pj5<=7JV%rw-Taoa)DdK5sJAq) zAR@ikqD1Kxr^!@OXit-2Z~{Ibx;A+?4tm6z$g}rWe+AH})s!^m_pVIakr4t*h&?qT z*IN?E{45BzZ)S!qq}$`9Hze$`k95#ew>_x{Gevs%5P)Hv?XVLi%_jx$>s|_XLesGl zT9w>{KO-XU*6G8LcgoEjD(5&&vAt>7pl4};N4@!vY&`J8J8ecjkA}pD#2cLV{PD&i z!JqE5^F8?w=Y`ob4YpMT9&eex18=Q75Vk4!gl+!#58VCX=le3DO{Y)Bw-I#D-b4R; zsMh=WvtZs6Xe$8QY&zk*U7cmUPk*EA8+_D#mUf?iBjJO0qA);DlSzpE!;J1*^mAX|vQm*T%ZKTS`+RHJ8C)pRlE zd-JuLAu$`5Rh=S_j-=@!r%(PYBu~Zom?TM7rOq6(pRh9~4 zSx8Xbj*!x!){cs6T;qXa&7ef7J)vkMHTku*%Jh1b>kM~!S>hp7z|ODcqF_uc*L75^ zL|m*`v@VLcu5_9gIw|rxD^R0FE}oQ>+|k7ONJLEMgYtQbl?6(To?vAniRt)MJ)70# z(Y`E?j;eBUP|MjAm3V3~$WZa7OMpb1Whi2&!V(v&`=RKsO$unC6Ir9wOhj3mPGUk6 zkzQW`5`Pvi7m!qQmXwtclD-`LCDT{JYErGaR#yK8Z6gdDQf(roQOsde?J;?$HKm?J z4V`+HS%S?Jad14#1n&-A3k3zI?(08{-~NK3pHZXT2C+Z!SX_-e%z&Ca<2J4x= zz;UNcn}k$&UsGxc>WF;(Fi@wffGmXBJVOfz-BWa2VOU3xyAb9_4PJF!|319O=#%UQ zCvGbAW`}Av!1j)ieT0Pj!DqV3htNl;c83V7o0s{W1fSLCef4&mNijWg84+?KY!ekZx15jp4j^^i5+4NEdlhT-IqT%YN;z5 z67IgmCYayYa=?ndXYSRsYWj@tLf|4dVf{uJyoblUb2x? zH8Nr*DBPam(iD6dU!ry0?RbDJjTw`OZ1W77`J56Ob21$ppEWlj;%Gdz<6Ib;Y7Ed# zy6!eHOA~0r4NU@<6iI9%bwd7ZY?MYdTc(MwQ&hE)gx6^z%F&1tZr63nqbi~fl37$G ztg7>smMJ9l{Az)Rto3}3!dFqD;#To>!J~Cf1iK*5d0f^!E^{51S|>=?<4V>EUXDm< z5ivy}z!N0NF%!ssW8sM>f0SjUhN^_fKFT1QY*Fvcj;1&ZnG z**M9k2YEg|&dO^ruV>e0@`5^#7B*VTb!wOh{ji@HA8cz0A@r4N7zp)I}PJ9qA^Bpe&8c-V4Z2lYg2bfgO8Q6QEJ32?dUS6s+4m=2!CIsVMzo(61{=lhgcKPl&+63d@?yK9MbtetsxT_nLJ5;-yGDI%j zarn-Z+lV=#Pi(eL)3&^4+{1Us^lIa`vA6tJ2z}p+zSWlwu#tQuTt8Im!|jk-@5#I; zd0PoSVaI$@^(m^0kM44KXZv}1r9M>bclqVRbxg#VUIn2c@t&9`0fF__njWe3%R2M7 zDfs3ZFPhb3X9)J1jDtqygP5%CBvAf39IcWqgv(&aZLB)VR2ZRxO{Baf+BU>o9Jk3- z2*z%LMBNf_WRa0#k+7<^NRW0S<;})ir4h&ks{}}J>lle1s@!!F^EyRkTW}=cNmOxJ zQ{TR-SX3&%(rP}h*b)`_)x1D*t)g5aBQKCxS9Kz*I4YHjwWO$mOeoDzOOJRYOiCCQ z#Tkq7pt6{hYikWjkQ@`a9*v=#W{}VJVSRL1=i_}SCYi3MV>$t9GN%rYF*TR7npd2D zin09;mDRAV4Z2!Z$iGpRU$z;i*LlL1i#pEF;$*#qtjt+f){vnL9qUSp_PUt>Bu=5m z!$-c1GP%+mht~B-P*g=wuf@i~DM}2a)S%Zu&MrL$bdz)69_O z-?*}V+!JZ4Fx=oa((RY^v+WUx#D?ju!)@*I zpl_T1Hj=(;d4K*cRec+&-?ok?_F*L=&9HA1zRh}qn2Fzrd?{*U0qy7pMP1V&z|R#uV2gA#q0dws)W5d z*OQ`RDc(Uu1(`Hn-C#$Q?Jt-#;hUwMV~p3#By<5uB7~u zNVGK(Y#vC})|(9t&$j8l!DT2^W~UKHF31&iiqs+|;*BIb5jsw^N@AtbF{dOnGDeDw zs&}16ydtA1>9MQxe4y>fMtGC?U}_#3d@VKT;f>R|TW|)T~NbB};y_f~YK5yj-&k#qAVilvXuL^Od!U zkpF{o@|l1lQq5Vdj12_8I=tZcjn-rdQRJP*!m?HsUZc2{RjD{tRCrQLUSR7$S|jtX z_r@z3W#c--uXAz@C2u6w>IEv+pEz>8Ej${5Y$3K|mHs6tioXhI7cQ=)Zx%j5)x%(3 zNVxlCyN~C%j8tjA?5P`il{(Di*%$D-_75E#)0;DTI{e)4vyF7>9^rJDaOtGcxb5&H zSm(rjydw8q&~&8yOm8LShJ>H$?elx2rpYF?YrW54Cqni7P3SA|A+hgo!%zD%VTi+R zO7||ceiwGB_}{kEX-FXZ$Frf3yCHSxyX)3@Nc()tJ_w!t0PCppejfB=s)s)KV7#&1 zxalz2kwM=-|AzTtkw=eqw<-|A>%6V=B4HP)?kd-<=bCVT3tx3%WZ^xdYu2lPjqfU>CUd>8&j?Iv3B-^nWa+74mBPv=@a8#GV$$FHXl#3cK-K<9Xn=aUM6`>lDdng34|- zg<@|iSF=5_+?&B_I<1O0)#YdcHAUQuxv^fTbT3oZ7j0|a8joj;Y~?jF4D%%xbbXP% zlsH=FobimY3diyK%N#^qVkN0UQoV*GUqdWqjmkS+HmY&dnmQ@QB9a`8zZ#Y63cs@$ zs|uCkR0F{;8p*R9#R&!XBY(O=)m@VMPwD9ClTsAF!?_x#T1|^RrADBRC}*D!kLLZc zo>&tsK~jc@d0R*jhHoI%KMw?*9zD9YGs0bf#y&zXyvIA9--%PJH~Vnu=G{po`mb2- z55Zpj{c{}LORdGo9_9}G67RDn_IpUU%>w8qVe+c>uC&cQo_6^8u4uvoB5`@}7%SCx z*jD0A%R;3*EbHfm*tQK%(3_|z4>whGvuv03#r6EB^-k_8>p%DNX#62;Y%e6-gpeTj z$@(@6P1td?Px!p5|E5l+;d&)9c^Zm+2sQ!Bu--puMMWn<1gq;=%;rH>Uhrw~&Rj>^wu=)E4e^1@AzEW#CB)`ur zb|BdAhzn*M&+uI&*G*Ot<0H1dZ&KKOcJRJun^sF~CrQO)GKy2@J=ALpsq37SW5vn> zNjp{>h)|QGg~A8YG*tj7LW(`!*mTw;a!u;Bz-u`o-*rmI$~A?qPKA`CL=@vGtR~ZX zIo*qvvq`lghsXYbu0)#F36XFL^bsxPD4nQC|{sVT_fqnT0%`}=^0imu!5n8Ym-uqACV=7 z&_}FL6qaO~Wm3nvt{{_mTxyho&L|@$<^LIjo>2(HsSRVO>oa{cJF=nn$TLg!kQ~2o zQY@)M8IdL;*8SMl_k-KuJ&rv*U^}+->Cn(uwU4(vvuAI3j?+Ct%e~!$@X!tW>hxpK zE&oqW>Hk_{rFV#SU%B3p$#WytszunY12J-RqvDDXIGL9kGC!V zwg|Tqj{EfRyViLSM1JU(-Kw$&Q6e5c4D2t#(4eoNKj;_zU)4`OEYl}i3 zs_NUUGTW>)5Ak?E>uoDk>_dbV1L~yOC#7LWR>C%W#kNXAxb8Oc|0cD*Z>#B(W?yj# z?LU2N?I-Xp6>EvAg&pd$JGw z6X(5-^H<{8X(ayR>#wtKo~y~lE4g=Z1qbUw@0Ud~t|Vj%byg&;LMw{wAx#2mvVEA2 zj1-x|uxLx7jhgK{({V)vm23%S9p~`{a9th+N~7E};lN{UKoYcafks4|N}PPzjM?mv z8+w`#ViImndQn_9Iq5jc&=C@CvXjs|iO!`)f=vlmaD8S%4$?6Lbx~xUDlW~$umdn{;R+CSB zgdbgADLvJCf3mO6&(GnoJ~Yu3nDel*Ij}_md{qLU;qnbhrCd|89i2uaioAk^d8tRy#Dpr;Bb()NhRXEcE-&GC zqkTi7=~yVsr6JOXuDO7`v)9}>rJ(+O^YXL92PPyR>t)O{w?_E24Fvn=g^ifZAsz%B z9zD@MHI*srCDniSZ{u_}v)Y;cb~9ZNct1 z+-G1fa(fL=!uRk70rzE~lPbE6vF*sM+s2=^kmNVvP38QuKKUkmAEZ81;R)e5!DsRN zr~6&i-GeGCG~bu?^G@z{_u;og2=`^C!wvQl1$CxFQXkHPZ!sT&X@`(}--TAe_v(5` ztUdbi)cx{)#ULd2+sJRY505@E-?qI_@!z#==vBDP{&tY-lP%+Ya=i^jy=6dLUCb?v zTXA=a!vLkl-JRm@UUYCP?(SOLDNwvP3=|!txC~MpN^uzmm&Wj|8=H5zoO>$GAh9QAzD99ekzyORT+!#9?VYkiKz4nX_XzA5&4rqt<9EBisvqC z0kVdRZV3Td{?XCOb3k_)o;IA7wkj6+me$GDD&PXHKZWL}#E6fUeBH&Tdn_XH8-CRJ zp&AuLUO{_=jYF%SM2xbSaJZ?HgX<{9a>B;l)_aZ$c2w6I3)oMcXrC_i{#NDetVV@8 zbE{zl=Q}Gzbmds_0G&>~Yu>6jx-;~eU3(NAKD>KRjQD5P-}1W-pU95AD68ox4zJ94 zY8Y2$=h=7_Z}zr(=^IdeTL=~VqZ$1ZH`h?%8ZNpA;&67~J(*Kk83-y1nX1wKQKr1I z>t}*#b9(fAk@?NM0FR&PMN%P|C$RYrL$Q=OqZ2>&ni?=ST6XHQR5f25`?=2#c?LaG zPN|i7aYNjH5bxw8&#Fmx=LnADCfJCE53%#*ey2`mjbMM8k7wZvK|T9%i}N3k2}3TR z^daoW0_H|{%iuXd?s4m6_PSo9KT#&ub+&)QA-dDit_7;{+(l1R?_`l5f{EqX_syu3 z+UVnaq{m=_w{qTRSZ?X}>(1AJwcS7hsjxFP3e(neg3(@N+q?mn8RiXIspj6q3q&Wq zr;@zAs_+-@fUoku18q+0jzyQRk6nbP`L~CjfPLBNUt-U6WlEkoc&NgGIYsmSiGv~M z2x>{WdC&CODMNFh>8wKZv*!S39mwi^HkTXv*jYiko*(-1Ez?@#^{R zuAMU>63LZEi>~LtXn_fXP8{&!?~upVL~uXh##U(W1pm5Zq&7uqmeKjzi~}nV%ay{a z)T(lMpUU|~pg`ISY2H#aERIGh*3Un-HFakYhn6IoWFIdQRit#fz9lQo1Dz&E&?P25d|E zyc?ncDDKbD5sKcvtdGH80Oo!Z-&OQpmM6Op#A-WT0t~t}PHzqtXr*g{<&{6EZB_~LSFGwy}u~$pX_ei(fBi`>>0Ha z_(mfQ5%iHYM>wPCpeGM=7k22^;@s$h4K!M^KW)z4PDT~dE>wL#cZH$lpR})@dr*t2 zv8&53#W$@AydEYdJtgWvX&V_(nZ@j{=F_j-dQsz>oyX%FI+J`@vtCe|Kafd3EY|N< zZg0XMr=nljxp9i`MV6aPJ8*)3KRddk>Ni*}Hxz%xX^oEGMV>MGUZ?QjwS91FNgrOu#n9*IEEyzNNYI2!AMMO6!zZ_o8$Io=rx)X-hDYN?N|M`JdKf>PtG zY60=jgdq z!&C=enKIWoU&TsiBG#Tx;DgI{C?5{_n=`tsE}?2$lN=bpqR6%Z;u{OJ^NOGYi*`uS zu|H6~agz%3Y1dvET*mnlG)7`up>LekWW11LoK4?+Fkm>W?m%5qO2L-SA7(&g_)Tr7 z6a9y6CDxjDg0^FG5W_-$a=_RCV8vV1Im1!2RbF9)J3!3(OE1?tJu+bG)6{obe!{#k zAjG#Df08Lrj%Iw7r=@u!<$~>r49zCit1L0+?tQWE{QILrkP5_kC(F(evFX$2lRh+> zgax-xQtni1%S<;tsm6I81FJ{{$_Z;pt(pt*A5)tXJl{lLJ`hwMG|fOYbSIfv*++b3 zf3i%7_warY%b%)wz+hlBug+4%yIH{+bCxX1zPn#YJrNd28k}g5ST8hKeB(eoTnC3f z(6^t}iHAL%(?|!#S6+G$fL)jbKA(s!RmIF7{4LZfzbzm?)j4GRAnXCHE~86zAUEsW zy|TD@ioU4USUwf_>Qwhl0;fnf^<*<40R-8OLkZx&JNMZY{<6-sJ;Mn-YSA;H820*g zi?ak+r5L~AvpfbLS}8`i?y0Y&OYFDFC`&J^!UJX{)1eMt47CuL3FND4`4_xX)X=SF-ws9-$ z^8UsGJPDR$tk6Pf*gt4o$(>ox9v1y~v=E3-m3I6%vphGDRa!*<_P|4hdkttOBD@j| zIsvdf{wgvPBYAxZ?p1hssGtNr?KqqVXqZeWe^r@q8nMWioz))_K~^f1T7PP}P0G#cz^Ubwt4 z{)8929q04A0ag2mN9Tu)Gt?J}MpVqZoL{W#Dia76d8MZZw@atEYrrEf$PZAH>CesH zE)bBWv9m~ZWWr>_<4vT&2PM)xufIoiCB1NftiD@%|0G}fl^k^X)DGnD$qGzcHGX{T zm;%+)9WNTLSO4?m_>@P499(}YqWPv9loJ*_pl5Bjau_VqR{dvfN9h*w?jRD3?6Q2L z=t0$0hc4-etTPfu`pWTe`}I}W6LORN9sv{MM!3|zIdIGQ1D(nnF-}9ss>9}s>}WiJ zby2A$d!3fTs8xcsfTpHVWT%uSt42;%MigZ;;o`r9LA&2D-`^HO@w`FiZP~foFF`2t z&!plABU;$$)#hdFO9sRm)>sMz&LS&>D}&{NsOi-B2Raf_sf6N9wd(!#znk^9)d9QP ze~DPvmb4~WBRjGbAWk@+D=(2)tly^|jr%oQ?Lab(vK87|j^_hR6^&`s>q+Enz_iq; z5L370f`C#%=JU>47Zo|Nf+ zh-03zI{*2e$q$u|?3IrdYAXlI+?7|@{{5yJ7Kl7i+|Y8X_*0z32UCSjB6$7cN%~F~ zLA<`?M^yB2^xpHvTl7Cp!cGX>*YDP!xn8O4(dzI|yJYI^HxQRqdBhz7a5vE673X)3 zUuTWby@LM~`s>->9>9J9%QXI;w}V&O1Y02I)SiH(UbSepJ_9m3TvkY5Epgkn>=X7s z8N5rRJSRH-U%q06ult>MYx6eO*$q=*+hDevtb@gE_v&N^Dzy}cBz3{0G=^{%DP&sL(+}*Cn3sA zRC)DGggtOy|6s|Cx0)oscS_I|(YXOOQCxfYeT;#5rt^lUC#_SI#o`CzuJzuxjW1nS zK9Vk;l;?6`%e9bO9jA~sse@Z|=esBmii}fu#2}M@+RdBgq)||~t|R9@g>Rm=6W6+o zT$Gy?Gq+1F87Jvf(=9-Z2XrT0xrfK~Qfr?Z6|}{ptZVPZYxPTSsBoO`tH8tKa>c)g z?Pm@!Tz~V-Ew*q{li3S!c?1b&G!A3?yp}YrC(uR-ZJ4FqS=pUZr!u!2YNh5>>hTkY zvIc-`Q0-qp`Z67vp;Xdz{x$1h>#!>@l?@aXzJ_dI9|77->dJ({1?ubUe;dg?$62CCk$IS?#~k{AqNG)}6sX zO=j+@TRC6!rQ#%DrSzgAg(|>}U+lX51wPu^QyLwcsL0>Bka^TXpvAvm=yv^rI>2A# z-}8rV>s;i0!|UBS6r4pf|EC>zM3jKn3*zj=`?JoKb_<@NuO$5^$>-nBQXI`H)kYq- z*WvhLVZ;M{i$eHm=VBTW8=tOkS2kbUplFbHnXc`HtUhMYC<*EF4ykKJ7_ro!d@F^h z=iYWa%QhRDwR*+I06j*Ip@!{1p0@^EBHDK)uSLZ+VejGaQY#3ysE`i|q85MDOp5FK zIo6Y=rS7DeC1j;YZmZBJOAKX*6Ohm0OClf)qL-WHP182l7{g~+5g`ytLbCaUq^Q(3 zH=@RoDl*Z9VxZX}I{b#Q<7wH-BR`sny*La^xO7S4hy)|2r1vQnPcg{6Ozg@&W~Xt< zbqTD*33GH!E>k1nHyO~d-X|)C&8ImW54PoP-QeIKEmt_+R$B#F6MWQ=R>%ZXDd&wF zzi(P1dZCWz(U9d*g_M;uxEFxSs+PqU?tN>HR_fd`FWCg~6rl3a<8P8O?n6S?Mr-=B zU1Ohprk_$K-8YTzfkDfY%B2l+cjx)8)1nS%s1t3~$hZDDoCu=quP54WW{U50E_VwN z&fNIbM}dr`MH3P(myUVz`~M|J(hHA~r4|^q=+4@ur@wnA2Q1JQwZCnH=kzbx)jJRd za*qW?xAwNmy)}P7^YxfpQNa1C{J+Grb7ppuq}$uz;CMsqMo?>U`$=}KuFK$mfgw!f zgiyU~hTv~1PGXjCiXJ2wtJbIgCEg~7$flr4KxLF5Rn=Ff=}?nlB>|ie(>6-VoTfrX zahT4KnpBlcliWfYE_VeC0W@d5Q7m#(cqiVm))-&W>1mk2nKjg)jqzed))I1hJZSgS z^6=lJlKbi`^soaR-1l}Aeswmv+8u470R=?U6*UEpQCCEKkEt|_b5i_HGn-b3C6~Xk zVA2u5hE_FL(6RK;xg`~?$#)$naKoUb}9dp(Y~@6-M;FS;m&KHC>h zMO$~j3H!6tBci++8GBhSUpj30u>7HoxrTms+xvMQq|tmj&~9YtJsC}P>}>Q`QE>SZ zYYqzpYxiOY_2n(&oRh|fn)lITjydP)cJbkbK`o08@cIID8K6Jt3~N2{*~pk7Omy4E zqY-X8^bJ$$psZg*-`uDf^AY@T`zs4WcvLSNcF|Wj0nJ9s6#Pxzm=D9*KfJWHKWjH9 zE#yW;j=p1xi;PoW8F6Fy<7kiOqvKej`)gE*&O`gBTl?Pr$V!5;MKht2O$=8&3zabP zS{cvZRXIF#%am`UbW-vyAz!)C4HR7IS96xMY-7!2S`zK8YA-qZ2QS`S|X{rls8=N7Y({c@tq4Dp# zca#P#w}NjZhjab^I$tORpqW^%C+mmZ>M%R=dHoI88&N@3TK#2WG+W#l&z@KdQ30dyOHfh5H&Bl|A~< z4!O*3W5!Q!5)U2mQdbVD+fAu4W#e^@OoY^E)EIGdS#}hh9c~HwYiN!^iJ$q=9X=~a zVl*(bSQdS`vE~^*<~DV4_ktMPN0xS5R_XtwQ!bVISYS(LWb2=zCf|b_T`NNQeQ^VR z0m2M5tWY`yKhEJE;sp_7rB>c8>9)Sef7ca9a!nO53@{sqVLSQ9TrVajW(LH|XB%sw zNjMW8f(3fE$C=Jj74267kFM98$tIzS4lI;gqsi(4zq-XFvBF>PQ#$?U&gQ9+pl&v( zW;bt^MtSP+C4k4;6?geav;7VA zzZ%!-2Pee8351|{RCavL;z}CmHl7^p@q3E zqR8@o6d>L0Au?}P*P1nCsv4gjgR2Inb^iu{#Z&5 zrX+W-8A}JgjN1)h1#D?V$TRvfDEv;coW*sDVmJ1%c$)96ai|ZKq62bOW+&+WELQ#I zO_m;8qv4hnSt*u7glNiuN9b01HT#()ond@8r~by}OGQ;SthG9Aca6KV4?(%sHi_pk zAwh;~%ywO`4qaG?=S3UI9sF!D%Lu8}sFv2G!baz8lts$9sk_SJ+jmAeI2>!!fEB?# z^$s1kEL|La&kge*T<<2Q7AU*1O?G?o&~AOL{hh72t{n~j&x`-V3y`Jn&t7^zq&P1+ z@ra<46rS0G$flsidG7RqRFghz*Iu}w7}*aI9R99wu7aq0YsA^vcebzik!yh+JEuW6 z7u;q$E#K155(Zi6M2CF{R7`S#k4UV=&~<}}BCLKHguH|kHSlBdZM1+2SNE; zgBSNxfxTts(=QiVfEKDEvHs{=VeF^HqMZvxT-;AkkYwWbTwWzF&vCn9;_u$B3DCkm zyXo_)Qr|@H2i73xT0$GN5Ez)8S`^`Kkj8bTZ|kVY@$N)z6|gdeeYd{8|DXNdihsE1 ze!8n9G=F-n42;0iugruLtScG%)_)0=uHL=k#V{aLCxhk9wG}PLqUX}`$YU41$jxYr zZiXliXfGqU8$FH0WRbr{F}(L@D7hLZ(a75IKN$M<%994omni<-v%rdn-f9eD#u0Y0F_& z!lAyNj?Lf2FJxSZIqzdc+_NfvDzYnxl~JW|!nGVyEB>S#+=`amr@c zTI(t2!|O;Bd8x|jv8BDEMR2ggtrXPpDd0W~QFpoc+jih> zMbt#TWRnc!AH*M+WsXGI^q-Am2?KM%D~Z|p;Ci#UevnxD``q-f*y z4sDA|wBK*y)soo$a!I4w;fIs!N7~sM1-tCv$_vzcoSeXwhtO=o`R*$UVo!fK5gJ;b z9+Ot-J&P;=V~27;1&c1p%9d2nIJnA3A|=)hByu&UWi_{GCBGyn z@%(H>L{go%Q)K_zIhaMLC{>wP*Hs_#yyE@3}e4`q7 zr1kE+7YK(9eEDb4;70DXPd4d4oKJ?<-t$EpK0LMgpwn2hilnx9i`hU)Kpt7RAFxKa zc)X+8e?^3ilWIN9`$wUm^>1ez;HC(O($OC+`aiY{Xn32NdwVWY8G36S{ebA;P|m!@ zL?;S1iStL-`RQJSyBI=M$}kEsAB@|&ye#8Eh{8Z>)mMCH4wi;x*fAtDhTkLN(ZS9B zf%dG#Qw)44YEcUah;+D%$m0q~WJsbt{Ghb8vP#KmUG@3kkM9sGs?gUGT2zYdi1Pi< zi3xsKWZz192d2?j?Ai<8_ZO~zWPK02XPb$x((fCv>Ulw*qW5~Jq+f{mSjpSZw)CB9 z*iYImkS49#X<2IEA6IW0tU{{}UGHvEcI109QI0bFy;4X|3~jwkZ&wUi18WqWnpQjF z(bhOXj);_7nSM;y@#%x^8>@2)S~I0tJC^_^s;!9dRinepzF^FX#X#?Sg_|g}V*5qI z^XHjFHZw#LMEw@MZ!p*!{5Dl5Y-xM6oMP8KEV=6R!`d&QEdZx5z&|B7Pv7ip{`(A4 z#>YFB_Xkx`1oyrB*~4|HPm7(f0RMs0cy(K4_u`MrjcZm0tG<64e_h(&UJ%De=E#dV zQc_D1W&`!RZTDAIL2QY>FkxWI>4 zS4ZcaP6?n-b5??1;9TD~G`_$%LrG&uv3|~hO<`iCpaR1RFL9juWT6Gj?!~Y_F*Yn4 zjG?N=D-u+@y%*uU^#5DJ7L)pos{pKSrJ8NyUQ=~$E6Qyb)JL$-UO5S0CmrQ?-LP@7 z^^p({auVs{s5M*h>p_2oTTO9nrimD$h=xFZnG*hTmaB4|4yEZn?HQLeQDeX4+i)Jb zuP`~sjlD(oDB!$3X)-g0pZLN{If}>~m_!rn-ga1QM<5N*BD+B9mS}yjqLk01Qh2WX z#bU)S4cX8)mUm zoi#kNnJr$b^SC4fo4^y5ZsQGaz&+tsa^LWywMDz6ThsX_Dusk~l@-o^cpn14$sg5z ze4CJX;S@2$H52BgX-v?AOsCyNgTS-${M3>tQIhATrexo#HzE(;6pSA*Ept_ffM>GS zbQXzqFZbeN7(gKY-I|%U^|4Wh-wb)a!EXYyXSnQW@-|hxNAo!qs_P#?MY0BM>2-{s z^qoIv?mRP!_!jG;a#47!MM*4CLMRx3HtX;L*tg2LbP!W29)CZjHEQmh=1p3gB_3-Eahu3Gq|6popSFQ%1bsi``9Z;|OY#w#424EV0cD0i9VJfx zdO2MYRd#avgZ~#ip5WfV11BQNuKkjn&rQ7J#hTUkkB+@?$t#*zg?BJ@$9++!sF)}8 zER69FA@*6*rP{Ha%4s;^`I@sxW3un-Ei@R}J6^~PUl2QBt9`)t_a0u~V*K<;re{dd zHxLj>s3q*q?M9k8@&3ayS^Xt9|C6nB70`BMmNokRlRV5cL_o!&nf%Rx04A!tRT7#Y zIpjxn_6d=;i`xVIrwVS&IL z7UM+M+#4fV@J_=FZ3c>Vlpp>!%~L07??^N&@Ihm|{#!&Bph>P}h$&tfE(m?)l5xQj zc{UoEa+oOXXq5$0Za#gmihD9GMo}95e`}Etc=7jdBbxMGC#(6SYg;tp{yz!TDwA&#-{c z4z1l{*;Tsp*H51%h6GcOd(;hxIho+}{Jjd!KUIB)lLZvVFdBK3ObIp$;vW+QWnmHm zmdj0*HJe-cj=U0)@-7h^qBbQ&P!%{s&ds(-euk{LcvRsik)p3GOPls;GnJKrM#BC6 zpTmW0ecY0lR&cQEf%e}7sc}BH+|LMYY&)Wy3gP0;Ri;z*udz9yN*bN@k^#6yc#ph< zgB%vD&Li6l@Ws96eNt9tum8xvp&zhui~7v>CYZg3-nTfkwzl0_>DJ>HhbN2o7h&<8 z&@NbB7|dXN9NB~bDd}L0?w~3t>EZzcg-K$uv1*B-iUBBJ!Yi}?fgSNHQM?}&ycWXP z&qV65()Q;}l-cWVy0{+h-Fh7XSOF1nux;|RW5?xJ7vvZt-rsTZawgzHFCt+um$>|b}_iCoA>F|~5T6F8y zU(CJlbVd(^EG{qW^}{dlDWyNL`%lU-WLog)jJO7JU>OudK3!Q>t{#sLnPAn54>Z{K z$73H+pVSKB!e^0}E>AJt_1?u0m`>Ge0LBpO2+*`K`J>4mPS{Y@$%ufEL_68XL9#;k zcJMG$boq8R77S!t+bYvdol33+-IlGuo0k8@xIQFIi-#auv@tJkDs`t_#9PLge}+f3 ze>}U%c`PnZ9BzdNPygY28f{G3VpRtvOP>UCUR2MVZ`fSyp@$!kg)9Evx=N77Ll;vj zrJU0GaE!M*9FgJpWUWNknWcxPC&)|@lcuj0$ISpKHQR@*pI*5I;KgTxx3BE{hrS4BxTIPeX{Q8nGkRhuTPL8m0im+!ec6i+X^X`wNBFlYz zNT$x*3(llz=JL&MrUXE`>p;|9m(3T>6l0eyQ`~~c3lS-z+&3>eaZH+sD>(@Cr7 zk&1V-Wzr0}6I(J>Yn1wG4Rb5*=LBqXSj z9-+olK*4Wr_#OrJ{{m$DBkl=2;s#Y4K$1#?^#&%dCLkgW^ScR?1WgMXL0e6S>BOeh zOQ;oF$Mp+sKXX`C5i3;aB~a2BXDR_O(84%STH&SuDe9~-Ivn_+o@C { '0xc42edfcc21ed14dda456aa0756c153f7985d8813', '0x0', ); - expect(queryByText('Fund your wallet')).toBeInTheDocument(); + expect(queryByText('Tips for using a wallet')).toBeInTheDocument(); }); it('does not show the ramp card when the account has a balance', () => { const { queryByText } = render(); - expect(queryByText('Fund your wallet')).not.toBeInTheDocument(); + expect(queryByText('Tips for using a wallet')).not.toBeInTheDocument(); }); }); diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 5cfeb6803875..4cbe529e3df2 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -164,7 +164,7 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { setShowFundingMethodModal(false)} - title={t('selectFundingMethod')} + title={t('fundingMethod')} onClickReceive={onClickReceive} /> )} diff --git a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js index 87bda9a71a85..9a1062e4f177 100644 --- a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js +++ b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js @@ -16,10 +16,6 @@ import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { useNftsCollections } from '../../../../../hooks/useNftsCollections'; import { getCurrentNetwork, - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - getShouldHideZeroBalanceTokens, - getSelectedAccount, - ///: END:ONLY_INCLUDE_IF getIsMainnet, getUseNftDetection, getNftIsStillFetchingIndication, @@ -42,14 +38,6 @@ import { MetaMetricsEventName, } from '../../../../../../shared/constants/metametrics'; import { getCurrentLocale } from '../../../../../ducks/locale/locale'; -///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) -import { - RAMPS_CARD_VARIANT_TYPES, - RampsCard, -} from '../../../../multichain/ramps-card/ramps-card'; -import { useAccountTotalFiatBalance } from '../../../../../hooks/useAccountTotalFiatBalance'; -import { getIsNativeTokenBuyable } from '../../../../../ducks/ramps'; -///: END:ONLY_INCLUDE_IF import Spinner from '../../../../ui/spinner'; export default function NftsTab() { @@ -63,20 +51,6 @@ export default function NftsTab() { getNftIsStillFetchingIndication, ); - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - const selectedAccount = useSelector(getSelectedAccount); - const shouldHideZeroBalanceTokens = useSelector( - getShouldHideZeroBalanceTokens, - ); - const { totalFiatBalance } = useAccountTotalFiatBalance( - selectedAccount, - shouldHideZeroBalanceTokens, - ); - const balanceIsZero = Number(totalFiatBalance) === 0; - const isBuyableChain = useSelector(getIsNativeTokenBuyable); - const showRampsCard = isBuyableChain && balanceIsZero; - ///: END:ONLY_INCLUDE_IF - const { nftsLoading, collections, previouslyOwnedCollection } = useNftsCollections(); @@ -132,13 +106,6 @@ export default function NftsTab() { return ( <> - { - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - showRampsCard ? ( - - ) : null - ///: END:ONLY_INCLUDE_IF - } {isMainnet && !useNftDetection ? ( diff --git a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.test.js b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.test.js index 85f92a5344db..52acdbcd84f4 100644 --- a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.test.js +++ b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.test.js @@ -248,10 +248,6 @@ describe('NFT Items', () => { jest.clearAllMocks(); }); - function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - describe('NFTs Detection Notice', () => { it('should render the NFTs Detection Notice when currently selected network is Mainnet and nft detection is set to false and user has nfts', () => { render({ @@ -374,24 +370,4 @@ describe('NFT Items', () => { expect(historyPushMock).toHaveBeenCalledWith(SECURITY_ROUTE); }); }); - - describe('NFT Tab Ramps Card', () => { - it('shows the ramp card when user balance is zero', async () => { - const { queryByText } = render({ - selectedAddress: ACCOUNT_1, - balance: '0x0', - }); - // wait for spinner to be removed - await delay(3000); - expect(queryByText('Get ETH to buy NFTs')).toBeInTheDocument(); - }); - - it('does not show the ramp card when the account has a balance', () => { - const { queryByText } = render({ - selectedAddress: ACCOUNT_1, - balance: ETH_BALANCE, - }); - expect(queryByText('Get ETH to buy NFTs')).not.toBeInTheDocument(); - }); - }); }); diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx index 509a4aa60a2a..34ec98e671b9 100644 --- a/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx +++ b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx @@ -57,7 +57,7 @@ describe('FundingMethodModal', () => { expect(queryByTestId('funding-method-modal')).toBeNull(); }); - it('should call openBuyCryptoInPdapp when the Buy Crypto item is clicked', () => { + it('should call openBuyCryptoInPdapp when the Token Marketplace item is clicked', () => { const { getByText } = renderWithProvider( { store, ); - fireEvent.click(getByText('Buy crypto')); + fireEvent.click(getByText('Token marketplace')); expect(openBuyCryptoInPdapp).toHaveBeenCalled(); }); diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx index 47d6ed22c2e8..baa0e234a32a 100644 --- a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx +++ b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx @@ -115,8 +115,8 @@ export const FundingMethodModal: React.FC = ({ { const { chainId, nickname } = useSelector(getMultichainCurrentNetwork); const { symbol } = useSelector(getMultichainDefaultToken); - const isTokenVariant = variant === RAMPS_CARD_VARIANT_TYPES.TOKEN; - useEffect(() => { trackEvent({ event: MetaMetricsEventName.EmptyBuyBannerDisplayed, @@ -110,7 +99,7 @@ export const RampsCard = ({ variant, handleOnClick }) => { category: MetaMetricsEventCategory.Navigation, properties: { location: `${variant} tab`, - text: `Buy ${symbol}`, + text: `Token Marketplace`, // FIXME: This might not be a number for non-EVM networks chain_id: chainId, token_symbol: symbol, @@ -132,14 +121,14 @@ export const RampsCard = ({ variant, handleOnClick }) => { }} > - {t(title, [symbol])} + {t(title)} - {t(body, [symbol])} + {t(body)} - {isTokenVariant ? t('getStarted') : t('buyToken', [symbol])} + {t('tokenMarketplace')} ); diff --git a/ui/components/multichain/ramps-card/ramps-card.stories.js b/ui/components/multichain/ramps-card/ramps-card.stories.js index 2a4dce444c7e..903ea3d27f9a 100644 --- a/ui/components/multichain/ramps-card/ramps-card.stories.js +++ b/ui/components/multichain/ramps-card/ramps-card.stories.js @@ -24,12 +24,6 @@ export const TokensStory = (args) => ( TokensStory.storyName = 'Tokens'; -export const NFTsStory = (args) => ( - -); - -NFTsStory.storyName = 'NFTs'; - export const ActivityStory = (args) => ( ); diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap index 95828e3e250e..79400367de13 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -268,17 +268,17 @@ exports[`AssetPage should render a native asset 1`] = `

- Start your journey with ETH + Tips for using a wallet

- Get started with web3 by adding some ETH to your wallet. + Adding tokens unlocks more ways to use web3.

- Start your journey with ETH + Tips for using a wallet

- Get started with web3 by adding some ETH to your wallet. + Adding tokens unlocks more ways to use web3.

- Start your journey with ETH + Tips for using a wallet

- Get started with web3 by adding some ETH to your wallet. + Adding tokens unlocks more ways to use web3.

Date: Wed, 30 Oct 2024 20:45:13 +0100 Subject: [PATCH 23/56] chore: Add a new transaction event prop (#28153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add a new `transaction_contract_address` prop into anonymised transaction events. ## **Related issues** ## **Manual testing steps** 1. Submit a dapp transaction and check for anonymised events in the Network tab. The new prop will be there. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com> --- app/scripts/lib/transaction/metrics.test.ts | 68 +++++++++++++++++++++ app/scripts/lib/transaction/metrics.ts | 3 + 2 files changed, 71 insertions(+) diff --git a/app/scripts/lib/transaction/metrics.test.ts b/app/scripts/lib/transaction/metrics.test.ts index f3dfa87799fc..7dcedd4e467e 100644 --- a/app/scripts/lib/transaction/metrics.test.ts +++ b/app/scripts/lib/transaction/metrics.test.ts @@ -17,6 +17,7 @@ import { import { MetaMetricsTransactionEventSource, MetaMetricsEventCategory, + MetaMetricsEventUiCustomization, } from '../../../../shared/constants/metametrics'; import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils'; import { @@ -167,6 +168,7 @@ describe('Transaction metrics', () => { first_seen: 1624408066355, gas_limit: '0x7b0d', gas_price: '2', + transaction_contract_address: undefined, transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, transaction_replaced: undefined, }; @@ -756,6 +758,72 @@ describe('Transaction metrics', () => { mockTransactionMetricsRequest.finalizeEventFragment, ).toBeCalledWith(expectedUniqueId); }); + + it('should create, update, finalize event fragment with transaction_contract_address', async () => { + mockTransactionMeta.txReceipt = { + gasUsed: '0x123', + status: '0x0', + }; + mockTransactionMeta.submittedTime = 123; + mockTransactionMeta.status = TransactionStatus.confirmed; + mockTransactionMeta.type = TransactionType.contractInteraction; + const expectedUniqueId = 'transaction-submitted-1'; + const properties = { + ...expectedProperties, + status: TransactionStatus.confirmed, + transaction_type: TransactionType.contractInteraction, + asset_type: AssetType.unknown, + ui_customizations: [ + MetaMetricsEventUiCustomization.RedesignedConfirmation, + ], + is_smart_transaction: undefined, + transaction_advanced_view: undefined, + }; + const sensitiveProperties = { + ...expectedSensitiveProperties, + transaction_contract_address: + '0x1678a085c290ebd122dc42cba69373b5953b831d', + completion_time: expect.any(String), + gas_used: '0.000000291', + status: METRICS_STATUS_FAILED, + }; + + await handleTransactionConfirmed(mockTransactionMetricsRequest, { + ...mockTransactionMeta, + actionId: mockActionId, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties, + sensitiveProperties, + }); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties, + sensitiveProperties, + }, + ); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toHaveBeenCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toHaveBeenCalledWith(expectedUniqueId); + }); }); describe('handleTransactionDropped', () => { diff --git a/app/scripts/lib/transaction/metrics.ts b/app/scripts/lib/transaction/metrics.ts index 7df95ec66a05..b33be10c8df4 100644 --- a/app/scripts/lib/transaction/metrics.ts +++ b/app/scripts/lib/transaction/metrics.ts @@ -916,6 +916,7 @@ async function buildEventFragmentProperties({ let transactionContractMethod; let transactionApprovalAmountVsProposedRatio; let transactionApprovalAmountVsBalanceRatio; + let transactionContractAddress; let transactionType = TransactionType.simpleSend; if (type === TransactionType.swapAndSend) { transactionType = TransactionType.swapAndSend; @@ -928,6 +929,7 @@ async function buildEventFragmentProperties({ } else if (contractInteractionTypes) { transactionType = TransactionType.contractInteraction; transactionContractMethod = contractMethodName; + transactionContractAddress = transactionMeta.txParams?.to; if ( transactionContractMethod === contractMethodNames.APPROVE && tokenStandard === TokenStandard.ERC20 @@ -1088,6 +1090,7 @@ async function buildEventFragmentProperties({ first_seen: time, gas_limit: gasLimit, transaction_replaced: transactionReplaced, + transaction_contract_address: transactionContractAddress, ...extraParams, ...gasParamsInGwei, // TODO: Replace `any` with type From 956f99a279efa9a9aa5f5c97c07e468b18227d87 Mon Sep 17 00:00:00 2001 From: jiexi Date: Wed, 30 Oct 2024 13:07:21 -0700 Subject: [PATCH 24/56] =?UTF-8?q?feat:=20Bump=20`QueuedRequestController`?= =?UTF-8?q?=20from=20`^2.0.0`=20to=20`^7.0.0`=C2=A0=20(#28090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bumps `@metamask/queued-request-controller` to fix queueing issue with Chain Permission `wallet_switchEthereumChain` and `wallet_addEthereumChain` when switching to a previously permitted chain and with `wallet_addEthereumChain` not being enqueued when it still should be. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28090?quickstart=1) ## **Related issues** Related: https://github.com/MetaMask/core/pull/4846 Fixes: https://github.com/MetaMask/metamask-extension/issues/28101 Fixes: https://github.com/MetaMask/metamask-extension/issues/27977 ## **Manual testing steps** The easiest way to test this would be a combination of using the test dapp and the following request to switch chains ``` await window.ethereum.request({ "method": "wallet_switchEthereumChain", "params": [ { chainId: "0x1" } ], }); ``` The behaviors you should see include: **One dapp:** * On a dapp permissioned for chain A and B, on chain A, queue up several send transactions, then use wallet_switchEthereumChain to switch to chain B. The send transactions should NOT get cleared immediately after requesting the chain switch. Chain switch should NOT happen until the previous approvals are approved/rejected. * On a dapp permissioned for chain A and B, on chain A, queue up one send transaction, then use wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should see chain B as the active chain for the dapp, and all subsequent approvals cleared/rejected automatically. * On a dapp permissioned for ONLY chain A, on chain A, queue up one send transaction, then use wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should an approval prompt for adding chain B. If you approve it, the dapp should then be on chain B, with all subsequent approvals cleared/rejected. If you disapprove it, you should be prompted with the subsequent approvals. * On a dapp permissioned for ONLY chain A, on chain A, wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should an approval prompt for adding chain B. If you approve it, the dapp should then be on chain B, with all subsequent approvals cleared/rejected. If you disapprove it, you should be prompted with the subsequent approvals. **Two dapps:** * On a dapp permissioned for chain A, on chain A, queue up several send transactions, On a separate dapp permissioned for chain A and B, on chain A, use wallet_switchEthereumChain to switch to chain B. The send transactions should NOT get cleared immediately after requesting the chain switch. Chain switch should NOT happen until the previous approvals are approved/rejected. * On a dapp permissioned for chain A and B, on chain A, queue up one send transaction. On a separate dapp permissioned for chain A and B, on chain A, use wallet_switchEthereumChain to switch to chain B. Then on the first dapp queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should see chain B as the active chain for the second dapp, and then you should still be prompted with the subsequent approvals for the first dapp. * One one dapp, start a wallet_addEthereumChain for a chain that does not exist in the wallet and leave the approval alone. On a different dapp, do the same thing. Only the request from the first dapp should be accessible (i.e. no scrubbing between both of them). After rejecting the first request, the second request should then appear (which will look exactly the same of course). Wallet should not lock up if you repeat this and accept either of the requests ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/2634119f-67db-4866-8520-9320a9400b1d https://github.com/user-attachments/assets/c78c13ab-ea4f-4420-bccc-70959786e8db ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: MetaMask Bot --- ...ToNonEvmAccountReqFilterMiddleware.test.ts | 7 +- app/scripts/metamask-controller.js | 46 ++++++----- lavamoat/browserify/beta/policy.json | 76 +++++-------------- lavamoat/browserify/flask/policy.json | 76 +++++-------------- lavamoat/browserify/main/policy.json | 76 +++++-------------- lavamoat/browserify/mmi/policy.json | 76 +++++-------------- package.json | 2 +- shared/constants/methods-tags.ts | 19 +++++ yarn.lock | 36 ++++----- 9 files changed, 149 insertions(+), 265 deletions(-) diff --git a/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts b/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts index 063271a9984a..09893ea05a5e 100644 --- a/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts +++ b/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts @@ -1,12 +1,11 @@ import { jsonrpc2, Json } from '@metamask/utils'; import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; -import type { JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; import createEvmMethodsToNonEvmAccountReqFilterMiddleware, { EvmMethodsToNonEvmAccountFilterMessenger, } from './createEvmMethodsToNonEvmAccountReqFilterMiddleware'; describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { - const getMockRequest = (method: string, params: Json) => ({ + const getMockRequest = (method: string, params: Record) => ({ jsonrpc: jsonrpc2, id: 1, method, @@ -286,7 +285,7 @@ describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { }: { accountType: EthAccountType | BtcAccountType; method: string; - params: Json; + params: Record; calledNext: number; }) => { const filterFn = createEvmMethodsToNonEvmAccountReqFilterMiddleware({ @@ -298,7 +297,7 @@ describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { const mockEnd = jest.fn(); filterFn( - getMockRequest(method, params) as JsonRpcRequest, + getMockRequest(method, params), getMockResponse(), mockNext, mockEnd, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5eba6f25c12e..2e9e68866030 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -156,7 +156,11 @@ import { NotificationServicesPushController, NotificationServicesController, } from '@metamask/notification-services-controller'; -import { methodsRequiringNetworkSwitch } from '../../shared/constants/methods-tags'; +import { + methodsRequiringNetworkSwitch, + methodsThatCanSwitchNetworkWithoutApproval, + methodsThatShouldBeEnqueued, +} from '../../shared/constants/methods-tags'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; @@ -485,22 +489,6 @@ export default class MetamaskController extends EventEmitter { this.approvalController.clear(providerErrors.userRejectedRequest()); }; - this.queuedRequestController = new QueuedRequestController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'QueuedRequestController', - allowedActions: [ - 'NetworkController:getState', - 'NetworkController:setActiveNetwork', - 'SelectedNetworkController:getNetworkClientIdForDomain', - ], - allowedEvents: ['SelectedNetworkController:stateChange'], - }), - shouldRequestSwitchNetwork: ({ method }) => - methodsRequiringNetworkSwitch.includes(method), - clearPendingConfirmations, - showApprovalRequest: opts.showUserConfirmation, - }); - this.approvalController = new ApprovalController({ messenger: this.controllerMessenger.getRestricted({ name: 'ApprovalController', @@ -516,6 +504,28 @@ export default class MetamaskController extends EventEmitter { ], }); + this.queuedRequestController = new QueuedRequestController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'QueuedRequestController', + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:setActiveNetwork', + 'SelectedNetworkController:getNetworkClientIdForDomain', + ], + allowedEvents: ['SelectedNetworkController:stateChange'], + }), + shouldRequestSwitchNetwork: ({ method }) => + methodsRequiringNetworkSwitch.includes(method), + canRequestSwitchNetworkWithoutApproval: ({ method }) => + methodsThatCanSwitchNetworkWithoutApproval.includes(method), + clearPendingConfirmations, + showApprovalRequest: () => { + if (this.approvalController.getTotalApprovalCount() > 0) { + opts.showUserConfirmation(); + } + }, + }); + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) this.mmiConfigurationController = new MmiConfigurationController({ initState: initState.MmiConfigurationController, @@ -5637,7 +5647,7 @@ export default class MetamaskController extends EventEmitter { this.preferencesController, ), shouldEnqueueRequest: (request) => { - return methodsRequiringNetworkSwitch.includes(request.method); + return methodsThatShouldBeEnqueued.includes(request.method); }, }); engine.push(requestQueueMiddleware); diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 09a0999ef6b0..d511ca2a5122 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1280,9 +1280,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/json-rpc-middleware-stream": { @@ -2153,64 +2168,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 09a0999ef6b0..d511ca2a5122 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1280,9 +1280,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/json-rpc-middleware-stream": { @@ -2153,64 +2168,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 09a0999ef6b0..d511ca2a5122 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1280,9 +1280,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/json-rpc-middleware-stream": { @@ -2153,64 +2168,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index b94fc59f9574..2f8a3f7cec97 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1372,9 +1372,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/json-rpc-middleware-stream": { @@ -2245,64 +2260,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/package.json b/package.json index 43513828d7fe..9191330a0c39 100644 --- a/package.json +++ b/package.json @@ -333,7 +333,7 @@ "@metamask/preinstalled-example-snap": "^0.2.0", "@metamask/profile-sync-controller": "^0.9.7", "@metamask/providers": "^14.0.2", - "@metamask/queued-request-controller": "^2.0.0", + "@metamask/queued-request-controller": "^7.0.0", "@metamask/rate-limit-controller": "^6.0.0", "@metamask/rpc-errors": "^7.0.0", "@metamask/safe-event-emitter": "^3.1.1", diff --git a/shared/constants/methods-tags.ts b/shared/constants/methods-tags.ts index a35954769b1b..329c0d493244 100644 --- a/shared/constants/methods-tags.ts +++ b/shared/constants/methods-tags.ts @@ -16,3 +16,22 @@ export const methodsRequiringNetworkSwitch = [ 'eth_signTypedData_v4', 'personal_sign', ] as const; + +/** + * This is a list of methods that may change the globally selected network + * without prompting for user approval. For UI/UX reasons these type of + * requests must be treated specially in the QueuedRequestController. + */ +export const methodsThatCanSwitchNetworkWithoutApproval = [ + 'wallet_addEthereumChain', + 'wallet_switchEthereumChain', +]; + +/** + * This is a list of methods that require special handling and must + * be enqueued and processed by the QueuedRequestController. + */ +export const methodsThatShouldBeEnqueued = [ + ...methodsRequiringNetworkSwitch, + ...methodsThatCanSwitchNetworkWithoutApproval, +]; diff --git a/yarn.lock b/yarn.lock index 175f874647f6..3b1dc9246bbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5489,14 +5489,14 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/json-rpc-engine@npm:10.0.0" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/json-rpc-engine@npm:10.0.1" dependencies: - "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^9.1.0" - checksum: 10/2c401a4a64392aeb11c4f7ca8d7b458ba1106cff1e0b3dba8b3e0cc90e82f8c55ac2dc9fdfcd914b289e3298fb726d637cf21382336dde2c207cf76129ce5eab + "@metamask/utils": "npm:^10.0.0" + checksum: 10/15a8eeab9af39b9ed87311da728e81169484ace733a8ef9fc469bd887654e37afa19f9e5228246dc80daad3fbf9b16067e73b2969d37d44acf5bc6ffa2c70082 languageName: node linkType: hard @@ -6051,20 +6051,20 @@ __metadata: languageName: node linkType: hard -"@metamask/queued-request-controller@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/queued-request-controller@npm:2.0.0" +"@metamask/queued-request-controller@npm:^7.0.0": + version: 7.0.0 + resolution: "@metamask/queued-request-controller@npm:7.0.0" dependencies: - "@metamask/base-controller": "npm:^6.0.0" - "@metamask/controller-utils": "npm:^11.0.0" - "@metamask/json-rpc-engine": "npm:^9.0.0" - "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.1" + "@metamask/json-rpc-engine": "npm:^10.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/swappable-obj-proxy": "npm:^2.2.0" - "@metamask/utils": "npm:^8.3.0" + "@metamask/utils": "npm:^10.0.0" peerDependencies: - "@metamask/network-controller": ^19.0.0 - "@metamask/selected-network-controller": ^15.0.0 - checksum: 10/b618fa05465a52e5b689d932d99b47552b5987a9141d58260966611f1057190132f14b1a2123c48399f218fc57c577e1c86375e8ee2b43871cdc597fbaeedb7a + "@metamask/network-controller": ^22.0.0 + "@metamask/selected-network-controller": ^19.0.0 + checksum: 10/69118c11e3faecdbec7c9f02f4ecec4734ce0950115bfac0cdd4338309898690ae3187bcef1cc4f75f54c5c02eff07d80286d3ef29088a665039c13cb50bef88 languageName: node linkType: hard @@ -25992,7 +25992,7 @@ __metadata: "@metamask/preinstalled-example-snap": "npm:^0.2.0" "@metamask/profile-sync-controller": "npm:^0.9.7" "@metamask/providers": "npm:^14.0.2" - "@metamask/queued-request-controller": "npm:^2.0.0" + "@metamask/queued-request-controller": "npm:^7.0.0" "@metamask/rate-limit-controller": "npm:^6.0.0" "@metamask/rpc-errors": "npm:^7.0.0" "@metamask/safe-event-emitter": "npm:^3.1.1" From 43e43b360e65dfe26244ff0070a039f15d3dbfa9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Oct 2024 21:18:00 +0100 Subject: [PATCH 25/56] chore(cherry-pick): update @metamask/bitcoin-wallet-snap to 0.8.2 (#28135) (#28140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We did update the permission for the Bitcoin Snap in the 0.8.2. We'd like to have this in the upcoming release as discussed internally. > [!IMPORTANT] > This update does not invalidate anything regarding the testing that has been done for Bitcoin support. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28140?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. `yarn start:flask` 2. Enable Bitcoin support 3. Create your Bitcoin accounts (mainnet + testnet) 4. Interact with your accounts: - Check the balance - Initiate a send flow ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 767646ae91ed..d4517b42229d 100644 --- a/package.json +++ b/package.json @@ -302,7 +302,7 @@ "@metamask/approval-controller": "^7.0.0", "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A38.3.0#~/.yarn/patches/@metamask-assets-controllers-npm-38.3.0-57b3d695bb.patch", "@metamask/base-controller": "^7.0.0", - "@metamask/bitcoin-wallet-snap": "^0.8.1", + "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", "@metamask/contract-metadata": "^2.5.0", "@metamask/controller-utils": "^11.2.0", diff --git a/yarn.lock b/yarn.lock index 54b160bc821e..3558c24d8d5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4982,10 +4982,10 @@ __metadata: languageName: node linkType: hard -"@metamask/bitcoin-wallet-snap@npm:^0.8.1": - version: 0.8.1 - resolution: "@metamask/bitcoin-wallet-snap@npm:0.8.1" - checksum: 10/0fff706a98c6f798ae0ae78bf9a8913c0b056b18aff64f994e521c5005ab7e326fafe1d383b2b7c248456948eaa263df3b31a081d620d82ed7c266857c94a955 +"@metamask/bitcoin-wallet-snap@npm:^0.8.2": + version: 0.8.2 + resolution: "@metamask/bitcoin-wallet-snap@npm:0.8.2" + checksum: 10/42da719ae59b12d7150e513f082351dab8f901587ca12897b43c0b5d9123bbf066a2666c48b81b25e594f97ef237e1d1d7e9ccea8bd9bfb54910c5cd8d43b420 languageName: node linkType: hard @@ -26097,7 +26097,7 @@ __metadata: "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A38.3.0#~/.yarn/patches/@metamask-assets-controllers-npm-38.3.0-57b3d695bb.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" - "@metamask/bitcoin-wallet-snap": "npm:^0.8.1" + "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" "@metamask/browser-passworder": "npm:^4.3.0" "@metamask/build-utils": "npm:^3.0.0" "@metamask/contract-metadata": "npm:^2.5.0" From ac1173b9c2fde2980aa3dff8910253627417bc5f Mon Sep 17 00:00:00 2001 From: Nidhi Kumari Date: Thu, 31 Oct 2024 02:37:02 +0530 Subject: [PATCH 26/56] =?UTF-8?q?feat=20(cherry-pick):=20added=20test=20ne?= =?UTF-8?q?twork=20as=20selected=20network=20if=20globally=20selected=20fo?= =?UTF-8?q?r=E2=80=A6=20(#28139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … connection Request (#27980) This PR is to select a test network in the default selected networks list if its the globally selected network at the time of connection request. ## **Related issues** Fixes: [#27891](https://github.com/MetaMask/metamask-extension/issues/27891) ## **Manual testing steps** 1. Run extension with yarn start 2. Switch to Sepolia 3. Go to test-dapp, click on connect. 4. In the connections page, check Sepolia is the selected along with non test networks 5. Click confirm, dapp should be connected to MM and user should be on Sepolia network in MM. ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/127fc7bb-2e68-411a-b407-7f6d5158e911 ### **After** https://github.com/user-attachments/assets/dd0b5aa6-404a-421f-93a4-67cab43d60c6 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28139?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../api-specs/ConfirmationRejectionRule.ts | 14 ---- test/e2e/helpers.js | 10 --- test/e2e/json-rpc/switchEthereumChain.spec.js | 65 ++++++++++++++++--- test/e2e/page-objects/pages/test-dapp.ts | 9 --- .../e2e/snaps/test-snap-txinsights-v2.spec.js | 5 ++ .../connections/edit-networks-flow.spec.js | 8 --- .../dapp1-switch-dapp2-send.spec.js | 32 ++++++--- ...multi-dapp-sendTx-revokePermission.spec.js | 4 +- .../switchChain-watchAsset.spec.js | 14 +++- .../connect-page/connect-page.tsx | 17 ++++- 10 files changed, 114 insertions(+), 64 deletions(-) diff --git a/test/e2e/api-specs/ConfirmationRejectionRule.ts b/test/e2e/api-specs/ConfirmationRejectionRule.ts index 3e37dcd07fd7..43046d8b0943 100644 --- a/test/e2e/api-specs/ConfirmationRejectionRule.ts +++ b/test/e2e/api-specs/ConfirmationRejectionRule.ts @@ -73,20 +73,6 @@ export class ConfirmationsRejectRule implements Rule { tag: 'button', }); - const editButtons = await this.driver.findElements( - '[data-testid="edit"]', - ); - await editButtons[1].click(); - - await this.driver.clickElement({ - text: 'Localhost 8545', - tag: 'p', - }); - - await this.driver.clickElement( - '[data-testid="connect-more-chains-button"]', - ); - const screenshotTwo = await this.driver.driver.takeScreenshot(); call.attachments.push({ type: 'image', diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index c857838f0810..6d2ccebeb7c7 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -783,16 +783,6 @@ const connectToDapp = async (driver) => { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - const editButtons = await driver.findElements('[data-testid="edit"]'); - await editButtons[1].click(); - - await driver.clickElement({ - text: 'Localhost 8545', - tag: 'p', - }); - - await driver.clickElement('[data-testid="connect-more-chains-button"]'); - await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', diff --git a/test/e2e/json-rpc/switchEthereumChain.spec.js b/test/e2e/json-rpc/switchEthereumChain.spec.js index fba06db48131..60ba4eb9aacb 100644 --- a/test/e2e/json-rpc/switchEthereumChain.spec.js +++ b/test/e2e/json-rpc/switchEthereumChain.spec.js @@ -157,8 +157,34 @@ describe('Switch Ethereum Chain for two dapps', function () { tag: 'button', }); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); + // Switch to Dapp One and connect it + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findClickableElement({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElement('#connectButton'); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + await editButtons[1].click(); + + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + + await driver.clickElement('[data-testid="connect-more-chains-button"]'); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Switch to Dapp Two + await driver.switchToWindowWithUrl(DAPP_ONE_URL); // Initiate send transaction on Dapp two await driver.clickElement('#sendButton'); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); @@ -181,8 +207,6 @@ describe('Switch Ethereum Chain for two dapps', function () { await driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); - - // Switch to tx and confirm send tx. await switchToNotificationWindow(driver, 4); await driver.findClickableElements({ text: 'Confirm', @@ -192,7 +216,6 @@ describe('Switch Ethereum Chain for two dapps', function () { text: 'Confirm', tag: 'button', }); - // Delay here after notification for second notification popup for switchEthereumChain await driver.delay(1000); @@ -203,7 +226,12 @@ describe('Switch Ethereum Chain for two dapps', function () { text: 'Confirm', tag: 'button', }); - await driver.clickElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ css: '#chainId', text: '0x539' }); }, ); }); @@ -273,7 +301,18 @@ describe('Switch Ethereum Chain for two dapps', function () { await driver.clickElement('#connectButton'); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + // Click the edit button for networks + await editButtons[1].click(); + + // Disconnect Mainnet + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + await driver.clickElement('[data-testid="connect-more-chains-button"]'); await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', @@ -293,14 +332,11 @@ describe('Switch Ethereum Chain for two dapps', function () { await driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); - - // Switch to notification of switchEthereumChain await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await driver.findClickableElements({ text: 'Confirm', tag: 'button', }); - // Switch back to dapp one await driver.switchToWindow(dappOne); assert.equal(await driver.getCurrentUrl(), `${DAPP_URL}/`); @@ -397,11 +433,22 @@ describe('Switch Ethereum Chain for two dapps', function () { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + // Click the edit button for networks + await editButtons[1].click(); + + // Disconnect Mainnet + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + + await driver.clickElement('[data-testid="connect-more-chains-button"]'); await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', }); - await driver.switchToWindow(dappTwo); assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_URL}/`); diff --git a/test/e2e/page-objects/pages/test-dapp.ts b/test/e2e/page-objects/pages/test-dapp.ts index 4a02d80459e0..b9487ee599b9 100644 --- a/test/e2e/page-objects/pages/test-dapp.ts +++ b/test/e2e/page-objects/pages/test-dapp.ts @@ -209,15 +209,6 @@ class TestDapp { await this.driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await this.driver.waitForSelector(this.connectMetaMaskMessage); - // TODO: Extra steps needed to preserve the current network. - // Following steps can be removed once the issue is fixed (#27891) - const editNetworkButton = await this.driver.findClickableElements( - this.editConnectButton, - ); - await editNetworkButton[1].click(); - await this.driver.clickElement(this.localhostCheckbox); - await this.driver.clickElement(this.updateNetworkButton); - await this.driver.clickElementAndWaitForWindowToClose( this.confirmDialogButton, ); diff --git a/test/e2e/snaps/test-snap-txinsights-v2.spec.js b/test/e2e/snaps/test-snap-txinsights-v2.spec.js index 5fb56687de96..830629d1c43e 100644 --- a/test/e2e/snaps/test-snap-txinsights-v2.spec.js +++ b/test/e2e/snaps/test-snap-txinsights-v2.spec.js @@ -127,6 +127,11 @@ describe('Test Snap TxInsights-v2', function () { tag: 'button', text: 'Activity', }); + // wait for transaction confirmation + await driver.waitForSelector({ + css: '.transaction-status-label', + text: 'Confirmed', + }); }, ); }); diff --git a/test/e2e/tests/connections/edit-networks-flow.spec.js b/test/e2e/tests/connections/edit-networks-flow.spec.js index e14e1ae325d5..1db224f0ac0a 100644 --- a/test/e2e/tests/connections/edit-networks-flow.spec.js +++ b/test/e2e/tests/connections/edit-networks-flow.spec.js @@ -9,11 +9,6 @@ const { } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); -async function switchToNetworkByName(driver, networkName) { - await driver.clickElement('.mm-picker-network'); - await driver.clickElement(`[data-testid="${networkName}"]`); -} - describe('Edit Networks Flow', function () { it('should be able to edit networks', async function () { await withFixtures( @@ -43,9 +38,6 @@ describe('Edit Networks Flow', function () { await driver.clickElement( '.mm-modal-content__dialog button[aria-label="Close"]', ); - - // Switch to first network, whose send transaction was just confirmed - await switchToNetworkByName(driver, 'Localhost 8545'); await locateAccountBalanceDOM(driver); await driver.clickElement( '[data-testid ="account-options-menu-button"]', diff --git a/test/e2e/tests/request-queuing/dapp1-switch-dapp2-send.spec.js b/test/e2e/tests/request-queuing/dapp1-switch-dapp2-send.spec.js index c330596c48f3..c98e0eb229c6 100644 --- a/test/e2e/tests/request-queuing/dapp1-switch-dapp2-send.spec.js +++ b/test/e2e/tests/request-queuing/dapp1-switch-dapp2-send.spec.js @@ -51,6 +51,17 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + await editButtons[1].click(); + + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + + await driver.clickElement('[data-testid="connect-more-chains-button"]'); await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', @@ -93,7 +104,7 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { params: [{ chainId: '0x539' }], }); - // Initiate switchEthereumChain on Dapp Two + // Initiate switchEthereumChain on Dapp One await driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); @@ -192,7 +203,17 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { await driver.clickElement('#connectButton'); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + await editButtons[1].click(); + + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + await driver.clickElement('[data-testid="connect-more-chains-button"]'); await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', @@ -235,17 +256,11 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { params: [{ chainId: '0x539' }], }); - // Initiate switchEthereumChain on Dapp Two + // Initiate switchEthereumChain on Dapp One await driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.findElement({ - text: 'Use your enabled networks', - tag: 'p', - }); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); await driver.clickElement('#sendButton'); @@ -259,6 +274,7 @@ describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { // There is an extra window appearing and disappearing // so we leave this delay until the issue is fixed (#27360) await driver.delay(5000); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); // Check correct network on the send confirmation. diff --git a/test/e2e/tests/request-queuing/multi-dapp-sendTx-revokePermission.spec.js b/test/e2e/tests/request-queuing/multi-dapp-sendTx-revokePermission.spec.js index d32e96e29571..06d232635131 100644 --- a/test/e2e/tests/request-queuing/multi-dapp-sendTx-revokePermission.spec.js +++ b/test/e2e/tests/request-queuing/multi-dapp-sendTx-revokePermission.spec.js @@ -88,7 +88,7 @@ describe('Request Queuing for Multiple Dapps and Txs on different networks revok await driver.switchToWindowWithUrl(DAPP_URL); await driver.findElement({ css: '[id="chainId"]', - text: '0x1', + text: '0x539', }); await driver.clickElement('#sendButton'); @@ -108,7 +108,7 @@ describe('Request Queuing for Multiple Dapps and Txs on different networks revok await driver.switchToWindowWithUrl(DAPP_URL); await driver.findElement({ css: '[id="chainId"]', - text: '0x1', + text: '0x539', }); await driver.assertElementNotPresent({ css: '[id="chainId"]', diff --git a/test/e2e/tests/request-queuing/switchChain-watchAsset.spec.js b/test/e2e/tests/request-queuing/switchChain-watchAsset.spec.js index 308a9c36914b..5767bd26def5 100644 --- a/test/e2e/tests/request-queuing/switchChain-watchAsset.spec.js +++ b/test/e2e/tests/request-queuing/switchChain-watchAsset.spec.js @@ -3,9 +3,9 @@ const { defaultGanacheOptions, logInWithBalanceValidation, openDapp, - switchToNotificationWindow, WINDOW_TITLES, withFixtures, + switchToNotificationWindow, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); const { DAPP_URL } = require('../../constants'); @@ -48,7 +48,17 @@ describe('Request Queue SwitchChain -> WatchAsset', function () { await driver.clickElement('#connectButton'); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); + + await editButtons[1].click(); + // Disconnect Localhost 8545. By Default, this was the globally selected network + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); + + await driver.clickElement('[data-testid="connect-more-chains-button"]'); await driver.clickElementAndWaitForWindowToClose({ text: 'Connect', tag: 'button', @@ -72,7 +82,6 @@ describe('Request Queue SwitchChain -> WatchAsset', function () { text: 'Use your enabled networks', tag: 'p', }); - // Switch back to test dapp await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); @@ -81,7 +90,6 @@ describe('Request Queue SwitchChain -> WatchAsset', function () { text: 'Add Token(s) to Wallet', tag: 'button', }); - await switchToNotificationWindow(driver); // Confirm Switch Network diff --git a/ui/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index 0ae22b3d9e0f..e002e54ef34e 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -39,6 +39,7 @@ import { EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; +import { getMultichainNetwork } from '../../../selectors/multichain'; export type ConnectPageRequest = { id: string; @@ -92,10 +93,24 @@ export const ConnectPage: React.FC = ({ ), [networkConfigurations], ); + + // By default, if a non test network is the globally selected network. We will only show non test networks as default selected. + const currentlySelectedNetwork = useSelector(getMultichainNetwork); + const currentlySelectedNetworkChainId = + currentlySelectedNetwork.network.chainId; + // If globally selected network is a test network, include that in the default selcted networks for connection request + const selectedTestNetwork = testNetworks.find( + (network: { chainId: string }) => + network.chainId === currentlySelectedNetworkChainId, + ); + + const selectedNetworksList = selectedTestNetwork + ? [...nonTestNetworks, selectedTestNetwork] + : nonTestNetworks; const defaultSelectedChainIds = requestedChainIds.length > 0 ? requestedChainIds - : nonTestNetworks.map(({ chainId }) => chainId); + : selectedNetworksList.map(({ chainId }) => chainId); const [selectedChainIds, setSelectedChainIds] = useState( defaultSelectedChainIds, ); From 334c63c8b5db83c13ed013fd83180e242f8709fe Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 30 Oct 2024 19:39:02 -0230 Subject: [PATCH 27/56] [cherry pick] Fix bugs related to queued requests (#28197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a cherry-pick of #28090 for v12.6.0. Original description: ## **Description** Bumps `@metamask/queued-request-controller` to fix queueing issue with Chain Permission `wallet_switchEthereumChain` and `wallet_addEthereumChain` when switching to a previously permitted chain and with `wallet_addEthereumChain` not being enqueued when it still should be. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28090?quickstart=1) ## **Related issues** Related: https://github.com/MetaMask/core/pull/4846 Fixes: https://github.com/MetaMask/metamask-extension/issues/28101 Fixes: https://github.com/MetaMask/metamask-extension/issues/27977 Fixes: #28102 ## **Manual testing steps** The easiest way to test this would be a combination of using the test dapp and the following request to switch chains ``` await window.ethereum.request({ "method": "wallet_switchEthereumChain", "params": [ { chainId: "0x1" } ], }); ``` The behaviors you should see include: **One dapp:** * On a dapp permissioned for chain A and B, on chain A, queue up several send transactions, then use wallet_switchEthereumChain to switch to chain B. The send transactions should NOT get cleared immediately after requesting the chain switch. Chain switch should NOT happen until the previous approvals are approved/rejected. * On a dapp permissioned for chain A and B, on chain A, queue up one send transaction, then use wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should see chain B as the active chain for the dapp, and all subsequent approvals cleared/rejected automatically. * On a dapp permissioned for ONLY chain A, on chain A, queue up one send transaction, then use wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should an approval prompt for adding chain B. If you approve it, the dapp should then be on chain B, with all subsequent approvals cleared/rejected. If you disapprove it, you should be prompted with the subsequent approvals. * On a dapp permissioned for ONLY chain A, on chain A, wallet_switchEthereumChain to switch to chain B, then queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should an approval prompt for adding chain B. If you approve it, the dapp should then be on chain B, with all subsequent approvals cleared/rejected. If you disapprove it, you should be prompted with the subsequent approvals. **Two dapps:** * On a dapp permissioned for chain A, on chain A, queue up several send transactions, On a separate dapp permissioned for chain A and B, on chain A, use wallet_switchEthereumChain to switch to chain B. The send transactions should NOT get cleared immediately after requesting the chain switch. Chain switch should NOT happen until the previous approvals are approved/rejected. * On a dapp permissioned for chain A and B, on chain A, queue up one send transaction. On a separate dapp permissioned for chain A and B, on chain A, use wallet_switchEthereumChain to switch to chain B. Then on the first dapp queue up several more send transactions. Reject/approve the first transaction. Afterwards, you should see chain B as the active chain for the second dapp, and then you should still be prompted with the subsequent approvals for the first dapp. * One one dapp, start a wallet_addEthereumChain for a chain that does not exist in the wallet and leave the approval alone. On a different dapp, do the same thing. Only the request from the first dapp should be accessible (i.e. no scrubbing between both of them). After rejecting the first request, the second request should then appear (which will look exactly the same of course). Wallet should not lock up if you repeat this and accept either of the requests ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/2634119f-67db-4866-8520-9320a9400b1d https://github.com/user-attachments/assets/c78c13ab-ea4f-4420-bccc-70959786e8db ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: jiexi Co-authored-by: MetaMask Bot --- ...ToNonEvmAccountReqFilterMiddleware.test.ts | 7 +- app/scripts/metamask-controller.js | 46 ++++--- lavamoat/browserify/beta/policy.json | 112 +++++++++--------- lavamoat/browserify/flask/policy.json | 112 +++++++++--------- lavamoat/browserify/main/policy.json | 112 +++++++++--------- lavamoat/browserify/mmi/policy.json | 112 +++++++++--------- package.json | 2 +- shared/constants/methods-tags.ts | 19 +++ yarn.lock | 84 +++++++------ 9 files changed, 314 insertions(+), 292 deletions(-) diff --git a/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts b/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts index 063271a9984a..09893ea05a5e 100644 --- a/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts +++ b/app/scripts/lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware.test.ts @@ -1,12 +1,11 @@ import { jsonrpc2, Json } from '@metamask/utils'; import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; -import type { JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; import createEvmMethodsToNonEvmAccountReqFilterMiddleware, { EvmMethodsToNonEvmAccountFilterMessenger, } from './createEvmMethodsToNonEvmAccountReqFilterMiddleware'; describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { - const getMockRequest = (method: string, params: Json) => ({ + const getMockRequest = (method: string, params: Record) => ({ jsonrpc: jsonrpc2, id: 1, method, @@ -286,7 +285,7 @@ describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { }: { accountType: EthAccountType | BtcAccountType; method: string; - params: Json; + params: Record; calledNext: number; }) => { const filterFn = createEvmMethodsToNonEvmAccountReqFilterMiddleware({ @@ -298,7 +297,7 @@ describe('createEvmMethodsToNonEvmAccountReqFilterMiddleware', () => { const mockEnd = jest.fn(); filterFn( - getMockRequest(method, params) as JsonRpcRequest, + getMockRequest(method, params), getMockResponse(), mockNext, mockEnd, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index c33485f665b7..4284a2614a9d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -157,7 +157,11 @@ import { NotificationServicesController, } from '@metamask/notification-services-controller'; import { isProduction } from '../../shared/modules/environment'; -import { methodsRequiringNetworkSwitch } from '../../shared/constants/methods-tags'; +import { + methodsRequiringNetworkSwitch, + methodsThatCanSwitchNetworkWithoutApproval, + methodsThatShouldBeEnqueued, +} from '../../shared/constants/methods-tags'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; @@ -486,22 +490,6 @@ export default class MetamaskController extends EventEmitter { this.approvalController.clear(providerErrors.userRejectedRequest()); }; - this.queuedRequestController = new QueuedRequestController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'QueuedRequestController', - allowedActions: [ - 'NetworkController:getState', - 'NetworkController:setActiveNetwork', - 'SelectedNetworkController:getNetworkClientIdForDomain', - ], - allowedEvents: ['SelectedNetworkController:stateChange'], - }), - shouldRequestSwitchNetwork: ({ method }) => - methodsRequiringNetworkSwitch.includes(method), - clearPendingConfirmations, - showApprovalRequest: opts.showUserConfirmation, - }); - this.approvalController = new ApprovalController({ messenger: this.controllerMessenger.getRestricted({ name: 'ApprovalController', @@ -517,6 +505,28 @@ export default class MetamaskController extends EventEmitter { ], }); + this.queuedRequestController = new QueuedRequestController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'QueuedRequestController', + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:setActiveNetwork', + 'SelectedNetworkController:getNetworkClientIdForDomain', + ], + allowedEvents: ['SelectedNetworkController:stateChange'], + }), + shouldRequestSwitchNetwork: ({ method }) => + methodsRequiringNetworkSwitch.includes(method), + canRequestSwitchNetworkWithoutApproval: ({ method }) => + methodsThatCanSwitchNetworkWithoutApproval.includes(method), + clearPendingConfirmations, + showApprovalRequest: () => { + if (this.approvalController.getTotalApprovalCount() > 0) { + opts.showUserConfirmation(); + } + }, + }); + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) this.mmiConfigurationController = new MmiConfigurationController({ initState: initState.MmiConfigurationController, @@ -5642,7 +5652,7 @@ export default class MetamaskController extends EventEmitter { this.preferencesController, ), shouldEnqueueRequest: (request) => { - return methodsRequiringNetworkSwitch.includes(request.method); + return methodsThatShouldBeEnqueued.includes(request.method); }, }); engine.push(requestQueueMiddleware); diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index b9b6062a30d7..943338c29bdb 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -787,15 +787,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -1354,9 +1369,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/keyring-api": { @@ -2197,64 +2227,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -2326,8 +2305,23 @@ }, "@metamask/rpc-errors": { "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/rpc-errors>@metamask/utils": true, + "@metamask/rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/rpc-errors>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/rpc-methods-flask>nanoid": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index b9b6062a30d7..943338c29bdb 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -787,15 +787,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -1354,9 +1369,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/keyring-api": { @@ -2197,64 +2227,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -2326,8 +2305,23 @@ }, "@metamask/rpc-errors": { "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/rpc-errors>@metamask/utils": true, + "@metamask/rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/rpc-errors>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/rpc-methods-flask>nanoid": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index b9b6062a30d7..943338c29bdb 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -787,15 +787,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -1354,9 +1369,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/keyring-api": { @@ -2197,64 +2227,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -2326,8 +2305,23 @@ }, "@metamask/rpc-errors": { "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/rpc-errors>@metamask/utils": true, + "@metamask/rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/rpc-errors>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/rpc-methods-flask>nanoid": { diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 073882b78df5..321e8ea7e6ff 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -879,15 +879,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -1446,9 +1461,24 @@ }, "@metamask/json-rpc-engine": { "packages": { + "@metamask/json-rpc-engine>@metamask/utils": true, "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "@metamask/utils": true + "@metamask/safe-event-emitter": true + } + }, + "@metamask/json-rpc-engine>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/keyring-api": { @@ -2289,64 +2319,13 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, + "@metamask/base-controller": true, + "@metamask/json-rpc-engine": true, "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true } }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/queued-request-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/queued-request-controller>@metamask/json-rpc-engine>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/queued-request-controller>@metamask/rpc-errors>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/queued-request-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -2418,8 +2397,23 @@ }, "@metamask/rpc-errors": { "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/rpc-errors>@metamask/utils": true, + "@metamask/rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/rpc-errors>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/rpc-methods-flask>nanoid": { diff --git a/package.json b/package.json index d4517b42229d..bd0ec3a52409 100644 --- a/package.json +++ b/package.json @@ -345,7 +345,7 @@ "@metamask/preinstalled-example-snap": "^0.2.0", "@metamask/profile-sync-controller": "^0.9.7", "@metamask/providers": "^14.0.2", - "@metamask/queued-request-controller": "^2.0.0", + "@metamask/queued-request-controller": "^7.0.0", "@metamask/rate-limit-controller": "^6.0.0", "@metamask/rpc-errors": "^7.0.0", "@metamask/safe-event-emitter": "^3.1.1", diff --git a/shared/constants/methods-tags.ts b/shared/constants/methods-tags.ts index a35954769b1b..329c0d493244 100644 --- a/shared/constants/methods-tags.ts +++ b/shared/constants/methods-tags.ts @@ -16,3 +16,22 @@ export const methodsRequiringNetworkSwitch = [ 'eth_signTypedData_v4', 'personal_sign', ] as const; + +/** + * This is a list of methods that may change the globally selected network + * without prompting for user approval. For UI/UX reasons these type of + * requests must be treated specially in the QueuedRequestController. + */ +export const methodsThatCanSwitchNetworkWithoutApproval = [ + 'wallet_addEthereumChain', + 'wallet_switchEthereumChain', +]; + +/** + * This is a list of methods that require special handling and must + * be enqueued and processed by the QueuedRequestController. + */ +export const methodsThatShouldBeEnqueued = [ + ...methodsRequiringNetworkSwitch, + ...methodsThatCanSwitchNetworkWithoutApproval, +]; diff --git a/yarn.lock b/yarn.lock index 3558c24d8d5d..2643747a815a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4972,13 +4972,13 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^7.0.0, @metamask/base-controller@npm:^7.0.1": - version: 7.0.1 - resolution: "@metamask/base-controller@npm:7.0.1" +"@metamask/base-controller@npm:^7.0.0, @metamask/base-controller@npm:^7.0.1, @metamask/base-controller@npm:^7.0.2": + version: 7.0.2 + resolution: "@metamask/base-controller@npm:7.0.2" dependencies: - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" immer: "npm:^9.0.6" - checksum: 10/774b6d68ac95a5ec187e890d321bede50065f8a6f1ba7b49a19f5971366274054ac0e401548b51d3b014d0bca5d650409fb554dd13ce120e7fb3495b4e8e67b1 + checksum: 10/6f78ec5af840c9947aa8eac6e402df6469600260d613a92196daefd5b072097a176fe5da1c386f2d36853513254b74140d667d817a12880c46f088e18ff3606a languageName: node linkType: hard @@ -5015,20 +5015,21 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0": - version: 11.4.0 - resolution: "@metamask/controller-utils@npm:11.4.0" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0, @metamask/controller-utils@npm:^11.4.1": + version: 11.4.2 + resolution: "@metamask/controller-utils@npm:11.4.2" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" "@spruceid/siwe-parser": "npm:2.1.0" "@types/bn.js": "npm:^5.1.5" + bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/f34d24880eab264bddaa5bef21afaecb206db6978364565d0f7b7a54b1d411f129eb84175041df3be8a66394c2d49e83b6648b5cbde6f34662a60fc553c31458 + checksum: 10/fdae49ee97e7a2a1bb6414011ca59932f8712a768a9c4c43673a2504c9fa9e61d83df53a21ff0506ef6a8cf774704f2df58a6d71385c8786ec5cab4359c051e1 languageName: node linkType: hard @@ -5582,14 +5583,14 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/json-rpc-engine@npm:10.0.0" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/json-rpc-engine@npm:10.0.1" dependencies: - "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^9.1.0" - checksum: 10/2c401a4a64392aeb11c4f7ca8d7b458ba1106cff1e0b3dba8b3e0cc90e82f8c55ac2dc9fdfcd914b289e3298fb726d637cf21382336dde2c207cf76129ce5eab + "@metamask/utils": "npm:^10.0.0" + checksum: 10/15a8eeab9af39b9ed87311da728e81169484ace733a8ef9fc469bd887654e37afa19f9e5228246dc80daad3fbf9b16067e73b2969d37d44acf5bc6ffa2c70082 languageName: node linkType: hard @@ -6137,20 +6138,20 @@ __metadata: languageName: node linkType: hard -"@metamask/queued-request-controller@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/queued-request-controller@npm:2.0.0" +"@metamask/queued-request-controller@npm:^7.0.0": + version: 7.0.0 + resolution: "@metamask/queued-request-controller@npm:7.0.0" dependencies: - "@metamask/base-controller": "npm:^6.0.0" - "@metamask/controller-utils": "npm:^11.0.0" - "@metamask/json-rpc-engine": "npm:^9.0.0" - "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.1" + "@metamask/json-rpc-engine": "npm:^10.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/swappable-obj-proxy": "npm:^2.2.0" - "@metamask/utils": "npm:^8.3.0" + "@metamask/utils": "npm:^10.0.0" peerDependencies: - "@metamask/network-controller": ^19.0.0 - "@metamask/selected-network-controller": ^15.0.0 - checksum: 10/b618fa05465a52e5b689d932d99b47552b5987a9141d58260966611f1057190132f14b1a2123c48399f218fc57c577e1c86375e8ee2b43871cdc597fbaeedb7a + "@metamask/network-controller": ^22.0.0 + "@metamask/selected-network-controller": ^19.0.0 + checksum: 10/69118c11e3faecdbec7c9f02f4ecec4734ce0950115bfac0cdd4338309898690ae3187bcef1cc4f75f54c5c02eff07d80286d3ef29088a665039c13cb50bef88 languageName: node linkType: hard @@ -6175,13 +6176,13 @@ __metadata: languageName: node linkType: hard -"@metamask/rpc-errors@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/rpc-errors@npm:7.0.0" +"@metamask/rpc-errors@npm:^7.0.0, @metamask/rpc-errors@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/rpc-errors@npm:7.0.1" dependencies: - "@metamask/utils": "npm:^9.0.0" + "@metamask/utils": "npm:^10.0.0" fast-safe-stringify: "npm:^2.0.6" - checksum: 10/f25e2a5506d4d0d6193c88aef8f035ec189a1177f8aee8fa01c9a33d73b1536ca7b5eea2fb33a477768bbd2abaf16529e68f0b3cf714387e5d6c9178225354fd + checksum: 10/819708b4a7d9695ee67fd867d8f94bb5a273b479a242b17bd53c83d1fceec421fc42928f0bb340f4f138ec803dd82ec9659ce7b09a86aedad6a81d5a39ec5c35 languageName: node linkType: hard @@ -6583,6 +6584,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/utils@npm:10.0.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + debug: "npm:^4.3.4" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/9c2e6421f685d8a45145b6026a6f9fd0701eb5a2e8490fc6d18e64c103d5a62097f301cbc797790da52ceb5853bd9f65845c934b00299e69e5e6736c52b32f0f + languageName: node + linkType: hard + "@metamask/utils@npm:^8.1.0, @metamask/utils@npm:^8.2.0, @metamask/utils@npm:^8.3.0": version: 8.5.0 resolution: "@metamask/utils@npm:8.5.0" @@ -26150,7 +26168,7 @@ __metadata: "@metamask/preinstalled-example-snap": "npm:^0.2.0" "@metamask/profile-sync-controller": "npm:^0.9.7" "@metamask/providers": "npm:^14.0.2" - "@metamask/queued-request-controller": "npm:^2.0.0" + "@metamask/queued-request-controller": "npm:^7.0.0" "@metamask/rate-limit-controller": "npm:^6.0.0" "@metamask/rpc-errors": "npm:^7.0.0" "@metamask/safe-event-emitter": "npm:^3.1.1" From 77064317eba54d76bba0338c75c8416b8fb63886 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 31 Oct 2024 00:16:02 -0230 Subject: [PATCH 28/56] fix (cherry-pick): Fix audit failures v12.5.1 (#28187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picks https://github.com/MetaMask/metamask-extension/pull/28145 to v12.5.1, and brings https://github.com/MetaMask/metamask-extension/pull/28177 into v12.5.1 as well. The latter originally targeted v12.6.0, but we need it for a hotfix ahead of that. The PR description for that was: ## **Description** Forcing resolutions to fix `yarn audit` warnings and more specifically this issue: - https://github.com/advisories/GHSA-584q-6j8j-r5pm I decided to be very explicit about the resolution itself based on the output of: ```console $ yarn why secp256k1 ├─ ethereum-cryptography@npm:0.1.3 │ └─ secp256k1@npm:4.0.4 (via npm:^4.0.1) │ ├─ ganache@npm:7.9.2 │ └─ secp256k1@npm:4.0.3 (via npm:4.0.3) │ ├─ ganache@patch:ganache@npm%3A7.9.2#~/.yarn/patches/ganache-npm-7.9.2-a70dc8da34.patch::version=7.9.2&hash=7d7c66 │ └─ secp256k1@npm:4.0.3 (via npm:4.0.3) │ ├─ gridplus-sdk@npm:2.5.1 │ └─ secp256k1@npm:4.0.2 (via npm:4.0.2) │ └─ hdkey@npm:2.1.0 └─ secp256k1@npm:4.0.4 (via npm:^4.0.0) ``` We could also have a more straightforward resolution like: ```json ... "resolutions": { ... "secp256k1": "4.0.4" } ... ``` But that could also catch version with different major. Let me know what would be the preferred solution here. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28176?quickstart=1) ## **Related issues** Fixes: https://github.com/advisories/GHSA-584q-6j8j-r5pm ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: Charly Chevalier Co-authored-by: sahar-fehri --- .yarnrc.yml | 3 +- lavamoat/browserify/beta/policy.json | 18 +----------- lavamoat/browserify/flask/policy.json | 18 +----------- lavamoat/browserify/main/policy.json | 18 +----------- lavamoat/browserify/mmi/policy.json | 18 +----------- package.json | 6 +++- yarn.lock | 41 +++++++++++++-------------- 7 files changed, 30 insertions(+), 92 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index fb335f532861..9d0f9e431392 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -123,7 +123,8 @@ npmAuditIgnoreAdvisories: # Currently in use for the network list drag and drop functionality. # Maintenance has stopped and the project will be archived in 2025. - 'react-beautiful-dnd (deprecation)' - + # New package name format for new versions: @ethereumjs/wallet. + - 'ethereumjs-wallet (deprecation)' npmRegistries: 'https://npm.pkg.github.com': npmAlwaysAuth: true diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 0ce8a5ab4899..4ce4933e2153 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -3879,10 +3879,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -3995,22 +3995,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 0ce8a5ab4899..4ce4933e2153 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -3879,10 +3879,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -3995,22 +3995,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 0ce8a5ab4899..4ce4933e2153 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -3879,10 +3879,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -3995,22 +3995,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index f8cd1fdfca54..742e978f06e8 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -3971,10 +3971,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -4087,22 +4087,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/package.json b/package.json index 913ebf677d7b..be3247aad496 100644 --- a/package.json +++ b/package.json @@ -267,7 +267,11 @@ "@metamask/network-controller@npm:^19.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/network-controller@npm:^20.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "path-to-regexp": "1.9.0", - "@metamask/snaps-utils@npm:^8.1.1": "patch:@metamask/snaps-utils@npm%3A8.1.1#~/.yarn/patches/@metamask-snaps-utils-npm-8.1.1-7d5dd6a26a.patch" + "@metamask/snaps-utils@npm:^8.1.1": "patch:@metamask/snaps-utils@npm%3A8.1.1#~/.yarn/patches/@metamask-snaps-utils-npm-8.1.1-7d5dd6a26a.patch", + "secp256k1@npm:^4.0.0": "4.0.4", + "secp256k1@npm:^4.0.1": "4.0.4", + "secp256k1@npm:4.0.2": "4.0.4", + "secp256k1@npm:4.0.3": "4.0.4" }, "dependencies": { "@babel/runtime": "patch:@babel/runtime@npm%3A7.24.0#~/.yarn/patches/@babel-runtime-npm-7.24.0-7eb1dd11a2.patch", diff --git a/yarn.lock b/yarn.lock index 8e842fb9c927..3e3cc8b809c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17235,9 +17235,9 @@ __metadata: languageName: node linkType: hard -"elliptic@npm:^6.0.0, elliptic@npm:^6.4.0, elliptic@npm:^6.5.2, elliptic@npm:^6.5.4": - version: 6.5.6 - resolution: "elliptic@npm:6.5.6" +"elliptic@npm:^6.0.0, elliptic@npm:^6.4.0, elliptic@npm:^6.5.4, elliptic@npm:^6.5.7": + version: 6.6.0 + resolution: "elliptic@npm:6.6.0" dependencies: bn.js: "npm:^4.11.9" brorand: "npm:^1.1.0" @@ -17246,7 +17246,7 @@ __metadata: inherits: "npm:^2.0.4" minimalistic-assert: "npm:^1.0.1" minimalistic-crypto-utils: "npm:^1.0.1" - checksum: 10/09377ec924fdb37775d63e5d7e5ebb2845842e6f08880b68265b1108863e968970c4a4e1c43df622078c8262417deec9a04aeb9d34e8d09a9693e19b5454e1df + checksum: 10/27575b0403e010e5d7e7a131fcadce6a7dd1ae82ccb24cc7c20b275d32ab1cb7ecb6a070225795df08407441dc8c7a32efd986596d48d1d6846f64ff8f094af7 languageName: node linkType: hard @@ -27676,6 +27676,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^5.0.0": + version: 5.1.0 + resolution: "node-addon-api@npm:5.1.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10/595f59ffb4630564f587c502119cbd980d302e482781021f3b479f5fc7e41cf8f2f7280fdc2795f32d148e4f3259bd15043c52d4a3442796aa6f1ae97b959636 + languageName: node + linkType: hard + "node-addon-api@npm:^7.0.0": version: 7.1.0 resolution: "node-addon-api@npm:7.1.0" @@ -32575,27 +32584,15 @@ __metadata: languageName: node linkType: hard -"secp256k1@npm:4.0.2": - version: 4.0.2 - resolution: "secp256k1@npm:4.0.2" - dependencies: - elliptic: "npm:^6.5.2" - node-addon-api: "npm:^2.0.0" - node-gyp: "npm:latest" - node-gyp-build: "npm:^4.2.0" - checksum: 10/80f0a5b44dbe0a062ed0fbf2a82044037a2598a0ea6ec5a0924bfa1f53006b423a43db82ff1cb2924d280b06f2a34235a1733631b3459b86b7a886c0ef41e0c5 - languageName: node - linkType: hard - -"secp256k1@npm:4.0.3, secp256k1@npm:^4.0.0, secp256k1@npm:^4.0.1": - version: 4.0.3 - resolution: "secp256k1@npm:4.0.3" +"secp256k1@npm:4.0.4": + version: 4.0.4 + resolution: "secp256k1@npm:4.0.4" dependencies: - elliptic: "npm:^6.5.4" - node-addon-api: "npm:^2.0.0" + elliptic: "npm:^6.5.7" + node-addon-api: "npm:^5.0.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.2.0" - checksum: 10/8b45820cd90fd2f95cc8fdb9bf8a71e572de09f2311911ae461a951ffa9e30c99186a129d0f1afeb380dd67eca0c10493f8a7513c39063fda015e99995088e3b + checksum: 10/45000f348c853df7c1e2b67c48efb062ae78c0620ab1a5cfb02fa20d3aad39c641f4e7a18b3de3b54a7c0cc1e0addeb8ecd9d88bc332e92df17a92b60c36122a languageName: node linkType: hard From 5dbbda37c7da03ccb72f5377b345588f3b1306dd Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Thu, 31 Oct 2024 03:46:16 +0100 Subject: [PATCH 29/56] chore: Cherry-pick resimulate into `V12.5.1` (#28178) This PR cherry-picks https://github.com/MetaMask/metamask-extension/commit/2c86162cba2f52bd6ee6ab33b32244b2483be461#diff-63ab7391d870a62d8bcd3cc5d5371432068538deb98e5effcb899434ed8649bb --------- Co-authored-by: MetaMask Bot Co-authored-by: Matthew Walsh --- .metamaskrc.dist | 4 + app/_locales/en/messages.json | 6 + .../lib/transaction/smart-transactions.ts | 4 +- app/scripts/metamask-controller.js | 4 + builds.yml | 5 +- lavamoat/browserify/beta/policy.json | 42 +++++- lavamoat/browserify/flask/policy.json | 42 +++++- lavamoat/browserify/main/policy.json | 42 +++++- lavamoat/browserify/mmi/policy.json | 42 +++++- package.json | 2 +- ...rs-after-init-opt-in-background-state.json | 1 + .../errors-after-init-opt-in-ui-state.json | 1 + .../mock-request-buy-erc721.ts | 5 +- .../mock-request-no-changes.ts | 3 +- .../app/confirm/info/row/constants.ts | 3 +- ui/index.js | 3 - .../info/__snapshots__/info.test.tsx.snap | 41 +++--- .../base-transaction-info.test.tsx.snap | 123 ++++++++++------- .../simulation-details.test.tsx | 15 +++ .../simulation-details/simulation-details.tsx | 122 ++++++++++++----- .../transactions/useResimulationAlert.test.ts | 126 ++++++++++++++++++ .../transactions/useResimulationAlert.ts | 34 +++++ .../hooks/useConfirmationAlerts.ts | 5 +- yarn.lock | 69 +++++++--- 24 files changed, 601 insertions(+), 143 deletions(-) create mode 100644 ui/pages/confirmations/hooks/alerts/transactions/useResimulationAlert.test.ts create mode 100644 ui/pages/confirmations/hooks/alerts/transactions/useResimulationAlert.ts diff --git a/.metamaskrc.dist b/.metamaskrc.dist index 601105e2af44..fc2a5a831a4b 100644 --- a/.metamaskrc.dist +++ b/.metamaskrc.dist @@ -45,3 +45,7 @@ BLOCKAID_PUBLIC_KEY= ; Enable/disable why did you render debug tool: https://github.com/welldone-software/why-did-you-render ; This should NEVER be enabled in production since it slows down react ; ENABLE_WHY_DID_YOU_RENDER=false + +; API key used in Etherscan requests to prevent rate limiting. +; Only applies to Mainnet and Sepolia. +; ETHERSCAN_API_KEY= diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e312be4794e5..903a5f2b2b33 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -426,6 +426,9 @@ "alertMessageAddressMismatchWarning": { "message": "Attackers sometimes mimic sites by making small changes to the site address. Make sure you're interacting with the intended site before you continue." }, + "alertMessageChangeInSimulationResults": { + "message": "Estimated changes for this transaction have been updated. Review them closely before proceeding." + }, "alertMessageGasEstimateFailed": { "message": "We’re unable to provide an accurate fee and this estimate might be high. We suggest you to input a custom gas limit, but there’s a risk the transaction will still fail." }, @@ -465,6 +468,9 @@ "alertModalReviewAllAlerts": { "message": "Review all alerts" }, + "alertReasonChangeInSimulationResults": { + "message": "Results have changed" + }, "alertReasonGasEstimateFailed": { "message": "Inaccurate fee" }, diff --git a/app/scripts/lib/transaction/smart-transactions.ts b/app/scripts/lib/transaction/smart-transactions.ts index acf17c306a5f..9f9d81567847 100644 --- a/app/scripts/lib/transaction/smart-transactions.ts +++ b/app/scripts/lib/transaction/smart-transactions.ts @@ -313,7 +313,9 @@ class SmartTransactionHook { signedTransactions, signedCanceledTransactions: [], txParams: this.#txParams, - transactionMeta: this.#transactionMeta, + // TODO: Replace `any` with type - version mismatch between smart-transactions-controller and transaction-controller breaking type safety + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionMeta: this.#transactionMeta as any, }); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index ea8fa297cd2f..679f2f9a4be8 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1849,6 +1849,10 @@ export default class MetamaskController extends EventEmitter { getCurrentChainId({ metamask: this.networkController.state }) ], incomingTransactions: { + etherscanApiKeysByChainId: { + [CHAIN_IDS.MAINNET]: process.env.ETHERSCAN_API_KEY, + [CHAIN_IDS.SEPOLIA]: process.env.ETHERSCAN_API_KEY, + }, includeTokenTransfers: false, isEnabled: () => Boolean( diff --git a/builds.yml b/builds.yml index acee49063822..ceeb6ae2b17d 100644 --- a/builds.yml +++ b/builds.yml @@ -272,8 +272,10 @@ env: - SECURITY_ALERTS_API_ENABLED: '' # URL of security alerts API used to validate dApp requests - SECURITY_ALERTS_API_URL: 'http://localhost:3000' + # API key to authenticate Etherscan requests to avoid rate limiting + - ETHERSCAN_API_KEY: '' - # Enables the notifications feature within the build: + # Enables the notifications feature within the build: - NOTIFICATIONS: '' - METAMASK_RAMP_API_CONTENT_BASE_URL: https://on-ramp-content.api.cx.metamask.io @@ -290,6 +292,7 @@ env: ### - EIP_4337_ENTRYPOINT: null + ### # Enable/disable why did you render debug tool: https://github.com/welldone-software/why-did-you-render # This should NEVER be enabled in production since it slows down react diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 4ce4933e2153..52c3250ffa65 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -783,15 +783,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -2726,9 +2741,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, - "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/rpc-errors": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2754,6 +2769,27 @@ "@swc/helpers>tslib": true } }, + "@metamask/transaction-controller>@metamask/rpc-errors": { + "packages": { + "@metamask/transaction-controller>@metamask/utils": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/user-operation-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 4ce4933e2153..52c3250ffa65 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -783,15 +783,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -2726,9 +2741,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, - "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/rpc-errors": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2754,6 +2769,27 @@ "@swc/helpers>tslib": true } }, + "@metamask/transaction-controller>@metamask/rpc-errors": { + "packages": { + "@metamask/transaction-controller>@metamask/utils": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/user-operation-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 4ce4933e2153..52c3250ffa65 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -783,15 +783,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -2726,9 +2741,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, - "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/rpc-errors": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2754,6 +2769,27 @@ "@swc/helpers>tslib": true } }, + "@metamask/transaction-controller>@metamask/rpc-errors": { + "packages": { + "@metamask/transaction-controller>@metamask/utils": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/user-operation-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 742e978f06e8..6725d2dfa1c6 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -875,15 +875,30 @@ }, "packages": { "@ethereumjs/tx>@ethereumjs/util": true, + "@metamask/controller-utils>@metamask/utils": true, "@metamask/controller-utils>@spruceid/siwe-parser": true, "@metamask/ethjs>@metamask/ethjs-unit": true, - "@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eslint>fast-deep-equal": true, "eth-ens-namehash": true } }, + "@metamask/controller-utils>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/controller-utils>@spruceid/siwe-parser": { "globals": { "console.error": true, @@ -2818,9 +2833,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, - "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/rpc-errors": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2846,6 +2861,27 @@ "@swc/helpers>tslib": true } }, + "@metamask/transaction-controller>@metamask/rpc-errors": { + "packages": { + "@metamask/transaction-controller>@metamask/utils": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/user-operation-controller": { "globals": { "fetch": true diff --git a/package.json b/package.json index be3247aad496..0e0d4ccb67fa 100644 --- a/package.json +++ b/package.json @@ -364,7 +364,7 @@ "@metamask/snaps-rpc-methods": "^11.1.1", "@metamask/snaps-sdk": "^6.5.1", "@metamask/snaps-utils": "patch:@metamask/snaps-utils@npm%3A8.1.1#~/.yarn/patches/@metamask-snaps-utils-npm-8.1.1-7d5dd6a26a.patch", - "@metamask/transaction-controller": "^37.0.0", + "@metamask/transaction-controller": "^38.1.0", "@metamask/user-operation-controller": "^13.0.0", "@metamask/utils": "^9.1.0", "@ngraveio/bc-ur": "^1.1.12", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index c133de6128ca..423d65c681ec 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -305,6 +305,7 @@ }, "TxController": { "methodData": "object", + "submitHistory": "object", "transactions": "object", "lastFetchedBlockNumbers": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index dfee54fbd6cb..95e995c9c466 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -194,6 +194,7 @@ "isSignedIn": "boolean", "isProfileSyncingEnabled": null, "isProfileSyncingUpdateLoading": "boolean", + "submitHistory": "object", "subscriptionAccountsSeen": "object", "isMetamaskNotificationsFeatureSeen": "boolean", "isNotificationServicesEnabled": "boolean", diff --git a/test/e2e/tests/simulation-details/mock-request-buy-erc721.ts b/test/e2e/tests/simulation-details/mock-request-buy-erc721.ts index fd71e42559aa..9346ee6f83f1 100644 --- a/test/e2e/tests/simulation-details/mock-request-buy-erc721.ts +++ b/test/e2e/tests/simulation-details/mock-request-buy-erc721.ts @@ -269,8 +269,7 @@ export const BUY_ERC721_REQUEST_2_MOCK: MockRequestResponse = { result: { transactions: [ { - return: - '0x0000000000000000000000000000000000000000000000000000000000000000', + return: `0x0000000000000000000000000000000000000000000000000000000000000000`, status: '0x1', gasUsed: '0x5f66', gasLimit: '0x60b9', @@ -388,7 +387,7 @@ export const BUY_ERC721_REQUEST_2_MOCK: MockRequestResponse = { baseFeePerGas: 42103363836, }, { - return: `0x00000000000000000000000000000000${SENDER_ADDRESS_NO_0X_MOCK}`, + return: `0x000000000000000000000000${SENDER_ADDRESS_NO_0X_MOCK}`, status: '0x1', gasUsed: '0x5f66', gasLimit: '0x60b9', diff --git a/test/e2e/tests/simulation-details/mock-request-no-changes.ts b/test/e2e/tests/simulation-details/mock-request-no-changes.ts index 59b7fc9b8b3d..03aa562fd354 100644 --- a/test/e2e/tests/simulation-details/mock-request-no-changes.ts +++ b/test/e2e/tests/simulation-details/mock-request-no-changes.ts @@ -5,7 +5,7 @@ export const NO_CHANGES_TRANSACTION_MOCK = { maxFeePerGas: '0x0', maxPriorityFeePerGas: '0x0', to: SENDER_ADDRESS_MOCK, - value: '0x38d7ea4c68000', + value: '0x0', }; export const NO_CHANGES_REQUEST_MOCK: MockRequestResponse = { @@ -42,6 +42,7 @@ export const NO_CHANGES_REQUEST_MOCK: MockRequestResponse = { stateDiff: { post: { [SENDER_ADDRESS_MOCK]: { + balance: '0x3185e67a46d9066', nonce: '0x3c0', }, }, diff --git a/ui/components/app/confirm/info/row/constants.ts b/ui/components/app/confirm/info/row/constants.ts index f260f9bce282..415358aa5252 100644 --- a/ui/components/app/confirm/info/row/constants.ts +++ b/ui/components/app/confirm/info/row/constants.ts @@ -3,8 +3,9 @@ export const TEST_ADDRESS = '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1'; export enum RowAlertKey { EstimatedFee = 'estimatedFee', SigningInWith = 'signingInWith', - Speed = 'speed', RequestFrom = 'requestFrom', + Resimulation = 'resimulation', + Speed = 'speed', } export enum AlertActionKey { diff --git a/ui/index.js b/ui/index.js index bc427addb55e..8cf2048cba41 100644 --- a/ui/index.js +++ b/ui/index.js @@ -297,9 +297,6 @@ function setupStateHooks(store) { // for more info) state.version = global.platform.getVersion(); state.browser = window.navigator.userAgent; - state.completeTxList = await actions.getTransactions({ - filterToCurrentNetwork: false, - }); return state; }; window.stateHooks.getSentryAppState = function () { diff --git a/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap index 60bb488888b3..0cc41ec89e11 100644 --- a/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap @@ -310,26 +310,35 @@ exports[`Info renders info section for contract interaction request 1`] = ` class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
-

- Estimated changes -

-
- +

+ Estimated changes +

+
+
+ +
+
diff --git a/ui/pages/confirmations/components/confirm/info/base-transaction-info/__snapshots__/base-transaction-info.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/base-transaction-info/__snapshots__/base-transaction-info.test.tsx.snap index 3cd8e825da01..d9e3fff08108 100644 --- a/ui/pages/confirmations/components/confirm/info/base-transaction-info/__snapshots__/base-transaction-info.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/base-transaction-info/__snapshots__/base-transaction-info.test.tsx.snap @@ -15,26 +15,35 @@ exports[` renders component for contract interaction requ class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
-

- Estimated changes -

-
- +

+ Estimated changes +

+
+
+ +
+
@@ -352,26 +361,35 @@ exports[` renders component for contract interaction requ class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
-

- Estimated changes -

-
- +

+ Estimated changes +

+
+
+ +
+
@@ -727,26 +745,35 @@ exports[` renders component for contract interaction requ class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
-

- Estimated changes -

-
- +

+ Estimated changes +

+
+
+ +
+
diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx index 5971e513e3a1..ce8e0686d08b 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx @@ -25,6 +25,21 @@ jest.mock('./balance-change-list', () => ({ jest.mock('./useSimulationMetrics'); +jest.mock( + '../../../../components/app/confirm/info/row/alert-row/alert-row', + () => ({ + ConfirmInfoAlertRow: jest.fn(({ label }) => <>{label}), + }), +); + +jest.mock('../../context/confirm', () => ({ + useConfirmContext: jest.fn(() => ({ + currentConfirmation: { + id: 'testTransactionId', + }, + })), +})); + const renderSimulationDetails = (simulationData?: Partial) => renderWithProvider( { ); }; +const HeaderWithAlert = ({ transactionId }: { transactionId: string }) => { + const t = useI18nContext(); + + return ( + + {/* Intentional fragment */} + <> + + ); +}; + +const LegacyHeader = () => { + const t = useI18nContext(); + return ( + + + {t('simulationDetailsTitle')} + + + + + + ); +}; + /** * Header at the top of the simulation preview. * * @param props * @param props.children + * @param props.isTransactionsRedesign + * @param props.transactionId */ -const HeaderLayout: React.FC = ({ children }) => { - const t = useI18nContext(); +const HeaderLayout: React.FC<{ + isTransactionsRedesign: boolean; + transactionId: string; +}> = ({ children, isTransactionsRedesign, transactionId }) => { return ( { alignItems={AlignItems.center} justifyContent={JustifyContent.spaceBetween} > - - - {t('simulationDetailsTitle')} - - - - - + {isTransactionsRedesign ? ( + + ) : ( + + )} {children} ); @@ -143,11 +180,13 @@ const HeaderLayout: React.FC = ({ children }) => { * @param props.inHeader * @param props.isTransactionsRedesign * @param props.children + * @param props.transactionId */ const SimulationDetailsLayout: React.FC<{ inHeader?: React.ReactNode; isTransactionsRedesign: boolean; -}> = ({ inHeader, isTransactionsRedesign, children }) => ( + transactionId: string; +}> = ({ inHeader, isTransactionsRedesign, transactionId, children }) => ( - {inHeader} + + {inHeader} + {children} ); @@ -201,6 +245,7 @@ export const SimulationDetails: React.FC = ({ } isTransactionsRedesign={isTransactionsRedesign} + transactionId={transactionId} > ); } @@ -218,7 +263,10 @@ export const SimulationDetails: React.FC = ({ if (error) { return ( - + ); @@ -228,7 +276,10 @@ export const SimulationDetails: React.FC = ({ const empty = balanceChanges.length === 0; if (empty) { return ( - + ); @@ -237,7 +288,10 @@ export const SimulationDetails: React.FC = ({ const outgoing = balanceChanges.filter((bc) => bc.amount.isNegative()); const incoming = balanceChanges.filter((bc) => !bc.amount.isNegative()); return ( - + { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns no alerts if no confirmation', () => { + expect(runHook()).toEqual([]); + }); + + it('returns no alerts if no transactions', () => { + expect( + runHook({ + currentConfirmation: CONFIRMATION_MOCK, + transactions: [], + }), + ).toEqual([]); + }); + + it('returns no alerts if isUpdatedAfterSecurityCheck is false', () => { + const notResimulatedConfirmation = { + ...TRANSACTION_META_MOCK, + simulationData: { + isUpdatedAfterSecurityCheck: false, + tokenBalanceChanges: [], + }, + }; + expect( + runHook({ + currentConfirmation: notResimulatedConfirmation, + }), + ).toEqual([]); + }); + + it('returns alert if isUpdatedAfterSecurityCheck is true', () => { + const resimulatedConfirmation = { + ...CONFIRMATION_MOCK, + simulationData: { + isUpdatedAfterSecurityCheck: true, + tokenBalanceChanges: [], + }, + }; + const alerts = runHook({ + currentConfirmation: resimulatedConfirmation, + }); + + expect(alerts).toEqual([ + { + actions: [], + field: RowAlertKey.Resimulation, + isBlocking: false, + key: 'simulationDetailsTitle', + message: + 'Estimated changes for this transaction have been updated. Review them closely before proceeding.', + reason: 'Results have changed', + severity: Severity.Danger, + }, + ]); + }); +}); diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useResimulationAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useResimulationAlert.ts new file mode 100644 index 000000000000..c838e07e62c4 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/transactions/useResimulationAlert.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { Severity } from '../../../../../helpers/constants/design-system'; +import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; +import { useConfirmContext } from '../../../context/confirm'; + +export function useResimulationAlert(): Alert[] { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + + const isUpdatedAfterSecurityCheck = (currentConfirmation as TransactionMeta) + ?.simulationData?.isUpdatedAfterSecurityCheck; + + return useMemo(() => { + if (!isUpdatedAfterSecurityCheck) { + return []; + } + + return [ + { + actions: [], + field: RowAlertKey.Resimulation, + isBlocking: false, + key: 'simulationDetailsTitle', + message: t('alertMessageChangeInSimulationResults'), + reason: t('alertReasonChangeInSimulationResults'), + severity: Severity.Danger, + }, + ]; + }, [isUpdatedAfterSecurityCheck, t]); +} diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts index 3ea9a5e2d254..c5f77f143cb6 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts @@ -10,6 +10,7 @@ import { useNetworkBusyAlerts } from './alerts/transactions/useNetworkBusyAlerts import { useNoGasPriceAlerts } from './alerts/transactions/useNoGasPriceAlerts'; import { usePendingTransactionAlerts } from './alerts/transactions/usePendingTransactionAlerts'; import { useQueuedConfirmationsAlerts } from './alerts/transactions/useQueuedConfirmationsAlerts'; +import { useResimulationAlert } from './alerts/transactions/useResimulationAlert'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { useSigningOrSubmittingAlerts } from './alerts/transactions/useSigningOrSubmittingAlerts'; ///: END:ONLY_INCLUDE_IF @@ -34,11 +35,11 @@ function useTransactionAlerts(): Alert[] { const networkBusyAlerts = useNetworkBusyAlerts(); const noGasPriceAlerts = useNoGasPriceAlerts(); const pendingTransactionAlerts = usePendingTransactionAlerts(); + const resimulationAlert = useResimulationAlert(); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const signingOrSubmittingAlerts = useSigningOrSubmittingAlerts(); ///: END:ONLY_INCLUDE_IF const queuedConfirmationsAlerts = useQueuedConfirmationsAlerts(); - return useMemo( () => [ ...gasEstimateFailedAlerts, @@ -48,6 +49,7 @@ function useTransactionAlerts(): Alert[] { ...networkBusyAlerts, ...noGasPriceAlerts, ...pendingTransactionAlerts, + ...resimulationAlert, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) ...signingOrSubmittingAlerts, ///: END:ONLY_INCLUDE_IF @@ -61,6 +63,7 @@ function useTransactionAlerts(): Alert[] { networkBusyAlerts, noGasPriceAlerts, pendingTransactionAlerts, + resimulationAlert, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) signingOrSubmittingAlerts, ///: END:ONLY_INCLUDE_IF diff --git a/yarn.lock b/yarn.lock index 3e3cc8b809c1..81ef4b4de92a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4933,13 +4933,13 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^7.0.0, @metamask/base-controller@npm:^7.0.1": - version: 7.0.1 - resolution: "@metamask/base-controller@npm:7.0.1" +"@metamask/base-controller@npm:^7.0.0, @metamask/base-controller@npm:^7.0.1, @metamask/base-controller@npm:^7.0.2": + version: 7.0.2 + resolution: "@metamask/base-controller@npm:7.0.2" dependencies: - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" immer: "npm:^9.0.6" - checksum: 10/774b6d68ac95a5ec187e890d321bede50065f8a6f1ba7b49a19f5971366274054ac0e401548b51d3b014d0bca5d650409fb554dd13ce120e7fb3495b4e8e67b1 + checksum: 10/6f78ec5af840c9947aa8eac6e402df6469600260d613a92196daefd5b072097a176fe5da1c386f2d36853513254b74140d667d817a12880c46f088e18ff3606a languageName: node linkType: hard @@ -4976,20 +4976,20 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0": - version: 11.3.0 - resolution: "@metamask/controller-utils@npm:11.3.0" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.1": + version: 11.4.1 + resolution: "@metamask/controller-utils@npm:11.4.1" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" "@spruceid/siwe-parser": "npm:2.1.0" "@types/bn.js": "npm:^5.1.5" bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/3200228d1f4ea5fa095228db4e5050529caf0470e072382eb8f7571bb9b07515516ca9e846b7751388399d9ae967e4985dafd6120902ef6c998e98f4eb36d964 + checksum: 10/fff4864858ce2072456537c9b51cb4c10d178a27b39ab5af8d6e9595efb59dd043bb49be336d8ac725d1281279db4365855f024329398508658b2b2d3b5bc2a5 languageName: node linkType: hard @@ -6159,6 +6159,16 @@ __metadata: languageName: node linkType: hard +"@metamask/rpc-errors@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/rpc-errors@npm:7.0.1" + dependencies: + "@metamask/utils": "npm:^10.0.0" + fast-safe-stringify: "npm:^2.0.6" + checksum: 10/819708b4a7d9695ee67fd867d8f94bb5a273b479a242b17bd53c83d1fceec421fc42928f0bb340f4f138ec803dd82ec9659ce7b09a86aedad6a81d5a39ec5c35 + languageName: node + linkType: hard + "@metamask/safe-event-emitter@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/safe-event-emitter@npm:2.0.0" @@ -6523,9 +6533,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^37.0.0": - version: 37.0.0 - resolution: "@metamask/transaction-controller@npm:37.0.0" +"@metamask/transaction-controller@npm:^38.1.0": + version: 38.1.0 + resolution: "@metamask/transaction-controller@npm:38.1.0" dependencies: "@ethereumjs/common": "npm:^3.2.0" "@ethereumjs/tx": "npm:^4.2.0" @@ -6533,13 +6543,13 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/base-controller": "npm:^7.0.1" - "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/rpc-errors": "npm:^6.3.1" - "@metamask/utils": "npm:^9.1.0" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.0" async-mutex: "npm:^0.5.0" bn.js: "npm:^5.2.1" eth-method-registry: "npm:^4.0.0" @@ -6550,9 +6560,9 @@ __metadata: "@babel/runtime": ^7.23.9 "@metamask/accounts-controller": ^18.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/gas-fee-controller": ^20.0.0 - "@metamask/network-controller": ^21.0.0 - checksum: 10/b4608260cb86ad1a867926b983a21050a2be899f17af909ad2403b5148eada348b0fbb3f7ecef9ebc7cf8d28c040ce4d6f5009709328cda00fab61e10fa94de6 + "@metamask/gas-fee-controller": ^22.0.0 + "@metamask/network-controller": ^22.0.0 + checksum: 10/c1bdca52bbbce42a76ec9c640197534ec6c223b0f5d5815acfa53490dc1175850ea9aeeb6ae3c5ec34218f0bdbbbeb3e8731e2552aa9411e3ed7798a5dea8ab5 languageName: node linkType: hard @@ -6586,6 +6596,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/utils@npm:10.0.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + debug: "npm:^4.3.4" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/9c2e6421f685d8a45145b6026a6f9fd0701eb5a2e8490fc6d18e64c103d5a62097f301cbc797790da52ceb5853bd9f65845c934b00299e69e5e6736c52b32f0f + languageName: node + linkType: hard + "@metamask/utils@npm:^8.1.0, @metamask/utils@npm:^8.2.0, @metamask/utils@npm:^8.3.0": version: 8.5.0 resolution: "@metamask/utils@npm:8.5.0" @@ -26164,7 +26191,7 @@ __metadata: "@metamask/snaps-utils": "patch:@metamask/snaps-utils@npm%3A8.1.1#~/.yarn/patches/@metamask-snaps-utils-npm-8.1.1-7d5dd6a26a.patch" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:^8.4.0" - "@metamask/transaction-controller": "npm:^37.0.0" + "@metamask/transaction-controller": "npm:^38.1.0" "@metamask/user-operation-controller": "npm:^13.0.0" "@metamask/utils": "npm:^9.1.0" "@ngraveio/bc-ur": "npm:^1.1.12" From 82a548cd9938976b43faa83263bcb716b1619155 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 31 Oct 2024 00:49:14 -0230 Subject: [PATCH 30/56] V12.5.1 changelog (#28200) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc65a61eae0..3e6b06f0d37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [12.5.1] +### Changed +- Improve accuracy of transaction simulation warnings in some scenarios ([#26845](https://github.com/MetaMask/metamask-extension/pull/26845)) + +### Fixed +- Fix bug that could cause token balances to appear as zero, and a balance error to be displayed, on the send screen ([#28136](https://github.com/MetaMask/metamask-extension/pull/28136)) ## [12.5.0] ### Added From 8b7ad816b3b9481dcc17bbb6f8df799b28fe656d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 31 Oct 2024 09:06:42 +0000 Subject: [PATCH 31/56] fix (cherry-pick): incorrect standard swap gas fee estimation (#28127) (#28191) --- .../swaps/prepare-swap-page/review-quote.js | 3 +- .../prepare-swap-page/review-quote.test.js | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.js b/ui/pages/swaps/prepare-swap-page/review-quote.js index 13d11a93cd1f..680ece113f5d 100644 --- a/ui/pages/swaps/prepare-swap-page/review-quote.js +++ b/ui/pages/swaps/prepare-swap-page/review-quote.js @@ -257,7 +257,8 @@ export default function ReviewQuote({ setReceiveToAmount }) { ); const smartTransactionFees = useSelector(getSmartTransactionFees, isEqual); const swapsNetworkConfig = useSelector(getSwapsNetworkConfig, shallowEqual); - const { estimatedBaseFee = '0' } = useGasFeeEstimates(); + const { gasFeeEstimates: networkGasFeeEstimates } = useGasFeeEstimates(); + const { estimatedBaseFee = '0' } = networkGasFeeEstimates ?? {}; const gasFeeEstimates = useAsyncResult(async () => { if (!networkAndAccountSupports1559) { diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.test.js b/ui/pages/swaps/prepare-swap-page/review-quote.test.js index cacd52ca47ed..1e4ab9199226 100644 --- a/ui/pages/swaps/prepare-swap-page/review-quote.test.js +++ b/ui/pages/swaps/prepare-swap-page/review-quote.test.js @@ -10,6 +10,7 @@ import { } from '../../../../test/jest'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { getSwap1559GasFeeEstimates } from '../swaps.util'; +import { getNetworkConfigurationByNetworkClientId } from '../../../store/actions'; import ReviewQuote from './review-quote'; jest.mock( @@ -17,11 +18,18 @@ jest.mock( () => () => '', ); +jest.mock('../../../store/actions', () => ({ + ...jest.requireActual('../../../store/actions'), + getNetworkConfigurationByNetworkClientId: jest.fn(), +})); + jest.mock('../swaps.util', () => ({ ...jest.requireActual('../swaps.util'), getSwap1559GasFeeEstimates: jest.fn(), })); +const ESTIMATED_BASE_FEE_MOCK = '1234'; + const middleware = [thunk]; const createProps = (customProps = {}) => { return { @@ -31,6 +39,15 @@ const createProps = (customProps = {}) => { }; describe('ReviewQuote', () => { + const getNetworkConfigurationByNetworkClientIdMock = jest.mocked( + getNetworkConfigurationByNetworkClientId, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getNetworkConfigurationByNetworkClientIdMock.mockResolvedValue(undefined); + }); + const getSwap1559GasFeeEstimatesMock = jest.mocked( getSwap1559GasFeeEstimates, ); @@ -210,5 +227,45 @@ describe('ReviewQuote', () => { expect(getByText('Max fee:')).toBeInTheDocument(); expect(getByText('$8.15')).toBeInTheDocument(); }); + + it('extracts estimated base fee from network gas fee estimates', async () => { + getNetworkConfigurationByNetworkClientIdMock.mockResolvedValueOnce({ + chainId: CHAIN_IDS.MAINNET, + }); + + smartDisabled1559State.metamask.gasFeeEstimatesByChainId = { + [CHAIN_IDS.MAINNET]: { + gasFeeEstimates: { + estimatedBaseFee: ESTIMATED_BASE_FEE_MOCK, + }, + }, + }; + + getSwap1559GasFeeEstimatesMock.mockResolvedValueOnce({ + estimatedBaseFee: '0x1', + tradeGasFeeEstimates: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x3', + baseAndPriorityFeePerGas: '0x123456789123', + }, + approveGasFeeEstimates: undefined, + }); + + const store = configureMockStore(middleware)(smartDisabled1559State); + const props = createProps(); + + renderWithProvider(, store); + + await act(() => { + // Intentionally empty + }); + + expect(getSwap1559GasFeeEstimatesMock).toHaveBeenCalledWith( + expect.any(Object), + null, + ESTIMATED_BASE_FEE_MOCK, + CHAIN_IDS.MAINNET, + ); + }); }); }); From 94dc115ccf132bbbc5256cc6d7ef0b3dffc18d75 Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Thu, 31 Oct 2024 14:38:44 +0000 Subject: [PATCH 32/56] Update LavaMoat policies --- lavamoat/browserify/beta/policy.json | 37 +++++++++++---------------- lavamoat/browserify/flask/policy.json | 37 +++++++++++---------------- lavamoat/browserify/main/policy.json | 37 +++++++++++---------------- lavamoat/browserify/mmi/policy.json | 37 +++++++++++---------------- 4 files changed, 60 insertions(+), 88 deletions(-) diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 943338c29bdb..ef4c915328c2 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2971,9 +2971,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, + "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/transaction-controller>@metamask/rpc-errors": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2999,10 +2999,19 @@ "@swc/helpers>tslib": true } }, - "@metamask/transaction-controller>@metamask/rpc-errors": { + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/user-operation-controller": { @@ -4151,10 +4160,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -4267,22 +4276,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 943338c29bdb..ef4c915328c2 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2971,9 +2971,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, + "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/transaction-controller>@metamask/rpc-errors": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2999,10 +2999,19 @@ "@swc/helpers>tslib": true } }, - "@metamask/transaction-controller>@metamask/rpc-errors": { + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/user-operation-controller": { @@ -4151,10 +4160,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -4267,22 +4276,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 943338c29bdb..ef4c915328c2 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2971,9 +2971,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, + "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/transaction-controller>@metamask/rpc-errors": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -2999,10 +2999,19 @@ "@swc/helpers>tslib": true } }, - "@metamask/transaction-controller>@metamask/rpc-errors": { + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/user-operation-controller": { @@ -4151,10 +4160,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -4267,22 +4276,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 321e8ea7e6ff..94c331a71ee5 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -3063,9 +3063,9 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, + "@metamask/rpc-errors": true, "@metamask/transaction-controller>@metamask/nonce-tracker": true, - "@metamask/transaction-controller>@metamask/rpc-errors": true, - "@metamask/utils": true, + "@metamask/transaction-controller>@metamask/utils": true, "bn.js": true, "browserify>buffer": true, "eth-method-registry": true, @@ -3091,10 +3091,19 @@ "@swc/helpers>tslib": true } }, - "@metamask/transaction-controller>@metamask/rpc-errors": { + "@metamask/transaction-controller>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, "packages": { - "@metamask/rpc-errors>fast-safe-stringify": true, - "@metamask/utils": true + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true } }, "@metamask/user-operation-controller": { @@ -4243,10 +4252,10 @@ "eth-lattice-keyring>gridplus-sdk>elliptic": true, "eth-lattice-keyring>gridplus-sdk>eth-eip712-util-browser": true, "eth-lattice-keyring>gridplus-sdk>rlp": true, - "eth-lattice-keyring>gridplus-sdk>secp256k1": true, "eth-lattice-keyring>gridplus-sdk>uuid": true, "ethereumjs-util>ethereum-cryptography>bs58check": true, "ethers>@ethersproject/sha2>hash.js": true, + "ganache>secp256k1": true, "lodash": true } }, @@ -4359,22 +4368,6 @@ "TextEncoder": true } }, - "eth-lattice-keyring>gridplus-sdk>secp256k1": { - "packages": { - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": true - } - }, - "eth-lattice-keyring>gridplus-sdk>secp256k1>elliptic": { - "packages": { - "@metamask/ppom-validator>elliptic>brorand": true, - "@metamask/ppom-validator>elliptic>hmac-drbg": true, - "@metamask/ppom-validator>elliptic>minimalistic-assert": true, - "@metamask/ppom-validator>elliptic>minimalistic-crypto-utils": true, - "bn.js": true, - "ethers>@ethersproject/sha2>hash.js": true, - "pumpify>inherits": true - } - }, "eth-lattice-keyring>gridplus-sdk>uuid": { "globals": { "crypto": true From f30160485aaad381de8b738fa7aa596c6f736702 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 31 Oct 2024 12:56:53 -0230 Subject: [PATCH 33/56] test: Fix data deletion e2e tests (#28221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The "Delete MetaMetrics Data" e2e tests were recently broken due to a change in CI configuration. The code-under-test was written to always use the environment variable present for the data deletion source ID and endpoint, but the e2e tests wrongly assumed that it would never be present. The service has been updated to use the fallback values for e2e test builds, even if the environment variable is present. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28221?quickstart=1) ## **Related issues** Fixes CI failure currently on all branches (e.g. https://app.circleci.com/pipelines/github/MetaMask/metamask-extension/108823/workflows/cbc57b89-8647-4219-b413-24df4fdad95c/jobs/4070258 ) This bug was introduced in #24503, but only began causing failures recently when CI configuration was updated with these new environment variables. ## **Manual testing steps** See that the data deletion e2e tests succeed even with both data deletion environment variables set. ## **Screenshots/Recordings** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/scripts/services/data-deletion-service.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/scripts/services/data-deletion-service.ts b/app/scripts/services/data-deletion-service.ts index 3bdafc03b582..5ec9ede75a87 100644 --- a/app/scripts/services/data-deletion-service.ts +++ b/app/scripts/services/data-deletion-service.ts @@ -12,11 +12,17 @@ import { import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import { DeleteRegulationStatus } from '../../../shared/constants/metametrics'; -const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID = - process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? 'test'; -const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT = - process.env.ANALYTICS_DATA_DELETION_ENDPOINT ?? - 'https://metametrics.metamask.test'; +const inTest = process.env.IN_TEST; +const fallbackSourceId = 'test'; +const fallbackDataDeletionEndpoint = 'https://metametrics.metamask.test'; + +const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID = inTest + ? fallbackSourceId + : process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? fallbackSourceId; +const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT = inTest + ? fallbackDataDeletionEndpoint + : process.env.ANALYTICS_DATA_DELETION_ENDPOINT ?? + fallbackDataDeletionEndpoint; /** * The number of times we retry a specific failed request to the data deletion API. From 70ba803360aba67bb9feea5d6d4db11b8d5e098c Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Thu, 31 Oct 2024 08:27:24 -0700 Subject: [PATCH 34/56] feat: poll native currency prices across chains (#28196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Leverages https://github.com/MetaMask/core/pull/4852 to fetch native currency prices across all evm chains, instead of just the current chain. Metamask will now hit the `/pricemulti` endpoint of cryptocompare to fetch an array of currencies in 1 request. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28196?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** No user facing changes. 1. Add some networks with native currencies other than ETH like polygon, bnb 2. Verify native tokens have the correct fiat price 3. Export state and verify `currencyRates` has entries for each native currency ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: MetaMask Bot --- ...s-controllers-npm-41.0.0-57b3d695bb.patch} | 0 app/scripts/metamask-controller.js | 8 +-- lavamoat/browserify/beta/policy.json | 17 ++++- lavamoat/browserify/flask/policy.json | 17 ++++- lavamoat/browserify/main/policy.json | 17 ++++- lavamoat/browserify/mmi/policy.json | 17 ++++- package.json | 2 +- test/e2e/mock-e2e.js | 10 +-- test/e2e/tests/metrics/errors.spec.js | 2 + ...rs-after-init-opt-in-background-state.json | 12 +++- .../errors-after-init-opt-in-ui-state.json | 12 +++- .../tests/privacy/basic-functionality.spec.js | 4 +- test/e2e/tests/settings/localization.spec.js | 10 +-- ui/hooks/useCurrencyRatePolling.ts | 19 ++++-- ui/selectors/multichain.test.ts | 2 +- ui/store/actions.ts | 8 +-- yarn.lock | 67 ++++++++++--------- 17 files changed, 158 insertions(+), 66 deletions(-) rename .yarn/patches/{@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch => @metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch} (100%) diff --git a/.yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch b/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch similarity index 100% rename from .yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch rename to .yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 2e9e68866030..55f5e881de4a 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -877,13 +877,13 @@ export default class MetamaskController extends EventEmitter { messenger: currencyRateMessenger, state: initState.CurrencyController, }); - const initialFetchExchangeRate = - this.currencyRateController.fetchExchangeRate.bind( + const initialFetchMultiExchangeRate = + this.currencyRateController.fetchMultiExchangeRate.bind( this.currencyRateController, ); - this.currencyRateController.fetchExchangeRate = (...args) => { + this.currencyRateController.fetchMultiExchangeRate = (...args) => { if (this.preferencesController.state.useCurrencyRateCheck) { - return initialFetchExchangeRate(...args); + return initialFetchMultiExchangeRate(...args); } return { conversionRate: null, diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index d511ca2a5122..c87c2b4ce9b0 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -674,6 +674,7 @@ "@ethersproject/providers": true, "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/polling-controller": true, + "@metamask/assets-controllers>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -681,7 +682,6 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/rpc-errors": true, - "@metamask/utils": true, "bn.js": true, "cockatiel": true, "ethers>@ethersproject/address": true, @@ -702,6 +702,21 @@ "uuid": true } }, + "@metamask/assets-controllers>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/base-controller": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index d511ca2a5122..c87c2b4ce9b0 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -674,6 +674,7 @@ "@ethersproject/providers": true, "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/polling-controller": true, + "@metamask/assets-controllers>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -681,7 +682,6 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/rpc-errors": true, - "@metamask/utils": true, "bn.js": true, "cockatiel": true, "ethers>@ethersproject/address": true, @@ -702,6 +702,21 @@ "uuid": true } }, + "@metamask/assets-controllers>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/base-controller": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index d511ca2a5122..c87c2b4ce9b0 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -674,6 +674,7 @@ "@ethersproject/providers": true, "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/polling-controller": true, + "@metamask/assets-controllers>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -681,7 +682,6 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/rpc-errors": true, - "@metamask/utils": true, "bn.js": true, "cockatiel": true, "ethers>@ethersproject/address": true, @@ -702,6 +702,21 @@ "uuid": true } }, + "@metamask/assets-controllers>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/base-controller": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 2f8a3f7cec97..83758144b1a3 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -766,6 +766,7 @@ "@ethersproject/providers": true, "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/polling-controller": true, + "@metamask/assets-controllers>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -773,7 +774,6 @@ "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/rpc-errors": true, - "@metamask/utils": true, "bn.js": true, "cockatiel": true, "ethers>@ethersproject/address": true, @@ -794,6 +794,21 @@ "uuid": true } }, + "@metamask/assets-controllers>@metamask/utils": { + "globals": { + "TextDecoder": true, + "TextEncoder": true + }, + "packages": { + "@metamask/utils>@metamask/superstruct": true, + "@metamask/utils>@scure/base": true, + "@metamask/utils>pony-cause": true, + "@noble/hashes": true, + "browserify>buffer": true, + "nock>debug": true, + "semver": true + } + }, "@metamask/base-controller": { "globals": { "setTimeout": true diff --git a/package.json b/package.json index 9191330a0c39..8d5ea4c341d3 100644 --- a/package.json +++ b/package.json @@ -286,7 +286,7 @@ "@metamask/address-book-controller": "^6.0.0", "@metamask/announcement-controller": "^7.0.0", "@metamask/approval-controller": "^7.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A39.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index cc49b55f192a..85636fcb9089 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -107,7 +107,7 @@ const privateHostMatchers = [ async function setupMocking( server, testSpecificMock, - { chainId, ethConversionInUsd = '1700' }, + { chainId, ethConversionInUsd = 1700 }, ) { const privacyReport = new Set(); await server.forAnyRequest().thenPassThrough({ @@ -616,13 +616,15 @@ async function setupMocking( }); await server - .forGet('https://min-api.cryptocompare.com/data/price') - .withQuery({ fsym: 'ETH', tsyms: 'USD' }) + .forGet('https://min-api.cryptocompare.com/data/pricemulti') + .withQuery({ fsyms: 'ETH', tsyms: 'usd' }) .thenCallback(() => { return { statusCode: 200, json: { - USD: ethConversionInUsd, + ETH: { + USD: ethConversionInUsd, + }, }, }; }); diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index e9fae5d6323c..3b003b044b5a 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -46,6 +46,8 @@ const maskedBackgroundFields = [ 'AppStateController.notificationGasPollTokens', 'AppStateController.popupGasPollTokens', 'CurrencyController.currencyRates.ETH.conversionDate', + 'CurrencyController.currencyRates.LineaETH.conversionDate', + 'CurrencyController.currencyRates.SepoliaETH.conversionDate', ]; const maskedUiFields = maskedBackgroundFields.map(backgroundToUiField); diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 80ea24c8cc3a..6d77cd3ae351 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -85,6 +85,16 @@ "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 + }, + "LineaETH": { + "conversionDate": "number", + "conversionRate": 1700, + "usdConversionRate": 1700 + }, + "SepoliaETH": { + "conversionDate": "number", + "conversionRate": 1700, + "usdConversionRate": 1700 } }, "currentCurrency": "usd" @@ -134,7 +144,7 @@ "MultichainBalancesController": { "balances": "object" }, "MultichainRatesController": { "fiatCurrency": "usd", - "rates": { "btc": { "conversionDate": 0, "conversionRate": "0" } }, + "rates": { "btc": { "conversionDate": 0, "conversionRate": 0 } }, "cryptocurrencies": ["btc"] }, "NameController": { "names": "object", "nameSources": "object" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 6574204ec2bf..e577bb71a6be 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -54,6 +54,16 @@ "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 + }, + "LineaETH": { + "conversionDate": "number", + "conversionRate": 1700, + "usdConversionRate": 1700 + }, + "SepoliaETH": { + "conversionDate": "number", + "conversionRate": 1700, + "usdConversionRate": 1700 } }, "connectedStatusPopoverHasBeenShown": true, @@ -187,7 +197,7 @@ "lastFetchedBlockNumbers": "object", "submitHistory": "object", "fiatCurrency": "usd", - "rates": { "btc": { "conversionDate": 0, "conversionRate": "0" } }, + "rates": { "btc": { "conversionDate": 0, "conversionRate": 0 } }, "cryptocurrencies": ["btc"], "snaps": "object", "jobs": "object", diff --git a/test/e2e/tests/privacy/basic-functionality.spec.js b/test/e2e/tests/privacy/basic-functionality.spec.js index 674ba8772e29..a945154f4bd3 100644 --- a/test/e2e/tests/privacy/basic-functionality.spec.js +++ b/test/e2e/tests/privacy/basic-functionality.spec.js @@ -26,8 +26,8 @@ async function mockApis(mockServer) { }; }), await mockServer - .forGet('https://min-api.cryptocompare.com/data/price') - .withQuery({ fsym: 'ETH', tsyms: 'USD' }) + .forGet('https://min-api.cryptocompare.com/data/pricemulti') + .withQuery({ fsyms: 'ETH', tsyms: 'usd' }) .thenCallback(() => { return { statusCode: 200, diff --git a/test/e2e/tests/settings/localization.spec.js b/test/e2e/tests/settings/localization.spec.js index 1fb1e8d1e8a6..229c385efbeb 100644 --- a/test/e2e/tests/settings/localization.spec.js +++ b/test/e2e/tests/settings/localization.spec.js @@ -7,14 +7,16 @@ const FixtureBuilder = require('../../fixture-builder'); async function mockPhpConversion(mockServer) { return await mockServer - .forGet('https://min-api.cryptocompare.com/data/price') - .withQuery({ fsym: 'ETH', tsyms: 'PHP,USD' }) + .forGet('https://min-api.cryptocompare.com/data/pricemulti') + .withQuery({ fsyms: 'ETH', tsyms: 'php,USD' }) .thenCallback(() => { return { statusCode: 200, json: { - PHP: '100000', - USD: '2500', + ETH: { + PHP: '100000', + USD: '2500', + }, }, }; }); diff --git a/ui/hooks/useCurrencyRatePolling.ts b/ui/hooks/useCurrencyRatePolling.ts index f9d58620b2b0..e7ad21adedf5 100644 --- a/ui/hooks/useCurrencyRatePolling.ts +++ b/ui/hooks/useCurrencyRatePolling.ts @@ -1,25 +1,30 @@ import { useSelector } from 'react-redux'; import { - getSelectedNetworkClientId, + getNetworkConfigurationsByChainId, getUseCurrencyRateCheck, } from '../selectors'; import { - currencyRateStartPollingByNetworkClientId, + currencyRateStartPolling, currencyRateStopPollingByPollingToken, } from '../store/actions'; import { getCompletedOnboarding } from '../ducks/metamask/metamask'; import usePolling from './usePolling'; -const useCurrencyRatePolling = (networkClientId?: string) => { +const useCurrencyRatePolling = () => { const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); const completedOnboarding = useSelector(getCompletedOnboarding); - const selectedNetworkClientId = useSelector(getSelectedNetworkClientId); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + + const nativeCurrencies = [ + ...new Set( + Object.values(networkConfigurations).map((n) => n.nativeCurrency), + ), + ]; usePolling({ - startPolling: (input) => - currencyRateStartPollingByNetworkClientId(input.networkClientId), + startPolling: currencyRateStartPolling, stopPollingByPollingToken: currencyRateStopPollingByPollingToken, - input: { networkClientId: networkClientId ?? selectedNetworkClientId }, + input: nativeCurrencies, enabled: useCurrencyRateCheck && completedOnboarding, }); }; diff --git a/ui/selectors/multichain.test.ts b/ui/selectors/multichain.test.ts index 19fdac1559a7..3097d61f9549 100644 --- a/ui/selectors/multichain.test.ts +++ b/ui/selectors/multichain.test.ts @@ -105,7 +105,7 @@ function getEvmState(chainId: Hex = CHAIN_IDS.MAINNET): TestState { rates: { btc: { conversionDate: 0, - conversionRate: '100000', + conversionRate: 100000, }, }, }, diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 06b892db0b1c..77189e9683af 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4525,15 +4525,15 @@ export async function removePollingTokenFromAppState(pollingToken: string) { /** * Informs the CurrencyRateController that the UI requires currency rate polling * - * @param networkClientId - unique identifier for the network client + * @param nativeCurrencies - An array of native currency symbols * @returns polling token that can be used to stop polling */ -export async function currencyRateStartPollingByNetworkClientId( - networkClientId: string, +export async function currencyRateStartPolling( + nativeCurrencies: string[], ): Promise { const pollingToken = await submitRequestToBackground( 'currencyRateStartPolling', - [{ networkClientId }], + [{ nativeCurrencies }], ); await addPollingTokenToAppState(pollingToken); return pollingToken; diff --git a/yarn.lock b/yarn.lock index 3b1dc9246bbb..1047946c0bc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4772,9 +4772,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:39.0.0": - version: 39.0.0 - resolution: "@metamask/assets-controllers@npm:39.0.0" +"@metamask/assets-controllers@npm:41.0.0": + version: 41.0.0 + resolution: "@metamask/assets-controllers@npm:41.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/address": "npm:^5.7.0" @@ -4782,14 +4782,14 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/base-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^7.0.2" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/controller-utils": "npm:^11.4.2" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/polling-controller": "npm:^11.0.0" - "@metamask/rpc-errors": "npm:^7.0.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/polling-controller": "npm:^12.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" @@ -4804,15 +4804,15 @@ __metadata: "@metamask/accounts-controller": ^18.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/keyring-controller": ^17.0.0 - "@metamask/network-controller": ^21.0.0 + "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^13.0.0 - checksum: 10/1fcfbe98fc1d2cf2b3dfef94d4a3c0752cfd9b5e7208196ebc58c34e34cbb47480eaa608979cdcf41abb7f8ce3c4a8ee2f6031793a5b584ce377f2fff3ec6ade + checksum: 10/63f1a9605d692217889511ca161ee614d8e12d7f7233773afb34c4fb6323fad1c29b3a4ee920ef6f84e4b165ffb8764dfd105bdc9bad75084f52a7c876faa4f5 languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A39.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch": - version: 39.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A39.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch::version=39.0.0&hash=e14ff8" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch": + version: 41.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch::version=41.0.0&hash=e14ff8" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/address": "npm:^5.7.0" @@ -4820,14 +4820,14 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/base-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^7.0.2" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/controller-utils": "npm:^11.4.2" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/polling-controller": "npm:^11.0.0" - "@metamask/rpc-errors": "npm:^7.0.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/polling-controller": "npm:^12.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" @@ -4842,9 +4842,9 @@ __metadata: "@metamask/accounts-controller": ^18.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/keyring-controller": ^17.0.0 - "@metamask/network-controller": ^21.0.0 + "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^13.0.0 - checksum: 10/95cbdcf80e46a601118c806ba41113ac2feb18f2518265c4084c0b37d04e7a02ea6fb4ca2ff480905b9f9f4c13e2daaa6ae6bd4d375986396c5ef26ce0d2bed3 + checksum: 10/f7d609be61f4e952abd78d996a44131941f1fcd476066d007bed5047d1c887d38e9e9cf117eeb963148674fd9ad6ae87c8384bc8a21d4281628aaab1b60ce7a8 languageName: node linkType: hard @@ -4925,9 +4925,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0, @metamask/controller-utils@npm:^11.4.1": - version: 11.4.1 - resolution: "@metamask/controller-utils@npm:11.4.1" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0, @metamask/controller-utils@npm:^11.4.1, @metamask/controller-utils@npm:^11.4.2": + version: 11.4.2 + resolution: "@metamask/controller-utils@npm:11.4.2" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" @@ -4935,10 +4935,11 @@ __metadata: "@metamask/utils": "npm:^10.0.0" "@spruceid/siwe-parser": "npm:2.1.0" "@types/bn.js": "npm:^5.1.5" + bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/fff4864858ce2072456537c9b51cb4c10d178a27b39ab5af8d6e9595efb59dd043bb49be336d8ac725d1281279db4365855f024329398508658b2b2d3b5bc2a5 + checksum: 10/fdae49ee97e7a2a1bb6414011ca59932f8712a768a9c4c43673a2504c9fa9e61d83df53a21ff0506ef6a8cf774704f2df58a6d71385c8786ec5cab4359c051e1 languageName: node linkType: hard @@ -5906,19 +5907,19 @@ __metadata: languageName: node linkType: hard -"@metamask/polling-controller@npm:^11.0.0": - version: 11.0.0 - resolution: "@metamask/polling-controller@npm:11.0.0" +"@metamask/polling-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/polling-controller@npm:12.0.1" dependencies: - "@metamask/base-controller": "npm:^7.0.1" - "@metamask/controller-utils": "npm:^11.3.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/utils": "npm:^10.0.0" "@types/uuid": "npm:^8.3.0" fast-json-stable-stringify: "npm:^2.1.0" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/network-controller": ^21.0.0 - checksum: 10/67b563a5d1ce02dc9c2db25ad4ad1fb9f75d5578cf380cce85176ff2cd136addce612c3982653254647b9d8c535374e93d96abb6e500e42076bf3a524a72e75f + "@metamask/network-controller": ^22.0.0 + checksum: 10/eac9ed2fcc9697a2aa55e9746d4eac8d762dd6948b00d77cd2d4894b8c3e1a8e6ed5d0df4d01a69d9a7e2b3c09d9d7c1ffc6f9504023388dd7452d45b5d87065 languageName: node linkType: hard @@ -25934,7 +25935,7 @@ __metadata: "@metamask/announcement-controller": "npm:^7.0.0" "@metamask/api-specs": "npm:^0.9.3" "@metamask/approval-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A39.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-39.0.0-57b3d695bb.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A41.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-41.0.0-57b3d695bb.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" From eb9a2edf465e1429dd54302645e46d6b32f112dd Mon Sep 17 00:00:00 2001 From: Harika <153644847+hjetpoluru@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:57:09 -0400 Subject: [PATCH 35/56] chore: Cherry pick data deletion into v12.6.0 (#28223) ## **Description** Cherry-pick PR https://github.com/MetaMask/metamask-extension/pull/28221 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28223?quickstart=1) Co-authored-by: Mark Stacey --- app/scripts/services/data-deletion-service.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/scripts/services/data-deletion-service.ts b/app/scripts/services/data-deletion-service.ts index 3bdafc03b582..5ec9ede75a87 100644 --- a/app/scripts/services/data-deletion-service.ts +++ b/app/scripts/services/data-deletion-service.ts @@ -12,11 +12,17 @@ import { import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import { DeleteRegulationStatus } from '../../../shared/constants/metametrics'; -const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID = - process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? 'test'; -const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT = - process.env.ANALYTICS_DATA_DELETION_ENDPOINT ?? - 'https://metametrics.metamask.test'; +const inTest = process.env.IN_TEST; +const fallbackSourceId = 'test'; +const fallbackDataDeletionEndpoint = 'https://metametrics.metamask.test'; + +const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID = inTest + ? fallbackSourceId + : process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? fallbackSourceId; +const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT = inTest + ? fallbackDataDeletionEndpoint + : process.env.ANALYTICS_DATA_DELETION_ENDPOINT ?? + fallbackDataDeletionEndpoint; /** * The number of times we retry a specific failed request to the data deletion API. From 741aa96b6bfb739a634f2da4582464eb5c6dcfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Tavares?= Date: Thu, 31 Oct 2024 16:58:06 +0000 Subject: [PATCH 36/56] test: add ui render for debug ui integration tests (#27621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds UI rendering capabilities for debugging UI integration tests. It introduces the jest-preview package to allow visual inspection of component states during test execution. For jest-preview to properly render the page being tested in the UI integration test, we need to provide it with the bundled css as well as with the relevant static assets. To do that we have also introduced a minimal build script for UI integration tests, that outputs to `test/integration/config/assets/`. For consistency with actual application build process, the build script re-uses a lot of the existing build methods. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27621?quickstart=1) ## **Related issues** N/A ## **Manual testing steps** 1. Run the UI integration tests with the new debug rendering enabled 2. Verify that component states can be visually inspected during test runs ## **Screenshots/Recordings** Output of the `debug()` in a UI integration test. ![Screenshot 2024-10-23 at 12 01 04](https://github.com/user-attachments/assets/411fc8da-2431-485c-94b7-761faee2487a) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: MetaMask Bot --- .depcheckrc.yml | 2 + .gitignore | 3 + .../webpack.integration.tests.config.ts | 116 +++ jest.integration.config.js | 9 + lavamoat/browserify/beta/policy.json | 4 +- lavamoat/browserify/flask/policy.json | 4 +- lavamoat/browserify/main/policy.json | 4 +- lavamoat/browserify/mmi/policy.json | 4 +- lavamoat/build-system/policy.json | 9 +- package.json | 10 +- test/integration/config/setupAfter.js | 7 + yarn.lock | 780 +++++++++++++++++- 12 files changed, 914 insertions(+), 38 deletions(-) create mode 100644 development/webpack/webpack.integration.tests.config.ts diff --git a/.depcheckrc.yml b/.depcheckrc.yml index d0d6eac5b5bc..50b79a78ec30 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -81,6 +81,8 @@ ignores: # trezor - 'ts-mixer' - '@testing-library/dom' + - 'mini-css-extract-plugin' + - 'webpack-cli' # files depcheck should not parse ignorePatterns: diff --git a/.gitignore b/.gitignore index 1671e69527e0..074f4076a7cc 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ html-report/ /app/images/branding /changed-files + +# UI Integration tests +test/integration/config/assets diff --git a/development/webpack/webpack.integration.tests.config.ts b/development/webpack/webpack.integration.tests.config.ts new file mode 100644 index 000000000000..77e032581180 --- /dev/null +++ b/development/webpack/webpack.integration.tests.config.ts @@ -0,0 +1,116 @@ +/** + * @file The webpack configuration file to enable debug previewing for UI integration tests. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + type Configuration, + type WebpackPluginInstance, + ProgressPlugin, +} from 'webpack'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import CopyPlugin from 'copy-webpack-plugin'; +import rtlCss from 'postcss-rtlcss'; +import autoprefixer from 'autoprefixer'; + +const context = join(__dirname, '../../app'); +const browsersListPath = join(context, '../.browserslistrc'); +const browsersListQuery = readFileSync(browsersListPath, 'utf8'); + +const plugins: WebpackPluginInstance[] = [ + new CopyPlugin({ + patterns: [ + { from: join(context, '_locales'), to: '_locales' }, // translations + // misc images + // TODO: fix overlap between this folder and automatically bundled assets + { from: join(context, 'images'), to: 'images' }, + ], + }), + new ProgressPlugin(), + new MiniCssExtractPlugin({ filename: '[name].css' }), +]; + +const config = { + entry: { + index: join(context, '../ui/css/index.scss'), + }, + plugins, + mode: 'development', + context, + stats: 'normal', + name: `MetaMask UI integration test`, + output: { + path: join(context, '..', 'test/integration/config/assets'), + clean: true, + }, + // note: loaders in a `use` array are applied in *reverse* order, i.e., bottom + // to top, (or right to left depending on the current formatting of the file) + module: { + rules: [ + // css, sass/scss + { + test: /\.(css|sass|scss)$/u, + use: [ + MiniCssExtractPlugin.loader, + // Resolves CSS `@import` and `url()` paths and loads the files. + { + loader: 'css-loader', + options: { + url: true, + }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + autoprefixer({ overrideBrowserslist: browsersListQuery }), + rtlCss({ processEnv: false }), + ], + }, + }, + }, + { + loader: 'resolve-url-loader', + }, + // Compiles Sass to CSS + { + loader: 'sass-loader', + options: { + // Use 'sass-embedded', as it is usually faster than 'sass' + implementation: 'sass-embedded', + sassOptions: { + api: 'modern', + // We don't need to specify the charset because the HTML + // already does and browsers use the HTML's charset for CSS. + // Additionally, webpack + sass can cause problems with the + // charset placement, as described here: + // https://github.com/webpack-contrib/css-loader/issues/1212 + charset: false, + // The order of includePaths is important; prefer our own + // folders over `node_modules` + includePaths: [ + // enables aliases to `@use design - system`, + // `@use utilities`, etc. + join(context, '../ui/css'), + join(context, '../node_modules'), + ], + // Disable the webpackImporter, as we: + // a) don't want to rely on it in case we want to switch away + // from webpack in the future + // b) the sass importer is faster + // c) the "modern" sass api doesn't work with the + // webpackImporter yet. + webpackImporter: false, + }, + sourceMap: true, + }, + }, + ], + }, + ], + }, +} as const satisfies Configuration; + +export default config; diff --git a/jest.integration.config.js b/jest.integration.config.js index 6f5d79484386..d7236b832aed 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -35,4 +35,13 @@ module.exports = { customExportConditions: ['node', 'node-addons'], }, workerIdleMemoryLimit: '500MB', + transform: { + // Use babel-jest to transpile tests with the next/babel preset + // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object + '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', + '^.+\\.(css|scss|sass|less)$': 'jest-preview/transforms/css', + '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)': + 'jest-preview/transforms/file', + }, + transformIgnorePatterns: ['/node_modules/'], }; diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index c87c2b4ce9b0..ac719964d896 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2025,9 +2025,9 @@ "@ethereumjs/tx>ethereum-cryptography": true, "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/phishing-controller>fastest-levenshtein": true, "@noble/hashes": true, - "punycode": true + "punycode": true, + "webpack-cli>fastest-levenshtein": true } }, "@metamask/polling-controller": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index c87c2b4ce9b0..ac719964d896 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2025,9 +2025,9 @@ "@ethereumjs/tx>ethereum-cryptography": true, "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/phishing-controller>fastest-levenshtein": true, "@noble/hashes": true, - "punycode": true + "punycode": true, + "webpack-cli>fastest-levenshtein": true } }, "@metamask/polling-controller": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index c87c2b4ce9b0..ac719964d896 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2025,9 +2025,9 @@ "@ethereumjs/tx>ethereum-cryptography": true, "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/phishing-controller>fastest-levenshtein": true, "@noble/hashes": true, - "punycode": true + "punycode": true, + "webpack-cli>fastest-levenshtein": true } }, "@metamask/polling-controller": { diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 83758144b1a3..1b4e98aae177 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2117,9 +2117,9 @@ "@ethereumjs/tx>ethereum-cryptography": true, "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/phishing-controller>fastest-levenshtein": true, "@noble/hashes": true, - "punycode": true + "punycode": true, + "webpack-cli>fastest-levenshtein": true } }, "@metamask/polling-controller": { diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index 8b96f3fcc0fc..5a607452526c 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -6281,9 +6281,9 @@ "packages": { "@babel/register>clone-deep>is-plain-object": true, "lavamoat>lavamoat-core>merge-deep>clone-deep>for-own": true, - "lavamoat>lavamoat-core>merge-deep>clone-deep>kind-of": true, "lavamoat>lavamoat-core>merge-deep>clone-deep>lazy-cache": true, - "lavamoat>lavamoat-core>merge-deep>clone-deep>shallow-clone": true + "lavamoat>lavamoat-core>merge-deep>clone-deep>shallow-clone": true, + "lavamoat>lavamoat-core>merge-deep>kind-of": true } }, "lavamoat>lavamoat-core>merge-deep>clone-deep>for-own": { @@ -6291,11 +6291,6 @@ "gulp>undertaker>object.reduce>for-own>for-in": true } }, - "lavamoat>lavamoat-core>merge-deep>clone-deep>kind-of": { - "packages": { - "browserify>insert-module-globals>is-buffer": true - } - }, "lavamoat>lavamoat-core>merge-deep>clone-deep>lazy-cache": { "globals": { "process.env.TRAVIS": true, diff --git a/package.json b/package.json index 8d5ea4c341d3..edd149c79252 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "test:unit:coverage": "jest --coverage", "test:unit:webpack": "tsx --test development/webpack/test/*.test.ts", "test:unit:webpack:coverage": "nyc --reporter=html --reporter=json --reporter=text --report-dir=./coverage/webpack tsx --test development/webpack/test/*.test.ts", - "test:integration": "jest --config jest.integration.config.js", - "test:integration:coverage": "jest --config jest.integration.config.js --coverage", + "test:integration": "npx webpack build --config ./development/webpack/webpack.integration.tests.config.ts && jest --config jest.integration.config.js", + "test:integration:coverage": "yarn test:integration --coverage", "test:e2e:chrome": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js", "test:e2e:chrome:mmi": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --mmi", "test:e2e:chrome:flask": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --build-type flask", @@ -602,6 +602,7 @@ "jest": "^29.7.0", "jest-canvas-mock": "^2.3.1", "jest-environment-jsdom": "patch:jest-environment-jsdom@npm%3A29.7.0#~/.yarn/patches/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b.patch", + "jest-preview": "^0.3.1", "jsdom": "^16.7.0", "json-schema-to-ts": "^3.0.1", "koa": "^2.7.0", @@ -611,6 +612,7 @@ "level": "^8.0.1", "lockfile-lint": "^4.10.6", "loose-envify": "^1.4.0", + "mini-css-extract-plugin": "^2.9.1", "mocha": "^10.2.0", "mocha-junit-reporter": "^2.2.1", "mockttp": "^3.10.1", @@ -668,6 +670,7 @@ "watchify": "^4.0.0", "webextension-polyfill": "^0.8.0", "webpack": "^5.91.0", + "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.3", "ws": "^8.17.1", "yaml": "^2.4.1", @@ -741,7 +744,8 @@ "core-js-pure": true, "resolve-url-loader>es6-iterator>d>es5-ext": false, "resolve-url-loader>es6-iterator>d>es5-ext>esniff>es5-ext": false, - "level>classic-level": false + "level>classic-level": false, + "jest-preview": false } }, "packageManager": "yarn@4.4.1" diff --git a/test/integration/config/setupAfter.js b/test/integration/config/setupAfter.js index 39eba1e429a5..ad9e49178094 100644 --- a/test/integration/config/setupAfter.js +++ b/test/integration/config/setupAfter.js @@ -1,2 +1,9 @@ // This file is for Jest-specific setup only and runs before our Jest tests. +import { jestPreviewConfigure } from 'jest-preview'; +import '../config/assets/index.css'; import '../../helpers/setup-after-helper'; + +// Should be path from root of your project +jestPreviewConfigure({ + publicFolder: 'test/integration/config/assets', // No need to configure if `publicFolder` is `public` +}); diff --git a/yarn.lock b/yarn.lock index 1047946c0bc9..7890bcaa7b3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1646,13 +1646,13 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.0, @babel/types@npm:^7.13.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.0, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": - version: 7.25.9 - resolution: "@babel/types@npm:7.25.9" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.0, @babel/types@npm:^7.13.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.0, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": + version: 7.26.0 + resolution: "@babel/types@npm:7.26.0" dependencies: "@babel/helper-string-parser": "npm:^7.25.9" "@babel/helper-validator-identifier": "npm:^7.25.9" - checksum: 10/dd0f2874b10048aa230a5633ab440bbee8c3905f254ef26223b5321ddb824b057b9404d24a87556c6a9f7430198fa6311473778d147ed8ed7845428aee2ebc34 + checksum: 10/40780741ecec886ed9edae234b5eb4976968cc70d72b4e5a40d55f83ff2cc457de20f9b0f4fe9d858350e43dab0ea496e7ef62e2b2f08df699481a76df02cd6e languageName: node linkType: hard @@ -1727,7 +1727,7 @@ __metadata: languageName: node linkType: hard -"@discoveryjs/json-ext@npm:^0.5.3": +"@discoveryjs/json-ext@npm:^0.5.0, @discoveryjs/json-ext@npm:^0.5.3": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" checksum: 10/b95682a852448e8ef50d6f8e3b7ba288aab3fd98a2bafbe46881a3db0c6e7248a2debe9e1ee0d4137c521e4743ca5bbcb1c0765c9d7b3e0ef53231506fec42b4 @@ -7067,6 +7067,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.28 + resolution: "@polka/url@npm:1.0.0-next.28" + checksum: 10/7402aaf1de781d0eb0870d50cbcd394f949aee11b38a267a5c3b4e3cfee117e920693e6e93ce24c87ae2d477a59634f39d9edde8e86471cae756839b07c79af7 + languageName: node + linkType: hard + "@popperjs/core@npm:^2.4.0": version: 2.9.2 resolution: "@popperjs/core@npm:2.9.2" @@ -7939,6 +7946,13 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/is@npm:^0.14.0": + version: 0.14.0 + resolution: "@sindresorhus/is@npm:0.14.0" + checksum: 10/789cd128f0b43e158e657c4505539c8997905fcb5c06d750b7df778cab2b6887bc1eb8878026a20d84524528786ef69fc3d12a964ae56a478a87bcfc7f8272f3 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.2.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -9109,6 +9123,133 @@ __metadata: languageName: node linkType: hard +"@svgr/babel-plugin-add-jsx-attribute@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/cab83832830a57735329ed68f67c03b57ca21fa037b0134847b0c5c0ef4beca89956d7dacfbf7b2a10fd901e7009e877512086db2ee918b8c69aee7742ae32c0 + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-attribute@npm:*": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-attribute@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/ff992893c6c4ac802713ba3a97c13be34e62e6d981c813af40daabcd676df68a72a61bd1e692bb1eda3587f1b1d700ea462222ae2153bb0f46886632d4f88d08 + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-empty-expression@npm:*": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-empty-expression@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0fb691b63a21bac00da3aa2dccec50d0d5a5b347ff408d60803b84410d8af168f2656e4ba1ee1f24dab0ae4e4af77901f2928752bb0434c1f6788133ec599ec8 + languageName: node + linkType: hard + +"@svgr/babel-plugin-replace-jsx-attribute-value@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-replace-jsx-attribute-value@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/b7d2125758e766e1ebd14b92216b800bdc976959bc696dbfa1e28682919147c1df4bb8b1b5fd037d7a83026e27e681fea3b8d3741af8d3cf4c9dfa3d412125df + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-dynamic-title@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-svg-dynamic-title@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0fd42ebf127ae9163ef341e84972daa99bdcb9e6ed3f83aabd95ee173fddc43e40e02fa847fbc0a1058cf5549f72b7960a2c5e22c3e4ac18f7e3ac81277852ae + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-em-dimensions@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-svg-em-dimensions@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/c1550ee9f548526fa66fd171e3ffb5696bfc4e4cd108a631d39db492c7410dc10bba4eb5a190e9df824bf806130ccc586ae7d2e43c547e6a4f93bbb29a18f344 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-react-native-svg@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-transform-react-native-svg@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/4c924af22b948b812629e80efb90ad1ec8faae26a232d8ca8a06b46b53e966a2c415a57806a3ff0ea806a622612e546422719b69ec6839717a7755dac19171d9 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-svg-component@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-plugin-transform-svg-component@npm:6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/a4ddd3cf8b1a7a0542ff2c6a3eb7a75d6f79a86a62210306d94fb05e59699bb5da4ddde9ce98ef477b9cd528007fb728dc4d388d413b3aa25f48ed92b1f0a1c1 + languageName: node + linkType: hard + +"@svgr/babel-preset@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/babel-preset@npm:6.5.1" + dependencies: + "@svgr/babel-plugin-add-jsx-attribute": "npm:^6.5.1" + "@svgr/babel-plugin-remove-jsx-attribute": "npm:*" + "@svgr/babel-plugin-remove-jsx-empty-expression": "npm:*" + "@svgr/babel-plugin-replace-jsx-attribute-value": "npm:^6.5.1" + "@svgr/babel-plugin-svg-dynamic-title": "npm:^6.5.1" + "@svgr/babel-plugin-svg-em-dimensions": "npm:^6.5.1" + "@svgr/babel-plugin-transform-react-native-svg": "npm:^6.5.1" + "@svgr/babel-plugin-transform-svg-component": "npm:^6.5.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/9f124be39a8e64f909162f925b3a63ddaa5a342a5e24fc0b7f7d9d4d7f7e3b916596c754fb557dc259928399cad5366a27cb231627a0d2dcc4b13ac521cf05af + languageName: node + linkType: hard + +"@svgr/core@npm:^6.2.1": + version: 6.5.1 + resolution: "@svgr/core@npm:6.5.1" + dependencies: + "@babel/core": "npm:^7.19.6" + "@svgr/babel-preset": "npm:^6.5.1" + "@svgr/plugin-jsx": "npm:^6.5.1" + camelcase: "npm:^6.2.0" + cosmiconfig: "npm:^7.0.1" + checksum: 10/0aa3078eefb969d93fb5639c2d64c8868cf65134f0e36a1733dc595acc990081cbad62295e34b860150ce6baa21516d71410c5527579a1a0950cdc35a765873a + languageName: node + linkType: hard + +"@svgr/hast-util-to-babel-ast@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/hast-util-to-babel-ast@npm:6.5.1" + dependencies: + "@babel/types": "npm:^7.20.0" + entities: "npm:^4.4.0" + checksum: 10/0410c6e5bf98fe31729ab1785642b915e7645e65c7ee5b2dd292a4603f8a1377402b95237c550b10dbdcc0bf084df1546ac7e98004d1fe5982cb8508147b47bb + languageName: node + linkType: hard + +"@svgr/plugin-jsx@npm:^6.5.1": + version: 6.5.1 + resolution: "@svgr/plugin-jsx@npm:6.5.1" + dependencies: + "@babel/core": "npm:^7.19.6" + "@svgr/babel-preset": "npm:^6.5.1" + "@svgr/hast-util-to-babel-ast": "npm:^6.5.1" + svg-parser: "npm:^2.0.4" + peerDependencies: + "@svgr/core": ^6.0.0 + checksum: 10/42f22847a6bdf930514d7bedd3c5e1fd8d53eb3594779f9db16cb94c762425907c375cd8ec789114e100a4d38068aca6c7ab5efea4c612fba63f0630c44cc859 + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.4.11": version: 1.4.11 resolution: "@swc/core-darwin-arm64@npm:1.4.11" @@ -9387,6 +9528,15 @@ __metadata: languageName: node linkType: hard +"@szmarczak/http-timer@npm:^1.1.2": + version: 1.1.2 + resolution: "@szmarczak/http-timer@npm:1.1.2" + dependencies: + defer-to-connect: "npm:^1.0.1" + checksum: 10/9b63853bd53bff72c4990ebc9cd3f625bbab757247099af172564da6649a27a1d41b1a70cd849dd65b2a078300029c1c80bf3079e6a91e285da7b259eb147146 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^4.0.5": version: 4.0.6 resolution: "@szmarczak/http-timer@npm:4.0.6" @@ -11594,6 +11744,39 @@ __metadata: languageName: node linkType: hard +"@webpack-cli/configtest@npm:^2.1.1": + version: 2.1.1 + resolution: "@webpack-cli/configtest@npm:2.1.1" + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + checksum: 10/9f9f9145c2d05471fc83d426db1df85cf49f329836b0c4b9f46b6948bed4b013464c00622b136d2a0a26993ce2306976682592245b08ee717500b1db45009a72 + languageName: node + linkType: hard + +"@webpack-cli/info@npm:^2.0.2": + version: 2.0.2 + resolution: "@webpack-cli/info@npm:2.0.2" + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + checksum: 10/8f9a178afca5c82e113aed1efa552d64ee5ae4fdff63fe747c096a981ec74f18a5d07bd6e89bbe6715c3e57d96eea024a410e58977169489fe1df044c10dd94e + languageName: node + linkType: hard + +"@webpack-cli/serve@npm:^2.0.5": + version: 2.0.5 + resolution: "@webpack-cli/serve@npm:2.0.5" + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + peerDependenciesMeta: + webpack-dev-server: + optional: true + checksum: 10/20424e5c1e664e4d7ab11facee7033bb729f6acd86493138069532934c1299c1426da72942822dedb00caca8fc60cc8aec1626e610ee0e8a9679e3614f555860 + languageName: node + linkType: hard + "@welldone-software/why-did-you-render@npm:^8.0.3": version: 8.0.3 resolution: "@welldone-software/why-did-you-render@npm:8.0.3" @@ -12161,6 +12344,15 @@ __metadata: languageName: node linkType: hard +"ansi-align@npm:^3.0.0": + version: 3.0.1 + resolution: "ansi-align@npm:3.0.1" + dependencies: + string-width: "npm:^4.1.0" + checksum: 10/4c7e8b6a10eaf18874ecee964b5db62ac86d0b9266ad4987b3a1efcb5d11a9e12c881ee40d14951833135a8966f10a3efe43f9c78286a6e632f53d85ad28b9c0 + languageName: node + linkType: hard + "ansi-colors@npm:1.1.0, ansi-colors@npm:^1.0.1": version: 1.1.0 resolution: "ansi-colors@npm:1.1.0" @@ -13596,6 +13788,22 @@ __metadata: languageName: node linkType: hard +"boxen@npm:^5.0.0": + version: 5.1.2 + resolution: "boxen@npm:5.1.2" + dependencies: + ansi-align: "npm:^3.0.0" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.1.0" + cli-boxes: "npm:^2.2.1" + string-width: "npm:^4.2.2" + type-fest: "npm:^0.20.2" + widest-line: "npm:^3.1.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/bc3d3d88d77dc8cabb0811844acdbd4805e8ca8011222345330817737042bf6f86d93eb74a3f7e0cab634e64ef69db03cf52b480761ed90a965de0c8ff1bea8c + languageName: node + linkType: hard + "bplist-parser@npm:^0.2.0": version: 0.2.0 resolution: "bplist-parser@npm:0.2.0" @@ -14187,6 +14395,21 @@ __metadata: languageName: node linkType: hard +"cacheable-request@npm:^6.0.0": + version: 6.1.0 + resolution: "cacheable-request@npm:6.1.0" + dependencies: + clone-response: "npm:^1.0.2" + get-stream: "npm:^5.1.0" + http-cache-semantics: "npm:^4.0.0" + keyv: "npm:^3.0.0" + lowercase-keys: "npm:^2.0.0" + normalize-url: "npm:^4.1.0" + responselike: "npm:^1.0.2" + checksum: 10/804f6c377ce6fef31c584babde31d55c69305569058ad95c24a41bb7b33d0ea188d388467a9da6cb340e95a3a1f8a94e1f3a709fef5eaf9c6b88e62448fa29be + languageName: node + linkType: hard + "cacheable-request@npm:^7.0.2": version: 7.0.2 resolution: "cacheable-request@npm:7.0.2" @@ -14290,7 +14513,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0": +"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10/8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d @@ -14565,6 +14788,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^2.0.0": + version: 2.0.0 + resolution: "ci-info@npm:2.0.0" + checksum: 10/3b374666a85ea3ca43fa49aa3a048d21c9b475c96eb13c133505d2324e7ae5efd6a454f41efe46a152269e9b6a00c9edbe63ec7fa1921957165aae16625acd67 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.3.2 resolution: "ci-info@npm:3.3.2" @@ -14657,6 +14887,13 @@ __metadata: languageName: node linkType: hard +"cli-boxes@npm:^2.2.1": + version: 2.2.1 + resolution: "cli-boxes@npm:2.2.1" + checksum: 10/be79f8ec23a558b49e01311b39a1ea01243ecee30539c880cf14bf518a12e223ef40c57ead0cb44f509bffdffc5c129c746cd50d863ab879385370112af4f585 + languageName: node + linkType: hard + "cli-cursor@npm:^3.1.0": version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" @@ -14963,7 +15200,7 @@ __metadata: languageName: node linkType: hard -"colorette@npm:^2.0.10, colorette@npm:^2.0.19": +"colorette@npm:^2.0.10, colorette@npm:^2.0.14, colorette@npm:^2.0.19": version: 2.0.20 resolution: "colorette@npm:2.0.20" checksum: 10/0b8de48bfa5d10afc160b8eaa2b9938f34a892530b2f7d7897e0458d9535a066e3998b49da9d21161c78225b272df19ae3a64d6df28b4c9734c0e55bbd02406f @@ -15064,7 +15301,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^9.0.0, commander@npm:^9.4.0": +"commander@npm:^9.0.0, commander@npm:^9.2.0, commander@npm:^9.4.0": version: 9.5.0 resolution: "commander@npm:9.5.0" checksum: 10/41c49b3d0f94a1fbeb0463c85b13f15aa15a9e0b4d5e10a49c0a1d58d4489b549d62262b052ae0aa6cfda53299bee487bfe337825df15e342114dde543f82906 @@ -15211,6 +15448,20 @@ __metadata: languageName: node linkType: hard +"configstore@npm:^5.0.1": + version: 5.0.1 + resolution: "configstore@npm:5.0.1" + dependencies: + dot-prop: "npm:^5.2.0" + graceful-fs: "npm:^4.1.2" + make-dir: "npm:^3.0.0" + unique-string: "npm:^2.0.0" + write-file-atomic: "npm:^3.0.0" + xdg-basedir: "npm:^4.0.0" + checksum: 10/60ef65d493b63f96e14b11ba7ec072fdbf3d40110a94fb7199d1c287761bdea5c5244e76b2596325f30c1b652213aa75de96ea20afd4a5f82065e61ea090988e + languageName: node + linkType: hard + "connect-history-api-fallback@npm:^2.0.0": version: 2.0.0 resolution: "connect-history-api-fallback@npm:2.0.0" @@ -16072,6 +16323,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^3.3.0": + version: 3.3.0 + resolution: "decompress-response@npm:3.3.0" + dependencies: + mimic-response: "npm:^1.0.0" + checksum: 10/952552ac3bd7de2fc18015086b09468645c9638d98a551305e485230ada278c039c91116e946d07894b39ee53c0f0d5b6473f25a224029344354513b412d7380 + languageName: node + linkType: hard + "decompress-response@npm:^6.0.0": version: 6.0.0 resolution: "decompress-response@npm:6.0.0" @@ -16231,6 +16491,13 @@ __metadata: languageName: node linkType: hard +"defer-to-connect@npm:^1.0.1": + version: 1.1.3 + resolution: "defer-to-connect@npm:1.1.3" + checksum: 10/9491b301dcfa04956f989481ba7a43c2231044206269eb4ab64a52d6639ee15b1252262a789eb4239fb46ab63e44d4e408641bae8e0793d640aee55398cb3930 + languageName: node + linkType: hard + "defer-to-connect@npm:^2.0.0": version: 2.0.1 resolution: "defer-to-connect@npm:2.0.1" @@ -16963,6 +17230,15 @@ __metadata: languageName: node linkType: hard +"dot-prop@npm:^5.2.0": + version: 5.3.0 + resolution: "dot-prop@npm:5.3.0" + dependencies: + is-obj: "npm:^2.0.0" + checksum: 10/33b2561617bd5c73cf9305368ba4638871c5dbf9c8100c8335acd2e2d590a81ec0e75c11cfaea5cc3cf8c2f668cad4beddb52c11856d0c9e666348eee1baf57a + languageName: node + linkType: hard + "dot-prop@npm:^6.0.1": version: 6.0.1 resolution: "dot-prop@npm:6.0.1" @@ -17699,6 +17975,13 @@ __metadata: languageName: node linkType: hard +"escape-goat@npm:^2.0.0": + version: 2.1.1 + resolution: "escape-goat@npm:2.1.1" + checksum: 10/ce05c70c20dd7007b60d2d644b625da5412325fdb57acf671ba06cb2ab3cd6789e2087026921a05b665b0a03fadee2955e7fc0b9a67da15a6551a980b260eba7 + languageName: node + linkType: hard + "escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" @@ -19061,7 +19344,7 @@ __metadata: languageName: node linkType: hard -"fastest-levenshtein@npm:^1.0.16": +"fastest-levenshtein@npm:^1.0.12, fastest-levenshtein@npm:^1.0.16": version: 1.0.16 resolution: "fastest-levenshtein@npm:1.0.16" checksum: 10/ee85d33b5cef592033f70e1c13ae8624055950b4eb832435099cd56aa313d7f251b873bedbc06a517adfaff7b31756d139535991e2406967438e03a1bf1b008e @@ -19323,6 +19606,16 @@ __metadata: languageName: node linkType: hard +"find-node-modules@npm:^2.1.3": + version: 2.1.3 + resolution: "find-node-modules@npm:2.1.3" + dependencies: + findup-sync: "npm:^4.0.0" + merge: "npm:^2.1.1" + checksum: 10/4b8a194ffd56ccf1a1033de35e2ee8209869b05cce68ff7c4ab0dbf04e63fd7196283383eee4c84596c7b311755b2836815209d558234cadc330a87881e5a3f4 + languageName: node + linkType: hard + "find-pkg@npm:^0.1.2": version: 0.1.2 resolution: "find-pkg@npm:0.1.2" @@ -19424,6 +19717,18 @@ __metadata: languageName: node linkType: hard +"findup-sync@npm:^4.0.0": + version: 4.0.0 + resolution: "findup-sync@npm:4.0.0" + dependencies: + detect-file: "npm:^1.0.0" + is-glob: "npm:^4.0.0" + micromatch: "npm:^4.0.2" + resolve-dir: "npm:^1.0.1" + checksum: 10/94131e1107ad63790ed00c4c39ca131a93ea602607bd97afeffd92b69a9a63cf2c6f57d6db88cb753fe748ac7fde79e1e76768ff784247026b7c5ebf23ede3a0 + languageName: node + linkType: hard + "fined@npm:^1.0.1": version: 1.1.0 resolution: "fined@npm:1.1.0" @@ -20097,6 +20402,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^4.1.0": + version: 4.1.0 + resolution: "get-stream@npm:4.1.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10/12673e8aebc79767d187b203e5bfabb8266304037815d3bcc63b6f8c67c6d4ad0d98d4d4528bcdc1cbea68f1dd91bcbd87827aa3cdcfa9c5fa4a4644716d72c2 + languageName: node + linkType: hard + "get-stream@npm:^5.0.0, get-stream@npm:^5.1.0, get-stream@npm:^5.2.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -20367,6 +20681,15 @@ __metadata: languageName: node linkType: hard +"global-dirs@npm:^3.0.0": + version: 3.0.1 + resolution: "global-dirs@npm:3.0.1" + dependencies: + ini: "npm:2.0.0" + checksum: 10/70147b80261601fd40ac02a104581432325c1c47329706acd773f3a6ce99bb36d1d996038c85ccacd482ad22258ec233c586b6a91535b1a116b89663d49d6438 + languageName: node + linkType: hard + "global-modules@npm:^0.2.3": version: 0.2.3 resolution: "global-modules@npm:0.2.3" @@ -20583,6 +20906,25 @@ __metadata: languageName: node linkType: hard +"got@npm:^9.6.0": + version: 9.6.0 + resolution: "got@npm:9.6.0" + dependencies: + "@sindresorhus/is": "npm:^0.14.0" + "@szmarczak/http-timer": "npm:^1.1.2" + cacheable-request: "npm:^6.0.0" + decompress-response: "npm:^3.3.0" + duplexer3: "npm:^0.1.4" + get-stream: "npm:^4.1.0" + lowercase-keys: "npm:^1.0.1" + mimic-response: "npm:^1.0.1" + p-cancelable: "npm:^1.0.0" + to-readable-stream: "npm:^1.0.0" + url-parse-lax: "npm:^3.0.0" + checksum: 10/fae3273b44392b6b1d88071d04ea984784e63dbf8ba3f70b04cb7edda53c7668ee17288ac46af507a9f2aa60c183c5ea1732339141d253dda3eb19f92985c771 + languageName: node + linkType: hard + "graceful-fs@npm:^4.0.0, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -21034,6 +21376,13 @@ __metadata: languageName: node linkType: hard +"has-yarn@npm:^2.1.0": + version: 2.1.0 + resolution: "has-yarn@npm:2.1.0" + checksum: 10/5eb1d0bb8518103d7da24532bdbc7124ffc6d367b5d3c10840b508116f2f1bcbcf10fd3ba843ff6e2e991bdf9969fd862d42b2ed58aade88343326c950b7e7f7 + languageName: node + linkType: hard + "has@npm:^1.0.0, has@npm:^1.0.3": version: 1.0.3 resolution: "has@npm:1.0.3" @@ -21834,6 +22183,13 @@ __metadata: languageName: node linkType: hard +"ini@npm:2.0.0": + version: 2.0.0 + resolution: "ini@npm:2.0.0" + checksum: 10/04e24ba05c4f6947e15560824e153b4610bceea2f5a3ab68651d221a4aab3c77d4e3e90a917ebc8bf5ad71a30a8575de56c39d6b4c4b1375a28016b9f3625f9d + languageName: node + linkType: hard + "ini@npm:^1.3.4, ini@npm:^1.3.5, ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" @@ -21918,6 +22274,13 @@ __metadata: languageName: node linkType: hard +"interpret@npm:^3.1.1": + version: 3.1.1 + resolution: "interpret@npm:3.1.1" + checksum: 10/bc9e11126949c4e6ff49b0b819e923a9adc8e8bf3f9d4f2d782de6d5f592774f6fee4457c10bd08c6a2146b4baee460ccb242c99e5397defa9c846af0d00505a + languageName: node + linkType: hard + "invariant@npm:2.2.4, invariant@npm:^2.2.4": version: 2.2.4 resolution: "invariant@npm:2.2.4" @@ -22127,6 +22490,17 @@ __metadata: languageName: node linkType: hard +"is-ci@npm:^2.0.0": + version: 2.0.0 + resolution: "is-ci@npm:2.0.0" + dependencies: + ci-info: "npm:^2.0.0" + bin: + is-ci: bin.js + checksum: 10/77b869057510f3efa439bbb36e9be429d53b3f51abd4776eeea79ab3b221337fe1753d1e50058a9e2c650d38246108beffb15ccfd443929d77748d8c0cc90144 + languageName: node + linkType: hard + "is-core-module@npm:^2.12.1, is-core-module@npm:^2.13.0, is-core-module@npm:^2.4.0, is-core-module@npm:^2.8.1, is-core-module@npm:^2.9.0": version: 2.13.1 resolution: "is-core-module@npm:2.13.1" @@ -22394,6 +22768,16 @@ __metadata: languageName: node linkType: hard +"is-installed-globally@npm:^0.4.0": + version: 0.4.0 + resolution: "is-installed-globally@npm:0.4.0" + dependencies: + global-dirs: "npm:^3.0.0" + is-path-inside: "npm:^3.0.2" + checksum: 10/5294d21c82cb9beedd693ce1dfb12117c4db36d6e35edc9dc6bf06cb300d23c96520d1bfb063386b054268ae3d7255c3f09393b52218cc26ace99b217bf37c93 + languageName: node + linkType: hard + "is-interactive@npm:^1.0.0": version: 1.0.0 resolution: "is-interactive@npm:1.0.0" @@ -22460,6 +22844,13 @@ __metadata: languageName: node linkType: hard +"is-npm@npm:^5.0.0": + version: 5.0.0 + resolution: "is-npm@npm:5.0.0" + checksum: 10/9baff02b0c69a3d3c79b162cb2f9e67fb40ef6d172c16601b2e2471c21e9a4fa1fc9885a308d7bc6f3a3cd2a324c27fa0bf284c133c3349bb22571ab70d041cc + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.7 resolution: "is-number-object@npm:1.0.7" @@ -22860,6 +23251,13 @@ __metadata: languageName: node linkType: hard +"is-yarn-global@npm:^0.3.0": + version: 0.3.0 + resolution: "is-yarn-global@npm:0.3.0" + checksum: 10/bca013d65fee2862024c9fbb3ba13720ffca2fe750095174c1c80922fdda16402b5c233f5ac9e265bc12ecb5446e7b7f519a32d9541788f01d4d44e24d2bf481 + languageName: node + linkType: hard + "isarray@npm:0.0.1": version: 0.0.1 resolution: "isarray@npm:0.0.1" @@ -23456,6 +23854,31 @@ __metadata: languageName: node linkType: hard +"jest-preview@npm:^0.3.1": + version: 0.3.1 + resolution: "jest-preview@npm:0.3.1" + dependencies: + "@svgr/core": "npm:^6.2.1" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" + chokidar: "npm:^3.5.3" + commander: "npm:^9.2.0" + connect: "npm:^3.7.0" + find-node-modules: "npm:^2.1.3" + open: "npm:^8.4.0" + postcss-import: "npm:^14.1.0" + postcss-load-config: "npm:^4.0.1" + sirv: "npm:^2.0.2" + slash: "npm:^3.0.0" + string-hash: "npm:^1.1.3" + update-notifier: "npm:^5.1.0" + ws: "npm:^8.5.0" + bin: + jest-preview: cli/index.js + checksum: 10/25a68ee58af86081e47a6923356b4ab10760cb2781fb0565774dfa2440bee9993f32d69818600f12d30f69ca2dfd2f354d6758f7882756cb1d100caf5cc9d455 + languageName: node + linkType: hard + "jest-process-manager@npm:^0.3.1": version: 0.3.1 resolution: "jest-process-manager@npm:0.3.1" @@ -23987,6 +24410,13 @@ __metadata: languageName: node linkType: hard +"json-buffer@npm:3.0.0": + version: 3.0.0 + resolution: "json-buffer@npm:3.0.0" + checksum: 10/6e364585600598c42f1cc85d1305569aeb1a6a13e7c67960f17b403f087e2700104ec8e49fc681ab6d6278ee4d132ac033f2625c22a9777ed9b83b403b40f23e + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -24411,6 +24841,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^3.0.0": + version: 3.1.0 + resolution: "keyv@npm:3.1.0" + dependencies: + json-buffer: "npm:3.0.0" + checksum: 10/6de272b3f78975a9a0b12259953c09d5bbe9de9acfd845471ebd758928b523f70563462f0c16a866fe9b447ff5bdebda72c62bc23734eb72cd1fb8f1d7076843 + languageName: node + linkType: hard + "kind-of@npm:^2.0.1": version: 2.0.1 resolution: "kind-of@npm:2.0.1" @@ -24567,6 +25006,15 @@ __metadata: languageName: node linkType: hard +"latest-version@npm:^5.1.0": + version: 5.1.0 + resolution: "latest-version@npm:5.1.0" + dependencies: + package-json: "npm:^6.3.0" + checksum: 10/fbc72b071eb66c40f652441fd783a9cca62f08bf42433651937f078cd9ef94bf728ec7743992777826e4e89305aef24f234b515e6030503a2cbee7fc9bdc2c0f + languageName: node + linkType: hard + "launch-editor@npm:^2.6.1": version: 2.6.1 resolution: "launch-editor@npm:2.6.1" @@ -24862,6 +25310,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.0.0": + version: 3.1.2 + resolution: "lilconfig@npm:3.1.2" + checksum: 10/8058403850cfad76d6041b23db23f730e52b6c17a8c28d87b90766639ca0ee40c748a3e85c2d7bd133d572efabff166c4b015e5d25e01fd666cb4b13cfada7f0 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.1.6 resolution: "lines-and-columns@npm:1.1.6" @@ -25202,7 +25657,7 @@ __metadata: languageName: node linkType: hard -"lowercase-keys@npm:^1.0.0": +"lowercase-keys@npm:^1.0.0, lowercase-keys@npm:^1.0.1": version: 1.0.1 resolution: "lowercase-keys@npm:1.0.1" checksum: 10/12ba64572dc25ae9ee30d37a11f3a91aea046c1b6b905fdf8ac77e2f268f153ed36e60d39cb3bfa47a89f31d981dae9a8cc9915124a56fe51ff01ed6e8bb68fa @@ -25873,6 +26328,13 @@ __metadata: languageName: node linkType: hard +"merge@npm:^2.1.1": + version: 2.1.1 + resolution: "merge@npm:2.1.1" + checksum: 10/1875521a8e429ba8d82c6d24bf3f229b4b64a348873c41a1245851b422c0caa7fbeb958118c24fbfcbb71e416a29924b3b1c4518911529db175f49eb5bcb5e62 + languageName: node + linkType: hard + "mersenne-twister@npm:^1.1.0": version: 1.1.0 resolution: "mersenne-twister@npm:1.1.0" @@ -26187,6 +26649,7 @@ __metadata: jest-canvas-mock: "npm:^2.3.1" jest-environment-jsdom: "patch:jest-environment-jsdom@npm%3A29.7.0#~/.yarn/patches/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b.patch" jest-junit: "npm:^14.0.1" + jest-preview: "npm:^0.3.1" jsdom: "npm:^16.7.0" json-schema-to-ts: "npm:^3.0.1" koa: "npm:^2.7.0" @@ -26202,6 +26665,7 @@ __metadata: loose-envify: "npm:^1.4.0" lottie-web: "npm:^5.12.2" luxon: "npm:^3.2.1" + mini-css-extract-plugin: "npm:^2.9.1" mocha: "npm:^10.2.0" mocha-junit-reporter: "npm:^2.2.1" mockttp: "npm:^3.10.1" @@ -26296,6 +26760,7 @@ __metadata: web3-stream-provider: "npm:^5.0.0" webextension-polyfill: "npm:^0.8.0" webpack: "npm:^5.91.0" + webpack-cli: "npm:^5.1.4" webpack-dev-server: "npm:^5.0.3" ws: "npm:^8.17.1" yaml: "npm:^2.4.1" @@ -26786,7 +27251,7 @@ __metadata: languageName: node linkType: hard -"mimic-response@npm:^1.0.0": +"mimic-response@npm:^1.0.0, mimic-response@npm:^1.0.1": version: 1.0.1 resolution: "mimic-response@npm:1.0.1" checksum: 10/034c78753b0e622bc03c983663b1cdf66d03861050e0c8606563d149bc2b02d63f62ce4d32be4ab50d0553ae0ffe647fc34d1f5281184c6e1e8cf4d85e8d9823 @@ -26816,6 +27281,18 @@ __metadata: languageName: node linkType: hard +"mini-css-extract-plugin@npm:^2.9.1": + version: 2.9.1 + resolution: "mini-css-extract-plugin@npm:2.9.1" + dependencies: + schema-utils: "npm:^4.0.0" + tapable: "npm:^2.2.1" + peerDependencies: + webpack: ^5.0.0 + checksum: 10/a4a0c73a054254784b9d39a3a4f117691600355125242dfc46ced0912b4937050823478bdbf403b5392c21e2fb2203902b41677d67c7d668f77b985b594e94c6 + languageName: node + linkType: hard + "minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1": version: 1.0.1 resolution: "minimalistic-assert@npm:1.0.1" @@ -27199,6 +27676,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.0 + resolution: "mrmime@npm:2.0.0" + checksum: 10/8d95f714ea200c6cf3e3777cbc6168be04b05ac510090a9b41eef5ec081efeb1d1de3e535ffb9c9689fffcc42f59864fd52a500e84a677274f070adeea615c45 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -27724,6 +28208,13 @@ __metadata: languageName: node linkType: hard +"normalize-url@npm:^4.1.0": + version: 4.5.1 + resolution: "normalize-url@npm:4.5.1" + checksum: 10/20ced2845fcfaa46da74efc0aa39b7bed22f3db39e6e8b844261613082a36a2dcd468decad89fa9313b5464bebab4034f96bda7880e8fc468027fecf6a6fa254 + languageName: node + linkType: hard + "normalize-url@npm:^6.0.1": version: 6.1.0 resolution: "normalize-url@npm:6.1.0" @@ -28266,6 +28757,13 @@ __metadata: languageName: node linkType: hard +"p-cancelable@npm:^1.0.0": + version: 1.1.0 + resolution: "p-cancelable@npm:1.1.0" + checksum: 10/2db3814fef6d9025787f30afaee4496a8857a28be3c5706432cbad76c688a6db1874308f48e364a42f5317f5e41e8e7b4f2ff5c8ff2256dbb6264bc361704ece + languageName: node + linkType: hard + "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" @@ -28458,6 +28956,18 @@ __metadata: languageName: node linkType: hard +"package-json@npm:^6.3.0": + version: 6.5.0 + resolution: "package-json@npm:6.5.0" + dependencies: + got: "npm:^9.6.0" + registry-auth-token: "npm:^4.0.0" + registry-url: "npm:^5.0.0" + semver: "npm:^6.2.0" + checksum: 10/adb8e49f352ea0d71a4d351732c3870d57f21e6f3921d69a83dd9ef04b45cdb0a035495826fbe9fb2cb9a7e521484404b7d527c181133867b126588efa1996c6 + languageName: node + linkType: hard + "pako@npm:~0.2.0": version: 0.2.9 resolution: "pako@npm:0.2.9" @@ -29177,6 +29687,19 @@ __metadata: languageName: node linkType: hard +"postcss-import@npm:^14.1.0": + version: 14.1.0 + resolution: "postcss-import@npm:14.1.0" + dependencies: + postcss-value-parser: "npm:^4.0.0" + read-cache: "npm:^1.0.0" + resolve: "npm:^1.1.7" + peerDependencies: + postcss: ^8.0.0 + checksum: 10/434ab43145ad6beeb3cd7405596cb29920061e9d55091196e0264daf0a4e543a8cf1568c233e5a4466786749f904c03a9d51d406685055af2a14a8337d8773d5 + languageName: node + linkType: hard + "postcss-less@npm:^3.1.4": version: 3.1.4 resolution: "postcss-less@npm:3.1.4" @@ -29204,6 +29727,24 @@ __metadata: languageName: node linkType: hard +"postcss-load-config@npm:^4.0.1": + version: 4.0.2 + resolution: "postcss-load-config@npm:4.0.2" + dependencies: + lilconfig: "npm:^3.0.0" + yaml: "npm:^2.3.4" + peerDependencies: + postcss: ">=8.0.9" + ts-node: ">=9.0.0" + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + checksum: 10/e2c2ed9b7998a5b123e1ce0c124daf6504b1454c67dcc1c8fdbcc5ffb2597b7de245e3ac34f63afc928d3fd3260b1e36492ebbdb01a9ff63f16b3c8b7b925d1b + languageName: node + linkType: hard + "postcss-loader@npm:^8.1.1": version: 8.1.1 resolution: "postcss-loader@npm:8.1.1" @@ -29370,7 +29911,7 @@ __metadata: languageName: node linkType: hard -"postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": +"postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" checksum: 10/e4e4486f33b3163a606a6ed94f9c196ab49a37a7a7163abfcd469e5f113210120d70b8dd5e33d64636f41ad52316a3725655421eb9a1094f1bcab1db2f555c62 @@ -29458,6 +29999,13 @@ __metadata: languageName: node linkType: hard +"prepend-http@npm:^2.0.0": + version: 2.0.0 + resolution: "prepend-http@npm:2.0.0" + checksum: 10/7694a9525405447662c1ffd352fcb41b6410c705b739b6f4e3a3e21cf5fdede8377890088e8934436b8b17ba55365a615f153960f30877bf0d0392f9e93503ea + languageName: node + linkType: hard + "preserve@npm:^0.2.0": version: 0.2.0 resolution: "preserve@npm:0.2.0" @@ -29852,6 +30400,15 @@ __metadata: languageName: node linkType: hard +"pupa@npm:^2.1.1": + version: 2.1.1 + resolution: "pupa@npm:2.1.1" + dependencies: + escape-goat: "npm:^2.0.0" + checksum: 10/49529e50372ffdb0cccf0efa0f3b3cb0a2c77805d0d9cc2725bd2a0f6bb414631e61c93a38561b26be1259550b7bb6c2cb92315aa09c8bf93f3bdcb49f2b2fb7 + languageName: node + linkType: hard + "puppeteer-core@npm:^2.1.1": version: 2.1.1 resolution: "puppeteer-core@npm:2.1.1" @@ -30111,7 +30668,7 @@ __metadata: languageName: node linkType: hard -"rc@npm:^1.0.1, rc@npm:^1.1.6, rc@npm:^1.2.8": +"rc@npm:1.2.8, rc@npm:^1.0.1, rc@npm:^1.1.6, rc@npm:^1.2.8": version: 1.2.8 resolution: "rc@npm:1.2.8" dependencies: @@ -30743,6 +31300,15 @@ __metadata: languageName: node linkType: hard +"read-cache@npm:^1.0.0": + version: 1.0.0 + resolution: "read-cache@npm:1.0.0" + dependencies: + pify: "npm:^2.3.0" + checksum: 10/83a39149d9dfa38f0c482ea0d77b34773c92fef07fe7599cdd914d255b14d0453e0229ef6379d8d27d6947f42d7581635296d0cfa7708f05a9bd8e789d398b31 + languageName: node + linkType: hard + "read-cmd-shim@npm:^4.0.0": version: 4.0.0 resolution: "read-cmd-shim@npm:4.0.0" @@ -30951,6 +31517,15 @@ __metadata: languageName: node linkType: hard +"rechoir@npm:^0.8.0": + version: 0.8.0 + resolution: "rechoir@npm:0.8.0" + dependencies: + resolve: "npm:^1.20.0" + checksum: 10/ad3caed8afdefbc33fbc30e6d22b86c35b3d51c2005546f4e79bcc03c074df804b3640ad18945e6bef9ed12caedc035655ec1082f64a5e94c849ff939dc0a788 + languageName: node + linkType: hard + "redent@npm:^3.0.0": version: 3.0.0 resolution: "redent@npm:3.0.0" @@ -31146,6 +31721,15 @@ __metadata: languageName: node linkType: hard +"registry-auth-token@npm:^4.0.0": + version: 4.2.2 + resolution: "registry-auth-token@npm:4.2.2" + dependencies: + rc: "npm:1.2.8" + checksum: 10/00d1b1c69f09df52a0bfbaecee71f2ba094d8fd8d1abc325090655b2c6c8a69c969b31525086c10f95126c3452cd4a0c5c9a6832fb08bec5a32a4e224b790cf8 + languageName: node + linkType: hard + "registry-url@npm:^3.0.3": version: 3.1.0 resolution: "registry-url@npm:3.1.0" @@ -31155,6 +31739,15 @@ __metadata: languageName: node linkType: hard +"registry-url@npm:^5.0.0": + version: 5.1.0 + resolution: "registry-url@npm:5.1.0" + dependencies: + rc: "npm:^1.2.8" + checksum: 10/bcea86c84a0dbb66467b53187fadebfea79017cddfb4a45cf27530d7275e49082fe9f44301976eb0164c438e395684bcf3dae4819b36ff9d1640d8cc60c73df9 + languageName: node + linkType: hard + "regjsgen@npm:^0.8.0": version: 0.8.0 resolution: "regjsgen@npm:0.8.0" @@ -31698,6 +32291,15 @@ __metadata: languageName: node linkType: hard +"responselike@npm:^1.0.2": + version: 1.0.2 + resolution: "responselike@npm:1.0.2" + dependencies: + lowercase-keys: "npm:^1.0.0" + checksum: 10/2e9e70f1dcca3da621a80ce71f2f9a9cad12c047145c6ece20df22f0743f051cf7c73505e109814915f23f9e34fb0d358e22827723ee3d56b623533cab8eafcd + languageName: node + linkType: hard + "responselike@npm:^2.0.0": version: 2.0.1 resolution: "responselike@npm:2.0.1" @@ -32507,6 +33109,15 @@ __metadata: languageName: node linkType: hard +"semver-diff@npm:^3.1.1": + version: 3.1.1 + resolution: "semver-diff@npm:3.1.1" + dependencies: + semver: "npm:^6.3.0" + checksum: 10/8bbe5a5d7add2d5e51b72314a9215cd294d71f41cdc2bf6bd59ee76411f3610b576172896f1d191d0d7294cb9f2f847438d2ee158adacc0c224dca79052812fe + languageName: node + linkType: hard + "semver-greatest-satisfied-range@npm:^1.1.0": version: 1.1.0 resolution: "semver-greatest-satisfied-range@npm:1.1.0" @@ -32543,7 +33154,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": +"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": version: 7.6.3 resolution: "semver@npm:7.6.3" bin: @@ -32898,6 +33509,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^2.0.2": + version: 2.0.4 + resolution: "sirv@npm:2.0.4" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10/24f42cf06895017e589c9d16fc3f1c6c07fe8b0dbafce8a8b46322cfba67b7f2498610183954cb0e9d089c8cb60002a7ee7e8bca6a91a0d7042bfbc3473c95c3 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -33521,7 +34143,7 @@ __metadata: languageName: node linkType: hard -"string-hash@npm:^1.1.1": +"string-hash@npm:^1.1.1, string-hash@npm:^1.1.3": version: 1.1.3 resolution: "string-hash@npm:1.1.3" checksum: 10/104b8667a5e0dc71bfcd29fee09cb88c6102e27bfb07c55f95535d90587d016731d52299380052e514266f4028a7a5172e0d9ac58e2f8f5001be61dc77c0754d @@ -33555,7 +34177,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -34160,6 +34782,13 @@ __metadata: languageName: node linkType: hard +"svg-parser@npm:^2.0.4": + version: 2.0.4 + resolution: "svg-parser@npm:2.0.4" + checksum: 10/ec196da6ea21481868ab26911970e35488361c39ead1c6cdd977ba16c885c21a91ddcbfd113bfb01f79a822e2a751ef85b2f7f95e2cb9245558ebce12c34af1f + languageName: node + linkType: hard + "svg-tags@npm:^1.0.0": version: 1.0.0 resolution: "svg-tags@npm:1.0.0" @@ -34586,6 +35215,13 @@ __metadata: languageName: node linkType: hard +"to-readable-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "to-readable-stream@npm:1.0.0" + checksum: 10/a99e23d49777d9d03686f03cc0bbbcb4648d991648990a98bc93b55cf91a2ae830c41b5efa36802f1c00a34bba93bd33b10346772fd3f49bcf1667a99c85f354 + languageName: node + linkType: hard + "to-regex-range@npm:^2.1.0": version: 2.1.1 resolution: "to-regex-range@npm:2.1.1" @@ -34661,6 +35297,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10/5132d562cf88ff93fd710770a92f31dbe67cc19b5c6ccae2efc0da327f0954d211bbfd9456389655d726c624f284b4a23112f56d1da931ca7cfabbe1f45e778a + languageName: node + linkType: hard + "tough-cookie@npm:^4.0.0, tough-cookie@npm:^4.1.2": version: 4.1.3 resolution: "tough-cookie@npm:4.1.3" @@ -35703,6 +36346,28 @@ __metadata: languageName: node linkType: hard +"update-notifier@npm:^5.1.0": + version: 5.1.0 + resolution: "update-notifier@npm:5.1.0" + dependencies: + boxen: "npm:^5.0.0" + chalk: "npm:^4.1.0" + configstore: "npm:^5.0.1" + has-yarn: "npm:^2.1.0" + import-lazy: "npm:^2.1.0" + is-ci: "npm:^2.0.0" + is-installed-globally: "npm:^0.4.0" + is-npm: "npm:^5.0.0" + is-yarn-global: "npm:^0.3.0" + latest-version: "npm:^5.1.0" + pupa: "npm:^2.1.1" + semver: "npm:^7.3.4" + semver-diff: "npm:^3.1.1" + xdg-basedir: "npm:^4.0.0" + checksum: 10/9df39e2d4f2e59ea788c719baaacf3d2bdde09d065f00319d52c0af255990e15f98ba40c115fb6246b6b2d5468685f36955ae0679c0b7fec834892fe7db4cab2 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2, uri-js@npm:^4.4.1": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -35728,6 +36393,15 @@ __metadata: languageName: node linkType: hard +"url-parse-lax@npm:^3.0.0": + version: 3.0.0 + resolution: "url-parse-lax@npm:3.0.0" + dependencies: + prepend-http: "npm:^2.0.0" + checksum: 10/1040e357750451173132228036aff1fd04abbd43eac1fb3e4fca7495a078bcb8d33cb765fe71ad7e473d9c94d98fd67adca63bd2716c815a2da066198dd37217 + languageName: node + linkType: hard + "url-parse@npm:^1.5.3": version: 1.5.10 resolution: "url-parse@npm:1.5.10" @@ -36551,6 +37225,38 @@ __metadata: languageName: node linkType: hard +"webpack-cli@npm:^5.1.4": + version: 5.1.4 + resolution: "webpack-cli@npm:5.1.4" + dependencies: + "@discoveryjs/json-ext": "npm:^0.5.0" + "@webpack-cli/configtest": "npm:^2.1.1" + "@webpack-cli/info": "npm:^2.0.2" + "@webpack-cli/serve": "npm:^2.0.5" + colorette: "npm:^2.0.14" + commander: "npm:^10.0.1" + cross-spawn: "npm:^7.0.3" + envinfo: "npm:^7.7.3" + fastest-levenshtein: "npm:^1.0.12" + import-local: "npm:^3.0.2" + interpret: "npm:^3.1.1" + rechoir: "npm:^0.8.0" + webpack-merge: "npm:^5.7.3" + peerDependencies: + webpack: 5.x.x + peerDependenciesMeta: + "@webpack-cli/generators": + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + bin: + webpack-cli: bin/cli.js + checksum: 10/9ac3ae7c43b032051de2803d751bd3b44e1f226b931dcd56066a8e01b12734d49730903df9235e1eb1b67b2ee7451faf24a219c8f4a229c4f42c42e827eac44c + languageName: node + linkType: hard + "webpack-dev-middleware@npm:^6.1.1": version: 6.1.1 resolution: "webpack-dev-middleware@npm:6.1.1" @@ -36645,6 +37351,17 @@ __metadata: languageName: node linkType: hard +"webpack-merge@npm:^5.7.3": + version: 5.10.0 + resolution: "webpack-merge@npm:5.10.0" + dependencies: + clone-deep: "npm:^4.0.1" + flat: "npm:^5.0.2" + wildcard: "npm:^2.0.0" + checksum: 10/fa46ab200f17d06c7cb49fc37ad91f15769753953c9724adac1061fa305a2a223cb37c3ed25a5f501580c91f11a0800990fe3814c70a77bf1aa5b3fca45a2ac6 + languageName: node + linkType: hard + "webpack-sources@npm:^3.2.3": version: 3.2.3 resolution: "webpack-sources@npm:3.2.3" @@ -36878,6 +37595,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^3.1.0": + version: 3.1.0 + resolution: "widest-line@npm:3.1.0" + dependencies: + string-width: "npm:^4.0.0" + checksum: 10/03db6c9d0af9329c37d74378ff1d91972b12553c7d72a6f4e8525fe61563fa7adb0b9d6e8d546b7e059688712ea874edd5ded475999abdeedf708de9849310e0 + languageName: node + linkType: hard + "wif@npm:^4.0.0": version: 4.0.0 resolution: "wif@npm:4.0.0" @@ -36887,6 +37613,13 @@ __metadata: languageName: node linkType: hard +"wildcard@npm:^2.0.0": + version: 2.0.1 + resolution: "wildcard@npm:2.0.1" + checksum: 10/e0c60a12a219e4b12065d1199802d81c27b841ed6ad6d9d28240980c73ceec6f856771d575af367cbec2982d9ae7838759168b551776577f155044f5a5ba843c + languageName: node + linkType: hard + "wordwrap@npm:^1.0.0": version: 1.0.0 resolution: "wordwrap@npm:1.0.0" @@ -37077,6 +37810,13 @@ __metadata: languageName: node linkType: hard +"xdg-basedir@npm:^4.0.0": + version: 4.0.0 + resolution: "xdg-basedir@npm:4.0.0" + checksum: 10/0073d5b59a37224ed3a5ac0dd2ec1d36f09c49f0afd769008a6e9cd3cd666bd6317bd1c7ce2eab47e1de285a286bad11a9b038196413cd753b79770361855f3c + languageName: node + linkType: hard + "xhr2@npm:0.2.1": version: 0.2.1 resolution: "xhr2@npm:0.2.1" @@ -37175,12 +37915,12 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.4.1": - version: 2.4.1 - resolution: "yaml@npm:2.4.1" +"yaml@npm:^2.3.4, yaml@npm:^2.4.1": + version: 2.6.0 + resolution: "yaml@npm:2.6.0" bin: yaml: bin.mjs - checksum: 10/2c54fd69ef59126758ae710f9756405a7d41abcbb61aca894250d0e81e76057c14dc9bb00a9528f72f99b8f24077f694a6f7fd09cdd6711fcec2eebfbb5df409 + checksum: 10/f4369f667c7626c216ea81b5840fe9b530cdae4cff2d84d166ec1239e54bf332dbfac4a71bf60d121f8e85e175364a4e280a520292269b6cf9d074368309adf9 languageName: node linkType: hard From 630352a10bea546c019b5c2b9383a36c7aa4f8c1 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 31 Oct 2024 15:21:36 -0230 Subject: [PATCH 37/56] fix: Fix left-aligned fullscreen UI (#28218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Home screen was recently updated to make the overview left-aligned. However the fullscreen UI was not considered, and it ended up looking ugly/broken. The fullscreen UI has been updated to be centered, as it was before. The Home screen remains left-aligned in the popup. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28218?quickstart=1) ## **Related issues** Fixes #27593 ## **Manual testing steps** Compare how the Home screen overview looks on the fullscreen UI and the popup. It should be centered on the fullscreen UI, left-aligned on the popup. ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-10-31 at 11 32 12](https://github.com/user-attachments/assets/989ebd4e-90a5-42ae-a522-f7e4d77f0685) ### **After** ![Screenshot 2024-10-31 at 11 28 35](https://github.com/user-attachments/assets/6802bfab-b462-4168-8536-cabb49aceb53) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ui/components/app/wallet-overview/index.scss | 8 ++++++++ .../app/wallet-overview/wallet-overview.js | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss index a790e8b7ba2e..47dc40200e69 100644 --- a/ui/components/app/wallet-overview/index.scss +++ b/ui/components/app/wallet-overview/index.scss @@ -9,6 +9,10 @@ flex-direction: column; width: 100%; + &-fullscreen { + align-items: center; + } + &__balance { flex: 1; display: flex; @@ -16,6 +20,10 @@ flex-direction: column; align-items: start; width: 100%; + + .wallet-overview-fullscreen > & { + align-items: center; + } } &__icon_button { diff --git a/ui/components/app/wallet-overview/wallet-overview.js b/ui/components/app/wallet-overview/wallet-overview.js index 213a7b2f2317..04127276acaf 100644 --- a/ui/components/app/wallet-overview/wallet-overview.js +++ b/ui/components/app/wallet-overview/wallet-overview.js @@ -2,9 +2,23 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; +// TODO: Move this function to shared +// eslint-disable-next-line import/no-restricted-paths +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; + const WalletOverview = ({ balance, buttons, className }) => { return ( -
+
{balance}
{buttons}
From 8fa97988b5c2f92e589357b234d9fda6b87dc31c Mon Sep 17 00:00:00 2001 From: Marina Boboc <120041701+benjisclowder@users.noreply.github.com> Date: Thu, 31 Oct 2024 20:30:37 +0200 Subject: [PATCH 38/56] V12.6.0 Changelog (#28166) ## **Description** Adding 12.6.0 changelog entries. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28166?quickstart=1) --------- Co-authored-by: Dan J Miller --- CHANGELOG.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db903de48796..59734a8a23f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [12.6.0] +### Added +- Added the APE network icon ([#27841](https://github.com/MetaMask/metamask-extension/pull/27841)) +- Added token sorting and improved token importing on the Asset List page ([#27184](https://github.com/MetaMask/metamask-extension/pull/27184)) +- Added an aggregated balance feature and updated settings to toggle between fiat and native token balances ([#27097](https://github.com/MetaMask/metamask-extension/pull/27097)) +- Added a network picker to the AssetPicker for easier cross-chain swaps ([#26559](https://github.com/MetaMask/metamask-extension/pull/26559)) +- Added new header and conditional simulations for dapp-initiated token transfer confirmations ([#27875](https://github.com/MetaMask/metamask-extension/pull/27875)) +- Added simulation section to NFT permit confirmations ([#27825](https://github.com/MetaMask/metamask-extension/pull/27825)) +- Added transaction flow and details sections for wallet-initiated ERC20 token transfer confirmations ([#27654](https://github.com/MetaMask/metamask-extension/pull/27654)) +- Added support for typed sign requests for NFT permits ([#27796](https://github.com/MetaMask/metamask-extension/pull/27796)) +- Added support for gas fee flows in standard swaps on EIP-1559 networks ([#27612](https://github.com/MetaMask/metamask-extension/pull/27612)) +- Added a Token Send Heading component ([#27562](https://github.com/MetaMask/metamask-extension/pull/27562)) +- Added support for Etherscan API keys and improved transaction history logging ([#27611](https://github.com/MetaMask/metamask-extension/pull/27611)) +- Added a custom header for wallet-initiated ERC20 token transfer confirmations ([#27391](https://github.com/MetaMask/metamask-extension/pull/27391)) +- Added redesigned screens for setApprovalForAll and revoke setApprovalForAll for users who opt into experimental transaction screens ([#27401](https://github.com/MetaMask/metamask-extension/pull/27401)) +- Added new screens for approve, increaseAllowance, and revoke approval for users who enable experimental transaction screens ([#26985](https://github.com/MetaMask/metamask-extension/pull/26985)) +- Added support for revoking ERC20 allowances ([#26906](https://github.com/MetaMask/metamask-extension/pull/26906)) +- Added a "Delete MetaMetrics Data" button to the Security & Privacy tab, allowing users to delete their MetaMetrics data ([#24571](https://github.com/MetaMask/metamask-extension/pull/24571)) +- Added a new Default Settings view and updated Congratulations views in the onboarding process ([#24562](https://github.com/MetaMask/metamask-extension/pull/24562)) +- Added a delay for Linea swap approvals to increase success rate and updated token symbol retrieval on the awaiting swap page ([#27810](https://github.com/MetaMask/metamask-extension/pull/27810)) +- Enabled smart transactions by default for new users and updated selectors to handle user preferences and metrics separately ([#27885](https://github.com/MetaMask/metamask-extension/pull/27885)) +- Added animations and cosmetic changes to the smart transaction status page ([#27650](https://github.com/MetaMask/metamask-extension/pull/27650)) +- Enabled gas-included swaps for users with insufficient ETH when smart transactions are enabled ([#27427](https://github.com/MetaMask/metamask-extension/pull/27427)) +- Added padding to center-align text on the permissions page when no site or snap is connected ([#27660](https://github.com/MetaMask/metamask-extension/pull/27660)) +- Released Chain Permissions by removing feature flags ([#27561](https://github.com/MetaMask/metamask-extension/pull/27561)) +- Added support for power users survey with toast notifications ([#27361](https://github.com/MetaMask/metamask-extension/pull/27361)) +- Added editing flow for switching networks via dapp ([#26635](https://github.com/MetaMask/metamask-extension/pull/26635)) +- [FLASK] Added the ability to send Bitcoin from Bitcoin accounts ([#27964](https://github.com/MetaMask/metamask-extension/pull/27964)) + +### Changed +- Bumped snap-keyring to version 4.4.0 to sanitize redirect URLs passed by a Snap ([#27864](https://github.com/MetaMask/metamask-extension/pull/27864)) +- Updated the insufficient funds alert to replace "transaction fees" with "network fees." ([#27762](https://github.com/MetaMask/metamask-extension/pull/27762)) +- Updated the SIWE signature page to display the parsed URI instead of the domain ([#27754](https://github.com/MetaMask/metamask-extension/pull/27754)) +- Limited the number of decimals on the spending cap modal to match the token's supported decimals ([#27672](https://github.com/MetaMask/metamask-extension/pull/27672)) +- Updated petnames component to prefer displaying token symbols over token names for brevity ([#27693](https://github.com/MetaMask/metamask-extension/pull/27693)) +- Updated banner alert to render multiple general alerts and fixed related UI issues ([#27339](https://github.com/MetaMask/metamask-extension/pull/27339)) +- Updated Trezor Connect to v9.4.0 and removed outdated workarounds ([#27112](https://github.com/MetaMask/metamask-extension/pull/27112)) +- Restored the ability to switch between pending confirmations when routed to a specific confirmation ([#27753](https://github.com/MetaMask/metamask-extension/pull/27753)) +- Updated edit modals with design improvements and a fixed update button ([#27623](https://github.com/MetaMask/metamask-extension/pull/27623)) +- Updated copy for the onboarding message and settings screens ([#27821](https://github.com/MetaMask/metamask-extension/pull/27821)) +- Updated copy and spacing in the Permissions Screen ([#27658](https://github.com/MetaMask/metamask-extension/pull/27658)) +- Removed phishing detection from the onboarding Security group ([#27819](https://github.com/MetaMask/metamask-extension/pull/27819)) +- Removed the "Alerts" section from Settings, keeping alert features enabled by default ([#27709](https://github.com/MetaMask/metamask-extension/pull/27709)) +- Updated the toast component and its copy ([#27656](https://github.com/MetaMask/metamask-extension/pull/27656)) +- Changed survey timeout from one week to one day ([#27603](https://github.com/MetaMask/metamask-extension/pull/27603)) +- Updated UI for the connect and review permissions pages ([#27478](https://github.com/MetaMask/metamask-extension/pull/27478)) + +### Fixed +- Fixed an error when starting a "Send ETH" flow from a dapp with a Bitcoin account selected ([#27566](https://github.com/MetaMask/metamask-extension/pull/27566)) +- Fixed currency display to show token balance when fiat conversion rate is unavailable ([#27893](https://github.com/MetaMask/metamask-extension/pull/27893)) +- Fixed the issue where the add token modal couldn't be dismissed in MMI ([#27855](https://github.com/MetaMask/metamask-extension/pull/27855)) +- Fixed an issue that caused the app to crash when switching networks ([#27604](https://github.com/MetaMask/metamask-extension/pull/27604)) +- Fixed navigation error between transactions when one transaction is of type "Approve All." ([#27985](https://github.com/MetaMask/metamask-extension/pull/27985)) +- Fixed nonce value updating issue when multiple transactions are created in parallel ([#27874](https://github.com/MetaMask/metamask-extension/pull/27874)) +- Fixed issue with nonce not resetting when switching networks ([#27789](https://github.com/MetaMask/metamask-extension/pull/27789)) +- Fixed design issues and spacing in the redesigned transactions, and corrected loader behavior for confirmations ([#27605](https://github.com/MetaMask/metamask-extension/pull/27605)) +- Fixed bugs related to max approval values and array value spending caps ([#27573](https://github.com/MetaMask/metamask-extension/pull/27573)) +- Reverted the color change for the "Speed" key by removing the variant causing the issue ([#27416](https://github.com/MetaMask/metamask-extension/pull/27416)) +- Improved token decimal handling by using verified contract details when available and added support for tokens with null decimals ([#27328](https://github.com/MetaMask/metamask-extension/pull/27328)) +- Improved the alert system and refined alerts for SIWE and contract interactions ([#27205](https://github.com/MetaMask/metamask-extension/pull/27205)) +- Fixed an issue where entering a backslash in the settings search would cause a crash ([#27432](https://github.com/MetaMask/metamask-extension/pull/27432)) +- Automatically expand the first insight on the confirmation page ([#27872](https://github.com/MetaMask/metamask-extension/pull/27872)) +- Removed HTML arrows from custom UI inputs of type number in Snaps ([#27953](https://github.com/MetaMask/metamask-extension/pull/27953)) +- Hid the options menu and info icon in the Snaps header for preinstalled Snaps ([#27937](https://github.com/MetaMask/metamask-extension/pull/27937)) +- Fixed sticky footer UI issue on Snaps Home Page in extended view ([#27799](https://github.com/MetaMask/metamask-extension/pull/27799)) +- Fixed issue with Snap name truncation in the Snap Authorship Header ([#27752](https://github.com/MetaMask/metamask-extension/pull/27752)) +- Fixed the color of the "more" button in the Copyable component ([#27600](https://github.com/MetaMask/metamask-extension/pull/27600)) +- Fixed alignment issue by applying flex to Snaps buttons only when containing images and icons ([#27564](https://github.com/MetaMask/metamask-extension/pull/27564)) +- Fixed issue with input focus being lost on re-render in Snaps interfaces ([#27429](https://github.com/MetaMask/metamask-extension/pull/27429)) +- Fixed issue where state updates with falsy values were ignored in Snaps interfaces ([#27488](https://github.com/MetaMask/metamask-extension/pull/27488)) +- Fixed text color for secondary buttons in Snaps footer on hover and corrected footer variant when only one action is provided ([#27335](https://github.com/MetaMask/metamask-extension/pull/27335)) +- Fixed an issue where hardware wallet users were taken to the "Processing..." screen before approving transactions during swaps ([#27117](https://github.com/MetaMask/metamask-extension/pull/27117)) ### Uncategorized - ci: reduced Sentry frequency on CircleCI develop ([#27912](https://github.com/MetaMask/metamask-extension/pull/27912)) - chore:Master sync ([#27935](https://github.com/MetaMask/metamask-extension/pull/27935)) @@ -231,7 +302,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Instead of having different networks in the network list for the same chain but different RPC urls, there are now multiple selectable RPC urls per chain - For the UI, networks are now added, edited, and deleted directly in the network list. Networks are no longer edited via the settings page. - Users with multiple RPC endpoints per chain are shown a modal upon upgrade, allowing them to select a different endpoint as the default. - - The UI for wallet_addEthereumChain is changed, to message that users may be adding an additional endpoint to an existing network, rather than adding a new network. + - The UI for wallet_addEthereumChain is changed, to message that users may be adding an additional endpoint to an existing network, rather than adding a new network. - Added display of names and images for ERC721 NFTs to the simulations in transaction confirmations ([#25692](https://github.com/MetaMask/metamask-extension/pull/25692)) - Added a modal to edit the spending cap for ERC20 approve and increase allowance ([#26845](https://github.com/MetaMask/metamask-extension/pull/26845)) - Added a new modal to help users with zero balance buy, receive, or transfer tokens ([#26426](https://github.com/MetaMask/metamask-extension/pull/26426)) From dd03cc203d4c89ce56b80e579b86161666577d90 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 31 Oct 2024 16:11:59 -0230 Subject: [PATCH 39/56] feat: Improve provider method metrics for add/switch chain (#28214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The provider metrics for `wallet_addEthereumChain` and `wallet_switchEthereumChain` are tracked more effectively now. They are no longer rate-limited to 0.1%, instead we track 100% of these calls. They also now include the chain ID being added or switched to. It was deemed safe to disable rate limiting here because historically these methods have required user confirmation, so it's not likely that a dapp would send them repeatedly in a short time frame. Volume should be on a similar scale to other manual user confirmations like signatures. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28214?quickstart=1) ## **Related issues** Fixes https://github.com/MetaMask/MetaMask-planning/issues/3520 ## **Manual testing steps** * Build the wallet using the instructions listed here for debugging with the mock Segment API: https://github.com/MetaMask/metamask-extension/blob/develop/development/README.md#debugging-with-the-mock-segment-api * Connect to the test-dapp * Trigger `wallet_addEthereumChain` and `wallet_switchEthereumChain`, and see that they are correctly captured as metric events. ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/910a1572-74fa-4ecf-8a5d-6f0856757207 Unfortunately the mock Segment server doesn't show the parameters. But it does show that the event was received. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../lib/createRPCMethodTrackingMiddleware.js | 4 +++ .../createRPCMethodTrackingMiddleware.test.js | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.js index a1c5a036f13f..cb57c681649f 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.js @@ -53,6 +53,8 @@ const RATE_LIMIT_MAP = { [MESSAGE_TYPE.ETH_DECRYPT]: RATE_LIMIT_TYPES.NON_RATE_LIMITED, [MESSAGE_TYPE.ETH_GET_ENCRYPTION_PUBLIC_KEY]: RATE_LIMIT_TYPES.NON_RATE_LIMITED, + [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN]: RATE_LIMIT_TYPES.NON_RATE_LIMITED, + [MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN]: RATE_LIMIT_TYPES.NON_RATE_LIMITED, [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS]: RATE_LIMIT_TYPES.TIMEOUT, [MESSAGE_TYPE.WALLET_REQUEST_PERMISSIONS]: RATE_LIMIT_TYPES.TIMEOUT, [MESSAGE_TYPE.SEND_METADATA]: RATE_LIMIT_TYPES.BLOCKED, @@ -126,6 +128,8 @@ const EVENT_NAME_MAP = { */ const TRANSFORM_PARAMS_MAP = { [MESSAGE_TYPE.WATCH_ASSET]: ({ type }) => ({ type }), + [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN]: ([{ chainId }]) => ({ chainId }), + [MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN]: ([{ chainId }]) => ({ chainId }), }; const rateLimitTimeoutsByMethod = {}; diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index 01daaf2974a4..244a995bf5f7 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -883,6 +883,34 @@ describe('createRPCMethodTrackingMiddleware', () => { }, { type: 'ERC20' }, ], + [ + 'only the chain ID', + 'wallet_addEthereumChain', + [ + { + chainId: '0x64', + chainName: 'Gnosis', + rpcUrls: ['https://rpc.gnosischain.com'], + iconUrls: [ + 'https://xdaichain.com/fake/example/url/xdai.svg', + 'https://xdaichain.com/fake/example/url/xdai.png', + ], + nativeCurrency: { + name: 'XDAI', + symbol: 'XDAI', + decimals: 18, + }, + blockExplorerUrls: ['https://blockscout.com/poa/xdai/'], + }, + ], + { chainId: '0x64' }, + ], + [ + 'only the chain ID', + 'wallet_switchEthereumChain', + [{ chainId: '0x123' }], + { chainId: '0x123' }, + ], ])( `should include %s in the '%s' tracked events params property`, async (_, method, params, expected) => { From 3df0a1683659b5f8d1856c667cb74de94fe28fbe Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 31 Oct 2024 16:21:41 -0230 Subject: [PATCH 40/56] v12.6.0 changelog lint fix (#28228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/PR?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- CHANGELOG.md | 212 +-------------------------------------------------- 1 file changed, 1 insertion(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59734a8a23f9..af63a4ed61d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,217 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed issue where state updates with falsy values were ignored in Snaps interfaces ([#27488](https://github.com/MetaMask/metamask-extension/pull/27488)) - Fixed text color for secondary buttons in Snaps footer on hover and corrected footer variant when only one action is provided ([#27335](https://github.com/MetaMask/metamask-extension/pull/27335)) - Fixed an issue where hardware wallet users were taken to the "Processing..." screen before approving transactions during swaps ([#27117](https://github.com/MetaMask/metamask-extension/pull/27117)) -### Uncategorized -- ci: reduced Sentry frequency on CircleCI develop ([#27912](https://github.com/MetaMask/metamask-extension/pull/27912)) -- chore:Master sync ([#27935](https://github.com/MetaMask/metamask-extension/pull/27935)) -- Merge origin/develop into master-sync -- test: Completing missing step for import ERC1155 token origin dapp in existing E2E test ([#27680](https://github.com/MetaMask/metamask-extension/pull/27680)) -- fix: error in navigating between transaction when one of the transaction is approve all ([#27985](https://github.com/MetaMask/metamask-extension/pull/27985)) -- fix: Automatically expand first insight ([#27872](https://github.com/MetaMask/metamask-extension/pull/27872)) -- feat(metametrics): use specific `account_hardware_type` for OneKey devices ([#27296](https://github.com/MetaMask/metamask-extension/pull/27296)) -- feat: add migration 131 ([#27364](https://github.com/MetaMask/metamask-extension/pull/27364)) -- fix(snaps): Remove arrows of custom UI inputs ([#27953](https://github.com/MetaMask/metamask-extension/pull/27953)) -- chore: Disable account syncing in prod ([#27943](https://github.com/MetaMask/metamask-extension/pull/27943)) -- test: Remove delays from onboarding tests ([#27961](https://github.com/MetaMask/metamask-extension/pull/27961)) -- perf: Create custom trace to measure performance of opening the account list ([#27907](https://github.com/MetaMask/metamask-extension/pull/27907)) -- feat: add BTC send flow ([#27964](https://github.com/MetaMask/metamask-extension/pull/27964)) -- fix: flaky test `Confirmation Redesign ERC721 Approve Component Submit an Approve transaction @no-mmi Sends a type 2 transaction (EIP1559)` ([#27928](https://github.com/MetaMask/metamask-extension/pull/27928)) -- fix: lint-lockfile flaky job by changing resources from medium to medium-plus ([#27950](https://github.com/MetaMask/metamask-extension/pull/27950)) -- feat: add “Incomplete Asset Displayed” metric & fix: should only set default decimals if ERC20 ([#27494](https://github.com/MetaMask/metamask-extension/pull/27494)) -- feat: Convert AppStateController to typescript ([#27572](https://github.com/MetaMask/metamask-extension/pull/27572)) -- chore(deps): upgrade from json-rpc-engine to @metamask/json-rpc-engine ([#22875](https://github.com/MetaMask/metamask-extension/pull/22875)) -- feat: dapp initiated token transfer ([#27875](https://github.com/MetaMask/metamask-extension/pull/27875)) -- chore: bump signature controller to remove message managers ([#27787](https://github.com/MetaMask/metamask-extension/pull/27787)) -- chore: add testing-library/dom dependency ([#27493](https://github.com/MetaMask/metamask-extension/pull/27493)) -- test: [POM] Migrate contract interaction with snap account e2e tests to page object modal ([#27924](https://github.com/MetaMask/metamask-extension/pull/27924)) -- fix: bump message signing snap to support portfolio automatic connections ([#27936](https://github.com/MetaMask/metamask-extension/pull/27936)) -- fix: hide options menu that was being shown for preinstalled Snaps ([#27937](https://github.com/MetaMask/metamask-extension/pull/27937)) -- fix: bump `@metamask/ppom-validator` from `0.34.0` to `0.35.1` ([#27939](https://github.com/MetaMask/metamask-extension/pull/27939)) -- fix: add APE network icon ([#27841](https://github.com/MetaMask/metamask-extension/pull/27841)) -- feat: NFT permit simulations ([#27825](https://github.com/MetaMask/metamask-extension/pull/27825)) -- fix: fix currency display when tokenToFiatConversion rate is not avai… ([#27893](https://github.com/MetaMask/metamask-extension/pull/27893)) -- feat: convert AlertController to typescript ([#27764](https://github.com/MetaMask/metamask-extension/pull/27764)) -- feat(TXL-435): turn smart transactions on by default for new users ([#27885](https://github.com/MetaMask/metamask-extension/pull/27885)) -- feat: Add transaction flow and details sections ([#27654](https://github.com/MetaMask/metamask-extension/pull/27654)) -- fix: flaky test `Vault Decryptor Page is able to decrypt the vault pasting the text in the vault-decryptor webapp` ([#27921](https://github.com/MetaMask/metamask-extension/pull/27921)) -- chore: bump `@metamask/eth-snap-keyring` to version 4.4.0 ([#27864](https://github.com/MetaMask/metamask-extension/pull/27864)) -- fix: flaky tests `Add existing token using search renders the balance for the chosen token` ([#27853](https://github.com/MetaMask/metamask-extension/pull/27853)) -- feat(logging): add extension request logging and retrieval ([#27655](https://github.com/MetaMask/metamask-extension/pull/27655)) -- test: Update test-dapp to verison 8.7.0 ([#27816](https://github.com/MetaMask/metamask-extension/pull/27816)) -- fix: fall back to bundled chainlist ([#23392](https://github.com/MetaMask/metamask-extension/pull/23392)) -- fix: SonarCloud for forks ([#27700](https://github.com/MetaMask/metamask-extension/pull/27700)) -- fix(deps): update from eth-rpc-errors to @metamask/rpc-errors (cause edition) ([#24496](https://github.com/MetaMask/metamask-extension/pull/24496)) -- fix: swapQuotesError as a property in the reported metric ([#27712](https://github.com/MetaMask/metamask-extension/pull/27712)) -- chore: Bump Snaps packages ([#27376](https://github.com/MetaMask/metamask-extension/pull/27376)) -- chore: update @metamask/bitcoin-wallet-snap to 0.7.0 ([#27730](https://github.com/MetaMask/metamask-extension/pull/27730)) -- fix: Onboarding: Code style nits ([#27767](https://github.com/MetaMask/metamask-extension/pull/27767)) -- fix: updated edit modals ([#27623](https://github.com/MetaMask/metamask-extension/pull/27623)) -- feat: use asset pickers with network dropdown in cross-chain swaps page ([#27522](https://github.com/MetaMask/metamask-extension/pull/27522)) -- test: set ENABLE_MV3 automatically ([#27748](https://github.com/MetaMask/metamask-extension/pull/27748)) -- feat: Adding typed sign support for NFT permit ([#27796](https://github.com/MetaMask/metamask-extension/pull/27796)) -- fix: Contract Interaction - cannot read the property `text_signature` ([#27686](https://github.com/MetaMask/metamask-extension/pull/27686)) -- feat: Use requested permissions as default selected values for AmonHenV2 connection flow with case insensitive address comparison ([#27517](https://github.com/MetaMask/metamask-extension/pull/27517)) -- test: [POM] Migrate signature with snap account e2e tests to page object modal ([#27829](https://github.com/MetaMask/metamask-extension/pull/27829)) -- fix: flaky test `ERC1155 NFTs testdapp interaction should batch transfers ERC1155 token` ([#27897](https://github.com/MetaMask/metamask-extension/pull/27897)) -- chore: Master sync following v12.4.1 ([#27793](https://github.com/MetaMask/metamask-extension/pull/27793)) -- fix: flaky test `Permissions sets permissions and connect to Dapp` ([#27888](https://github.com/MetaMask/metamask-extension/pull/27888)) -- fix: flaky test `ERC721 NFTs testdapp interaction should prompt users to add their NFTs to their wallet (all at once)` ([#27889](https://github.com/MetaMask/metamask-extension/pull/27889)) -- fix: flaky test `Wallet Revoke Permissions should revoke eth_accounts permissions via test dapp` ([#27894](https://github.com/MetaMask/metamask-extension/pull/27894)) -- fix: flaky test `Snap Account Signatures and Disconnects can connect to the Test Dapp, then #signTypedDataV3, disconnect then connect, then #signTypedDataV4 (async flow approve)` ([#27887](https://github.com/MetaMask/metamask-extension/pull/27887)) -- test(mock-e2e): add private domains logic for the privacy report ([#27844](https://github.com/MetaMask/metamask-extension/pull/27844)) -- fix: SENTRY_DSN_FAKE problem ([#27881](https://github.com/MetaMask/metamask-extension/pull/27881)) -- chore: remove unused swaps code ([#27679](https://github.com/MetaMask/metamask-extension/pull/27679)) -- test(TXL-308): initial e2e for stx using swaps ([#27215](https://github.com/MetaMask/metamask-extension/pull/27215)) -- feat: upgrade assets-controllers to v38.3.0 ([#27755](https://github.com/MetaMask/metamask-extension/pull/27755)) -- fix: nonce value when there are multiple transactions in parallel ([#27874](https://github.com/MetaMask/metamask-extension/pull/27874)) -- fix: phishing test to not check c2 domains ([#27846](https://github.com/MetaMask/metamask-extension/pull/27846)) -- feat: use messenger in AccountTracker to get Preferences state ([#27711](https://github.com/MetaMask/metamask-extension/pull/27711)) -- fix: "Update Network: should update added rpc url for exis..." flaky tests ([#27437](https://github.com/MetaMask/metamask-extension/pull/27437)) -- feat: update copy for 'Default settings' ([#27821](https://github.com/MetaMask/metamask-extension/pull/27821)) -- fix: updated permissions flow copy changes ([#27658](https://github.com/MetaMask/metamask-extension/pull/27658)) -- fix: flaky test `Add account should not affect public address when using secret recovery phrase to recover account with non-zero balance @no-mmi` ([#27834](https://github.com/MetaMask/metamask-extension/pull/27834)) -- fix: hackily wait longer for linea swap approval tx to increase chance of success ([#27810](https://github.com/MetaMask/metamask-extension/pull/27810)) -- fix: flaky test `MultiRpc: should select rpc from settings @no-mmi` ([#27858](https://github.com/MetaMask/metamask-extension/pull/27858)) -- perf: include custom traces in benchmark results ([#27701](https://github.com/MetaMask/metamask-extension/pull/27701)) -- fix: Reset nonce as network is switched ([#27789](https://github.com/MetaMask/metamask-extension/pull/27789)) -- fix: dismiss addToken modal for mmi ([#27855](https://github.com/MetaMask/metamask-extension/pull/27855)) -- fix(multichain): fix eth send flow (from dapp) when a btc account is selected ([#27566](https://github.com/MetaMask/metamask-extension/pull/27566)) -- chore: Add react-beautiful-dnd to deprecated packages list ([#27856](https://github.com/MetaMask/metamask-extension/pull/27856)) -- feat: Create a quality gate for typescript coverage ([#27717](https://github.com/MetaMask/metamask-extension/pull/27717)) -- feat: preferences controller to base controller v2 ([#27398](https://github.com/MetaMask/metamask-extension/pull/27398)) -- revert: use networkClientId to resolve chainId in PPOM Middleware ([#27570](https://github.com/MetaMask/metamask-extension/pull/27570)) -- feat: Added metrics for edit networks and accounts ([#27820](https://github.com/MetaMask/metamask-extension/pull/27820)) -- fix: no connected state for permissions page ([#27660](https://github.com/MetaMask/metamask-extension/pull/27660)) -- feat: remove phishing detection from onboarding Security group ([#27819](https://github.com/MetaMask/metamask-extension/pull/27819)) -- ci: Revert minimum E2E timeout to 20 minutes ([#27827](https://github.com/MetaMask/metamask-extension/pull/27827)) -- fix: disable balance checker for Sepolia in account tracker ([#27763](https://github.com/MetaMask/metamask-extension/pull/27763)) -- ci: Improve validation for `sentry:publish` script ([#26580](https://github.com/MetaMask/metamask-extension/pull/26580)) -- test: Fix Vault Decryptor Page e2e test on develop branch ([#27794](https://github.com/MetaMask/metamask-extension/pull/27794)) -- chore: remove old token details page ([#27774](https://github.com/MetaMask/metamask-extension/pull/27774)) -- chore: remove token list display component ([#27772](https://github.com/MetaMask/metamask-extension/pull/27772)) -- chore: update Trezor Connect to v9.4.0, remove workarounds ([#27112](https://github.com/MetaMask/metamask-extension/pull/27112)) -- test: [POM] Migrate transaction with snap account e2e tests to page object modal ([#27760](https://github.com/MetaMask/metamask-extension/pull/27760)) -- fix(snaps): Restore confirmation switching on routed confirmation ([#27753](https://github.com/MetaMask/metamask-extension/pull/27753)) -- Merge origin/develop into master-sync -- test: Onboarding: Fix vault-decryption-chrome.spec.js ([#27779](https://github.com/MetaMask/metamask-extension/pull/27779)) -- feat: support gas fee flows in standard swaps ([#27612](https://github.com/MetaMask/metamask-extension/pull/27612)) -- feat: Token send heading component ([#27562](https://github.com/MetaMask/metamask-extension/pull/27562)) -- feat: adds the new default settings view to onboarding ([#24562](https://github.com/MetaMask/metamask-extension/pull/24562)) -- chore(3212): remove alert settings ([#27709](https://github.com/MetaMask/metamask-extension/pull/27709)) -- docs: remove outdated Medium link, update "Twitter" to "X" ([#26692](https://github.com/MetaMask/metamask-extension/pull/26692)) -- fix: Replace 'transaction fees' with 'network fees' in the insufficie… ([#27762](https://github.com/MetaMask/metamask-extension/pull/27762)) -- fix: issue with Snap title in Snap Authorship Header ([#27752](https://github.com/MetaMask/metamask-extension/pull/27752)) -- fix: SIWE signature page displays parsed URI instead of domain ([#27754](https://github.com/MetaMask/metamask-extension/pull/27754)) -- fix: updated toasts component and copy ([#27656](https://github.com/MetaMask/metamask-extension/pull/27656)) -- feat: add network picker to AssetPicker ([#26559](https://github.com/MetaMask/metamask-extension/pull/26559)) -- fix(btc): fix jazzicons generations ([#27662](https://github.com/MetaMask/metamask-extension/pull/27662)) -- feat: Release Chain Permissions ([#27561](https://github.com/MetaMask/metamask-extension/pull/27561)) -- feat: upgrade assets-controllers to v38.2.0 ([#27629](https://github.com/MetaMask/metamask-extension/pull/27629)) -- ci: followup to CircleCI Sentry reporting ([#27548](https://github.com/MetaMask/metamask-extension/pull/27548)) -- chore: Master sync ([#27729](https://github.com/MetaMask/metamask-extension/pull/27729)) -- fix(multichain): fix getMultichainCurrentCurrency selector ([#27726](https://github.com/MetaMask/metamask-extension/pull/27726)) -- fix: Limit amount of decimals on spending cap modal ([#27672](https://github.com/MetaMask/metamask-extension/pull/27672)) -- Merge origin/develop into master-sync -- test: [POM] Migrate create snap account e2e tests to page object modal ([#27697](https://github.com/MetaMask/metamask-extension/pull/27697)) -- fix: Prefer token symbol to token name ([#27693](https://github.com/MetaMask/metamask-extension/pull/27693)) -- fix(btc): fetch btc balance right after account creation ([#27628](https://github.com/MetaMask/metamask-extension/pull/27628)) -- fix: UI startup with no Sentry DSN ([#27714](https://github.com/MetaMask/metamask-extension/pull/27714)) -- feat: Sort/Import Tokens in Extension ([#27184](https://github.com/MetaMask/metamask-extension/pull/27184)) -- ci: make git-diff-develop work for PRs from foreign repos ([#27268](https://github.com/MetaMask/metamask-extension/pull/27268)) -- test: Convert json-rpc e2e tests to TypeScript ([#27659](https://github.com/MetaMask/metamask-extension/pull/27659)) -- fix: allow getAddTransactionRequest to pass through other params ([#27117](https://github.com/MetaMask/metamask-extension/pull/27117)) -- perf: add tags to UI startup trace ([#27550](https://github.com/MetaMask/metamask-extension/pull/27550)) -- fix: Disable redirecting Extension users using beta & flask build and dev env to the existing offboarding page ([#27226](https://github.com/MetaMask/metamask-extension/pull/27226)) -- feat(NOTIFY-1193): add profile sync dev menu ([#27666](https://github.com/MetaMask/metamask-extension/pull/27666)) -- refactor: Typescript conversion of log-web3-shim-usage.js ([#23732](https://github.com/MetaMask/metamask-extension/pull/23732)) -- test: removing race condition for asserting inner values (PR-#2) ([#27664](https://github.com/MetaMask/metamask-extension/pull/27664)) -- fix(btc): fix address validation ([#27690](https://github.com/MetaMask/metamask-extension/pull/27690)) -- chore: Update coverage.json ([#27696](https://github.com/MetaMask/metamask-extension/pull/27696)) -- fix: test coverage quality gate ([#27691](https://github.com/MetaMask/metamask-extension/pull/27691)) -- fix: banner alert to render multiple general alerts ([#27339](https://github.com/MetaMask/metamask-extension/pull/27339)) -- refactor: routes constants ([#27078](https://github.com/MetaMask/metamask-extension/pull/27078)) -- fix: Test coverage quality gate ([#27581](https://github.com/MetaMask/metamask-extension/pull/27581)) -- feat: Adding delete metametrics data to security and privacy tab ([#24571](https://github.com/MetaMask/metamask-extension/pull/24571)) -- feat(stx): animations and cosmetic changes to smart transaction status page ([#27650](https://github.com/MetaMask/metamask-extension/pull/27650)) -- build: add lottie-web dependency to extension ([#27632](https://github.com/MetaMask/metamask-extension/pull/27632)) -- fix(btc): do not show percentage for tokens ([#27637](https://github.com/MetaMask/metamask-extension/pull/27637)) -- feat: support Etherscan API keys ([#27611](https://github.com/MetaMask/metamask-extension/pull/27611)) -- feat: change survey timeout time from a week to a day ([#27603](https://github.com/MetaMask/metamask-extension/pull/27603)) -- fix: Design papercuts for redesigned transactions ([#27605](https://github.com/MetaMask/metamask-extension/pull/27605)) -- test: removing race condition for asserting inner values (PR-#1) ([#27606](https://github.com/MetaMask/metamask-extension/pull/27606)) -- test: [POM] Migrate Snap Simple Keyring page and Snap List page to page object modal ([#27327](https://github.com/MetaMask/metamask-extension/pull/27327)) -- fix: fix sentry reading undefined ([#27584](https://github.com/MetaMask/metamask-extension/pull/27584)) -- fix: fix sentry reading null ([#27582](https://github.com/MetaMask/metamask-extension/pull/27582)) -- fix(btc): disable balanceIsCached flag ([#27636](https://github.com/MetaMask/metamask-extension/pull/27636)) -- chore: update accounts related packages ([#27284](https://github.com/MetaMask/metamask-extension/pull/27284)) -- chore: set bridge src network, tokens and top assets ([#26214](https://github.com/MetaMask/metamask-extension/pull/26214)) -- test: [Snaps E2E] add delay to installed snaps test to reduce flaking ([#27521](https://github.com/MetaMask/metamask-extension/pull/27521)) -- chore: set bridge dest network, tokens and top assets ([#26213](https://github.com/MetaMask/metamask-extension/pull/26213)) -- fix: fix reading address from market data ([#27604](https://github.com/MetaMask/metamask-extension/pull/27604)) -- feat: Migrate AccountTrackerController to BaseController v2 ([#27258](https://github.com/MetaMask/metamask-extension/pull/27258)) -- fix: disable transaction data decode if deployment ([#27586](https://github.com/MetaMask/metamask-extension/pull/27586)) -- fix: revert jest collect coverage patterns ([#27583](https://github.com/MetaMask/metamask-extension/pull/27583)) -- fix: add amount row for contract deployment ([#27594](https://github.com/MetaMask/metamask-extension/pull/27594)) -- fix: "Dapp viewed Event @no-mmi is sent when refreshing da..." flaky test ([#27381](https://github.com/MetaMask/metamask-extension/pull/27381)) -- chore: fix deps audit ([#27620](https://github.com/MetaMask/metamask-extension/pull/27620)) -- fix: Max approval and array value spending cap bugs ([#27573](https://github.com/MetaMask/metamask-extension/pull/27573)) -- feat: add power users survey support ([#27361](https://github.com/MetaMask/metamask-extension/pull/27361)) -- fix: Recreate offscreen document if it already exists ([#27596](https://github.com/MetaMask/metamask-extension/pull/27596)) -- fix: flaky test `Block Explorer links to the token tracker in the explorer` ([#27599](https://github.com/MetaMask/metamask-extension/pull/27599)) -- fix(snaps): `Copyable` more button color ([#27600](https://github.com/MetaMask/metamask-extension/pull/27600)) -- fix: flaky test `Import flow allows importing multiple tokens from search` ([#27567](https://github.com/MetaMask/metamask-extension/pull/27567)) -- fix(27428): fix if we type enter anything followed by a \ in settings search ([#27432](https://github.com/MetaMask/metamask-extension/pull/27432)) -- fix: flaky test `Address Book Edit entry in address book` due to race condition with mmi menu ([#27557](https://github.com/MetaMask/metamask-extension/pull/27557)) -- refactor: Typescript conversion of get-provider-state.js ([#23635](https://github.com/MetaMask/metamask-extension/pull/23635)) -- chore: Use "gas_included" event prop ([#27559](https://github.com/MetaMask/metamask-extension/pull/27559)) -- fix: mock locale in unit test ([#27574](https://github.com/MetaMask/metamask-extension/pull/27574)) -- feat: codefence Account Watcher for flask ([#27543](https://github.com/MetaMask/metamask-extension/pull/27543)) -- chore: start upgrade to React Router v6 ([#27185](https://github.com/MetaMask/metamask-extension/pull/27185)) -- fix: AmonHenV2 connection flow incremental permitted chain approval and account address case comparison ([#27518](https://github.com/MetaMask/metamask-extension/pull/27518)) -- fix: flaky test `Backup and Restore should backup the account settings` ([#27565](https://github.com/MetaMask/metamask-extension/pull/27565)) -- fix: Apply flex to Snaps buttons only when containing images and icons ([#27564](https://github.com/MetaMask/metamask-extension/pull/27564)) -- feat: aggregated balance feature ([#27097](https://github.com/MetaMask/metamask-extension/pull/27097)) -- feat: Add redesign integration tests ([#27259](https://github.com/MetaMask/metamask-extension/pull/27259)) -- fix: flaky test `4byte setting does not try to get contract method name from 4byte when the setting is off` ([#27560](https://github.com/MetaMask/metamask-extension/pull/27560)) -- feat: add merge queue ([#26871](https://github.com/MetaMask/metamask-extension/pull/26871)) -- feat: remove squiggle animation from swaps smart transactions ([#27264](https://github.com/MetaMask/metamask-extension/pull/27264)) -- feat: Enable gas included swaps ([#27427](https://github.com/MetaMask/metamask-extension/pull/27427)) -- fix(snaps): Fix custom UI buttons submitting forms ([#27531](https://github.com/MetaMask/metamask-extension/pull/27531)) -- chore: Master sync following v12.3.1 ([#27538](https://github.com/MetaMask/metamask-extension/pull/27538)) -- Merge origin/develop into master-sync -- fix(NOTIFY-1171): account syncing performance and bug fixes ([#27529](https://github.com/MetaMask/metamask-extension/pull/27529)) -- fix: genUnapprovedApproveConfirmation import path ([#27530](https://github.com/MetaMask/metamask-extension/pull/27530)) -- fix(snaps): Keep focus on input if interface re-renders ([#27429](https://github.com/MetaMask/metamask-extension/pull/27429)) -- fix: Allow state updates in Snaps interfaces to state values that are falsy ([#27488](https://github.com/MetaMask/metamask-extension/pull/27488)) -- fix: updated ui for connect and review page ([#27478](https://github.com/MetaMask/metamask-extension/pull/27478)) -- feat: Custom header for wallet initiated confirmations ([#27391](https://github.com/MetaMask/metamask-extension/pull/27391)) -- feat: convert account tracker to typescript ([#27231](https://github.com/MetaMask/metamask-extension/pull/27231)) -- fix: Fix snaps permission connection for `CHAIN_PERMISSIONS` feature flag ([#27459](https://github.com/MetaMask/metamask-extension/pull/27459)) -- fix: flaky test `Navigation Signature - Different signature types initiates multiple signatures and rejects all` ([#27481](https://github.com/MetaMask/metamask-extension/pull/27481)) -- feat: Double Sentry performance trace sample rate ([#27468](https://github.com/MetaMask/metamask-extension/pull/27468)) -- ci: Expand github bot policy update comment to be more actionable ([#27242](https://github.com/MetaMask/metamask-extension/pull/27242)) -- chore: Add `useLedgerConnection` unit tests ([#27358](https://github.com/MetaMask/metamask-extension/pull/27358)) -- ci: Sentry reporting only on develop branch, with Git message overrides ([#27412](https://github.com/MetaMask/metamask-extension/pull/27412)) -- test: Fix flaky permit test ([#27450](https://github.com/MetaMask/metamask-extension/pull/27450)) -- fix: removed closeMenu for ConnectedAccountsMenu ([#27460](https://github.com/MetaMask/metamask-extension/pull/27460)) -- fix(snaps): Set proper text color for secondary button ([#27335](https://github.com/MetaMask/metamask-extension/pull/27335)) -- chore: set bridge selected tokens and amount ([#26212](https://github.com/MetaMask/metamask-extension/pull/26212)) -- fix: flaky test `Add account should not affect public address when using secret recovery phrase to recover account with non-zero balance @no-mmi`aded ([#27420](https://github.com/MetaMask/metamask-extension/pull/27420)) -- fix: flaky test `Responsive UI Send Transaction from responsive window` ([#27417](https://github.com/MetaMask/metamask-extension/pull/27417)) -- fix: flaky test `Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx should queue send tx after switch network confirmation and transaction should target the correct network after switch is confirmed` ([#27352](https://github.com/MetaMask/metamask-extension/pull/27352)) -- fix: Change speed key color ([#27416](https://github.com/MetaMask/metamask-extension/pull/27416)) -- feat: Display setApprovalForAll and revoke setApprovalForAll to users… ([#27401](https://github.com/MetaMask/metamask-extension/pull/27401)) -- fix: "Warning: Invalid argument supplied to oneOfType" ([#27267](https://github.com/MetaMask/metamask-extension/pull/27267)) -- feat: Editing flow ([#26635](https://github.com/MetaMask/metamask-extension/pull/26635)) -- chore: bump profile-sync-controller to 0.9.3 ([#27415](https://github.com/MetaMask/metamask-extension/pull/27415)) -- fix: Remove duplication ([#27421](https://github.com/MetaMask/metamask-extension/pull/27421)) -- fix: Confirm Page test failing in CI/CD ([#27423](https://github.com/MetaMask/metamask-extension/pull/27423)) -- feat: Display approve, increaseAllowance and revoke approval to users… ([#26985](https://github.com/MetaMask/metamask-extension/pull/26985)) -- feat: Add performance metrics for signature requests ([#26967](https://github.com/MetaMask/metamask-extension/pull/26967)) -- fix: Permit DataTree token decimals ([#27328](https://github.com/MetaMask/metamask-extension/pull/27328)) -- fix: alert system and refine SIWE and contract interaction alerts ([#27205](https://github.com/MetaMask/metamask-extension/pull/27205)) -- fix(NOTIFY-1166): rename account sync event names ([#27413](https://github.com/MetaMask/metamask-extension/pull/27413)) -- feat: ERC20 Revoke Allowance ([#26906](https://github.com/MetaMask/metamask-extension/pull/26906)) + ## [12.5.1] ### Changed - Improve accuracy of transaction simulation warnings in some scenarios ([#26845](https://github.com/MetaMask/metamask-extension/pull/26845)) From 2c9ad97c9017a41dbb322957e36279ec4ab597b9 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 31 Oct 2024 17:36:52 -0230 Subject: [PATCH 41/56] [cherry pick] Fix left aligned fullscreen (#28218) (#28229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a cherry-pick of #28218 for v12.6.0. Original description: ## **Description** The Home screen was recently updated to make the overview left-aligned. However the fullscreen UI was not considered, and it ended up looking ugly/broken. The fullscreen UI has been updated to be centered, as it was before. The Home screen remains left-aligned in the popup. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28218?quickstart=1) ## **Related issues** Fixes #27593 ## **Manual testing steps** Compare how the Home screen overview looks on the fullscreen UI and the popup. It should be centered on the fullscreen UI, left-aligned on the popup. ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-10-31 at 11 32 12](https://github.com/user-attachments/assets/989ebd4e-90a5-42ae-a522-f7e4d77f0685) ### **After** ![Screenshot 2024-10-31 at 11 28 35](https://github.com/user-attachments/assets/6802bfab-b462-4168-8536-cabb49aceb53) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ui/components/app/wallet-overview/index.scss | 8 ++++++++ .../app/wallet-overview/wallet-overview.js | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss index 4759af1ffa8c..318c26501097 100644 --- a/ui/components/app/wallet-overview/index.scss +++ b/ui/components/app/wallet-overview/index.scss @@ -9,6 +9,10 @@ flex-direction: column; width: 100%; + &-fullscreen { + align-items: center; + } + &__balance { flex: 1; display: flex; @@ -16,6 +20,10 @@ flex-direction: column; align-items: start; width: 100%; + + .wallet-overview-fullscreen > & { + align-items: center; + } } &__icon_button { diff --git a/ui/components/app/wallet-overview/wallet-overview.js b/ui/components/app/wallet-overview/wallet-overview.js index 213a7b2f2317..04127276acaf 100644 --- a/ui/components/app/wallet-overview/wallet-overview.js +++ b/ui/components/app/wallet-overview/wallet-overview.js @@ -2,9 +2,23 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; +// TODO: Move this function to shared +// eslint-disable-next-line import/no-restricted-paths +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; + const WalletOverview = ({ balance, buttons, className }) => { return ( -
+
{balance}
{buttons}
From f6441023cd31e22f49fe02fe29230f3d064b5603 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 31 Oct 2024 21:49:04 +0000 Subject: [PATCH 42/56] fix: cherry-pick: Prevent coercing small spending caps to zero (#28179) (#28183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick: https://github.com/MetaMask/metamask-extension/pull/28179 ## **Description** Previously there was a bug that affected the approve screen. When users had a small spending cap (between 0.001 and 0.0001 or smaller), it was coerced to 0. This was caused by the method `new Intl.NumberFormat(locale).format(spendingCap)` that applied the `1,000` large number formatting, so the fix is to bypass it entirely for values smaller than 1. Additionally, these unformatted small numbers are presented in scientific notation, so we leverage `toNonScientificString(spendingCap)` to prevent that. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28179?quickstart=1) ## **Related issues** Fixes: [#28117](https://github.com/MetaMask/metamask-extension/issues/28117) ## **Manual testing steps** See original bug report. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28183?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../use-approve-token-simulation.test.ts | 68 ++++++++++++++++++- .../hooks/use-approve-token-simulation.ts | 14 +++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts index 4173d21910c5..0178e2ffff62 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts @@ -65,7 +65,7 @@ describe('useApproveTokenSimulation', () => { expect(result.current).toMatchInlineSnapshot(` { - "formattedSpendingCap": 7, + "formattedSpendingCap": "7", "pending": undefined, "spendingCap": "#7", "value": { @@ -155,4 +155,70 @@ describe('useApproveTokenSimulation', () => { } `); }); + + it('returns correct small decimal number token amount for fungible tokens', async () => { + const useIsNFTMock = jest.fn().mockImplementation(() => ({ isNFT: false })); + + const useDecodedTransactionDataMock = jest.fn().mockImplementation(() => ({ + pending: false, + value: { + data: [ + { + name: 'approve', + params: [ + { + type: 'address', + value: '0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4', + }, + { + type: 'uint256', + value: 10 ** 5, + }, + ], + }, + ], + source: 'FourByte', + }, + })); + + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); + (useDecodedTransactionData as jest.Mock).mockImplementation( + useDecodedTransactionDataMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result } = renderHookWithProvider( + () => useApproveTokenSimulation(transactionMeta, '18'), + mockState, + ); + + expect(result.current).toMatchInlineSnapshot(` + { + "formattedSpendingCap": "0.0000000000001", + "pending": undefined, + "spendingCap": "0.0000000000001", + "value": { + "data": [ + { + "name": "approve", + "params": [ + { + "type": "address", + "value": "0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4", + }, + { + "type": "uint256", + "value": 100000, + }, + ], + }, + ], + "source": "FourByte", + }, + } + `); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts index 19f26c9c9300..8d938a12c461 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts @@ -15,6 +15,15 @@ function isSpendingCapUnlimited(decodedSpendingCap: number) { return decodedSpendingCap >= UNLIMITED_THRESHOLD; } +function toNonScientificString(num: number): string { + if (num >= 10e-18) { + return num.toFixed(18).replace(/\.?0+$/u, ''); + } + + // keep in scientific notation + return num.toString(); +} + export const useApproveTokenSimulation = ( transactionMeta: TransactionMeta, decimals: string, @@ -46,8 +55,9 @@ export const useApproveTokenSimulation = ( }, [value, decimals]); const formattedSpendingCap = useMemo(() => { - return isNFT - ? decodedSpendingCap + // formatting coerces small numbers to 0 + return isNFT || decodedSpendingCap < 1 + ? toNonScientificString(decodedSpendingCap) : new Intl.NumberFormat(locale).format(decodedSpendingCap); }, [decodedSpendingCap, isNFT, locale]); From 1cf8791e10bb1b3ca240d4a38fe36d94eac9c16a Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Thu, 31 Oct 2024 23:20:09 -0400 Subject: [PATCH 43/56] refactor: move `getSelectedInternalAccount` from `selectors.js` to `accounts.ts` (#27644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/data/mock-state.json | 7 ++ .../selected-account-component.test.js | 2 +- .../interactive-replacement-token-modal.tsx | 2 +- ...ractive-replacement-token-notification.tsx | 3 + ui/ducks/bridge/selectors.ts | 2 +- ui/ducks/metamask/metamask.js | 4 +- ui/hooks/bridge/useBridging.ts | 1 + ui/hooks/useMultichainSelector.ts | 1 + ui/pages/asset/components/token-buttons.tsx | 1 + .../interactive-replacement-token-page.tsx | 2 +- .../security-tab/security-tab.container.js | 2 +- ui/selectors/accounts.test.ts | 73 +++++++++++++++++ ui/selectors/accounts.ts | 7 +- ui/selectors/index.js | 1 + ui/selectors/institutional/selectors.ts | 5 +- ui/selectors/multichain.ts | 3 +- ui/selectors/permissions.js | 2 +- ui/selectors/selectors.js | 6 +- ui/selectors/selectors.test.js | 79 +++---------------- ui/selectors/transactions.js | 3 +- 20 files changed, 119 insertions(+), 87 deletions(-) diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 654e915a1305..2865478912f3 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -420,6 +420,7 @@ "address": "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc", "id": "cf8dace4-9439-4bd4-b3a8-88c821c8fcb3", "metadata": { + "importTime": 0, "name": "Test Account", "keyring": { "type": "HD Key Tree" @@ -439,6 +440,7 @@ "address": "0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b", "id": "07c2cfec-36c9-46c4-8115-3836d3ac9047", "metadata": { + "importTime": 0, "name": "Test Account 2", "keyring": { "type": "HD Key Tree" @@ -458,6 +460,7 @@ "address": "0xc42edfcc21ed14dda456aa0756c153f7985d8813", "id": "15e69915-2a1a-4019-93b3-916e11fd432f", "metadata": { + "importTime": 0, "name": "Ledger Hardware 2", "keyring": { "type": "Ledger Hardware" @@ -477,6 +480,7 @@ "address": "0xeb9e64b93097bc15f01f13eae97015c57ab64823", "id": "784225f4-d30b-4e77-a900-c8bbce735b88", "metadata": { + "importTime": 0, "name": "Test Account 3", "keyring": { "type": "HD Key Tree" @@ -496,6 +500,7 @@ "address": "0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281", "id": "694225f4-d30b-4e77-a900-c8bbce735b42", "metadata": { + "importTime": 0, "name": "Test Account 4", "keyring": { "type": "Custody test" @@ -515,11 +520,13 @@ "address": "0xb552685e3d2790efd64a175b00d51f02cdafee5d", "id": "c3deeb99-ba0d-4a4e-a0aa-033fc1f79ae3", "metadata": { + "importTime": 0, "name": "Snap Account 1", "keyring": { "type": "Snap Keyring" }, "snap": { + "enabled": true, "id": "snap-id", "name": "snap-name" } diff --git a/ui/components/app/selected-account/selected-account-component.test.js b/ui/components/app/selected-account/selected-account-component.test.js index 290e737c0808..9545580bebbb 100644 --- a/ui/components/app/selected-account/selected-account-component.test.js +++ b/ui/components/app/selected-account/selected-account-component.test.js @@ -111,7 +111,7 @@ describe('SelectedAccount Component', () => { const tooltipTitle = await waitFor(() => container.querySelector( - '[data-original-title="This account is not set up for use with goerli"]', + '[data-original-title="This account is not set up for use with Goerli"]', ), ); diff --git a/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx b/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx index 06fe1336ca7e..e76dabc7add7 100644 --- a/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx +++ b/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx @@ -4,7 +4,7 @@ import { ICustodianType } from '@metamask-institutional/types'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { hideModal } from '../../../store/actions'; -import { getSelectedInternalAccount } from '../../../selectors/selectors'; +import { getSelectedInternalAccount } from '../../../selectors/accounts'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import { Box, diff --git a/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx b/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx index 10dc049b8678..7c1d0f60488f 100644 --- a/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx +++ b/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx @@ -56,6 +56,7 @@ const InteractiveReplacementTokenNotification: React.FC< interactiveReplacementToken && Boolean(Object.keys(interactiveReplacementToken).length); + // @ts-expect-error keyring type is wrong maybe? if (!/^Custody/u.test(keyring.type) || !hasInteractiveReplacementToken) { setShowNotification(false); return; @@ -66,6 +67,7 @@ const InteractiveReplacementTokenNotification: React.FC< )) as unknown as string; const custodyAccountDetails = await dispatch( mmiActions.getAllCustodianAccountsWithToken( + // @ts-expect-error keyring type is wrong maybe? keyring.type.split(' - ')[1], token, ), @@ -105,6 +107,7 @@ const InteractiveReplacementTokenNotification: React.FC< interactiveReplacementToken?.oldRefreshToken, isUnlocked, dispatch, + // @ts-expect-error keyring type is wrong maybe? keyring.type, interactiveReplacementToken, mmiActions, diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index d0dcd8fca51b..568d62e7a2d4 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -8,7 +8,7 @@ import { getIsBridgeEnabled, getSwapsDefaultToken, SwapsEthToken, -} from '../../selectors'; +} from '../../selectors/selectors'; import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; import { BridgeControllerState, diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index d7fa8211b3b7..9627608eb709 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -17,9 +17,9 @@ import { checkNetworkAndAccountSupports1559, getAddressBook, getSelectedNetworkClientId, - getSelectedInternalAccount, getNetworkConfigurationsByChainId, -} from '../../selectors'; +} from '../../selectors/selectors'; +import { getSelectedInternalAccount } from '../../selectors/accounts'; import * as actionConstants from '../../store/actionConstants'; import { updateTransactionGasFees } from '../../store/actions'; import { setCustomGasLimit, setCustomGasPrice } from '../gas/gas.duck'; diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index a68aeb361bdd..fe7a21e2206f 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -43,6 +43,7 @@ const useBridging = () => { const isMarketingEnabled = useSelector(getDataCollectionForMarketing); const providerConfig = useSelector(getProviderConfig); const keyring = useSelector(getCurrentKeyring); + // @ts-expect-error keyring type is wrong maybe? const usingHardwareWallet = isHardwareKeyring(keyring.type); const isBridgeSupported = useSelector(getIsBridgeEnabled); diff --git a/ui/hooks/useMultichainSelector.ts b/ui/hooks/useMultichainSelector.ts index 326ac79bf9cd..9bd979df7e7e 100644 --- a/ui/hooks/useMultichainSelector.ts +++ b/ui/hooks/useMultichainSelector.ts @@ -11,6 +11,7 @@ export function useMultichainSelector< ) { return useSelector((state: TState) => { // We either pass an account or fallback to the currently selected one + // @ts-expect-error state types don't match return selector(state, account || getSelectedInternalAccount(state)); }); } diff --git a/ui/pages/asset/components/token-buttons.tsx b/ui/pages/asset/components/token-buttons.tsx index 7921af85f2ce..988bd72963e2 100644 --- a/ui/pages/asset/components/token-buttons.tsx +++ b/ui/pages/asset/components/token-buttons.tsx @@ -71,6 +71,7 @@ const TokenButtons = ({ ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const keyring = useSelector(getCurrentKeyring); + // @ts-expect-error keyring type is wrong maybe? const usingHardwareWallet = isHardwareKeyring(keyring.type); ///: END:ONLY_INCLUDE_IF diff --git a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx index c7258d1b2e38..5c00eb4ffa10 100644 --- a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx +++ b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx @@ -27,7 +27,7 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { getMetaMaskAccounts } from '../../../selectors'; import { getInstitutionalConnectRequests } from '../../../ducks/institutional/institutional'; -import { getSelectedInternalAccount } from '../../../selectors/selectors'; +import { getSelectedInternalAccount } from '../../../selectors/accounts'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; import { CHAIN_IDS } from '../../../../shared/constants/network'; diff --git a/ui/pages/settings/security-tab/security-tab.container.js b/ui/pages/settings/security-tab/security-tab.container.js index 224072ef2b10..fa529c1ad3df 100644 --- a/ui/pages/settings/security-tab/security-tab.container.js +++ b/ui/pages/settings/security-tab/security-tab.container.js @@ -27,7 +27,7 @@ import { getNetworkConfigurationsByChainId, getMetaMetricsDataDeletionId, getPetnamesEnabled, -} from '../../../selectors'; +} from '../../../selectors/selectors'; import { openBasicFunctionalityModal } from '../../../ducks/app/app'; import SecurityTab from './security-tab.component'; diff --git a/ui/selectors/accounts.test.ts b/ui/selectors/accounts.test.ts index 61a0059989ba..639da0185b72 100644 --- a/ui/selectors/accounts.test.ts +++ b/ui/selectors/accounts.test.ts @@ -1,3 +1,5 @@ +import { EthAccountType } from '@metamask/keyring-api'; +import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { MOCK_ACCOUNTS, MOCK_ACCOUNT_EOA, @@ -5,12 +7,14 @@ import { MOCK_ACCOUNT_BIP122_P2WPKH, MOCK_ACCOUNT_BIP122_P2WPKH_TESTNET, } from '../../test/data/mock-accounts'; +import mockState from '../../test/data/mock-state.json'; import { AccountsState, isSelectedInternalAccountEth, isSelectedInternalAccountBtc, hasCreatedBtcMainnetAccount, hasCreatedBtcTestnetAccount, + getSelectedInternalAccount, } from './accounts'; const MOCK_STATE: AccountsState = { @@ -23,6 +27,75 @@ const MOCK_STATE: AccountsState = { }; describe('Accounts Selectors', () => { + describe('#getSelectedInternalAccount', () => { + it('returns selected internalAccount', () => { + expect( + getSelectedInternalAccount(mockState as AccountsState), + ).toStrictEqual({ + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + importTime: 0, + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: [ + 'personal_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + }); + }); + + it('returns undefined if selectedAccount is undefined', () => { + expect( + getSelectedInternalAccount({ + metamask: { + internalAccounts: { + accounts: {}, + selectedAccount: '', + }, + }, + }), + ).toBeUndefined(); + }); + + it('returns selectedAccount', () => { + const mockInternalAccount = { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + importTime: 0, + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }; + expect( + getSelectedInternalAccount({ + metamask: { + internalAccounts: { + accounts: { + [mockInternalAccount.id]: mockInternalAccount, + }, + selectedAccount: mockInternalAccount.id, + }, + }, + }), + ).toStrictEqual(mockInternalAccount); + }); + }); + describe('isSelectedInternalAccountEth', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ diff --git a/ui/selectors/accounts.ts b/ui/selectors/accounts.ts index bd33d5af1f89..d69cd130f9aa 100644 --- a/ui/selectors/accounts.ts +++ b/ui/selectors/accounts.ts @@ -8,7 +8,7 @@ import { isBtcMainnetAddress, isBtcTestnetAddress, } from '../../shared/lib/multichain'; -import { getSelectedInternalAccount, getInternalAccounts } from './selectors'; +import { getInternalAccounts } from './selectors'; export type AccountsState = { metamask: AccountsControllerState; @@ -20,6 +20,11 @@ function isBtcAccount(account: InternalAccount) { return Boolean(account && account.type === P2wpkh); } +export function getSelectedInternalAccount(state: AccountsState) { + const accountId = state.metamask.internalAccounts.selectedAccount; + return state.metamask.internalAccounts.accounts[accountId]; +} + export function isSelectedInternalAccountEth(state: AccountsState) { const account = getSelectedInternalAccount(state); const { Eoa, Erc4337 } = EthAccountType; diff --git a/ui/selectors/index.js b/ui/selectors/index.js index 6c65a481a709..290c70fb2a31 100644 --- a/ui/selectors/index.js +++ b/ui/selectors/index.js @@ -7,3 +7,4 @@ export * from './permissions'; export * from './selectors'; export * from './transactions'; export * from './approvals'; +export * from './accounts'; diff --git a/ui/selectors/institutional/selectors.ts b/ui/selectors/institutional/selectors.ts index edaaf5278ae7..05bd13b52509 100644 --- a/ui/selectors/institutional/selectors.ts +++ b/ui/selectors/institutional/selectors.ts @@ -1,5 +1,6 @@ import { toChecksumAddress } from 'ethereumjs-util'; -import { getAccountType, getSelectedInternalAccount } from '../selectors'; +import { getAccountType } from '../selectors'; +import { getSelectedInternalAccount } from '../accounts'; import { getProviderConfig } from '../../ducks/metamask/metamask'; import { hexToDecimal } from '../../../shared/modules/conversion.utils'; // TODO: Remove restricted import @@ -166,6 +167,7 @@ export function getCustodianIconForAddress(state: State, address: string) { export function getIsCustodianSupportedChain(state: State) { try { + // @ts-expect-error state types don't match const selectedAccount = getSelectedInternalAccount(state); const accountType = getAccountType(state); const providerConfig = getProviderConfig(state); @@ -207,6 +209,7 @@ export function getIsCustodianSupportedChain(state: State) { export function getMMIAddressFromModalOrAddress(state: State) { const modalAddress = state?.appState?.modal?.modalState?.props?.address; + // @ts-expect-error state types don't match const selectedAddress = getSelectedInternalAccount(state)?.address; return modalAddress || selectedAddress; diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 308dc104b6d6..96266687b6df 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -24,7 +24,7 @@ import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, TEST_NETWORK_IDS, } from '../../shared/constants/network'; -import { AccountsState } from './accounts'; +import { AccountsState, getSelectedInternalAccount } from './accounts'; import { getCurrentChainId, getCurrentCurrency, @@ -33,7 +33,6 @@ import { getNativeCurrencyImage, getNetworkConfigurationsByChainId, getSelectedAccountCachedBalance, - getSelectedInternalAccount, getShouldShowFiat, getShowFiatInTestnets, } from './selectors'; diff --git a/ui/selectors/permissions.js b/ui/selectors/permissions.js index 4031b8e881a3..00468f2f948d 100644 --- a/ui/selectors/permissions.js +++ b/ui/selectors/permissions.js @@ -10,9 +10,9 @@ import { getInternalAccount, getMetaMaskAccountsOrdered, getOriginOfCurrentTab, - getSelectedInternalAccount, getTargetSubjectMetadata, } from './selectors'; +import { getSelectedInternalAccount } from './accounts'; // selectors diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index ac8a6394bf6b..bb7ef5f796d7 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -117,6 +117,7 @@ import { getOrderedConnectedAccountsForConnectedDapp, getSubjectMetadata, } from './permissions'; +import { getSelectedInternalAccount } from './accounts'; import { createDeepEqualSelector } from './util'; import { getMultichainBalances, getMultichainNetwork } from './multichain'; @@ -353,11 +354,6 @@ export function getMaybeSelectedInternalAccount(state) { : undefined; } -export function getSelectedInternalAccount(state) { - const accountId = state.metamask.internalAccounts.selectedAccount; - return state.metamask.internalAccounts.accounts[accountId]; -} - export function checkIfMethodIsEnabled(state, methodName) { const internalAccount = getSelectedInternalAccount(state); return Boolean(internalAccount.methods.includes(methodName)); diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index 5772f9642805..459864c1e1f3 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -6,15 +6,14 @@ import { } from '@metamask/keyring-api'; import { deepClone } from '@metamask/snaps-utils'; import { TransactionStatus } from '@metamask/transaction-controller'; -import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { KeyringType } from '../../shared/constants/keyring'; -import { DeleteRegulationStatus } from '../../shared/constants/metametrics'; -import { CHAIN_IDS, NETWORK_TYPES } from '../../shared/constants/network'; import mockState from '../../test/data/mock-state.json'; +import { CHAIN_IDS, NETWORK_TYPES } from '../../shared/constants/network'; import { createMockInternalAccount } from '../../test/jest/mocks'; +import { getProviderConfig } from '../ducks/metamask/metamask'; import { mockNetworkState } from '../../test/stub/networks'; +import { DeleteRegulationStatus } from '../../shared/constants/metametrics'; import { selectSwitchedNetworkNeverShowMessage } from '../components/app/toast-master/selectors'; -import { getProviderConfig } from '../ducks/metamask/metamask'; import * as selectors from './selectors'; jest.mock('../../app/scripts/lib/util', () => ({ @@ -79,49 +78,6 @@ describe('Selectors', () => { }); }); - describe('#getSelectedInternalAccount', () => { - it('returns undefined if selectedAccount is undefined', () => { - expect( - selectors.getSelectedInternalAccount({ - metamask: { - internalAccounts: { - accounts: {}, - selectedAccount: '', - }, - }, - }), - ).toBeUndefined(); - }); - - it('returns selectedAccount', () => { - const mockInternalAccount = { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Test Account', - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }; - expect( - selectors.getSelectedInternalAccount({ - metamask: { - internalAccounts: { - accounts: { - [mockInternalAccount.id]: mockInternalAccount, - }, - selectedAccount: mockInternalAccount.id, - }, - }, - }), - ).toStrictEqual(mockInternalAccount); - }); - }); - describe('#checkIfMethodIsEnabled', () => { it('returns true if the method is enabled', () => { expect( @@ -974,28 +930,6 @@ describe('Selectors', () => { }); }); - it('returns selected internalAccount', () => { - expect(selectors.getSelectedInternalAccount(mockState)).toStrictEqual({ - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Test Account', - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: [ - 'personal_sign', - 'eth_signTransaction', - 'eth_signTypedData_v1', - 'eth_signTypedData_v3', - 'eth_signTypedData_v4', - ], - type: 'eip155:eoa', - }); - }); - it('returns selected account', () => { const account = selectors.getSelectedAccount(mockState); expect(account.balance).toStrictEqual('0x346ba7725f412cbfdb'); @@ -1475,6 +1409,7 @@ describe('Selectors', () => { balance: '0x0', id: '07c2cfec-36c9-46c4-8115-3836d3ac9047', metadata: { + importTime: 0, name: 'Test Account 2', keyring: { type: 'HD Key Tree', @@ -1499,6 +1434,7 @@ describe('Selectors', () => { balance: '0x0', id: '784225f4-d30b-4e77-a900-c8bbce735b88', metadata: { + importTime: 0, name: 'Test Account 3', keyring: { type: 'HD Key Tree', @@ -1522,6 +1458,7 @@ describe('Selectors', () => { address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', metadata: { + importTime: 0, name: 'Test Account', keyring: { type: 'HD Key Tree', @@ -1547,6 +1484,7 @@ describe('Selectors', () => { address: '0xc42edfcc21ed14dda456aa0756c153f7985d8813', id: '15e69915-2a1a-4019-93b3-916e11fd432f', metadata: { + importTime: 0, name: 'Ledger Hardware 2', keyring: { type: 'Ledger Hardware', @@ -1574,8 +1512,10 @@ describe('Selectors', () => { keyring: { type: 'Snap Keyring', }, + importTime: 0, name: 'Snap Account 1', snap: { + enabled: true, id: 'snap-id', name: 'snap-name', }, @@ -1596,6 +1536,7 @@ describe('Selectors', () => { { id: '694225f4-d30b-4e77-a900-c8bbce735b42', metadata: { + importTime: 0, name: 'Test Account 4', keyring: { type: 'Custody test', diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 2c5fb8fe4b98..3074fd4bfde4 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -13,7 +13,8 @@ import txHelper from '../helpers/utils/tx-helper'; import { SmartTransactionStatus } from '../../shared/constants/transaction'; import { hexToDecimal } from '../../shared/modules/conversion.utils'; import { getProviderConfig } from '../ducks/metamask/metamask'; -import { getCurrentChainId, getSelectedInternalAccount } from './selectors'; +import { getCurrentChainId } from './selectors'; +import { getSelectedInternalAccount } from './accounts'; import { hasPendingApprovals, getApprovalRequestsByType } from './approvals'; import { createDeepEqualSelector, From 08e46815143559e66a960f7770697a88d760a3f3 Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Fri, 1 Nov 2024 08:11:58 -0700 Subject: [PATCH 44/56] chore: improve token lookup performance in `useAccountTotalFiatBalance` (#28233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** `useAccountTotalFiatBalance` looks up each token in the token list to add additional fields. But it was O(n) searching through the entire list, which can be thousands of tokens. The token list is keyed on token address, and can be queried directly instead. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28233?quickstart=1) ## **Related issues** ## **Manual testing steps** No visual changes. In the account picker, erc20 tokens should still have icons on the right of each account. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ui/hooks/useAccountTotalFiatBalance.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/ui/hooks/useAccountTotalFiatBalance.js b/ui/hooks/useAccountTotalFiatBalance.js index b0c9b293c906..7b4a4675225a 100644 --- a/ui/hooks/useAccountTotalFiatBalance.js +++ b/ui/hooks/useAccountTotalFiatBalance.js @@ -51,7 +51,6 @@ export const useAccountTotalFiatBalance = ( const tokens = detectedTokens?.[currentChainId]?.[account?.address] ?? []; // This selector returns all the tokens, we need it to get the image of token const allTokenList = useSelector(getTokenList); - const allTokenListValues = Object.values(allTokenList); const primaryTokenImage = useSelector(getNativeCurrencyImage); const nativeCurrency = useSelector(getNativeCurrency); @@ -92,20 +91,18 @@ export const useAccountTotalFiatBalance = ( }; // To match the list of detected tokens with the entire token list to find the image for tokens - const findMatchingTokens = (array1, array2) => { + const findMatchingTokens = (tokenList, _tokensWithBalances) => { const result = []; - array2.forEach((token2) => { - const matchingToken = array1.find( - (token1) => token1.symbol === token2.symbol, - ); + _tokensWithBalances.forEach((token) => { + const matchingToken = tokenList[token.address.toLowerCase()]; if (matchingToken) { result.push({ ...matchingToken, - balance: token2.balance, - string: token2.string, - balanceError: token2.balanceError, + balance: token.balance, + string: token.string, + balanceError: token.balanceError, }); } }); @@ -113,10 +110,7 @@ export const useAccountTotalFiatBalance = ( return result; }; - const matchingTokens = findMatchingTokens( - allTokenListValues, - tokensWithBalances, - ); + const matchingTokens = findMatchingTokens(allTokenList, tokensWithBalances); // Combine native token, detected token with image in an array const allTokensWithFiatValues = [ From 9b85efbe7953d2ed269ad51852bf0788e082fb80 Mon Sep 17 00:00:00 2001 From: Kanthesha Devaramane Date: Fri, 1 Nov 2024 17:00:22 +0000 Subject: [PATCH 45/56] feat: Upgrade alert controller to base controller v2 (#28054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Following the [Wallet Framework team's OKRs for Q3 2024](https://docs.google.com/document/d/1JLEzfUxHlT8lw8ntgMWG0vQb5BAATcrYZDj0wRB2ogI/edit#heading=h.kzzai3cfecro), we want to bring AlertController up to date with our latest controller patterns. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28054?quickstart=1) ## **Related issues** Fixes: #25915 ## **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 Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../controllers/alert-controller.test.ts | 303 +++++++----------- app/scripts/controllers/alert-controller.ts | 124 ++++--- app/scripts/metamask-controller.js | 6 +- 3 files changed, 189 insertions(+), 244 deletions(-) diff --git a/app/scripts/controllers/alert-controller.test.ts b/app/scripts/controllers/alert-controller.test.ts index a8aee606e02d..de314c31f050 100644 --- a/app/scripts/controllers/alert-controller.test.ts +++ b/app/scripts/controllers/alert-controller.test.ts @@ -2,16 +2,16 @@ * @jest-environment node */ import { ControllerMessenger } from '@metamask/base-controller'; -import { KeyringControllerStateChangeEvent } from '@metamask/keyring-controller'; -import { SnapControllerStateChangeEvent } from '@metamask/snaps-controllers'; import { EthAccountType } from '@metamask/keyring-api'; import { - AlertControllerActions, - AlertControllerEvents, AlertController, AllowedActions, AllowedEvents, - AlertControllerState, + AlertControllerMessenger, + AlertControllerGetStateAction, + AlertControllerStateChangeEvent, + AlertControllerOptions, + getDefaultAlertControllerState, } from './alert-controller'; const EMPTY_ACCOUNT = { @@ -28,230 +28,153 @@ const EMPTY_ACCOUNT = { importTime: 0, }, }; -describe('AlertController', () => { - let controllerMessenger: ControllerMessenger< - AlertControllerActions | AllowedActions, - | AlertControllerEvents - | KeyringControllerStateChangeEvent - | SnapControllerStateChangeEvent - | AllowedEvents + +type WithControllerOptions = Partial; + +type WithControllerCallback = ({ + controller, +}: { + controller: AlertController; + messenger: ControllerMessenger< + AllowedActions | AlertControllerGetStateAction, + AllowedEvents | AlertControllerStateChangeEvent >; - let alertController: AlertController; +}) => ReturnValue; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; - beforeEach(() => { - controllerMessenger = new ControllerMessenger< - AllowedActions, - AllowedEvents - >(); - controllerMessenger.registerActionHandler( - 'AccountsController:getSelectedAccount', - () => EMPTY_ACCOUNT, - ); +async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { ...alertControllerOptions } = rest; - const alertMessenger = controllerMessenger.getRestricted({ + const controllerMessenger = new ControllerMessenger< + AllowedActions | AlertControllerGetStateAction, + AllowedEvents | AlertControllerStateChangeEvent + >(); + + const alertControllerMessenger: AlertControllerMessenger = + controllerMessenger.getRestricted({ name: 'AlertController', - allowedActions: [`AccountsController:getSelectedAccount`], - allowedEvents: [`AccountsController:selectedAccountChange`], + allowedActions: ['AccountsController:getSelectedAccount'], + allowedEvents: ['AccountsController:selectedAccountChange'], }); - alertController = new AlertController({ - state: { - unconnectedAccountAlertShownOrigins: { - testUnconnectedOrigin: false, - }, - web3ShimUsageOrigins: { - testWeb3ShimUsageOrigin: 0, - }, - }, - controllerMessenger: alertMessenger, - }); + controllerMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + jest.fn().mockReturnValue(EMPTY_ACCOUNT), + ); + + const controller = new AlertController({ + messenger: alertControllerMessenger, + ...alertControllerOptions, }); + return await fn({ + controller, + messenger: controllerMessenger, + }); +} + +describe('AlertController', () => { describe('default state', () => { - it('should be same as AlertControllerState initialized', () => { - expect(alertController.store.getState()).toStrictEqual({ - alertEnabledness: { - unconnectedAccount: true, - web3ShimUsage: true, - }, - unconnectedAccountAlertShownOrigins: { - testUnconnectedOrigin: false, - }, - web3ShimUsageOrigins: { - testWeb3ShimUsageOrigin: 0, - }, + it('should be same as AlertControllerState initialized', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual( + getDefaultAlertControllerState(), + ); }); }); }); describe('alertEnabledness', () => { - it('should default unconnectedAccount of alertEnabledness to true', () => { - expect( - alertController.store.getState().alertEnabledness.unconnectedAccount, - ).toStrictEqual(true); + it('should default unconnectedAccount of alertEnabledness to true', async () => { + await withController(({ controller }) => { + expect( + controller.state.alertEnabledness.unconnectedAccount, + ).toStrictEqual(true); + }); }); - it('should set unconnectedAccount of alertEnabledness to false', () => { - alertController.setAlertEnabledness('unconnectedAccount', false); - expect( - alertController.store.getState().alertEnabledness.unconnectedAccount, - ).toStrictEqual(false); - expect( - controllerMessenger.call('AlertController:getState').alertEnabledness - .unconnectedAccount, - ).toStrictEqual(false); + it('should set unconnectedAccount of alertEnabledness to false', async () => { + await withController(({ controller }) => { + controller.setAlertEnabledness('unconnectedAccount', false); + expect( + controller.state.alertEnabledness.unconnectedAccount, + ).toStrictEqual(false); + }); }); }); describe('unconnectedAccountAlertShownOrigins', () => { - it('should default unconnectedAccountAlertShownOrigins', () => { - expect( - alertController.store.getState().unconnectedAccountAlertShownOrigins, - ).toStrictEqual({ - testUnconnectedOrigin: false, - }); - expect( - controllerMessenger.call('AlertController:getState') - .unconnectedAccountAlertShownOrigins, - ).toStrictEqual({ - testUnconnectedOrigin: false, + it('should default unconnectedAccountAlertShownOrigins', async () => { + await withController(({ controller }) => { + expect( + controller.state.unconnectedAccountAlertShownOrigins, + ).toStrictEqual({}); }); }); - it('should set unconnectedAccountAlertShownOrigins', () => { - alertController.setUnconnectedAccountAlertShown('testUnconnectedOrigin'); - expect( - alertController.store.getState().unconnectedAccountAlertShownOrigins, - ).toStrictEqual({ - testUnconnectedOrigin: true, - }); - expect( - controllerMessenger.call('AlertController:getState') - .unconnectedAccountAlertShownOrigins, - ).toStrictEqual({ - testUnconnectedOrigin: true, + it('should set unconnectedAccountAlertShownOrigins', async () => { + await withController(({ controller }) => { + controller.setUnconnectedAccountAlertShown('testUnconnectedOrigin'); + expect( + controller.state.unconnectedAccountAlertShownOrigins, + ).toStrictEqual({ + testUnconnectedOrigin: true, + }); }); }); }); describe('web3ShimUsageOrigins', () => { - it('should default web3ShimUsageOrigins', () => { - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 0, - }); - expect( - controllerMessenger.call('AlertController:getState') - .web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 0, + it('should default web3ShimUsageOrigins', async () => { + await withController(({ controller }) => { + expect(controller.state.web3ShimUsageOrigins).toStrictEqual({}); }); }); - it('should set origin of web3ShimUsageOrigins to recorded', () => { - alertController.setWeb3ShimUsageRecorded('testWeb3ShimUsageOrigin'); - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 1, - }); - expect( - controllerMessenger.call('AlertController:getState') - .web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 1, + it('should set origin of web3ShimUsageOrigins to recorded', async () => { + await withController(({ controller }) => { + controller.setWeb3ShimUsageRecorded('testWeb3ShimUsageOrigin'); + expect(controller.state.web3ShimUsageOrigins).toStrictEqual({ + testWeb3ShimUsageOrigin: 1, + }); }); }); - it('should set origin of web3ShimUsageOrigins to dismissed', () => { - alertController.setWeb3ShimUsageAlertDismissed('testWeb3ShimUsageOrigin'); - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 2, - }); - expect( - controllerMessenger.call('AlertController:getState') - .web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 2, + it('should set origin of web3ShimUsageOrigins to dismissed', async () => { + await withController(({ controller }) => { + controller.setWeb3ShimUsageAlertDismissed('testWeb3ShimUsageOrigin'); + expect(controller.state.web3ShimUsageOrigins).toStrictEqual({ + testWeb3ShimUsageOrigin: 2, + }); }); }); }); describe('selectedAccount change', () => { - it('should set unconnectedAccountAlertShownOrigins to {}', () => { - controllerMessenger.publish('AccountsController:selectedAccountChange', { - id: '', - address: '0x1234567', - options: {}, - methods: [], - type: 'eip155:eoa', - metadata: { - name: '', - keyring: { - type: '', + it('should set unconnectedAccountAlertShownOrigins to {}', async () => { + await withController(({ controller, messenger }) => { + messenger.publish('AccountsController:selectedAccountChange', { + id: '', + address: '0x1234567', + options: {}, + methods: [], + type: 'eip155:eoa', + metadata: { + name: '', + keyring: { + type: '', + }, + importTime: 0, }, - importTime: 0, - }, - }); - expect( - alertController.store.getState().unconnectedAccountAlertShownOrigins, - ).toStrictEqual({}); - expect( - controllerMessenger.call('AlertController:getState') - .unconnectedAccountAlertShownOrigins, - ).toStrictEqual({}); - }); - }); - - describe('AlertController:getState', () => { - it('should return the current state of the property', () => { - const defaultWeb3ShimUsageOrigins = { - testWeb3ShimUsageOrigin: 0, - }; - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual(defaultWeb3ShimUsageOrigins); - expect( - controllerMessenger.call('AlertController:getState') - .web3ShimUsageOrigins, - ).toStrictEqual(defaultWeb3ShimUsageOrigins); - }); - }); - - describe('AlertController:stateChange', () => { - it('state will be published when there is state change', () => { - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 0, - }); - - controllerMessenger.subscribe( - 'AlertController:stateChange', - (state: Partial) => { - expect(state.web3ShimUsageOrigins).toStrictEqual({ - testWeb3ShimUsageOrigin: 1, - }); - }, - ); - - alertController.setWeb3ShimUsageRecorded('testWeb3ShimUsageOrigin'); - - expect( - alertController.store.getState().web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 1, - }); - expect( - alertController.getWeb3ShimUsageState('testWeb3ShimUsageOrigin'), - ).toStrictEqual(1); - expect( - controllerMessenger.call('AlertController:getState') - .web3ShimUsageOrigins, - ).toStrictEqual({ - testWeb3ShimUsageOrigin: 1, + }); + expect( + controller.state.unconnectedAccountAlertShownOrigins, + ).toStrictEqual({}); }); }); }); diff --git a/app/scripts/controllers/alert-controller.ts b/app/scripts/controllers/alert-controller.ts index 9e1882035e02..90e177e9edca 100644 --- a/app/scripts/controllers/alert-controller.ts +++ b/app/scripts/controllers/alert-controller.ts @@ -1,9 +1,13 @@ -import { ObservableStore } from '@metamask/obs-store'; import { AccountsControllerGetSelectedAccountAction, AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; -import { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { + BaseController, + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; import { TOGGLEABLE_ALERT_TYPES, Web3ShimUsageAlertStates, @@ -14,10 +18,10 @@ const controllerName = 'AlertController'; /** * Returns the state of the {@link AlertController}. */ -export type AlertControllerGetStateAction = { - type: 'AlertController:getState'; - handler: () => AlertControllerState; -}; +export type AlertControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + AlertControllerState +>; /** * Actions exposed by the {@link AlertController}. @@ -27,10 +31,10 @@ export type AlertControllerActions = AlertControllerGetStateAction; /** * Event emitted when the state of the {@link AlertController} changes. */ -export type AlertControllerStateChangeEvent = { - type: 'AlertController:stateChange'; - payload: [AlertControllerState, []]; -}; +export type AlertControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + AlertControllerState +>; /** * Events emitted by {@link AlertController}. @@ -76,12 +80,15 @@ export type AlertControllerState = { * @property state - The initial controller state * @property controllerMessenger - The controller messenger */ -type AlertControllerOptions = { +export type AlertControllerOptions = { state?: Partial; - controllerMessenger: AlertControllerMessenger; + messenger: AlertControllerMessenger; }; -const defaultState: AlertControllerState = { +/** + * Function to get default state of the {@link AlertController}. + */ +export const getDefaultAlertControllerState = (): AlertControllerState => ({ alertEnabledness: TOGGLEABLE_ALERT_TYPES.reduce( (alertEnabledness: Record, alertType: string) => { alertEnabledness[alertType] = true; @@ -91,61 +98,76 @@ const defaultState: AlertControllerState = { ), unconnectedAccountAlertShownOrigins: {}, web3ShimUsageOrigins: {}, +}); + +/** + * {@link AlertController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const controllerMetadata = { + alertEnabledness: { + persist: true, + anonymous: true, + }, + unconnectedAccountAlertShownOrigins: { + persist: true, + anonymous: false, + }, + web3ShimUsageOrigins: { + persist: true, + anonymous: false, + }, }; /** * Controller responsible for maintaining alert-related state. */ -export class AlertController { - store: ObservableStore; - - readonly #controllerMessenger: AlertControllerMessenger; - +export class AlertController extends BaseController< + typeof controllerName, + AlertControllerState, + AlertControllerMessenger +> { #selectedAddress: string; constructor(opts: AlertControllerOptions) { - const state: AlertControllerState = { - ...defaultState, - ...opts.state, - }; - - this.store = new ObservableStore(state); - this.#controllerMessenger = opts.controllerMessenger; - this.#controllerMessenger.registerActionHandler( - 'AlertController:getState', - () => this.store.getState(), - ); - this.store.subscribe((alertState: AlertControllerState) => { - this.#controllerMessenger.publish( - 'AlertController:stateChange', - alertState, - [], - ); + super({ + messenger: opts.messenger, + metadata: controllerMetadata, + name: controllerName, + state: { + ...getDefaultAlertControllerState(), + ...opts.state, + }, }); - this.#selectedAddress = this.#controllerMessenger.call( + this.#selectedAddress = this.messagingSystem.call( 'AccountsController:getSelectedAccount', ).address; - this.#controllerMessenger.subscribe( + this.messagingSystem.subscribe( 'AccountsController:selectedAccountChange', (account: { address: string }) => { - const currentState = this.store.getState(); + const currentState = this.state; if ( currentState.unconnectedAccountAlertShownOrigins && this.#selectedAddress !== account.address ) { this.#selectedAddress = account.address; - this.store.updateState({ unconnectedAccountAlertShownOrigins: {} }); + this.update((state) => { + state.unconnectedAccountAlertShownOrigins = {}; + }); } }, ); } setAlertEnabledness(alertId: string, enabledness: boolean): void { - const { alertEnabledness } = this.store.getState(); - alertEnabledness[alertId] = enabledness; - this.store.updateState({ alertEnabledness }); + this.update((state) => { + state.alertEnabledness[alertId] = enabledness; + }); } /** @@ -154,9 +176,9 @@ export class AlertController { * @param origin - The origin the alert has been shown for */ setUnconnectedAccountAlertShown(origin: string): void { - const { unconnectedAccountAlertShownOrigins } = this.store.getState(); - unconnectedAccountAlertShownOrigins[origin] = true; - this.store.updateState({ unconnectedAccountAlertShownOrigins }); + this.update((state) => { + state.unconnectedAccountAlertShownOrigins[origin] = true; + }); } /** @@ -167,7 +189,7 @@ export class AlertController { * origin, or undefined. */ getWeb3ShimUsageState(origin: string): number | undefined { - return this.store.getState().web3ShimUsageOrigins?.[origin]; + return this.state.web3ShimUsageOrigins?.[origin]; } /** @@ -194,10 +216,10 @@ export class AlertController { * @param value - The state value to set. */ #setWeb3ShimUsageState(origin: string, value: number): void { - const { web3ShimUsageOrigins } = this.store.getState(); - if (web3ShimUsageOrigins) { - web3ShimUsageOrigins[origin] = value; - this.store.updateState({ web3ShimUsageOrigins }); - } + this.update((state) => { + if (state.web3ShimUsageOrigins) { + state.web3ShimUsageOrigins[origin] = value; + } + }); } } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 55f5e881de4a..009f87634caa 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1797,7 +1797,7 @@ export default class MetamaskController extends EventEmitter { this.alertController = new AlertController({ state: initState.AlertController, - controllerMessenger: this.controllerMessenger.getRestricted({ + messenger: this.controllerMessenger.getRestricted({ name: 'AlertController', allowedEvents: ['AccountsController:selectedAccountChange'], allowedActions: ['AccountsController:getSelectedAccount'], @@ -2383,7 +2383,7 @@ export default class MetamaskController extends EventEmitter { AddressBookController: this.addressBookController, CurrencyController: this.currencyRateController, NetworkController: this.networkController, - AlertController: this.alertController.store, + AlertController: this.alertController, OnboardingController: this.onboardingController, PermissionController: this.permissionController, PermissionLogController: this.permissionLogController, @@ -2438,7 +2438,7 @@ export default class MetamaskController extends EventEmitter { this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, CurrencyController: this.currencyRateController, - AlertController: this.alertController.store, + AlertController: this.alertController, OnboardingController: this.onboardingController, PermissionController: this.permissionController, PermissionLogController: this.permissionLogController, From 8c4af60886775f2bbffc475f5f7e12fd2200ec01 Mon Sep 17 00:00:00 2001 From: Niranjana Binoy <43930900+NiranjanaBinoy@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:00:33 -0400 Subject: [PATCH 46/56] fix: Error handling for the state log download failure (#26999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Display error message if the `exportAsFile()` for state log download in advanced tab in the settings fails. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/26999?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/2875 ## **Manual testing steps** 1. Throw an error artificially in the `exportAsFile()` state logs download function 2. `Failed to download state log.` message is diaplayed ## **Screenshots/Recordings** ### **Before** Screenshot 2024-11-01 at 10 21 23 AM ### **After** Screenshot 2024-11-01 at 10 34 37 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- ui/ducks/app/app.test.js | 19 ++++++ ui/ducks/app/app.ts | 33 +++++++++++ .../advanced-tab.component.test.js.snap | 1 + .../advanced-tab/advanced-tab.component.js | 34 +++++++---- .../advanced-tab.component.test.js | 59 ++++++++++++++++++- .../advanced-tab/advanced-tab.container.js | 13 ++-- .../advanced-tab/advanced-tab.stories.js | 3 +- ui/store/actionConstants.ts | 2 + 8 files changed, 146 insertions(+), 18 deletions(-) diff --git a/ui/ducks/app/app.test.js b/ui/ducks/app/app.test.js index 9a7a93ea958b..27b20a5841b3 100644 --- a/ui/ducks/app/app.test.js +++ b/ui/ducks/app/app.test.js @@ -339,4 +339,23 @@ describe('App State', () => { expect(state.showDataDeletionErrorModal).toStrictEqual(false); }); + + it('displays error in settings', () => { + const state = reduceApp(metamaskState, { + type: actions.SHOW_SETTINGS_PAGE_ERROR, + payload: 'settings page error', + }); + + expect(state.errorInSettings).toStrictEqual('settings page error'); + }); + + it('hides error in settings', () => { + const displayErrorInSettings = { errorInSettings: 'settings page error' }; + const oldState = { ...metamaskState, ...displayErrorInSettings }; + const state = reduceApp(oldState, { + type: actions.HIDE_SETTINGS_PAGE_ERROR, + }); + + expect(state.errorInSettings).toBeNull(); + }); }); diff --git a/ui/ducks/app/app.ts b/ui/ducks/app/app.ts index e6a7855ce7a5..81f875446f1e 100644 --- a/ui/ducks/app/app.ts +++ b/ui/ducks/app/app.ts @@ -105,6 +105,7 @@ type AppState = { snapsInstallPrivacyWarningShown: boolean; isAddingNewNetwork: boolean; isMultiRpcOnboarding: boolean; + errorInSettings: string | null; }; export type AppSliceState = { @@ -192,6 +193,7 @@ const initialState: AppState = { snapsInstallPrivacyWarningShown: false, isAddingNewNetwork: false, isMultiRpcOnboarding: false, + errorInSettings: null, }; export default function reduceApp( @@ -632,6 +634,16 @@ export default function reduceApp( ...appState, showDataDeletionErrorModal: false, }; + case actionConstants.SHOW_SETTINGS_PAGE_ERROR: + return { + ...appState, + errorInSettings: action.payload, + }; + case actionConstants.HIDE_SETTINGS_PAGE_ERROR: + return { + ...appState, + errorInSettings: null, + }; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) case actionConstants.SHOW_KEYRING_SNAP_REMOVAL_RESULT: return { @@ -720,6 +732,27 @@ export function setCustomTokenAmount(payload: string): PayloadAction { return { type: actionConstants.SET_CUSTOM_TOKEN_AMOUNT, payload }; } +/** + * An action creator for display a error to the user in various places in the + * UI. It will not be cleared until a new warning replaces it or `hideWarning` + * is called. + * + * @param payload - The warning to show. + * @returns The action to display the warning. + */ +export function displayErrorInSettings(payload: string): PayloadAction { + return { + type: actionConstants.SHOW_SETTINGS_PAGE_ERROR, + payload, + }; +} + +export function hideErrorInSettings() { + return { + type: actionConstants.HIDE_SETTINGS_PAGE_ERROR, + }; +} + // Selectors export function getQrCodeData(state: AppSliceState): { type?: string | null; diff --git a/ui/pages/settings/advanced-tab/__snapshots__/advanced-tab.component.test.js.snap b/ui/pages/settings/advanced-tab/__snapshots__/advanced-tab.component.test.js.snap index 6318abd37570..e914c54fe4ca 100644 --- a/ui/pages/settings/advanced-tab/__snapshots__/advanced-tab.component.test.js.snap +++ b/ui/pages/settings/advanced-tab/__snapshots__/advanced-tab.component.test.js.snap @@ -29,6 +29,7 @@ exports[`AdvancedTab Component should match snapshot 1`] = ` > diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js index 50aea4e0dc60..132b97f7caa9 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js @@ -40,9 +40,10 @@ export default class AdvancedTab extends PureComponent { setUseNonceField: PropTypes.func, useNonceField: PropTypes.bool, setHexDataFeatureFlag: PropTypes.func, - displayWarning: PropTypes.func, + displayErrorInSettings: PropTypes.func, + hideErrorInSettings: PropTypes.func, showResetAccountConfirmationModal: PropTypes.func, - warning: PropTypes.string, + errorInSettings: PropTypes.string, sendHexData: PropTypes.bool, showFiatInTestnets: PropTypes.bool, showTestNetworks: PropTypes.bool, @@ -80,7 +81,9 @@ export default class AdvancedTab extends PureComponent { componentDidMount() { const { t } = this.context; + const { hideErrorInSettings } = this.props; handleSettingsRefs(t, t('advanced'), this.settingsRefs); + hideErrorInSettings(); } async getTextFromFile(file) { @@ -112,7 +115,7 @@ export default class AdvancedTab extends PureComponent { renderStateLogs() { const { t } = this.context; - const { displayWarning } = this.props; + const { displayErrorInSettings } = this.props; return ( { - window.logStateString((err, result) => { + window.logStateString(async (err, result) => { if (err) { - displayWarning(t('stateLogError')); + displayErrorInSettings(t('stateLogError')); } else { - exportAsFile( - `${t('stateLogFileName')}.json`, - result, - ExportableContentType.JSON, - ); + try { + await exportAsFile( + `${t('stateLogFileName')}.json`, + result, + ExportableContentType.JSON, + ); + } catch (error) { + displayErrorInSettings(error.message); + } } }); }} @@ -576,11 +584,13 @@ export default class AdvancedTab extends PureComponent { } render() { - const { warning } = this.props; + const { errorInSettings } = this.props; // When adding/removing/editing the order of renders, double-check the order of the settingsRefs. This affects settings-search.js return (
- {warning ?
{warning}
: null} + {errorInSettings ? ( +
{errorInSettings}
+ ) : null} {this.renderStateLogs()} {this.renderResetAccount()} {this.renderToggleStxOptIn()} diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js index 2c64b79e4f4d..aa5dc11ace89 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js @@ -1,15 +1,17 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import mockState from '../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import { exportAsFile } from '../../../helpers/utils/export-utils'; import AdvancedTab from '.'; const mockSetAutoLockTimeLimit = jest.fn().mockReturnValue({ type: 'TYPE' }); const mockSetShowTestNetworks = jest.fn(); const mockSetShowFiatConversionOnTestnetsPreference = jest.fn(); const mockSetStxPrefEnabled = jest.fn(); +const mockDisplayErrorInSettings = jest.fn(); jest.mock('../../../store/actions.ts', () => { return { @@ -21,6 +23,32 @@ jest.mock('../../../store/actions.ts', () => { }; }); +jest.mock('../../../ducks/app/app.ts', () => ({ + displayErrorInSettings: () => mockDisplayErrorInSettings, + hideErrorInSettings: () => jest.fn(), +})); + +jest.mock('../../../helpers/utils/export-utils', () => ({ + ...jest.requireActual('../../../helpers/utils/export-utils'), + exportAsFile: jest + .fn() + .mockResolvedValueOnce({}) + .mockImplementationOnce(new Error('state file error')), +})); + +jest.mock('webextension-polyfill', () => ({ + runtime: { + getPlatformInfo: jest.fn().mockResolvedValue('mac'), + }, +})); + +Object.defineProperty(window, 'stateHooks', { + value: { + getCleanAppState: () => mockState, + getLogs: () => [], + }, +}); + describe('AdvancedTab Component', () => { const mockStore = configureMockStore([thunk])(mockState); @@ -105,4 +133,33 @@ describe('AdvancedTab Component', () => { expect(mockSetStxPrefEnabled).toHaveBeenCalled(); }); }); + + describe('renderStateLogs', () => { + it('should render the toggle button for state log download', () => { + const { queryByTestId } = renderWithProvider(, mockStore); + const stateLogButton = queryByTestId('advanced-setting-state-logs'); + expect(stateLogButton).toBeInTheDocument(); + }); + + it('should call exportAsFile when the toggle button is clicked', async () => { + const { queryByTestId } = renderWithProvider(, mockStore); + const stateLogButton = queryByTestId( + 'advanced-setting-state-logs-button', + ); + fireEvent.click(stateLogButton); + await waitFor(() => { + expect(exportAsFile).toHaveBeenCalledTimes(1); + }); + }); + it('should call displayErrorInSettings when the state file download fails', async () => { + const { queryByTestId } = renderWithProvider(, mockStore); + const stateLogButton = queryByTestId( + 'advanced-setting-state-logs-button', + ); + fireEvent.click(stateLogButton); + await waitFor(() => { + expect(mockDisplayErrorInSettings).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/ui/pages/settings/advanced-tab/advanced-tab.container.js b/ui/pages/settings/advanced-tab/advanced-tab.container.js index f2ad894d1e8b..aaa094e0655c 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.container.js @@ -5,7 +5,6 @@ import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../../shared/constants/prefe import { getPreferences } from '../../../selectors'; import { backupUserData, - displayWarning, setAutoLockTimeLimit, setDismissSeedBackUpReminder, setFeatureFlag, @@ -17,11 +16,15 @@ import { showModal, } from '../../../store/actions'; import { getSmartTransactionsPreferenceEnabled } from '../../../../shared/modules/selectors'; +import { + displayErrorInSettings, + hideErrorInSettings, +} from '../../../ducks/app/app'; import AdvancedTab from './advanced-tab.component'; export const mapStateToProps = (state) => { const { - appState: { warning }, + appState: { errorInSettings }, metamask, } = state; const { @@ -37,7 +40,7 @@ export const mapStateToProps = (state) => { } = getPreferences(state); return { - warning, + errorInSettings, sendHexData, showFiatInTestnets, showTestNetworks, @@ -54,7 +57,9 @@ export const mapDispatchToProps = (dispatch) => { backupUserData: () => backupUserData(), setHexDataFeatureFlag: (shouldShow) => dispatch(setFeatureFlag('sendHexData', shouldShow)), - displayWarning: (warning) => dispatch(displayWarning(warning)), + displayErrorInSettings: (errorInSettings) => + dispatch(displayErrorInSettings(errorInSettings)), + hideErrorInSettings: () => dispatch(hideErrorInSettings()), showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), setUseNonceField: (value) => dispatch(setUseNonceField(value)), diff --git a/ui/pages/settings/advanced-tab/advanced-tab.stories.js b/ui/pages/settings/advanced-tab/advanced-tab.stories.js index 46e7ee978f43..36c84cbba8c0 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.stories.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.stories.js @@ -22,7 +22,8 @@ export default { setDismissSeedBackUpReminder: { action: 'setDismissSeedBackUpReminder' }, setUseNonceField: { action: 'setUseNonceField' }, setHexDataFeatureFlag: { action: 'setHexDataFeatureFlag' }, - displayWarning: { action: 'displayWarning' }, + displayErrorInSettings: { action: 'displayErrorInSettings' }, + hideErrorInSettings: { action: 'hideErrorInSettings' }, history: { action: 'history' }, showResetAccountConfirmationModal: { action: 'showResetAccountConfirmationModal', diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 6f8080e516ae..2e31c6c7dd7f 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -49,6 +49,8 @@ export const LOCK_METAMASK = 'LOCK_METAMASK'; // error handling export const DISPLAY_WARNING = 'DISPLAY_WARNING'; export const HIDE_WARNING = 'HIDE_WARNING'; +export const SHOW_SETTINGS_PAGE_ERROR = 'SHOW_SETTINGS_PAGE_ERROR'; +export const HIDE_SETTINGS_PAGE_ERROR = 'HIDE_SETTINGS_PAGE_ERROR'; export const CAPTURE_SINGLE_EXCEPTION = 'CAPTURE_SINGLE_EXCEPTION'; // accounts screen export const SHOW_ACCOUNTS_PAGE = 'SHOW_ACCOUNTS_PAGE'; From 2de414e3f6bef968b4cd43ae2fba48ef79489590 Mon Sep 17 00:00:00 2001 From: George Marshall Date: Fri, 1 Nov 2024 11:18:00 -0700 Subject: [PATCH 47/56] chore: remove broken link in docs (#28232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR removes a broken link from the README that was originally intended to point to the `Box` component in Storybook. While the link works in Storybook, it does not function correctly when viewed directly on GitHub or in code editors, leading to potential confusion. To simplify access, we are removing this link; users can still locate the `Box` component by searching for it directly within Storybook. ## **Related issues** Fixes: N/A ## **Manual testing steps** 1. View the README file on GitHub. 2. Confirm that the broken link has been removed. 3. Check that references to the `Box` component remain understandable without the link. ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/d48cf851-163c-4169-a2f6-b0020ba79b8d ### **After** https://github.com/user-attachments/assets/46d1f0b1-a2fa-4130-b726-460524118969 No more link references in code base Screenshot 2024-10-31 at 4 00 15 PM ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g., pulled and built the branch, reviewed the updated README). - [ ] 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. --- ui/components/component-library/README.md | 2 +- ui/components/component-library/text/README.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/components/component-library/README.md b/ui/components/component-library/README.md index ec5006d3491f..a1f865bfe95a 100644 --- a/ui/components/component-library/README.md +++ b/ui/components/component-library/README.md @@ -4,7 +4,7 @@ This folder contains design system components that are built 1:1 with the Figma ## Architecture -All components are built on top of the `Box` component and accept all `Box` [component props](/docs/components-componentlibrary-box--docs#props). +All components are built on top of the `Box` component and accept all `Box` component props. ### Layout diff --git a/ui/components/component-library/text/README.mdx b/ui/components/component-library/text/README.mdx index 5b275aa48e23..183fcbbca9f8 100644 --- a/ui/components/component-library/text/README.mdx +++ b/ui/components/component-library/text/README.mdx @@ -580,7 +580,7 @@ Values using the `TextAlign` object from `./ui/helpers/constants/design-system.j ### Box Props -Box props are now integrated with the `Text` component. Valid Box props: [Box](/docs/components-componentlibrary-box--docs#props) +Box props are now integrated with the `Text` component. You no longer need to pass these props as an object through `boxProps` From 59044a48787bdc4ffbb105219b8c74f59e60befa Mon Sep 17 00:00:00 2001 From: Victor Thomas <10986371+vthomas13@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:52:31 -0400 Subject: [PATCH 48/56] chore: Adding installType to Sentry Tags for easy filtering (#28084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** InstallType is a recently added flag to help quickly determine whether a Sentry issue is coming from a natural webstore install, or a developer environment. We want to be able to filter by this flag in the Sentry UI. Added the tag, but also simplified some previous logic from when I added extensionId to make adding extra attributes less tedious in the future. We can also use this pattern for tags. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28084?quickstart=1) ## **Related issues** Fixes: #27667 ## **Manual testing steps** 1. Open App 2. Use developer options to trigger a sentry error 3. Go into Sentry UI and verify that installType is a tag in addition to being in the extra properties. ## **Screenshots/Recordings** Screenshot 2024-10-24 at 1 04 59 PM ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] 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-extension/blob/develop/.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. --------- Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com> --- app/scripts/lib/setupSentry.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 1b9e9f4ddbfc..354bb0bbb620 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -424,12 +424,17 @@ export function rewriteReport(report) { if (!report.extra) { report.extra = {}; } - - report.extra.appState = appState; - if (browser.runtime && browser.runtime.id) { - report.extra.extensionId = browser.runtime.id; + if (!report.tags) { + report.tags = {}; } - report.extra.installType = installType; + + Object.assign(report.extra, { + appState, + installType, + extensionId: browser.runtime?.id, + }); + + report.tags.installType = installType; } catch (err) { log('Error rewriting report', err); } From a94de6a93d583325b46166bcb7a7e2ac84f9f38a Mon Sep 17 00:00:00 2001 From: Niranjana Binoy <43930900+NiranjanaBinoy@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:13:10 -0400 Subject: [PATCH 49/56] fix: Removing `warning` prop from settings (#27990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Even though `warning` property is still used in the setting-tab and security-tab, we are no longer using `displayWarning` to update the error from the settings. This makes the error displayed in the tabs irrelevant to the component. So with this PR we are removing the warning property from settings-tab and security-tab. We are removing the warning property from advance-tab in https://github.com/MetaMask/metamask-extension/pull/26999 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27990?quickstart=1) ## **Related issues** Related to https://github.com/MetaMask/metamask-extension/issues/25838 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** Screenshot 2024-11-01 at 9 52 31 AM Screenshot 2024-11-01 at 9 52 07 AM Screenshot 2024-11-01 at 9 52 19 AM ### **After** Screenshot 2024-11-01 at 10 20 47 AM Screenshot 2024-11-01 at 10 21 23 AM Screenshot 2024-11-01 at 10 21 14 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com> Co-authored-by: Danica Shen --- .../security-tab/__snapshots__/security-tab.test.js.snap | 5 ----- ui/pages/settings/security-tab/security-tab.component.js | 4 ---- ui/pages/settings/security-tab/security-tab.container.js | 6 +----- ui/pages/settings/security-tab/security-tab.test.js | 2 -- ui/pages/settings/settings-tab/settings-tab.component.js | 4 ---- ui/pages/settings/settings-tab/settings-tab.container.js | 6 +----- 6 files changed, 2 insertions(+), 25 deletions(-) diff --git a/ui/pages/settings/security-tab/__snapshots__/security-tab.test.js.snap b/ui/pages/settings/security-tab/__snapshots__/security-tab.test.js.snap index dcec71767fe6..0927d04f89cb 100644 --- a/ui/pages/settings/security-tab/__snapshots__/security-tab.test.js.snap +++ b/ui/pages/settings/security-tab/__snapshots__/security-tab.test.js.snap @@ -83,11 +83,6 @@ exports[`Security Tab should match snapshot 1`] = `
-
- warning -
diff --git a/ui/pages/settings/security-tab/security-tab.component.js b/ui/pages/settings/security-tab/security-tab.component.js index 1fae729d3f31..f9e854ff2465 100644 --- a/ui/pages/settings/security-tab/security-tab.component.js +++ b/ui/pages/settings/security-tab/security-tab.component.js @@ -64,7 +64,6 @@ export default class SecurityTab extends PureComponent { }; static propTypes = { - warning: PropTypes.string, history: PropTypes.object, openSeaEnabled: PropTypes.bool, setOpenSeaEnabled: PropTypes.func, @@ -1131,7 +1130,6 @@ export default class SecurityTab extends PureComponent { render() { const { - warning, petnamesEnabled, dataCollectionForMarketing, setDataCollectionForMarketing, @@ -1144,8 +1142,6 @@ export default class SecurityTab extends PureComponent { {showDataCollectionDisclaimer ? this.renderDataCollectionWarning() : null} - - {warning &&
{warning}
} {this.context.t('security')} diff --git a/ui/pages/settings/security-tab/security-tab.container.js b/ui/pages/settings/security-tab/security-tab.container.js index fa529c1ad3df..676a53097d4a 100644 --- a/ui/pages/settings/security-tab/security-tab.container.js +++ b/ui/pages/settings/security-tab/security-tab.container.js @@ -32,10 +32,7 @@ import { openBasicFunctionalityModal } from '../../../ducks/app/app'; import SecurityTab from './security-tab.component'; const mapStateToProps = (state) => { - const { - appState: { warning }, - metamask, - } = state; + const { metamask } = state; const petnamesEnabled = getPetnamesEnabled(state); @@ -60,7 +57,6 @@ const mapStateToProps = (state) => { const networkConfigurations = getNetworkConfigurationsByChainId(state); return { - warning, incomingTransactionsPreferences, networkConfigurations, participateInMetaMetrics, diff --git a/ui/pages/settings/security-tab/security-tab.test.js b/ui/pages/settings/security-tab/security-tab.test.js index 1685c5417151..ac93efc2e324 100644 --- a/ui/pages/settings/security-tab/security-tab.test.js +++ b/ui/pages/settings/security-tab/security-tab.test.js @@ -48,8 +48,6 @@ jest.mock('../../../ducks/app/app.ts', () => { }); describe('Security Tab', () => { - mockState.appState.warning = 'warning'; // This tests an otherwise untested render branch - const mockStore = configureMockStore([thunk])(mockState); function renderWithProviders(ui, store) { diff --git a/ui/pages/settings/settings-tab/settings-tab.component.js b/ui/pages/settings/settings-tab/settings-tab.component.js index 191bbbc78685..6d56cd9ae10b 100644 --- a/ui/pages/settings/settings-tab/settings-tab.component.js +++ b/ui/pages/settings/settings-tab/settings-tab.component.js @@ -57,7 +57,6 @@ export default class SettingsTab extends PureComponent { static propTypes = { setUseBlockie: PropTypes.func, setCurrentCurrency: PropTypes.func, - warning: PropTypes.string, updateCurrentLocale: PropTypes.func, currentLocale: PropTypes.string, useBlockie: PropTypes.bool, @@ -429,11 +428,8 @@ export default class SettingsTab extends PureComponent { } render() { - const { warning } = this.props; - return (
- {warning ?
{warning}
: null} {this.renderCurrentConversion()} {this.renderShowNativeTokenAsMainBalance()} {this.renderCurrentLocale()} diff --git a/ui/pages/settings/settings-tab/settings-tab.container.js b/ui/pages/settings/settings-tab/settings-tab.container.js index 7de17ffefde4..e6ad25f0df92 100644 --- a/ui/pages/settings/settings-tab/settings-tab.container.js +++ b/ui/pages/settings/settings-tab/settings-tab.container.js @@ -18,10 +18,7 @@ import { getProviderConfig } from '../../../ducks/metamask/metamask'; import SettingsTab from './settings-tab.component'; const mapStateToProps = (state) => { - const { - appState: { warning }, - metamask, - } = state; + const { metamask } = state; const { currentCurrency, useBlockie, currentLocale } = metamask; const { ticker: nativeCurrency } = getProviderConfig(state); const { address: selectedAddress } = getSelectedInternalAccount(state); @@ -31,7 +28,6 @@ const mapStateToProps = (state) => { const tokenList = getTokenList(state); return { - warning, currentLocale, currentCurrency, nativeCurrency, From db2e8be23acd9bbf09ef700f647c1b759b9f2aba Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 1 Nov 2024 17:42:49 -0230 Subject: [PATCH 50/56] chore: Remove obsolete preview build support (#27968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We had previously configured CI to support installing `@metamask`- scoped packages from the GitHub npm registry, as we used this registry for "preview builds" from the core repository in the past. This is no longer used; we publish preview builds to npm now instead. The obsolete CI changes have been removed. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27968?quickstart=1) ## **Related issues** * [Preview build instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#testing-changes-to-packages-with-preview-builds) ([permalink](https://github.com/MetaMask/core/blob/56efd1d13a8873a5abb9bd9880d0576148b9d1e4/docs/contributing.md#testing-changes-to-packages-with-preview-builds)) * Preview build related extension PRs: #16547, #19970, #20096, #20312 ## **Manual testing steps** This is not anticipated to have any functional impact. If it does break something, it would be the install step, so we can test this by installing locally (and seeing that the install step succeeded on CI). ## **Screenshots/Recordings** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .circleci/config.yml | 2 +- .circleci/scripts/install-dependencies.sh | 42 ----------------------- .yarnrc.yml | 8 ----- 3 files changed, 1 insertion(+), 51 deletions(-) delete mode 100755 .circleci/scripts/install-dependencies.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index 3836d5e4048e..aa13dea93b75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -444,7 +444,7 @@ jobs: - gh/install - run: name: Install dependencies - command: .circleci/scripts/install-dependencies.sh + command: yarn --immutable - save_cache: key: dependency-cache-{{ checksum "/tmp/YARN_VERSION" }}-{{ checksum "yarn.lock" }} paths: diff --git a/.circleci/scripts/install-dependencies.sh b/.circleci/scripts/install-dependencies.sh deleted file mode 100755 index 35b7a690fa0c..000000000000 --- a/.circleci/scripts/install-dependencies.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -o pipefail - -IS_NON_FORK_DRAFT='false' - -if [[ -n $CIRCLE_PULL_REQUEST ]] && gh auth status -then - PR_NUMBER="${CIRCLE_PULL_REQUEST##*/}" - if [ -n "$PR_NUMBER" ] - then - IS_NON_FORK_DRAFT="$(gh pr view --json isDraft --jq '.isDraft' "$PR_NUMBER")" - fi -fi - -# Build query to see whether there are any "preview-like" packages in the manifest -# A "preview-like" package is a `@metamask`-scoped package with a prerelease version that has no period. -QUERY='.dependencies + .devDependencies' # Get list of all dependencies -QUERY+=' | with_entries( select(.key | startswith("@metamask") ) )' # filter to @metamask-scoped packages -QUERY+=' | to_entries[].value' # Get version ranges -QUERY+=' | select(test("^\\d+\\.\\d+\\.\\d+-[^.]+$"))' # Get pinned versions where the prerelease part has no "." - -# Use `-e` flag so that exit code indicates whether any matches were found -if jq -e "${QUERY}" < ./package.json -then - echo "Preview builds detected" - HAS_PREVIEW_BUILDS='true' -else - echo "No preview builds detected" - HAS_PREVIEW_BUILDS='false' -fi - -if [[ $IS_NON_FORK_DRAFT == 'true' && $HAS_PREVIEW_BUILDS == 'true' ]] -then - # Use GitHub registry on draft PRs, allowing the use of preview builds - echo "Installing with preview builds" - METAMASK_NPM_REGISTRY=https://npm.pkg.github.com yarn --immutable -else - echo "Installing without preview builds" - yarn --immutable -fi diff --git a/.yarnrc.yml b/.yarnrc.yml index cc0c959e2722..8e12d8037c6a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -119,14 +119,6 @@ npmAuditIgnoreAdvisories: - 'react-beautiful-dnd (deprecation)' # New package name format for new versions: @ethereumjs/wallet. - 'ethereumjs-wallet (deprecation)' -npmRegistries: - 'https://npm.pkg.github.com': - npmAlwaysAuth: true - npmAuthToken: '${GITHUB_PACKAGE_READ_TOKEN-}' - -npmScopes: - metamask: - npmRegistryServer: '${METAMASK_NPM_REGISTRY:-https://registry.yarnpkg.com}' plugins: - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs From d773dd695dd1585d2b1afaf8d3ddd21b0e54785a Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Sat, 2 Nov 2024 09:18:07 +0100 Subject: [PATCH 51/56] feat: add token verification source count and link to block explorer (#27759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** In a previous redesign, the information about the number of sources a token has been verified on and the link to that token on the relevant block explorer was removed from the swap page. This returns that information. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27759?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to the swap page 2. Select swap assets and amount 3. See token verification info and block explorer link ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-10-10 at 14 30 01](https://github.com/user-attachments/assets/85a3281e-a7b2-4649-9a72-7f756b233214) ### **After** ![Screenshot 2024-10-10 at 14 30 21](https://github.com/user-attachments/assets/6f557ed5-a956-4ab8-b3bd-957ac4d9fc2a) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/_locales/en/messages.json | 4 + shared/constants/common.ts | 54 ++++++++++++ shared/constants/swaps.ts | 29 ------- .../assets/nfts/nft-details/nft-details.tsx | 4 +- .../account-list/account-list.tsx | 4 +- ...nteractive-replacement-token-page.test.tsx | 4 +- .../interactive-replacement-token-page.tsx | 4 +- ui/pages/swaps/awaiting-swap/awaiting-swap.js | 4 +- .../prepare-swap-page/prepare-swap-page.js | 83 +++++++++++++------ .../prepare-swap-page.test.js | 12 +-- .../item-list/item-list.component.js | 4 +- .../smart-transaction-status.js | 4 +- 12 files changed, 136 insertions(+), 74 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 512eb3b0ee36..cc077810750b 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5876,6 +5876,10 @@ "swapTokenVerifiedOn1SourceTitle": { "message": "Potentially inauthentic token" }, + "swapTokenVerifiedSources": { + "message": "Confirmed by $1 sources. Verify on $2.", + "description": "$1 the number of sources that have verified the token, $2 points the user to a block explorer as a place they can verify information about the token." + }, "swapTooManyDecimalsError": { "message": "$1 allows up to $2 decimals", "description": "$1 is a token symbol and $2 is the max. number of decimals allowed for the token" diff --git a/shared/constants/common.ts b/shared/constants/common.ts index f45ec8abd7e4..96d2b0c65b55 100644 --- a/shared/constants/common.ts +++ b/shared/constants/common.ts @@ -1,5 +1,59 @@ +import { CHAIN_IDS } from './network'; + export enum EtherDenomination { ETH = 'ETH', GWEI = 'GWEI', WEI = 'WEI', } + +const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/'; +const BSC_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'BscScan'; +const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/'; +const MAINNET_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'Etherscan'; +const GOERLI_DEFAULT_BLOCK_EXPLORER_URL = 'https://goerli.etherscan.io/'; +const GOERLI_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'Goerli Etherscan'; +const POLYGON_DEFAULT_BLOCK_EXPLORER_URL = 'https://polygonscan.com/'; +const POLYGON_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'PolygonScan'; +const AVALANCHE_DEFAULT_BLOCK_EXPLORER_URL = 'https://snowtrace.io/'; +const AVALANCHE_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'Snowtrace'; +const OPTIMISM_DEFAULT_BLOCK_EXPLORER_URL = 'https://optimistic.etherscan.io/'; +const OPTIMISM_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'Optimism Explorer'; +const ARBITRUM_DEFAULT_BLOCK_EXPLORER_URL = 'https://arbiscan.io/'; +const ARBITRUM_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'ArbiScan'; +const ZKSYNC_DEFAULT_BLOCK_EXPLORER_URL = 'https://explorer.zksync.io/'; +const ZKSYNC_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'Zksync Explorer'; +const LINEA_DEFAULT_BLOCK_EXPLORER_URL = 'https://lineascan.build/'; +const LINEA_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'LineaScan'; +const BASE_DEFAULT_BLOCK_EXPLORER_URL = 'https://basescan.org/'; +const BASE_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL = 'BaseScan'; + +type BlockExplorerUrlMap = { + [key: string]: string; +}; + +export const CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP: BlockExplorerUrlMap = { + [CHAIN_IDS.BSC]: BSC_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.MAINNET]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.POLYGON]: POLYGON_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.GOERLI]: GOERLI_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_DEFAULT_BLOCK_EXPLORER_URL, + [CHAIN_IDS.BASE]: BASE_DEFAULT_BLOCK_EXPLORER_URL, +} as const; + +export const CHAINID_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL_MAP: BlockExplorerUrlMap = + { + [CHAIN_IDS.BSC]: BSC_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.MAINNET]: MAINNET_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.POLYGON]: POLYGON_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.GOERLI]: GOERLI_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + [CHAIN_IDS.BASE]: BASE_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, + } as const; diff --git a/shared/constants/swaps.ts b/shared/constants/swaps.ts index 3868c7b6e2f0..8dfecccef6e6 100644 --- a/shared/constants/swaps.ts +++ b/shared/constants/swaps.ts @@ -49,10 +49,6 @@ export type SwapsTokenObject = { iconUrl: string; }; -type BlockExplorerUrlMap = { - [key: string]: string; -}; - export const ETH_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { symbol: CURRENCY_SYMBOLS.ETH, name: 'Ether', @@ -174,17 +170,6 @@ export const TOKEN_API_BASE_URL = 'https://tokens.api.cx.metamask.io'; export const GAS_API_BASE_URL = 'https://gas.api.cx.metamask.io'; export const GAS_DEV_API_BASE_URL = 'https://gas.uat-api.cx.metamask.io'; -const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/'; -export const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/'; -const GOERLI_DEFAULT_BLOCK_EXPLORER_URL = 'https://goerli.etherscan.io/'; -const POLYGON_DEFAULT_BLOCK_EXPLORER_URL = 'https://polygonscan.com/'; -const AVALANCHE_DEFAULT_BLOCK_EXPLORER_URL = 'https://snowtrace.io/'; -const OPTIMISM_DEFAULT_BLOCK_EXPLORER_URL = 'https://optimistic.etherscan.io/'; -const ARBITRUM_DEFAULT_BLOCK_EXPLORER_URL = 'https://arbiscan.io/'; -const ZKSYNC_DEFAULT_BLOCK_EXPLORER_URL = 'https://explorer.zksync.io/'; -export const LINEA_DEFAULT_BLOCK_EXPLORER_URL = 'https://lineascan.build/'; -const BASE_DEFAULT_BLOCK_EXPLORER_URL = 'https://basescan.org/'; - export const ALLOWED_PROD_SWAPS_CHAIN_IDS = [ CHAIN_IDS.MAINNET, SWAPS_TESTNET_CHAIN_ID, @@ -298,20 +283,6 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { [CHAIN_IDS.BASE]: BASE_SWAPS_TOKEN_OBJECT, } as const; -export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP: BlockExplorerUrlMap = - { - [CHAIN_IDS.BSC]: BSC_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.MAINNET]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.POLYGON]: POLYGON_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.GOERLI]: GOERLI_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.AVALANCHE]: AVALANCHE_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.OPTIMISM]: OPTIMISM_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.ARBITRUM]: ARBITRUM_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.LINEA_MAINNET]: LINEA_DEFAULT_BLOCK_EXPLORER_URL, - [CHAIN_IDS.BASE]: BASE_DEFAULT_BLOCK_EXPLORER_URL, - } as const; - export const ETHEREUM = 'ethereum'; export const POLYGON = 'polygon'; export const BSC = 'bsc'; diff --git a/ui/components/app/assets/nfts/nft-details/nft-details.tsx b/ui/components/app/assets/nfts/nft-details/nft-details.tsx index 5ee8525cc98b..0dc9ec05250c 100644 --- a/ui/components/app/assets/nfts/nft-details/nft-details.tsx +++ b/ui/components/app/assets/nfts/nft-details/nft-details.tsx @@ -66,7 +66,7 @@ import { MetaMetricsContext } from '../../../../../contexts/metametrics'; import { Content, Footer, Page } from '../../../../multichain/pages/page'; import { formatCurrency } from '../../../../../helpers/utils/confirm-tx.util'; import { getShortDateFormatterV2 } from '../../../../../pages/asset/util'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../../shared/constants/common'; import { getConversionRate } from '../../../../../ducks/metamask/metamask'; import { Numeric } from '../../../../../../shared/modules/Numeric'; // TODO: Remove restricted import @@ -277,7 +277,7 @@ export default function NftDetails({ nft }: { nft: Nft }) { null as unknown as string, // no holderAddress { blockExplorerUrl: - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, }, ); }; diff --git a/ui/pages/institutional/account-list/account-list.tsx b/ui/pages/institutional/account-list/account-list.tsx index e710a0e6e93a..84431f867307 100644 --- a/ui/pages/institutional/account-list/account-list.tsx +++ b/ui/pages/institutional/account-list/account-list.tsx @@ -1,6 +1,6 @@ import React from 'react'; import CustodyLabels from '../../../components/institutional/custody-labels'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/common'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { shortenAddress } from '../../../helpers/utils/util'; import Tooltip from '../../../components/ui/tooltip'; @@ -41,7 +41,7 @@ type CustodyAccountListProps = { }; const getButtonLinkHref = (account: Account) => { - const url = SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET]; + const url = CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET]; return `${url}address/${account.address}`; }; diff --git a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.test.tsx b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.test.tsx index b2db09b4d06c..6fca1af16a8e 100644 --- a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.test.tsx +++ b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.test.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../test/lib/render-helpers'; import mockState from '../../../../test/data/mock-state.json'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/common'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { shortenAddress } from '../../../helpers/utils/util'; import { getSelectedInternalAccountFromMockState } from '../../../../test/jest/mocks'; @@ -145,7 +145,7 @@ describe('Interactive Replacement Token Page', function () { it('should render all the accounts correctly', async () => { const expectedHref = `${ - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET] + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET] }address/${custodianAddress}`; await act(async () => { diff --git a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx index 5c00eb4ffa10..c12bb0aedf57 100644 --- a/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx +++ b/ui/pages/institutional/interactive-replacement-token-page/interactive-replacement-token-page.tsx @@ -29,7 +29,7 @@ import { getMetaMaskAccounts } from '../../../selectors'; import { getInstitutionalConnectRequests } from '../../../ducks/institutional/institutional'; import { getSelectedInternalAccount } from '../../../selectors/accounts'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/common'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { mmiActionsFactory, @@ -43,7 +43,7 @@ import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { shortenAddress } from '../../../helpers/utils/util'; const getButtonLinkHref = ({ address }: { address: string }) => { - const url = SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET]; + const url = CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET]; return `${url}address/${address}`; }; diff --git a/ui/pages/swaps/awaiting-swap/awaiting-swap.js b/ui/pages/swaps/awaiting-swap/awaiting-swap.js index 7c410ca03ce5..111af726acfa 100644 --- a/ui/pages/swaps/awaiting-swap/awaiting-swap.js +++ b/ui/pages/swaps/awaiting-swap/awaiting-swap.js @@ -48,8 +48,8 @@ import { QUOTES_NOT_AVAILABLE_ERROR, CONTRACT_DATA_DISABLED_ERROR, OFFLINE_FOR_MAINTENANCE, - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, } from '../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/common'; import { isSwapsDefaultTokenSymbol } from '../../../../shared/modules/swaps.utils'; import PulseLoader from '../../../components/ui/pulse-loader'; @@ -143,7 +143,7 @@ export default function AwaitingSwap({ }; const baseNetworkUrl = rpcPrefs.blockExplorerUrl ?? - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null; const blockExplorerUrl = getBlockExplorerLink( { hash: txHash, chainId }, diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js index 8a701289bebd..1e5eb5179e2c 100644 --- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -2,9 +2,9 @@ import React, { useContext, useEffect, useState, useCallback } from 'react'; import BigNumber from 'bignumber.js'; import PropTypes from 'prop-types'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { uniqBy, isEqual } from 'lodash'; +import { uniqBy, isEqual, isEmpty } from 'lodash'; import { useHistory } from 'react-router-dom'; -import { getTokenTrackerLink } from '@metamask/etherscan-link'; +import { getAccountLink, getTokenTrackerLink } from '@metamask/etherscan-link'; import classnames from 'classnames'; import { MetaMetricsContext } from '../../../contexts/metametrics'; @@ -83,23 +83,23 @@ import { usePrevious } from '../../../hooks/usePrevious'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount'; -import { - isSwapsDefaultTokenAddress, - isSwapsDefaultTokenSymbol, -} from '../../../../shared/modules/swaps.utils'; +import { isSwapsDefaultTokenAddress } from '../../../../shared/modules/swaps.utils'; import { MetaMetricsEventCategory, MetaMetricsEventLinkType, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, TokenBucketPriority, ERROR_FETCHING_QUOTES, QUOTES_NOT_AVAILABLE_ERROR, QUOTES_EXPIRED_ERROR, MAX_ALLOWED_SLIPPAGE, } from '../../../../shared/constants/swaps'; +import { + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, + CHAINID_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL_MAP, +} from '../../../../shared/constants/common'; import { resetSwapsPostFetchState, ignoreTokens, @@ -142,6 +142,7 @@ import SwapsBannerAlert from '../swaps-banner-alert/swaps-banner-alert'; import SwapsFooter from '../swaps-footer'; import SelectedToken from '../selected-token/selected-token'; import ListWithSearch from '../list-with-search/list-with-search'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import QuotesLoadingAnimation from './quotes-loading-animation'; import ReviewQuote from './review-quote'; @@ -228,8 +229,8 @@ export default function PrepareSwapPage({ const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); - const fetchParamsFromToken = isSwapsDefaultTokenSymbol( - sourceTokenInfo?.symbol, + const fetchParamsFromToken = isSwapsDefaultTokenAddress( + sourceTokenInfo?.address, chainId, ) ? defaultSwapsToken @@ -241,7 +242,8 @@ export default function PrepareSwapPage({ // but is not in tokensWithBalances or tokens, then we want to add it to the usersTokens array so that // the balance of the token can appear in the from token selection dropdown const fromTokenArray = - !isSwapsDefaultTokenSymbol(fromToken?.symbol, chainId) && fromToken?.balance + !isSwapsDefaultTokenAddress(fromToken?.address, chainId) && + fromToken?.balance ? [fromToken] : []; const usersTokens = uniqBy( @@ -310,7 +312,10 @@ export default function PrepareSwapPage({ { showFiat: true }, true, ); - const swapFromFiatValue = isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) + const swapFromFiatValue = isSwapsDefaultTokenAddress( + fromTokenAddress, + chainId, + ) ? swapFromEthFiatValue : swapFromTokenFiatValue; @@ -435,19 +440,27 @@ export default function PrepareSwapPage({ onInputChange(fromTokenInputValue, token.string, token.decimals); }; - const blockExplorerTokenLink = getTokenTrackerLink( - selectedToToken.address, - chainId, - null, // no networkId - null, // no holderAddress - { - blockExplorerUrl: - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, - }, - ); + const blockExplorerTokenLink = + chainId === CHAIN_IDS.ZKSYNC_ERA + ? // Use getAccountLink because zksync explorer uses a /address URL scheme instead of /token + getAccountLink(selectedToToken.address, chainId, { + blockExplorerUrl: + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, + }) + : getTokenTrackerLink( + selectedToToken.address, + chainId, + null, // no networkId + null, // no holderAddress + { + blockExplorerUrl: + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null, + }, + ); const blockExplorerLabel = rpcPrefs.blockExplorerUrl - ? getURLHostName(blockExplorerTokenLink) + ? CHAINID_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL_MAP[chainId] ?? + t('etherscan') : t('etherscan'); const { address: toAddress } = toToken || {}; @@ -786,18 +799,23 @@ export default function PrepareSwapPage({ ); } - const isNonDefaultToken = !isSwapsDefaultTokenSymbol( - fromTokenSymbol, + const isNonDefaultFromToken = !isSwapsDefaultTokenAddress( + fromTokenAddress, chainId, ); const hasPositiveFromTokenBalance = rawFromTokenBalance > 0; const isTokenEligibleForMaxBalance = - isSmartTransaction || (!isSmartTransaction && isNonDefaultToken); + isSmartTransaction || (!isSmartTransaction && isNonDefaultFromToken); const showMaxBalanceLink = fromTokenSymbol && isTokenEligibleForMaxBalance && hasPositiveFromTokenBalance; + const isNonDefaultToToken = !isSwapsDefaultTokenAddress( + selectedToToken.address, + chainId, + ); + return (
@@ -1024,6 +1042,21 @@ export default function PrepareSwapPage({ {selectedToToken?.string && yourTokenToBalance}
+ +
+ {selectedToToken && + !isEmpty(selectedToToken) && + isNonDefaultToToken && + t('swapTokenVerifiedSources', [ + occurrences, + , + ])} +
+
{showCrossChainSwapsLink && ( { }, }); const props = createProps(); - const { getByText } = renderWithProvider( + const { getByText, getAllByText } = renderWithProvider( , store, ); @@ -128,7 +128,7 @@ describe('PrepareSwapPage', () => { expect( getByText('USDC is only verified on 1 source', { exact: false }), ).toBeInTheDocument(); - expect(getByText('etherscan.io')).toBeInTheDocument(); + expect(getAllByText('Etherscan')[0]).toBeInTheDocument(); expect(getByText('Continue swapping')).toBeInTheDocument(); }); @@ -143,7 +143,7 @@ describe('PrepareSwapPage', () => { }, }); const props = createProps(); - const { getByText } = renderWithProvider( + const { getByText, getAllByText } = renderWithProvider( , store, ); @@ -151,7 +151,7 @@ describe('PrepareSwapPage', () => { expect( getByText('Verify this token on', { exact: false }), ).toBeInTheDocument(); - expect(getByText('etherscan.io')).toBeInTheDocument(); + expect(getAllByText('Etherscan')[0]).toBeInTheDocument(); expect(getByText('Continue swapping')).toBeInTheDocument(); }); @@ -167,11 +167,11 @@ describe('PrepareSwapPage', () => { }, }); const props = createProps(); - const { getByText } = renderWithProvider( + const { getAllByText } = renderWithProvider( , store, ); - const blockExplorer = getByText('etherscan.io'); + const blockExplorer = getAllByText('Etherscan')[0]; expect(blockExplorer).toBeInTheDocument(); fireEvent.click(blockExplorer); expect(global.platform.openTab).toHaveBeenCalledWith({ diff --git a/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js b/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js index 779d1edf58a8..bd1bb5aa5aaf 100644 --- a/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js +++ b/ui/pages/swaps/searchable-item-list/item-list/item-list.component.js @@ -13,7 +13,7 @@ import { getUseCurrencyRateCheck, } from '../../../../selectors'; import { MetaMetricsEventCategory } from '../../../../../shared/constants/metametrics'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../shared/constants/common'; import { getURLHostName } from '../../../../helpers/utils/util'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; @@ -35,7 +35,7 @@ export default function ItemList({ const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); const blockExplorerLink = rpcPrefs.blockExplorerUrl ?? - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null; const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); const blockExplorerHostName = getURLHostName(blockExplorerLink); diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js index 7b8d5910c218..d3127c9a94f3 100644 --- a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js +++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js @@ -23,7 +23,7 @@ import { getSmartTransactionsEnabled, getSmartTransactionsOptInStatusForMetrics, } from '../../../../shared/modules/selectors'; -import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; +import { CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/common'; import { DEFAULT_ROUTE, PREPARE_SWAP_ROUTE, @@ -87,7 +87,7 @@ export default function SmartTransactionStatusPage() { ); const baseNetworkUrl = rpcPrefs.blockExplorerUrl ?? - SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ?? null; let smartTransactionStatus = SmartTransactionStatus.pending; From 5e28e36059dd50d6e6993f353a590a3886cc3a93 Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Sun, 3 Nov 2024 23:09:54 -0800 Subject: [PATCH 52/56] fix: margin on asset chart min/max indicators (#27916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Design feedback that the min/max indicators on the asset chart should not be edge to edge like the chart itself. They should have margin like the rest of the content on the page. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27916?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Click on a token in the token list 2. Click various date range buttons 3. Verify min/max indicators on the chart are in right place 4. Verify min/max indicators keep 16px margin from the edges ## **Screenshots/Recordings** ### **Before** ![image](https://github.com/user-attachments/assets/1b319f18-277d-47a9-b3b2-90b854a58864) ### **After** ![image](https://github.com/user-attachments/assets/90a12c91-16cf-46b4-be91-c287d7d93bf8) ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- .../asset/components/__snapshots__/asset-page.test.tsx.snap | 4 ++-- ui/pages/asset/components/chart/chart-tooltip.tsx | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap index 79400367de13..b5ebc0a83eb6 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -764,7 +764,7 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = ` style="padding-right: 100%; direction: rtl;" >

$1.00

@@ -777,7 +777,7 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = ` style="padding-right: 100%; direction: rtl;" >

$1.00

diff --git a/ui/pages/asset/components/chart/chart-tooltip.tsx b/ui/pages/asset/components/chart/chart-tooltip.tsx index ac7fbc3c20d4..4fa4b83a85ff 100644 --- a/ui/pages/asset/components/chart/chart-tooltip.tsx +++ b/ui/pages/asset/components/chart/chart-tooltip.tsx @@ -42,6 +42,8 @@ const ChartTooltip = ({ }} > Date: Mon, 4 Nov 2024 09:50:59 +0000 Subject: [PATCH 53/56] refactor: remove global network usage from signatures (#28167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Remove global network usage from all signature components and hooks. Specifically: - Replace usages of the following selectors with the chain ID extracted from the signature request. - `getCurrentChainId` - `getNativeCurrency` - `getProviderConfig` - Add new selectors: - `selectNetworkConfigurationByChainId` - `selectDefaultRpcEndpointByChainId` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28167?quickstart=1) ## **Related issues** Fixes: [#3375](https://github.com/MetaMask/MetaMask-planning/issues/3375) [#3500](https://github.com/MetaMask/MetaMask-planning/issues/3500) [#3459](https://github.com/MetaMask/MetaMask-planning/issues/3459) [#3374](https://github.com/MetaMask/MetaMask-planning/issues/3374) ## **Manual testing steps** Regression of all signature types including legacy and redesigned confirmations. Also verify Blockaid warnings. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --- test/data/confirmations/personal_sign.ts | 4 + test/data/confirmations/typed_sign.ts | 11 +- .../confirmations/signatures/permit.test.tsx | 2 + .../signatures/personalSign.test.tsx | 2 + ui/hooks/useName.test.ts | 1 - ui/hooks/useNftCollectionsMetadata.test.ts | 4 - .../value-display/value-display.tsx | 1 + .../network-change-toast-legacy.tsx | 19 +- .../contract-details-modal.js | 10 +- .../blockaid-banner-alert.js | 6 +- .../signature-request-header.js | 32 ++- .../signature-request-header.stories.js | 17 ++ .../signature-request-header.test.js | 10 +- .../signature-request-original.stories.js | 19 +- .../signature-request-original.test.js | 7 + .../signature-request-siwe.test.js.snap | 2 +- .../signature-request-siwe.stories.js | 25 +- .../signature-request-siwe.test.js | 8 +- .../signature-request.test.js.snap | 41 --- .../signature-request/signature-request.js | 16 +- .../signature-request.stories.js | 8 +- .../signature-request.test.js | 269 ++++++++---------- .../confirm-signature-request/index.test.js | 13 +- .../hooks/alerts/useBlockaidAlerts.ts | 6 +- .../hooks/useConfirmationNetworkInfo.ts | 19 +- ...rackERC20WithoutDecimalInformation.test.ts | 3 +- .../useTrackERC20WithoutDecimalInformation.ts | 5 +- ui/selectors/selectors.js | 22 ++ 28 files changed, 307 insertions(+), 275 deletions(-) diff --git a/test/data/confirmations/personal_sign.ts b/test/data/confirmations/personal_sign.ts index b0f135efa53d..69c3ff9f75bc 100644 --- a/test/data/confirmations/personal_sign.ts +++ b/test/data/confirmations/personal_sign.ts @@ -1,3 +1,4 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { SignatureRequestType } from '../../../ui/pages/confirmations/types/confirm'; export const PERSONAL_SIGN_SENDER_ADDRESS = @@ -5,6 +6,7 @@ export const PERSONAL_SIGN_SENDER_ADDRESS = export const unapprovedPersonalSignMsg = { id: '0050d5b0-c023-11ee-a0cb-3390a510a0ab', + chainId: CHAIN_IDS.GOERLI, status: 'unapproved', time: new Date().getTime(), type: 'personal_sign', @@ -20,6 +22,7 @@ export const unapprovedPersonalSignMsg = { export const signatureRequestSIWE = { id: '210ca3b0-1ccb-11ef-b096-89c4d726ebb5', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -57,6 +60,7 @@ export const signatureRequestSIWE = { export const SignatureRequestSIWEWithResources = { id: '210ca3b0-1ccb-11ef-b096-89c4d726ebb5', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', diff --git a/test/data/confirmations/typed_sign.ts b/test/data/confirmations/typed_sign.ts index 7be24a1389c6..831d561f0cb2 100644 --- a/test/data/confirmations/typed_sign.ts +++ b/test/data/confirmations/typed_sign.ts @@ -1,9 +1,10 @@ -import { TransactionType } from '@metamask/transaction-controller'; +import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { SignatureRequestType } from '../../../ui/pages/confirmations/types/confirm'; export const unapprovedTypedSignMsgV1 = { id: '82ab2400-e2c6-11ee-9627-73cc88f00492', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -60,6 +61,7 @@ const rawMessageV3 = { export const unapprovedTypedSignMsgV3 = { id: '17e41af0-e073-11ee-9eec-5fd284826685', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -129,6 +131,7 @@ export const rawMessageV4 = { export const unapprovedTypedSignMsgV4 = { id: '0050d5b0-c023-11ee-a0cb-3390a510a0ab', + chainId: CHAIN_IDS.GOERLI, status: 'unapproved', time: new Date().getTime(), chainid: '0x5', @@ -145,6 +148,7 @@ export const unapprovedTypedSignMsgV4 = { export const orderSignatureMsg = { id: 'e5249ae0-4b6b-11ef-831f-65b48eb489ec', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { result_type: 'loading', reason: 'validation_in_progress', @@ -165,6 +169,7 @@ export const orderSignatureMsg = { export const permitSignatureMsg = { id: '0b1787a0-1c44-11ef-b70d-e7064bd7b659', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -185,6 +190,7 @@ export const permitSignatureMsg = { export const permitNFTSignatureMsg = { id: 'c5067710-87cf-11ef-916c-71f266571322', + chainId: CHAIN_IDS.GOERLI, status: 'unapproved', time: 1728651190529, type: 'eth_signTypedData', @@ -200,6 +206,7 @@ export const permitNFTSignatureMsg = { export const permitSignatureMsgWithNoDeadline = { id: '0b1787a0-1c44-11ef-b70d-e7064bd7b659', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -219,6 +226,7 @@ export const permitSignatureMsgWithNoDeadline = { export const permitBatchSignatureMsg = { id: '0b1787a0-1c44-11ef-b70d-e7064bd7b659', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', @@ -239,6 +247,7 @@ export const permitBatchSignatureMsg = { export const permitSingleSignatureMsg = { id: '0b1787a0-1c44-11ef-b70d-e7064bd7b659', + chainId: CHAIN_IDS.GOERLI, securityAlertResponse: { reason: 'loading', result_type: 'validation_in_progress', diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index 8e9c979562f2..5ff87bf7c533 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -1,6 +1,7 @@ import { ApprovalType } from '@metamask/controller-utils'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import nock from 'nock'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -42,6 +43,7 @@ const getMetaMaskStateWithUnapprovedPermitSign = (accountAddress: string) => { unapprovedTypedMessages: { [pendingPermitId]: { id: pendingPermitId, + chainId: CHAIN_IDS.SEPOLIA, status: 'unapproved', time: pendingPermitTime, type: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA, diff --git a/test/integration/confirmations/signatures/personalSign.test.tsx b/test/integration/confirmations/signatures/personalSign.test.tsx index 690446caa533..5a9c311c9abd 100644 --- a/test/integration/confirmations/signatures/personalSign.test.tsx +++ b/test/integration/confirmations/signatures/personalSign.test.tsx @@ -1,5 +1,6 @@ import { ApprovalType } from '@metamask/controller-utils'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -34,6 +35,7 @@ const getMetaMaskStateWithUnapprovedPersonalSign = (accountAddress: string) => { unapprovedPersonalMsgs: { [pendingPersonalSignId]: { id: pendingPersonalSignId, + chainId: CHAIN_IDS.SEPOLIA, status: 'unapproved', time: pendingPersonalSignTime, type: MESSAGE_TYPE.PERSONAL_SIGN, diff --git a/ui/hooks/useName.test.ts b/ui/hooks/useName.test.ts index f746c4bb6267..b102e9dce7a0 100644 --- a/ui/hooks/useName.test.ts +++ b/ui/hooks/useName.test.ts @@ -15,7 +15,6 @@ jest.mock('react-redux', () => ({ })); jest.mock('../selectors', () => ({ - getCurrentChainId: jest.fn(), getNames: jest.fn(), })); diff --git a/ui/hooks/useNftCollectionsMetadata.test.ts b/ui/hooks/useNftCollectionsMetadata.test.ts index e1e2b6745ad1..cf7997cb518b 100644 --- a/ui/hooks/useNftCollectionsMetadata.test.ts +++ b/ui/hooks/useNftCollectionsMetadata.test.ts @@ -16,10 +16,6 @@ jest.mock('react-redux', () => ({ useSelector: (selector: any) => selector(), })); -jest.mock('../selectors', () => ({ - getCurrentChainId: jest.fn(), -})); - jest.mock('../store/actions', () => ({ getNFTContractInfo: jest.fn(), getTokenStandardAndDetails: jest.fn(), diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx index fa0c911d5eae..c7a9eae6a496 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx @@ -59,6 +59,7 @@ const PermitSimulationValueDisplay: React.FC< const tokenDetails = useGetTokenStandardAndDetails(tokenContract); useTrackERC20WithoutDecimalInformation( + chainId, tokenContract, tokenDetails as TokenDetailsERC20, MetaMetricsEventLocation.SignatureConfirmation, diff --git a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx index fca355a9b063..f8d6b87e50db 100644 --- a/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx +++ b/ui/pages/confirmations/components/confirm/network-change-toast/network-change-toast-legacy.tsx @@ -1,18 +1,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Hex } from '@metamask/utils'; import { Box } from '../../../../../components/component-library'; import { Toast } from '../../../../../components/multichain'; import { getLastInteractedConfirmationInfo, setLastInteractedConfirmationInfo, } from '../../../../../store/actions'; -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, -} from '../../../../../selectors'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { selectNetworkConfigurationByChainId } from '../../../../../selectors'; const CHAIN_CHANGE_THRESHOLD_MILLISECONDS = 60 * 1000; // 1 Minute const TOAST_TIMEOUT_MILLISECONDS = 5 * 1000; // 5 Seconds @@ -22,12 +18,13 @@ const NetworkChangeToastLegacy = ({ }: { confirmation: { id: string; chainId: string }; }) => { - const chainId = useSelector(getCurrentChainId); - const newChainId = confirmation?.chainId ?? chainId; + const newChainId = confirmation?.chainId; const [toastVisible, setToastVisible] = useState(false); const t = useI18nContext(); - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); - const network = networkConfigurations[newChainId as Hex]; + + const network = useSelector((state) => + selectNetworkConfigurationByChainId(state, newChainId), + ); const hideToast = useCallback(() => { setToastVisible(false); @@ -35,9 +32,11 @@ const NetworkChangeToastLegacy = ({ useEffect(() => { let isMounted = true; + if (!confirmation) { return undefined; } + (async () => { const lastInteractedConfirmationInfo = await getLastInteractedConfirmationInfo(); @@ -71,7 +70,7 @@ const NetworkChangeToastLegacy = ({ return () => { isMounted = false; }; - }, [confirmation?.id, chainId]); + }, [confirmation?.id]); if (!toastVisible) { return null; diff --git a/ui/pages/confirmations/components/contract-details-modal/contract-details-modal.js b/ui/pages/confirmations/components/contract-details-modal/contract-details-modal.js index a0a16cc838c3..795401673691 100644 --- a/ui/pages/confirmations/components/contract-details-modal/contract-details-modal.js +++ b/ui/pages/confirmations/components/contract-details-modal/contract-details-modal.js @@ -39,7 +39,7 @@ export default function ContractDetailsModal({ tokenAddress, toAddress, chainId, - rpcPrefs, + blockExplorerUrl, tokenId, assetName, assetStandard, @@ -178,7 +178,7 @@ export default function ContractDetailsModal({ tokenAddress, chainId, { - blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, + blockExplorerUrl: blockExplorerUrl ?? null, }, null, ); @@ -287,7 +287,7 @@ export default function ContractDetailsModal({ toAddress, chainId, { - blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, + blockExplorerUrl: blockExplorerUrl ?? null, }, null, ); @@ -338,9 +338,9 @@ ContractDetailsModal.propTypes = { */ chainId: PropTypes.string, /** - * RPC prefs of the current network + * Block explorer URL of the current network */ - rpcPrefs: PropTypes.object, + blockExplorerUrl: PropTypes.string, /** * The token id of the NFT */ diff --git a/ui/pages/confirmations/components/security-provider-banner-alert/blockaid-banner-alert/blockaid-banner-alert.js b/ui/pages/confirmations/components/security-provider-banner-alert/blockaid-banner-alert/blockaid-banner-alert.js index 4115f06cd644..ff5adfea8b5d 100644 --- a/ui/pages/confirmations/components/security-provider-banner-alert/blockaid-banner-alert/blockaid-banner-alert.js +++ b/ui/pages/confirmations/components/security-provider-banner-alert/blockaid-banner-alert/blockaid-banner-alert.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { captureException } from '@sentry/browser'; import BlockaidPackage from '@blockaid/ppom_release/package.json'; -import { useSelector } from 'react-redux'; import { NETWORK_TO_NAME_MAP } from '../../../../../../shared/constants/network'; import { OverflowWrap } from '../../../../../helpers/constants/design-system'; import { I18nContext } from '../../../../../contexts/i18n'; @@ -20,7 +19,6 @@ import { useTransactionEventFragment } from '../../../hooks/useTransactionEventF import SecurityProviderBannerAlert from '../security-provider-banner-alert'; import LoadingIndicator from '../../../../../components/ui/loading-indicator'; -import { getCurrentChainId } from '../../../../../selectors'; import { getReportUrl } from './blockaid-banner-utils'; const zlib = require('zlib'); @@ -59,8 +57,6 @@ function BlockaidBannerAlert({ txData, ...props }) { const { securityAlertResponse, origin, msgParams, type, txParams, chainId } = txData; - const selectorChainId = useSelector(getCurrentChainId); - const t = useContext(I18nContext); const { updateTransactionEventFragment } = useTransactionEventFragment(); @@ -131,7 +127,7 @@ function BlockaidBannerAlert({ txData, ...props }) { const reportData = { blockNumber: block, blockaidVersion: BlockaidPackage.version, - chain: NETWORK_TO_NAME_MAP[chainId ?? selectorChainId], + chain: NETWORK_TO_NAME_MAP[chainId], classification: isFailedResultType ? 'error' : reason, domain: origin ?? msgParams?.origin ?? txParams?.origin, jsonRpcMethod: type, diff --git a/ui/pages/confirmations/components/signature-request-header/signature-request-header.js b/ui/pages/confirmations/components/signature-request-header/signature-request-header.js index 9c91ca476ebb..61cbfe13e290 100644 --- a/ui/pages/confirmations/components/signature-request-header/signature-request-header.js +++ b/ui/pages/confirmations/components/signature-request-header/signature-request-header.js @@ -1,16 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { NetworkType } from '@metamask/controller-utils'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { - getNativeCurrency, - getProviderConfig, -} from '../../../../ducks/metamask/metamask'; import { accountsWithSendEtherInfoSelector, - getCurrentChainId, getCurrentCurrency, + selectDefaultRpcEndpointByChainId, + selectNetworkConfigurationByChainId, } from '../../../../selectors'; import { formatCurrency } from '../../../../helpers/utils/confirm-tx.util'; import { @@ -26,22 +25,33 @@ import NetworkAccountBalanceHeader from '../../../../components/app/network-acco const SignatureRequestHeader = ({ txData }) => { const t = useI18nContext(); const { + chainId, msgParams: { from }, } = txData; const allAccounts = useSelector(accountsWithSendEtherInfoSelector); const fromAccount = getAccountByAddress(allAccounts, from); - const nativeCurrency = useSelector(getNativeCurrency); const currentCurrency = useSelector(getCurrentCurrency); - const currentChainId = useSelector(getCurrentChainId); - const providerConfig = useSelector(getProviderConfig); - const networkName = getNetworkNameFromProviderType(providerConfig.type); + const { nativeCurrency, name: networkNickname } = useSelector((state) => + selectNetworkConfigurationByChainId(state, chainId), + ); + + const defaultRpcEndpoint = useSelector((state) => + selectDefaultRpcEndpointByChainId(state, chainId), + ); + + const networkType = + defaultRpcEndpoint.type === RpcEndpointType.Custom + ? NetworkType.rpc + : defaultRpcEndpoint.networkClientId; + + const networkName = getNetworkNameFromProviderType(networkType); const conversionRate = null; // setting conversion rate to null by default to display balance in native const currentNetwork = networkName === '' - ? providerConfig.nickname || t('unknownNetwork') + ? networkNickname || t('unknownNetwork') : t(networkName); const balanceInBaseAsset = conversionRate @@ -71,7 +81,7 @@ const SignatureRequestHeader = ({ txData }) => { conversionRate ? currentCurrency?.toUpperCase() : nativeCurrency } accountAddress={fromAccount.address} - chainId={currentChainId} + chainId={chainId} /> ); }; diff --git a/ui/pages/confirmations/components/signature-request-header/signature-request-header.stories.js b/ui/pages/confirmations/components/signature-request-header/signature-request-header.stories.js index 673b9a67e598..111be8932207 100644 --- a/ui/pages/confirmations/components/signature-request-header/signature-request-header.stories.js +++ b/ui/pages/confirmations/components/signature-request-header/signature-request-header.stories.js @@ -1,13 +1,30 @@ import React from 'react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { Provider } from 'react-redux'; +import configureStore from '../../../../store/store'; +import testData from '../../../../../.storybook/test-data'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import SignatureRequestHeader from './signature-request-header'; +const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET; + +const store = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), + }, +}); + export default { title: 'Confirmations/Components/SignatureRequestHeader', + decorators: [(story) => {story()}], argTypes: { txData: { control: 'object' }, }, args: { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e', data: JSON.stringify({ diff --git a/ui/pages/confirmations/components/signature-request-header/signature-request-header.test.js b/ui/pages/confirmations/components/signature-request-header/signature-request-header.test.js index a3ddd3136627..862339239643 100644 --- a/ui/pages/confirmations/components/signature-request-header/signature-request-header.test.js +++ b/ui/pages/confirmations/components/signature-request-header/signature-request-header.test.js @@ -1,12 +1,17 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import mockState from '../../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import SignatureRequestHeader from '.'; +const CHAIN_ID_MOCK = CHAIN_IDS.GOERLI; + const props = { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', }, @@ -14,7 +19,10 @@ const props = { }; describe('SignatureRequestHeader', () => { - const store = configureMockStore()(mockState); + const store = configureMockStore()({ + ...mockState, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), + }); it('should match snapshot', () => { const { container } = renderWithProvider( diff --git a/ui/pages/confirmations/components/signature-request-original/signature-request-original.stories.js b/ui/pages/confirmations/components/signature-request-original/signature-request-original.stories.js index 297978bb5f04..7343d5c34140 100644 --- a/ui/pages/confirmations/components/signature-request-original/signature-request-original.stories.js +++ b/ui/pages/confirmations/components/signature-request-original/signature-request-original.stories.js @@ -1,10 +1,16 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { Provider } from 'react-redux'; import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; import testData from '../../../../../.storybook/test-data'; +import configureStore from '../../../../store/store'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import README from './README.mdx'; import SignatureRequestOriginal from './signature-request-original.component'; +const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET; + const [MOCK_PRIMARY_ACCOUNT, MOCK_SECONDARY_ACCOUNT] = Object.values( testData.metamask.internalAccounts.accounts, ); @@ -41,9 +47,17 @@ const MOCK_SIGN_DATA = JSON.stringify({ }, }); +const store = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), + }, +}); + export default { title: 'Confirmations/Components/SignatureRequestOriginal', - + decorators: [(story) => {story()}], component: SignatureRequestOriginal, parameters: { docs: { @@ -87,6 +101,7 @@ DefaultStory.storyName = 'personal_sign Type'; DefaultStory.args = { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e', data: MOCK_SIGN_DATA, @@ -102,6 +117,7 @@ ETHSignTypedStory.storyName = 'eth_signTypedData Type'; ETHSignTypedStory.args = { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e', data: [ @@ -128,6 +144,7 @@ AccountMismatchStory.storyName = 'Account Mismatch warning'; AccountMismatchStory.args = { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4', data: MOCK_SIGN_DATA, diff --git a/ui/pages/confirmations/components/signature-request-original/signature-request-original.test.js b/ui/pages/confirmations/components/signature-request-original/signature-request-original.test.js index 11577f44e312..b8d1429751e6 100644 --- a/ui/pages/confirmations/components/signature-request-original/signature-request-original.test.js +++ b/ui/pages/confirmations/components/signature-request-original/signature-request-original.test.js @@ -3,6 +3,7 @@ import configureMockStore from 'redux-mock-store'; import { fireEvent, screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; import { EthAccountType } from '@metamask/keyring-api'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; import { SECURITY_PROVIDER_MESSAGE_SEVERITY } from '../../../../../shared/constants/security-provider'; import mockState from '../../../../../test/data/mock-state.json'; @@ -11,6 +12,7 @@ import configureStore from '../../../../store/store'; import { rejectPendingApproval } from '../../../../store/actions'; import { shortenAddress } from '../../../../helpers/utils/util'; import { ETH_EOA_METHODS } from '../../../../../shared/constants/eth-methods'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import SignatureRequestOriginal from '.'; jest.mock('../../../../store/actions', () => ({ @@ -21,12 +23,15 @@ jest.mock('../../../../store/actions', () => ({ setLastInteractedConfirmationInfo: jest.fn(), })); +const CHAIN_ID_MOCK = CHAIN_IDS.GOERLI; + const address = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; const props = { signMessage: jest.fn(), cancelMessage: jest.fn(), txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: address, data: [ @@ -76,6 +81,7 @@ const render = ({ txData = props.txData, selectedAccount } = {}) => { const store = configureStore({ metamask: { ...mockState.metamask, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), internalAccounts, }, }); @@ -120,6 +126,7 @@ describe('SignatureRequestOriginal', () => { it('should escape RTL character in label or value', () => { const txData = { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', data: [ diff --git a/ui/pages/confirmations/components/signature-request-siwe/__snapshots__/signature-request-siwe.test.js.snap b/ui/pages/confirmations/components/signature-request-siwe/__snapshots__/signature-request-siwe.test.js.snap index a524cbcc1caf..fcf0bb30d8a3 100644 --- a/ui/pages/confirmations/components/signature-request-siwe/__snapshots__/signature-request-siwe.test.js.snap +++ b/ui/pages/confirmations/components/signature-request-siwe/__snapshots__/signature-request-siwe.test.js.snap @@ -169,7 +169,7 @@ exports[`SignatureRequestSIWE (Sign in with Ethereum) should match snapshot 1`] > 966.987986 - ETH + GoerliETH
diff --git a/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.stories.js b/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.stories.js index de29680421c6..7900cfe1828d 100644 --- a/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.stories.js +++ b/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.stories.js @@ -1,18 +1,37 @@ import React from 'react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { Provider } from 'react-redux'; import testData from '../../../../../.storybook/test-data'; +import configureStore from '../../../../store/store'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import README from './README.mdx'; import SignatureRequestSIWE from './signature-request-siwe'; +const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET; + +const TRANSACTION_DATA_MOCK = { + chainId: CHAIN_ID_MOCK, +}; + const { internalAccounts: { accounts, selectedAccount }, } = testData.metamask; + const otherAccount = Object.values(accounts)[1]; const { address: selectedAddress } = accounts[selectedAccount]; +const store = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), + }, +}); + export default { title: 'Confirmations/Components/SignatureRequestSIWE', - + decorators: [(story) => {story()}], component: SignatureRequestSIWE, parameters: { docs: { @@ -134,6 +153,7 @@ DefaultStory.storyName = 'Default'; DefaultStory.args = { txData: { + ...TRANSACTION_DATA_MOCK, msgParams, }, }; @@ -144,6 +164,7 @@ export const BadDomainStory = (args) => { BadDomainStory.args = { txData: { + ...TRANSACTION_DATA_MOCK, msgParams: badDomainParams, }, }; @@ -154,6 +175,7 @@ export const BadAddressStory = (args) => { BadAddressStory.args = { txData: { + ...TRANSACTION_DATA_MOCK, msgParams: badAddressParams, }, }; @@ -164,6 +186,7 @@ export const BadDomainAndAddressStory = (args) => { BadDomainAndAddressStory.args = { txData: { + ...TRANSACTION_DATA_MOCK, msgParams: badDomainAndAddressParams, }, }; diff --git a/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.test.js b/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.test.js index c97be33e45b0..f8f1a67b8cbc 100644 --- a/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.test.js +++ b/ui/pages/confirmations/components/signature-request-siwe/signature-request-siwe.test.js @@ -2,14 +2,16 @@ import React from 'react'; import { cloneDeep } from 'lodash'; import { fireEvent } from '@testing-library/react'; import { ApprovalType } from '@metamask/controller-utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import mockState from '../../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import configureStore from '../../../../store/store'; -import { getCurrentChainId } from '../../../../selectors'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import SignatureRequestSIWE from '.'; const MOCK_ORIGIN = 'https://example-dapp.website'; const MOCK_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; +const CHAIN_ID_MOCK = CHAIN_IDS.GOERLI; const mockStoreInitialState = { metamask: { @@ -20,6 +22,7 @@ const mockStoreInitialState = { name: 'Example Test Dapp', }, }, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), }, }; @@ -37,6 +40,7 @@ const mockProps = { cancelPersonalMessage: jest.fn(), signPersonalMessage: jest.fn(), txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: MOCK_ADDRESS, data: '0x6c6f63616c686f73743a383038302077616e747320796f7520746f207369676e20696e207769746820796f757220457468657265756d206163636f756e743a0a3078466232433135303034333433393034653566343038323537386334653865313131303563463765330a0a436c69636b20746f207369676e20696e20616e642061636365707420746865205465726d73206f6620536572766963653a2068747470733a2f2f636f6d6d756e6974792e6d6574616d61736b2e696f2f746f730a0a5552493a20687474703a2f2f6c6f63616c686f73743a383038300a56657273696f6e3a20310a436861696e2049443a20310a4e6f6e63653a2053544d74364b514d7777644f58453330360a4973737565642041743a20323032322d30332d31385432313a34303a34302e3832335a0a5265736f75726365733a0a2d20697066733a2f2f516d653773733341525667787636725871565069696b4d4a3875324e4c676d67737a673133705972444b456f69750a2d2068747470733a2f2f6578616d706c652e636f6d2f6d792d776562322d636c61696d2e6a736f6e', @@ -183,7 +187,7 @@ describe('SignatureRequestSIWE (Sign in with Ethereum)', () => { transactions: [ ...mockStoreInitialState.metamask.transactions, { - chainId: getCurrentChainId(mockStoreInitialState), + chainId: CHAIN_ID_MOCK, status: 'unapproved', }, ], diff --git a/ui/pages/confirmations/components/signature-request/__snapshots__/signature-request.test.js.snap b/ui/pages/confirmations/components/signature-request/__snapshots__/signature-request.test.js.snap index ba272fc26d89..943f8699b9aa 100644 --- a/ui/pages/confirmations/components/signature-request/__snapshots__/signature-request.test.js.snap +++ b/ui/pages/confirmations/components/signature-request/__snapshots__/signature-request.test.js.snap @@ -177,26 +177,6 @@ exports[`Signature Request Component render should match snapshot when we are us
-
- -
-

- To view and confirm your most recent request, you'll need to approve or reject existing requests first. -

-

-

-
@@ -962,7 +942,6 @@ exports[`Signature Request Component render should match snapshot when we want t > 0 - ETH
@@ -970,26 +949,6 @@ exports[`Signature Request Component render should match snapshot when we want t
-
- -
-

- To view and confirm your most recent request, you'll need to approve or reject existing requests first. -

-

-

-
diff --git a/ui/pages/confirmations/components/signature-request/signature-request.js b/ui/pages/confirmations/components/signature-request/signature-request.js index 3cf2a54327c9..2a7977da0c75 100644 --- a/ui/pages/confirmations/components/signature-request/signature-request.js +++ b/ui/pages/confirmations/components/signature-request/signature-request.js @@ -18,16 +18,14 @@ import { doesAddressRequireLedgerHidConnection, getSubjectMetadata, getTotalUnapprovedMessagesCount, + selectNetworkConfigurationByChainId, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) accountsWithSendEtherInfoSelector, getSelectedAccount, getAccountType, ///: END:ONLY_INCLUDE_IF } from '../../../../selectors'; -import { - getProviderConfig, - isAddressLedger, -} from '../../../../ducks/metamask/metamask'; +import { isAddressLedger } from '../../../../ducks/metamask/metamask'; import { sanitizeMessage, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -97,6 +95,7 @@ const SignatureRequest = ({ txData, warnings }) => { const { id, type, + chainId, msgParams: { from, data, origin, version }, } = txData; @@ -104,7 +103,12 @@ const SignatureRequest = ({ txData, warnings }) => { const hardwareWalletRequiresConnection = useSelector((state) => doesAddressRequireLedgerHidConnection(state, from), ); - const { chainId, rpcPrefs } = useSelector(getProviderConfig); + + const { blockExplorerUrls } = useSelector((state) => + selectNetworkConfigurationByChainId(state, chainId), + ); + + const blockExplorerUrl = blockExplorerUrls?.[0]; const unapprovedMessagesCount = useSelector(getTotalUnapprovedMessagesCount); const subjectMetadata = useSelector(getSubjectMetadata); const isLedgerWallet = useSelector((state) => isAddressLedger(state, from)); @@ -337,7 +341,7 @@ const SignatureRequest = ({ txData, warnings }) => { setShowContractDetails(false)} isContractRequestingSignature /> diff --git a/ui/pages/confirmations/components/signature-request/signature-request.stories.js b/ui/pages/confirmations/components/signature-request/signature-request.stories.js index b31032f8b1a7..cc5374e2becb 100644 --- a/ui/pages/confirmations/components/signature-request/signature-request.stories.js +++ b/ui/pages/confirmations/components/signature-request/signature-request.stories.js @@ -1,21 +1,25 @@ import React from 'react'; import { Provider } from 'react-redux'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import configureStore from '../../../../store/store'; import testData from '../../../../../.storybook/test-data'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import README from './README.mdx'; import SignatureRequest from './signature-request'; +const CHAIN_ID_MOCK = CHAIN_IDS.MAINNET; + const store = configureStore({ ...testData, metamask: { ...testData.metamask, + ...mockNetworkState({ chainId: CHAIN_ID_MOCK }), }, }); export default { title: 'Confirmations/Components/SignatureRequest', decorators: [(story) => {story()}], - component: SignatureRequest, parameters: { docs: { @@ -35,6 +39,7 @@ DefaultStory.storyName = 'Default'; DefaultStory.args = { txData: { + chainId: CHAIN_ID_MOCK, msgParams: { from: '0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e', data: JSON.stringify({ @@ -82,6 +87,7 @@ AccountMismatchStory.storyName = 'AccountMismatch'; AccountMismatchStory.args = { ...DefaultStory.args, txData: { + chainId: CHAIN_ID_MOCK, msgParams: { ...DefaultStory.args.txData.msgParams, from: '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4', diff --git a/ui/pages/confirmations/components/signature-request/signature-request.test.js b/ui/pages/confirmations/components/signature-request/signature-request.test.js index 9851cdbef454..2e10381c40cb 100644 --- a/ui/pages/confirmations/components/signature-request/signature-request.test.js +++ b/ui/pages/confirmations/components/signature-request/signature-request.test.js @@ -1,35 +1,25 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { fireEvent } from '@testing-library/react'; import configureMockStore from 'redux-mock-store'; import { EthAccountType } from '@metamask/keyring-api'; import mockState from '../../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import { SECURITY_PROVIDER_MESSAGE_SEVERITY } from '../../../../../shared/constants/security-provider'; -import { - getNativeCurrency, - getProviderConfig, -} from '../../../../ducks/metamask/metamask'; -import { - accountsWithSendEtherInfoSelector, - conversionRateSelector, - getCurrentCurrency, - getMemoizedAddressBook, - getPreferences, - getSelectedAccount, - getTotalUnapprovedMessagesCount, - getInternalAccounts, - unconfirmedTransactionsHashSelector, - getAccountType, - getMemoizedMetaMaskInternalAccounts, - getSelectedInternalAccount, - pendingApprovalsSortedSelector, - getNetworkConfigurationsByChainId, -} from '../../../../selectors'; import { ETH_EOA_METHODS } from '../../../../../shared/constants/eth-methods'; import { mockNetworkState } from '../../../../../test/stub/networks'; import SignatureRequest from './signature-request'; +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => jest.fn(), +})); + +const CHAIN_ID_MOCK = '0x539'; + +const TRANSACTION_DATA_MOCK = { + chainId: CHAIN_ID_MOCK, +}; + const baseProps = { clearConfirmTransaction: () => jest.fn(), cancel: () => jest.fn(), @@ -37,8 +27,11 @@ const baseProps = { showRejectTransactionsConfirmationModal: () => jest.fn(), sign: () => jest.fn(), }; + const mockStore = { + ...mockState, metamask: { + ...mockState.metamask, ...mockNetworkState({ chainId: '0x539', nickname: 'Localhost 8545', @@ -61,12 +54,13 @@ const mockStore = { metadata: { name: 'John Doe', keyring: { - type: 'HD Key Tree', + type: 'Custody', }, }, options: {}, methods: ETH_EOA_METHODS, type: EthAccountType.Eoa, + balance: 0, }, }, selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', @@ -94,15 +88,6 @@ const mockStore = { }, }, }; -jest.mock('react-redux', () => { - const actual = jest.requireActual('react-redux'); - - return { - ...actual, - useSelector: jest.fn(), - useDispatch: () => jest.fn(), - }; -}); const mockCustodySignFn = jest.fn(); @@ -114,62 +99,13 @@ jest.mock('../../../../hooks/useMMICustodySignMessage', () => ({ jest.mock('@metamask-institutional/extension'); -const generateUseSelectorRouter = (opts) => (selector) => { - const mockSelectedInternalAccount = getSelectedInternalAccount(opts); - - switch (selector) { - case getProviderConfig: - return getProviderConfig(opts); - case getCurrentCurrency: - return opts.metamask.currentCurrency; - case getNativeCurrency: - return getProviderConfig(opts).ticker; - case getTotalUnapprovedMessagesCount: - return opts.metamask.unapprovedTypedMessagesCount; - case getPreferences: - return opts.metamask.preferences; - case conversionRateSelector: - return opts.metamask.currencyRates[getProviderConfig(opts).ticker] - ?.conversionRate; - case getSelectedAccount: - return mockSelectedInternalAccount; - case getInternalAccounts: - return Object.values(opts.metamask.internalAccounts.accounts); - case getMemoizedMetaMaskInternalAccounts: - return Object.values(opts.metamask.internalAccounts.accounts); - case getMemoizedAddressBook: - return []; - case accountsWithSendEtherInfoSelector: - return Object.values(opts.metamask.internalAccounts.accounts).map( - (internalAccount) => { - return { - ...internalAccount, - ...(opts.metamask.accounts[internalAccount.address] ?? {}), - balance: - opts.metamask.accounts[internalAccount.address]?.balance ?? 0, - }; - }, - ); - case getAccountType: - return 'custody'; - case unconfirmedTransactionsHashSelector: - return {}; - case pendingApprovalsSortedSelector: - return Object.values(opts.metamask.pendingApprovals); - case getNetworkConfigurationsByChainId: - return opts.metamask.networkConfigurationsByChainId; - default: - return undefined; - } -}; describe('Signature Request Component', () => { - const store = configureMockStore()(mockState); + const store = configureMockStore()(mockStore); describe('render', () => { let messageData; beforeEach(() => { - useSelector.mockImplementation(generateUseSelectorRouter(mockStore)); messageData = { domain: { chainId: 97, @@ -219,35 +155,39 @@ describe('Signature Request Component', () => { }); it('should match snapshot when we want to switch to fiat', () => { - useSelector.mockImplementation( - generateUseSelectorRouter({ - ...mockStore, - metamask: { - ...mockStore.metamask, - currencyRates: { - ...mockStore.metamask.currencyRates, - ETH: { - ...(mockStore.metamask.currencyRates.ETH || {}), - conversionRate: 231.06, - }, + const storeOverride = configureMockStore()({ + ...mockStore, + metamask: { + ...mockStore.metamask, + ...mockNetworkState({ + chainId: CHAIN_ID_MOCK, + }), + currencyRates: { + ...mockStore.metamask.currencyRates, + ETH: { + ...(mockStore.metamask.currencyRates.ETH || {}), + conversionRate: 231.06, }, }, - }), - ); + }, + }); + const msgParams = { from: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', data: JSON.stringify(messageData), version: 'V4', origin: 'test', }; + const { container } = renderWithProvider( , - store, + storeOverride, ); expect(container).toMatchSnapshot(); @@ -260,10 +200,12 @@ describe('Signature Request Component', () => { version: 'V4', origin: 'test', }; + const { container } = renderWithProvider( , @@ -280,10 +222,12 @@ describe('Signature Request Component', () => { version: 'V4', origin: 'test', }; + const { queryByTestId } = renderWithProvider( , @@ -308,6 +252,7 @@ describe('Signature Request Component', () => { , @@ -321,29 +266,30 @@ describe('Signature Request Component', () => { }); it('should not render a reject multiple requests link if there is not multiple requests', () => { - useSelector.mockImplementation( - generateUseSelectorRouter({ - ...mockStore, - metamask: { - ...mockStore.metamask, - unapprovedTypedMessagesCount: 0, - }, - }), - ); + const storeOverride = configureMockStore()({ + ...mockStore, + metamask: { + ...mockStore.metamask, + unapprovedTypedMessagesCount: 0, + }, + }); + const msgParams = { from: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', data: JSON.stringify(messageData), version: 'V4', origin: 'test', }; + const { container } = renderWithProvider( , - store, + storeOverride, ); expect( @@ -358,10 +304,12 @@ describe('Signature Request Component', () => { version: 'V4', origin: 'test', }; + const { container } = renderWithProvider( , @@ -384,6 +332,7 @@ describe('Signature Request Component', () => { , @@ -408,6 +357,7 @@ describe('Signature Request Component', () => { , @@ -429,6 +379,7 @@ describe('Signature Request Component', () => { { { }); it('should render a warning when the selected account is not the one being used to sign', () => { - const msgParams = { - from: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', - data: JSON.stringify(messageData), - version: 'V4', - origin: 'test', - }; - - useSelector.mockImplementation( - generateUseSelectorRouter({ - ...mockStore, - metamask: { - ...mockStore.metamask, + const storeOverride = configureMockStore()({ + ...mockStore, + metamask: { + ...mockStore.metamask, + accounts: { + ...mockStore.metamask.accounts, + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0x0', + name: 'Account 1', + }, + '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5': { + address: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', + balance: '0x0', + name: 'Account 2', + }, + }, + internalAccounts: { accounts: { - ...mockStore.metamask.accounts, - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1': { address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - balance: '0x0', - name: 'Account 1', - }, - '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5': { - address: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', - balance: '0x0', - name: 'Account 2', - }, - }, - internalAccounts: { - accounts: { - 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1', - metadata: { - name: 'Account 1', - keyring: { - type: 'HD Key Tree', - }, + id: 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1', + metadata: { + name: 'Account 1', + keyring: { + type: 'HD Key Tree', }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, }, - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 2', - keyring: { - type: 'HD Key Tree', - }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Account 2', + keyring: { + type: 'HD Key Tree', }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, }, - selectedAccount: 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1', }, + selectedAccount: 'b7e813d6-e31c-4bad-8615-8d4eff9f44f1', }, - }), - ); + }, + }); + + const msgParams = { + from: '0xd8f6a2ffb0fc5952d16c9768b71cfd35b6399aa5', + data: JSON.stringify(messageData), + version: 'V4', + origin: 'test', + }; const { container } = renderWithProvider( , - store, + storeOverride, ); expect( @@ -571,6 +522,7 @@ describe('Signature Request Component', () => { {...baseProps} conversionRate={null} txData={{ + ...TRANSACTION_DATA_MOCK, msgParams, securityAlertResponse: { resultType: 'Malicious', @@ -602,6 +554,7 @@ describe('Signature Request Component', () => { , @@ -610,7 +563,9 @@ describe('Signature Request Component', () => { const rejectRequestsLink = getByTestId('page-container-footer-next'); fireEvent.click(rejectRequestsLink); - expect(mockCustodySignFn).toHaveBeenCalledWith({ msgParams }); + expect(mockCustodySignFn).toHaveBeenCalledWith( + expect.objectContaining({ msgParams }), + ); }); }); }); diff --git a/ui/pages/confirmations/confirm-signature-request/index.test.js b/ui/pages/confirmations/confirm-signature-request/index.test.js index e0c7c7c8bbeb..f634bf40845c 100644 --- a/ui/pages/confirmations/confirm-signature-request/index.test.js +++ b/ui/pages/confirmations/confirm-signature-request/index.test.js @@ -7,13 +7,21 @@ import { CHAIN_IDS } from '../../../../shared/constants/network'; import { mockNetworkState } from '../../../../test/stub/networks'; import ConfTx from '.'; +const CHAIN_ID_MOCK = CHAIN_IDS.GOERLI; + const mockState = { metamask: { + ...mockNetworkState({ + chainId: CHAIN_IDS.GOERLI, + nickname: 'Goerli test network', + ticker: undefined, + }), unapprovedPersonalMsgs: {}, unapprovedPersonalMsgCount: 0, unapprovedTypedMessages: { 267460284130106: { id: 267460284130106, + chainId: CHAIN_ID_MOCK, msgParams: { data: '{"domain":{"chainId":"5","name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Group":[{"name":"name","type":"string"},{"name":"members","type":"Person[]"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}', from: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', @@ -27,11 +35,6 @@ const mockState = { }, }, unapprovedTypedMessagesCount: 1, - ...mockNetworkState({ - chainId: CHAIN_IDS.GOERLI, - nickname: 'Goerli test network', - ticker: undefined, - }), currencyRates: {}, keyrings: [], subjectMetadata: {}, diff --git a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts index 1b6412d66614..2f26fcefbee9 100644 --- a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts @@ -15,7 +15,6 @@ import { import { Alert } from '../../../../ducks/confirm-alerts/confirm-alerts'; import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { getCurrentChainId } from '../../../../selectors'; import { SIGNATURE_TRANSACTION_TYPES, REDESIGN_DEV_TRANSACTION_TYPES, @@ -51,7 +50,6 @@ type SecurityAlertResponsesState = { const useBlockaidAlerts = (): Alert[] => { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); - const selectorChainId = useSelector(getCurrentChainId); const securityAlertId = ( currentConfirmation?.securityAlertResponse as SecurityAlertResponse @@ -99,9 +97,7 @@ const useBlockaidAlerts = (): Alert[] => { const reportData = { blockNumber: block, blockaidVersion: BlockaidPackage.version, - chain: (NETWORK_TO_NAME_MAP as Record)[ - chainId ?? selectorChainId - ], + chain: (NETWORK_TO_NAME_MAP as Record)[chainId], classification: isFailedResultType ? 'error' : reason, domain: origin ?? msgParams?.origin ?? origin, jsonRpcMethod: type, diff --git a/ui/pages/confirmations/hooks/useConfirmationNetworkInfo.ts b/ui/pages/confirmations/hooks/useConfirmationNetworkInfo.ts index 4c6dddfa7a5a..f0463287ef34 100644 --- a/ui/pages/confirmations/hooks/useConfirmationNetworkInfo.ts +++ b/ui/pages/confirmations/hooks/useConfirmationNetworkInfo.ts @@ -6,30 +6,23 @@ import { NETWORK_TO_NAME_MAP, } from '../../../../shared/constants/network'; -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, -} from '../../../selectors'; - import { useI18nContext } from '../../../hooks/useI18nContext'; import { useConfirmContext } from '../context/confirm'; +import { selectNetworkConfigurationByChainId } from '../../../selectors'; function useConfirmationNetworkInfo() { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); - const currentChainId = useSelector(getCurrentChainId); + const chainId = currentConfirmation?.chainId as Hex; + + const networkConfiguration = useSelector((state) => + selectNetworkConfigurationByChainId(state, chainId), + ); let networkDisplayName = ''; let networkImageUrl = ''; if (currentConfirmation) { - // use the current confirmation chainId, else use the current network chainId - const chainId = - (currentConfirmation?.chainId as Hex | undefined) ?? currentChainId; - - const networkConfiguration = networkConfigurations[chainId]; - networkDisplayName = networkConfiguration?.name ?? NETWORK_TO_NAME_MAP[chainId as keyof typeof NETWORK_TO_NAME_MAP] ?? diff --git a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts index dff0103fbe21..9c98432918e2 100644 --- a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts +++ b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useContext } from 'react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { TokenStandard } from '../../../../shared/constants/transaction'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { TokenDetailsERC20 } from '../utils/token'; @@ -30,7 +31,7 @@ describe('useTrackERC20WithoutDecimalInformation', () => { }); renderHook(() => - useTrackERC20WithoutDecimalInformation('0x5', { + useTrackERC20WithoutDecimalInformation(CHAIN_IDS.MAINNET, '0x5', { standard: TokenStandard.ERC20, } as TokenDetailsERC20), ); diff --git a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts index fa6a5e620fc4..266048e11134 100644 --- a/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts +++ b/ui/pages/confirmations/hooks/useTrackERC20WithoutDecimalInformation.ts @@ -1,4 +1,3 @@ -import { useSelector } from 'react-redux'; import { useContext, useEffect } from 'react'; import { Hex } from '@metamask/utils'; @@ -10,23 +9,23 @@ import { } from '../../../../shared/constants/metametrics'; import { TokenStandard } from '../../../../shared/constants/transaction'; import { MetaMetricsContext } from '../../../contexts/metametrics'; -import { getCurrentChainId } from '../../../selectors'; 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 = MetaMetricsEventLocation.SignatureConfirmation, ) => { const trackEvent = useContext(MetaMetricsContext); - const chainId = useSelector(getCurrentChainId); useEffect(() => { if (chainId === undefined || tokenDetails === undefined) { diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index bb7ef5f796d7..4ea9f20371ab 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -700,6 +700,28 @@ export const getNetworkConfigurationsByChainId = createDeepEqualSelector( (networkConfigurationsByChainId) => networkConfigurationsByChainId, ); +/** + * @type (state: any, chainId: string) => import('@metamask/network-controller').NetworkConfiguration + */ +export const selectNetworkConfigurationByChainId = createSelector( + getNetworkConfigurationsByChainId, + (_state, chainId) => chainId, + (networkConfigurationsByChainId, chainId) => + networkConfigurationsByChainId[chainId], +); + +export const selectDefaultRpcEndpointByChainId = createSelector( + selectNetworkConfigurationByChainId, + (networkConfiguration) => { + if (!networkConfiguration) { + return undefined; + } + + const { defaultRpcEndpointIndex, rpcEndpoints } = networkConfiguration; + return rpcEndpoints[defaultRpcEndpointIndex]; + }, +); + export function getRequestingNetworkInfo(state, chainIds) { // If chainIds is undefined, set it to an empty array let processedChainIds = chainIds === undefined ? [] : chainIds; From cdfaa42e0d4948a6c195d3e24d0caa9342cb35af Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:17:07 +0100 Subject: [PATCH 54/56] test: [POM] Migrate edit network rpc e2e tests and create related page class functions (#28161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Migrate `test/e2e/tests/network/multi-rpc.spec.ts` e2e tests to Page Object Model (POM) pattern. - Created edit network related page classes and functions - Removed unnecessary delay by correctly implementing page object functions - Objective of this PR is to improve test stability and maintainability, it also reduced flakiness. - update: it also fixes the flaky test Update Network [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27155?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28163 ## **Manual testing steps** Check code readability, make sure tests pass. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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. --------- Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com> --- .../page-objects/pages/dialog/edit-network.ts | 54 ++++ .../pages/dialog/select-network.ts | 92 ++++-- test/e2e/page-objects/pages/homepage.ts | 86 ++++-- .../onboarding-privacy-settings-page.ts | 30 +- test/e2e/tests/network/multi-rpc.spec.ts | 283 ++++++------------ 5 files changed, 304 insertions(+), 241 deletions(-) create mode 100644 test/e2e/page-objects/pages/dialog/edit-network.ts diff --git a/test/e2e/page-objects/pages/dialog/edit-network.ts b/test/e2e/page-objects/pages/dialog/edit-network.ts new file mode 100644 index 000000000000..09a1dd70a5f9 --- /dev/null +++ b/test/e2e/page-objects/pages/dialog/edit-network.ts @@ -0,0 +1,54 @@ +import { Driver } from '../../../webdriver/driver'; + +class EditNetworkModal { + private driver: Driver; + + private readonly editModalNetworkNameInput = + '[data-testid="network-form-network-name"]'; + + private readonly editModalRpcDropDownButton = + '[data-testid="test-add-rpc-drop-down"]'; + + private readonly editModalSaveButton = { + text: 'Save', + tag: 'button', + }; + + constructor(driver: Driver) { + this.driver = driver; + } + + async check_pageIsLoaded(): Promise { + try { + await this.driver.waitForMultipleSelectors([ + this.editModalNetworkNameInput, + this.editModalRpcDropDownButton, + this.editModalSaveButton, + ]); + } catch (e) { + console.log( + 'Timeout while waiting for select network dialog to be loaded', + e, + ); + throw e; + } + console.log('Edit network dialog is loaded'); + } + + /** + * Selects an RPC from the dropdown in the edit network modal. + * + * @param rpcName - The name of the RPC to select. + */ + async selectRPCInEditNetworkModal(rpcName: string): Promise { + console.log(`Select RPC ${rpcName} in edit network modal`); + await this.driver.clickElement(this.editModalRpcDropDownButton); + await this.driver.clickElement({ + text: rpcName, + tag: 'button', + }); + await this.driver.clickElementAndWaitToDisappear(this.editModalSaveButton); + } +} + +export default EditNetworkModal; diff --git a/test/e2e/page-objects/pages/dialog/select-network.ts b/test/e2e/page-objects/pages/dialog/select-network.ts index 2c399a4118d8..bc20c42855ae 100644 --- a/test/e2e/page-objects/pages/dialog/select-network.ts +++ b/test/e2e/page-objects/pages/dialog/select-network.ts @@ -3,15 +3,15 @@ import { Driver } from '../../../webdriver/driver'; class SelectNetwork { private driver: Driver; - private networkName: string | undefined; - - private readonly addNetworkButton = { - tag: 'button', - text: 'Add a custom network', - }; + private readonly addNetworkButton = '[data-testid="test-add-button"]'; private readonly closeButton = 'button[aria-label="Close"]'; + private readonly editNetworkButton = + '[data-testid="network-list-item-options-edit"]'; + + private readonly rpcUrlItem = '.select-rpc-url__item'; + private readonly searchInput = '[data-testid="network-redesign-modal-search-input"]'; @@ -20,6 +20,11 @@ class SelectNetwork { tag: 'h4', }; + private readonly selectRpcMessage = { + text: 'Select RPC URL', + tag: 'h4', + }; + private readonly toggleButton = '.toggle-button > div'; constructor(driver: Driver) { @@ -42,15 +47,9 @@ class SelectNetwork { console.log('Select network dialog is loaded'); } - async selectNetworkName(networkName: string): Promise { - console.log(`Click ${networkName}`); - this.networkName = `[data-testid="${networkName}"]`; - await this.driver.clickElementAndWaitToDisappear(this.networkName); - } - - async addNewNetwork(): Promise { - console.log('Click Add network'); - await this.driver.clickElement(this.addNetworkButton); + async clickAddButton(): Promise { + console.log('Click Add Button'); + await this.driver.clickElementAndWaitToDisappear(this.addNetworkButton); } async clickCloseButton(): Promise { @@ -58,21 +57,68 @@ class SelectNetwork { await this.driver.clickElementAndWaitToDisappear(this.closeButton); } - async toggleShowTestNetwork(): Promise { - console.log('Toggle show test network in select network dialog'); - await this.driver.clickElement(this.toggleButton); - } - async fillNetworkSearchInput(networkName: string): Promise { console.log(`Fill network search input with ${networkName}`); await this.driver.fill(this.searchInput, networkName); } - async clickAddButton(): Promise { - console.log('Click Add Button'); + async openEditNetworkModal(): Promise { + console.log('Open edit network modal'); + await this.driver.clickElementAndWaitToDisappear(this.editNetworkButton); + } + + async openNetworkListOptions(chainId: string): Promise { + console.log(`Open network options for ${chainId} in network dialog`); + await this.driver.clickElement( + `[data-testid="network-list-item-options-button-${chainId}"]`, + ); + } + + async openNetworkRPC(chainId: string): Promise { + console.log(`Open network RPC ${chainId}`); await this.driver.clickElementAndWaitToDisappear( - '[data-testid="test-add-button"]', + `[data-testid="network-rpc-name-button-${chainId}"]`, + ); + await this.driver.waitForSelector(this.selectRpcMessage); + } + + async selectNetworkName(networkName: string): Promise { + console.log(`Click ${networkName}`); + const networkNameItem = `[data-testid="${networkName}"]`; + await this.driver.clickElementAndWaitToDisappear(networkNameItem); + } + + async selectRPC(rpcName: string): Promise { + console.log(`Select RPC ${rpcName} for network`); + await this.driver.waitForSelector(this.selectRpcMessage); + await this.driver.clickElementAndWaitToDisappear({ + text: rpcName, + tag: 'button', + }); + } + + async toggleShowTestNetwork(): Promise { + console.log('Toggle show test network in select network dialog'); + await this.driver.clickElement(this.toggleButton); + } + + async check_networkRPCNumber(expectedNumber: number): Promise { + console.log( + `Wait for ${expectedNumber} RPC URLs to be displayed in select network dialog`, ); + await this.driver.wait(async () => { + const rpcNumber = await this.driver.findElements(this.rpcUrlItem); + return rpcNumber.length === expectedNumber; + }, 10000); + console.log(`${expectedNumber} RPC URLs found in select network dialog`); + } + + async check_rpcIsSelected(rpcName: string): Promise { + console.log(`Check RPC ${rpcName} is selected in network dialog`); + await this.driver.waitForSelector({ + text: rpcName, + tag: 'button', + }); } } diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index 7c322b0f2cbb..101e6f9de83c 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -22,6 +22,11 @@ class HomePage { css: '.mm-banner-alert', }; + private readonly closeUseNetworkNotificationModalButton = { + text: 'Got it', + tag: 'h6', + }; + private readonly completedTransactions = '[data-testid="activity-list-item"]'; private readonly confirmedTransactions = { @@ -34,6 +39,8 @@ class HomePage { css: '.transaction-status-label--failed', }; + private readonly popoverBackground = '.popover-bg'; + private readonly sendButton = '[data-testid="eth-overview-send"]'; private readonly tokensTab = '[data-testid="account-overview__asset-tab"]'; @@ -60,8 +67,16 @@ class HomePage { console.log('Home page is loaded'); } - async startSendFlow(): Promise { - await this.driver.clickElement(this.sendButton); + async closeUseNetworkNotificationModal(): Promise { + // We need to use clickElementSafe + assertElementNotPresent as sometimes the network dialog doesn't appear, as per this issue (#25788) + // TODO: change the 2 actions for clickElementAndWaitToDisappear, once the issue is fixed + await this.driver.assertElementNotPresent(this.popoverBackground); + await this.driver.clickElementSafe( + this.closeUseNetworkNotificationModalButton, + ); + await this.driver.assertElementNotPresent( + this.closeUseNetworkNotificationModalButton, + ); } async goToActivityList(): Promise { @@ -69,13 +84,6 @@ class HomePage { await this.driver.clickElement(this.activityTab); } - async check_basicFunctionalityOffWarnigMessageIsDisplayed(): Promise { - console.log( - 'Check if basic functionality off warning message is displayed on homepage', - ); - await this.driver.waitForSelector(this.basicFunctionalityOffWarningMessage); - } - async goToNFTList(): Promise { console.log(`Open NFT tab on homepage`); await this.driver.clickElement(this.nftTab); @@ -85,6 +93,10 @@ class HomePage { await this.driver.clickElement(this.nftIconOnActivityList); } + async startSendFlow(): Promise { + await this.driver.clickElement(this.sendButton); + } + /** * Checks if the toaster message for adding a network is displayed on the homepage. * @@ -100,6 +112,37 @@ class HomePage { }); } + async check_basicFunctionalityOffWarnigMessageIsDisplayed(): Promise { + console.log( + 'Check if basic functionality off warning message is displayed on homepage', + ); + await this.driver.waitForSelector(this.basicFunctionalityOffWarningMessage); + } + + /** + * This function checks the specified number of completed transactions are displayed in the activity list on the homepage. + * It waits up to 10 seconds for the expected number of completed transactions to be visible. + * + * @param expectedNumber - The number of completed transactions expected to be displayed in the activity list. Defaults to 1. + * @returns A promise that resolves if the expected number of completed transactions is displayed within the timeout period. + */ + async check_completedTxNumberDisplayedInActivity( + expectedNumber: number = 1, + ): Promise { + console.log( + `Wait for ${expectedNumber} completed transactions to be displayed in activity list`, + ); + await this.driver.wait(async () => { + const completedTxs = await this.driver.findElements( + this.completedTransactions, + ); + return completedTxs.length === expectedNumber; + }, 10000); + console.log( + `${expectedNumber} completed transactions found in activity list on homepage`, + ); + } + /** * This function checks if the specified number of confirmed transactions are displayed in the activity list on homepage. * It waits up to 10 seconds for the expected number of confirmed transactions to be visible. @@ -125,27 +168,20 @@ class HomePage { } /** - * This function checks the specified number of completed transactions are displayed in the activity list on the homepage. - * It waits up to 10 seconds for the expected number of completed transactions to be visible. + * Checks if the toaster message for editing a network is displayed on the homepage. * - * @param expectedNumber - The number of completed transactions expected to be displayed in the activity list. Defaults to 1. - * @returns A promise that resolves if the expected number of completed transactions is displayed within the timeout period. + * @param networkName - The name of the network that was edited. */ - async check_completedTxNumberDisplayedInActivity( - expectedNumber: number = 1, + async check_editNetworkMessageIsDisplayed( + networkName: string, ): Promise { console.log( - `Wait for ${expectedNumber} completed transactions to be displayed in activity list`, - ); - await this.driver.wait(async () => { - const completedTxs = await this.driver.findElements( - this.completedTransactions, - ); - return completedTxs.length === expectedNumber; - }, 10000); - console.log( - `${expectedNumber} completed transactions found in activity list on homepage`, + `Check the toaster message for editing network ${networkName} is displayed on homepage`, ); + await this.driver.waitForSelector({ + tag: 'h6', + text: `“${networkName}” was successfully edited!`, + }); } /** diff --git a/test/e2e/page-objects/pages/onboarding/onboarding-privacy-settings-page.ts b/test/e2e/page-objects/pages/onboarding/onboarding-privacy-settings-page.ts index dac2ab447710..e8288edb98cb 100644 --- a/test/e2e/page-objects/pages/onboarding/onboarding-privacy-settings-page.ts +++ b/test/e2e/page-objects/pages/onboarding/onboarding-privacy-settings-page.ts @@ -134,8 +134,7 @@ class OnboardingPrivacySettingsPage { this.confirmAddCustomNetworkButton, ); // Navigate back to default privacy settings - await this.driver.clickElement(this.categoryBackButton); - await this.driver.waitForElementToStopMoving(this.categoryBackButton); + await this.navigateBackToSettingsPage(); } /** @@ -152,6 +151,16 @@ class OnboardingPrivacySettingsPage { ); } + /** + * Navigate back to the onboarding privacy settings page. + */ + async navigateBackToSettingsPage(): Promise { + console.log('Navigate back to onboarding privacy settings page'); + // Wait until the onboarding carousel has stopped moving otherwise the click has no effect. + await this.driver.clickElement(this.categoryBackButton); + await this.driver.waitForElementToStopMoving(this.categoryBackButton); + } + async navigateToGeneralSettings(): Promise { console.log('Navigate to general settings'); await this.check_pageIsLoaded(); @@ -159,6 +168,17 @@ class OnboardingPrivacySettingsPage { await this.driver.waitForSelector(this.generalSettingsMessage); } + /** + * Open the edit network modal for a given network name. + * + * @param networkName - The name of the network to open the edit modal for. + */ + async openEditNetworkModal(networkName: string): Promise { + console.log(`Open edit network modal for ${networkName}`); + await this.driver.clickElement({ text: networkName, tag: 'p' }); + await this.driver.waitForSelector(this.addRpcUrlDropDown); + } + /** * Go to assets settings and toggle options, then navigate back. */ @@ -172,8 +192,7 @@ class OnboardingPrivacySettingsPage { await this.driver.findClickableElements(this.assetsPrivacyToggle) ).map((toggle) => toggle.click()), ); - await this.driver.clickElement(this.categoryBackButton); - await this.driver.waitForElementToStopMoving(this.categoryBackButton); + await this.navigateBackToSettingsPage(); } /** @@ -186,8 +205,7 @@ class OnboardingPrivacySettingsPage { await this.driver.waitForSelector(this.basicFunctionalityTurnOffMessage); await this.driver.clickElement(this.basicFunctionalityCheckbox); await this.driver.clickElement(this.basicFunctionalityTurnOffButton); - await this.driver.clickElement(this.categoryBackButton); - await this.driver.waitForElementToStopMoving(this.categoryBackButton); + await this.navigateBackToSettingsPage(); } } diff --git a/test/e2e/tests/network/multi-rpc.spec.ts b/test/e2e/tests/network/multi-rpc.spec.ts index 6fc7025f5dbc..362a4c3e29e4 100644 --- a/test/e2e/tests/network/multi-rpc.spec.ts +++ b/test/e2e/tests/network/multi-rpc.spec.ts @@ -1,20 +1,27 @@ import { strict as assert } from 'assert'; import { Suite } from 'mocha'; import FixtureBuilder from '../../fixture-builder'; -import { - defaultGanacheOptions, - importSRPOnboardingFlow, - regularDelayMs, - TEST_SEED_PHRASE, - unlockWallet, - withFixtures, -} from '../../helpers'; +import { defaultGanacheOptions, withFixtures } from '../../helpers'; import { Driver } from '../../webdriver/driver'; import { Mockttp } from '../../mock-e2e'; import { expectMockRequest, expectNoMockRequest, } from '../../helpers/mock-server'; +import EditNetworkModal from '../../page-objects/pages/dialog/edit-network'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import HomePage from '../../page-objects/pages/homepage'; +import OnboardingCompletePage from '../../page-objects/pages/onboarding/onboarding-complete-page'; +import OnboardingPrivacySettingsPage from '../../page-objects/pages/onboarding/onboarding-privacy-settings-page'; +import SelectNetwork from '../../page-objects/pages/dialog/select-network'; +import { + loginWithoutBalanceValidation, + loginWithBalanceValidation, +} from '../../page-objects/flows/login.flow'; +import { + completeImportSRPOnboardingFlow, + importSRPOnboardingFlow, +} from '../../page-objects/flows/onboarding.flow'; describe('MultiRpc:', function (this: Suite) { it('should migrate to multi rpc @no-mmi', async function () { @@ -73,36 +80,18 @@ describe('MultiRpc:', function (this: Suite) { }, async ({ driver }: { driver: Driver }) => { - const password = 'password'; - - await driver.navigate(); - - await importSRPOnboardingFlow(driver, TEST_SEED_PHRASE, password); + await completeImportSRPOnboardingFlow(driver); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed(); - await driver.delay(regularDelayMs); - - // complete - await driver.clickElement('[data-testid="onboarding-complete-done"]'); - - // pin extension - await driver.clickElement('[data-testid="pin-extension-next"]'); - await driver.clickElement('[data-testid="pin-extension-done"]'); - - // pin extension walkthrough screen - await driver.findElement('[data-testid="account-menu-icon"]'); - - // Avoid a stale element error - await driver.delay(regularDelayMs); - await driver.clickElement('[data-testid="network-display"]'); - - await driver.clickElement( - '[data-testid="network-rpc-name-button-0xa4b1"]', - ); - - const menuItems = await driver.findElements('.select-rpc-url__item'); + await new HeaderNavbar(driver).clickSwitchNetworkDropDown(); + const selectNetworkDialog = new SelectNetwork(driver); + await selectNetworkDialog.check_pageIsLoaded(); // check rpc number - assert.equal(menuItems.length, 2); + await selectNetworkDialog.openNetworkRPC('0xa4b1'); + await selectNetworkDialog.check_networkRPCNumber(2); }, ); }); @@ -173,7 +162,9 @@ describe('MultiRpc:', function (this: Suite) { }, async ({ driver, mockedEndpoint }) => { - await unlockWallet(driver); + await loginWithoutBalanceValidation(driver); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); const usedUrlBeforeSwitch = await mockedEndpoint[1].getSeenRequests(); @@ -189,28 +180,21 @@ describe('MultiRpc:', function (this: Suite) { // check that requests are sent on the background for the rpc https://responsive-rpc.test/ await expectNoMockRequest(driver, mockedEndpoint[0], { timeout: 3000 }); - // Avoid a stale element error - await driver.delay(regularDelayMs); - await driver.clickElement('[data-testid="network-display"]'); - - // select second rpc - await driver.clickElement( - '[data-testid="network-rpc-name-button-0xa4b1"]', - ); - - await driver.delay(regularDelayMs); - await driver.clickElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + const selectNetworkDialog = new SelectNetwork(driver); + await selectNetworkDialog.check_pageIsLoaded(); + await selectNetworkDialog.openNetworkRPC('0xa4b1'); + await selectNetworkDialog.check_networkRPCNumber(2); - await driver.delay(regularDelayMs); - await driver.clickElement('[data-testid="network-display"]'); + // select second rpc for Arbitrum network in the network dialog + await selectNetworkDialog.selectRPC('Arbitrum mainnet 2'); + await homePage.check_pageIsLoaded(); + await headerNavbar.clickSwitchNetworkDropDown(); - const arbitrumRpcUsed = await driver.findElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); + // check that the second rpc is selected in the network dialog + await selectNetworkDialog.check_pageIsLoaded(); + await selectNetworkDialog.check_rpcIsSelected('Arbitrum mainnet 2'); const usedUrl = await mockedEndpoint[0].getSeenRequests(); // check the url first request send on the background to the mocked rpc after switch @@ -218,9 +202,6 @@ describe('MultiRpc:', function (this: Suite) { // check that requests are sent on the background for the url https://responsive-rpc.test/ await expectMockRequest(driver, mockedEndpoint[0], { timeout: 3000 }); - - const existRpcUsed = arbitrumRpcUsed !== undefined; - assert.equal(existRpcUsed, true, 'Second Rpc is used'); }, ); }); @@ -280,53 +261,33 @@ describe('MultiRpc:', function (this: Suite) { testSpecificMock: mockRPCURLAndChainId, }, - async ({ driver }: { driver: Driver }) => { - await unlockWallet(driver); - - // Avoid a stale element error - await driver.delay(regularDelayMs); - await driver.clickElement('[data-testid="network-display"]'); - - // Go to Edit Menu - await driver.clickElement( - '[data-testid="network-list-item-options-button-0xa4b1"]', - ); - await driver.clickElement( - '[data-testid="network-list-item-options-edit"]', + async ({ driver, ganacheServer }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + const selectNetworkDialog = new SelectNetwork(driver); + await selectNetworkDialog.check_pageIsLoaded(); + + // go to Edit Menu for Arbitrum network and select the second rpc + await selectNetworkDialog.openNetworkListOptions('0xa4b1'); + await selectNetworkDialog.openEditNetworkModal(); + + const editNetworkModal = new EditNetworkModal(driver); + await editNetworkModal.check_pageIsLoaded(); + await editNetworkModal.selectRPCInEditNetworkModal( + 'Arbitrum mainnet 2', ); - await driver.clickElement('[data-testid="test-add-rpc-drop-down"]'); - await driver.delay(regularDelayMs); - await driver.clickElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); + // validate the network was successfully edited + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_editNetworkMessageIsDisplayed('Arbitrum One'); + await homePage.closeUseNetworkNotificationModal(); - await driver.clickElement({ - text: 'Save', - tag: 'button', - }); - - // Validate the network was edited - const networkEdited = await driver.isElementPresent({ - text: '“Arbitrum One” was successfully edited!', - }); - assert.equal( - networkEdited, - true, - '“Arbitrum One” was successfully edited!', - ); - - await driver.delay(regularDelayMs); - await driver.clickElement('[data-testid="network-display"]'); - - const arbitrumRpcUsed = await driver.findElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); - - const existRpcUsed = arbitrumRpcUsed !== undefined; - assert.equal(existRpcUsed, true, 'Second Rpc is used'); + // check that the second rpc is selected in the network dialog + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.check_pageIsLoaded(); + await selectNetworkDialog.check_rpcIsSelected('Arbitrum mainnet 2'); }, ); }); @@ -387,93 +348,41 @@ describe('MultiRpc:', function (this: Suite) { }, async ({ driver }: { driver: Driver }) => { - const password = 'password'; - - await driver.navigate(); - - await importSRPOnboardingFlow(driver, TEST_SEED_PHRASE, password); - - await driver.delay(regularDelayMs); - - // go to advanced settigns - await driver.clickElementAndWaitToDisappear({ - text: 'Manage default privacy settings', - }); - - await driver.clickElement({ - text: 'General', - }); - - // open edit modal - await driver.clickElement({ - text: 'arbitrum-mainnet.infura.io', - tag: 'p', - }); - - await driver.clickElement('[data-testid="test-add-rpc-drop-down"]'); - - await driver.delay(regularDelayMs); - await driver.clickElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); - - await driver.clickElementAndWaitToDisappear({ - text: 'Save', - tag: 'button', - }); - - await driver.clickElement('[data-testid="category-back-button"]'); - - await driver.clickElement( - '[data-testid="privacy-settings-back-button"]', + await importSRPOnboardingFlow(driver); + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.navigateToDefaultPrivacySettings(); + const onboardingPrivacySettingsPage = new OnboardingPrivacySettingsPage( + driver, ); + await onboardingPrivacySettingsPage.check_pageIsLoaded(); + await onboardingPrivacySettingsPage.navigateToGeneralSettings(); - await driver.clickElementAndWaitToDisappear({ - text: 'Done', - tag: 'button', - }); - - await driver.clickElement({ - text: 'Next', - tag: 'button', - }); - - await driver.clickElementAndWaitToDisappear({ - text: 'Done', - tag: 'button', - }); - - // Validate the network was edited - const networkEdited = await driver.isElementPresent({ - text: '“Arbitrum One” was successfully edited!', - }); - assert.equal( - networkEdited, - true, - '“Arbitrum One” was successfully edited!', + // open edit network modal during onboarding and select the second rpc + await onboardingPrivacySettingsPage.openEditNetworkModal( + 'Arbitrum One', ); - // Ensures popover backround doesn't kill test - await driver.assertElementNotPresent('.popover-bg'); - - // We need to use clickElementSafe + assertElementNotPresent as sometimes the network dialog doesn't appear, as per this issue (#27870) - // TODO: change the 2 actions for clickElementAndWaitToDisappear, once the issue is fixed - await driver.clickElementSafe({ tag: 'h6', text: 'Got it' }); - - await driver.assertElementNotPresent({ - tag: 'h6', - text: 'Got it', - }); - - await driver.clickElement('[data-testid="network-display"]'); - - const arbitrumRpcUsed = await driver.findElement({ - text: 'Arbitrum mainnet 2', - tag: 'button', - }); - - const existRpcUsed = arbitrumRpcUsed !== undefined; - assert.equal(existRpcUsed, true, 'Second Rpc is used'); + const editNetworkModal = new EditNetworkModal(driver); + await editNetworkModal.check_pageIsLoaded(); + await editNetworkModal.selectRPCInEditNetworkModal( + 'Arbitrum mainnet 2', + ); + await onboardingPrivacySettingsPage.navigateBackToSettingsPage(); + await onboardingPrivacySettingsPage.check_pageIsLoaded(); + await onboardingPrivacySettingsPage.navigateBackToOnboardingCompletePage(); + + // finish onboarding and check the network successfully edited message is displayed + await onboardingCompletePage.completeOnboarding(); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_editNetworkMessageIsDisplayed('Arbitrum One'); + await homePage.closeUseNetworkNotificationModal(); + + // check that the second rpc is selected in the network dialog + await new HeaderNavbar(driver).clickSwitchNetworkDropDown(); + const selectNetworkDialog = new SelectNetwork(driver); + await selectNetworkDialog.check_pageIsLoaded(); + await selectNetworkDialog.check_rpcIsSelected('Arbitrum mainnet 2'); }, ); }); From 49e5e7861bb70571942e70c3f0c0ca29918d3e97 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 4 Nov 2024 10:39:12 +0000 Subject: [PATCH 55/56] fix: Prevent coercing symbols to zero in the edit spending cap modal (#28192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Since the `TextField` in the "edit spending cap" modal has a type `TextFieldType.Number`, it already blocks most symbols and letters. However, it does currently support `+`, `-` and `e` characters as they can be used to construe numbers. For example, when a `-` sign is introduced in the input field, the interim value is coerced to `''`, as there is no numerical equivalent to the sign by itself. The first part of this fix was to disable the "Save" button on such cases. If the user wants to revoke the spending cap, they can introduce `0`, but `''` is not a valid response. Furthermore, when a valid number is introduced but that uses scientific notation or `+`/`-` signs, the submission is disabled and a validation message is shown to the user: "Enter numbers only". [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28192?quickstart=1) ## **Related issues** Fixes: [#28096](https://github.com/MetaMask/metamask-extension/issues/28096) ## **Manual testing steps** 1. Deploy an erc20 token contract in the test DApp 2. Trigger an approve confirmation 3. Attempt to edit the spending cap with -1, 10e10, or any others. 4. You should be prevented from submitting and see the validation message. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-10-30 at 17 46 48 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/_locales/en/messages.json | 3 +++ .../edit-spending-cap-modal.tsx | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index cc077810750b..4b3c59b52297 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1824,6 +1824,9 @@ "editSpendingCapError": { "message": "The spending cap can’t exceed $1 decimal digits. Remove decimal digits to continue." }, + "editSpendingCapSpecialCharError": { + "message": "Enter numbers only" + }, "enable": { "message": "Enable" }, diff --git a/ui/pages/confirmations/components/confirm/info/approve/edit-spending-cap-modal/edit-spending-cap-modal.tsx b/ui/pages/confirmations/components/confirm/info/approve/edit-spending-cap-modal/edit-spending-cap-modal.tsx index 2762e99652a5..f908333e4f25 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/edit-spending-cap-modal/edit-spending-cap-modal.tsx +++ b/ui/pages/confirmations/components/confirm/info/approve/edit-spending-cap-modal/edit-spending-cap-modal.tsx @@ -124,6 +124,8 @@ export const EditSpendingCapModal = ({ decimals && parseInt(decimals, 10) < countDecimalDigits(customSpendingCapInputValue); + const showSpecialCharacterError = /[-+e]/u.test(customSpendingCapInputValue); + return ( )} + {showSpecialCharacterError && ( + + {t('editSpendingCapSpecialCharError')} + + )} From 7a8da64d2116074ce5837b28364513aa1211404c Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:09:15 -0500 Subject: [PATCH 56/56] fix: Add different copy for tooltip when a snap is requesting a signature (#27492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **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? The tooltip content incorrectly says "site" when a snap is requesting a signature 2. What is the improvement/solution? Changing the copy to say "snap" for when a snap is making the signature request. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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/_locales/en/messages.json | 3 + .../info/personal-sign/personal-sign.test.tsx | 96 +++++++++++++++++++ .../info/personal-sign/personal-sign.tsx | 12 ++- .../info/typed-sign-v1/typed-sign-v1.test.tsx | 62 ++++++++++++ .../info/typed-sign-v1/typed-sign-v1.tsx | 6 +- .../info/typed-sign/typed-sign.test.tsx | 62 ++++++++++++ .../confirm/info/typed-sign/typed-sign.tsx | 6 +- 7 files changed, 244 insertions(+), 3 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 4b3c59b52297..61171df6e922 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -4438,6 +4438,9 @@ "requestFromInfo": { "message": "This is the site asking for your signature." }, + "requestFromInfoSnap": { + "message": "This is the Snap asking for your signature." + }, "requestFromTransactionDescription": { "message": "This is the site asking for your confirmation." }, diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx index 6fe06c81467a..e105493485ec 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx @@ -10,6 +10,8 @@ import { } from '../../../../../../../test/data/confirmations/helper'; import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; import { signatureRequestSIWE } from '../../../../../../../test/data/confirmations/personal_sign'; +import * as utils from '../../../../utils'; +import * as snapUtils from '../../../../../../helpers/utils/snaps'; import PersonalSignInfo from './personal-sign'; jest.mock( @@ -21,6 +23,29 @@ jest.mock( }), ); +jest.mock('../../../../utils', () => { + const originalUtils = jest.requireActual('../../../../utils'); + return { + ...originalUtils, + isSIWESignatureRequest: jest.fn().mockReturnValue(false), + }; +}); + +jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { + const originalUtils = jest.requireActual( + '../../../../../../../node_modules/@metamask/snaps-utils', + ); + return { + ...originalUtils, + stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), + getSnapPrefix: jest.fn().mockReturnValue('npm:'), + }; +}); + +jest.mock('../../../../../../helpers/utils/snaps', () => ({ + isSnapId: jest.fn(), +})); + describe('PersonalSignInfo', () => { it('renders correctly for personal sign request', () => { const state = getMockPersonalSignConfirmState(); @@ -62,6 +87,7 @@ describe('PersonalSignInfo', () => { }); it('display signing in from for SIWE request', () => { + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(true); const state = getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); const mockStore = configureMockStore([])(state); @@ -73,6 +99,7 @@ describe('PersonalSignInfo', () => { }); it('display simulation for SIWE request if preference useTransactionSimulations is enabled', () => { + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(true); const state = getMockPersonalSignConfirmStateForRequest( signatureRequestSIWE, { @@ -88,4 +115,73 @@ describe('PersonalSignInfo', () => { ); expect(getByText('Estimated changes')).toBeDefined(); }); + + it('does not display tooltip text when isSIWE is true', async () => { + const state = + getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); // isSIWE is true + + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(true); + const mockStore = configureMockStore([])(state); + const { queryByText, getByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = getByText('Request from'); + await requestFromLabel.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + + expect( + queryByText('This is the site asking for your signature.'), + ).toBeNull(); + expect( + queryByText('This is the Snap asking for your signature.'), + ).toBeNull(); + }); + + it('displays "requestFromInfoSnap" tooltip when isSIWE is false and origin is a snap', async () => { + const state = + getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); + + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + + const mockStore = configureMockStore([])(state); + const { queryByText, getByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = getByText('Request from'); + await requestFromLabel.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + + expect( + queryByText('This is the Snap asking for your signature.'), + ).toBeDefined(); + }); + + it('displays "requestFromInfo" tooltip when isSIWE is false and origin is not a snap', async () => { + const state = + getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + + const mockStore = configureMockStore([])(state); + const { getByText, queryByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = getByText('Request from'); + await requestFromLabel.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + + expect( + queryByText('This is the site asking for your signature.'), + ).toBeDefined(); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx index 3199c3d108e0..5eb798439ca8 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.tsx @@ -19,6 +19,7 @@ import { selectUseTransactionSimulations } from '../../../../selectors/preferenc import { isSIWESignatureRequest } from '../../../../utils'; import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { SIWESignInfo } from './siwe-sign'; const PersonalSignInfo: React.FC = () => { @@ -39,6 +40,15 @@ const PersonalSignInfo: React.FC = () => { hexToText(currentConfirmation.msgParams?.data), ); + let toolTipMessage; + if (!isSIWE) { + if (isSnapId(currentConfirmation.msgParams.origin)) { + toolTipMessage = t('requestFromInfoSnap'); + } else { + toolTipMessage = t('requestFromInfo'); + } + } + return ( <> {isSIWE && useTransactionSimulations && ( @@ -56,7 +66,7 @@ const PersonalSignInfo: React.FC = () => { alertKey={RowAlertKey.RequestFrom} ownerId={currentConfirmation.id} label={t('requestFrom')} - tooltip={isSIWE ? undefined : t('requestFromInfo')} + tooltip={toolTipMessage} > diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx index c6801dd91314..2b1e6969ddd5 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx @@ -5,6 +5,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../test/data/confirmations/helper'; import { unapprovedTypedSignMsgV1 } from '../../../../../../../test/data/confirmations/typed_sign'; +import * as snapUtils from '../../../../../../helpers/utils/snaps'; import TypedSignInfoV1 from './typed-sign-v1'; jest.mock( @@ -16,6 +17,21 @@ jest.mock( }), ); +jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { + const originalUtils = jest.requireActual( + '../../../../../../../node_modules/@metamask/snaps-utils', + ); + return { + ...originalUtils, + stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), + getSnapPrefix: jest.fn().mockReturnValue('npm:'), + }; +}); + +jest.mock('../../../../../../helpers/utils/snaps', () => ({ + isSnapId: jest.fn(), +})); + describe('TypedSignInfo', () => { it('correctly renders typed sign data request', () => { const mockState = getMockTypedSignConfirmStateForRequest( @@ -42,4 +58,50 @@ describe('TypedSignInfo', () => { ); expect(container).toMatchInlineSnapshot(`
`); }); + + it('displays "requestFromInfoSnap" tooltip when origin is a snap', async () => { + const mockState = getMockTypedSignConfirmStateForRequest({ + id: '123', + type: TransactionType.signTypedData, + chainId: '0x5', + }); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + const mockStore = configureMockStore([])(mockState); + const { queryByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = queryByText('Request from'); + + await requestFromLabel?.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + expect( + queryByText('This is the Snap asking for your signature.'), + ).toBeDefined(); + }); + + it('displays "requestFromInfo" tooltip when origin is not a snap', async () => { + const mockState = getMockTypedSignConfirmStateForRequest({ + id: '123', + type: TransactionType.signTypedData, + chainId: '0x5', + }); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(false); + const mockStore = configureMockStore([])(mockState); + const { queryByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = queryByText('Request from'); + + await requestFromLabel?.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + expect( + queryByText('This is the site asking for your signature.'), + ).toBeDefined(); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx index b7bfdba16e8a..eb935d6bdef2 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.tsx @@ -14,6 +14,7 @@ import { import { useConfirmContext } from '../../../../context/confirm'; import { ConfirmInfoRowTypedSignDataV1 } from '../../row/typed-sign-data-v1/typedSignDataV1'; import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { isSnapId } from '../../../../../../helpers/utils/snaps'; const TypedSignV1Info: React.FC = () => { const t = useI18nContext(); @@ -23,6 +24,9 @@ const TypedSignV1Info: React.FC = () => { return null; } + const toolTipMessage = isSnapId(currentConfirmation.msgParams?.origin) + ? t('requestFromInfoSnap') + : t('requestFromInfo'); const chainId = currentConfirmation.chainId as string; return ( @@ -32,7 +36,7 @@ const TypedSignV1Info: React.FC = () => { alertKey={RowAlertKey.RequestFrom} ownerId={currentConfirmation.id} label={t('requestFrom')} - tooltip={t('requestFromInfo')} + tooltip={toolTipMessage} > { }; }); +jest.mock('../../../../../../../node_modules/@metamask/snaps-utils', () => { + const originalUtils = jest.requireActual( + '../../../../../../../node_modules/@metamask/snaps-utils', + ); + return { + ...originalUtils, + stripSnapPrefix: jest.fn().mockReturnValue('@metamask/examplesnap'), + getSnapPrefix: jest.fn().mockReturnValue('npm:'), + }; +}); + +jest.mock('../../../../../../helpers/utils/snaps', () => ({ + isSnapId: jest.fn(), +})); + describe('TypedSignInfo', () => { it('renders origin for typed sign data request', () => { const state = getMockTypedSignConfirmState(); @@ -127,4 +143,50 @@ describe('TypedSignInfo', () => { ); expect(container).toMatchSnapshot(); }); + + it('displays "requestFromInfoSnap" tooltip when origin is a snap', async () => { + const mockState = getMockTypedSignConfirmStateForRequest({ + id: '123', + type: TransactionType.signTypedData, + chainId: '0x5', + }); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + const mockStore = configureMockStore([])(mockState); + const { queryByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = queryByText('Request from'); + + await requestFromLabel?.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + expect( + queryByText('This is the Snap asking for your signature.'), + ).toBeDefined(); + }); + + it('displays "requestFromInfo" tooltip when origin is not a snap', async () => { + const mockState = getMockTypedSignConfirmStateForRequest({ + id: '123', + type: TransactionType.signTypedData, + chainId: '0x5', + }); + (snapUtils.isSnapId as jest.Mock).mockReturnValue(false); + const mockStore = configureMockStore([])(mockState); + const { queryByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + const requestFromLabel = queryByText('Request from'); + + await requestFromLabel?.dispatchEvent( + new MouseEvent('mouseenter', { bubbles: true }), + ); + expect( + queryByText('This is the site asking for your signature.'), + ).toBeDefined(); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx index c1830b05bd4a..fa5e61caef1f 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.tsx @@ -22,6 +22,7 @@ import { fetchErc20Decimals } from '../../../../utils/token'; import { useConfirmContext } from '../../../../context/confirm'; import { selectUseTransactionSimulations } from '../../../../selectors/preferences'; import { ConfirmInfoRowTypedSignData } from '../../row/typed-sign-data/typedSignData'; +import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { PermitSimulation } from './permit-simulation'; const TypedSignInfo: React.FC = () => { @@ -55,6 +56,9 @@ const TypedSignInfo: React.FC = () => { })(); }, [verifyingContract]); + const toolTipMessage = isSnapId(currentConfirmation.msgParams.origin) + ? t('requestFromInfoSnap') + : t('requestFromInfo'); const msgData = currentConfirmation.msgParams?.data as string; return ( @@ -73,7 +77,7 @@ const TypedSignInfo: React.FC = () => { alertKey={RowAlertKey.RequestFrom} ownerId={currentConfirmation.id} label={t('requestFrom')} - tooltip={t('requestFromInfo')} + tooltip={toolTipMessage} >