From 8b02ae4072f060e659eb91c5de2d64db991b25cd Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Fri, 15 Nov 2024 16:29:27 -0800 Subject: [PATCH 001/148] feat: upgrade assets controllers to version 44 (#28472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Upgrades the assets controllers to version 44. And starts replacing some instances of https://github.com/MetaMask/eth-token-tracker with reading state from the `TokenBalancesController` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28472?quickstart=1) ## **Related issues** ## **Manual testing steps** No visual changes. Token balances should render correctly like before on the tokens page, and when switching accounts and chains. ## **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-44.0.0-c223d56176.patch} | 0 app/scripts/constants/sentry-state.ts | 3 + app/scripts/metamask-controller.js | 39 +++++++ package.json | 2 +- ...rs-after-init-opt-in-background-state.json | 3 + .../errors-after-init-opt-in-ui-state.json | 1 + .../app/assets/asset-list/asset-list.test.tsx | 2 + .../app/wallet-overview/btc-overview.test.tsx | 2 + .../app/wallet-overview/eth-overview.test.js | 2 + .../account-overview-btc.test.tsx | 5 + .../account-overview-eth.test.tsx | 5 + ui/ducks/metamask/metamask.js | 10 ++ ui/hooks/useAccountTotalFiatBalance.js | 7 +- ui/hooks/useAccountTotalFiatBalance.test.js | 41 ++----- ...MultichainAccountTotalFiatBalance.test.tsx | 28 +++-- .../useMultichainAccountTotalFiatBalance.ts | 6 +- ui/hooks/useTokenBalances.ts | 110 ++++++++++++++++++ ui/pages/asset/components/asset-page.test.tsx | 6 + ui/pages/routes/routes.component.test.js | 2 + ui/store/actions.ts | 20 ++++ yarn.lock | 29 ++--- 21 files changed, 256 insertions(+), 67 deletions(-) rename .yarn/patches/{@metamask-assets-controllers-npm-43.1.1-c223d56176.patch => @metamask-assets-controllers-npm-44.0.0-c223d56176.patch} (100%) create mode 100644 ui/hooks/useTokenBalances.ts diff --git a/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch b/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch similarity index 100% rename from .yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch rename to .yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 6f960a496b3d..5146e38e8a41 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -355,6 +355,9 @@ export const SENTRY_BACKGROUND_STATE = { [AllProperties]: false, }, }, + TokenBalancesController: { + tokenBalances: false, + }, TokenRatesController: { marketData: false, }, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 6e2ba119053c..cc7eaa12d3d1 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -12,6 +12,7 @@ import { CodefiTokenPricesServiceV2, RatesController, fetchMultiExchangeRate, + TokenBalancesController, } from '@metamask/assets-controllers'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { createEngineStream } from '@metamask/json-rpc-middleware-stream'; @@ -685,6 +686,7 @@ export default class MetamaskController extends EventEmitter { 'AccountsController:selectedEvmAccountChange', 'PreferencesController:stateChange', 'TokenListController:stateChange', + 'NetworkController:stateChange', ], }); this.tokensController = new TokensController({ @@ -893,6 +895,28 @@ export default class MetamaskController extends EventEmitter { }; }; + const tokenBalancesMessenger = this.controllerMessenger.getRestricted({ + name: 'TokenBalancesController', + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'TokensController:getState', + 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'TokensController:stateChange', + 'NetworkController:stateChange', + ], + }); + + this.tokenBalancesController = new TokenBalancesController({ + messenger: tokenBalancesMessenger, + state: initState.TokenBalancesController, + interval: 30000, + }); + const phishingControllerMessenger = this.controllerMessenger.getRestricted({ name: 'PhishingController', }); @@ -2413,6 +2437,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + TokenBalancesController: this.tokenBalancesController, SmartTransactionsController: this.smartTransactionsController, NftController: this.nftController, PhishingController: this.phishingController, @@ -2468,6 +2493,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + TokenBalancesController: this.tokenBalancesController, SmartTransactionsController: this.smartTransactionsController, NftController: this.nftController, SelectedNetworkController: this.selectedNetworkController, @@ -3229,6 +3255,7 @@ export default class MetamaskController extends EventEmitter { nftController, nftDetectionController, currencyRateController, + tokenBalancesController, tokenDetectionController, ensController, tokenListController, @@ -4047,6 +4074,14 @@ export default class MetamaskController extends EventEmitter { tokenListStopPollingByPollingToken: tokenListController.stopPollingByPollingToken.bind(tokenListController), + tokenBalancesStartPolling: tokenBalancesController.startPolling.bind( + tokenBalancesController, + ), + tokenBalancesStopPollingByPollingToken: + tokenBalancesController.stopPollingByPollingToken.bind( + tokenBalancesController, + ), + // GasFeeController gasFeeStartPollingByNetworkClientId: gasFeeController.startPollingByNetworkClientId.bind(gasFeeController), @@ -6681,6 +6716,7 @@ export default class MetamaskController extends EventEmitter { this.tokenRatesController.stopAllPolling(); this.tokenDetectionController.stopAllPolling(); this.tokenListController.stopAllPolling(); + this.tokenBalancesController.stopAllPolling(); this.appStateController.clearPollingTokens(); } catch (error) { console.error(error); @@ -6921,6 +6957,9 @@ export default class MetamaskController extends EventEmitter { await this._createTransactionNotifcation(transactionMeta); await this._updateNFTOwnership(transactionMeta); this._trackTransactionFailure(transactionMeta); + await this.tokenBalancesController.updateBalancesByChainId({ + chainId: transactionMeta.chainId, + }); } async _createTransactionNotifcation(transactionMeta) { diff --git a/package.json b/package.json index ae2ebbd338a9..04098328933f 100644 --- a/package.json +++ b/package.json @@ -292,7 +292,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@patch%3A@metamask/assets-controllers@npm%253A43.1.1%23~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch%3A%3Aversion=43.1.1&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.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/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 999dce99ca0c..e47cfcd806b9 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 @@ -314,6 +314,9 @@ "swapsFeatureFlags": {} } }, + "TokenBalancesController": { + "tokenBalances": "object" + }, "TokenListController": { "tokenList": "object", "tokensChainsCache": { 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 acd9d6f8d074..7fd8501eb2b8 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 @@ -181,6 +181,7 @@ "0xe705": "object", "0xe708": "object" }, + "tokenBalances": "object", "preventPollingOnNetworkRestart": false, "tokens": "object", "ignoredTokens": "object", diff --git a/ui/components/app/assets/asset-list/asset-list.test.tsx b/ui/components/app/assets/asset-list/asset-list.test.tsx index 329c29a6108e..fd65e740238d 100644 --- a/ui/components/app/assets/asset-list/asset-list.test.tsx +++ b/ui/components/app/assets/asset-list/asset-list.test.tsx @@ -64,6 +64,8 @@ jest.mock('../../../../hooks/useIsOriginalNativeTokenSymbol', () => { jest.mock('../../../../store/actions', () => { return { getTokenSymbol: jest.fn(), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), }; }); diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index 62e6f5ff82b3..671e03a87ea8 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -29,6 +29,8 @@ jest.mock('../../../store/actions', () => ({ handleSnapRequest: jest.fn(), sendMultichainTransaction: jest.fn(), setDefaultHomeActiveTabName: jest.fn(), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), })); const PORTOFOLIO_URL = 'https://portfolio.test'; diff --git a/ui/components/app/wallet-overview/eth-overview.test.js b/ui/components/app/wallet-overview/eth-overview.test.js index 539cfbb6c59f..a8c490b923c6 100644 --- a/ui/components/app/wallet-overview/eth-overview.test.js +++ b/ui/components/app/wallet-overview/eth-overview.test.js @@ -38,6 +38,8 @@ jest.mock('../../../ducks/locale/locale', () => ({ jest.mock('../../../store/actions', () => ({ startNewDraftTransaction: jest.fn(), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), })); const mockGetIntlLocale = getIntlLocale; diff --git a/ui/components/multichain/account-overview/account-overview-btc.test.tsx b/ui/components/multichain/account-overview/account-overview-btc.test.tsx index 1def72354c82..fa32883ce773 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-btc.test.tsx @@ -8,6 +8,11 @@ import { AccountOverviewBtcProps, } from './account-overview-btc'; +jest.mock('../../../store/actions', () => ({ + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + const defaultProps: AccountOverviewBtcProps = { defaultHomeActiveTabName: null, onTabClick: jest.fn(), diff --git a/ui/components/multichain/account-overview/account-overview-eth.test.tsx b/ui/components/multichain/account-overview/account-overview-eth.test.tsx index ba2049ffd205..f9b53665e753 100644 --- a/ui/components/multichain/account-overview/account-overview-eth.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-eth.test.tsx @@ -8,6 +8,11 @@ import { AccountOverviewEthProps, } from './account-overview-eth'; +jest.mock('../../../store/actions', () => ({ + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + const render = (props: AccountOverviewEthProps) => { const store = configureStore({ metamask: mockState.metamask, diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 63ff92a11ccc..7ddc156c92ae 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -457,6 +457,16 @@ export const getGasEstimateTypeByChainId = createSelector( }, ); +/** + * Returns the balances of imported and detected tokens across all accounts and chains. + * + * @param {*} state + * @returns { import('@metamask/assets-controllers').TokenBalancesControllerState['tokenBalances']} + */ +export function getTokenBalances(state) { + return state.metamask.tokenBalances; +} + export const getGasFeeEstimatesByChainId = createSelector( getGasFeeControllerEstimatesByChainId, getTransactionGasFeeEstimatesByChainId, diff --git a/ui/hooks/useAccountTotalFiatBalance.js b/ui/hooks/useAccountTotalFiatBalance.js index 7b4a4675225a..aa1f906473ef 100644 --- a/ui/hooks/useAccountTotalFiatBalance.js +++ b/ui/hooks/useAccountTotalFiatBalance.js @@ -22,7 +22,7 @@ import { import { formatCurrency } from '../helpers/utils/confirm-tx.util'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { roundToDecimalPlacesRemovingExtraZeroes } from '../helpers/utils/util'; -import { useTokenTracker } from './useTokenTracker'; +import { useTokenTracker } from './useTokenBalances'; export const useAccountTotalFiatBalance = ( account, @@ -54,10 +54,11 @@ export const useAccountTotalFiatBalance = ( const primaryTokenImage = useSelector(getNativeCurrencyImage); const nativeCurrency = useSelector(getNativeCurrency); - const { loading, tokensWithBalances } = useTokenTracker({ + const loading = false; + const { tokensWithBalances } = useTokenTracker({ + chainId: currentChainId, tokens, address: account?.address, - includeFailedTokens: true, hideZeroBalanceTokens: shouldHideZeroBalanceTokens, }); diff --git a/ui/hooks/useAccountTotalFiatBalance.test.js b/ui/hooks/useAccountTotalFiatBalance.test.js index 9fb1227367e1..6ac93cd08e33 100644 --- a/ui/hooks/useAccountTotalFiatBalance.test.js +++ b/ui/hooks/useAccountTotalFiatBalance.test.js @@ -14,35 +14,6 @@ const mockAccount = createMockInternalAccount({ address: '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da', }); -jest.mock('./useTokenTracker', () => { - return { - useTokenTracker: () => ({ - loading: false, - tokensWithBalances: [ - { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - balance: '48573', - balanceError: null, - decimals: 6, - image: undefined, - isERC721: undefined, - string: '0.04857', - symbol: 'USDC', - }, - { - address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', - symbol: 'YFI', - balance: '1409247882142934', - decimals: 18, - string: '0.001409247882142934', - balanceError: null, - }, - ], - error: null, - }), - }; -}); - const renderUseAccountTotalFiatBalance = (address) => { const state = { ...mockState, @@ -78,7 +49,7 @@ const renderUseAccountTotalFiatBalance = (address) => { }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - detectedTokens: { + allTokens: { '0x1': { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { @@ -96,6 +67,14 @@ const renderUseAccountTotalFiatBalance = (address) => { ], }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', + }, + }, + }, }, }; @@ -122,8 +101,6 @@ describe('useAccountTotalFiatBalance', () => { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', balance: '48573', - image: undefined, - isERC721: undefined, decimals: 6, string: 0.04857, balanceError: null, diff --git a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx index ffd664612a02..e46eff925e50 100644 --- a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx +++ b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx @@ -15,31 +15,21 @@ const mockTokenBalances = [ balance: '48573', balanceError: null, decimals: 6, - image: undefined, - isERC721: undefined, - string: '0.04857', + string: 0.04857, symbol: 'USDC', + tokenFiatAmount: '0.05', }, { address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', symbol: 'YFI', balance: '1409247882142934', decimals: 18, - string: '0.001409247882142934', + string: 0.00141, balanceError: null, + tokenFiatAmount: '7.52', }, ]; -jest.mock('./useTokenTracker', () => { - return { - useTokenTracker: () => ({ - loading: false, - tokensWithBalances: mockTokenBalances, - error: null, - }), - }; -}); - const mockAccount = createMockInternalAccount({ name: 'Account 1', address: '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da', @@ -104,7 +94,7 @@ const renderUseMultichainAccountTotalFiatBalance = ( }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - detectedTokens: { + allTokens: { '0x1': { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { @@ -122,6 +112,14 @@ const renderUseMultichainAccountTotalFiatBalance = ( ], }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', + }, + }, + }, }, }; diff --git a/ui/hooks/useMultichainAccountTotalFiatBalance.ts b/ui/hooks/useMultichainAccountTotalFiatBalance.ts index 9e807be41ea5..335b8399c6d5 100644 --- a/ui/hooks/useMultichainAccountTotalFiatBalance.ts +++ b/ui/hooks/useMultichainAccountTotalFiatBalance.ts @@ -31,9 +31,9 @@ export const useMultichainAccountTotalFiatBalance = ( tokensWithBalances: { address: string; symbol: string; - decimals: string; - isERC721: boolean; - image: string; + decimals: number; + isERC721?: boolean; + image?: string; }[]; totalWeiBalance?: string; totalBalance?: string; diff --git a/ui/hooks/useTokenBalances.ts b/ui/hooks/useTokenBalances.ts new file mode 100644 index 000000000000..9ff92d488814 --- /dev/null +++ b/ui/hooks/useTokenBalances.ts @@ -0,0 +1,110 @@ +import { useSelector } from 'react-redux'; +import BN from 'bn.js'; +import { Token } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import { getNetworkConfigurationsByChainId } from '../selectors'; +import { + tokenBalancesStartPolling, + tokenBalancesStopPollingByPollingToken, +} from '../store/actions'; +import { getTokenBalances } from '../ducks/metamask/metamask'; +import { hexToDecimal } from '../../shared/modules/conversion.utils'; +import useMultiPolling from './useMultiPolling'; + +export const useTokenBalances = ({ chainIds }: { chainIds?: Hex[] } = {}) => { + const tokenBalances = useSelector(getTokenBalances); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + + useMultiPolling({ + startPolling: tokenBalancesStartPolling, + stopPollingByPollingToken: tokenBalancesStopPollingByPollingToken, + input: chainIds ?? Object.keys(networkConfigurations), + }); + + return { tokenBalances }; +}; + +// This hook is designed for backwards compatibility with `ui/hooks/useTokenTracker.js` +// and the github.com/MetaMask/eth-token-tracker library. It replaces RPC calls with +// reading state from `TokenBalancesController`. It should not be used in new code. +// Instead, prefer to use `useTokenBalances` directly, or compose higher level hooks from it. +export const useTokenTracker = ({ + chainId, + tokens, + address, + hideZeroBalanceTokens, +}: { + chainId: Hex; + tokens: Token[]; + address: Hex; + hideZeroBalanceTokens?: boolean; +}) => { + const { tokenBalances } = useTokenBalances({ chainIds: [chainId] }); + + const tokensWithBalances = tokens.reduce((acc, token) => { + const hexBalance = + tokenBalances[address]?.[chainId]?.[token.address as Hex] ?? '0x0'; + if (hexBalance !== '0x0' || !hideZeroBalanceTokens) { + const decimalBalance = hexToDecimal(hexBalance); + acc.push({ + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + balance: decimalBalance, + balanceError: null, + string: stringifyBalance( + new BN(decimalBalance), + new BN(token.decimals), + ), + }); + } + return acc; + }, [] as (Token & { balance: string; string: string; balanceError: unknown })[]); + + return { + tokensWithBalances, + }; +}; + +// From https://github.com/MetaMask/eth-token-tracker/blob/main/lib/util.js +// Ensures backwards compatibility with display formatting. +function stringifyBalance(balance: BN, bnDecimals: BN, balanceDecimals = 5) { + if (balance.eq(new BN(0))) { + return '0'; + } + + const decimals = parseInt(bnDecimals.toString(), 10); + if (decimals === 0) { + return balance.toString(); + } + + let bal = balance.toString(); + let len = bal.length; + let decimalIndex = len - decimals; + let prefix = ''; + + if (decimalIndex <= 0) { + while (prefix.length <= decimalIndex * -1) { + prefix += '0'; + len += 1; + } + bal = prefix + bal; + decimalIndex = 1; + } + + const whole = bal.substr(0, len - decimals); + + if (balanceDecimals === 0) { + return whole; + } + + const fractional = bal.substr(decimalIndex, balanceDecimals); + if (/0+$/u.test(fractional)) { + let withOnlySigZeroes = bal.substr(decimalIndex).replace(/0+$/u, ''); + if (withOnlySigZeroes.length > 0) { + withOnlySigZeroes = `.${withOnlySigZeroes}`; + } + return `${whole}${withOnlySigZeroes}`; + } + return `${whole}.${fractional}`; +} diff --git a/ui/pages/asset/components/asset-page.test.tsx b/ui/pages/asset/components/asset-page.test.tsx index 6bf1d12feb9e..5df516184004 100644 --- a/ui/pages/asset/components/asset-page.test.tsx +++ b/ui/pages/asset/components/asset-page.test.tsx @@ -13,6 +13,12 @@ import { setBackgroundConnection } from '../../../store/background-connection'; import { mockNetworkState } from '../../../../test/stub/networks'; import AssetPage from './asset-page'; +jest.mock('../../../store/actions', () => ({ + ...jest.requireActual('../../../store/actions'), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + // Mock the price chart jest.mock('react-chartjs-2', () => ({ Line: () => null })); diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 6151fedc687b..8a516fd76d6a 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -43,6 +43,8 @@ jest.mock('../../store/actions', () => ({ .mockResolvedValue({ chainId: '0x5' }), showNetworkDropdown: () => mockShowNetworkDropdown, hideNetworkDropdown: () => mockHideNetworkDropdown, + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), })); jest.mock('../../ducks/bridge/actions', () => ({ diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 32434d3a1d59..390db05f04df 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4625,6 +4625,26 @@ export async function tokenListStopPollingByPollingToken(pollingToken: string) { await removePollingTokenFromAppState(pollingToken); } +export async function tokenBalancesStartPolling( + chainId: string, +): Promise { + const pollingToken = await submitRequestToBackground( + 'tokenBalancesStartPolling', + [{ chainId }], + ); + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +export async function tokenBalancesStopPollingByPollingToken( + pollingToken: string, +) { + await submitRequestToBackground('tokenBalancesStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + /** * Informs the TokenRatesController that the UI requires * token rate polling for the given chain id. diff --git a/yarn.lock b/yarn.lock index 8d622a3d2d25..4602bb34f62f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4934,11 +4934,12 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:43.1.1": - version: 43.1.1 - resolution: "@metamask/assets-controllers@npm:43.1.1" +"@metamask/assets-controllers@npm:44.0.0": + version: 44.0.0 + resolution: "@metamask/assets-controllers@npm:44.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -4968,15 +4969,16 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/e8f37928085a243f2f3a9d3b09b486f31737814d6257ee49bc2d841d2f467733b8c533c056e9ca24acdcc80414503b34b00e10abb1cdfeb8483e6fe30bc4a62f + checksum: 10/6f3d8712a90aa322aabd38d43663d299ad7ee98a6d838d72bfc3b426ea0e4e925bb78c1aaaa3c75d43e95d46993c47583a4a03f4c58aee155525424fa86207ae languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A43.1.1#~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch::version=43.1.1&hash=5a94c2": - version: 43.1.1 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A43.1.1#~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch::version=43.1.1&hash=5a94c2" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2": + version: 44.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2" dependencies: "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -5006,15 +5008,16 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/1a3672cb721c6716d33c1c6c5e7ffc2859689407e70af7503204220afe41f6c0d20f883ad7d51089af7376e4005de7479525b3faa49113c942cf3ab1bceba154 + checksum: 10/0d6c386a1f1e68ab339340fd8fa600827f55f234bc54b2224069a1819ab037641daa9696a0d62f187c0649317393efaeeb119a7852af51da3bb340e0e98cf9f6 languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A43.1.1%23~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch%3A%3Aversion=43.1.1&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch": - version: 43.1.1 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A43.1.1%23~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch%3A%3Aversion=43.1.1&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch::version=43.1.1&hash=c4e407" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch": + version: 44.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch::version=44.0.0&hash=c4e407" dependencies: "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -5044,7 +5047,7 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/189f45b9afefef9dc54f29920eb64fcebbc1dd13d792627ab2649c973a94ca310c848411fe4b294419c881d1956ff50bd6107f7411faa2a953da005662269e40 + checksum: 10/11e8920bdf8ffce4a534c6aadfe768176c4e461a00bc06e6ece52f085755ff252194881d9edd308097186a05057075fd9812b6e4b1fd97dd731814ad205013da languageName: node linkType: hard @@ -26737,7 +26740,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@patch%3A@metamask/assets-controllers@npm%253A43.1.1%23~/.yarn/patches/@metamask-assets-controllers-npm-43.1.1-c223d56176.patch%3A%3Aversion=43.1.1&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" From ee75939b5275dc582fb07b658d8f96341edb52d3 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 18 Nov 2024 17:01:03 -0330 Subject: [PATCH 002/148] chore: Update `cross-spawn` (#28522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The package `cross-spawn` has been updated to v7.0.6 to address a security advisory. The advisory doesn't impact our usage of this library, but it was easy to update. We had two usages of an older major version of this library in our dependency tree (v5), which were forced to v7 using a resolution. The only breaking changes in v6 and v7 were dropping support for older Node.js versions that are already below our minimum supported version. `cross-spawn` changelog: https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28522?quickstart=1) ## **Related issues** Resolves https://github.com/advisories/GHSA-3xgq-45jj-v275 ## **Manual testing steps** N/A ## **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. --- package.json | 5 +++-- yarn.lock | 63 +++++----------------------------------------------- 2 files changed, 9 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 04098328933f..a04f6e9b3bc7 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,8 @@ "@ledgerhq/evm-tools/axios": "^0.28.0", "@ledgerhq/hw-app-eth/axios": "^0.28.0", "@ledgerhq/hw-app-eth@npm:^6.39.0": "patch:@ledgerhq/hw-app-eth@npm%3A6.39.0#~/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch", - "@ledgerhq/evm-tools@npm:^1.2.3": "patch:@ledgerhq/evm-tools@npm%3A1.2.3#~/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch" + "@ledgerhq/evm-tools@npm:^1.2.3": "patch:@ledgerhq/evm-tools@npm%3A1.2.3#~/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch", + "cross-spawn@npm:^5.0.1": "^7.0.5" }, "dependencies": { "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", @@ -560,7 +561,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^12.0.2", "core-js-pure": "^3.38.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "crypto-browserify": "^3.12.0", "css-loader": "^6.10.0", "css-to-xpath": "^0.1.0", diff --git a/yarn.lock b/yarn.lock index 4602bb34f62f..4e745717f247 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16162,25 +16162,14 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^5.0.1": - version: 5.1.0 - resolution: "cross-spawn@npm:5.1.0" - dependencies: - lru-cache: "npm:^4.0.1" - shebang-command: "npm:^1.2.0" - which: "npm:^1.2.9" - checksum: 10/726939c9954fc70c20e538923feaaa33bebc253247d13021737c3c7f68cdc3e0a57f720c0fe75057c0387995349f3f12e20e9bfdbf12274db28019c7ea4ec166 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" dependencies: path-key: "npm:^3.1.0" shebang-command: "npm:^2.0.0" which: "npm:^2.0.1" - checksum: 10/e1a13869d2f57d974de0d9ef7acbf69dc6937db20b918525a01dacb5032129bd552d290d886d981e99f1b624cb03657084cc87bd40f115c07ecf376821c729ce + checksum: 10/0d52657d7ae36eb130999dffff1168ec348687b48dd38e2ff59992ed916c88d328cf1d07ff4a4a10bc78de5e1c23f04b306d569e42f7a2293915c081e4dfee86 languageName: node linkType: hard @@ -26031,16 +26020,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^4.0.1": - version: 4.1.1 - resolution: "lru-cache@npm:4.1.1" - dependencies: - pseudomap: "npm:^1.0.2" - yallist: "npm:^2.1.2" - checksum: 10/a412db13e89abe202c2314e633bd8580be2a668ba2036c34da376ac66163aa9fba4727ca66ff7907ad68fb574511f0bc6275c0598fdaeeab92e1125f5397d0e4 - languageName: node - linkType: hard - "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -26927,7 +26906,7 @@ __metadata: copy-to-clipboard: "npm:^3.3.3" copy-webpack-plugin: "npm:^12.0.2" core-js-pure: "npm:^3.38.0" - cross-spawn: "npm:^7.0.3" + cross-spawn: "npm:^7.0.5" crypto-browserify: "npm:^3.12.0" css-loader: "npm:^6.10.0" css-to-xpath: "npm:^0.1.0" @@ -30655,13 +30634,6 @@ __metadata: languageName: node linkType: hard -"pseudomap@npm:^1.0.2": - version: 1.0.2 - resolution: "pseudomap@npm:1.0.2" - checksum: 10/856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 - languageName: node - linkType: hard - "psl@npm:^1.1.33": version: 1.9.0 resolution: "psl@npm:1.9.0" @@ -33737,15 +33709,6 @@ __metadata: languageName: node linkType: hard -"shebang-command@npm:^1.2.0": - version: 1.2.0 - resolution: "shebang-command@npm:1.2.0" - dependencies: - shebang-regex: "npm:^1.0.0" - checksum: 10/9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908 - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -33755,13 +33718,6 @@ __metadata: languageName: node linkType: hard -"shebang-regex@npm:^1.0.0": - version: 1.0.0 - resolution: "shebang-regex@npm:1.0.0" - checksum: 10/404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372 - languageName: node - linkType: hard - "shebang-regex@npm:^3.0.0": version: 3.0.0 resolution: "shebang-regex@npm:3.0.0" @@ -37911,7 +37867,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.12, which@npm:^1.2.14, which@npm:^1.2.9, which@npm:^1.3.1": +"which@npm:^1.2.12, which@npm:^1.2.14, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -38245,13 +38201,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^2.1.2": - version: 2.1.2 - resolution: "yallist@npm:2.1.2" - checksum: 10/75fc7bee4821f52d1c6e6021b91b3e079276f1a9ce0ad58da3c76b79a7e47d6f276d35e206a96ac16c1cf48daee38a8bb3af0b1522a3d11c8ffe18f898828832 - languageName: node - linkType: hard - "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" From ec8e5fbc35237e23af477419cb732f0ad5adf512 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Mon, 18 Nov 2024 15:31:01 -0600 Subject: [PATCH 003/148] fix: PortfolioView: Selector to determine networks to poll (#28502) 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/28502?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. --- ui/selectors/selectors.js | 52 +++++++++++++++ ui/selectors/selectors.test.js | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index aad920b6cb8b..67d272b7642b 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2196,6 +2196,58 @@ export const getAllEnabledNetworks = createDeepEqualSelector( ), ); +export const getChainIdsToPoll = createDeepEqualSelector( + getPreferences, + getNetworkConfigurationsByChainId, + getCurrentChainId, + (preferences, networkConfigurations, currentChainId) => { + const { pausedChainIds = [] } = preferences; + + if (!process.env.PORTFOLIO_VIEW) { + return [currentChainId]; + } + + return Object.keys(networkConfigurations).filter( + (chainId) => + !TEST_CHAINS.includes(chainId) && !pausedChainIds.includes(chainId), + ); + }, +); + +export const getNetworkClientIdsToPoll = createDeepEqualSelector( + getPreferences, + getNetworkConfigurationsByChainId, + getCurrentChainId, + (preferences, networkConfigurations, currentChainId) => { + const { pausedChainIds = [] } = preferences; + + if (!process.env.PORTFOLIO_VIEW) { + const networkConfiguration = networkConfigurations[currentChainId]; + return [ + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].networkClientId, + ]; + } + + return Object.entries(networkConfigurations).reduce( + (acc, [chainId, network]) => { + if ( + !TEST_CHAINS.includes(chainId) && + !pausedChainIds.includes(chainId) + ) { + acc.push( + network.rpcEndpoints[network.defaultRpcEndpointIndex] + .networkClientId, + ); + } + return acc; + }, + [], + ); + }, +); + /** * To retrieve the maxBaseFee and priorityFee the user has set as default * diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index d6656e481709..85180dec45f4 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -838,6 +838,123 @@ describe('Selectors', () => { }); }); + describe('#getChainIdsToPoll', () => { + const networkConfigurationsByChainId = { + [CHAIN_IDS.MAINNET]: { + chainId: CHAIN_IDS.MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + chainId: CHAIN_IDS.LINEA_MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-mainnet' }], + }, + [CHAIN_IDS.SEPOLIA]: { + chainId: CHAIN_IDS.SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'sepolia' }], + }, + [CHAIN_IDS.LINEA_SEPOLIA]: { + chainId: CHAIN_IDS.LINEA_SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-sepolia' }], + }, + }; + + beforeEach(() => { + process.env.PORTFOLIO_VIEW = 'true'; + }); + + afterEach(() => { + process.env.PORTFOLIO_VIEW = undefined; + }); + + it('returns only non-test chain IDs', () => { + const chainIds = selectors.getChainIdsToPoll({ + metamask: { + preferences: { pausedChainIds: [] }, + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(2); + expect(chainIds).toStrictEqual([ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + ]); + }); + + it('does not return paused chain IDs', () => { + const chainIds = selectors.getChainIdsToPoll({ + metamask: { + preferences: { pausedChainIds: [CHAIN_IDS.LINEA_MAINNET] }, + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(1); + expect(chainIds).toStrictEqual([CHAIN_IDS.MAINNET]); + }); + }); + + describe('#getNetworkClientIdsToPoll', () => { + const networkConfigurationsByChainId = { + [CHAIN_IDS.MAINNET]: { + chainId: CHAIN_IDS.MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + chainId: CHAIN_IDS.LINEA_MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-mainnet' }], + }, + [CHAIN_IDS.SEPOLIA]: { + chainId: CHAIN_IDS.SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'sepolia' }], + }, + [CHAIN_IDS.LINEA_SEPOLIA]: { + chainId: CHAIN_IDS.LINEA_SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-sepolia' }], + }, + }; + + beforeEach(() => { + process.env.PORTFOLIO_VIEW = 'true'; + }); + + afterEach(() => { + process.env.PORTFOLIO_VIEW = undefined; + }); + + it('returns only non-test chain IDs', () => { + const chainIds = selectors.getNetworkClientIdsToPoll({ + metamask: { + preferences: { pausedChainIds: [] }, + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(2); + expect(chainIds).toStrictEqual(['mainnet', 'linea-mainnet']); + }); + + it('does not return paused chain IDs', () => { + const chainIds = selectors.getNetworkClientIdsToPoll({ + metamask: { + preferences: { pausedChainIds: [CHAIN_IDS.LINEA_MAINNET] }, + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(1); + expect(chainIds).toStrictEqual(['mainnet']); + }); + }); + describe('#isHardwareWallet', () => { it('returns false if it is not a HW wallet', () => { const mockStateWithImported = modifyStateWithHWKeyring( From fd78a56f628f7bfe8e2a9864dff0244d6bd446f3 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 18 Nov 2024 18:01:13 -0330 Subject: [PATCH 004/148] fix: Make QR scanner more strict (#28521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The QR scanner is now more strict about the contents it allows to be scanned. If the scanned QR code deviates at all from the supported formats, it will return "unknown" as the result (as it always has for completely unrecognized QR codes). Previously we would accept QR codes with a recognized prefix even if the complete contents did not match our expectations, which has resulted in unexpected behavior. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28521?quickstart=1) ## **Related issues** Fixes #28527 ## **Manual testing steps** - Open the MetaMask extension and select 'Send' - Click on the QR scanner icon in the "Send To" field and enable webcam - Scan a ERC-20 wallet receive QR from a mobile app, which follows the EIP-681 standard and contains a valid token contract and account address - ERC-20 Token Contract Address, which is the first address in the string, populates the "Send To" field instead of the intended recipient address ## **Screenshots/Recordings** ### **Before** We didn't record this, but multiple people on the team reproduced the problem. ### **After** https://www.loom.com/share/be8822e872a14ec98a47547cf6198603 ## **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). - [ ] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - We don't yet have any way to test QR scanning. We will follow up later with tests, and rely on manual testing for now. Later test automation work tracked in #28528 - [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). - [ ] 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. --- .../modals/qr-scanner/qr-scanner.component.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ui/components/app/modals/qr-scanner/qr-scanner.component.js b/ui/components/app/modals/qr-scanner/qr-scanner.component.js index 75e1a83417b9..51ae5a89a6ef 100644 --- a/ui/components/app/modals/qr-scanner/qr-scanner.component.js +++ b/ui/components/app/modals/qr-scanner/qr-scanner.component.js @@ -22,6 +22,10 @@ const READY_STATE = { READY: 'READY', }; +const ethereumPrefix = 'ethereum:'; +// A 0x-prefixed Ethereum address is 42 characters (2 prefix + 40 address) +const addressLength = 42; + const parseContent = (content) => { let type = 'unknown'; let values = {}; @@ -31,12 +35,18 @@ const parseContent = (content) => { // For ex. EIP-681 (https://eips.ethereum.org/EIPS/eip-681) // Ethereum address links - fox ex. ethereum:0x.....1111 - if (content.split('ethereum:').length > 1) { + if ( + content.split(ethereumPrefix).length > 1 && + content.length === ethereumPrefix.length + addressLength + ) { type = 'address'; - // uses regex capture groups to match and extract address while ignoring everything else + // uses regex capture groups to match and extract address values = { address: parseScanContent(content) }; // Regular ethereum addresses - fox ex. 0x.....1111 - } else if (content.substring(0, 2).toLowerCase() === '0x') { + } else if ( + content.substring(0, 2).toLowerCase() === '0x' && + content.length === addressLength + ) { type = 'address'; values = { address: content }; } From 3c5178636b430f13ffefd3a244ff3f6c8aa93000 Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Tue, 19 Nov 2024 11:32:00 +0100 Subject: [PATCH 005/148] test: improve logs for e2e errors (#28479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds an improvement on our logs when the errors do not have the expected form of a.value, leading to displaying empty errors and not failing the test. Those are happening for RPC and some snap errors types, which currently are displayed as empty (see below screenshots): - The RPC errors doesn't have a `value` property but a `description`, so we were seeing empty errors in the logs - In the snaps errors, the a.value property is not directly present, instead, the relevant information is nested within the preview.properties array - Other error structures, which doesn't fall under the 3 error categories, will be captured in a fallback, which will stringify the complete error With this change we are now able to see better error logs in our e2e. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28479?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3648 ## **Manual testing steps** 1. Run a test which triggers an RPC error like: `yarn test:e2e:single test/e2e/tests/request-queuing/ui.spec.js --browser=chrome` 2. Check console errors before and after this change ## **Screenshots/Recordings** ### **Before** See empty RPC error logs: ![Screenshot from 2024-11-15 08-47-39](https://github.com/user-attachments/assets/40f4a2dd-00f2-4bb3-b8da-740cd24254ec) See empty snap error logs (the 1st one type is logged but the 2nd one is empty): ![Screenshot from 2024-11-15 10-57-48](https://github.com/user-attachments/assets/019c1088-0816-4de3-a33a-9ff0c4266a9a) ### **After** See complete RPC error logs ![Screenshot from 2024-11-15 09-43-45](https://github.com/user-attachments/assets/80b6ff10-e615-4261-8b13-30674e7a51bf) See complete snaps error logs ![Screenshot from 2024-11-15 10-58-04](https://github.com/user-attachments/assets/629ec3da-ee19-4cda-ba82-fb73a01a8d03) ## **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/e2e/snaps/test-snap-metrics.spec.js | 1 + test/e2e/tests/account/lockdown.spec.ts | 1 + test/e2e/webdriver/driver.js | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/e2e/snaps/test-snap-metrics.spec.js b/test/e2e/snaps/test-snap-metrics.spec.js index 54ebd572d993..6c8ac7b9530f 100644 --- a/test/e2e/snaps/test-snap-metrics.spec.js +++ b/test/e2e/snaps/test-snap-metrics.spec.js @@ -900,6 +900,7 @@ describe('Test Snap Metrics', function () { testSpecificMock: mockSegment, ignoredConsoleErrors: [ 'MetaMask - RPC Error: Failed to fetch snap "npm:@metamask/bip32-example-snap": Failed to fetch tarball for package "@metamask/bip32-example-snap"..', + 'Failed to fetch snap "npm:@metamask/bip32-example-…ball for package "@metamask/bip32-example-snap"..', ], }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { diff --git a/test/e2e/tests/account/lockdown.spec.ts b/test/e2e/tests/account/lockdown.spec.ts index 4307e1e33d6e..c2c4706855e0 100644 --- a/test/e2e/tests/account/lockdown.spec.ts +++ b/test/e2e/tests/account/lockdown.spec.ts @@ -86,6 +86,7 @@ describe('lockdown', function (this: Mocha.Suite) { { fixtures: new FixtureBuilder().build(), ganacheOptions, + ignoredConsoleErrors: ['Error: Could not establish connection.'], title: this.test?.fullTitle(), }, async ({ driver }: { driver: Driver }) => { diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index 1a10f7c0199d..42fa0f018f6d 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -1311,7 +1311,23 @@ class Driver { #getErrorFromEvent(event) { // Extract the values from the array - const values = event.args.map((a) => a.value); + const values = event.args.map((a) => { + // Handle snaps error type + if (a && a.preview && Array.isArray(a.preview.properties)) { + return a.preview.properties + .filter((prop) => prop.value !== 'Object') + .map((prop) => prop.value) + .join(', '); + } else if (a.description) { + // Handle RPC error type + return a.description; + } else if (a.value) { + // Handle generic error types + return a.value; + } + // Fallback for other error structures + return JSON.stringify(a, null, 2); + }); if (values[0]?.includes('%s')) { // The values are in the "printf" form of [message, ...substitutions] From 91acd9cf6a8ff63e19929eef0b04dbcbe7aff055 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:08:17 +0100 Subject: [PATCH 006/148] test: [POM] Migrate onboarding metrics e2e tests to TS and Page Object Model to reduce flakiness (#28424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Migrate onboarding metrics e2e tests to TS and Page Object Model - Use onboarding functions designed with Page Object Model, to reduce flakiness. [![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/28425 ## **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. --- test/e2e/helpers.js | 107 ------------------ .../e2e/page-objects/flows/onboarding.flow.ts | 42 +++++-- .../onboarding/onboarding-metrics-page.ts | 6 + ...nstalled.spec.js => app-installed.spec.ts} | 53 +++++---- ....spec.js => nft-detection-metrics.spec.ts} | 45 +++----- ...pec.js => token-detection-metrics.spec.ts} | 44 +++---- ...created.spec.js => wallet-created.spec.ts} | 60 ++++------ .../account-syncing/new-user-sync.spec.ts | 6 +- test/e2e/tests/onboarding/onboarding.spec.ts | 4 +- test/e2e/vault-decryption-chrome.spec.ts | 2 +- 10 files changed, 123 insertions(+), 246 deletions(-) rename test/e2e/tests/metrics/{app-installed.spec.js => app-installed.spec.ts} (56%) rename test/e2e/tests/metrics/{nft-detection-metrics.spec.js => nft-detection-metrics.spec.ts} (68%) rename test/e2e/tests/metrics/{token-detection-metrics.spec.js => token-detection-metrics.spec.ts} (68%) rename test/e2e/tests/metrics/{wallet-created.spec.js => wallet-created.spec.ts} (61%) diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index a40edadf7c69..091f946a8071 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -1,4 +1,3 @@ -const { strict: assert } = require('assert'); const path = require('path'); const { promises: fs, writeFileSync, readFileSync } = require('fs'); const BigNumber = require('bignumber.js'); @@ -386,106 +385,6 @@ const getWindowHandles = async (driver, handlesCount) => { return { extension, dapp, popup }; }; -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Begin the create new wallet flow on onboarding screen. - * @param {WebDriver} driver - */ -const onboardingBeginCreateNewWallet = async (driver) => { - // agree to terms of use - await driver.clickElement('[data-testid="onboarding-terms-checkbox"]'); - - // welcome - await driver.clickElement('[data-testid="onboarding-create-wallet"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Choose either "I Agree" or "No Thanks" on the MetaMetrics onboarding screen - * @param {WebDriver} driver - * @param {boolean} option - true to opt into metrics, default is false - */ -const onboardingChooseMetametricsOption = async (driver, option = false) => { - const optionIdentifier = option ? 'i-agree' : 'no-thanks'; - // metrics - await driver.clickElement(`[data-testid="metametrics-${optionIdentifier}"]`); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Set a password for MetaMask during onboarding - * @param {WebDriver} driver - * @param {string} password - Password to set - */ -const onboardingCreatePassword = async (driver, password) => { - // create password - await driver.fill('[data-testid="create-password-new"]', password); - await driver.fill('[data-testid="create-password-confirm"]', password); - await driver.clickElement('[data-testid="create-password-terms"]'); - await driver.clickElement('[data-testid="create-password-wallet"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Choose to secure wallet, and then get recovery phrase and confirm the SRP - * during onboarding flow. - * @param {WebDriver} driver - */ -const onboardingRevealAndConfirmSRP = async (driver) => { - // secure my wallet - await driver.clickElement('[data-testid="secure-wallet-recommended"]'); - - // reveal SRP - await driver.clickElement('[data-testid="recovery-phrase-reveal"]'); - - const revealedSeedPhrase = await driver.findElement( - '[data-testid="recovery-phrase-chips"]', - ); - - const recoveryPhrase = await revealedSeedPhrase.getText(); - - await driver.clickElement('[data-testid="recovery-phrase-next"]'); - - // confirm SRP - const words = recoveryPhrase.split(/\s*(?:[0-9)]+|\n|\.|^$|$)\s*/u); - const finalWords = words.filter((str) => str !== ''); - assert.equal(finalWords.length, 12); - - await driver.fill('[data-testid="recovery-phrase-input-2"]', finalWords[2]); - await driver.fill('[data-testid="recovery-phrase-input-3"]', finalWords[3]); - await driver.fill('[data-testid="recovery-phrase-input-7"]', finalWords[7]); - - await driver.clickElement('[data-testid="confirm-recovery-phrase"]'); - - await driver.clickElementAndWaitToDisappear({ - tag: 'button', - text: 'Confirm', - }); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Complete the onboarding flow by confirming completion. Final step before the - * reminder to pin the extension. - * @param {WebDriver} driver - */ -const onboardingCompleteWalletCreation = async (driver) => { - // complete - await driver.findElement({ text: 'Congratulations', tag: 'h2' }); - await driver.clickElement('[data-testid="onboarding-complete-done"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Move through the steps of pinning extension after successful onboarding - * @param {WebDriver} driver - */ -const onboardingPinExtension = async (driver) => { - // pin extension - await driver.clickElement('[data-testid="pin-extension-next"]'); - await driver.clickElement('[data-testid="pin-extension-done"]'); -}; - const openSRPRevealQuiz = async (driver) => { // navigate settings to reveal SRP await driver.clickElement('[data-testid="account-options-menu-button"]'); @@ -1099,12 +998,6 @@ module.exports = { validateContractDetails, switchToNotificationWindow, getEventPayloads, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, assertInAnyOrder, genRandInitBal, openActionMenuAndStartSendFlow, diff --git a/test/e2e/page-objects/flows/onboarding.flow.ts b/test/e2e/page-objects/flows/onboarding.flow.ts index 4af974bff9af..01c5000162f3 100644 --- a/test/e2e/page-objects/flows/onboarding.flow.ts +++ b/test/e2e/page-objects/flows/onboarding.flow.ts @@ -14,16 +14,19 @@ import { E2E_SRP } from '../../default-fixture'; * * @param options - The options object. * @param options.driver - The WebDriver instance. - * @param options.password - The password to create. Defaults to WALLET_PASSWORD. - * @param options.needNavigateToNewPage - Whether to navigate to a new page to start the onboarding flow. Defaults to true. + * @param [options.password] - The password to create. Defaults to WALLET_PASSWORD. + * @param [options.participateInMetaMetrics] - Whether to participate in MetaMetrics. Defaults to false. + * @param [options.needNavigateToNewPage] - Indicates whether to navigate to a new page before starting the onboarding flow. Defaults to true. */ export const createNewWalletOnboardingFlow = async ({ driver, password = WALLET_PASSWORD, + participateInMetaMetrics = false, needNavigateToNewPage = true, }: { driver: Driver; password?: string; + participateInMetaMetrics?: boolean; needNavigateToNewPage?: boolean; }): Promise => { console.log('Starting the creation of a new wallet onboarding flow'); @@ -37,7 +40,11 @@ export const createNewWalletOnboardingFlow = async ({ const onboardingMetricsPage = new OnboardingMetricsPage(driver); await onboardingMetricsPage.check_pageIsLoaded(); - await onboardingMetricsPage.clickNoThanksButton(); + if (participateInMetaMetrics) { + await onboardingMetricsPage.clickIAgreeButton(); + } else { + await onboardingMetricsPage.clickNoThanksButton(); + } const onboardingPasswordPage = new OnboardingPasswordPage(driver); await onboardingPasswordPage.check_pageIsLoaded(); @@ -97,15 +104,30 @@ export const importSRPOnboardingFlow = async ({ /** * Complete create new wallet onboarding flow * - * @param driver - The WebDriver instance. - * @param password - The password to use. Defaults to WALLET_PASSWORD. + * @param options - The options object. + * @param options.driver - The WebDriver instance. + * @param [options.password] - The password to use. Defaults to WALLET_PASSWORD. + * @param [options.participateInMetaMetrics] - Whether to participate in MetaMetrics. Defaults to false. + * @param [options.needNavigateToNewPage] - Indicates whether to navigate to a new page before starting the onboarding flow. Defaults to true. */ -export const completeCreateNewWalletOnboardingFlow = async ( - driver: Driver, - password: string = WALLET_PASSWORD, -) => { +export const completeCreateNewWalletOnboardingFlow = async ({ + driver, + password = WALLET_PASSWORD, + participateInMetaMetrics = false, + needNavigateToNewPage = true, +}: { + driver: Driver; + password?: string; + participateInMetaMetrics?: boolean; + needNavigateToNewPage?: boolean; +}): Promise => { console.log('start to complete create new wallet onboarding flow '); - await createNewWalletOnboardingFlow({ driver, password }); + await createNewWalletOnboardingFlow({ + driver, + password, + participateInMetaMetrics, + needNavigateToNewPage, + }); const onboardingCompletePage = new OnboardingCompletePage(driver); await onboardingCompletePage.check_pageIsLoaded(); await onboardingCompletePage.check_congratulationsMessageIsDisplayed(); diff --git a/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts b/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts index 2982acaa40c0..692bc547ba05 100644 --- a/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts +++ b/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts @@ -3,6 +3,8 @@ import { Driver } from '../../../webdriver/driver'; class OnboardingMetricsPage { private driver: Driver; + private readonly iAgreeButton = '[data-testid="metametrics-i-agree"]'; + private readonly metametricsMessage = { text: 'Help us improve MetaMask', tag: 'h2', @@ -33,6 +35,10 @@ class OnboardingMetricsPage { async clickNoThanksButton(): Promise { await this.driver.clickElementAndWaitToDisappear(this.noThanksButton); } + + async clickIAgreeButton(): Promise { + await this.driver.clickElementAndWaitToDisappear(this.iAgreeButton); + } } export default OnboardingMetricsPage; diff --git a/test/e2e/tests/metrics/app-installed.spec.js b/test/e2e/tests/metrics/app-installed.spec.ts similarity index 56% rename from test/e2e/tests/metrics/app-installed.spec.js rename to test/e2e/tests/metrics/app-installed.spec.ts index cb2ddde78198..91336e99ad32 100644 --- a/test/e2e/tests/metrics/app-installed.spec.js +++ b/test/e2e/tests/metrics/app-installed.spec.ts @@ -1,25 +1,21 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - getEventPayloads, - tinyDelayMs, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import OnboardingMetricsPage from '../../page-objects/pages/onboarding/onboarding-metrics-page'; +import StartOnboardingPage from '../../page-objects/pages/onboarding/start-onboarding-page'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'App Installed'. Do not use the constants from the metrics constants files, * because if these change we want a strong indicator to our data team that the * shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -44,15 +40,19 @@ describe('App Installed Events @no-mmi', function () { participateInMetaMetrics: true, }) .build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { await driver.navigate(); - await driver.delay(tinyDelayMs); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); + const startOnboardingPage = new StartOnboardingPage(driver); + await startOnboardingPage.check_pageIsLoaded(); + await startOnboardingPage.checkTermsCheckbox(); + await startOnboardingPage.clickCreateWalletButton(); + + const onboardingMetricsPage = new OnboardingMetricsPage(driver); + await onboardingMetricsPage.check_pageIsLoaded(); + await onboardingMetricsPage.clickIAgreeButton(); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 1); @@ -74,14 +74,19 @@ describe('App Installed Events @no-mmi', function () { metaMetricsId: 'fake-metrics-id', }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { await driver.navigate(); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); + const startOnboardingPage = new StartOnboardingPage(driver); + await startOnboardingPage.check_pageIsLoaded(); + await startOnboardingPage.checkTermsCheckbox(); + await startOnboardingPage.clickCreateWalletButton(); + + const onboardingMetricsPage = new OnboardingMetricsPage(driver); + await onboardingMetricsPage.check_pageIsLoaded(); + await onboardingMetricsPage.clickNoThanksButton(); const mockedRequests = await getEventPayloads( driver, diff --git a/test/e2e/tests/metrics/nft-detection-metrics.spec.js b/test/e2e/tests/metrics/nft-detection-metrics.spec.ts similarity index 68% rename from test/e2e/tests/metrics/nft-detection-metrics.spec.js rename to test/e2e/tests/metrics/nft-detection-metrics.spec.ts index a0c901087425..1b3939162915 100644 --- a/test/e2e/tests/metrics/nft-detection-metrics.spec.js +++ b/test/e2e/tests/metrics/nft-detection-metrics.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -72,20 +63,14 @@ describe('Nft detection event @no-mmi', function () { useNftDetection: true, }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 3); assert.deepStrictEqual(events[0].properties, { diff --git a/test/e2e/tests/metrics/token-detection-metrics.spec.js b/test/e2e/tests/metrics/token-detection-metrics.spec.ts similarity index 68% rename from test/e2e/tests/metrics/token-detection-metrics.spec.js rename to test/e2e/tests/metrics/token-detection-metrics.spec.ts index 923f7c86a242..6c8672e81e5e 100644 --- a/test/e2e/tests/metrics/token-detection-metrics.spec.js +++ b/test/e2e/tests/metrics/token-detection-metrics.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -69,19 +60,14 @@ describe('Token detection event @no-mmi', function () { }) .withPreferencesController({ useTokenDetection: true }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 3); diff --git a/test/e2e/tests/metrics/wallet-created.spec.js b/test/e2e/tests/metrics/wallet-created.spec.ts similarity index 61% rename from test/e2e/tests/metrics/wallet-created.spec.js rename to test/e2e/tests/metrics/wallet-created.spec.ts index fbe80fb595dc..bcad42100442 100644 --- a/test/e2e/tests/metrics/wallet-created.spec.js +++ b/test/e2e/tests/metrics/wallet-created.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -58,20 +49,14 @@ describe('Wallet Created Events @no-mmi', function () { participateInMetaMetrics: true, }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 2); assert.deepStrictEqual(events[0].properties, { @@ -101,20 +86,13 @@ describe('Wallet Created Events @no-mmi', function () { metaMetricsId: 'fake-metrics-id', }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); - - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + }); const mockedRequests = await getEventPayloads( driver, mockedEndpoints, diff --git a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts index 992027dd7840..0d7010f25f21 100644 --- a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts @@ -38,10 +38,10 @@ describe('Account syncing - New User @no-mmi', function () { }, async ({ driver }) => { // Create a new wallet - await completeCreateNewWalletOnboardingFlow( + await completeCreateNewWalletOnboardingFlow({ driver, - NOTIFICATIONS_TEAM_PASSWORD, - ); + password: NOTIFICATIONS_TEAM_PASSWORD, + }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); diff --git a/test/e2e/tests/onboarding/onboarding.spec.ts b/test/e2e/tests/onboarding/onboarding.spec.ts index 9ea81f040998..9409ef7e351c 100644 --- a/test/e2e/tests/onboarding/onboarding.spec.ts +++ b/test/e2e/tests/onboarding/onboarding.spec.ts @@ -41,7 +41,9 @@ describe('MetaMask onboarding @no-mmi', function () { title: this.test?.fullTitle(), }, async ({ driver }: { driver: Driver }) => { - await completeCreateNewWalletOnboardingFlow(driver); + await completeCreateNewWalletOnboardingFlow({ + driver, + }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); diff --git a/test/e2e/vault-decryption-chrome.spec.ts b/test/e2e/vault-decryption-chrome.spec.ts index f66ee7d10839..939494b00a60 100644 --- a/test/e2e/vault-decryption-chrome.spec.ts +++ b/test/e2e/vault-decryption-chrome.spec.ts @@ -165,7 +165,7 @@ describe('Vault Decryptor Page', function () { // we don't need to use navigate since MM will automatically open a new window in prod build await driver.waitUntilXWindowHandles(2); - // we cannot use the customized driver functionsas there is no socket for window communications in prod builds + // we cannot use the customized driver functions as there is no socket for window communications in prod builds const windowHandles = await driver.driver.getAllWindowHandles(); // switch to MetaMask window and create a new vault through onboarding flow From 39528b02100a6003f412bbef5e0b560002c945bc Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Tue, 19 Nov 2024 12:45:14 -0500 Subject: [PATCH 007/148] perf: optimize fonts by using woff2 instead of ttf (#26554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reason for change: woff2 is has been supported by Firefox and Chrome since circa 2015, so it's safe to use. It's designed specifically for use on the web. It is a smaller format than an equivalent ttf (which we were using prior to this PR). This PR isn't substantial. On my machine it shaves only 8 milliseconds off of our popup's load time (from ~60.5ms to 52.5ms - using https://github.com/MetaMask/metamask-extension/pull/26555 as the baseline). --- .vscode/cspell.json | 1 + development/build/styles.js | 3 +- .../utils/plugins/ManifestPlugin/index.ts | 5 -- development/webpack/webpack.config.ts | 12 ++- lavamoat/build-system/policy.json | 71 ++++++++++++++++++ package.json | 1 + types/postcss-discard-font-face.d.ts | 68 +++++++++++++++++ ui/css/utilities/fonts.scss | 8 +- .../Euclid/EuclidCircularB-Bold-WebXL.ttf | Bin 150928 -> 0 bytes .../Euclid/EuclidCircularB-Bold-WebXL.woff2 | Bin 0 -> 44544 bytes .../Euclid/EuclidCircularB-Medium-WebXL.woff2 | Bin 0 -> 45444 bytes .../fonts/Euclid/EuclidCircularB-Medium.ttf | Bin 160832 -> 0 bytes .../Euclid/EuclidCircularB-Regular-WebXL.ttf | Bin 154192 -> 0 bytes .../EuclidCircularB-Regular-WebXL.woff2 | Bin 0 -> 45196 bytes .../EuclidCircularB-RegularItalic-WebXL.ttf | Bin 157072 -> 0 bytes .../EuclidCircularB-RegularItalic-WebXL.woff2 | Bin 0 -> 46776 bytes yarn.lock | 53 +++++++++++++ 17 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 types/postcss-discard-font-face.d.ts delete mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf create mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 create mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 delete mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf delete mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.ttf create mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.woff2 delete mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf create mode 100644 ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 51c7db9f6211..0696498afe86 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -38,6 +38,7 @@ "codespace", "codespaces", "corepack", + "crossorigin", "datetime", "datetimes", "dedupe", diff --git a/development/build/styles.js b/development/build/styles.js index a8ac03c96ea5..8dbf0d42d94c 100644 --- a/development/build/styles.js +++ b/development/build/styles.js @@ -5,6 +5,7 @@ const gulpStylelint = require('gulp-stylelint'); const watch = require('gulp-watch'); const sourcemaps = require('gulp-sourcemaps'); const rtlcss = require('postcss-rtlcss'); +const discardFonts = require('postcss-discard-font-face'); const postcss = require('gulp-postcss'); const pipeline = pify(require('readable-stream').pipeline); const sass = require('sass-embedded'); @@ -83,7 +84,7 @@ async function buildScssPipeline(src, dest, devMode) { '-mm-fa-path()': () => new sass.SassString('./fonts/fontawesome'), }, }).on('error', gulpSass.logError), - postcss([autoprefixer(), rtlcss()]), + postcss([autoprefixer(), rtlcss(), discardFonts(['woff2'])]), devMode && sourcemaps.write(), gulp.dest(dest), ].filter(Boolean), diff --git a/development/webpack/utils/plugins/ManifestPlugin/index.ts b/development/webpack/utils/plugins/ManifestPlugin/index.ts index c08cfd7ba6e6..bd18541cc1e3 100644 --- a/development/webpack/utils/plugins/ManifestPlugin/index.ts +++ b/development/webpack/utils/plugins/ManifestPlugin/index.ts @@ -93,11 +93,6 @@ export class ManifestPlugin { '.txt', '.wasm', '.vtt', // very slow to process? - // ttf is disabled as some were getting corrupted during compression. You - // can test this by uncommenting it, running with --zip, and then unzipping - // the resulting zip file. If it is still broken the unzip operation will - // show an error. - // '.ttf', '.wav', '.xml', ]); diff --git a/development/webpack/webpack.config.ts b/development/webpack/webpack.config.ts index d207b3ae741c..9ec97b5507e1 100644 --- a/development/webpack/webpack.config.ts +++ b/development/webpack/webpack.config.ts @@ -17,6 +17,7 @@ import CopyPlugin from 'copy-webpack-plugin'; import HtmlBundlerPlugin from 'html-bundler-webpack-plugin'; import rtlCss from 'postcss-rtlcss'; import autoprefixer from 'autoprefixer'; +import discardFonts from 'postcss-discard-font-face'; import type ReactRefreshPluginType from '@pmmmwh/react-refresh-webpack-plugin'; import { SelfInjectPlugin } from './utils/plugins/SelfInjectPlugin'; import { @@ -112,6 +113,14 @@ const plugins: WebpackPluginInstance[] = [ isTest: args.test, shouldIncludeSnow: args.snow, }, + preload: [ + { + attributes: { as: 'font', crossorigin: true }, + // preload our own fonts, as other fonts use fallback formats we don't + // want to preload + test: /fonts\/\.(?:woff2)$/u, + }, + ], }), new ManifestPlugin({ web_accessible_resources: webAccessibleResources, @@ -282,6 +291,7 @@ const config = { plugins: [ autoprefixer({ overrideBrowserslist: browsersListQuery }), rtlCss({ processEnv: false }), + discardFonts(['woff2']), // keep woff2 fonts ], }, }, @@ -323,7 +333,7 @@ const config = { }, // images, fonts, wasm, etc. { - test: /\.(?:png|jpe?g|ico|webp|svg|gif|ttf|eot|woff2?|wasm)$/u, + test: /\.(?:png|jpe?g|ico|webp|svg|gif|woff2|wasm)$/u, type: 'asset/resource', generator: { filename: 'assets/[name].[contenthash][ext]' }, }, diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index 1cadff4100d7..5338922720ef 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -6565,6 +6565,72 @@ "postcss>source-map-js": true } }, + "postcss-discard-font-face": { + "packages": { + "postcss-discard-font-face>balanced-match": true, + "postcss-discard-font-face>postcss": true + } + }, + "postcss-discard-font-face>postcss": { + "builtin": { + "fs": true, + "path": true + }, + "globals": { + "console": true + }, + "packages": { + "postcss-discard-font-face>postcss>chalk": true, + "postcss-discard-font-face>postcss>js-base64": true, + "postcss-discard-font-face>postcss>source-map": true, + "postcss-discard-font-face>postcss>supports-color": true + } + }, + "postcss-discard-font-face>postcss>chalk": { + "globals": { + "process.env.TERM": true, + "process.platform": true + }, + "packages": { + "postcss-discard-font-face>postcss>chalk>ansi-styles": true, + "postcss-discard-font-face>postcss>chalk>escape-string-regexp": true, + "postcss-discard-font-face>postcss>chalk>strip-ansi": true, + "postcss-discard-font-face>postcss>chalk>supports-color": true, + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi": true + } + }, + "postcss-discard-font-face>postcss>chalk>strip-ansi": { + "packages": { + "postcss-discard-font-face>postcss>chalk>strip-ansi>ansi-regex": true + } + }, + "postcss-discard-font-face>postcss>chalk>supports-color": { + "globals": { + "process.argv": true, + "process.env": true, + "process.platform": true, + "process.stdout": true + } + }, + "postcss-discard-font-face>postcss>js-base64": { + "globals": { + "Base64": "write", + "define": true + } + }, + "postcss-discard-font-face>postcss>supports-color": { + "globals": { + "process": true + }, + "packages": { + "postcss-discard-font-face>postcss>supports-color>has-flag": true + } + }, + "postcss-discard-font-face>postcss>supports-color>has-flag": { + "globals": { + "process.argv": true + } + }, "postcss-rtlcss": { "globals": { "SuppressedError": true @@ -7383,6 +7449,11 @@ "zipToModeAwareCache": true } }, + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi": { + "packages": { + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi>ansi-regex": true + } + }, "prop-types": { "globals": { "console": true, diff --git a/package.json b/package.json index a04f6e9b3bc7..6d97d41c658f 100644 --- a/package.json +++ b/package.json @@ -631,6 +631,7 @@ "nyc": "^15.1.0", "path-browserify": "^1.0.1", "postcss": "^8.4.32", + "postcss-discard-font-face": "^3.0.0", "postcss-loader": "^8.1.1", "postcss-rtlcss": "^4.0.9", "prettier": "^2.7.1", diff --git a/types/postcss-discard-font-face.d.ts b/types/postcss-discard-font-face.d.ts new file mode 100644 index 000000000000..83adf5c4a43c --- /dev/null +++ b/types/postcss-discard-font-face.d.ts @@ -0,0 +1,68 @@ +declare module 'postcss-discard-font-face' { + import { type Plugin as PostCssPlugin } from 'postcss'; + + /** + * For each font, return `false` to remove, or a new string if you would like + * to transform the *URL*. + * + * @example + * ```typescript + * (url: string, format: string) => { + * return !url.includes('.exe'); // remove if url ends with `.exe` + * } + * ``` + */ + type FilterFunction = (url: string, format: string) => boolean | string; + + /** + * Allowlist is an array of formats to *keep*. + * + * @example + * ```javascript + * ['ttf', 'svg'] // keep ttf and svg formats + * ``` + */ + type Allowlist = string[]; + + /** + * @example + * ```javascript + * { + * weight: [400], + * style: ['normal'] + * } + * ``` + */ + type Properties = Record; + + /** + * @example + * ```typescript + * const options = { + * font: { + * // keep `Arial` with `weight: 400` and `style: normal` + * Arial: { + * weight: [400], + * style: ["normal"] + * } + * } + * } + * ``` + */ + type Options = { + font: { + [fontName: string]: Properties; + }; + }; + + /** + * Discard font faces with PostCSS. + * + * @param filter - A filter function, allowlist, or options object + */ + function discardFontFace( + filter: Allowlist | FilterFunction | Options, + ): PostCssPlugin; + + export = discardFontFace; +} diff --git a/ui/css/utilities/fonts.scss b/ui/css/utilities/fonts.scss index a189b49d9a94..a61ec03ad0ab 100644 --- a/ui/css/utilities/fonts.scss +++ b/ui/css/utilities/fonts.scss @@ -25,28 +25,28 @@ $font-path: './fonts'; font-family: 'Euclid Circular B'; font-style: normal; font-weight: 400; - src: url('#{$font-path}/Euclid/EuclidCircularB-Regular-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Regular-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: italic; font-weight: 400; - src: url('#{$font-path}/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: normal; font-weight: 500; - src: url('#{$font-path}/Euclid/EuclidCircularB-Medium.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Medium-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: normal; font-weight: 700; - src: url('#{$font-path}/Euclid/EuclidCircularB-Bold-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Bold-WebXL.woff2') format('woff2'); } // Brand Evolution Font Families diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf deleted file mode 100644 index 244ebba0a48132cefec3cb28bc356537190566da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 150928 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 _#Tk7*DV*^sG!F#HKA0_!w%gwm1 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 QI_#MO?3P0CYs=aH4`8D400000 diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a94605d95a59bf6a6a1d86331a662ee5299908f4 GIT binary patch literal 44544 zcmV)BK*PUxPew8T0RR910ImQ46951J0!@$r0Iin*0{}t*00000000000000000000 z0000PMjM0HG#sKB6oo|wU;vFM2q+1Ha}g8@gY`Irwlfmx6ah8@Bm5 z7zZE>fj3)0Cz1nchaaJe+HTWr2q#&ldaD^)Av<3Kw+*;%PbBV8HEbISb%X8z!K!ZB zmi_<#|NpN^8Zu^=6mxBXRzW~Sottj&t4YLT%5B#}>Fb^xBGcfF*TQ773bH-4ZMr)X-DFd?PN~gUa;k+5_U$oGkh(;FXcCpe6&zufsb2;g z1tktg=I%@;Qq7q}Wdj=@cRKSntOKPd&KAiC3MlmXoPU}83nLk*c4mdTM4K*fMxUwV z58`kkYT#-U`Lk@5bY*Xc85aBp*BAOeT4XBTLAK;@M~9(Wg$;`A+isQ?;*loVL=yMr z9`S4$PX$Jl^OHa*ZykD~z+?D-`(?5>Cc7W`C+rGq*b1+~arw1-&mLGrLZ4bY|D zTMCm=4@d@LJ%Pjq;w0r|>@;*7(*M;}^+gs~mSmZz>~it8y;I%1zz2wNa@nRw6om8g zQCTxyx@pph+~oi1$Ef$$KDVd)&4frYyQr`Vmycp$h^2^hDIfj>bK(F0?f$>H)~-7B zA=do{T9Ft@A(_cHZ7UU{9`6j$-2THt2Eu?v-|p7I$T8RkVqN2LN10xAZGs60_> z6Jw}=HU=UXJ=FJ&>Su$I|L1w#{B!TidM_{QBA&fS>__6hy=k-3;AW z>~K`v*fnf%(#3L7ZNHsraWBBGN%{SB+21m+&Nr!LtKA4_*odwGwBGAs9swWw%C&4s6cy-|Nl5>=lz+bg+LPsEl6UBJxHUyHU^sfw~eC0F&tT) z3kIPWEmS8+k$C0S7uT2fjLWDEE0PovmYtHp=US9%&pl8&xt!2!s%de{5vfQG(cSU#A5zvLmjXYCMD2W1WwTt}=VYsP||n@wJF_O^C=F(x1d0stt0xpE7a9MLn& zf{?z>Rl4Ev<05>3DHre{U?n>c-}8=V{0r`}oOt_Lq>aHD8i-V;`dapjhj%zQ_V)fK z$P#GgGTV-9*?Y~e_`bA)lHrw*5X7Q3!qV*puq4^QCb{oSTbp3furMFAd!C8&Z@Q|q zeibz%Ub7Bj=-g9oPT$OVTgXLMjO;6&w`iUJKEET@r1Cj(sIs z%o9@Yw9y)-IUlVtrV7!0s5ORyGnh5NT3F{Zo1HbOOY)n`E=xDO(5LZU5?+)Xe9?^; z9($q#U>#vr05;jKZPv#V{P6r|`f9tE;3#AqR_gvJIFu+lhg6n}!7@CKhZK~DYbfN= z9-Qyt3WppYPEpW9ntHv1VwipaM9?~ zjQ@;AT8&2XNiwWtJGNve8`&WT|DX99jlh_&y?J()Ft7v;v3C!tDDQd#Xo{;8tMSFh zTrm~Zz58ENt#*OYm|KQa>#4e?6Dj*%e?@oQ6{Uy)Kto^PI1IIq=5j;U(cBn;k3Zxj zT8W!;b+L-6zOuR8mVB=4TYH=3Hn-5#3U^9f26ZmbJ;v+~f&QSYKPZGcMd>bq?iTlj zM>tS)8kghgn1H^S^`%JBu3pLq^Kd7HNQ5v#2;;vVyZ4#vjNgX;pE)BW+fQiH5bW(G zy_IxUrmioDh={e>-#=?!MgMmG;k<`;_=hpZYHf@b5hY5AXh>b7)3-@E=0G%YCuUXI zcYg~WhVDJR5<&^Zlu%4CMhIbq5W<*pzU=4woJ7I9!_e5u1?`?+?Hk8vvE+=-&48W) z_2U?uJ-Vt-e(9K?Af9l<_>4z6YtwdBQcq1{$^f&Hz}fGPHLb-hzuHsns9+EV`bh5a zIluR(_P(REGG~R?dlc6NY#EtgMGRO=$>aUC)&UBDr@Kfp@DDRtR$!|P$S=0{j-vK+ zRj8o$OURZ{`%Oy>YH6W>shYruph0=DHKG@6!w$Et7zb1Cn}Vn2G2X*POz@wEhZ?c?bECJLzZ&U1+3~GNALZ>?z9Jx2_|__dJpEmPfo~O#gIqde z07Fgv10WxY*&r!RRp_MRW%oIF8+_&;$IfvrSM8^0jxUezihq_KiCN;7*pqm>x%n!8 zD=;}v?o4h^4kaI@s#EVLeNO5Pz41G#XK7vTdhU%1^6>Y_gFC|m;e(#Oo;hBDUfaP& zkOqcg*XT|3F7V!oSchmnCh{{xFYw?j?${qd+(UxEE2IQFjyW)Rei-CTWHWLr@)-(= zV&J>Gw`UiMhnj=xM)jcjQG@7s^ev1A(~Mb;>A)Q5uw4Xm%-U{XUtoSWVnGmQo{4S2 zwqbi8Slu>`o9y?%*W*_l+Rg4_ZtT`0f(rpnSVuTSI6t}$N&}s zR$v{_3tR{vhM;56D0B_F13iLXKp&y66laPj1y2Df0*b6lDJeQi^s-zsHJ}7}`X7x-yx7QN_SME~T#1=*l z)0@H;BeKAwLr4rv#f)Z6y&A_x!_G@5Nm{sP$m0@1DT=S`ogQ?F?bYL=t#X8Xtq zbM!l_-X*s!ayO~Pz-*QKzVwJ6JuuIh^C1B|6|4X+<&NZD&bweogv?$I2<<_^&S*L9 ziWT-GEbUEFx_!y>c|G6oW&wDs$f370X4Y9onU0oSj`zy%lVg=uaM%p^B)gj(6eC3i(b0#b*0a}?V~Sz?swn%9)9w( z^s~PM_-)37h0jNOl=`!_c%D+0V%+AnxR`nkMP3ENBJ{t`dH(RXFrjhlJHri0>}o$xg@D% z80s9FG}LC^RAC*j_Zk6Lr|#@C*;zSahG0)S9hR@G8lPXezJ<)9|HNI9h@ z(fYg*zBWi+lc~EqJIty=#ZC@w-ze6(S=>VCTzSagBd8?Ut*?s;pC&+C3=?7`!Zjqj zNN^;rs-H#Zu9h*;1Yuojsuv5`9>)lZQ)qc){0y^0{+LaCaduFDrT<$Hrr*Ml+ zn@`U;9WE^x)?w1k2Gc9{X1eL><%cC38Lf}&*t%yxMjo$jjqBH?#I$ea^}RPVfg&!# zGbt*T+$kqlj9b($bS0mJVvepx4N+R}ivYNwAjgPst%EBNe_rc#b2et}_D2MRqQOWf zPldmN9GyW@z-Y5!`n*Jq{Y_*S4B&hkj>b$&vb1DI+Ic~KK!^^K#H~Gt*ZAax{zy<+ zEB*nvuSH)BUqBI6oMTBqOZF^5cykg;(kx6{G(FH3z*J=o@LN8c$eczR=`FFh+gwWv zqZg7UtygLmJpZ;HV~>z%krOe0NOt@fcSZu=krvw7=!c)A?D(v+V&irk%n;3(ow|`~ zUU{hWa@a`IS(^|x!<7-~A_cQVvx~neAx~)|4yaak!CSC7}we=iv@ z?bayK>6i$%2sX#~VqoEFMSdehqAojmQnMf4g3@|l zyPi7U>*UpSqFb6CaeB*}8_E&}etP6WbqaHjk~CV@mH@zG1Dh?ZxlqDvc*u zsSAxJJ^&;hG6+2Qh*9CaGXcI%NqVTf(sX4F=QXiaie_o_NHg!&$8Y!o zj}gsGMo%HnNVniaC<(c7jN+NdnJsVyC=YSCunZBBFGrrh7p15sDh$M<=VuCTwgpMr{7vdo`xt$c1Tw2~`Drp*IhD6aQ z^L@0TC&f`S@-+o&rCZHxjabV%o?!iGE58i`D_BVz?R1QGL0(!;P=gFHoF8#u%uC}E z6NyQtm}X{l*8k1n=2>8oCB5vW6;@db>$nZrlr7k1hu!>MarRLMUOFTkRpA&;^y$DE z=Ui~f71!K^+nFci8D4nhO}`g>@TtF~zVeOl{NN|Q^zXnQ{_;=%f35%qAh0HaEFSz( zVL%fFNhFjO2Rs@HQjygnM-;%I6c$ukU6ckbI<4peH0Us}!hg?A*BX z;Hlqo!Qv&6ObV%z){=C{(3#M9T4<$Bx0jSlfIMA1t%UJR(BZUF%5*t21vMQ-KSTQB zE(!XlK5KkxPSB!BzmED}(r@_f#R<+ln)$T*hqwR!$?$cf_27*n8jTr?z-o`ZJ*SgT z*Q#!LdZaphUEiJb*oDexl0HiIXhmaU-X>#Bq4?BVX}4D_vRS&IAutFbkVw%&t|z&K z+L+ePC}aUt*R&aQDD8iZA;K5~CCkGK5`cN&45Ngr9<=^ddqc(@HC1w6EIvdwYNFjceY%s?#`K_jEH1P7mC0`1|_W`6IWycPl#h zHHH2frr%QbQ|i7+UzV{2!%4x!!rNn3+wZ0HOX+Fx3AJ`wPTNJ^8Sl9K#idLpm&A@T z4lOdAAR15#;UH;;p8y9ciDUq2z=j8mm;{2jXk_IwM$?Vq7^~9P8?6djJxdUWViD^A zrO@-WT|7pCLotklR~(A+J}; zq^Yf{w^7ev9O|^Hz|J>MFXj@yvZ@oTz|8cqt*SIZf4)gs%u~F*chXxKG4fZDjZ?<2 zwwXGtR%sc1OFe^;T--dsIh836ek$PL3`w4hhl+bV-8THgG(su(d4*sfu$?yZcxn>z zoAF?(;%NADWOpu@D%4EWrLon&g-J)x&ZP2!CU#4STRM3K^7eM!IB}Hx0U@k9LX8;B zsaojlj(bb>4Afy^=$)z@`*WTTXksjaJ(QAeT|W?iYMLwd}ep6UOnm6J$|YRy-WO3Kacl zLK5?Fasn?E2xljo}Q?tdg+zGO%H%*QN zjB!mQiSo?AMff|FT|-{HXD~}BPyt?*BD~H7}5-F1` zitM%bss0Zu`D0o5$|yy61(HMz4(s0=MeXolqFhBzW_VU`KUI_@1{|=p*m#2syx%Gj zKklIig$Z_>`e(UO$t_Z`4;=)?1ColQHOV8XPKJaw=E09b+eGPRe@A}Mn37jK){i8C zvkFb+r*>M+PSG!_b;eM~*znU*Ka1hwmq~@N6yrzdtxB-0@bgH6j6OqbV9x`23S@oNn)R~&bCU>W{M^mb)>%!C?OiTHO zxRUNItYJ}+TEd3V)8d)&H=e2I;CWuuJD2TZH+$I2zFkBgJ`Q~X$2q|{&U1l_e92dQ z%{RrjUstc790`RC`Do-t+D4;;{yxLgae0dmn*lSK#cbv?=>v!jr4u0c#z;ZW6T z-lvLETcmCzCJ6$)_X);jl$_Iwc;?}2Mro0@&P@rtc#3MOnMO6$rO{eSQ^?fir(#4s zQ$%4=^6haDM>W-@w1+cyMBE9Is)quZFg2x9KH};qDpb@}Q0#MBJTdqpC_F8JpZ5#O zMl8y26x&?1-Umt%6GTi9aR{>D-2z`JN~>sE5)qu3JPk#}!W>@8q>M_rsR&0$`L)9w zgHH*dZ0sDIT=~XkDX}7Vf-EXak0%M=bQEn`Jc$Vmng)$mL@P{M%C3w_d65VqDy*I2 z*farzwJ9YvYpl$jAd8w{C${06h{~&IA`%O5ato(WL@rFe6r&8KoLnT0MmaUGShUgZ z;n>*;KvSC$t8*tv>i=_C#5(M!=SlCfaI0%PAh+dF3x$Wg%UlN>Vc|Ck1|!IlOQFDr zuTQ$50#QXZX*3opYJ>=%4V9x|K~hn2!;W3-O9>GocqlFVgzLaXivrZ3l1+}`;h$(N9UI<#)yisP4Rz3zAt71*q zJ=5ailoOCA`q7fgvpDD?xcGKmr&$A8_EJsyS}w5~;W=O`>lM9KPah820tl!aZ)?Ak2KpwVI;~|88Gu{ok(q9dRG~y6t`BDwXuyO6lu8;Dzlvfs1eSd8$jW!9kD{zIu zHEuTqCLk-787z*HpDP{>mSmfz&l1YhRchT5spFnBDHBqS{!DITVAV|+Hkdx;5203L z_0&pg$iASwYG5josTvcAl%ps`R7SNH{U(EGNKVWYW|y6Ev$hbC=&jO`{YC`|zk>hd z4}-Atp!u+X$^MKEK-u0lTp}`^lGDWwOoYthJEU|?&8A!eiHY3MC!1TL`?cMKJ#BlV z94H(uoCvHWJ4_1^@??sdOE&0wV+%WD8-j#6&VZAmqk$8H-mOz8g~pleNM|jk@>WZ( z5BC%Z_Lx&@cRUXx#v2>-P3MhVVWYV}+BQ$B67k-2fCGajCUy1O?v4DMRnb_U$Evo$YR z&4CTDN|-IFdMpWyNF{KIF@x}(vY=E=55Fv>ZjA^TF__UVZd@YGh|nn@bY5#CC3I}l z8#{DKyMuG)kvn8_347}HI!6m9K>{BYlo|=?aID3iiaizkxKc9OMX_{z-%GlA0(<90 znjIR1OZOi+xEB4I!@Sc$WYt7-^Z4Tr%kTaZ^PDo8FEv8V`XGxZB5H92uK@sxOa z%tp$4akYOY(!hmkLumr(En2Dd`eBnB2J@-TQ8=h!?5HZFFrGY*qS})8jV*NkZnhy& zm$Q}B>&P`%y!04256jKEQZm*#8zu`0QB&~jq+mhZJQ7&y+CYmH=rnI*{yb?hrc^bt-ZIMUqsA zaKvLHo3*~OonkEr&qDdYbreAO08*Y*IADUuDX%Vv+^Gr!nTt5fah@HaTdIack=H`= zd!nV8IfL_x#3pVal~k_w#h6`Au4$9^_w`xsC}08(N0~$HVx36a=qEy}L_j$CyqX3u z23ao-Gkxw*Y#7Gy8?O(t^0(F>Q<6Q{VzPu1>#1;$YH2}LCY4Bx+hLC9!P25uh0x(7 zIdL_9BZIAEsr5VdiCM(1Q#S~iL+*9#N>nS;+tO#pVej*`C@UN?W5+`o;{kNKia5?O9J^^e%7 z?o8EbMVI(r!aGD6F|r`PPhsK@+9Lbx~`PMG8$G+eBFAh?dryd&+<|%~soM zo#XZz&}W@32JNgz2MpNdn1ksayX`e#&}67jjvlAO28_7qn5T#IIjr507DwmDC=u#) z5I{PQ6~I2pkjckjS8yKlMsJeAVnz(tEL%YzbWuo#=B(q=CA5C(=+Xv&7QP`!P;OB2 zz6h`3xB*<|bHOB3ATQN!ugA8hM6^RuJY+603CEmcx%OK$()exu_>ijfh*Bc|PC-X~? z4?w8#K~N>gFfd~}Fzhuj$!->@I7CVJmkbh6&!OGWx{t%kVr2_UFp<_)p%Cso!A)Ts z^AvJQvFa|TPZ(7Dy$zFXvYE`pn*7PcB&|z8YYns=jqN7(C<=1BY4i}|I2d=1&G`n# zH4);9PruQ}#%6LngmU@|IFl0L5IRU(i?Uci!%Vtr5D0_@jZ>4GWtraaykYo%&mkd3 zvkB6RGPVL+aCD4cGjFHRe-nSrS|od!0x;fk!h@a4r!H;GcOefYuZxg(!IZ$Prm15K z3vDjAh>!betuOGEZyP`N`aL0pZbLb}0e=hW8UF7HqlgSKG8FjY7yQ=*F^fOAYnN!P z0aNBWq_fGK5wbipH-{oVF=u zaoVdRvhGOslJ;3)v#$uUqn6&b3>4YbBL_|IF=($0B11>|s3V5upz?=oa9FD&4m%q6 z95YSZBwHIHLxH*b+F~;?ajmf<3|{J970w|b1+kR*jZ{%et264k@TZX7Xw*br$XB6l zS8cElHR&vRKY30Wd5haug+a(nNtt4PD4GSx8k^7u@Rrjg=jv2Rim}AZW=&V2$HgN|9EZhyyhE}D1tz>+2W^^{9<2{q!#YWcR(jl-Uu)Rk!orW7w!RX9nd#c5Te zYVlg4z^KZD_b82>6!57D5V91!`h|40LoLUi2t`EGC1wJ7Lknk3@QyO&qh&N3c3>V? z8sobTc>}p`BaRG19CEp07Zri5q)dKcW{sF4Rx(6|wA*lKiQq&IDtU*rLpMxTP*Ns? zAxx(Y-(;B(7JRqsKHG>JwbIemP3H6G3I7?ggN5Q1;1{vdL`=-~+G&I`oN8UtAOc`P z`j|ZPWA1`r3B&056Bj(c_E7GPAvny8>(+s7T%IjR3fEsag3}87O(_|AOB6( z>%or8`vpw28vj$}t_N19zZPxG03(*a^6#5#B>By%Pdn72MFY+usWdX|Xw~y2J)rKO z8#xqy16^uWEW)=&qZD)nPlyej?O=~FhUTT^+ZCQzvY5w~;F2cFRXy{FE8i1%+(WAZ z6Z&af)q{Y*q=5#RLRn%3rz$2JG4?WSU>H*Qb4*BXjbJCI5=8gSd;lN()}`Jyi7KDS zz*R*&G0#ACI44f{QmX`Xog=Y zQI|jw<1Q8|__ZiLlkzpw@Tz4Y!e?i&N{iB^P(rk|b6#%)^mIV(X`p`SwQ0!hPi+BCyCO=DZ^n}CF7NfTN@#rDxkgV4|@RP=%7;}aJUiAwraf6AL1R!bLnMnGKssvu%UUKd1l#E1@QU$CRlZqSMGEzMMK#M)!gAh;W@!%8WLSDt7bLc(%BNg5v@^RLO7-$gxL@zSv3W|e1AQt9XRb(DOqc7RFR-qJ7ptQC-+e)L>lA1(^Y6q;~9iVE{&il{OQ-4~L z)Q;sFF&5TR4D0r$vV= zdO1?&462E#XngQ&zJIH5G*nLKbIhaEL7bt0S&E6$AV|SN+J)^v=>MTFs1A)8HI1ON zhUZKnreq_iko*<~QuMG!8p#?+wb6@2Hs?n}Mza2z6fO_I6E4`)ZbQZ8`bLL==^5|F!KXP z2E)rHKzc)EPf0lnV1ltI1wT?Yplil2+3fyR`pofuZoeVHPx z(9mUcpW)C|*n>ENt0jEHVLXf}JF*T^zEpU9%t5=aW(q5U(wv+YNV6^o8RE&V zZXfPM#-a2wlRhcGy-m3b_RuUXpz~r2CN}oSI(|$J))th^RyYRvK8M`klggCEPR7$Wh)~X9iD2# zT#)S>&c$VG`!a$Hg4KsBmWGD$cu!!aBNuO8pYkvC={>2ZqQ{&VxzSf&cd6ZNUuM`T zC=*F>%ox;D#;ujT4yVj_8$`7omw2UGf$!r{Ec7o0r}v(51h^fm5`f-(jP^C7cnqOD z&;~Ht81Nri|KVCo4fCjAGz$0?7aNOe1n<-lI|671>8Y~~0$on{$bKbPv^mv*BQ037 zsh!5NYU4mo5etiE#6sGG>`Y$Ok2K-RL8NOQrjy8+c2eBrlVyGR>mL^v9?e}GX|&E) zy`+UX(RCE~8<+Hg?303aG?uRi|gIxJ820F$Kot0;0X)mUsTG=?f2*tfBA+f`ucf^BWwuk+{IEWGCq9aD)ml!#%X_H@9KvUDdCN!oq^Ws z>bKs1XSe)2i3~dZm0w4}k!X-89dh`oic$4sF~)ZD6p}C2 z;s}b?vyjCKPEV5d(hQFHMl+~>l~r-)vZ~}k2pP=5(rlX9n2i=*MB!AUCs8qd*;2hGje(On5T1oY5j@l$As7z1TQR=cCMv*a|2(8ghtJ<8{#x zgl@j?d4xA?a7rtr-CwZSJZjjm1!hZr7i4kM8wx0ftOy6iN~CX)X{~6PPd6%ZN;gGM zb@t#s4&SW|$6 zxzCwlZ3mM4H9PAsKd6k64jth;ur1PZpht4B$0%hJG+_peik71^Upmg_lg)UZ-FQh^ zD~3&M{3MKClwI+SR*;=x2@Sx`Qs^u%+aW+M47g-iMl*=tf~W&U|03{^zY3gthyU#F z6OT$V;E5MpYgn{FiXlt^Dh{cov!eqWIu=v(>1kYH0K*Zz>4M7aAePbO0g$9n+&Ae< zwL!=;mVZ$B)TXirRed$=*$k#!94r3tpo<(9K(;I1oZ@zIUK=0eU?+F5KI5q$pbn&La`j(pqc0N7c>XN~$===}u(BWmd$y+QwE0NaVGnx#JpujHSvZ$r9G+>5(ko87bvJ2hh39PI~z z%QA5eVV{P$fTbO&w!r4K*R z(nIFQaMY5bLq3ZygZ!zpSNsU8Yn5Jyw&6r|pBTJWjmgrgCkp^hy0Ys`zAHpw`3v-q z2X^ZOL#{9cJbvhwRt>xDryFP~lO2W3&V68sj%lvfGSISN!^LW-HX_xsN?KwbsrioT zT_rLJ7FEEu44V;q($oUsNQ2nCZn1$IROaXKd| zWXtM`uY3hoEra=&B6dn|+i2V;nf@Q4?OL2jQ6b3euESf{jv0R9qYPm$K#DroeK!WU zdE|RPdzX^(Z9t3IH))h}3*GXT^6!R(1drW%3--J`Csi;S6F(@%adT6k=T7B{(}XQG zi;->n2o`ruk^%W;+KCkW0|vZV$RQ2cfWLsCYr*PmYe6;AOls2>HZ4>GkP2fbNyN z?E+PI)&dqPebNRkRy`C!42FMpSqGoLt7ZksK3N<>oc`oitAIPQ42O_Qy1IttK9Uze zeYsk6xJ^0<@-8f>{l=RMh>3|sF0`jdXdF&9mQ(3U&E0NEqalfD07aRW!e-$Q9l)Om zAri(Y^|q1`goLGefvDSs5wb+qUHKwWdy)Y~f+Y_1m*R|yD)5-ko>txc%?&{Ij!^>% z)%g$#l{`~vhZQX(j54O0b{N58Ifb+Ioax|Xrd$&K$Jm{@J)x#_rR-$nY8kqb)RK+E zdjxvZt>}Ua@T63Y)i%;}1;lGZL}I)<2hrZhLd=AvXV~RF+A?#xE$%wxZp4WJs}~_4 z|4FZV$FA}KW|ykOr+cd+VC`B=P~BP?(_~%MhGcB9jgF3YqZT$cHt)Z{@3iCGxPd)X zBzHFS|L}E90R8-ftc4E_xn#y7`PgwFFEONJ8qQ_~xN7-M^xfKA@d%+octHI0$KQSR z&U}aL{`p&fF}%R<4^~xUuv?o#fcrW;OITX^08&XQ-=J>`Pi|!f)J2cciS}zjCUsuAf>|BLr9b0L|3dd1qWcewpGW? z?Hy*w)W;NT7DP|fT8kLxFP?_Dj1Hf~1l38>u}Khgczs=n2{kUGE<>cynkNja?y_F* zUHhN>YxdS=VUMSCvfkR{vhf8aTgUa13^Ck>En_h_?ZekE^- zoI#8Qwz$YgN59NhBL29&rB6cvN>H<;Ar`SeP?H&N%0aX@=Lqq%BzEM;?#C|R&PHJJ z3*$|(iU>$N`7aYNed6UY*j&UhfJp943Tn7szY*fHfH+yhJ9a2`mtKHR zTk9W%y#nxo^OZ$^xq1744h`INfm6_5*zfnRt#UK-o!3v&{fYVXmV*do*J22Wp<)6m z%Gc%7oI|IMm(@s$GWk!v!3s)cKZb%Ppi8Wqz@6u&Yh{Fk_d(Ki+^#JK;C)jwsIabL zx3|RN>dFj?2A{)#hA_}PENU*^vbQu1_iAssG(<_h;*FY6KgT@Fp}w7|)zNG=BIWb_ zN`ol&b1ODaEl0qemG^sF4iC=XT=I_SZggGoY#R2y3z@4(m2k{JmY(OECzS;AiCZW% zOKBjW*`$-oGu%F3AR%FFXR|$#OEz43ofdAg@`M~!t#MV@U;QZKrz8yBQ!pF(OgAel!fS)(wLH= zI{QCB6sTY|kn-9&wGvf$8pEl5oP1466Qj3AbKj;3BL$m+!p6Uxcs1M+&`!~Zk%bp; zui#Z0HA#6{P;_WgoeOEFGDyh6QM-9usw$fxtr-zt6!z0g#8!ONZV62LHkq3zp`oR) z^d>zaq`gaEZR)cQ=g}Imb^a+05l<#3aEOOSYZ=1j+jhVGVDZD~OmExVDf;fqr=99E zXcy-k(;dL?KHXO84qJNz^&t&TzdDn{)%9ixO{LvSC~n~qq_+$JGg`|mAb_8!BE#+@ z@o*Ioz-xi0k?Q$0AJxdD0*__GK{^zjI*Z=qRUsaklly|*%7$W|}YC6WqQx|FR_qIPnZCnD!KapDI4rC4G*Nr^B* zypJoq%X_mod%`%N<7IQKfS+|$;~#wOJgzMc{mVif$lhhEmmwfGb5)27>~eK;-`e^1 z^S1&6?SqCswEXUu|9kuEn*H!?diRNc_{FuO-P#1anxpmlqFzjqZuHumhI=3giOQDT zro5&)^Ifq=|Gt=RsT_N?UY4Ss@q{8It!=JCj?bfJEVlrO{fvsCf3N@|W0_ z{2T!X!e)L(VUGPsJ7x@5K%*dFK_3Xs8hDB@WNvh3Fb%ca4gt_>>9|oY*i0wf7zo5?j%avcvf;TqH%FGBDf(&aFWL zPJ;y_OP)_Su~9`~KT2eHOZCHgx$j#3Pi`Pm(mxeet7wSFIzck-v*xX{N9(-TV&}nB zdZ^TSq{58K-ig)ZOWlh{T{Ex#l~ z#8fn%W=A;#4f}lD+&>*({4*p58-w!0pcW&AGvzK69BHDw%&s}#_NXVKG0Ho>znzln)*ee*KDx)h58q?A#(9(g8CldxqWK-;5+L&vf4E zx^wS2z)OIocc`tC*Nn=nb1j+}~+HhSPyIrF|a4D7D7o@%D*$GM@v)t$izPCoUa3cjZ#^zN^%+rFCu7-P$g{9X?(j$4+f-C$RUfh5)Ay zUD^X|o(ThvZLe+~ceM^2i2{Q5or+lIibMMeLxKb61KXzn;KubL9UMbHcI}JqvdtmK zc@&77GS@Eb+L5ld$L7ZRs#(%$^Uxl<)qSc1z_}yGE}VKJB_2rfu34-aGDw#}z{Pj4 z(e;N1NQqnaLK0|DtKTcF(vunP>=;V5b$@#IhiZe(3Qyh(%vdY$Hh4hG#y_ZWD~9l6 z5(8+N1xA67o@jTD`LWy+V)Jv7A}t{OTM22AAHMe6H=Gd-@zo<YfGX{ch|9>qqpFqU!Bcmi@x z|Kb}p|3Vs&&`0I&$m zpwi@v;2E!u#>?MJYv1@nfnVEq;rn%3nSheA$y87h^_Tms7tz)~ zu~-z=E06SpvsP6jlxm=3=M&XFFxgN}7dH-F`8jTkpk*5AIrznN3{IuW(q^82?Khv- zPKxB37&ygs4o#;ka&ivd`29++OpGZgcb1%7qd+loU0&wlhi5X?rR@{9{_sb8%-+|g zWa1W(>>ioTGRs*!aOY2!^yCEbj;WY=1U2`J&gIyZU1#q7g?~zYDppI)!YibucWge- zt?oJZ;BWs-S0v)ddYGnZF-v@Sm}VHZN}N1Qa{`?v&K|avVxA_x``KmWDlzg4V&RKx2V8*& z%(KSzwLcm|KWzfuYCLGv()a$F?6xgG`+tA;-~JheM*Dg2@zK8%q2C%Af?xPG-23GE z-2VnRVId~57)!8xQp`*_wzFODQU49UZL6Mn+&#OREnfLn54%ZApL{ddIMJ4kv<(pB zTETi!1A@|)A#>YLYs z7S>Q>i)!j@*Lv8S;pSTMQdhCO6|8VYD_+TFtaMEq*xv2m!5!Jrxa~cXk6B2is6`&L z)yv+{=XjU9*+5_PUBiv`0HB1Vh)JD3b6k$%^OlmuGfqFYyVk0DYbD^}ZuGeSwW8Go zEbV4bI@ik95U{LUJ?(sLttHTjZuhJUwYQEyC%e=0F4mEaK%aKE7hS5e9_@X9_j=jo zx>`@5Q{C@XSF*H$K&N}q>#k;PBdk}kuwJ8r)lLm-Ck;F=TMKJ99Xzh-;W5ns_d6L8 z>Y!`~1VIZy3qcD(3qcD(3qcD(3&Bh3br_<_SUc#pR-yE2y;uSOLlKL(4Y0g&(}AH7 zh=@_r8;bRin%8h*`c6d%z#wqsgNAG4svo5@9=6o7PIl_zy`D~6cR@$m{cd!#EBErf zr~6g{Pb_x5|8=hOUFhOfeLY>-5dwhjl2lsZd3~-nf<~@FrB+!>8yfEa{dm;p@i4ns z1mem!8xsLiKR}8_^i5A0wSuiz76_;`3Zs*dEY|S(Tryo(u<^)CkF9N#-4w zcu}e4?L0`7T3{3g$znx3hjXSXSiL+T5T+oEJRk)vBK&?-M-VS;Ri6UhgLT|#KtJ}TR`aZQJYRr{^|AV52{1@Edk9LF zj$)o{7cRUcM12D9j`)n{5Bgg~xn_G*hjG~gknP`N0OBt z)#$cJWw6mqxYnxS1=MENK=AYcEkM%0h;qgVpuGil?zZmc5CqTDp!a1kUd>cP;Hb~c z66&eC>ubI;AcQE}4x##`jogxFB> zW=6=9Ud>JZ?b+iY%u9>{iFv~XgBj^U@=|>Csxa|1Gg3NV48kEeAPDqv%M|RvYUTAx~c$JUuOQhmlt?-aBZ+sUiQdqK~ym%mDO-RJ##UE|$uuj_Hh5q}QQ$6D5P zqI)aa^|pG7ttr>|}@oUFTao;T7*2^E>|ApY_i}_{1du4d4NQ zRe;Lt7XajwN6!_rYcje^1N@hE$*X&LFaDhs{2#q2rY}h~!0Wg4=g+4bUe6l}aK{i8 z-{o%HaN$X!MFlsQMGB+U=+7^r%7l)~&AWgfKKe6o-4}uHzc2K!Y6dL&k8ICXTe2(irwr_}hMc`{fU*h|Y#1CY? zC-oz_VZJE^i~IW%AHNFtuUsQ?{H4h6LPiDssnBoo{h`4Bl^8?e9MIgVMdeRI6;DKw zD)EqaL1{ZEYN?Xb&EyY02dKCgSz8{YD^E}gpd*zceN4(W5)QAfP%y)rB@ z$6O0aw?aGrC953E)q`#L#jA%NLoV6t2Ojp0$IVuYpRp>VSyo%;xnsgR)M%;TyC!;+ z`Dza6?PfUHXd$X+YA!8vS=QRjT*%#Mn>O2RWy?G?aGRIC;-&DduE}EZr^F6f*}@u| zi?zd6Q#>KZOV$8d5a zJQAcmStwXyi~LTT@#EO+4JDd)Y@{7c5cV%8_<#S~Q~(={3i=R*loMUMIv!e^#P$I< zaGnq>U`;ZgZqb3HbcRZ0>Yn!j<|j2f4X{0L-yZ;gUjV?r0RBM20huQ#ZlHcJ@%e|o z8TZT(`!1J>UsK4uh^Zi!_$`7ih^$D6pf$B1c6qet@+zbbsoMG`JTeM~PH#7qkufnE;;;0(Ox4x_=V!oEZFq|?t*xVi=AVz`_o|VGTSzsJU^Z+hs;##Xm zM0bfe^6EIb%(V{^ilSCUJZ4xdC|E^YD+slSprnM35{{s-xydjon({IcbW1BRhEC>+ zq_}oZgKvmS@B|HjS>(a4Y~HgF8{*&`jD8(!79cBjV92E$9`SYA(oKwg$mj#m5kQvq znSAZ2u8V?UO;{8Lvr4;dt!JMdslj^(^hlv~a2#phR@sm&f{HxmIvx7v3i`0ZDu~V8 zr3HTNG~qOVqlj6lpm=0vdyY%1!A4K-rH(4Dr2pEp(QYu(iy|#|)QOs;NJ=)8Xas`t zwQ>4jp!+wA*{fE3^c^gVlq67gQXOLhvt;&Oc8;PKfTmn#=fg1)Ggc7~8hbXku5+HD zElrR%WG2Z0QI-mM`DkRi)#sGst~nx|8b`}UYFI@HW$KgKt| zuC&K-UF*OT*^LevyCZEcvvWRRHHzZ-Ordh=Ao5&_tp@b3jmBWGq{@lKe34+n?JD?+ zEY4%lA868p*NNCSbiS6Y4%+MRAU95}_i=B3Ji? zb{YZ272yqaW{U$wfRNhL+B*CwV-Q`6`icf2U)LkvF^nt1{~qup|79MeSj3=|6!P9E zeB!o`0=yzcKwLLtqgO4mDLKgla>h6QiWYJ*4w~^jFb+u;!nBC^#kw9wuy7v`nW38t zJlyVjLs1|zm?{o{R^q#!o2*XFQB}OoNpvy7l|AZUaCslCY@MBpF}ZlyFPxfMtI0`i z885k3H1G$k`pRHo_5>JXx?RP*AO3I)Q4fRurNkBGXrkXM4NbA|m4?iS?p&Qr3q9E1 zz2&K%se{>+28%;tv~JGJj{s3pd*;LDNBe^4yj~H`5A~25DW{Pw_qcd=k+V6=A)tti zPI7x)XTabQ)6DGde8$3oA$F}EG~!K;aP!as6Xg5|bUX+=TQm)(-Ls*28;_UKnA+=p z9Qh?t)c6ve)A@ko1Qr~3Wl)6L$>8D`rCt4P^eu=I@{OON$UQ0+0|#I$@D#BGij8v| zxhhM#HvdXl;ka3n7Y;G{1YDmf0)D{8;jzYLA&;}iv#EHq%umQcF`2D@|sNS6_p_6=$aX7M; z+`_tU@r2t?r3x&D`OJ8y2euPOw4MtnjtJRaJ)65*9G5HE^_B(iql7_a%f^$q)usKe z*WR~)t9@W56A1UN-_r_PG2sKWE68jWUo^FC&^;`n>}JX|svbtyV7S3gfU0DO>`kKZ z)l@HK!XQ?;jMfuQM*g56yFO_MWha&*gHVD_^3gTd!Hv{3vR-MRbFf-{6j@*Z2F;(E zx=uF;;(g{bqhYsf$-&y{J-62C_lx=%6>~r)8tR}oEE!gfF6d_~VupHSU{k)-t)Dz$ z^s@eWKLOs9piG8jZNGHo&VC$bbo>FrrR-Wj+EZ&!8t)(;%x=X{;!qm|syC+9wv>$1 zHYaIhz&>bBHe|U?oyOpgGoufzk!U=Es}CORS5=Fu!Q?jR+`pljuMkonVOA6A`@yXK zAhDc_;+57?HV>%o{9*^!7#m23=55H+U^GF0F``59jfzg|ViK;3MLM_S^3GYw5!00A z%w%@G^ydmnQt-Jyh#OnfJS2DFbbZCu6gd>ujP@Va?6l*ohA;@XhC5KpxLOD@8l^7&!|vB=3Gs zyJRD=ek`)BkGQjyckb3y*3;{{XG(KX>XF**(U}mfkYT0G*UG}Gu!&7ha^-eNYzq=& zUPS>LXsuL48r=Q9qR_PKZh#Aef3rz=P6&nQfsYS)Kw4oNHx*(rNX6+;TQz_5WiAJ#z7VAw9>M%&lcOPsQ@B%+AYh`{9%@}J_Fe0H zO0hJUS&~gf=KUfI2vhO28mh51Y!Mo-Q1mnnR3hp>ZLNeUA0>aXsMNw0sn+J5DF5CQDjG&PZ-pe99r$Sr67(XzL;sYiftVu`vtafsjECZAJB&2mRA^WJ;-qc+B@&7HKGj9*+cs1Ic$yW zZ7}mq^1a`K6Q7!{!A^Z&MCPQr%Vz3+E@ZB_U1nRX<_o85r9isQ&H`;@u=F)g-dhq> zdXH*hTJh*WZfMmtIb8QuX}XKX8$ z_H<3_{v#epjTXi${cu2UcHIhn?8(HFPdXMGQK~WGE`xF7rx&~Li~RPL?Hc&^YhQk| z^ILSyO;xhMCT3*G4<7Dg%f8{%|6`!O5R5^cK)^wRro(VCuU;`a{|CSMIFWn`E%-Jz z5L7^V)@$tk!3X{@H@mBbn^lrKx~^3t&_DDER2&peXMH=!!4L8X<{3!NO(A~emYTuw z2rdLF{&C}`bSSlR#eMF);N4lUumnOhQxj2&V88t1*0;ctdtZ{)GR>2vN?3$I`xw|y zqx&YW>BFki=;Uw;eeq3gW6C_i*A&$V>S87bCC}6(O{%V#$gWVFNulRsZjPpS zc3h?6j)$(wX_IRkYhqMX#fanrH3dT)^yy0EL#wTMF$|^<>8Oils+uIdh~@~3XhPYf zI<-5g>vg$73V9%B(3mv&rM%Yqh)t`HMvfIJ)immLu?zT2N@4VIPVYS&qfSyi$=3*C$W69uaGBV z;ptA>ntaQXd9mg3;+Kq2>h%)zu~iWj_Zp)3ffQb22V0?*9TjljiC6R}VwGQ4*sR^; z5dy|6hQYCiMz_z8Dvl9!C?E!@_@_12omv9g%f*U89k_5awv~@9;OL%GPdr8NCSV>) zr_YqFk$$c0oG%C9MBh_9m{q+ys!pC83mum-R2IuOCH>N#i~(y@uyeb!tLQXl*F7M@ zqW{^M89I|ay#F&^VV}Pfj*buSF;pN;u?UJQ3Pc)`-;%~ zMm6sf=2ER3jq6RWCPW@_IwUeBs$!1`*EN*TT+ppW2sySNGpz$QX*FjwKje{fs_J-A z4%`LqpA9?7b#NA11Fh)cxZn9tOsXZP*rw%fyYN)RcNKgNr7RyFW?00$L*v4eU)f`2 zYP+5#LMH(81fZa@*iiYRnv5}n@%Wm+L*ovoVz4ynCG*vkz&gQXtf0<7CnBffq|)TS zgb7M+Dfk_Z16}qST~oP_$uA_>T<304X9qM+dI0hg&AOs;p(wodC&1-$MT;B|8{ z5?IGh!G|G8P&!c453V#+n((@{R*#YnYPtelKz-H2w{R}_&a9+}6g;sW)g2g~R1M)U zlc9VX3OM1j1a;CYrQ8M$pVDa$kwY6xXFaOf%{m<;SYjZ6H+k$Acg`)jIVNz{4j#|6XrXBO0~-$O3(PK=JWEjNhZp#c^dBNMPmPzhdyP)mx*@PDVo8Jh*-$r&1l7P$ zIRus^XE#@jt4(Y#oG;Rf&;$CJ?Kn)jtBnR5uq`woLJ1bu$`F z)nbko6mz<$Hl*IP0aXOGcNT)8@nGVIo4jD-p#nuSuGQ#XDY%=B^sb_Yq6(wL1}w0- zGq5l~@$1VTMtu}3gfQ6oCMBuoAMNKFw4yrn?QoxuS$jaSacm$j)nA0)6{ut@=}U0r zhg{WDgrIlHI)}eTZcQGv71k+4sL~XLoD*w$dD3nd1xtI^?7}H03f#Ax8J{Atf%IP8 zea>##5UoV1M^t~DAp6EZN50IF?wy9(uK7gSQuq4M)=yL*W!pnHB3cAWmP`Aji#Cpa zPHSpAXbltEKP~n}+L8rPJYRMpCRS1W9m8ivmgG2M>%Z9xJkq5buFjh>8OnM@qiBW? zG%i20i>^Y#;E(e{p)%}(2y zl-@1r-g}VJ9Mkn3YOS1~b~^YkM_`$EBXZgY$t{Fz$h1SGbyMqn?6XbuF@erQoktGgc?rnGHvTH|C3|IY*x7=xTc< zAyTJY#{F&aZ99$3ATrhbC(qDvzg#G{Wj8~j@Kg#S#MEX4I;f7Tx_iTA!G}r+2Vr-onW2HFnN$QP`k{la-Zfh)1!>h$kxd(? zRnwG{mR@fhLu)@%N)P3)TB!rIr+0?S%{nWc;;Sem9yL5wvmGcr!T}sB6%OW|&^pj} z#7~Z=-F~A)-iP(lU$63QJ$%m8kK1Pxi-L;@hSXy{l*)i|fue^KMrHkg`8#iMw0(+> zYQXd^i&w--fzR|+aZl5MsRdt`%jtNY#bJ>O!7ew;{TwqSF4ES|k4!$|fS;y>lIQU? zC`+YiZlZ-!fzn4jHJQb*!D!Z%C5-&QXYYzZuBh0Ekv49Hy3G*KN8N~{QmfgVRR2h=E3h?Q8fb(76*P6?QMUD%hD)X92=C6+D3vc~2o=>XB0^4L zgWL({J|78G4J2UFK4-%lgQ-Z8s)PfA8a;#pw-hK0z`G;y$gB~`P3NK37i>4M*=BU4 zQKb+o=#o#aaMg!Pt{bNd3WC5Nlxj{Wny;XF5bZ#f7>t;6m}*huv z$33?!zpMGAS$*UccR`q(7vB*OkQo(D4H++-wj^0^G;WEpPNV586A zJo%t$W(_oJgl1`EfzM!rJ8Ya;O<9E7J`QQLMrc+;>zMP&2rz-5=UZH_`2J+nx$e5g2ek)| z+gHCWJB6tws)-sVlc){V5}6FNSfbYB(Zr3@=MriOJ+S0C9Xr35NOV88!+9alg z0|!LD>z#j&t>IA!!C4UmE-!+px(EV}uLjl?@>fweNh0Y~>&5I1LnMZrqY z_$QM1ig@xCf{rEOM(#u7ET4#c$Pnz4LNb)BN~tT{-Mk@XX-c<+a&p57Ezw1oOwuUW z(eO3%W>JMG7HbxZku9NPA+L9G(gU1VwGP=A_9 z?8`PTS(LycX$DPriG=kIk~AzfK|8X9WAqi%O`-m&Wa(0L)Y8kIo|l(Kp_ixoc(vo* zQ78%YhD?3~_0)vVK|TQ#Bb9*3Vc$^YZjE?v7{wmAQ{%tH3ClvkR%UKiLI_us*X~wH ze5$0F1Fn*P|1SQ1d59uR@uPQPuc^187l$S;a{ceMzBUYZ`8*x`huJ z-^M~hp^>}^2xDNUGf{1GAA34lbkldPdue?+53-&J(b@M|k@Ig`ZzFAr zKA5RRo}Z;y6sec_yhqInQ~HV^Mrp~z1a^QRl3YjBv$;fr)d)yfVeXlQ%Q|T>Usj6} zbaoq(h_sE`Fhq2%M)Vh|(RVGSokZ9T{z;&bRm>(G@ zQL)iw=d}B-hexy8*>~;tgb#!(M|<{NAY3F4*+!#6lZN)+-AfYBSI{%6UHK0?-WRhu zo|3CFY)s#%$bMW|c6OseD!n46CaRRl6q)g{ZS<3D{(h)}sfnvZj2w3KfoOKFBGm(j z|M`_*)0Uj;e;na|Wd=21m9Rf2CTV(6LPa+!5})hwIy+hvLta8OaMYTl5Y$hfqi-n~ zs%_#n=o$&Q3gtD26Jc?SRjX_BI%8OL#w9H7bc{zlJ|!<)LWx$&lL*q_ayBA=`ai!x zkLPorP9rEA;CftYzC)CN8m&^Kk~070?z6gD_w#zhc`#)W(-6TbQSGn|D8s`1{efh; z9XnL45<^50)8E0~bLiH=TLYlIRrVygE7`KqzBS|c==sy<(|&8O-I6$UYTgg?aL-fM z9{z52&Kntcv-6GCh2_*eV*Q6~@4Q~k>XFe()W?FYnCY5h1}i0CVgvA^q~KLS$qSN` zZL%%YvE0j&oXbXT8Yc};+dFeFdfB_}(+`)zrnm@WnE@KeES0j308K1Yh}lYBR4cNK zTST>s$XlJ8#er&>tSRD=mXPYQ)zewn_TIuN>@0;jxU_L^JEn^~gQg8-rYjV<(uQWx zW4)%^n&-emowilK+}pIHmE=T{&KY5iU<)-aD_PN{GZK`HiVhs??W?d z)ai7EFqs(Q&qxk%afTO{XjyE|lk24>a*dxyh-D8Mj=EeNnow1i(~u({2psQraR23{L-euwe8mN&_z0Euu;x z2}{joC*(%vT%z^TgaSPmOgX%?{rBE5vLBZbyOewuto-V#NnbpL;t<(8i}JOLs))_a zPIj(?;OE@k(hH|(J+$#Sr!#alf?4Tj%#5B?j-0k_n)AuicSGQjVIh=z5gPO=|4FPc zq?x1<94HIgGRv~Ocd?o~iwkcbY?|0>9Ct}epPfg@zPs5hHg|oPV94MwwyB0+%inxw ztVPx_C3cFncWgtv$SkVt+`asUjbqkCU~%E%eMdhXcHC3A(xJinBs}tIP@8`#|J}tK zeX}KXl787K8KdgXms`ziX5ATo+Po>?g-xZ`2R}!a5u8h$3H8XA!Fs(au|z;>25xf# z`}>8Q8$dHjP$JgY>m~JQoNaZ>4UVuikaJs1Ji7HIvffvQeE#Q6JR_C29~CaE=AA}` z!EQ;XSf}9kr`@9q7Hw%9os zuhnO!b^h&4XR=TOv(j^QS%WXKhSyDS_we&)NO4<#JBIr#eYYpcnVMJi3CZJPm(g$T zkU#s7Rs4Erd(h*Y z`Wzg1f1dmv_=L#090gIt`ie&&?*@S7tJ;vq8<2l1fc+&1M3@ z>}043VuG4MKR)0RHG$!mBCj_v|N8(TqYr;YAio}pMnWIn7#Q^_^72h>1hg&;0K#-= z0zsCKF60!V^JNpvn8$ehV+{6ko_N{ezS2-}%f}m9ZmDlgekHgSun0US5eZ4y-q4Gk zvVdSd#h<%iLqSa!%#yhD5!^V}b0yyOY#4%_)!8SNAfMl2TXe zNOH=DT9-UacuBMP3LQ+94cf|#*EiC0Kb`|zWwbb@ZyZU$H<4aKdL9dk;=2k{I1-vM z*f*Xe1es7xO^tiQ3h7{CrKI#?C*<;b1& zKYktR?sJ`%7QvTVd$P{{u_Sr<4|QRbA1x&`FrBOrSlO%(0>K9syF^>oDzz0Yheh^W z@U9Bll%L8KaG-0IM^kyt>qPRe z>4J4GiY#h~D2*l~`)g>Xi@*$PD(7kZ53G0WC7?EN&9dhoz(r~X)Pam-MU6pGW5#u! zEYb5)6R#*R$|ns94WSu)B4k#nQ{#2+-;R^XP9(_EpRRc7)?cVyp>^wfq$`JU6aE%q z8}0Go-AgLosop9@iJxDXpj-9!bseD5{Zt=*VrUE=9}_w;dA!#Hc*9Q3-B!KrO8u4F zrbqRn$kM6LdIaQ`i9TH5{K3)pr{A&akD5BT<6-^Tz8-}OB#+@46^Rw|@nhsb6V`jg zo1RMdK2W^SUL0EH@25CtdcUgh)#{%<`Ey!1w>)jELif&Yz#|>q0&4E^2AFkMHf#8x z?8X}YY-MMh&cE)7zPhhxb91M9I;N)f+PO z6q8!Rg{TtBSRW8}ysVOZ$#n_O-v;$9#RS+ksLWVy0!vthjphmHKv_4}Z$nH_TLpJp zaD2wathVKzYctDOSjkqFpem92Ml{?PX`T7C)Oq1@kc}1{s6(p-eFk|{c5Z9xm@8r`%CicYrz#I2 z6W3)*P7jHN7-W7CoeiWD`h&}l`-%O!^Qu5n6;LbJzP7WSMhdsxMAG52nmjiyCeS=)(4)>?C!sG9-aslE2<8A3uGG1hO5 zuG6GG?sYzhI-VbwJs{NNegf45;2VxNZCveD|Ao$ZH;#zLd>+8bXM`*UZB1#sv+vcvB5StxB7$W(#xwg4=!w}Zm15&e3*Hwp}{~^(`~)~94vv- zXCx)oehdmhK(?0$U${_5wa*97E?^9*68#saWO~PY^U`RvG~T@}6Q}&EL?d?3t=^sC z;mrzGd+l!9c>O$k-pcDy6&A9oZ5dz_DA*|KU7R-l@Bi*Uon!V)i)rVlciAd#d%=Qi*a$UA>FE z4n_MFyVWC5Gd}Kg^+v~={QT?OX6&w%uC!Z1ZFw+%@_RswpPjBX0X%R|^D6*Q3UH;b z2DW&-3EXv;M|W;Lf^8LNBxJbVzTs(qQNh?+2|K}CoUz#LoAebXyjZaPi0<0+1=|K# zU#S28V`$#iRM0Reu<+0Ha0Gxx{p-tvp~ANw@Nz6^)T@g{Qnwj!e5x9m3aB`A|96d% zBJ+asczgIX13MP0lCh%^ox0alpxVuG1oA27k}6%@ARLeeSVS~~QW}F}3-tsMRz>B} z)vD3q?7KK7pxzygGYdkm+%KYyt- zf^sOHs}*zUx)5nRfm=s?1VN9eH1e<>#9RU)mk7jNT%5*5(X@;3n8Ua{Ao?u>cNUw- zhX9jswh%BKGLgl*1Bn`-HtuZhN(fqcH^*&K-htR1rpbtbc2jKxw#GXfgS2HwZ)b7G zF~yF5J_XPsVVh{*g+gPjt!L)S+NuB9Y{odm7oW6wo9(};wJT@ZdW^9`A!6>Oa0S?d1*xmca1l?Wm+Ln-L(kB2{S7|(u2x3KSdaSj>PJ)-w;+_yNxPPe8r;r;GU;3 z3?mt*hI^PiQQO=LDgA>LcxtYA8V1;TpFyA!(igNl+-W=?cVlAuFIl=QzHnbgWV*wU z0XCf6<6aWE!5gs^>3z@}IUJITWggKCM5uaQ_O^lgSM1#5&b`Q%{&+ z>z}ix6!srA#Z}CB{#BA6&%~*&#(_-;tR4t4_-M0Ni$o=CLfNk1`U+M5=efAfBKWb_ zeYnR1yMVXq9?oGixfew_b6NwxdXh3{O}}~#EC#7->er;#tXb>EQ7%tscfcr4-(U9y z*>>9x>45Z3-^U(cS1Ffx-UVA%o{_$DYx>f3H#|HsX+lNrzCudKkgG*_oqWJivhx2`d-RXh|k{TCE5rX<~UtzL8 zAJD%@B*^1xg5H^=yXuz7oBJYA@?Lxpo+>Cf zRYKESVk<4QXQOe5 z@`y}+Oo0m%D5@Y@tWZujt2OC#A0NITt=7=Zaw$FQ`F*77Nf-ZzFJkijl^FbjwL=KS zorGX}M{5UKGLTWhP59dJ6V>Vsn7#YdZu{34zmICA5A;!qbnWpdbz$dUe&g4T{y=>2UlX~TPlNzLNkE16tcUfx|h zZc=4?$}Hn?at=RZ&KVW>sXfxOcrcIes`ADAq{Il)7c$E6%KS3GncyI1bPEqw^4t=beZM1P)!tRK!92)(qsSQF!(r_KWY#V5XgqOk)R5ud@nX5vO!XDvJR?U41 zbcdjXK?|2(Cc|-W6`o#0`?Ggu4^8oClZIW;AlLwC@>hRlOe&b39L{Ft)=Nz25}e6W zMgtY$s2t|9sMK{UsA7fa z7s%2xrA<*$xXZn_SszlxO4bJy!<2cL9#MHVA8%l2iEMV1F;+I;lgLdyFGA#a*~T>$C+_@Ek(~{8X(BPOh{fuJ7r#KK1IXa zIe!2-TUyeaf9i0^ziqbXCQ2HgT;mtF+=Jc~`r#{9K>0o|7n6gS~+`Tu{Fn z5`XAO|FW$>O>n_toe70cMkI-VQbCCp3K&@Fn+Qle|37%spkI%l&|$6WQW_ni(I9$R zhv{p#DsJ~SM_%O5#3Vu?v~F?tjG$1UYc z!0@Q<;MlrTZdX5Uaa+{80XNIIHgMeHebH<2c$e2DPc3615U2XFzud?(YDCvH{drx^ z*}bvn{Sx&%%2@v7))ONS{~YuUx`PrJ`)@l(_)27KOM8 zXmf%UEYI5@P^&h^nWC!{5rlgbBnE1q_y@o?2O0?`A7sOtKTX7du`dl;++7V0ef@Gf zmW-2aQ{%)q_gc?B1ftioHZFO;`z{n}mwQUIDPSzK;oou#Uuw8LXcvAoew?dWr60Du zx9p8r#nCFoF$BImjS-@unPoDXIYe`vC~M*SsH9Tf6uv+nES1Vba%Iuc@{^70s2{~B z6vir*eu-oWee{7fu;{^K8k4@rwusIMq1@ZvTaJEZMU3|BPaIk@YTKVQq!rG6fT2xc zEn#j&|J8~_>`?S>3PF9cvmwLn#Ie)QTS5B{W#3pW-`B#MaVm<8IJI#>PVb~;fh~8& zls9gSZLah6E4BIN*ky}M13QDl`2yUSRrw0xj)0DLdCVh3 z5N-|83cEXzxVq?HJEI}bA>-4h{CcH_=UMguO;^1x8sAKlT}O-0tg9qSqJ6RQP%`KJ zSw48ULzsUnLbIxmt+Hv2Y;y4)$rum z{btW-$VYwtl;15#`xledLpyF(u)n8Wj)_gc8dAK}pB@)1i@~t4l+VjQ9fz}SG9jYS zUl$g*p@%nM1+7?Z#{SAs&VhtyPS4=-_dduzun)>32iX42WVj5fD_H(OdPZU7kg z%(U?M**I0Hz3kGcYj8G8r3KAmFHqdK+P%^-)2 z*^!Y*$IJ73lyK~hNzq!^Ka%Ius%ZTIU1f_Jwl%btTf_q5=U}B9%lhKM>r~TPd<$V+ zEcH4xn3D;X5M%BqXY-cBxoi!OTK_f_!+AMwv9O!EHYZJ+9)@kjqJMfPE-U*SIs?kb zyXM_Q)P~|LY(uswEzf}hsN9X64Ootu2fRrMCy)bI=oa951#tRy79k2ejz;gz`wuz% z83r^u9tfy(r{R~-(0Z#I>}{r`<<6WQWi+vX$}*G!Hea0NqYovCS~$z8s3b=cp=Ze@ zW$?A+qotBW?npiI0Fq_ZeHp0W^GK5LYM@4#biSccIbo*chOG^hB4wyl^3iRO{wg>d zs1D(wQTHO?le00D*&bweJ|cx27VQazZ3_GVvAor*wK^r+I6(c3Htda4SQ$u^h}&x_ zGmR)%rg^`f9%lq33O>`90ga-n)mm+9cgvKCX=8j;y0w>)nFT0V8ra53>N|AKxwd+U z$#V7*WoAAKmTE>lVJgekqplB1U@u5#uGmZzRS?2E(ncwY;bgrvF)`tC^&;`TaKjan zX(z;H#0%JV;-r+KH+RL0d zWl27clO+0*Q;?-1a#*&GIF z*HCe@X)UR#sJAs#MA>6y-g6pYaY2CzHdX|_ns1J4C@k9awoRh4V(cC(&66y?Poqa| z0T^0Itv}+vwQ={N(mH`XZoIqNbHKIo4c7Z{7^w^g59m^x@(qw;cfC3QYpgAys_>y| zyTdi)7t50ciaUH6~jn5|gZV+j`EtteIDirLuX1UUrZ1sOy zZz!Nv^VFp$-K83ADhjF=TAvq9c8}Gw^4v@^6<(O@*80hBfZvF;E)J#~&3r~%tFtIy zkE)>H`dPXRM5`%q0gKI;CT`+rwI25%tf4%K21T@PH!~om|1UV98teFEKz)x;A^key zLx_`+;{-B*T7imxY(|R{<3U=tn-EYvd%ZQnsGeQpB;#=ctP4;W^D81!fz5*&BDVR+ zO{g9LCs6lM&EMB1?NgKPd6WKiO$L(j;quBqYwg+FGZ?m#)e4q)#nEj%WOy~~&vy4M%94$hMuXdqhy z)KF25-Erbl?rQcPJHQUIL+pLH##-(Z8&B+Q_6~cOy~hr)gX|D{A4#!e$BrF4cI?=( zJ9PxePk3$CLrhg$^<$yp;gZ##Jf}gUYImwPQuA-LDUWgGgg9)9iIvRuzyxj!G_h&= zZkt}FdB%gBfcblx5Cx&APg>;jYJP3J@T6MADz{*H3KbjaclnmFdJTEKXO<6~a0hU` z@S|?((GC0X?JoithyKjjWe%Ke`Ld{PG4$;vDLVNsmeV!0RBQCTH8N92;VrWXSW5(EU zK<)cR8E-D06$M%WVGgw}oD;K7=?dnBanAYbW|PiG^K2TDoBKw0n6z+I_nGKikc2=lg#J%?IB81ttCI0rUREAm#u|`OVj7l>8?77a{nEw{7R?ANyVZ z=tO4pTJuL#{1{$y)-Tg#X+JDlryXfe3}w$1>uYt{?_V2W?Qs6oA%K{){tom9$}iRB zJAeQ1@5j~+nHp5xYW;uiG26w4c10MtJ+Z4(efQjRTfk4IE41C&cP*=DzFY)!=&Z*A zzqnDZfA@6bFM+rht@?KDJ<}f6yE`uUwt}YKX7@sBFkVjt_*whotoLf?hM`8)sqF)2 zA6bLx8iTWPmq?Aq12+JCY8wHd3%}-OKy!|`70~qEGesVuOFJ^mZhv;e#g>Ia`)nF-vfgj6TLVq8zJM>ZEi%Is5-y`;3 zeCF76F?iO?<~6&rU0hEELfrA}=q`cQlAi$h`CNauKr3e!;PqztI@Z&Lx zO@s)$8xpRQPj#mao6hi9)^iScJlwln_pSuz??+0R5bY7{Okwq$d(MUd(HiDT(^ueW zySg3s800zNaqFiB9)hS_dqLW%??;NN+3qpuE`;aUCo0SW$pA2^g7H6=1^t_V}FtPzePRPT# z5kGx7!wjD}WBvD+;X55J1zM%Mudq$l7Zn)^yrxGqIj5didirg9FY9H~T~qO60G1;w zTm^w8emP)cxe59yL2V463EcL#Zk+#Rv$_4G*Qkp~lO8w}F6jST*n^PW^S-om^T=~c z|3+a;w2(k~Ij%UsA^lKL%K7+_nZPMV7$$&-C02oLctGLOP-?tXm`B9{{g{!y<%5mq z-eb9s(7O2Jv|a4M(J#J+T<}%8vA6W^;cNPvV_dU$`3V^iy*B!7__lAO==g2;3MoGr z|F;u>d+urqiJjvS;B^~iPc7VH!KVO9;)>d}s}k){pGu^)iTohq*RQr)&+&uXf!JYk zZra}W>>@)n)SGr*@iRkb)y)S!Tfz%S365-UaB3{9UZ3UiZCPui?)RcO^7=& z6j9VBMkqX-$WOW;kVYnLYRc;sn?#DPD-a_QAR$<^EoT0$0Kau`v}B!Ejcyw@IBj{@^CJKkYwok<-)tZ&KOCbiZC+ZQY%(uj{=Y z%oQx=b!!>y#sfzu!k6r3Z9p~WH1d1#mo;Lt?QSo50QMsOj4K6z?%NCjkTa4)BNM9i zr)%!XS9hre$P9sRJ?n8~OzBV?lajBOjK(s3$0cXd(ZJbKUBJw{tNqD`STSpkl;GejM-#Q8&nDIHhTU_4)cz4)&0I*)a z=j{{8$K(1xVQ~uUhViwK>An%>ti_mkDee$Vd5a-SEmPzc@!kHSH{hTGM^3M)>){bak@2&JU&Kk=-B>kuy20KJc2;UDj@M-YZYcN~6hn^=A9mYs$P3b(C4Ti~J-VZeqV zc3pl3!PREPv)xQ)T3DF@ORH6=smd*_@J_P7r2@RU>^lI^7spHV2RGj7sWN!pmUou| zfTDNwm)0Eb{|kUzYM_tulRo79R8b*~2|n-6nqIqHL~8LogfmNzZuDj$Tb3P%(AK zwKjOX8&_d1tA@!5Dk>*&Oe_rpHs$Aq7?I_Iuj z7&{BTpy(GGb}!}IdeZ|X9k$cn3+x1qn_WZ8Vw9* z67Wq!9dMI1KFB}6Uh5n@0>yoT0_@>%yGV5!z9gwORDwK}DO8h9?G3M?fF%f4R58Ox z?)a-XVBwmR8upNsz^_3Xe!?*pJOsRUTQ;N1>l5@O12JDLXtD6VQ}h%;s17^Lu@L2p zV3$>(R;d~7{86FE)`C@1V#sJ zTfA+Auf_F` za&C9jCA96ps+|E3{Pvat*ko0CN7N#$^z-Z#DqT<~JZdT4<(R`tV-pLkoMw4ZEdKCe^WcCEGDfH1+qEqW`a+DeM*FZhSL!q% zhU}$f!j*sViyB&H+H1wJN)46HA71X1vxUp1R>aOiiA1Gl4z)=~18LIP;q1W~=*@yC z7SJ@IHXmA85QMc4T=&oaV+>D(znA^-DB?l@Phc~B+ip!q?zjqcUWpGNa% z&hS@0+5D6*Ri9KX6_)EtEeQi<+8B%p+?#STPGu+^qWtAbZ374pTb2#sEXFk;)wiJ5 zP!dcP_n<=K<5IHNP`fC>CKRDcpK7yC`4*?6+wmsM;_2MU1gnfmAvHOphFmbuW{z8j zf`qnN-wbP_R#X8@Mp7fTG8I{hQwqxlg=EE?E+iN;zD9paVHpZAxJcn%zKS|}M!1!; zf*NS>-P+DHR!%c(!re?pqdh&Vk=nt8b=!D=!w`v|vo_q!FLh+%sY@|&k1Ty^b|TDY zml!MKHp9(q8fp9(aWI=b$7^*4!S^+L*S@#V{^ecW{b zqj%l?nmp9oy64v|KM-DZX~@0|qT|GKBZK2F=p(Lo-WX6YrD-db8R@Gf+c)IzyV4bM zWgJ51SF#c+%OT!mug4~Z-Qfsi(#77Kgq~g4;}HnW#<2061;PZh6BOf4&vB#vQ52$ zZJ0=5v-x!v4;D+g7nNEIIfe4SgXT{v4tzyVv0HW?_RR`RU1i@~Wj!rd8OC0S!NWJ{ z5KDS=z`jma;HuR-DR~QCYK~r~BNchysva39l(a4Tr3gIknoYlXoro3mkP& z(AiL>c)jSt>s0CZo3VUiqflXh{2~<(SI&Av6FoW+$8-*A-ZC+#0y3T_QW09E=r|ep z#u;)+3w+0ph}wv~NQh6imX}U(G?NsN7gSItTFIj1U~Mq}3>KI$cQ05C#DBf?TZZRg zibVEPBLE`Jv!2mP8P#5qJl8AHv|G6(<6WSjcNyz;HA%wsW$_!E;mDE47UtoqVoX4< zup5uA`pIK$lyW@kQ`=J!wrtz-*j$H>^yoH^3!O9E<5SHPW%d;$$^~58r&sU@J%74K z20`h)g9a1(%u59mVK32ec87}wk_E}W%6P4PRkBV9NnvQ%NQI0{WXbHT*kxk3Ysdp} zJ}v%QHJZ16Y<+0G-6dFMpnww;nd;9?a0u2f#IOzrqs-dvkwje&KXSJi`ac;*&FjwU zsLYJ0XJLKbMrTl1C&9&7n)o+1e?u(=j+|) z{mx(f`ueAD{E<4$&dqCG*)@7$YxKfavn;?$?BWO}-CSV3l3~s)a!ktIN&Xz8kBK?+ zC)C_~$cEVzCjK1i7|zY4>n0KXOn<*^ub$KM(%bNS;s_&428GsnIX5SqeLg!zl!W$u zu()U~s;vp=a$HU&r>9`OlGzx8UBFwJiv_{oAp;22i)@v}TR=W3khM8B0LIT<*vM;mvQB-wJYa% zaMT}o=6Xaut(J(jknLGJrhN(cC%_Ld3i$QU1-K{JK}k2A*med)TJL9%!uV0#rv+w8 z+$fBEVy!O6Zp|WJN>3X7}p=6f=A{DU4yqQ&EDSj}YSvpd<@M=i-y~ zbp{L0B9E<8PY399$vaD`*V9JM9~CVRwhWiWdWh0=2{WUt^BgaoW?W#bE{V!q&h-Kn zkQR`|i=_BkGp)*TstpJS$yb({x5MnT(M4!bHpJmj7c{+vPE`lLf z+>&ChWzYCwcF+%!P@~AZV&~@Yo86nZGGb|$U>`-&qx4Los_}Es34!qHq~eJ0mM(qEgUi`XGFdMhpMoSU#UKledRc%@feNFO0$X%SN19AQ!m!1-e{Xz$V( z6$#80w4cbm84V(A+vCq4i3EYW~WikxgpV0 zCl6wp#=+QQb#oAK$q`QL;AEHA0c+xyX%vgJ_Gzu_k5Y_BvlJ>Ui!&AQ^pKi_F0?^- zbH#X2{8V!yum~g>zyQYr6jSVpx()Ue1xn*my9-el^ZdKS>xRNN)LKmTQo=veMs1Y0 zHd9|XLy{7Xu&LPIcLMOloq#%UiQITQ>gsRQtY&x4H!QTc)X5Z95DW=!;tdV zXCg&7674LXYrxy=xu6W9OpILF{*Z^CgjG~H;t*Xjgoi10-tJ3p8P=(1P1KH$RxN9e zfbGd=65T!O*73gp;*ntFvHDn!xMA?ln5vHPee0w<-AT=HL(ehA<69>JnNFaSBn34Y zdFgpbtnae)1x>usUbiqU$35z-g))k|z|qNT31SFyL7j8RjOmDOG>?T0K4KZzkBxW6 zVq6Dq3zr1oNG&kE2z?R zd~kT@Fdp(i)Eb9>(mN>t`e8kaXKC6Cx5rd-g)?F20B&YEExR)jj0h| zdCQi`Q9p^cf(7tQdAzT*(VtQ?KQ?P@bAqGVNLQY(lGbH!)gX=Y>*lM1sDl#8k_9LPBsH!>-6fIe`a9VzAQlel-VU1@u10D;~8o)#$zdl(1Ku3@;S;buAoM*eYZt*D>mvX{QwaNOiv80=7dvNgF z3)@qaGI}Oi0%7GI5lwGgnH2`acfL1=Db7SWmXEfk{-G+W@x|#|1cuxSuR?(#e)QBB zH}t#F6AswOBw1vNCQ-a}75dMNe0;C*0CZC$b(vx)QTFsKCND_WAJqA~0fOQLJVB_; zYI2co|2U>Nu?5E%B-rFY3bYem#X{&=0jmPe8&+1bpb1LsAugfm?`W&}O3tX}n$cIN z$0wvcKdY&F3db%+^4K_ghPr#1dgMma5gQr2+1FsiUNH)4rB#0rN#$$jS z2|#;Co(}eao+%AR)ngR9W>S_R#G-;Y4e~*31=wVO=MX5|R~4^?6{(eQ^8BW__q;8E z8wkQOkS657j-^LMyI*`WO{aL!3p@r)9C3d;8bdyNEdc#bJ!Og9)#d$9tR~SxN&LQY zh$SX^WSS*gYuq`{v^Mjk!VNe52u_Lz3OaAQY|#qGDWN5^0)cu^D8pEeLc8`h=6Ns) zMGFGK6_x2!6*1*NI?_`ZP$D=Zz? zKlwJT0mjH7qm3-Uro~~RDQ0B~rIpy#9rs!<+{Yi3Ga&%h3> zVsSzJ-C7%fB{^R8glQ#+1`E0HlGJnol`7wD&{m96V1*(Uk_ooaH)Cc9ag$)Z6}AX2 za1*!;P5Ivbv#)`~E_$ac+ayoyC}J}UzAsrG;m?-rKPi~AV>~n6(=+2NZj&=yD6SOR zfm=%f@@+&YuFg>{O}bem!_zF+B-@;BVpmoUIV5$aGEH%js92#dZjIuQHbt{ynI`*c z5JlQ$9%N87xmuDiySXM*#TZ#)5j5;p2-p2lJx{~DbT`q{)VRx{*bQQnC7ZN+o!UgX zdAFRc;S5Wn2&tRBqBMXQl4tO$O}SASPUQQfJxrF@uj;Umu_B>`m-tti+j=Ov*}g7C zj$o}37E`-sx=tk02r2Z1?_Ac7bEV#V|EXlH0?vN`0Rrctn-CwZB*b%cHE(rP z57b}wBh^oJokaBebj4i?J9gwL-ga`mjzo4tV!y*ld&>v46LzyOen3a;=dqdYA0cIn zUGlD?$)4*~Zgr-Fm5>95b?% zt<^zJiW2Q7Y2|uGU&K^tSCLhs!X`sZegDbV&*)pfI2in4i$+ILyI#*O9iK0)Us6kD z%ceb}dD+<@t+Ry2A!%=uXx#}^Ms@dw<>&WZ;H&tmlN31mYL2Mb6qom%x7iO_S@|-d zP5%P5yvQJ*1d0gAdya`6S;6N|16}2H@z!`QiwDl{KG)C)(tqqv^2nFE;pF$zmG6KX zL@Aa-JeK1T?QXN^@1>l3j}I_klpaJEX8eF(VL#)3kSP9o%N^gh;@Q2_=aqL{$awt= zH?%J1%o*|fvUyi)a`>%RWouK6wT&dyIF@MNI$H0jpj*4O){%Crq)RrNcYQy(ndBM& zR=#)Nc^CKUBx~!>|D1iWb^h}XU=7F1WD_TVEWfvR@g02r-Z$SScfZ@ffGb}%lNrw! z$)%LMiO*n-Np4gx&9}(ZP8wR>RJp^Vs;|iqNiPYfx5WwSsl2$B0IpMTMYjfU4>+1Q zl6Vtbhx7L#EkfOn(=wg|VqfUdm~#M1_ZZF^&+-1FFnY{aC-apv^E-TMLcZx`lgD&L zG2$F;bB1z|62ECmD?2l;b_H&F*3TI9$ZH&4knzun&YsM^ygV97+2Hs5st#eur8v@{ zUkD%zr!OH5yb9~}%C$GGZeVVCI+OvV0$5Q_0Sr4` z$k)KD333KXo3FXjK%=cDW^qRg4P>Bdr_r+w)deLxNugiS+fZHEPgwQJ5{i{ch*$gv zFFuL&&r2DQFWJHuAq+KyAJX`%<#Mi#0VLL%kLR-GD*$1num{)69GHn;sfU39&aiY{ zSC*nTVkVGC5HHlEN;XBxM?(*G-)E?<{F!qouR(NS#=jPKVn4%q<24e#0Qt6M?{JH& z>C4pZXx`2yiWtQ`l_FcHQckZo@RK2dzKjsh(N%x*^pjTv3@qRu?Kgwt?|4P;u>FKyNIMW#_m^J| zu}QW^9=W@A_uCBn=CMDh6^E>EHpt>yqkfYnb~|}%t?$K@{%l3niB+fyIaGGCW{waN2KmY%2R9@wyd<8 z(70WL`z&pVYb@AFJrFyYJ8qYVnlToSd032oQ~gb@MYN2gMcgY2(JGF_Lec8^DeP7{t0p-b3uIWgI#=JkQ$8Vuo>0qk9_Np zgGv62{cY4N{n%agUAm$fVqUR>KZ=FvqsnRB+A)N_Z}=<{=Egs{<^n$sFWC4q^)&2f zI3F9ck(3QyA#RY-?0qFZ;ge5zRTU*D_f${5cDm&#=HngDWL{2-KvW}x>XxWG+d)OUO)=QbftrS!l1>GJ%E5l`&ce~w-# zoLH&Y@gU0pkM6k5TYKEH=t-K0jQ0BM{Ssu3m1YT`K7FobM`SWPQT+AB3i+O7J@-w- zuRf#h#o;#bmS5nAuXsL!InCdt2XAD^sQzPeGHvisRE&4rkJPT}r-k9VCha?Bqk1jW z-bZyt+s7%6Dr`FLI;dyE-P6wLUmx6&cw%H*dzf|l9kLFUBVvrDD}VBx)mU7$(Y?E? zyIOO(-hs!po+x$Yo2ehut{1z3tW3w=wVS)ws`b_0n^N~%+d0pmefM)wyjAf{a;wDQ zF;U{tIWsIAZ)pcbilnI^q)bmAoi^?kZm-O^86JMVZByd>C+b<4DXr;|QfsAPMRDL+10?k|mXT4;$3 zwwtCRi&9rvxcd<*6I!OWQl#GwjHx{oYIl{$v?m$_?G$+j;DF7G?xvU_E~%6W>k0M1 z6>1`pEkBdG0-+I%+pXHxDn)O3cPG*{d$ynR)H_0^h>oDg54Ovtj*0tPYE`Md!kZZ( zFw`n4x+mQPYi>wfSE3Y>bDD7nvl3n0PPet9Tn*$ohFJog3-hI#`8p^&`lhx^kXBk7 z&VIt8`n(j9O1`<$*LSu8^bRqw9%2Uwm+a6e7Mguik?XXHUwa{)zy&*o1ejwW3e--nNP}x!~*(QZ`2RaT6J}jXAxs3Dv4y@nh$=tJOEOQ0+APeDp$< zjbq+o$MZn}1E^S#a@O<{s9b7UF9)GLx%)?3yOqupPTffjsXPHe99=`f<8s+Mh*k7meMg(@3qGWSFPZ zWLdjyO$1=BeVPf@Zp-cO1L<&Qy~Uu*wrbB1nM8I9ApwH;>>Ds$^!}H&O4M8Su9X>D zN3}TJeTYHstfn|yGlL8mFgawPz@W0 zdh6s&Yq#r|vf2Aeg1vbiRjzdr*+C>tIgt&A_yJ|A5v%A%uiVLJ6MUQ%{|S~UWEH5@ z5a11*(zunpW2$x`VP^=$Vq%Q8ReUGGo(9AW@fZg1ndvfvlFVx4EkZGjx1G%JH53L!mDszYSZXp}EACtAVoV(|>QJ zupa+C+dInP8j7t3~5P<{Auhh2m6g@iq$-_G9Q2v*;z5xe7}qY|U3Ay~TR-N@)ZGekzOzCsOM zk5EP9J1D^qFv>x4oTjM!y0;3o-`pKK{viRZU zZ^XM4THX?mCPMZ7)(d3-pW&MoEaf&2kpR*k8frrswds40E^}nh>mr|RUxG1eA7+Y3 z&a(FIXG9~+&02I>;5|kgV6>iS3QwxYbx9z^PA@&Lgh9AG=wy!T^VDkH(1}Uj&W^wv zbYQxui{i}6EP0VXlfzlD(yR*!s!Zx~J1L?EtSAV0 zO)r6kZinZ80uzQVulR^Bt@*tQha5>lgnZ4-8r~(UR41%;4Z|MEO^MteW+Gq4DWbXg z13x(?B~hWdQ**@efWu(Th?b*N_RdNOiEAXhB|UBosehCR&6D{Zqr_BMbq6x>sPb(v zBrEY_uU}R7gHd({4`7rOseCQg=P(kgV|~s;<6`z4*1Z6QFGL~fyTkhPVx}suzfY5J#$Se#=gcCvbVHcuJ zlcDc@blaOq-sYA3{}?}+b$dr5hA|?3{Ll-{0c2B0vJj(iT@*~)5hF4q=I9SYKVPrs zO`p$M{)LdP%47dRO*>$}X+wTS8(+rOdN45&@E+^)4XN8>JOjQ-7w_u^6?y9SxSPsU zSwHH%wpG4;YL8b+Q&a!ZECuKjvw>-W=b&a5W2#;4d|vI zYL?p4=64pMu3KN;=&T<=X4qq(tfD2Zi z>}Fn4;NYwYp*?C!Oqj)5=MSgUolXUa4B3IO8Q%OrB)X0R+GOqA_v>8tivCSPx|(Z( zOAlxpQqJUDm%!dVSkRLBo`{#g2_hj_B=Q(M z<-4(ViMAELM>9D9U$YIC4cL*wl8wPR!1JGatqzX(Sl{Srrp@-QS7yhAOqCgpEm`vo zJLTNn0v>DO?VL}R81EryfA8TSLc)_sE7ATI78&P>xiFhcV|_c4Q?sB9I2IT#SMVK$ zG1hw8%+h_ZN;>#nYw?;A#b5_PItCY{#rH_z1H#!C6CtIqxkF10s1n6l@uR(d=iCAC zLULqq2Z7QSR|Fj{i*@W$X%&f>pz*S<+2xh-QHCW7XOv{ zkBHn}K*6Du5I%g+3)KpIM!y&&YYljD3xTb>h7X)@3f0nd_cC1jJ~!~ft-cqw=9w+2_mZg$Twstc;(wAv&i?KRX@ zxz_%f<-YIkj4JE;E@%H4{ew&3y?C9y>$G+Moq9i+#p$7n*VZp@w7b$C*e9NL7>5B; zPdefAcv5BW+p3^%{0~vj(;KycT^F9O@4n1McM`JjoGWMDddz~% z%fZdXwhFFI(oqU8NbL2K+G%B}TiwK9I984g-~S#J-bc2bGsKSo`RFgP`&A_Alwx4_ zKD6+uWPnam*ftR$jCyfbrlg&ZdKVS!CKR7i?Bjd)5h5?+EUcuw5X8hSj>bBHykfbm zB(RLA#k%}0xuuwx#gX@K;OIMi|{RY|0f>d zeAq#t|HM_t5DmqeGFHOp_j-MBD`nq9JiAYuwi>%ckp0KT)-x>A)A<{%)&#k-dY0zU z)I@2t<=aT^9b6!9@<&)L%+oWULPTW6%qJ;F0O#YgVdV7PiQn050mDge3$gz{2;3Zx zb`R_G8IjZ9oX5@T_dFm>OcGwCkIqWsDJxPX^6B=JH$eW5+zkEIqC&y@d`8kI;*bXj zJ~U{NS6x)iJdNZApVy34=M|El6{AN@CCBc&w;tl?Emrd+8Xs$+2$ZhH`Ob<8 z57B<)WgH+R=NX|_;3OWvjQ+L)$v+Q#DDnWm6G6PNHsM1KO(_ooyUOL`>zX$(1<`hb z;e!NzM`(M>Yf}PgS?y2+eI84wt*CI0gq?49+H>3_`P<)&NF1kWlL5>u5i5SciAV6V z5x}=!0U66j@|H(HREdJ6rf`!ImmmvmUrDhJEU>I zJ|8!3gPiB@eco*9O3?DSYDJUHjrDJACPII5O~t%4^6|hhwxpkF=H-~Kk6o;Z8kWl- z0V@bQZc=cOh(YU5gwszxYAsz~egrPw5pmwXLzBOxj_5wV zYCGD4Icqht&M|cogEKErQI@DBFO|%x*GR$zzp5%>zpdF!kLO~A>v6dIm2-O8Bf;AR zYyYs|m>uM;(oia7fEK0vQc(`%k<+AcVSZXNWFn*8pj3=|TeR1Af2sw}0lwqr?2hns zn66yEOCIaU3SP@0VUX({U}HZ|MHXT(#$9=Ke5{(6z{T^kW1KfuP;UPmLwgNsz zKEBE$!wf#|olz@c+wbCm`|!TdY@zU;Q7=O#$e!hki6-mP$@i0queM%v!#HTZzzVYU zaohP0$2u{D_pZj}O|Pfi5rH-6Ydz0+3h!B8kO28mbrA8@KQ@o`_^X;+E4*EVs!Oz3 zOB5ey`Ja`-2NIq|pi6<*aX322v+jR|2E~5zD;8VA*Ps0R5qp?vJ$QD!E=VVSVrc~A zkB^JWls-iO#YS1V!O9F8?f+GpIG1V0|>?z2P#uIwX)7TYBvcPOL)*5(5072{z= zRM=OM2Nj6-($jje-Nf$tdjRB2kkc0gvFo0rNMQMJ@WDDiZf!%h5s-^QOx{Q4T!H<@ z<^uW0IgxtYJv&U%~zgYQhHrKpViIi&xV-=?_8Nj{)#Tc-{#JP(cE4 zDPzLv7zZ(!)x`vgdAkns(OY{^fQTIjg-o^gpa`>ni%B{tCM#oC`k;hm#qX??t+hWi z+gV0J3(Ktv#e6}c7^ICTE)GtC6Z6P&QS`MaqpMstN2e8SI*-so#fFjus926Bi7O}< z$ue2$Nrv=)jjs}MTa9HXZZ-~gQu{_dsZCB-iW$neMxmWq66lPRs!mCne)ZT~DQA%j zmDwzDA$HF!588^5^fzYi)4-?5sbb7J%P&8Kw z{8RnEmRZd;`8!9|OgW!DQpUHC{lonv{^$w-0NY?z AJpcdz literal 0 HcmV?d00001 diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4b4651673e744be642178b768655b3e72adfcdc4 GIT binary patch literal 45444 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%0ANis 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 E1AJW2Pyhe` diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5235d39a6eba57ea1a9a98cd650902c7e7a6285b GIT binary patch literal 46776 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 literal 0 HcmV?d00001 diff --git a/yarn.lock b/yarn.lock index 4e745717f247..ea2e201ab268 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13667,6 +13667,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^0.4.1": + version: 0.4.2 + resolution: "balanced-match@npm:0.4.2" + checksum: 10/205ebb42ce8529fa8e043a808b41bfb9818d5f98a8eb76a1cd5483f8a98dd0baefc8a9d940f36b591b1316a04f56b35c32b60ac9b1f848e41e4698672cec6c1e + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -21623,6 +21630,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "has-flag@npm:1.0.0" + checksum: 10/ce3f8ae978e70f16e4bbe17d3f0f6d6c0a3dd3b62a23f97c91d0fda9ed8e305e13baf95cc5bee4463b9f25ac9f5255de113165c5fb285e01b8065b2ac079b301 + languageName: node + linkType: hard + "has-flag@npm:^3.0.0": version: 3.0.0 resolution: "has-flag@npm:3.0.0" @@ -24491,6 +24505,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^2.1.9": + version: 2.6.4 + resolution: "js-base64@npm:2.6.4" + checksum: 10/c1a740a34fbb0ad0a528c2ab8749d7f873b1856a0638826306fcd98502e3c8c833334dff233085407e3201be543e5e71bf9692da7891ca680d9b03d027247a6a + languageName: node + linkType: hard + "js-base64@npm:^3.6.0": version: 3.6.1 resolution: "js-base64@npm:3.6.1" @@ -27001,6 +27022,7 @@ __metadata: path-browserify: "npm:^1.0.1" pify: "npm:^5.0.0" postcss: "npm:^8.4.32" + postcss-discard-font-face: "npm:^3.0.0" postcss-loader: "npm:^8.1.1" postcss-rtlcss: "npm:^4.0.9" prettier: "npm:^2.7.1" @@ -29999,6 +30021,16 @@ __metadata: languageName: node linkType: hard +"postcss-discard-font-face@npm:^3.0.0": + version: 3.0.0 + resolution: "postcss-discard-font-face@npm:3.0.0" + dependencies: + balanced-match: "npm:^0.4.1" + postcss: "npm:^5.0.21" + checksum: 10/968632c4426bb04816787575ad768ad0bca572573366aa7ef2f91069ab5224319bea3517b14d7ffb501f6787bc1d8d4a265f40027d4cc593cfcd7824bf834c34 + languageName: node + linkType: hard + "postcss-html@npm:^0.36.0": version: 0.36.0 resolution: "postcss-html@npm:0.36.0" @@ -30266,6 +30298,18 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^5.0.21": + version: 5.2.18 + resolution: "postcss@npm:5.2.18" + dependencies: + chalk: "npm:^1.1.3" + js-base64: "npm:^2.1.9" + source-map: "npm:^0.5.6" + supports-color: "npm:^3.2.3" + checksum: 10/ad157696a258c37e8cdebd28c575bba8e028276b030621d7ac191f855b61d56de9fe751f405ca088693806971426a7321f0b0201e0e828e99b1960ac8f474215 + languageName: node + linkType: hard + "postcss@npm:^7.0.14, postcss@npm:^7.0.16, postcss@npm:^7.0.2, postcss@npm:^7.0.21, postcss@npm:^7.0.26, postcss@npm:^7.0.32, postcss@npm:^7.0.6, postcss@npm:^7.0.7": version: 7.0.39 resolution: "postcss@npm:7.0.39" @@ -35039,6 +35083,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^3.2.3": + version: 3.2.3 + resolution: "supports-color@npm:3.2.3" + dependencies: + has-flag: "npm:^1.0.0" + checksum: 10/476a70d263a1f7ac11c26c10dfc58f0d9439edf198005b95f0e358ea8182d06b492d96320f16a841e4e968c7189044dd8c3f3037bd533480d15c7cc00e17c5d8 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" From 7ac581ced7f5f608f7aa7c0a95d866481b192092 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 19 Nov 2024 22:48:26 +0100 Subject: [PATCH 008/148] fix: account tracker controller with useMultiPolling (#28277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds Multi chain polling for the token account tracker controller. We will have a separate PR to add the following e2e: 1- polling doesn't start with the wallet locked 2- once we unlock the wallet, polling starts with the wallet networks: we should check that the mocked requests for polling are happening 3- once we add a new account, polling is updated: we should check new requests including the new address happen [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28277?quickstart=1) ## **Related issues** Fixes: Related: https://github.com/MetaMask/metamask-extension/pull/28402 ## **Manual testing steps** This should not result in any new visual changes/new errors 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: David Walsh Co-authored-by: seaona <54408225+seaona@users.noreply.github.com> Co-authored-by: Brian Bergeron --- app/scripts/metamask-controller.js | 12 +- .../mock-balance-polling.ts | 146 ++++++++++++++++++ test/e2e/mock-e2e.js | 2 + .../ppom-blockaid-alert-simple-send.spec.js | 5 +- .../mock-requests-for-swap-test.ts | 20 ++- .../smart-transactions.spec.ts | 4 +- test/e2e/tests/transaction/ens.spec.ts | 71 +++------ ui/contexts/assetPolling.tsx | 2 + ui/hooks/useAccountTrackerPolling.ts | 51 ++++++ ui/store/actions.ts | 31 ++++ 10 files changed, 281 insertions(+), 63 deletions(-) create mode 100644 test/e2e/mock-balance-polling/mock-balance-polling.ts create mode 100644 ui/hooks/useAccountTrackerPolling.ts diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index cc7eaa12d3d1..b59b21ae8111 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2629,13 +2629,11 @@ export default class MetamaskController extends EventEmitter { } triggerNetworkrequests() { - this.accountTrackerController.start(); this.txController.startIncomingTransactionPolling(); this.tokenDetectionController.enable(); } stopNetworkRequests() { - this.accountTrackerController.stop(); this.txController.stopIncomingTransactionPolling(); this.tokenDetectionController.disable(); } @@ -3274,6 +3272,7 @@ export default class MetamaskController extends EventEmitter { approvalController, phishingController, tokenRatesController, + accountTrackerController, // Notification Controllers authenticationController, userStorageController, @@ -4060,6 +4059,14 @@ export default class MetamaskController extends EventEmitter { tokenRatesController.stopPollingByPollingToken.bind( tokenRatesController, ), + accountTrackerStartPolling: + accountTrackerController.startPollingByNetworkClientId.bind( + accountTrackerController, + ), + accountTrackerStopPollingByPollingToken: + accountTrackerController.stopPollingByPollingToken.bind( + accountTrackerController, + ), tokenDetectionStartPolling: tokenDetectionController.startPolling.bind( tokenDetectionController, @@ -6718,6 +6725,7 @@ export default class MetamaskController extends EventEmitter { this.tokenListController.stopAllPolling(); this.tokenBalancesController.stopAllPolling(); this.appStateController.clearPollingTokens(); + this.accountTrackerController.stopAllPolling(); } catch (error) { console.error(error); } diff --git a/test/e2e/mock-balance-polling/mock-balance-polling.ts b/test/e2e/mock-balance-polling/mock-balance-polling.ts new file mode 100644 index 000000000000..ac0a38dbcdf9 --- /dev/null +++ b/test/e2e/mock-balance-polling/mock-balance-polling.ts @@ -0,0 +1,146 @@ +import { MockttpServer } from 'mockttp'; + +const CONTRACT_ADDRESS = { + BalanceCheckerEthereumMainnet: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + BalanceCheckerLineaMainnet: '0xf62e6a41561b3650a69bb03199c735e3e3328c0d', +}; + +const infuraUrl: string = + 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaMainnetUrl: string = + 'https://linea-mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaSepoliaUrl: string = + 'https://linea-sepolia.infura.io/v3/00000000000000000000000000000000'; +const infuraSepoliaUrl: string = + 'https://sepolia.infura.io/v3/00000000000000000000000000000000'; + +/** + * Mocks multi network balance polling requests. + * + * @param mockServer - The mock server instance to set up the mocks on. + * @returns A promise that resolves when all mocks have been set up. + */ +export async function mockMultiNetworkBalancePolling( + mockServer: MockttpServer, +): Promise { + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraLineaMainnetUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1', + }, + })); + + await mockServer + .forPost(infuraLineaMainnetUrl) + .withJsonBodyIncluding({ method: 'eth_call' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000001158E460913D000000000000000000000000000000000000000000000000000000001699651aa88200000000000000000000000000000000000000000000000000001beca58919dc0000000000000000000000000000000000000000000000000000974189179054f0000000000000000000000000000000000000000000000000001d9ae54845818000000000000000000000000000000000000000000000000000009184e72a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110d9316ec0000000000000000000000000000000000000000000000000000000000000000000', + }, + })); + + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ + method: 'eth_call', + params: [ + { + to: CONTRACT_ADDRESS.BalanceCheckerEthereumMainnet, + }, + ], + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000001158E460913D000000000000000000000000000000000000000000000000000000001699651aa88200000000000000000000000000000000000000000000000000001beca58919dc0000000000000000000000000000000000000000000000000000974189179054f0000000000000000000000000000000000000000000000000001d9ae54845818000000000000000000000000000000000000000000000000000009184e72a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110d9316ec0000000000000000000000000000000000000000000000000000000000000000000', + }, + })); + + await mockServer + .forGet( + 'https://accounts.api.cx.metamask.io/v2/accounts/0x5cfe73b6021e818b776b421b1c4db2474086a7e1/balances', + ) + .withQuery({ + networks: 1, + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 0, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '20', + }, + ], + unprocessedNetworks: [], + }, + })); +} diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index 5d372f3389a3..fc6b1ea4397a 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -43,6 +43,8 @@ const blacklistedHosts = [ 'goerli.infura.io', 'mainnet.infura.io', 'sepolia.infura.io', + 'linea-mainnet.infura.io', + 'linea-sepolia.infura.io', ]; const { mockEmptyStalelistAndHotlist, 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 c1c7323671f5..9e904af6513e 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 @@ -8,6 +8,9 @@ const { logInWithBalanceValidation, WINDOW_TITLES, } = require('../../helpers'); +const { + mockMultiNetworkBalancePolling, +} = require('../../mock-balance-polling/mock-balance-polling'); const { SECURITY_ALERTS_PROD_API_BASE_URL } = require('./constants'); const { mockServerJsonRpc } = require('./mocks/mock-server-json-rpc'); @@ -32,13 +35,13 @@ const SEND_REQUEST_BASE_MOCK = { }; async function mockInfura(mockServer) { + await mockMultiNetworkBalancePolling(mockServer); await mockServerJsonRpc(mockServer, [ ['eth_blockNumber'], ['eth_call'], ['eth_estimateGas'], ['eth_feeHistory'], ['eth_gasPrice'], - ['eth_getBalance'], ['eth_getBlockByNumber'], ['eth_getCode'], ['eth_getTransactionCount'], diff --git a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts index 457d1ea6c0a1..78d1497dc9cf 100644 --- a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts +++ b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts @@ -1,5 +1,7 @@ import { MockttpServer } from 'mockttp'; import { mockEthDaiTrade } from '../swaps/shared'; +import { mockMultiNetworkBalancePolling } from '../../mock-balance-polling/mock-balance-polling'; +import { mockServerJsonRpc } from '../ppom/mocks/mock-server-json-rpc'; const STX_UUID = '0d506aaa-5e38-4cab-ad09-2039cb7a0f33'; @@ -288,18 +290,14 @@ const GET_TRANSACTION_BY_HASH_RESPONSE = { }; export const mockSwapRequests = async (mockServer: MockttpServer) => { - await mockEthDaiTrade(mockServer); + await mockMultiNetworkBalancePolling(mockServer); - await mockServer - .forJsonRpcRequest({ - method: 'eth_getBalance', - params: ['0x5cfe73b6021e818b776b421b1c4db2474086a7e1'], - }) - .thenJson(200, { - id: 3806592044086814, - jsonrpc: '2.0', - result: '0x1bc16d674ec80000', // 2 ETH - }); + await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], + ['eth_chainId', { result: `0x1` }], + ]); + await mockEthDaiTrade(mockServer); await mockServer .forPost('https://transaction.api.cx.metamask.io/networks/1/getFees') diff --git a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts index 210d5abdb034..36324b9ea797 100644 --- a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts +++ b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts @@ -7,7 +7,6 @@ import { import FixtureBuilder from '../../fixture-builder'; import { unlockWallet, withFixtures } from '../../helpers'; import { Driver } from '../../webdriver/driver'; -import { CHAIN_IDS } from '../../../../shared/constants/network'; import { mockSwapRequests } from './mock-requests-for-swap-test'; export async function withFixturesForSmartTransactions( @@ -20,10 +19,9 @@ export async function withFixturesForSmartTransactions( }, test: (args: { driver: Driver }) => Promise, ) { - const inputChainId = CHAIN_IDS.MAINNET; await withFixtures( { - fixtures: new FixtureBuilder({ inputChainId }) + fixtures: new FixtureBuilder() .withPermissionControllerConnectedToTestDapp() .withPreferencesControllerSmartTransactionsOptedIn() .withNetworkControllerOnMainnet() diff --git a/test/e2e/tests/transaction/ens.spec.ts b/test/e2e/tests/transaction/ens.spec.ts index 47bae3e5e4cc..3291287ab5a4 100644 --- a/test/e2e/tests/transaction/ens.spec.ts +++ b/test/e2e/tests/transaction/ens.spec.ts @@ -7,6 +7,7 @@ import { loginWithoutBalanceValidation } from '../../page-objects/flows/login.fl import HomePage from '../../page-objects/pages/homepage'; import SendTokenPage from '../../page-objects/pages/send/send-token-page'; import { mockServerJsonRpc } from '../ppom/mocks/mock-server-json-rpc'; +import { mockMultiNetworkBalancePolling } from '../../mock-balance-polling/mock-balance-polling'; describe('ENS', function (this: Suite) { const sampleAddress: string = '1111111111111111111111111111111111111111'; @@ -15,82 +16,60 @@ describe('ENS', function (this: Suite) { const shortSampleAddress = '0x1111...1111'; const shortSampleAddresV2 = '0x11111...11111'; const chainId = 1; - const mockResolver = '226159d592e2b063810a10ebf6dcbada94ed68b8'; + // ENS Contract Addresses and Function Signatures + const ENSRegistryWithFallback: string = + '0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e'; + const resolverSignature: string = '0x0178b8bf'; + const ensNode: string = + 'eb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1'; + const resolverNodeAddress: string = + '226159d592e2b063810a10ebf6dcbada94ed68b8'; + const supportsInterfaceSignature: string = '0x01ffc9a7'; + const addressSignature: string = '0x3b3b57de'; const sampleEnsDomain: string = 'test.eth'; - const infuraUrl: string = - 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; async function mockInfura(mockServer: MockttpServer): Promise { - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_blockNumber' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: '0x1', - }, - })); - - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_getBalance' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: '0x1', - }, - })); - - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_getBlockByNumber' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: {}, - }, - })); + await mockMultiNetworkBalancePolling(mockServer); await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], ['eth_chainId', { result: `0x${chainId}` }], + // 1. Get the address of the resolver for the specified node [ 'eth_call', { params: [ { - to: '0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e', - data: '0x0178b8bfeb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1', + to: ENSRegistryWithFallback, + data: `${resolverSignature}${ensNode}`, }, ], - result: `0x000000000000000000000000${mockResolver}`, + result: `0x000000000000000000000000${resolverNodeAddress}`, }, ], + // 2. Check supportsInterface from the public resolver [ 'eth_call', { params: [ { - to: `0x${mockResolver}`, - data: '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', + to: `0x${resolverNodeAddress}`, + data: `${supportsInterfaceSignature}9061b92300000000000000000000000000000000000000000000000000000000`, }, ], result: `0x0000000000000000000000000000000000000000000000000000000000000000`, }, ], + // 3. Return the address associated with an ENS [ 'eth_call', { params: [ { - to: `0x${mockResolver}`, - data: '0x3b3b57deeb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1', + to: `0x${resolverNodeAddress}`, + data: `${addressSignature}eb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1`, }, ], result: `0x000000000000000000000000${sampleAddress}`, @@ -113,7 +92,7 @@ describe('ENS', function (this: Suite) { // click send button on homepage to start send flow const homepage = new HomePage(driver); await homepage.check_pageIsLoaded(); - await homepage.check_expectedBalanceIsDisplayed('<0.000001'); + await homepage.check_expectedBalanceIsDisplayed('20'); await homepage.startSendFlow(); // fill ens address as recipient when user lands on send token screen diff --git a/ui/contexts/assetPolling.tsx b/ui/contexts/assetPolling.tsx index be1954a37ec5..afe80a23a050 100644 --- a/ui/contexts/assetPolling.tsx +++ b/ui/contexts/assetPolling.tsx @@ -1,6 +1,7 @@ import React, { ReactNode } from 'react'; import useCurrencyRatePolling from '../hooks/useCurrencyRatePolling'; import useTokenRatesPolling from '../hooks/useTokenRatesPolling'; +import useAccountTrackerPolling from '../hooks/useAccountTrackerPolling'; import useTokenDetectionPolling from '../hooks/useTokenDetectionPolling'; import useTokenListPolling from '../hooks/useTokenListPolling'; @@ -10,6 +11,7 @@ import useTokenListPolling from '../hooks/useTokenListPolling'; export const AssetPollingProvider = ({ children }: { children: ReactNode }) => { useCurrencyRatePolling(); useTokenRatesPolling(); + useAccountTrackerPolling(); useTokenDetectionPolling(); useTokenListPolling(); diff --git a/ui/hooks/useAccountTrackerPolling.ts b/ui/hooks/useAccountTrackerPolling.ts new file mode 100644 index 000000000000..cc7f9aee3818 --- /dev/null +++ b/ui/hooks/useAccountTrackerPolling.ts @@ -0,0 +1,51 @@ +import { useSelector } from 'react-redux'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../selectors'; +import { + accountTrackerStartPolling, + accountTrackerStopPollingByPollingToken, +} from '../store/actions'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; +import useMultiPolling from './useMultiPolling'; + +const useAccountTrackerPolling = () => { + // Selectors to determine polling input + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const currentChainId = useSelector(getCurrentChainId); + const currentNetwork = networkConfigurations[currentChainId]; + const currentRpcEndpoint = + currentNetwork.rpcEndpoints[currentNetwork.defaultRpcEndpointIndex]; + + const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); + const availableNetworkClientIds = Object.values(networkConfigurations).map( + (networkConfiguration) => + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].networkClientId, + ); + const canStartPolling = completedOnboarding && isUnlocked; + const portfolioViewNetworks = canStartPolling + ? availableNetworkClientIds + : []; + const nonPortfolioViewNetworks = canStartPolling + ? [currentRpcEndpoint.networkClientId] + : []; + + const networkArrayToPollFor = process.env.PORTFOLIO_VIEW + ? portfolioViewNetworks + : nonPortfolioViewNetworks; + + useMultiPolling({ + startPolling: accountTrackerStartPolling, + stopPollingByPollingToken: accountTrackerStopPollingByPollingToken, + input: networkArrayToPollFor, + }); +}; + +export default useAccountTrackerPolling; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 390db05f04df..64ba84ddaf57 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4676,6 +4676,37 @@ export async function tokenRatesStopPollingByPollingToken( await removePollingTokenFromAppState(pollingToken); } +/** + * Starts polling on accountTrackerController with the networkClientId + * + * @param networkClientId - The network client ID to pull balances for. + * @returns polling token used to stop polling + */ +export async function accountTrackerStartPolling( + networkClientId: string, +): Promise { + const pollingToken = await submitRequestToBackground( + 'accountTrackerStartPolling', + [networkClientId], + ); + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +/** + * Stops polling on the account tracker controller. + * + * @param pollingToken - polling token to use to stop polling. + */ +export async function accountTrackerStopPollingByPollingToken( + pollingToken: string, +) { + await submitRequestToBackground('accountTrackerStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + /** * Informs the GasFeeController that the UI requires gas fee polling * From 9b6dab80bbae54f08b0464bc23b1a68059d97ed6 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 19 Nov 2024 20:11:45 -0330 Subject: [PATCH 009/148] chore: Reduce E2E test jobs run on PRs (#28525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The number of E2E test jobs run on PRs has been reduced to save on CircleCI credits. We still run the "chrome MV3" test job, but the Firefox and "chrome MV2/webpack build" E2E test jobs are now only run on `develop`, `master`, and RC branches. This should result in huge CircleCI credit savings. These jobs were chosen because it's uncommon for test failures or flakiness to manifest in these jobs without also appearing in the Chrome MV3 E2E test job, and this job represents the mmajority of our userbase (the Chrome MV2/webpack build is only used for development). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28525?quickstart=1) ## **Related issues** This is intended to reduce credit usage. There is no linked issue. ## **Manual testing steps** N/A ## **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 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 70268a812421..7a81017489c1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -197,6 +197,7 @@ workflows: requires: - prep-deps - test-e2e-chrome-webpack: + <<: *develop_master_rc_only requires: - prep-build-test-webpack - get-changed-files-with-git-diff @@ -205,6 +206,7 @@ workflows: - prep-build-test - get-changed-files-with-git-diff - test-e2e-firefox: + <<: *develop_master_rc_only requires: - prep-build-test-mv2 - get-changed-files-with-git-diff From 1b3f30497ebad590ee902cecceaf08ab0e2f52c8 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 20 Nov 2024 14:58:04 +0530 Subject: [PATCH 010/148] feat: display ERC20 and ERC721 token details returns by decoding api (#28366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add estimated changes display for ERC20 token and NFTs. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3614 ## **Manual testing steps** 1. Enable signature decoding api 2. On test dapp submit permit request 3. Check simulation section ## **Screenshots/Recordings** Screenshot 2024-11-08 at 12 19 40 AM Screenshot 2024-11-08 at 12 18 24 AM Screenshot 2024-11-08 at 12 16 13 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. --------- Co-authored-by: MetaMask Bot --- app/_locales/en/messages.json | 18 ++++ .../static-simulation/static-simulation.tsx | 4 +- .../permit-simulation.test.tsx.snap | 82 +++++++++++++- .../decoded-simulation.test.tsx.snap | 100 +++++++++++++++++- .../decoded-simulation.test.tsx | 22 +++- .../decoded-simulation/decoded-simulation.tsx | 91 +++++++++++++++- .../value-display/value-display.tsx | 31 +++++- 7 files changed, 336 insertions(+), 12 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 610ddba3a4dd..74e7d745cae7 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -4132,6 +4132,24 @@ "permissionsPageTourTitle": { "message": "Connected sites are now permissions" }, + "permitSimulationChange_approve": { + "message": "Spending cap" + }, + "permitSimulationChange_bidding": { + "message": "You bid" + }, + "permitSimulationChange_listing": { + "message": "You list" + }, + "permitSimulationChange_receive": { + "message": "You receive" + }, + "permitSimulationChange_revoke": { + "message": "Spending cap" + }, + "permitSimulationChange_transfer": { + "message": "You send" + }, "permitSimulationDetailInfo": { "message": "You're giving the spender permission to spend this many tokens from your account." }, diff --git a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx index cf8ad9cd5773..9a34abd8009d 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx @@ -15,14 +15,14 @@ import Preloader from '../../../../../../../components/ui/icon/preloader'; const StaticSimulation: React.FC<{ title: string; titleTooltip: string; - description: string; + description?: string; simulationElements: React.ReactNode; isLoading?: boolean; }> = ({ title, titleTooltip, description, simulationElements, isLoading }) => { return ( - + {description && } {isLoading ? ( diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap index 16e7f5fa79d9..45eebc590b7b 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap @@ -2,8 +2,86 @@ exports[`PermitSimulation should not render default simulation if decodingLoading is true 1`] = `

-
- "DECODED SIMULATION IMPLEMENTATION" +
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+ + + + + + + + + +
`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap index 9c190acccee9..9a794df953fc 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap @@ -2,8 +2,104 @@ exports[`DecodedSimulation renders component correctly 1`] = `
-
- "DECODED SIMULATION IMPLEMENTATION" +
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+

+ Spending cap +

+
+
+
+
+
+

+ 1,461,501,637,3... +

+
+
+
+
+
+ +

+ 0x6B175...71d0F +

+
+
+
+
+
+
`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx index 00e0fbadb496..690cfb5b5195 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -1,14 +1,34 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; +import { + DecodingData, + DecodingDataChangeType, +} from '@metamask/signature-controller'; import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../../test/data/confirmations/helper'; import { renderWithConfirmContextProvider } from '../../../../../../../../../test/lib/confirmations/render-helpers'; import { permitSignatureMsg } from '../../../../../../../../../test/data/confirmations/typed_sign'; import PermitSimulation from './decoded-simulation'; +const decodingData: DecodingData = { + stateChanges: [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '1461501637330902918203684832716283019655932542975', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, + ], +}; + describe('DecodedSimulation', () => { it('renders component correctly', async () => { - const state = getMockTypedSignConfirmStateForRequest(permitSignatureMsg); + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData, + }); const mockStore = configureMockStore([])(state); const { container } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx index f4767dbee264..b0a1ddee12e6 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx @@ -1,7 +1,92 @@ import React from 'react'; +import { + DecodingDataChangeType, + DecodingDataStateChange, +} from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; -const DecodedSimulation: React.FC = () => ( -
"DECODED SIMULATION IMPLEMENTATION"
-); +import { TokenStandard } from '../../../../../../../../../shared/constants/transaction'; +import { + Box, + Text, +} from '../../../../../../../../components/component-library'; +import { + TextColor, + TextVariant, +} from '../../../../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../../../../hooks/useI18nContext'; +import { SignatureRequestType } from '../../../../../../types/confirm'; +import { useConfirmContext } from '../../../../../../context/confirm'; +import StaticSimulation from '../../../shared/static-simulation/static-simulation'; +import PermitSimulationValueDisplay from '../value-display/value-display'; + +const getStateChangeLabelMap = ( + t: ReturnType, + changeType: string, +) => + ({ + [DecodingDataChangeType.Transfer]: t('permitSimulationChange_transfer'), + [DecodingDataChangeType.Receive]: t('permitSimulationChange_receive'), + [DecodingDataChangeType.Approve]: t('permitSimulationChange_approve'), + [DecodingDataChangeType.Revoke]: t('permitSimulationChange_revoke'), + [DecodingDataChangeType.Bidding]: t('permitSimulationChange_bidding'), + [DecodingDataChangeType.Listing]: t('permitSimulationChange_listing'), + }[changeType]); + +const StateChangeRow = ({ + stateChange, + chainId, +}: { + stateChange: DecodingDataStateChange; + chainId: Hex; +}) => { + const t = useI18nContext(); + const { assetType, changeType, amount, contractAddress, tokenID } = + stateChange; + return ( + + + {getStateChangeLabelMap(t, changeType)} + + {(assetType === TokenStandard.ERC20 || + assetType === TokenStandard.ERC721) && ( + + )} + + ); +}; + +const DecodedSimulation: React.FC = () => { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + const chainId = currentConfirmation.chainId as Hex; + const { decodingLoading, decodingData } = currentConfirmation; + + const stateChangeFragment = (decodingData?.stateChanges ?? []).map( + (change: DecodingDataStateChange) => ( + + ), + ); + + return ( + + ); +}; export default DecodedSimulation; 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 c7a9eae6a496..19c1bbb33d7f 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 @@ -27,6 +27,7 @@ import { Display, JustifyContent, TextAlign, + TextColor, } from '../../../../../../../../helpers/constants/design-system'; import Name from '../../../../../../../../components/app/name/name'; import { TokenDetailsERC20 } from '../../../../../../utils/token'; @@ -50,11 +51,25 @@ type PermitSimulationValueDisplayParams = { /** The tokenId for NFT */ tokenId?: string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; }; const PermitSimulationValueDisplay: React.FC< PermitSimulationValueDisplayParams -> = ({ chainId, primaryType, tokenContract, tokenId, value }) => { +> = ({ + chainId, + primaryType, + tokenContract, + tokenId, + value, + credit, + debit, +}) => { const exchangeRate = useTokenExchangeRate(tokenContract); const tokenDetails = useGetTokenStandardAndDetails(tokenContract); @@ -97,6 +112,17 @@ const PermitSimulationValueDisplay: React.FC< return null; } + let valueColor = TextColor.textDefault; + let valueBackgroundColor = BackgroundColor.backgroundAlternative; + + if (credit) { + valueColor = TextColor.successDefault; + valueBackgroundColor = BackgroundColor.successMuted; + } else if (debit) { + valueColor = TextColor.errorDefault; + valueBackgroundColor = BackgroundColor.errorMuted; + } + return ( @@ -113,8 +139,9 @@ const PermitSimulationValueDisplay: React.FC< > Date: Wed, 20 Nov 2024 10:46:30 +0100 Subject: [PATCH 011/148] test: add token price privacy spec (#28556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** There was a bug where the token price requests were performed before the onboarding (see [here](https://github.com/MetaMask/metamask-extension/pull/28277#issuecomment-2475927362)) and was fixed by [this PR.](https://github.com/MetaMask/metamask-extension/pull/28465) This test adds coverage to this scenario so the issue is not re-introduced again. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28556?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3659 ## **Manual testing steps** 1. Run the test before and after the fix was merged: see how it fails before and it passes after ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/0745a2be-ac2f-4f35-9aba-35831de60e6f ### **After** https://github.com/user-attachments/assets/aa4fb8e5-6e7c-45dc-9535-7d456c0f816f ## **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. --- ...nboarding-token-price-call-privacy.spec.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts diff --git a/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts new file mode 100644 index 000000000000..a599d96fd5d4 --- /dev/null +++ b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts @@ -0,0 +1,138 @@ +import assert from 'assert'; +import { Mockttp, MockedEndpoint } from 'mockttp'; +import { withFixtures, regularDelayMs } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import HomePage from '../../page-objects/pages/homepage'; +import OnboardingCompletePage from '../../page-objects/pages/onboarding/onboarding-complete-page'; +import { + importSRPOnboardingFlow, + createNewWalletOnboardingFlow, +} from '../../page-objects/flows/onboarding.flow'; + +// Mock function implementation for Token Price requests +async function mockTokenPriceApi( + mockServer: Mockttp, +): Promise { + return [ + // mainnet + await mockServer + .forGet('https://price.api.cx.metamask.io/v2/chains/1/spot-prices') + .thenCallback(() => ({ + statusCode: 200, + json: {}, + })), + // linea + await mockServer + .forGet('https://price.api.cx.metamask.io/v2/chains/59144/spot-prices') + .thenCallback(() => ({ + statusCode: 200, + json: {}, + })), + ]; +} + +describe('MetaMask onboarding @no-mmi', function () { + it("doesn't make any token price API requests before create new wallet onboarding is completed", async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: mockTokenPriceApi, + }, + async ({ driver, mockedEndpoint: mockedEndpoints }) => { + await createNewWalletOnboardingFlow({ driver }); + + // Check no requests are made before completing creat new wallet onboarding + // Intended delay to ensure we cover at least 1 polling loop of time for the network request + await driver.delay(regularDelayMs); + for (const mockedEndpoint of mockedEndpoints) { + const isPending = await mockedEndpoint.isPending(); + assert.equal( + isPending, + true, + `${mockedEndpoint} mock should still be pending before onboarding`, + ); + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length, + 0, + `${mockedEndpoint} should make no requests before onboarding`, + ); + } + + // complete create new wallet onboarding + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.completeOnboarding(); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + + // network requests happen here + for (const mockedEndpoint of mockedEndpoints) { + await driver.wait(async () => { + const isPending = await mockedEndpoint.isPending(); + return isPending === false; + }, driver.timeout); + + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length > 0, + true, + `${mockedEndpoint} should make requests after onboarding`, + ); + } + }, + ); + }); + + it("doesn't make any token price API requests before onboarding by import is completed", async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: mockTokenPriceApi, + }, + async ({ driver, mockedEndpoint: mockedEndpoints }) => { + await importSRPOnboardingFlow({ driver }); + + // Check no requests before completing onboarding + // Intended delay to ensure we cover at least 1 polling loop of time for the network request + await driver.delay(regularDelayMs); + for (const mockedEndpoint of mockedEndpoints) { + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length, + 0, + `${mockedEndpoint} should make no requests before import wallet onboarding complete`, + ); + } + + // complete import wallet onboarding + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.completeOnboarding(); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + + // requests happen here + for (const mockedEndpoint of mockedEndpoints) { + await driver.wait(async () => { + const isPending = await mockedEndpoint.isPending(); + return isPending === false; + }, driver.timeout); + + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length > 0, + true, + `${mockedEndpoint} should make requests after onboarding`, + ); + } + }, + ); + }); +}); From 960565870757f91fa5be1eea1c830f94bdb7761a Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 20 Nov 2024 11:43:36 +0000 Subject: [PATCH 012/148] fix: Address design review for NFT token send (#28433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** A few changes were made, namely: - Leverage `NftItem` component to display a square thumbnail of the Nft image - Update spending cap indicators to abbreviate long decimal numbers and show complete numbers on a tooltip - Center align token name - Add # to the token id - Change background color for native token fallback thumbnail [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28433?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28370 ## **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. --- ui/ducks/metamask/metamask.js | 9 +++ .../approve-static-simulation.tsx | 21 +++--- .../edit-spending-cap-modal.tsx | 23 +++---- .../use-approve-token-simulation.test.ts | 9 ++- .../hooks/use-approve-token-simulation.ts | 23 ++++--- .../approve/spending-cap/spending-cap.tsx | 20 ++---- .../native-transfer.test.tsx.snap | 2 +- .../nft-token-transfer.test.tsx.snap | 35 ++++++++-- .../native-send-heading.tsx | 2 + .../nft-send-heading.test.tsx.snap | 35 ++++++++-- .../nft-send-heading/nft-send-heading.tsx | 64 +++++++++++++++---- 11 files changed, 172 insertions(+), 71 deletions(-) diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 7ddc156c92ae..2b5561739acf 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -338,6 +338,15 @@ export const getNfts = (state) => { return allNfts?.[selectedAddress]?.[chainId] ?? []; }; +export const getNFTsByChainId = (state, chainId) => { + const { + metamask: { allNfts }, + } = state; + const { address: selectedAddress } = getSelectedInternalAccount(state); + + return allNfts?.[selectedAddress]?.[chainId] ?? []; +}; + export const getNftContracts = (state) => { const { metamask: { allNftContracts }, diff --git a/ui/pages/confirmations/components/confirm/info/approve/approve-static-simulation/approve-static-simulation.tsx b/ui/pages/confirmations/components/confirm/info/approve/approve-static-simulation/approve-static-simulation.tsx index c860556a359a..8d66b17a1f46 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/approve-static-simulation/approve-static-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/approve/approve-static-simulation/approve-static-simulation.tsx @@ -14,7 +14,6 @@ import { TextAlign, } from '../../../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; -import { SPENDING_CAP_UNLIMITED_MSG } from '../../../../../constants'; import { useConfirmContext } from '../../../../../context/confirm'; import { useAssetDetails } from '../../../../../hooks/useAssetDetails'; import StaticSimulation from '../../shared/static-simulation/static-simulation'; @@ -37,8 +36,13 @@ export const ApproveStaticSimulation = () => { const decimals = initialDecimals || '0'; - const { spendingCap, formattedSpendingCap, value, pending } = - useApproveTokenSimulation(transactionMeta, decimals); + const { + spendingCap, + isUnlimitedSpendingCap, + formattedSpendingCap, + value, + pending, + } = useApproveTokenSimulation(transactionMeta, decimals); const { isNFT } = useIsNFT(transactionMeta); @@ -61,9 +65,7 @@ export const ApproveStaticSimulation = () => { textAlign={TextAlign.Center} alignItems={AlignItems.center} > - {spendingCap === SPENDING_CAP_UNLIMITED_MSG - ? t('unlimited') - : spendingCap} + {isUnlimitedSpendingCap ? t('unlimited') : formattedSpendingCap} ); @@ -78,10 +80,9 @@ export const ApproveStaticSimulation = () => { marginInlineEnd={1} minWidth={BlockSize.Zero} > - {spendingCap === SPENDING_CAP_UNLIMITED_MSG ? ( - - {formattedTokenText} - + {Boolean(isUnlimitedSpendingCap) || + spendingCap !== formattedSpendingCap ? ( + {formattedTokenText} ) : ( formattedTokenText )} 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 1eb0ac2cde05..1cd080de96d4 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 @@ -62,41 +62,38 @@ export const EditSpendingCapModal = ({ Number(decimals ?? '0'), ).toFixed(); - const { formattedSpendingCap } = useApproveTokenSimulation( + const { formattedSpendingCap, spendingCap } = useApproveTokenSimulation( transactionMeta, decimals || '0', ); const [customSpendingCapInputValue, setCustomSpendingCapInputValue] = - useState(formattedSpendingCap.toString()); + useState(spendingCap); useEffect(() => { - if (formattedSpendingCap) { - setCustomSpendingCapInputValue(formattedSpendingCap.toString()); + if (spendingCap) { + setCustomSpendingCapInputValue(spendingCap); } - }, [formattedSpendingCap]); + }, [spendingCap]); const handleCancel = useCallback(() => { setIsOpenEditSpendingCapModal(false); - setCustomSpendingCapInputValue(formattedSpendingCap.toString()); + setCustomSpendingCapInputValue(spendingCap); }, [ setIsOpenEditSpendingCapModal, setCustomSpendingCapInputValue, - formattedSpendingCap, + spendingCap, ]); const [isModalSaving, setIsModalSaving] = useState(false); const handleSubmit = useCallback(async () => { setIsModalSaving(true); - const parsedValue = parseInt(String(customSpendingCapInputValue), 10); const customTxParamsData = getCustomTxParamsData( transactionMeta?.txParams?.data, { - customPermissionAmount: - // coerce negative numbers to zero - parsedValue < 0 ? '0' : customSpendingCapInputValue || '0', + customPermissionAmount: customSpendingCapInputValue || '0', decimals: decimals || '0', }, ); @@ -117,8 +114,8 @@ export const EditSpendingCapModal = ({ setIsModalSaving(false); setIsOpenEditSpendingCapModal(false); - setCustomSpendingCapInputValue(formattedSpendingCap.toString()); - }, [customSpendingCapInputValue, formattedSpendingCap]); + setCustomSpendingCapInputValue(spendingCap); + }, [customSpendingCapInputValue, spendingCap]); const showDecimalError = decimals && 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 0178e2ffff62..e0a5a8165dfb 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,8 @@ describe('useApproveTokenSimulation', () => { expect(result.current).toMatchInlineSnapshot(` { - "formattedSpendingCap": "7", + "formattedSpendingCap": "#7", + "isUnlimitedSpendingCap": false, "pending": undefined, "spendingCap": "#7", "value": { @@ -132,8 +133,9 @@ describe('useApproveTokenSimulation', () => { expect(result.current).toMatchInlineSnapshot(` { "formattedSpendingCap": "1,000,000,000,000,000", + "isUnlimitedSpendingCap": true, "pending": undefined, - "spendingCap": "UNLIMITED MESSAGE", + "spendingCap": "1000000000000000", "value": { "data": [ { @@ -197,7 +199,8 @@ describe('useApproveTokenSimulation', () => { expect(result.current).toMatchInlineSnapshot(` { - "formattedSpendingCap": "0.0000000000001", + "formattedSpendingCap": "<0.000001", + "isUnlimitedSpendingCap": false, "pending": undefined, "spendingCap": "0.0000000000001", "value": { 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 8a95fdc3e35b..acd470ba8822 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 @@ -1,11 +1,12 @@ import { TransactionMeta } from '@metamask/transaction-controller'; import { isHexString } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { isBoolean } from 'lodash'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { calcTokenAmount } from '../../../../../../../../shared/lib/transactions-controller-utils'; import { getIntlLocale } from '../../../../../../../ducks/locale/locale'; -import { SPENDING_CAP_UNLIMITED_MSG } from '../../../../../constants'; +import { formatAmount } from '../../../../simulation-details/formatAmount'; import { useDecodedTransactionData } from '../../hooks/useDecodedTransactionData'; import { useIsNFT } from './use-is-nft'; @@ -46,22 +47,26 @@ export const useApproveTokenSimulation = ( ).toFixed(); }, [value, decimals]); + const tokenPrefix = isNFT ? '#' : ''; + const formattedSpendingCap = useMemo(() => { - // formatting coerces small numbers to 0 - return isNFT || parseInt(decodedSpendingCap, 10) < 1 - ? decodedSpendingCap - : new Intl.NumberFormat(locale).format(parseInt(decodedSpendingCap, 10)); + return isNFT + ? `${tokenPrefix}${decodedSpendingCap}` + : formatAmount(locale, new BigNumber(decodedSpendingCap)); }, [decodedSpendingCap, isNFT, locale]); - const spendingCap = useMemo(() => { + const { spendingCap, isUnlimitedSpendingCap } = useMemo(() => { if (!isNFT && isSpendingCapUnlimited(parseInt(decodedSpendingCap, 10))) { - return SPENDING_CAP_UNLIMITED_MSG; + return { spendingCap: decodedSpendingCap, isUnlimitedSpendingCap: true }; } - const tokenPrefix = isNFT ? '#' : ''; - return `${tokenPrefix}${formattedSpendingCap}`; + return { + spendingCap: `${tokenPrefix}${decodedSpendingCap}`, + isUnlimitedSpendingCap: false, + }; }, [decodedSpendingCap, formattedSpendingCap, isNFT]); return { + isUnlimitedSpendingCap, spendingCap, formattedSpendingCap, value, diff --git a/ui/pages/confirmations/components/confirm/info/approve/spending-cap/spending-cap.tsx b/ui/pages/confirmations/components/confirm/info/approve/spending-cap/spending-cap.tsx index 8e7b522f050a..29d009c5b810 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/spending-cap/spending-cap.tsx +++ b/ui/pages/confirmations/components/confirm/info/approve/spending-cap/spending-cap.tsx @@ -9,7 +9,6 @@ import { import { ConfirmInfoSection } from '../../../../../../../components/app/confirm/info/row/section'; import Tooltip from '../../../../../../../components/ui/tooltip'; import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; -import { SPENDING_CAP_UNLIMITED_MSG } from '../../../../../constants'; import { useConfirmContext } from '../../../../../context/confirm'; import { useAssetDetails } from '../../../../../hooks/useAssetDetails'; import { Container } from '../../shared/transaction-data/transaction-data'; @@ -19,30 +18,25 @@ const SpendingCapGroup = ({ tokenSymbol, decimals, setIsOpenEditSpendingCapModal, - customSpendingCap, }: { tokenSymbol: string; decimals: string; setIsOpenEditSpendingCapModal: (newValue: boolean) => void; - customSpendingCap: string; }) => { const t = useI18nContext(); const { currentConfirmation: transactionMeta } = useConfirmContext(); - const { spendingCap, formattedSpendingCap, value } = + const { spendingCap, isUnlimitedSpendingCap, formattedSpendingCap, value } = useApproveTokenSimulation(transactionMeta, decimals); - const spendingCapValue = - customSpendingCap === '' ? formattedSpendingCap : customSpendingCap; - const SpendingCapElement = ( setIsOpenEditSpendingCapModal(true)} editIconClassName="edit-spending-cap-btn" @@ -63,8 +57,9 @@ const SpendingCapGroup = ({ tooltip={t('spendingCapTooltipDesc')} data-testid="confirmation__approve-spending-cap-group" > - {spendingCap === SPENDING_CAP_UNLIMITED_MSG ? ( - {SpendingCapElement} + {Boolean(isUnlimitedSpendingCap) || + spendingCap !== formattedSpendingCap ? ( + {SpendingCapElement} ) : ( SpendingCapElement )} @@ -95,7 +90,7 @@ export const SpendingCap = ({ Number(decimals ?? '0'), ).toFixed(); - const { pending, spendingCap } = useApproveTokenSimulation( + const { pending } = useApproveTokenSimulation( transactionMeta, decimals || '0', ); @@ -114,7 +109,6 @@ export const SpendingCap = ({ tokenSymbol={tokenSymbol || ''} decimals={decimals || '0'} setIsOpenEditSpendingCapModal={setIsOpenEditSpendingCapModal} - customSpendingCap={spendingCap} /> ); 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 index a17a5f37578a..7a488072fd42 100644 --- 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 @@ -6,7 +6,7 @@ exports[`NativeTransferInfo renders correctly 1`] = ` class="mm-box mm-box--padding-4 mm-box--display-flex mm-box--flex-direction-column mm-box--justify-content-center mm-box--align-items-center" >
G
diff --git a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap index ca9af0539f6e..3ff774c6770b 100644 --- a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap @@ -6,16 +6,43 @@ exports[`NFTTokenTransferInfo renders correctly 1`] = ` class="mm-box mm-box--padding-4 mm-box--display-flex mm-box--flex-direction-column mm-box--justify-content-center mm-box--align-items-center" >
- ? +

+ > + #undefined +

{ } name={multichainNetwork?.nickname} size={AvatarTokenSize.Xl} + backgroundColor={BackgroundColor.backgroundDefault} /> ); diff --git a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/__snapshots__/nft-send-heading.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/__snapshots__/nft-send-heading.test.tsx.snap index 2e98c4e5ce13..5559f00fa01f 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/__snapshots__/nft-send-heading.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/__snapshots__/nft-send-heading.test.tsx.snap @@ -6,16 +6,43 @@ exports[` renders component 1`] = ` class="mm-box mm-box--padding-4 mm-box--display-flex mm-box--flex-direction-column mm-box--justify-content-center mm-box--align-items-center" >
- ? +

+ > + #undefined +

`; diff --git a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx index 2f36d10ce42c..405236fe66da 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx @@ -1,19 +1,23 @@ +import { Nft } from '@metamask/assets-controllers'; import { TransactionMeta } from '@metamask/transaction-controller'; import React from 'react'; -import { - AvatarToken, - AvatarTokenSize, - Box, - Text, -} from '../../../../../../../components/component-library'; +import { useSelector } from 'react-redux'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../../../shared/constants/network'; +import { isEqualCaseInsensitive } from '../../../../../../../../shared/modules/string-utils'; +import { Box, Text } from '../../../../../../../components/component-library'; +import { NftItem } from '../../../../../../../components/multichain/nft-item'; +import { getNFTsByChainId } from '../../../../../../../ducks/metamask/metamask'; import { AlignItems, Display, FlexDirection, JustifyContent, + TextAlign, TextColor, TextVariant, } from '../../../../../../../helpers/constants/design-system'; +import { getNftImageAlt } from '../../../../../../../helpers/utils/nfts'; +import { getNetworkConfigurationsByChainId } from '../../../../../../../selectors'; import { useConfirmContext } from '../../../../../context/confirm'; import { useAssetDetails } from '../../../../../hooks/useAssetDetails'; @@ -25,21 +29,53 @@ const NFTSendHeading = () => { const userAddress = transactionMeta.txParams.from; const { data } = transactionMeta.txParams; const { chainId } = transactionMeta; + const { + assetName, + tokenImage, + tokenId: assetTokenId, + } = useAssetDetails(tokenAddress, userAddress, data, chainId); + const nfts: Nft[] = useSelector((state) => + getNFTsByChainId(state, chainId), + ) as Nft[]; + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const nft: Nft | undefined = + assetTokenId && + nfts.find( + ({ address, tokenId }: Nft) => + isEqualCaseInsensitive(address, tokenAddress as string) && + assetTokenId === tokenId.toString(), + ); + const imageOriginal = (nft as Nft | undefined)?.imageOriginal; + const image = (nft as Nft | undefined)?.image; + const nftImageAlt = nft && getNftImageAlt(nft); + const nftSrcUrl = imageOriginal ?? (image || ''); + const isIpfsURL = nftSrcUrl?.startsWith('ipfs:'); + const currentChain = networkConfigurations[chainId]; - const { assetName, tokenImage, tokenId } = useAssetDetails( - tokenAddress, - userAddress, - data, - chainId, + const TokenImage = ( + + + ); - const TokenImage = ; - const TokenName = ( {assetName} @@ -47,7 +83,7 @@ const NFTSendHeading = () => { const TokenID = ( - {tokenId} + {`#${assetTokenId}`} ); From 4cfd133fb8a02069a56da3142d167cfe2de659ea Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 20 Nov 2024 14:37:55 +0100 Subject: [PATCH 013/148] feat: cross chain aggregated balance (#28456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Branch was ininitially based on top of https://github.com/MetaMask/metamask-extension/pull/28276 This PR calculates balance for current account cross chains while not taking into account the test networks. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28456?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** Start the app with `PORTFOLIO_VIEW=1 ` 1. Go to main page and click on Ethereum network 2. Import your tokens 3. You should see the total balance in Fiat of your native + ERC20 tokens 4. Switch to another chain where you also have tokens; exp polygon 5. Notice that your total fiat balance is now total fiat balance on Ethereum + current native balance on polygon in fiat 6. Import any tokens you have on polygon 7. Notice that the total fiat balance now added up the sum of ERC20 tokens you had on Polygon 8. Click on the network filter and notice you can see aggregated balance on all networks and aggregated balance on the current network 9. Switch to "Current network" on the network filter and the main view should now show you the aggregated balance on this network. 10. Click on account item and notice that you now see the balance in fiat cross networks 11. Go to settings and turn on the setting "show native token as main balance"; and go back to home page. 12. You should now see your native balance in crypto 13. Click on network filter and you should still be able to see the all networks and current network aggregated balance in fiat. ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/ba52796d-1b39-4de0-be9c-175c8608e042 ## **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 Co-authored-by: MetaMask Bot --- app/_locales/en/messages.json | 4 + privacy-snapshot.json | 3 +- test/data/mock-send-state.json | 4 +- test/data/mock-state.json | 1 + test/e2e/default-fixture.js | 1 + ...rs-after-init-opt-in-background-state.json | 1 + .../errors-after-init-opt-in-ui-state.json | 2 +- ...s-before-init-opt-in-background-state.json | 3 +- .../errors-before-init-opt-in-ui-state.json | 1 + .../tests/settings/account-token-list.spec.js | 44 ++ .../data/integration-init-state.json | 3 +- .../asset-list-control-bar.tsx | 7 +- .../network-filter/network-filter.tsx | 68 +- ...entage-overview-cross-chains.test.tsx.snap | 23 + ...-percentage-overview-cross-chains.test.tsx | 588 ++++++++++++++++++ ...gated-percentage-overview-cross-chains.tsx | 187 ++++++ .../app/wallet-overview/btc-overview.test.tsx | 1 + .../app/wallet-overview/coin-overview.tsx | 76 ++- .../app/wallet-overview/eth-overview.test.js | 1 + .../account-list-item/account-list-item.js | 42 +- .../multichain/pages/send/send.test.js | 2 + ui/ducks/metamask/metamask.js | 4 + ...eAccountTotalCrossChainFiatBalance.test.ts | 226 +++++++ .../useAccountTotalCrossChainFiatBalance.ts | 117 ++++ .../useGetFormattedTokensPerChain.test.ts | 152 +++++ ui/hooks/useGetFormattedTokensPerChain.ts | 77 +++ ui/hooks/useTokenBalances.ts | 6 +- ui/pages/routes/routes.component.test.js | 2 + ui/selectors/selectors.js | 45 +- 29 files changed, 1642 insertions(+), 49 deletions(-) create mode 100644 ui/components/app/wallet-overview/__snapshots__/aggregated-percentage-overview-cross-chains.test.tsx.snap create mode 100644 ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx create mode 100644 ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx create mode 100644 ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts create mode 100644 ui/hooks/useAccountTotalCrossChainFiatBalance.ts create mode 100644 ui/hooks/useGetFormattedTokensPerChain.test.ts create mode 100644 ui/hooks/useGetFormattedTokensPerChain.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 74e7d745cae7..56a2ba405aef 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1343,6 +1343,10 @@ "creatorAddress": { "message": "Creator address" }, + "crossChainAggregatedBalancePopover": { + "message": "This reflects the value of all tokens you own on all networks. If you prefer seeing this value in ETH or other currencies, go to $1.", + "description": "$1 represents the settings page" + }, "crossChainSwapsLink": { "message": "Swap across networks with MetaMask Portfolio" }, diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 817d1e102bff..f5cf068b8728 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -52,12 +52,13 @@ "security-alerts.api.cx.metamask.io", "security-alerts.dev-api.cx.metamask.io", "sentry.io", + "sepolia.infura.io", "snaps.metamask.io", "sourcify.dev", "start.metamask.io", "static.cx.metamask.io", - "support.metamask.io", "support.metamask-institutional.io", + "support.metamask.io", "swap.api.cx.metamask.io", "test.metamask-phishing.io", "token.api.cx.metamask.io", diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json index 96cd95cfbd84..73468aca6171 100644 --- a/test/data/mock-send-state.json +++ b/test/data/mock-send-state.json @@ -63,6 +63,7 @@ "currentLocale": "en" }, "metamask": { + "accountsByChainId": {}, "ipfsGateway": "", "dismissSeedBackUpReminder": false, "usePhishDetect": true, @@ -131,7 +132,8 @@ "preferences": { "hideZeroBalanceTokens": false, "showFiatInTestnets": false, - "showTestNetworks": true + "showTestNetworks": true, + "tokenNetworkFilter": {} }, "seedPhraseBackedUp": null, "ensResolutionsByAddress": {}, diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 80e5499447d7..2932e5fc56d9 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -379,6 +379,7 @@ "showNativeTokenAsMainBalance": true, "showTestNetworks": true, "smartTransactionsOptInStatus": true, + "tokenNetworkFilter": {}, "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index fd2d5be42891..c2fba9d63424 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -225,6 +225,7 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { sortCallback: 'stringNumeric', }, shouldShowAggregatedBalancePopover: true, + tokenNetworkFilter: {}, }, selectedAddress: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', theme: 'light', 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 e47cfcd806b9..9df8707d1f17 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 @@ -237,6 +237,7 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", + "tokenNetworkFilter": "object", "shouldShowAggregatedBalancePopover": "boolean" }, "ipfsGateway": "string", 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 7fd8501eb2b8..006277f89160 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 @@ -38,7 +38,7 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", - "showMultiRpcModal": "boolean", + "tokenNetworkFilter": "object", "shouldShowAggregatedBalancePopover": "boolean" }, "firstTimeFlowType": "import", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index fa1a00cbe4ef..91c994e9ab66 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -119,7 +119,8 @@ "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", "showMultiRpcModal": "boolean", - "shouldShowAggregatedBalancePopover": "boolean" + "shouldShowAggregatedBalancePopover": "boolean", + "tokenNetworkFilter": "object" }, "selectedAddress": "string", "theme": "light", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index b3fa8d117beb..552f089c6604 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -135,6 +135,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", + "tokenNetworkFilter": "object", "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/settings/account-token-list.spec.js b/test/e2e/tests/settings/account-token-list.spec.js index 9e4822d0dbbc..ddd905501f50 100644 --- a/test/e2e/tests/settings/account-token-list.spec.js +++ b/test/e2e/tests/settings/account-token-list.spec.js @@ -5,9 +5,38 @@ const { logInWithBalanceValidation, unlockWallet, } = require('../../helpers'); +const { + switchToNetworkFlow, +} = require('../../page-objects/flows/network.flow'); +const { mockServerJsonRpc } = require('../ppom/mocks/mock-server-json-rpc'); const FixtureBuilder = require('../../fixture-builder'); +const infuraSepoliaUrl = + 'https://sepolia.infura.io/v3/00000000000000000000000000000000'; + +async function mockInfura(mockServer) { + await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], + ]); + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6857940763865360', + result: '0x15af1d78b58c40000', + }, + })); +} + +async function mockInfuraResponses(mockServer) { + await mockInfura(mockServer); +} + describe('Settings', function () { it('Should match the value of token list item and account list item for eth conversion', async function () { await withFixtures( @@ -49,6 +78,7 @@ describe('Settings', function () { .build(), ganacheOptions: defaultGanacheOptions, title: this.test.fullTitle(), + testSpecificMock: mockInfuraResponses, }, async ({ driver }) => { await unlockWallet(driver); @@ -63,6 +93,20 @@ describe('Settings', function () { ); await driver.delay(1000); assert.equal(await tokenListAmount.getText(), '$42,500.00\nUSD'); + + // switch to Sepolia + // the account list item used to always show account.balance as long as its EVM network. + // Now we are showing aggregated fiat balance on non testnetworks; but if it is a testnetwork we will show account.balance. + // The current test was running initially on localhost + // which is not a testnetwork resulting in the code trying to calculate the aggregated total fiat balance which shows 0.00$ + // If this test switches to mainnet then switches back to localhost; the test will pass because switching to mainnet + // will make the code calculate the aggregate fiat balance on mainnet+Linea mainnet and because this account in this test + // has 42,500.00 native Eth on mainnet then the aggregated total fiat would be 42,500.00. When the user switches back to localhost + // it will show the total that the test is expecting. + + // I think we can slightly modify this test to switch to Sepolia network before checking the account List item value + await switchToNetworkFlow(driver, 'Sepolia'); + await driver.clickElement('[data-testid="account-menu-icon"]'); const accountTokenValue = await driver.waitForSelector( '.multichain-account-list-item .multichain-account-list-item__asset', diff --git a/test/integration/data/integration-init-state.json b/test/integration/data/integration-init-state.json index a0ae3a8fb146..ed42111c00e1 100644 --- a/test/integration/data/integration-init-state.json +++ b/test/integration/data/integration-init-state.json @@ -784,7 +784,8 @@ "smartTransactionsOptInStatus": true, "petnamesEnabled": false, "showConfirmationAdvancedDetails": false, - "showMultiRpcModal": false + "showMultiRpcModal": false, + "tokenNetworkFilter": {} }, "preventPollingOnNetworkRestart": true, "previousAppVersion": "11.14.4", 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 c09421279265..7722eff36870 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,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; import { @@ -28,6 +28,7 @@ import { ENVIRONMENT_TYPE_POPUP, } from '../../../../../../shared/constants/app'; import NetworkFilter from '../network-filter'; +import { TEST_CHAINS } from '../../../../../../shared/constants/network'; type AssetListControlBarProps = { showTokensLinks?: boolean; @@ -43,6 +44,9 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { useState(false); const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; + const isTestNetwork = useMemo(() => { + return (TEST_CHAINS as string[]).includes(currentNetwork.chainId); + }, [currentNetwork.chainId, TEST_CHAINS]); const windowType = getEnvironmentType(); const isFullScreen = @@ -84,6 +88,7 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { className="asset-list-control-bar__button" onClick={toggleNetworkFilterPopover} size={ButtonBaseSize.Sm} + disabled={isTestNetwork} endIconName={IconName.ArrowDown} backgroundColor={ isNetworkFilterPopoverOpen diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index cc2d0f38210e..8b9fc06b33e7 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -4,15 +4,14 @@ import { setTokenNetworkFilter } from '../../../../../store/actions'; import { getCurrentChainId, getCurrentNetwork, - getIsTestnet, getPreferences, getSelectedInternalAccount, getShouldHideZeroBalanceTokens, getNetworkConfigurationsByChainId, + getChainIdsToPoll, } from '../../../../../selectors'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { SelectableListItem } from '../sort-control/sort-control'; -import { useAccountTotalFiatBalance } from '../../../../../hooks/useAccountTotalFiatBalance'; import { Text } from '../../../../component-library/text/text'; import { Display, @@ -24,6 +23,8 @@ import { Box } from '../../../../component-library/box/box'; import { AvatarNetwork } from '../../../../component-library'; import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../shared/constants/network'; +import { useGetFormattedTokensPerChain } from '../../../../../hooks/useGetFormattedTokensPerChain'; +import { useAccountTotalCrossChainFiatBalance } from '../../../../../hooks/useAccountTotalCrossChainFiatBalance'; type SortControlProps = { handleClose: () => void; @@ -36,15 +37,35 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { const selectedAccount = useSelector(getSelectedInternalAccount); const currentNetwork = useSelector(getCurrentNetwork); const allNetworks = useSelector(getNetworkConfigurationsByChainId); - const isTestnet = useSelector(getIsTestnet); - const { tokenNetworkFilter, showNativeTokenAsMainBalance } = - useSelector(getPreferences); + const { tokenNetworkFilter } = useSelector(getPreferences); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); - + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + true, // true to get formattedTokensWithBalancesPerChain for the current chain + allChainIDs, + ); const { totalFiatBalance: selectedAccountBalance } = - useAccountTotalFiatBalance(selectedAccount, shouldHideZeroBalanceTokens); + useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensWithBalancesPerChain, + ); + + const { formattedTokensWithBalancesPerChain: formattedTokensForAllNetworks } = + useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + false, // false to get the value for all networks + allChainIDs, + ); + const { totalFiatBalance: selectedAccountBalanceForAllNetworks } = + useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensForAllNetworks, + ); // TODO: fetch balances across networks // const multiNetworkAccountBalance = useMultichainAccountBalance() @@ -78,7 +99,15 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { color={TextColor.textDefault} > {/* TODO: Should query cross chain account balance */} - $1,000.00 + + @@ -120,16 +149,19 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { > {t('currentNetwork')} - + + + +
+

+ +$0.22 +

+

+ (+0.08%) +

+
+ +`; diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx new file mode 100644 index 000000000000..1a335de9c14b --- /dev/null +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx @@ -0,0 +1,588 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getIntlLocale } from '../../../ducks/locale/locale'; +import { + getCurrentCurrency, + getSelectedAccount, + getShouldHideZeroBalanceTokens, + getPreferences, + getMarketData, + getNetworkConfigurationsByChainId, + getAllTokens, + getChainIdsToPoll, +} from '../../../selectors'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; +import { AggregatedPercentageOverviewCrossChains } from './aggregated-percentage-overview-cross-chains'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +const mockUseGetFormattedTokensPerChain = jest.fn().mockReturnValue({ + formattedTokensWithBalancesPerChain: {}, +}); +jest.mock('../../../hooks/useGetFormattedTokensPerChain', () => ({ + useGetFormattedTokensPerChain: () => mockUseGetFormattedTokensPerChain(), +})); + +jest.mock('../../../ducks/locale/locale', () => ({ + getIntlLocale: jest.fn(), +})); + +jest.mock('../../../selectors', () => ({ + getCurrentCurrency: jest.fn(), + getSelectedAccount: jest.fn(), + getPreferences: jest.fn(), + getShouldHideZeroBalanceTokens: jest.fn(), + getMarketData: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getAllTokens: jest.fn(), + getChainIdsToPoll: jest.fn(), +})); + +jest.mock('../../../hooks/useAccountTotalCrossChainFiatBalance', () => ({ + useAccountTotalCrossChainFiatBalance: jest.fn(), +})); + +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; + +const mockGetMarketData = getMarketData as jest.Mock; +const mockGetChainIdsToPoll = getChainIdsToPoll as unknown as jest.Mock; +const mockGetNetworkConfigurationsByChainId = + getNetworkConfigurationsByChainId as unknown as jest.Mock; +const mockGetAllTokens = getAllTokens as jest.Mock; + +const allTokens = { + '0x1': { + '0x2990079bcdee240329a520d2444386fc119da21a': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'Crypto.com', + 'CoinGecko', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 6, + symbol: 'USDC', + }, + ], + }, + '0xe708': { + '0x2990079bcdee240329a520d2444386fc119da21a': [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + aggregators: ['LineaTeam', 'CoinGecko', 'Lifi', 'Rubic', 'Xswap'], + decimals: 18, + symbol: 'DAI', + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + aggregators: [ + 'LineaTeam', + 'CoinGecko', + 'Lifi', + 'Squid', + 'Rubic', + 'Xswap', + ], + decimals: 6, + symbol: 'USDT', + }, + ], + }, +}; +const networkConfigsByChainId = { + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xaa36a7': { + blockExplorerUrls: ['https://sepolia.etherscan.io'], + chainId: '0xaa36a7', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + type: 'infura', + url: 'https://sepolia.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe705': { + blockExplorerUrls: ['https://sepolia.lineascan.build'], + chainId: '0xe705', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea Sepolia', + nativeCurrency: 'LineaETH', + rpcEndpoints: [ + { + networkClientId: 'linea-sepolia', + type: 'infura', + url: 'https://linea-sepolia.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe708': { + blockExplorerUrls: ['https://lineascan.build'], + chainId: '0xe708', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, +}; +const selectedAccountMock = { + id: 'd51c0116-de36-4e77-b35b-408d4ea82d01', + address: '0x2990079bcdee240329a520d2444386fc119da21a', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 2', + importTime: 1725467263902, + lastSelected: 1725467263905, + keyring: { + type: 'Simple Key Pair', + }, + }, + balance: '0x0f7e2a03e67666', +}; + +const crossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: -0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: -0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: -0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: -0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: -0.2013707959252836, + }, + }, +}; + +const negativeCrossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: -0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: -0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: -0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: -0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: -0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: -0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: -0.2013707959252836, + }, + }, +}; +const positiveCrossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: 0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: 0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: 0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: 0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: 0.2013707959252836, + }, + }, +}; +describe('AggregatedPercentageOverviewCrossChains', () => { + beforeEach(() => { + mockGetIntlLocale.mockReturnValue('en-US'); + mockGetCurrentCurrency.mockReturnValue('USD'); + mockGetPreferences.mockReturnValue({ privacyMode: false }); + mockGetSelectedAccount.mockReturnValue(selectedAccountMock); + mockGetShouldHideZeroBalanceTokens.mockReturnValue(false); + + mockGetMarketData.mockReturnValue(crossChainMarketDataMock); + mockGetChainIdsToPoll.mockReturnValue(['0x1']); + mockGetNetworkConfigurationsByChainId.mockReturnValue( + networkConfigsByChainId, + ); + mockGetAllTokens.mockReturnValue(allTokens); + + jest.clearAllMocks(); + }); + + describe('render', () => { + it('renders correctly', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + it('should display zero percentage and amount if balance is zero across chains', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [], + tokenFiatBalances: [], + nativeFiatValue: '0', + }, + { + chainId: '0xe708', + tokensWithBalances: [], + tokenFiatBalances: [], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 0, + }); + + render(); + const percentageElement = screen.getByText('(+0.00%)'); + const numberElement = screen.getByText('+$0.00'); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display negative aggregated amount and percentage change with all negative market data cross chains', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + mockGetMarketData.mockReturnValue(negativeCrossChainMarketDataMock); + const expectedAmountChange = '-$0.97'; + const expectedPercentageChange = '(-0.33%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display positive aggregated amount and percentage change with all positive market data', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + mockGetMarketData.mockReturnValue(positiveCrossChainMarketDataMock); + const expectedAmountChange = '+$0.96'; + const expectedPercentageChange = '(+0.33%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display correct aggregated amount and percentage change with positive and negative market data', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + const expectedAmountChange = '+$0.22'; + const expectedPercentageChange = '(+0.08%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx new file mode 100644 index 000000000000..fe3698e2fc2f --- /dev/null +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx @@ -0,0 +1,187 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { zeroAddress, toChecksumAddress } from 'ethereumjs-util'; +import { + getCurrentCurrency, + getSelectedAccount, + getShouldHideZeroBalanceTokens, + getPreferences, + getMarketData, + getChainIdsToPoll, +} from '../../../selectors'; + +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { formatValue, isValidAmount } from '../../../../app/scripts/lib/util'; +import { getIntlLocale } from '../../../ducks/locale/locale'; +import { + Display, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { Box, SensitiveText } from '../../component-library'; +import { getCalculatedTokenAmount1dAgo } from '../../../helpers/utils/util'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { TokenWithBalance } from '../assets/asset-list/asset-list'; + +export const AggregatedPercentageOverviewCrossChains = () => { + const locale = useSelector(getIntlLocale); + const fiatCurrency = useSelector(getCurrentCurrency); + const { privacyMode } = useSelector(getPreferences); + const selectedAccount = useSelector(getSelectedAccount); + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + const crossChainMarketData = useSelector(getMarketData); + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + false, + allChainIDs, + ); + const { + totalFiatBalance: totalFiatCrossChains, + tokenFiatBalancesCrossChains, + } = useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensWithBalancesPerChain, + ); + + const getPerChainTotalFiat1dAgo = ( + chainId: string, + tokenFiatBalances: (string | undefined)[], + tokensWithBalances: TokenWithBalance[], + ) => { + const totalPerChain1dAgoERC20 = tokensWithBalances.reduce( + (total1dAgo: number, item: { address: string }, idx: number) => { + const found = + crossChainMarketData?.[chainId]?.[toChecksumAddress(item.address)]; + + const tokenFiat1dAgo = getCalculatedTokenAmount1dAgo( + tokenFiatBalances[idx], + found?.pricePercentChange1d, + ); + return total1dAgo + Number(tokenFiat1dAgo); + }, + 0, + ); + + return totalPerChain1dAgoERC20; + }; + + const totalFiat1dAgoCrossChains = useMemo(() => { + return tokenFiatBalancesCrossChains.reduce( + ( + total1dAgoCrossChains: number, + item: { + chainId: string; + nativeFiatValue: string; + tokenFiatBalances: (string | undefined)[]; + tokensWithBalances: TokenWithBalance[]; + }, + ) => { + const perChainERC20Total = getPerChainTotalFiat1dAgo( + item.chainId, + item.tokenFiatBalances, + item.tokensWithBalances, + ); + const nativePricePercentChange1d = + crossChainMarketData?.[item.chainId]?.[zeroAddress()] + ?.pricePercentChange1d; + + const nativeFiat1dAgo = getCalculatedTokenAmount1dAgo( + item.nativeFiatValue, + nativePricePercentChange1d, + ); + return ( + total1dAgoCrossChains + perChainERC20Total + Number(nativeFiat1dAgo) + ); + }, + 0, + ); // Initial total1dAgo is 0 + }, [tokenFiatBalancesCrossChains, crossChainMarketData]); + + const totalCrossChainBalance: number = Number(totalFiatCrossChains); + const crossChainTotalBalance1dAgo = totalFiat1dAgoCrossChains; + + const amountChangeCrossChains = + totalCrossChainBalance - crossChainTotalBalance1dAgo; + const percentageChangeCrossChains = + (amountChangeCrossChains / crossChainTotalBalance1dAgo) * 100 || 0; + + const formattedPercentChangeCrossChains = formatValue( + amountChangeCrossChains === 0 ? 0 : percentageChangeCrossChains, + true, + ); + + let formattedAmountChangeCrossChains = ''; + if (isValidAmount(amountChangeCrossChains)) { + formattedAmountChangeCrossChains = + (amountChangeCrossChains as number) >= 0 ? '+' : ''; + + const options = { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 2, + } as const; + + try { + // For currencies compliant with ISO 4217 Standard + formattedAmountChangeCrossChains += `${Intl.NumberFormat(locale, { + ...options, + style: 'currency', + currency: fiatCurrency, + }).format(amountChangeCrossChains as number)} `; + } catch { + // Non-standard Currency Codes + formattedAmountChangeCrossChains += `${Intl.NumberFormat(locale, { + ...options, + minimumFractionDigits: 2, + style: 'decimal', + }).format(amountChangeCrossChains as number)} `; + } + } + + let color = TextColor.textDefault; + + if (!privacyMode && isValidAmount(amountChangeCrossChains)) { + if ((amountChangeCrossChains as number) === 0) { + color = TextColor.textDefault; + } else if ((amountChangeCrossChains as number) > 0) { + color = TextColor.successDefault; + } else { + color = TextColor.errorDefault; + } + } else { + color = TextColor.textAlternative; + } + + return ( + + + {formattedAmountChangeCrossChains} + + + {formattedPercentChangeCrossChains} + + + ); +}; diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index 671e03a87ea8..93c9e09ff0fd 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -150,6 +150,7 @@ describe('BtcOverview', () => { // The balances won't be available preferences: { showNativeTokenAsMainBalance: false, + tokenNetworkFilter: {}, }, }, }), diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index 2244f3b33e82..b06f0c06b374 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -50,6 +50,8 @@ import { getTokensMarketData, getIsTestnet, getShouldShowAggregatedBalancePopover, + getIsTokenNetworkFilterEqualCurrentNetwork, + getChainIdsToPoll, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getDataCollectionForMarketing, getMetaMetricsId, @@ -61,7 +63,6 @@ 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, setPrivacyMode, @@ -69,9 +70,13 @@ import { import { useTheme } from '../../../hooks/useTheme'; import { getSpecificSettingsRoute } from '../../../helpers/utils/settings-search'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; + +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; import WalletOverview from './wallet-overview'; import CoinButtons from './coin-buttons'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; +import { AggregatedPercentageOverviewCrossChains } from './aggregated-percentage-overview-cross-chains'; export type CoinOverviewProps = { account: InternalAccount; @@ -136,12 +141,23 @@ export const CoinOverview = ({ const { showFiatInTestnets, privacyMode, showNativeTokenAsMainBalance } = useSelector(getPreferences); + const isTokenNetworkFilterEqualCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); + const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); - const { totalFiatBalance, loading } = useAccountTotalFiatBalance( + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( account, shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ); + const { totalFiatBalance } = useAccountTotalCrossChainFiatBalance( + account, + formattedTokensWithBalancesPerChain, ); const isEvm = useSelector(getMultichainIsEvm); @@ -150,7 +166,7 @@ export const CoinOverview = ({ let balanceToDisplay; if (isNotAggregatedFiatBalance) { balanceToDisplay = balance; - } else if (!loading) { + } else { balanceToDisplay = totalFiatBalance; } @@ -225,7 +241,13 @@ export const CoinOverview = ({ } return ( - + {isTokenNetworkFilterEqualCurrentNetwork || + !process.env.PORTFOLIO_VIEW ? ( + + ) : ( + + )} + { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - {t('aggregatedBalancePopover', [ - - {t('settings')} - , - ])} + {process.env.PORTFOLIO_VIEW + ? t('crossChainAggregatedBalancePopover', [ + + {t('settings')} + , + ]) + : t('aggregatedBalancePopover', [ + + {t('settings')} + , + ])} diff --git a/ui/components/app/wallet-overview/eth-overview.test.js b/ui/components/app/wallet-overview/eth-overview.test.js index a8c490b923c6..40c0b818649c 100644 --- a/ui/components/app/wallet-overview/eth-overview.test.js +++ b/ui/components/app/wallet-overview/eth-overview.test.js @@ -94,6 +94,7 @@ describe('EthOverview', () => { }, preferences: { showNativeTokenAsMainBalance: true, + tokenNetworkFilter: {}, }, useExternalServices: true, useCurrencyRateCheck: true, diff --git a/ui/components/multichain/account-list-item/account-list-item.js b/ui/components/multichain/account-list-item/account-list-item.js index 143c4d142a16..e63266080be5 100644 --- a/ui/components/multichain/account-list-item/account-list-item.js +++ b/ui/components/multichain/account-list-item/account-list-item.js @@ -47,8 +47,11 @@ import { import { MetaMetricsContext } from '../../../contexts/metametrics'; import { isAccountConnectedToCurrentTab, - getShowFiatInTestnets, getUseBlockie, + getShouldHideZeroBalanceTokens, + getIsTokenNetworkFilterEqualCurrentNetwork, + getShowFiatInTestnets, + getChainIdsToPoll, } from '../../../selectors'; import { getMultichainIsTestnet, @@ -67,6 +70,8 @@ import { useTheme } from '../../../hooks/useTheme'; // eslint-disable-next-line import/no-restricted-paths import { normalizeSafeAddress } from '../../../../app/scripts/lib/multichain/address'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; import { AccountListItemMenuTypes } from './account-list-item.types'; const MAXIMUM_CURRENCY_DECIMALS = 3; @@ -99,6 +104,7 @@ const AccountListItem = ({ const setAccountListItemMenuRef = (ref) => { setAccountListItemMenuElement(ref); }; + const isTestnet = useMultichainSelector(getMultichainIsTestnet, account); const isMainnet = !isTestnet; const shouldShowFiat = useMultichainSelector( @@ -110,14 +116,39 @@ const AccountListItem = ({ shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); const accountTotalFiatBalances = useMultichainAccountTotalFiatBalance(account); + // cross chain agg balance + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + const isTokenNetworkFilterEqualCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + account, + shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ); + const { totalFiatBalance } = useAccountTotalCrossChainFiatBalance( + account, + formattedTokensWithBalancesPerChain, + ); + // cross chain agg balance const mappedOrderedTokenList = accountTotalFiatBalances.orderedTokenList.map( (item) => ({ avatarValue: item.iconUrl, }), ); - const balanceToTranslate = isEvmNetwork - ? account.balance - : accountTotalFiatBalances.totalBalance; + let balanceToTranslate; + if (isEvmNetwork) { + balanceToTranslate = + isTestnet || !process.env.PORTFOLIO_VIEW + ? account.balance + : totalFiatBalance; + } else { + balanceToTranslate = accountTotalFiatBalances.totalBalance; + } ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const custodianIcon = useSelector((state) => @@ -313,6 +344,9 @@ const AccountListItem = ({ value={balanceToTranslate} type={PRIMARY} showFiat={showFiat} + isAggregatedFiatOverviewBalance={ + !isTestnet && process.env.PORTFOLIO_VIEW + } data-testid="first-currency-display" privacyMode={privacyMode} /> diff --git a/ui/components/multichain/pages/send/send.test.js b/ui/components/multichain/pages/send/send.test.js index 5195ee15de5b..bc8db6adf3b6 100644 --- a/ui/components/multichain/pages/send/send.test.js +++ b/ui/components/multichain/pages/send/send.test.js @@ -89,6 +89,7 @@ const baseStore = { }, }, metamask: { + accountsByChainId: {}, permissionHistory: {}, transactions: [ { @@ -168,6 +169,7 @@ const baseStore = { tokens: [], preferences: { showFiatInTestnets: true, + tokenNetworkFilter: {}, }, currentCurrency: 'USD', nativeCurrency: 'ETH', diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 2b5561739acf..af456e29acbc 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -369,6 +369,10 @@ export function getConversionRate(state) { ?.conversionRate; } +export function getCurrencyRates(state) { + return state.metamask.currencyRates; +} + export function getSendHexDataFeatureFlagState(state) { return state.metamask.featureFlags.sendHexData; } diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts new file mode 100644 index 000000000000..9fe819d92171 --- /dev/null +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { + getCurrentCurrency, + getNetworkConfigurationsByChainId, + getCrossChainTokenExchangeRates, + getCrossChainMetaMaskCachedBalances, +} from '../selectors'; +import { getCurrencyRates } from '../ducks/metamask/metamask'; +import { + FormattedTokensWithBalances, + useAccountTotalCrossChainFiatBalance, +} from './useAccountTotalCrossChainFiatBalance'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../selectors', () => ({ + getCurrentCurrency: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getCrossChainTokenExchangeRates: jest.fn(), + getCrossChainMetaMaskCachedBalances: jest.fn(), +})); +jest.mock('../ducks/metamask/metamask', () => ({ + getCurrencyRates: jest.fn(), +})); + +const mockGetCurrencyRates = getCurrencyRates as jest.Mock; +const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; +const mockGetNetworkConfigurationsByChainId = + getNetworkConfigurationsByChainId as unknown as jest.Mock; +const mockGetCrossChainTokenExchangeRates = + getCrossChainTokenExchangeRates as jest.Mock; +const mockGetCrossChainMetaMaskCachedBalances = + getCrossChainMetaMaskCachedBalances as jest.Mock; + +const mockUseTokenBalances = jest.fn().mockReturnValue({ + tokenBalances: { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x2f18e6', + '0x6B175474E89094C44Da98b954EedeAC495271d0F': '0x378afc9a77b47a30', + }, + }, + }, +}); +jest.mock('./useTokenBalances', () => ({ + useTokenBalances: () => mockUseTokenBalances(), + stringifyBalance: jest.fn(), +})); + +const mockCurrencyRates = { + ETH: { + conversionDate: 1732040829.246, + conversionRate: 3124.56, + usdConversionRate: 3124.56, + }, + LineaETH: { + conversionDate: 1732040829.246, + conversionRate: 3124.56, + usdConversionRate: 3124.56, + }, +}; + +const mockNetworkConfigs = { + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe708': { + blockExplorerUrls: ['https://lineascan.build'], + chainId: '0xe708', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, +}; + +const mockCrossChainTokenExchangeRates = { + '0x1': { + '0x0000000000000000000000000000000000000000': 1.0000131552270237, + '0x4d224452801ACEd8B2F0aebE155379bb5D594381': 0.0003643652288147761, + '0x6982508145454Ce325dDbE47a25d4ec3d2311933': 6.62249784302e-9, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': 0.00031961862176734744, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.00031993824038911484, + '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84': 0.9994154684043188, + }, + '0xe708': { + '0x0000000000000000000000000000000000000000': 0.9999084951480334, + }, +}; + +const mockCachedBalances = { + '0x1': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': '0x4e2adedda15fd6', + }, + '0xe708': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': '0x4e2adedda15fd6', + }, +}; + +describe('useAccountTotalCrossChainFiatBalance', () => { + beforeEach(() => { + mockGetCurrencyRates.mockReturnValue(mockCurrencyRates); + mockGetCurrentCurrency.mockReturnValue('usd'); + mockGetNetworkConfigurationsByChainId.mockReturnValue(mockNetworkConfigs); + mockGetCrossChainTokenExchangeRates.mockReturnValue( + mockCrossChainTokenExchangeRates, + ); + mockGetCrossChainMetaMaskCachedBalances.mockReturnValue(mockCachedBalances); + + jest.clearAllMocks(); + }); + it('should return totalFiatBalance successfully for eth and linea', async () => { + const testAccount = { + id: '7d3a1213-c465-4995-b42a-85e2ccfd2f22', + address: '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + }; + const testFormattedTokensWithBalances = [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + balance: '3086566', + string: '3.08656', + image: '', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + decimals: 18, + balance: '4002288959235586608', + string: '4.00228', + image: '', + }, + ], + }, + { + chainId: '0xe708', + tokensWithBalances: [], + }, + ]; + + const expectedResult = { + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + nativeFiatValue: '68.75', + tokenFiatBalances: ['3.09', '4'], + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: '3086566', + decimals: 6, + image: '', + string: '3.08656', + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + balance: '4002288959235586608', + decimals: 18, + image: '', + string: '4.00228', + symbol: 'DAI', + }, + ], + }, + { + chainId: '0xe708', + nativeFiatValue: '68.75', + tokenFiatBalances: [], + tokensWithBalances: [], + }, + ], + totalFiatBalance: '144.59', + }; + + let result; + await act(async () => { + result = renderHook(() => + useAccountTotalCrossChainFiatBalance( + testAccount, + testFormattedTokensWithBalances as FormattedTokensWithBalances[], + ), + ); + }); + + expect((result as unknown as Record).result.current).toEqual( + expectedResult, + ); + }); +}); diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts new file mode 100644 index 000000000000..d63328e4fbcf --- /dev/null +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts @@ -0,0 +1,117 @@ +import { shallowEqual, useSelector } from 'react-redux'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { + getCurrentCurrency, + getNetworkConfigurationsByChainId, + getCrossChainTokenExchangeRates, + getCrossChainMetaMaskCachedBalances, +} from '../selectors'; +import { + getValueFromWeiHex, + sumDecimals, +} from '../../shared/modules/conversion.utils'; +import { getCurrencyRates } from '../ducks/metamask/metamask'; +import { getTokenFiatAmount } from '../helpers/utils/token-util'; +import { TokenWithBalance } from '../components/app/assets/asset-list/asset-list'; + +type AddressBalances = { + [address: string]: number; +}; + +export type Balances = { + [id: string]: AddressBalances; +}; + +export type FormattedTokensWithBalances = { + chainId: string; + tokensWithBalances: TokenWithBalance[]; +}; + +export const useAccountTotalCrossChainFiatBalance = ( + account: { address: string }, + formattedTokensWithBalancesPerChain: FormattedTokensWithBalances[], +) => { + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const currencyRates = useSelector(getCurrencyRates); + const currentCurrency = useSelector(getCurrentCurrency); + + const crossChainContractRates = useSelector( + getCrossChainTokenExchangeRates, + shallowEqual, + ); + const crossChainCachedBalances: Balances = useSelector( + getCrossChainMetaMaskCachedBalances, + ); + const mergedCrossChainRates: Balances = { + ...crossChainContractRates, // todo add confirmation exchange rates? + }; + + const tokenFiatBalancesCrossChains = formattedTokensWithBalancesPerChain.map( + (singleChainTokenBalances) => { + const { tokensWithBalances } = singleChainTokenBalances; + const matchedChainSymbol = + allNetworks[singleChainTokenBalances.chainId as `0x${string}`] + .nativeCurrency; + const conversionRate = + currencyRates?.[matchedChainSymbol]?.conversionRate; + const tokenFiatBalances = tokensWithBalances.map((token) => { + const tokenExchangeRate = + mergedCrossChainRates?.[singleChainTokenBalances.chainId]?.[ + toChecksumAddress(token.address) + ]; + const totalFiatValue = getTokenFiatAmount( + tokenExchangeRate, + conversionRate, + currentCurrency, + token.string, + token.symbol, + false, + false, + ); + + return totalFiatValue; + }); + + const balanceCached = + crossChainCachedBalances?.[singleChainTokenBalances.chainId]?.[ + account?.address + ] ?? 0; + const nativeFiatValue = getValueFromWeiHex({ + value: balanceCached, + toCurrency: currentCurrency, + conversionRate, + numberOfDecimals: 2, + }); + return { + ...singleChainTokenBalances, + tokenFiatBalances, + nativeFiatValue, + }; + }, + ); + + const finalTotal = tokenFiatBalancesCrossChains.reduce( + (accumulator, currentValue) => { + const tmpCurrentValueFiatBalances: string[] = + currentValue.tokenFiatBalances.filter( + (value): value is string => value !== undefined, + ); + const totalFiatBalance = sumDecimals( + currentValue.nativeFiatValue, + ...tmpCurrentValueFiatBalances, + ); + + const totalAsNumber = totalFiatBalance.toNumber + ? totalFiatBalance.toNumber() + : Number(totalFiatBalance); + + return accumulator + totalAsNumber; + }, + 0, + ); + + return { + totalFiatBalance: finalTotal.toString(10), + tokenFiatBalancesCrossChains, + }; +}; diff --git a/ui/hooks/useGetFormattedTokensPerChain.test.ts b/ui/hooks/useGetFormattedTokensPerChain.test.ts new file mode 100644 index 000000000000..973a16b0e648 --- /dev/null +++ b/ui/hooks/useGetFormattedTokensPerChain.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { getAllTokens, getCurrentChainId } from '../selectors'; +import { useGetFormattedTokensPerChain } from './useGetFormattedTokensPerChain'; +import { stringifyBalance } from './useTokenBalances'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../selectors', () => ({ + getCurrentChainId: jest.fn(), + getAllTokens: jest.fn(), +})); + +const mockGetAllTokens = getAllTokens as jest.Mock; +const mockGetCurrentChainId = getCurrentChainId as jest.Mock; + +const mockUseTokenBalances = jest.fn().mockReturnValue({ + tokenBalances: { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x2f18e6', + '0x6B175474E89094C44Da98b954EedeAC495271d0F': '0x378afc9a77b47a30', + }, + }, + }, +}); +jest.mock('./useTokenBalances', () => ({ + useTokenBalances: () => mockUseTokenBalances(), + stringifyBalance: jest.fn(), +})); + +const allTokens = { + '0x1': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'Crypto.com', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'CMC', + 'Crypto.com', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 18, + symbol: 'DAI', + }, + ], + }, +}; + +describe('useGetFormattedTokensPerChain', () => { + beforeEach(() => { + mockGetAllTokens.mockReturnValue(allTokens); + mockGetCurrentChainId.mockReturnValue('0x1'); + + jest.clearAllMocks(); + }); + it('should tokensWithBalances for an array of chainIds', async () => { + (stringifyBalance as jest.Mock).mockReturnValueOnce(10.5); + (stringifyBalance as jest.Mock).mockReturnValueOnce(13); + const allChainIDs = ['0x1']; + const isTokenNetworkFilterEqualCurrentNetwork = true; + const shouldHideZeroBalanceTokens = true; + const testAccount = { + id: '7d3a1213-c465-4995-b42a-85e2ccfd2f22', + address: '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + }; + + const expectedResult = { + formattedTokensWithBalancesPerChain: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: '3086566', + decimals: 6, + string: 10.5, + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + balance: '4002288959235586608', + decimals: 18, + string: 13, + symbol: 'DAI', + }, + ], + }, + ], + }; + + let result; + await act(async () => { + result = renderHook(() => + useGetFormattedTokensPerChain( + testAccount, + shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ), + ); + }); + + expect((result as unknown as Record).result.current).toEqual( + expectedResult, + ); + }); +}); diff --git a/ui/hooks/useGetFormattedTokensPerChain.ts b/ui/hooks/useGetFormattedTokensPerChain.ts new file mode 100644 index 000000000000..a69f5be9e1e0 --- /dev/null +++ b/ui/hooks/useGetFormattedTokensPerChain.ts @@ -0,0 +1,77 @@ +import { useSelector } from 'react-redux'; +import { BN } from 'bn.js'; +import { Token } from '@metamask/assets-controllers'; +import { getAllTokens, getCurrentChainId } from '../selectors'; +import { hexToDecimal } from '../../shared/modules/conversion.utils'; + +import { TokenWithBalance } from '../components/multichain/asset-picker-amount/asset-picker-modal/types'; +import { stringifyBalance, useTokenBalances } from './useTokenBalances'; + +type AddressMapping = { + [chainId: string]: { + [tokenAddress: string]: string; + }; +}; + +type TokenBalancesMapping = { + [address: string]: AddressMapping; +}; + +export const useGetFormattedTokensPerChain = ( + account: { address: string }, + shouldHideZeroBalanceTokens: boolean, + shouldGetTokensPerCurrentChain: boolean, + allChainIDs: string[], +) => { + const currentChainId = useSelector(getCurrentChainId); + + const importedTokens = useSelector(getAllTokens); // returns the tokens only when they are imported + const currentTokenBalances: { tokenBalances: TokenBalancesMapping } = + useTokenBalances({ + chainIds: allChainIDs as `0x${string}`[], + }); + + // We will calculate aggregated balance only after the user imports the tokens to the wallet + // we need to format the balances we get from useTokenBalances and match them with symbol and decimals we get from getAllTokens + const networksToFormat = shouldGetTokensPerCurrentChain + ? [currentChainId] + : allChainIDs; + const formattedTokensWithBalancesPerChain = networksToFormat.map( + (singleChain) => { + const tokens = importedTokens?.[singleChain]?.[account?.address] ?? []; + + const tokensWithBalances = tokens.reduce( + (acc: TokenWithBalance[], token: Token) => { + const hexBalance = + currentTokenBalances.tokenBalances[account.address]?.[ + singleChain + ]?.[token.address] ?? '0x0'; + if (hexBalance !== '0x0' || !shouldHideZeroBalanceTokens) { + const decimalBalance = hexToDecimal(hexBalance); + acc.push({ + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + balance: decimalBalance, + string: stringifyBalance( + new BN(decimalBalance), + new BN(token.decimals), + ), + }); + } + return acc; + }, + [], + ); + + return { + chainId: singleChain, + tokensWithBalances, + }; + }, + ); + + return { + formattedTokensWithBalancesPerChain, + }; +}; diff --git a/ui/hooks/useTokenBalances.ts b/ui/hooks/useTokenBalances.ts index 9ff92d488814..8d3a078f8d07 100644 --- a/ui/hooks/useTokenBalances.ts +++ b/ui/hooks/useTokenBalances.ts @@ -68,7 +68,11 @@ export const useTokenTracker = ({ // From https://github.com/MetaMask/eth-token-tracker/blob/main/lib/util.js // Ensures backwards compatibility with display formatting. -function stringifyBalance(balance: BN, bnDecimals: BN, balanceDecimals = 5) { +export function stringifyBalance( + balance: BN, + bnDecimals: BN, + balanceDecimals = 5, +) { if (balance.eq(new BN(0))) { return '0'; } diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 8a516fd76d6a..6c08c9130761 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -112,6 +112,7 @@ describe('Routes Component', () => { ...mockSendState.metamask.swapsState, swapsFeatureIsLive: true, }, + accountsByChainId: {}, pendingApprovals: {}, approvalFlows: [], announcements: {}, @@ -123,6 +124,7 @@ describe('Routes Component', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }, }, send: { diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 67d272b7642b..7e4d04eeb3de 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -449,6 +449,21 @@ export function getMetaMaskCachedBalances(state) { return {}; } +export function getCrossChainMetaMaskCachedBalances(state) { + const allAccountsByChainId = state.metamask.accountsByChainId; + return Object.keys(allAccountsByChainId).reduce((acc, topLevelKey) => { + acc[topLevelKey] = Object.keys(allAccountsByChainId[topLevelKey]).reduce( + (innerAcc, innerKey) => { + innerAcc[innerKey] = + allAccountsByChainId[topLevelKey][innerKey].balance; + return innerAcc; + }, + {}, + ); + + return acc; + }, {}); +} /** * @typedef {import('./selectors.types').InternalAccountWithBalance} InternalAccountWithBalance */ @@ -568,7 +583,6 @@ export function getTargetAccount(state, targetAddress) { export const getTokenExchangeRates = (state) => { const chainId = getCurrentChainId(state); const contractMarketData = state.metamask.marketData?.[chainId] ?? {}; - return Object.entries(contractMarketData).reduce( (acc, [address, marketData]) => { acc[address] = marketData?.price ?? null; @@ -578,6 +592,22 @@ export const getTokenExchangeRates = (state) => { ); }; +export const getCrossChainTokenExchangeRates = (state) => { + const contractMarketData = state.metamask.marketData ?? {}; + + return Object.keys(contractMarketData).reduce((acc, topLevelKey) => { + acc[topLevelKey] = Object.keys(contractMarketData[topLevelKey]).reduce( + (innerAcc, innerKey) => { + innerAcc[innerKey] = contractMarketData[topLevelKey][innerKey]?.price; + return innerAcc; + }, + {}, + ); + + return acc; + }, {}); +}; + /** * Get market data for tokens on the current chain * @@ -954,6 +984,19 @@ export function getPetnamesEnabled(state) { return petnamesEnabled; } +export function getIsTokenNetworkFilterEqualCurrentNetwork(state) { + const chainId = getCurrentChainId(state); + const { tokenNetworkFilter: tokenNetworkFilterValue } = getPreferences(state); + const tokenNetworkFilter = tokenNetworkFilterValue || {}; + if ( + Object.keys(tokenNetworkFilter).length === 1 && + Object.keys(tokenNetworkFilter)[0] === chainId + ) { + return true; + } + return false; +} + export function getUseTransactionSimulations(state) { return Boolean(state.metamask.useTransactionSimulations); } From 220e932002ebcf94b7e366c03ae1eb078ec8e7aa Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 Nov 2024 17:13:12 +0100 Subject: [PATCH 014/148] chore: Bump Snaps packages (#28557) ## **Description** Bump Snaps packages and handle any required changes. Summary of Snaps changes: - Add support for `Address` in `Card` title - Make `fetch` responses an instance of `Response` - Add `isSecureContext` global - Use `arrayBuffer` for fetching local Snaps - Add interface persistence (unused for now) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28557?quickstart=1) --------- Co-authored-by: Guillaume Roux --- app/scripts/metamask-controller.js | 3 + builds.yml | 8 +-- package.json | 10 ++-- .../app/snaps/snap-ui-card/snap-ui-card.tsx | 4 +- .../snaps/snap-ui-renderer/components/card.ts | 41 +++++++++---- yarn.lock | 58 +++++++++---------- 6 files changed, 74 insertions(+), 50 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index b59b21ae8111..bfd1d88708d8 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1526,6 +1526,9 @@ export default class MetamaskController extends EventEmitter { `${this.approvalController.name}:acceptRequest`, `${this.snapController.name}:get`, ], + allowedEvents: [ + 'NotificationServicesController:notificationsListUpdated', + ], }); this.snapInterfaceController = new SnapInterfaceController({ diff --git a/builds.yml b/builds.yml index 316e8f943eb1..fe33507c1e4a 100644 --- a/builds.yml +++ b/builds.yml @@ -27,7 +27,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management # Main build uses the default browser manifest manifestOverrides: false @@ -48,7 +48,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management # Modifies how the version is displayed. # eg. instead of 10.25.0 -> 10.25.0-beta.2 @@ -70,7 +70,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: true - REQUIRE_SNAPS_ALLOWLIST: false - REJECT_INVALID_SNAPS_PLATFORM_VERSION: false - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - SUPPORT_LINK: https://support.metamask.io/ - SUPPORT_REQUEST_LINK: https://support.metamask.io/ - INFURA_ENV_KEY_REF: INFURA_FLASK_PROJECT_ID @@ -94,7 +94,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - MMI_CONFIGURATION_SERVICE_URL: https://configuration.metamask-institutional.io/v2/configuration/default - SUPPORT_LINK: https://support.metamask-institutional.io - SUPPORT_REQUEST_LINK: https://support.metamask-institutional.io diff --git a/package.json b/package.json index 6d97d41c658f..e042fa713ea2 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,7 @@ "semver@7.3.8": "^7.5.4", "@trezor/schema-utils@npm:1.0.2": "patch:@trezor/schema-utils@npm%3A1.0.2#~/.yarn/patches/@trezor-schema-utils-npm-1.0.2-7dd48689b2.patch", "lavamoat-core@npm:^15.1.1": "patch:lavamoat-core@npm%3A15.1.1#~/.yarn/patches/lavamoat-core-npm-15.1.1-51fbe39988.patch", - "@metamask/snaps-sdk": "^6.10.0", + "@metamask/snaps-sdk": "^6.11.0", "@swc/types@0.1.5": "^0.1.6", "@babel/core": "patch:@babel/core@npm%3A7.25.9#~/.yarn/patches/@babel-core-npm-7.25.9-4ae3bff7f3.patch", "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", @@ -348,11 +348,11 @@ "@metamask/selected-network-controller": "^18.0.2", "@metamask/signature-controller": "^21.1.0", "@metamask/smart-transactions-controller": "^13.0.0", - "@metamask/snaps-controllers": "^9.12.0", - "@metamask/snaps-execution-environments": "^6.9.2", + "@metamask/snaps-controllers": "^9.13.0", + "@metamask/snaps-execution-environments": "^6.10.0", "@metamask/snaps-rpc-methods": "^11.5.1", - "@metamask/snaps-sdk": "^6.10.0", - "@metamask/snaps-utils": "^8.5.2", + "@metamask/snaps-sdk": "^6.11.0", + "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", "@metamask/transaction-controller": "^38.3.0", "@metamask/user-operation-controller": "^13.0.0", diff --git a/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx b/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx index b7a0468b5315..15e33ee201c8 100644 --- a/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx +++ b/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { Display, FlexDirection, @@ -13,7 +13,7 @@ import { SnapUIImage } from '../snap-ui-image'; export type SnapUICardProps = { image?: string | undefined; - title: string; + title: string | ReactNode; description?: string | undefined; value: string; extra?: string | undefined; diff --git a/ui/components/app/snaps/snap-ui-renderer/components/card.ts b/ui/components/app/snaps/snap-ui-renderer/components/card.ts index 64c2b1c12a57..932d1e7a532e 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/card.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/card.ts @@ -1,13 +1,34 @@ import { CardElement } from '@metamask/snaps-sdk/jsx'; +import { mapToTemplate } from '../utils'; import { UIComponentFactory } from './types'; -export const card: UIComponentFactory = ({ element }) => ({ - element: 'SnapUICard', - props: { - image: element.props.image, - title: element.props.title, - description: element.props.description, - value: element.props.value, - extra: element.props.extra, - }, -}); +export const card: UIComponentFactory = ({ + element, + ...params +}) => { + if (typeof element.props.title !== 'string') { + return { + element: 'SnapUICard', + props: { + image: element.props.image, + description: element.props.description, + value: element.props.value, + extra: element.props.extra, + }, + propComponents: { + title: mapToTemplate({ element: element.props.title, ...params }), + }, + }; + } + + return { + element: 'SnapUICard', + props: { + image: element.props.image, + title: element.props.title, + description: element.props.description, + value: element.props.value, + extra: element.props.extra, + }, + }; +}; diff --git a/yarn.lock b/yarn.lock index ea2e201ab268..d14fee7c6d97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6381,9 +6381,9 @@ __metadata: linkType: hard "@metamask/slip44@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/slip44@npm:4.0.0" - checksum: 10/3e47e8834b0fbdabe1f126fd78665767847ddc1f9ccc8defb23007dd71fcd2e4899c8ca04857491be3630668a3765bad1e40fdfca9a61ef33236d8d08e51535e + version: 4.1.0 + resolution: "@metamask/slip44@npm:4.1.0" + checksum: 10/4265254a1800a24915bd1de15f86f196737132f9af2a084c2efc885decfc5dd87ad8f0687269d90b35e2ec64d3ea4fbff0caa793bcea6e585b1f3a290952b750 languageName: node linkType: hard @@ -6410,9 +6410,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.12.0": - version: 9.12.0 - resolution: "@metamask/snaps-controllers@npm:9.12.0" +"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.13.0": + version: 9.13.0 + resolution: "@metamask/snaps-controllers@npm:9.13.0" dependencies: "@metamask/approval-controller": "npm:^7.1.1" "@metamask/base-controller": "npm:^7.0.2" @@ -6425,8 +6425,8 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/snaps-registry": "npm:^3.2.2" "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/utils": "npm:^10.0.0" "@xstate/fsm": "npm:^2.0.0" browserify-zlib: "npm:^0.2.0" @@ -6440,30 +6440,30 @@ __metadata: semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^6.9.2 + "@metamask/snaps-execution-environments": ^6.10.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/8d411ff2cfd43e62fe780092e935a1d977379488407b56cca1390edfa9408871cbaf3599f6e6ee999340d46fd3650f225a3270ceec9492c6f2dc4d93538c25ae + checksum: 10/bcf60b61de067f89439cb15acbdf6f808b4bcda8e1cbc9debd693ca2c545c9d38c4e6f380191c4703bd9d28d7dd41e4ce5111664d7b474d5e86e460bcefc3637 languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^6.9.2": - version: 6.9.2 - resolution: "@metamask/snaps-execution-environments@npm:6.9.2" +"@metamask/snaps-execution-environments@npm:^6.10.0": + version: 6.10.0 + resolution: "@metamask/snaps-execution-environments@npm:6.10.0" dependencies: "@metamask/json-rpc-engine": "npm:^10.0.1" "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/post-message-stream": "npm:^8.1.1" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" nanoid: "npm:^3.1.31" readable-stream: "npm:^3.6.2" - checksum: 10/f81dd3728417dc63ed16b102504cdf6c815bffef7b1dad9e7b0e064618b008e1f0fe6d05c225bcafeee09fb4bc473599ee710e1a26a6f3604e965f656fce8e36 + checksum: 10/a881696ec942f268d7485869fcb8c6bc0c278319bbfaf7e5c6099e86278c7f59049595f00ecfc27511d0106b5ad2f7621f734c7b17f088b835e38e638d80db01 languageName: node linkType: hard @@ -6495,16 +6495,16 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.10.0": - version: 6.10.0 - resolution: "@metamask/snaps-sdk@npm:6.10.0" +"@metamask/snaps-sdk@npm:^6.11.0": + version: 6.11.0 + resolution: "@metamask/snaps-sdk@npm:6.11.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" - checksum: 10/02f04536328a64ff1e9e48fb6b109698d6d83f42af5666a9758ccb1e7a1e67c0c2e296ef2fef419dd3d1c8f26bbf30b9f31911a1baa66f044f21cd0ecb7a11a7 + checksum: 10/0f9b507139d1544b1b3d85ff8de81b800d543012d3ee9414c607c23abe9562e0dca48de089ed94be69f5ad981730a0f443371edfe6bc2d5ffb140b28e437bfd2 languageName: node linkType: hard @@ -6539,9 +6539,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.1.1, @metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.5.2": - version: 8.5.2 - resolution: "@metamask/snaps-utils@npm:8.5.2" +"@metamask/snaps-utils@npm:^8.1.1, @metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": + version: 8.6.0 + resolution: "@metamask/snaps-utils@npm:8.6.0" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" @@ -6551,7 +6551,7 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/slip44": "npm:^4.0.0" "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-sdk": "npm:^6.10.0" + "@metamask/snaps-sdk": "npm:^6.11.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" "@noble/hashes": "npm:^1.3.1" @@ -6566,7 +6566,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.1.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/e5d1344f948473e82d71007d2570272073cf070f40aa7746692a6d5e6f02cfce66a747cf50f439d32b29a3f6588486182453b26973f0d0c1d9f47914591d5790 + checksum: 10/c0f538f3f95e1875f6557b6ecc32f981bc4688d581af8cdc62c6c3ab8951c138286cd0b2d1cd82f769df24fcec10f71dcda67ae9a47edcff9ff73d52672df191 languageName: node linkType: hard @@ -26806,11 +26806,11 @@ __metadata: "@metamask/selected-network-controller": "npm:^18.0.2" "@metamask/signature-controller": "npm:^21.1.0" "@metamask/smart-transactions-controller": "npm:^13.0.0" - "@metamask/snaps-controllers": "npm:^9.12.0" - "@metamask/snaps-execution-environments": "npm:^6.9.2" + "@metamask/snaps-controllers": "npm:^9.13.0" + "@metamask/snaps-execution-environments": "npm:^6.10.0" "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.2" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/solana-wallet-snap": "npm:^0.1.9" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:8.13.0" From fac35b88a07e73fe9f3230b23534b0ed2408ba36 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 20 Nov 2024 17:17:47 +0100 Subject: [PATCH 015/148] feat: account syncing various updates (#28541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds various account syncing house keeping improvements in order to be ready for re-enablement in a subsequent PR. The number of files changed by this PR is large, but none affects a user facing feature, since account syncing is disabled in production. - Bump `@metamask/profile-sync-controller` to version `1.0.2` - Add two new state keys linked to `UserStorageController`, `isAccountSyncingReadyToBeDispatched` and `hasAccountSyncingSyncedAtLeastOnce` - Wait for `_addAccountsWithBalance` to finish adding accounts after onboarding, then set `isAccountSyncingReadyToBeDispatched` to `true` - Use `USER_STORAGE_FEATURE_NAMES` exported constant from `@metamask/profile-sync-controller` to define user storage paths everywhere (no more magic strings) - Add batch delete delete support for E2E util `UserStorageMockttpController` - Update all account sync E2E tests in order to wait for account sync to have successfully been completed once before going on with the rest of the instructions. ⚠️ Please note that this PR does **NOT** re-enable account syncing in production. This will be done in a subsequent PR. ## **Related issues** ## **Manual testing steps** 1. Import your SRP 2. Add accounts, rename accounts 3. Uninstall & reinstall 4. Import the same SRP 5. Verify that previous updates made in step 2 are there ## **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: MetaMask Bot --- app/scripts/constants/sentry-state.ts | 2 + app/scripts/metamask-controller.js | 87 ++++--- lavamoat/browserify/beta/policy.json | 32 +-- lavamoat/browserify/flask/policy.json | 32 +-- lavamoat/browserify/main/policy.json | 32 +-- lavamoat/browserify/mmi/policy.json | 32 +-- package.json | 2 +- shared/constants/metametrics.ts | 1 + .../userStorageMockttpController.test.ts | 221 ++++++++++++------ .../userStorageMockttpController.ts | 120 ++++++---- test/e2e/page-objects/pages/homepage.ts | 12 + ...rs-after-init-opt-in-background-state.json | 4 +- .../errors-after-init-opt-in-ui-state.json | 2 + .../importing-private-key-account.spec.ts | 18 +- .../account-syncing/new-user-sync.spec.ts | 13 +- .../onboarding-with-opt-out.spec.ts | 21 +- .../sync-after-adding-account.spec.ts | 44 +++- .../sync-after-modifying-account-name.spec.ts | 18 +- .../sync-after-onboarding.spec.ts | 12 +- test/e2e/tests/notifications/mocks.ts | 34 ++- .../data/notification-state.ts | 2 + .../useProfileSyncing/profileSyncing.test.tsx | 4 + .../useProfileSyncing/profileSyncing.ts | 11 +- .../profile-syncing.test.ts | 2 + .../metamask-notifications/profile-syncing.ts | 21 +- ui/store/actions.test.js | 5 +- ui/store/actions.ts | 3 +- yarn.lock | 121 ++++++++-- 28 files changed, 582 insertions(+), 326 deletions(-) diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 5146e38e8a41..bd953e72d49c 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -389,6 +389,8 @@ export const SENTRY_BACKGROUND_STATE = { UserStorageController: { isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, }, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) ...MMI_SENTRY_BACKGROUND_STATE, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index bfd1d88708d8..3b75349091ad 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1598,6 +1598,16 @@ export default class MetamaskController extends EventEmitter { }, }); }, + onAccountSyncErroneousSituation: (profileId, situationMessage) => { + this.metaMetricsController.trackEvent({ + category: MetaMetricsEventCategory.ProfileSyncing, + event: MetaMetricsEventName.AccountsSyncErroneousSituation, + properties: { + profile_id: profileId, + situation_message: situationMessage, + }, + }); + }, }, }, env: { @@ -1768,10 +1778,11 @@ export default class MetamaskController extends EventEmitter { if (!prevCompletedOnboarding && currCompletedOnboarding) { const { address } = this.accountsController.getSelectedAccount(); - this._addAccountsWithBalance(); + await this._addAccountsWithBalance(); this.postOnboardingInitialization(); this.triggerNetworkrequests(); + // execute once the token detection on the post-onboarding await this.tokenDetectionController.detectTokens({ selectedAddress: address, @@ -4441,43 +4452,51 @@ export default class MetamaskController extends EventEmitter { } async _addAccountsWithBalance() { - // Scan accounts until we find an empty one - const chainId = getCurrentChainId({ - metamask: this.networkController.state, - }); - const ethQuery = new EthQuery(this.provider); - const accounts = await this.keyringController.getAccounts(); - let address = accounts[accounts.length - 1]; - - for (let count = accounts.length; ; count++) { - const balance = await this.getBalance(address, ethQuery); - - if (balance === '0x0') { - // This account has no balance, so check for tokens - await this.tokenDetectionController.detectTokens({ - chainIds: [chainId], - selectedAddress: address, - }); + try { + // Scan accounts until we find an empty one + const chainId = getCurrentChainId({ + metamask: this.networkController.state, + }); + const ethQuery = new EthQuery(this.provider); + const accounts = await this.keyringController.getAccounts(); + let address = accounts[accounts.length - 1]; + + for (let count = accounts.length; ; count++) { + const balance = await this.getBalance(address, ethQuery); - const tokens = - this.tokensController.state.allTokens?.[chainId]?.[address]; - const detectedTokens = - this.tokensController.state.allDetectedTokens?.[chainId]?.[address]; - - if ( - (tokens?.length ?? 0) === 0 && - (detectedTokens?.length ?? 0) === 0 - ) { - // This account has no balance or tokens - if (count !== 1) { - await this.removeAccount(address); + if (balance === '0x0') { + // This account has no balance, so check for tokens + await this.tokenDetectionController.detectTokens({ + chainIds: [chainId], + selectedAddress: address, + }); + + const tokens = + this.tokensController.state.allTokens?.[chainId]?.[address]; + const detectedTokens = + this.tokensController.state.allDetectedTokens?.[chainId]?.[address]; + + if ( + (tokens?.length ?? 0) === 0 && + (detectedTokens?.length ?? 0) === 0 + ) { + // This account has no balance or tokens + if (count !== 1) { + await this.removeAccount(address); + } + break; } - break; } - } - // This account has assets, so check the next one - address = await this.keyringController.addNewAccount(count); + // This account has assets, so check the next one + address = await this.keyringController.addNewAccount(count); + } + } catch (e) { + log.warn(`Failed to add accounts with balance. Error: ${e}`); + } finally { + await this.userStorageController.setIsAccountSyncingReadyToBeDispatched( + true, + ); } } diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 54659cd66695..f87946a09c0f 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1919,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@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/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 54659cd66695..f87946a09c0f 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1919,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@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/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 54659cd66695..f87946a09c0f 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1919,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@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/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 38442fe5eb85..1bad9f3288a2 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2011,40 +2011,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@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/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/package.json b/package.json index e042fa713ea2..929cc375f683 100644 --- a/package.json +++ b/package.json @@ -338,7 +338,7 @@ "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.35.1", "@metamask/preinstalled-example-snap": "^0.2.0", - "@metamask/profile-sync-controller": "^0.9.7", + "@metamask/profile-sync-controller": "^1.0.2", "@metamask/providers": "^14.0.2", "@metamask/queued-request-controller": "^7.0.1", "@metamask/rate-limit-controller": "^6.0.0", diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 3f1941a5c00e..8688b8cfa8ae 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -631,6 +631,7 @@ export enum MetaMetricsEventName { AccountRenamed = 'Account Renamed', AccountsSyncAdded = 'Accounts Sync Added', AccountsSyncNameUpdated = 'Accounts Sync Name Updated', + AccountsSyncErroneousSituation = 'Accounts Sync Erroneous Situation', ActivityDetailsOpened = 'Activity Details Opened', ActivityDetailsClosed = 'Activity Details Closed', AnalyticsPreferenceSelected = 'Analytics Preference Selected', diff --git a/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts b/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts index 1b6591899c0e..f4d8fdeb4e3d 100644 --- a/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts +++ b/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts @@ -1,4 +1,5 @@ import * as mockttp from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { UserStorageMockttpController } from './userStorageMockttpController'; describe('UserStorageMockttpController', () => { @@ -12,11 +13,14 @@ describe('UserStorageMockttpController', () => { it('handles GET requests that have empty response', async () => { const controller = new UserStorageMockttpController(); - controller.setupPath('accounts', mockServer); + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(null); }); @@ -36,13 +40,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -62,13 +69,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -88,13 +98,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, + }, + ); expect(request.json).toEqual(mockedData[0]); }); @@ -119,24 +132,30 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, - body: { - getJson: async () => ({ - data: mockedAddedData.Data, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, + body: { + getJson: async () => ({ + data: mockedAddedData.Data, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([...mockedData, mockedAddedData]); }); @@ -161,24 +180,30 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - body: { - getJson: async () => ({ - data: mockedUpdatedData.Data, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + body: { + getJson: async () => ({ + data: mockedUpdatedData.Data, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0], mockedUpdatedData]); }); @@ -210,7 +235,7 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); @@ -219,20 +244,26 @@ describe('UserStorageMockttpController', () => { putData[entry.HashedKey] = entry.Data; }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts`, - body: { - getJson: async () => ({ - data: putData, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + data: putData, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual(mockedUpdatedData); }); @@ -252,19 +283,25 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - path: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0]]); }); @@ -282,22 +319,76 @@ describe('UserStorageMockttpController', () => { 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', Data: 'data2', }, + { + HashedKey: + 'x236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data3', + }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - path: `${baseUrl}/accounts`, - }); + const deleteRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + batch_delete: [mockedData[1].HashedKey, mockedData[2].HashedKey], + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(getRequest.json).toEqual([mockedData[0]]); + }); + + it('handles entire feature DELETE requests', async () => { + const controller = new UserStorageMockttpController(); + const mockedData = [ + { + HashedKey: + '7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b', + Data: 'data1', + }, + { + HashedKey: + 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data2', + }, + ]; + + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { + getResponse: mockedData, }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(deleteRequest.statusCode).toEqual(204); + + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + expect(getRequest.json).toEqual(null); }); }); diff --git a/test/e2e/helpers/user-storage/userStorageMockttpController.ts b/test/e2e/helpers/user-storage/userStorageMockttpController.ts index 970a10d11120..ce8583b9adcd 100644 --- a/test/e2e/helpers/user-storage/userStorageMockttpController.ts +++ b/test/e2e/helpers/user-storage/userStorageMockttpController.ts @@ -1,13 +1,22 @@ import { CompletedRequest, Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; + +const baseUrl = + 'https://user-storage\\.api\\.cx\\.metamask\\.io\\/api\\/v1\\/userstorage'; -// TODO: Export user storage schema from @metamask/profile-sync-controller export const pathRegexps = { - accounts: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/accounts/u, - networks: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/networks/u, - notifications: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/notifications/u, + [USER_STORAGE_FEATURE_NAMES.accounts]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.networks]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.networks}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.notifications]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.notifications}`, + 'u', + ), }; type UserStorageResponseData = { HashedKey: string; Data: string }; @@ -70,50 +79,75 @@ export class UserStorageMockttpController { const isFeatureEntry = determineIfFeatureEntryFromURL(request.path); const data = (await request.body.getJson()) as { - data: string | { [key: string]: string }; + data?: string | Record; + batch_delete?: string[]; }; - const newOrUpdatedSingleOrBatchEntries = - isFeatureEntry && typeof data?.data === 'string' - ? [ - { - HashedKey: request.path.split('/').pop() as string, - Data: data?.data, - }, - ] - : Object.entries(data?.data).map(([key, value]) => ({ - HashedKey: key, - Data: value, - })); - - newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + // We're handling batch delete inside the PUT method due to API limitations + if (data?.batch_delete) { + const keysToDelete = data.batch_delete; + const internalPathData = this.paths.get(path); if (!internalPathData) { - return; + return { + statusCode, + }; } - const doesThisEntryExist = internalPathData.response?.find( - (existingEntry) => existingEntry.HashedKey === entry.HashedKey, - ); + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.filter( + (entry) => !keysToDelete.includes(entry.HashedKey), + ), + }); + } - if (doesThisEntryExist) { - this.paths.set(path, { - ...internalPathData, - response: internalPathData.response.map((existingEntry) => - existingEntry.HashedKey === entry.HashedKey ? entry : existingEntry, - ), - }); - } else { - this.paths.set(path, { - ...internalPathData, - response: [ - ...(internalPathData?.response || []), - entry as { HashedKey: string; Data: string }, - ], - }); - } - }); + if (data?.data) { + const newOrUpdatedSingleOrBatchEntries = + isFeatureEntry && typeof data?.data === 'string' + ? [ + { + HashedKey: request.path.split('/').pop() as string, + Data: data?.data, + }, + ] + : Object.entries(data?.data).map(([key, value]) => ({ + HashedKey: key, + Data: value, + })); + + newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + const internalPathData = this.paths.get(path); + + if (!internalPathData) { + return; + } + + const doesThisEntryExist = internalPathData.response?.find( + (existingEntry) => existingEntry.HashedKey === entry.HashedKey, + ); + + if (doesThisEntryExist) { + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.map((existingEntry) => + existingEntry.HashedKey === entry.HashedKey + ? entry + : existingEntry, + ), + }); + } else { + this.paths.set(path, { + ...internalPathData, + response: [ + ...(internalPathData?.response || []), + entry as { HashedKey: string; Data: string }, + ], + }); + } + }); + } return { statusCode, diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index 89a32a550cc9..6a8a916d4349 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -1,6 +1,7 @@ import { strict as assert } from 'assert'; import { Driver } from '../../webdriver/driver'; import { Ganache } from '../../seeder/ganache'; +import { getCleanAppState } from '../../helpers'; import HeaderNavbar from './header-navbar'; class HomePage { @@ -348,6 +349,17 @@ class HomePage { `Amount for transaction ${expectedNumber} is displayed as ${expectedAmount}`, ); } + + /** + * This function checks if account syncing has been successfully completed at least once. + */ + async check_hasAccountSyncingSyncedAtLeastOnce(): Promise { + console.log('Check if account syncing has synced at least once'); + await this.driver.wait(async () => { + const uiState = await getCleanAppState(this.driver); + return uiState.metamask.hasAccountSyncingSyncedAtLeastOnce === true; + }, 10000); + } } export default HomePage; 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 9df8707d1f17..39ec3fb2530b 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 @@ -348,6 +348,8 @@ "UserOperationController": { "userOperations": "object" }, "UserStorageController": { "isProfileSyncingEnabled": null, - "isProfileSyncingUpdateLoading": "boolean" + "isProfileSyncingUpdateLoading": "boolean", + "hasAccountSyncingSyncedAtLeastOnce": "boolean", + "isAccountSyncingReadyToBeDispatched": "boolean" } } 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 006277f89160..615689cb6d19 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 @@ -224,6 +224,8 @@ "isSignedIn": "boolean", "isProfileSyncingEnabled": null, "isProfileSyncingUpdateLoading": "boolean", + "hasAccountSyncingSyncedAtLeastOnce": "boolean", + "isAccountSyncingReadyToBeDispatched": "boolean", "subscriptionAccountsSeen": "object", "isMetamaskNotificationsFeatureSeen": "boolean", "isNotificationServicesEnabled": "boolean", diff --git a/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts b/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts index 7b9e2378b058..1940d4bf3fd6 100644 --- a/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -28,9 +29,13 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -47,6 +52,7 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -75,7 +81,10 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -91,6 +100,7 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts index 0d7010f25f21..8e2908682542 100644 --- a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -28,7 +29,10 @@ describe('Account syncing - New User @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, @@ -45,6 +49,7 @@ describe('Account syncing - New User @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); // Open account menu and validate 1 account is shown const header = new HeaderNavbar(driver); @@ -77,7 +82,10 @@ describe('Account syncing - New User @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -94,6 +102,7 @@ describe('Account syncing - New User @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); // Open account menu and validate the 2 accounts have been retrieved const header = new HeaderNavbar(driver); diff --git a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts index 36776b367730..209d3a51fdaf 100644 --- a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -35,9 +36,13 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -94,7 +99,10 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -146,7 +154,10 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts index 31f92520f13e..23a5d1eaf47b 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -46,6 +51,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -73,7 +79,10 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -89,6 +98,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -97,8 +107,9 @@ describe('Account syncing - Add Account @no-mmi', function () { const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - const accountSyncResponse = - userStorageMockttpController.paths.get('accounts')?.response; + const accountSyncResponse = userStorageMockttpController.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + )?.response; await accountListPage.check_numberOfAvailableAccounts( accountSyncResponse?.length as number, @@ -124,9 +135,13 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -143,6 +158,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -168,7 +184,10 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -192,8 +211,9 @@ describe('Account syncing - Add Account @no-mmi', function () { const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - const accountSyncResponse = - userStorageMockttpController.paths.get('accounts')?.response; + const accountSyncResponse = userStorageMockttpController.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + )?.response; await accountListPage.check_numberOfAvailableAccounts( accountSyncResponse?.length as number, diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts index 45ee3ab23a85..22618d70f3c5 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -46,6 +51,7 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -72,7 +78,10 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -88,6 +97,7 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts index 5bebe7220e49..be2b2604633c 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Onboarding @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -45,6 +50,7 @@ describe('Account syncing - Onboarding @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/mocks.ts b/test/e2e/tests/notifications/mocks.ts index ce2ced3df210..748084918272 100644 --- a/test/e2e/tests/notifications/mocks.ts +++ b/test/e2e/tests/notifications/mocks.ts @@ -4,6 +4,7 @@ import { NotificationServicesController, NotificationServicesPushController, } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { UserStorageMockttpController } from '../../helpers/user-storage/userStorageMockttpController'; const AuthMocks = AuthenticationController.Mocks; @@ -32,14 +33,35 @@ export async function mockNotificationServices( mockAPICall(server, AuthMocks.getMockAuthAccessTokenResponse()); // Storage - if (!userStorageMockttpControllerInstance?.paths.get('accounts')) { - userStorageMockttpControllerInstance.setupPath('accounts', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); } - if (!userStorageMockttpControllerInstance?.paths.get('networks')) { - userStorageMockttpControllerInstance.setupPath('networks', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.networks, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.networks, + server, + ); } - if (!userStorageMockttpControllerInstance?.paths.get('notifications')) { - userStorageMockttpControllerInstance.setupPath('notifications', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.notifications, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.notifications, + server, + ); } // Notifications diff --git a/test/integration/notifications&auth/data/notification-state.ts b/test/integration/notifications&auth/data/notification-state.ts index c58bf707f521..61d74d161671 100644 --- a/test/integration/notifications&auth/data/notification-state.ts +++ b/test/integration/notifications&auth/data/notification-state.ts @@ -38,6 +38,8 @@ export const getMockedNotificationsState = () => { ...mockMetaMaskState, isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, isMetamaskNotificationsFeatureSeen: true, isNotificationServicesEnabled: true, isFeatureAnnouncementsEnabled: true, diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx index 99d3064085ea..42e902e29401 100644 --- a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx +++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx @@ -14,6 +14,7 @@ type ArrangeMocksMetamaskStateOverrides = { isUnlocked?: boolean; useExternalServices?: boolean; completedOnboarding?: boolean; + isAccountSyncingReadyToBeDispatched?: boolean; }; const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = { @@ -22,6 +23,7 @@ const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = { isUnlocked: true, useExternalServices: true, completedOnboarding: true, + isAccountSyncingReadyToBeDispatched: true, }; const arrangeMockState = ( @@ -89,6 +91,7 @@ describe('useShouldDispatchProfileSyncing()', () => { 'isUnlocked', 'useExternalServices', 'completedOnboarding', + 'isAccountSyncingReadyToBeDispatched', ] as const; const baseState = { isSignedIn: true, @@ -96,6 +99,7 @@ describe('useShouldDispatchProfileSyncing()', () => { isUnlocked: true, useExternalServices: true, completedOnboarding: true, + isAccountSyncingReadyToBeDispatched: true, }; const failureStateCases: { diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts index 5c073fdf6d94..57820d2e633b 100644 --- a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts +++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts @@ -10,7 +10,10 @@ import { } from '../../../store/actions'; import { selectIsSignedIn } from '../../../selectors/metamask-notifications/authentication'; -import { selectIsProfileSyncingEnabled } from '../../../selectors/metamask-notifications/profile-syncing'; +import { + selectIsAccountSyncingReadyToBeDispatched, + selectIsProfileSyncingEnabled, +} from '../../../selectors/metamask-notifications/profile-syncing'; import { getUseExternalServices } from '../../../selectors'; import { getIsUnlocked, @@ -120,6 +123,9 @@ export function useSetIsProfileSyncingEnabled(): { * @returns a boolean if internally we can perform syncing features or not. */ export const useShouldDispatchProfileSyncing = () => { + const isAccountSyncingReadyToBeDispatched = useSelector( + selectIsAccountSyncingReadyToBeDispatched, + ); const isProfileSyncingEnabled = useSelector(selectIsProfileSyncingEnabled); const basicFunctionality: boolean | undefined = useSelector( getUseExternalServices, @@ -135,7 +141,8 @@ export const useShouldDispatchProfileSyncing = () => { isProfileSyncingEnabled && isUnlocked && isSignedIn && - completedOnboarding, + completedOnboarding && + isAccountSyncingReadyToBeDispatched, ); return shouldDispatchProfileSyncing; diff --git a/ui/selectors/metamask-notifications/profile-syncing.test.ts b/ui/selectors/metamask-notifications/profile-syncing.test.ts index 946ffd3f0b5f..d05512e59523 100644 --- a/ui/selectors/metamask-notifications/profile-syncing.test.ts +++ b/ui/selectors/metamask-notifications/profile-syncing.test.ts @@ -5,6 +5,8 @@ describe('Profile Syncing Selectors', () => { metamask: { isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + isAccountSyncingReadyToBeDispatched: false, + hasAccountSyncingSyncedAtLeastOnce: false, }, }; diff --git a/ui/selectors/metamask-notifications/profile-syncing.ts b/ui/selectors/metamask-notifications/profile-syncing.ts index 8b9b8f4997d6..ae219f47be68 100644 --- a/ui/selectors/metamask-notifications/profile-syncing.ts +++ b/ui/selectors/metamask-notifications/profile-syncing.ts @@ -2,7 +2,9 @@ import { createSelector } from 'reselect'; import type { UserStorageController } from '@metamask/profile-sync-controller'; type AppState = { - metamask: UserStorageController.UserStorageControllerState; + metamask: UserStorageController.UserStorageControllerState & { + hasFinishedAddingAccountsWithBalance?: boolean; + }; }; const getMetamask = (state: AppState) => state.metamask; @@ -36,3 +38,20 @@ export const selectIsProfileSyncingUpdateLoading = createSelector( return metamask.isProfileSyncingUpdateLoading; }, ); + +/** + * Selector to determine if account syncing is ready to be dispatched. This is set to true after all operations adding accounts are completed. + * This is needed for account syncing in order to prevent conflicts with accounts that are being added by the above method during onboarding. + * + * This selector uses the `createSelector` function from 'reselect' to compute whether the update process for profile syncing is currently in a loading state, + * based on the `hasFinishedAddingAccountsWithBalance` property of the `metamask` object in the Redux store. + * + * @param {AppState} state - The current state of the Redux store. + * @returns {boolean} Returns true if the profile syncing update is loading, false otherwise. + */ +export const selectIsAccountSyncingReadyToBeDispatched = createSelector( + [getMetamask], + (metamask) => { + return metamask.isAccountSyncingReadyToBeDispatched; + }, +); diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 22e8db2fa281..10391adad3df 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -4,6 +4,7 @@ import thunk from 'redux-thunk'; import { EthAccountType } from '@metamask/keyring-api'; import { TransactionStatus } from '@metamask/transaction-controller'; import { NotificationServicesController } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import enLocale from '../../app/_locales/en/messages.json'; @@ -2605,7 +2606,9 @@ describe('Actions', () => { await store.dispatch(actions.deleteAccountSyncingDataFromUserStorage()); expect( - deleteAccountSyncingDataFromUserStorageStub.calledOnceWith('accounts'), + deleteAccountSyncingDataFromUserStorageStub.calledOnceWith( + USER_STORAGE_FEATURE_NAMES.accounts, + ), ).toBe(true); }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 64ba84ddaf57..6344f823aa02 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -42,6 +42,7 @@ import { import { InterfaceState } from '@metamask/snaps-sdk'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { NotificationServicesController } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { Patch } from 'immer'; import { HandlerType } from '@metamask/snaps-utils'; import switchDirection from '../../shared/lib/switch-direction'; @@ -5661,7 +5662,7 @@ export function deleteAccountSyncingDataFromUserStorage(): ThunkAction< try { const response = await submitRequestToBackground( 'deleteAccountSyncingDataFromUserStorage', - ['accounts'], + [USER_STORAGE_FEATURE_NAMES.accounts], ); return response; } catch (error) { diff --git a/yarn.lock b/yarn.lock index d14fee7c6d97..0147c3106fa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5258,16 +5258,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^11.0.1": - version: 11.0.1 - resolution: "@metamask/eth-block-tracker@npm:11.0.1" +"@metamask/eth-block-tracker@npm:^11.0.1, @metamask/eth-block-tracker@npm:^11.0.2": + version: 11.0.2 + resolution: "@metamask/eth-block-tracker@npm:11.0.2" dependencies: - "@metamask/eth-json-rpc-provider": "npm:^4.1.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" "@metamask/safe-event-emitter": "npm:^3.1.1" "@metamask/utils": "npm:^9.1.0" json-rpc-random-id: "npm:^1.0.1" pify: "npm:^5.0.0" - checksum: 10/6a5143dcd20ea87cd674efb25870275d97d4ffe921e843391a5b85876ebe074e5a587a128c268d27520904c74c9feecf91218ea086bd65cc6096f8501bdf8f32 + checksum: 10/11d22bd86056401aa41eff5a32e862f3644eaf03040d8aa54a95cb0c1dfd3e3ce7e650c25efabbe0954cc6ba5f92172c338b518df84f73c4601c4bbc960b588a languageName: node linkType: hard @@ -5310,6 +5310,18 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-json-rpc-infura@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/eth-json-rpc-infura@npm:10.0.0" + dependencies: + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" + "@metamask/json-rpc-engine": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/utils": "npm:^9.1.0" + checksum: 10/17e0147ff86c48107983035e9bda4d16fba321ee0e29733347e9338a4c795c506a2ffd643c44c9d5334886696412cf288f852d06311fed0d76edc8847ee6b8de + languageName: node + linkType: hard + "@metamask/eth-json-rpc-infura@npm:^9.1.0": version: 9.1.0 resolution: "@metamask/eth-json-rpc-infura@npm:9.1.0" @@ -5361,6 +5373,25 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-json-rpc-middleware@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:15.0.0" + dependencies: + "@metamask/eth-block-tracker": "npm:^11.0.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" + "@metamask/eth-sig-util": "npm:^7.0.3" + "@metamask/json-rpc-engine": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/utils": "npm:^9.1.0" + "@types/bn.js": "npm:^5.1.5" + bn.js: "npm:^5.2.1" + klona: "npm:^2.0.6" + pify: "npm:^5.0.0" + safe-stable-stringify: "npm:^2.4.3" + checksum: 10/3c48d34264c695535f2b4e819fb602d835b6ed37309116a06d04d1b706a7335e0205cd4ccdbf1d3e9dc15ebf40d88954a9a2dc18a91f223dcd6d6392e026a5e9 + languageName: node + linkType: hard + "@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch": version: 14.0.1 resolution: "@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch::version=14.0.1&hash=96e7e0" @@ -5402,16 +5433,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^4.0.0, @metamask/eth-json-rpc-provider@npm:^4.1.0, @metamask/eth-json-rpc-provider@npm:^4.1.1, @metamask/eth-json-rpc-provider@npm:^4.1.3": - version: 4.1.3 - resolution: "@metamask/eth-json-rpc-provider@npm:4.1.3" +"@metamask/eth-json-rpc-provider@npm:^4.0.0, @metamask/eth-json-rpc-provider@npm:^4.1.0, @metamask/eth-json-rpc-provider@npm:^4.1.1, @metamask/eth-json-rpc-provider@npm:^4.1.3, @metamask/eth-json-rpc-provider@npm:^4.1.5, @metamask/eth-json-rpc-provider@npm:^4.1.6": + version: 4.1.6 + resolution: "@metamask/eth-json-rpc-provider@npm:4.1.6" dependencies: - "@metamask/json-rpc-engine": "npm:^9.0.2" - "@metamask/rpc-errors": "npm:^6.3.1" + "@metamask/json-rpc-engine": "npm:^10.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" uuid: "npm:^8.3.2" - checksum: 10/d581cc0f6485783ed59ac9517aa7f0eb37ee6a0674409eeaba1bbda4b54fcc5f633cc8ace66207871e2c2fac33195982969f4e61c18b04faf4656cccf79d8d3d + checksum: 10/aeec2c362a5386357e9f8c707da9baa4326e83889633723656b6801b6461ea8ab8f020b0d9ed0bbc2d8fd6add4af4c99cc9c9a1cbedca267a033a9f19da41200 languageName: node linkType: hard @@ -5817,6 +5848,27 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-controller@npm:^18.0.0": + version: 18.0.0 + resolution: "@metamask/keyring-controller@npm:18.0.0" + dependencies: + "@ethereumjs/util": "npm:^8.1.0" + "@keystonehq/metamask-airgapped-keyring": "npm:^0.14.1" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/browser-passworder": "npm:^4.3.0" + "@metamask/eth-hd-keyring": "npm:^7.0.4" + "@metamask/eth-sig-util": "npm:^8.0.0" + "@metamask/eth-simple-keyring": "npm:^6.0.5" + "@metamask/keyring-api": "npm:^8.1.3" + "@metamask/message-manager": "npm:^11.0.1" + "@metamask/utils": "npm:^10.0.0" + async-mutex: "npm:^0.5.0" + ethereumjs-wallet: "npm:^1.0.1" + immer: "npm:^9.0.6" + checksum: 10/c301e4e8b9ac9da914bfaa371a43342aa37f5bb8ad107bbbd92f1d21a13c22351619f8bd6176493b808f4194aa9934bce5618ff0aed12325933f4330cdfd308e + languageName: node + linkType: hard + "@metamask/logging-controller@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/logging-controller@npm:6.0.0" @@ -5912,6 +5964,31 @@ __metadata: languageName: node linkType: hard +"@metamask/network-controller@npm:^22.0.2": + version: 22.0.2 + resolution: "@metamask/network-controller@npm:22.0.2" + dependencies: + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/eth-block-tracker": "npm:^11.0.2" + "@metamask/eth-json-rpc-infura": "npm:^10.0.0" + "@metamask/eth-json-rpc-middleware": "npm:^15.0.0" + "@metamask/eth-json-rpc-provider": "npm:^4.1.6" + "@metamask/eth-query": "npm:^4.0.0" + "@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:^10.0.0" + async-mutex: "npm:^0.5.0" + immer: "npm:^9.0.6" + loglevel: "npm:^1.8.1" + reselect: "npm:^5.1.1" + uri-js: "npm:^4.4.1" + uuid: "npm:^8.3.2" + checksum: 10/9da27189a4263ef7fa4596ada2000d7f944bc3f4dea63a77cf6f8b2ea89412d499068cf0542785088d19437263bd0b3b3bb3299533f87439729ccd8ecee2b625 + languageName: node + linkType: hard + "@metamask/network-controller@patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch": version: 21.0.0 resolution: "@metamask/network-controller@patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch::version=21.0.0&hash=1a5039" @@ -6210,13 +6287,14 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^0.9.7": - version: 0.9.7 - resolution: "@metamask/profile-sync-controller@npm:0.9.7" +"@metamask/profile-sync-controller@npm:^1.0.2": + version: 1.0.2 + resolution: "@metamask/profile-sync-controller@npm:1.0.2" dependencies: - "@metamask/base-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^7.0.2" "@metamask/keyring-api": "npm:^8.1.3" - "@metamask/keyring-controller": "npm:^17.2.2" + "@metamask/keyring-controller": "npm:^18.0.0" + "@metamask/network-controller": "npm:^22.0.2" "@metamask/snaps-sdk": "npm:^6.5.0" "@metamask/snaps-utils": "npm:^8.1.1" "@noble/ciphers": "npm:^0.5.2" @@ -6225,10 +6303,11 @@ __metadata: loglevel: "npm:^1.8.1" siwe: "npm:^2.3.2" peerDependencies: - "@metamask/accounts-controller": ^18.1.1 - "@metamask/keyring-controller": ^17.2.0 + "@metamask/accounts-controller": ^19.0.0 + "@metamask/keyring-controller": ^18.0.0 + "@metamask/network-controller": ^22.0.0 "@metamask/snaps-controllers": ^9.7.0 - checksum: 10/e53888533b2aae937bbe4e385dca2617c324b34e3e60af218cd98c26d514fb725f4c67b649f126e055f6a50a554817b229d37488115b98d70e8aee7b3a910bde + checksum: 10/e8ce9cc5749746bea3f6fb9207bbd4e8e3956f92447f3a6b790e3ba7203747e38b9a819f7a4f1896022cf6e1a065e6136a3c82ee83a4ec0ee56b23de27e23f03 languageName: node linkType: hard @@ -26796,7 +26875,7 @@ __metadata: "@metamask/ppom-validator": "npm:0.35.1" "@metamask/preferences-controller": "npm:^13.0.2" "@metamask/preinstalled-example-snap": "npm:^0.2.0" - "@metamask/profile-sync-controller": "npm:^0.9.7" + "@metamask/profile-sync-controller": "npm:^1.0.2" "@metamask/providers": "npm:^14.0.2" "@metamask/queued-request-controller": "npm:^7.0.1" "@metamask/rate-limit-controller": "npm:^6.0.0" From f3cec693921e695bb9f8d8a6e8dacdd8833cdb76 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:30:10 -0500 Subject: [PATCH 016/148] fix: contact names should not allow duplication (#28249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This fix prevents users from having duplicate names in their contact list. A warning banner is now displayed in the contact list when duplicates are found. All duplicate contacts will also have a warning icon next to them to help users better identify duplicates. #### What is considered a duplicate contact? A duplicate is a contact with a different address but same name as another contact or account. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28249?quickstart=1) ## **Related issues** Fixes: - https://github.com/MetaMask/metamask-extension/issues/26621 - https://github.com/MetaMask/metamask-extension/issues/26696 ## **Manual testing steps** #### Adding a duplicate contact 1. Go to Settings > Contacts page 2. Attempt to add a new contact using an existing contact or account name 3. An error message will appear underneath the `username` input and the submit button will be disabled. #### Viewing duplicate contact warning banner 1. In your local environment, go to file `ui/components/app/contact-list/contact-list.component.js` 2. On line `138` (`{hasDuplicateContacts ? this.renderDuplicateContactWarning() : null}` change `hasDuplicateContacts` to `true` to display the banner. ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/a7813050-e906-4d42-ac10-db05401d06d3 ### **After** https://github.com/user-attachments/assets/70e16e99-60ea-4404-9ea7-6f776f15d00e Screenshot 2024-11-15 at 1 59 21 PM Screenshot 2024-11-15 at 1 59 28 PM ## **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). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [ ] 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 | 9 ++ test/data/mock-data.js | 27 ++++ .../contact-list/contact-list.component.js | 61 ++++++++- .../app/contact-list/contact-list.test.js | 46 +++++++ .../recipient-group.component.js | 3 +- ui/components/app/contact-list/utils.ts | 61 +++++++++ .../address-list-item.test.tsx.snap | 102 ++++++++++++++- .../address-list-item.test.tsx | 38 +++++- .../address-list-item/address-list-item.tsx | 17 ++- .../multichain/address-list-item/index.scss | 5 + .../pages/send/components/address-book.tsx | 3 + .../domain-input-resolution-cell.tsx | 1 + ui/pages/confirmations/send/send.scss | 4 + .../add-contact/add-contact.component.js | 42 ++++-- .../add-contact/add-contact.container.js | 3 + .../add-contact/add-contact.test.js | 122 +++++++++++++++++- .../contact-list-tab.component.js | 6 +- .../contact-list-tab.container.js | 3 +- .../contact-list-tab.stories.js | 5 + .../edit-contact/edit-contact.component.js | 44 ++++++- .../edit-contact/edit-contact.container.js | 4 + .../edit-contact/edit-contact.test.js | 65 +++++++++- 22 files changed, 630 insertions(+), 41 deletions(-) create mode 100644 ui/components/app/contact-list/utils.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 56a2ba405aef..a59a48a21afe 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1734,6 +1734,12 @@ "dropped": { "message": "Dropped" }, + "duplicateContactTooltip": { + "message": "This contact name collides with an existing account or contact" + }, + "duplicateContactWarning": { + "message": "You have duplicate contacts" + }, "edit": { "message": "Edit" }, @@ -3028,6 +3034,9 @@ "message": "Address", "description": "Label above address field in name component modal." }, + "nameAlreadyInUse": { + "message": "Name is already in use" + }, "nameInstructionsNew": { "message": "If you know this address, give it a nickname to recognize it in the future.", "description": "Instruction text in name component modal when value is not recognised." diff --git a/test/data/mock-data.js b/test/data/mock-data.js index 4775f1dbb25e..c68d70a1fc59 100644 --- a/test/data/mock-data.js +++ b/test/data/mock-data.js @@ -1049,6 +1049,31 @@ const NETWORKS_2_API_MOCK_RESULT = { }, }; +const MOCK_ADDRESS_BOOK = [ + { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: 'Contact 1', + }, + { + address: '0x43c9159B6251f3E205B9113A023C8256cDD40D91', + chainId: '0x1', + isEns: true, + memo: '', + name: 'example.eth', + }, +]; + +const MOCK_DOMAIN_RESOLUTION = { + addressBookEntryName: 'example.eth', + domainName: 'example.eth', + protocol: 'Ethereum Name Service', + resolvedAddress: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + resolvingSnap: 'Ethereum Name Service resolver', +}; + module.exports = { TOKENS_API_MOCK_RESULT, TOP_ASSETS_API_MOCK_RESULT, @@ -1059,4 +1084,6 @@ module.exports = { SWAP_TEST_ETH_DAI_TRADES_MOCK, SWAP_TEST_ETH_USDC_TRADES_MOCK, NETWORKS_2_API_MOCK_RESULT, + MOCK_ADDRESS_BOOK, + MOCK_DOMAIN_RESOLUTION, }; diff --git a/ui/components/app/contact-list/contact-list.component.js b/ui/components/app/contact-list/contact-list.component.js index 548bbf68a90c..b7438f9fe195 100644 --- a/ui/components/app/contact-list/contact-list.component.js +++ b/ui/components/app/contact-list/contact-list.component.js @@ -2,10 +2,14 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { sortBy } from 'lodash'; import Button from '../../ui/button'; +import { BannerAlert, BannerAlertSeverity } from '../../component-library'; import RecipientGroup from './recipient-group/recipient-group.component'; +import { hasDuplicateContacts, buildDuplicateContactMap } from './utils'; export default class ContactList extends PureComponent { static propTypes = { + addressBook: PropTypes.array, + internalAccounts: PropTypes.array, searchForContacts: PropTypes.func, searchForRecents: PropTypes.func, searchForMyAccounts: PropTypes.func, @@ -22,6 +26,19 @@ export default class ContactList extends PureComponent { isShowingAllRecent: false, }; + renderDuplicateContactWarning() { + const { t } = this.context; + + return ( +
+ +
+ ); + } + renderRecents() { const { t } = this.context; const { isShowingAllRecent } = this.state; @@ -45,15 +62,40 @@ export default class ContactList extends PureComponent { } renderAddressBook() { - const unsortedContactsByLetter = this.props - .searchForContacts() - .reduce((obj, contact) => { + const { + addressBook, + internalAccounts, + searchForContacts, + selectRecipient, + selectedAddress, + } = this.props; + + const duplicateContactMap = buildDuplicateContactMap( + addressBook, + internalAccounts, + ); + + const unsortedContactsByLetter = searchForContacts().reduce( + (obj, contact) => { const firstLetter = contact.name[0].toUpperCase(); + + const isDuplicate = + (duplicateContactMap.get(contact.name.trim().toLowerCase()) ?? []) + .length > 1; + return { ...obj, - [firstLetter]: [...(obj[firstLetter] || []), contact], + [firstLetter]: [ + ...(obj[firstLetter] || []), + { + ...contact, + isDuplicate, + }, + ], }; - }, {}); + }, + {}, + ); const letters = Object.keys(unsortedContactsByLetter).sort(); @@ -71,8 +113,8 @@ export default class ContactList extends PureComponent { key={`${letter}-contact-group`} label={letter} items={groupItems} - onSelect={this.props.selectRecipient} - selectedAddress={this.props.selectedAddress} + onSelect={selectRecipient} + selectedAddress={selectedAddress} /> )); } @@ -95,11 +137,16 @@ export default class ContactList extends PureComponent { searchForRecents, searchForContacts, searchForMyAccounts, + addressBook, + internalAccounts, } = this.props; return (
{children || null} + {hasDuplicateContacts(addressBook, internalAccounts) + ? this.renderDuplicateContactWarning() + : null} {searchForRecents ? this.renderRecents() : null} {searchForContacts ? this.renderAddressBook() : null} {searchForMyAccounts ? this.renderMyAccounts() : null} diff --git a/ui/components/app/contact-list/contact-list.test.js b/ui/components/app/contact-list/contact-list.test.js index 6d0a990cb08c..1beecc17e0fa 100644 --- a/ui/components/app/contact-list/contact-list.test.js +++ b/ui/components/app/contact-list/contact-list.test.js @@ -1,6 +1,8 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { renderWithProvider } from '../../../../test/jest/rendering'; +import { MOCK_ADDRESS_BOOK } from '../../../../test/data/mock-data'; +import { createMockInternalAccount } from '../../../../test/jest/mocks'; import ContactList from '.'; describe('Contact List', () => { @@ -8,6 +10,48 @@ describe('Contact List', () => { metamask: {}, }); + const mockInternalAccounts = [createMockInternalAccount()]; + + it('displays the warning banner when multiple contacts have the same name', () => { + const mockAddressBook = [...MOCK_ADDRESS_BOOK, MOCK_ADDRESS_BOOK[0]]; // Adding duplicate contact + + const { getByText } = renderWithProvider( + , + store, + ); + + const duplicateContactBanner = getByText('You have duplicate contacts'); + + expect(duplicateContactBanner).toBeVisible(); + }); + + it('displays the warning banner when contact has same name as an existing account', () => { + const mockContactWithAccountName = { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: mockInternalAccounts[0].metadata.name, + }; + + const mockAddressBook = [...MOCK_ADDRESS_BOOK, mockContactWithAccountName]; + + const { getByText } = renderWithProvider( + , + store, + ); + + const duplicateContactBanner = getByText('You have duplicate contacts'); + + expect(duplicateContactBanner).toBeVisible(); + }); + describe('given searchForContacts', () => { const selectRecipient = () => null; const selectedAddress = null; @@ -37,6 +81,8 @@ describe('Contact List', () => { searchForContacts={() => contacts} selectRecipient={selectRecipient} selectedAddress={selectedAddress} + addressBook={MOCK_ADDRESS_BOOK} + internalAccounts={mockInternalAccounts} />, store, ); diff --git a/ui/components/app/contact-list/recipient-group/recipient-group.component.js b/ui/components/app/contact-list/recipient-group/recipient-group.component.js index 6bb0b4c30dd6..0788d29aaecd 100644 --- a/ui/components/app/contact-list/recipient-group/recipient-group.component.js +++ b/ui/components/app/contact-list/recipient-group/recipient-group.component.js @@ -7,12 +7,13 @@ export default function RecipientGroup({ items, onSelect }) { return null; } - return items.map(({ address, name }) => ( + return items.map(({ address, name, isDuplicate }) => ( onSelect(address, name)} key={address} + isDuplicate={isDuplicate} /> )); } diff --git a/ui/components/app/contact-list/utils.ts b/ui/components/app/contact-list/utils.ts new file mode 100644 index 000000000000..4254988e4af6 --- /dev/null +++ b/ui/components/app/contact-list/utils.ts @@ -0,0 +1,61 @@ +import { AddressBookEntry } from '@metamask/address-book-controller'; +import { InternalAccount } from '@metamask/keyring-api'; + +export const buildDuplicateContactMap = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], +) => { + const contactMap = new Map( + internalAccounts.map((account) => [ + account.metadata.name.trim().toLowerCase(), + [`account-id-${account.id}`], + ]), + ); + + addressBook.forEach((entry) => { + const { name, address } = entry; + + const sanitizedName = name.trim().toLowerCase(); + + const currentArray = contactMap.get(sanitizedName) ?? []; + currentArray.push(address); + + contactMap.set(sanitizedName, currentArray); + }); + + return contactMap; +}; + +export const hasDuplicateContacts = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], +) => { + const uniqueContactNames = Array.from( + new Set(addressBook.map(({ name }) => name.toLowerCase().trim())), + ); + + const hasAccountNameCollision = internalAccounts.some((account) => + uniqueContactNames.includes(account.metadata.name.toLowerCase().trim()), + ); + + return ( + uniqueContactNames.length !== addressBook.length || hasAccountNameCollision + ); +}; + +export const isDuplicateContact = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], + newName: string, +) => { + const nameExistsInAddressBook = addressBook.some( + ({ name }) => name.toLowerCase().trim() === newName.toLowerCase().trim(), + ); + + const nameExistsInAccountList = internalAccounts.some( + ({ metadata }) => + metadata.name.toLowerCase().trim() === newName.toLowerCase().trim(), + ); + + return nameExistsInAddressBook || nameExistsInAccountList; +}; diff --git a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap index 8d840ba595ce..c3895c50d76a 100644 --- a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap +++ b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap @@ -1,6 +1,106 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddressListItem renders the address and label 1`] = ` +exports[`AddressListItem displays duplicate contact warning icon 1`] = ` +
+ +
+`; + +exports[`AddressListItem renders the address and label without duplicate contact warning icon 1`] = `
@@ -152,9 +173,9 @@ export default class AddContact extends PureComponent { address={resolvedAddress} domainName={addressBookEntryName ?? domainName} onClick={() => { + this.handleNameChange(domainName); this.setState({ input: resolvedAddress, - newName: this.state.newName || domainName, }); this.props.resetDomainResolution(); }} @@ -164,9 +185,9 @@ export default class AddContact extends PureComponent { ); })}
- {errorToRender && ( + {addressError && (
- {t(errorToRender)} + {t(addressError)}
)} @@ -174,7 +195,10 @@ export default class AddContact extends PureComponent { { await addToAddressBook(newAddress, this.state.newName); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 3b18ebdde8e0..db7b87c48ea1 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js @@ -12,10 +12,13 @@ import { getDomainResolutions, resetDomainResolution, } from '../../../../ducks/domains'; +import { getAddressBook, getInternalAccounts } from '../../../../selectors'; import AddContact from './add-contact.component'; const mapStateToProps = (state) => { return { + addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), qrCodeData: getQrCodeData(state), domainError: getDomainError(state), domainResolutions: getDomainResolutions(state), diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js index af130617ae19..09a0e0d96692 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js @@ -7,6 +7,12 @@ import '@testing-library/jest-dom/extend-expect'; import { mockNetworkState } from '../../../../../test/stub/networks'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import { domainInitialState } from '../../../../ducks/domains'; +import { createMockInternalAccount } from '../../../../../test/jest/mocks'; +import { + MOCK_ADDRESS_BOOK, + MOCK_DOMAIN_RESOLUTION, +} from '../../../../../test/data/mock-data'; +import * as domainDucks from '../../../../ducks/domains'; import AddContact from './add-contact.component'; describe('AddContact component', () => { @@ -17,16 +23,29 @@ describe('AddContact component', () => { }, }; const props = { + addressBook: MOCK_ADDRESS_BOOK, + internalAccounts: [createMockInternalAccount()], history: { push: jest.fn() }, addToAddressBook: jest.fn(), scanQrCode: jest.fn(), qrCodeData: { type: 'address', values: { address: '0x123456789abcdef' } }, qrCodeDetected: jest.fn(), - domainResolution: '', + domainResolutions: [MOCK_DOMAIN_RESOLUTION], domainError: '', resetDomainResolution: jest.fn(), }; + beforeEach(() => { + jest.resetAllMocks(); + jest + .spyOn(domainDucks, 'lookupDomainName') + .mockImplementation(() => jest.fn()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should render the component with correct properties', () => { const store = configureMockStore(middleware)(state); @@ -113,4 +132,105 @@ describe('AddContact component', () => { }); expect(getByText('Save')).toBeDisabled(); }); + + it('should disable the submit button when the name is an existing account name', () => { + const duplicateName = 'Account 1'; + + const store = configureMockStore(middleware)(state); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + + const nameInput = document.getElementById('nickname'); + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const addressInput = getByTestId('ens-input'); + + fireEvent.change(addressInput, { + target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' }, + }); + + const saveButton = getByText('Save'); + expect(saveButton).toBeDisabled(); + }); + + it('should disable the submit button when the name is an existing contact name', () => { + const duplicateName = MOCK_ADDRESS_BOOK[0].name; + + const store = configureMockStore(middleware)(state); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + + const nameInput = document.getElementById('nickname'); + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const addressInput = getByTestId('ens-input'); + + fireEvent.change(addressInput, { + target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' }, + }); + + const saveButton = getByText('Save'); + expect(saveButton).toBeDisabled(); + }); + + it('should display error message when name entered is an existing account name', () => { + const duplicateName = 'Account 1'; + + const store = configureMockStore(middleware)(state); + + const { getByText } = renderWithProvider(, store); + + const nameInput = document.getElementById('nickname'); + + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); + + it('should display error message when name entered is an existing contact name', () => { + const duplicateName = MOCK_ADDRESS_BOOK[0].name; + + const store = configureMockStore(middleware)(state); + + const { getByText } = renderWithProvider(, store); + + const nameInput = document.getElementById('nickname'); + + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); + + it('should display error when ENS inserts a name that is already in use', () => { + const store = configureMockStore(middleware)(state); + + const { getByTestId, getByText } = renderWithProvider( + , + store, + ); + + const ensInput = getByTestId('ens-input'); + fireEvent.change(ensInput, { target: { value: 'example.eth' } }); + + const domainResolutionCell = getByTestId( + 'multichain-send-page__recipient__item', + ); + + fireEvent.click(domainResolutionCell); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); }); diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js index 6da9bbf4d14f..83cbaccd99d8 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js @@ -29,6 +29,7 @@ export default class ContactListTab extends Component { static propTypes = { addressBook: PropTypes.array, + internalAccounts: PropTypes.array, history: PropTypes.object, selectedAddress: PropTypes.string, viewingContact: PropTypes.bool, @@ -57,7 +58,8 @@ export default class ContactListTab extends Component { } renderAddresses() { - const { addressBook, history, selectedAddress } = this.props; + const { addressBook, internalAccounts, history, selectedAddress } = + this.props; const contacts = addressBook.filter(({ name }) => Boolean(name)); const nonContacts = addressBook.filter(({ name }) => !name); const { t } = this.context; @@ -66,6 +68,8 @@ export default class ContactListTab extends Component { return (
contacts} searchForRecents={() => nonContacts} selectRecipient={(address) => { diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js index 98991e7e744d..b1715715b4f7 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js @@ -1,7 +1,7 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import { getAddressBook } from '../../../selectors'; +import { getAddressBook, getInternalAccounts } from '../../../selectors'; import { CONTACT_ADD_ROUTE, @@ -28,6 +28,7 @@ const mapStateToProps = (state, ownProps) => { editingContact, addingContact, addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), selectedAddress: pathNameTailIsAddress ? pathNameTail : '', hideAddressBook, currentPath: pathname, diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js index 06cb6a153c5f..0cfe7be2dd5b 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../store/store'; import testData from '../../../../.storybook/test-data'; +import { getInternalAccounts } from '../../../selectors'; import ContactListTab from './contact-list-tab.component'; // Using Test Data For Redux @@ -14,6 +15,7 @@ export default { decorators: [(story) => {story()}], argsTypes: { addressBook: { control: 'object' }, + internalAccounts: { control: 'object' }, hideAddressBook: { control: 'boolean' }, selectedAddress: { control: 'select' }, history: { action: 'history' }, @@ -23,6 +25,8 @@ export default { const { metamask } = store.getState(); const { addresses } = metamask; +const internalAccounts = getInternalAccounts(store.getState()); + export const DefaultStory = (args) => { return (
@@ -34,6 +38,7 @@ export const DefaultStory = (args) => { DefaultStory.storyName = 'Default'; DefaultStory.args = { addressBook: addresses, + internalAccounts, hideAddressBook: false, selectedAddress: addresses.map(({ address }) => address), }; diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js index 335c5166da05..afb7efb1cc01 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js @@ -21,6 +21,7 @@ import { Display, TextVariant, } from '../../../../helpers/constants/design-system'; +import { isDuplicateContact } from '../../../../components/app/contact-list/utils'; export default class EditContact extends PureComponent { static contextTypes = { @@ -28,6 +29,8 @@ export default class EditContact extends PureComponent { }; static propTypes = { + addressBook: PropTypes.array, + internalAccounts: PropTypes.array, addToAddressBook: PropTypes.func, removeFromAddressBook: PropTypes.func, history: PropTypes.object, @@ -48,7 +51,30 @@ export default class EditContact extends PureComponent { newName: this.props.name, newAddress: this.props.address, newMemo: this.props.memo, - error: '', + nameError: '', + addressError: '', + }; + + validateName = (newName) => { + if (newName === this.props.name) { + return true; + } + + const { addressBook, internalAccounts } = this.props; + + return !isDuplicateContact(addressBook, internalAccounts, newName); + }; + + handleNameChange = (e) => { + const newName = e.target.value; + + const isValidName = this.validateName(newName); + + this.setState({ + nameError: isValidName ? null : this.context.t('nameAlreadyInUse'), + }); + + this.setState({ newName }); }; render() { @@ -118,9 +144,10 @@ export default class EditContact extends PureComponent { id="nickname" placeholder={this.context.t('addAlias')} value={this.state.newName} - onChange={(e) => this.setState({ newName: e.target.value })} + onChange={this.handleNameChange} fullWidth margin="dense" + error={this.state.nameError} />
@@ -132,7 +159,7 @@ export default class EditContact extends PureComponent { type="text" id="address" value={this.state.newAddress} - error={this.state.error} + error={this.state.addressError} onChange={(e) => this.setState({ newAddress: e.target.value })} fullWidth multiline @@ -189,7 +216,9 @@ export default class EditContact extends PureComponent { ); history.push(listRoute); } else { - this.setState({ error: this.context.t('invalidAddress') }); + this.setState({ + addressError: this.context.t('invalidAddress'), + }); } } else { // update name @@ -205,12 +234,13 @@ export default class EditContact extends PureComponent { history.push(`${viewRoute}/${address}`); }} submitText={this.context.t('save')} - disabled={ + disabled={Boolean( (this.state.newName === name && this.state.newAddress === address && this.state.newMemo === memo) || - !this.state.newName.trim() - } + !this.state.newName.trim() || + this.state.nameError, + )} />
); diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js index af248b04d330..ff10d850345e 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js @@ -2,8 +2,10 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { + getAddressBook, getAddressBookEntry, getInternalAccountByAddress, + getInternalAccounts, } from '../../../../selectors'; import { getProviderConfig } from '../../../../ducks/metamask/metamask'; import { @@ -34,6 +36,8 @@ const mapStateToProps = (state, ownProps) => { return { address: contact ? address : null, + addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), chainId, name, memo, diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js index 779416140e10..958385d2c79a 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js @@ -4,6 +4,8 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import '@testing-library/jest-dom/extend-expect'; +import { MOCK_ADDRESS_BOOK } from '../../../../../test/data/mock-data'; +import { createMockInternalAccount } from '../../../../../test/jest/mocks'; import EditContact from './edit-contact.component'; describe('AddContact component', () => { @@ -11,11 +13,17 @@ describe('AddContact component', () => { const state = { metamask: {}, }; + + const mockAccount1 = createMockInternalAccount(); + const mockAccount2 = createMockInternalAccount({ name: 'Test Contact' }); + const props = { + addressBook: MOCK_ADDRESS_BOOK, + internalAccounts: [mockAccount1, mockAccount2], addToAddressBook: jest.fn(), removeFromAddressBook: jest.fn(), history: { push: jest.fn() }, - name: '', + name: mockAccount1.metadata.name, address: '0x0000000000000000001', chainId: '', memo: '', @@ -36,11 +44,14 @@ describe('AddContact component', () => { const store = configureMockStore(middleware)(state); const { getByText } = renderWithProvider(, store); - const input = document.getElementById('address'); - fireEvent.change(input, { target: { value: 'invalid address' } }); - setTimeout(() => { - expect(getByText('Invalid address')).toBeInTheDocument(); - }, 100); + const addressInput = document.getElementById('address'); + fireEvent.change(addressInput, { target: { value: 'invalid address' } }); + + const submitButton = getByText('Save'); + + fireEvent.click(submitButton); + + expect(getByText('Invalid address')).toBeInTheDocument(); }); it('should get disabled submit button when username field is empty', () => { @@ -53,4 +64,46 @@ describe('AddContact component', () => { const saveButton = getByText('Save'); expect(saveButton).toBeDisabled(); }); + + it('should display error when entering a name that is in use by an existing contact', () => { + const store = configureMockStore(middleware)(state); + const { getByText } = renderWithProvider(, store); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: MOCK_ADDRESS_BOOK[0].name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(getByText('Name is already in use')).toBeDefined(); + }); + + it('should display error when entering a name that is in use by an existing account', () => { + const store = configureMockStore(middleware)(state); + const { getByText } = renderWithProvider(, store); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: mockAccount2.metadata.name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(getByText('Name is already in use')).toBeDefined(); + }); + + it('should not display error when entering the current contact name', () => { + const store = configureMockStore(middleware)(state); + const { getByText, queryByText } = renderWithProvider( + , + store, + ); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: mockAccount1.metadata.name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(queryByText('Name is already in use')).toBeNull(); + }); }); From 62430fb657ead13e81969520a1f2a64ecd34fc0d Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Wed, 20 Nov 2024 16:44:38 -0300 Subject: [PATCH 017/148] fix(sentry sampling): divide by 2 our sentry trace sample rate to avoid exceeding our quota (#28573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Divide by 2 our sentry trace sample rate to avoid exceeding our quota ## **Related issues** Fixes: None ## **Manual testing steps** - None ## **Screenshots/Recordings** - None ## **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: Harika <153644847+hjetpoluru@users.noreply.github.com> --- app/scripts/lib/setupSentry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 354bb0bbb620..e268d25b2810 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -134,7 +134,7 @@ function getTracesSampleRate(sentryTarget) { return 1.0; } - return 0.02; + return 0.01; } /** From 94a7b20741d3bb0a6a6f45da7e74d1cf8bce16e7 Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Wed, 20 Nov 2024 13:03:37 -0800 Subject: [PATCH 018/148] fix: use PORTFOLIO_VIEW flag to determine chain polling (#28504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the token price and detection hooks to only poll across chains when `PORTFOLIO_VIEW` is set. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28504?quickstart=1) ## **Related issues** ## **Manual testing steps** 1. With `PORTFOLIO_VIEW=1`, requests should go to the price api across all chains. 2. Without `PORTFOLIO_VIEW=1`, requests should go to the price api on the current chain. ## **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. --- .../onboarding-token-price-call-privacy.spec.ts | 7 ------- ui/hooks/useTokenDetectionPolling.ts | 8 +++++++- ui/hooks/useTokenRatesPolling.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts index a599d96fd5d4..f565c0bb354e 100644 --- a/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts +++ b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts @@ -21,13 +21,6 @@ async function mockTokenPriceApi( statusCode: 200, json: {}, })), - // linea - await mockServer - .forGet('https://price.api.cx.metamask.io/v2/chains/59144/spot-prices') - .thenCallback(() => ({ - statusCode: 200, - json: {}, - })), ]; } diff --git a/ui/hooks/useTokenDetectionPolling.ts b/ui/hooks/useTokenDetectionPolling.ts index 790384e21cbf..d2e08d01892d 100644 --- a/ui/hooks/useTokenDetectionPolling.ts +++ b/ui/hooks/useTokenDetectionPolling.ts @@ -1,5 +1,6 @@ import { useSelector } from 'react-redux'; import { + getCurrentChainId, getNetworkConfigurationsByChainId, getUseTokenDetection, } from '../selectors'; @@ -17,14 +18,19 @@ const useTokenDetectionPolling = () => { const useTokenDetection = useSelector(getUseTokenDetection); const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); + const currentChainId = useSelector(getCurrentChainId); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const enabled = completedOnboarding && isUnlocked && useTokenDetection; + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + useMultiPolling({ startPolling: tokenDetectionStartPolling, stopPollingByPollingToken: tokenDetectionStopPollingByPollingToken, - input: enabled ? [Object.keys(networkConfigurations)] : [], + input: enabled ? [chainIds] : [], }); return {}; diff --git a/ui/hooks/useTokenRatesPolling.ts b/ui/hooks/useTokenRatesPolling.ts index a740a426e36c..37864ec89b82 100644 --- a/ui/hooks/useTokenRatesPolling.ts +++ b/ui/hooks/useTokenRatesPolling.ts @@ -1,5 +1,6 @@ import { useSelector } from 'react-redux'; import { + getCurrentChainId, getMarketData, getNetworkConfigurationsByChainId, getTokenExchangeRates, @@ -16,10 +17,11 @@ import { } from '../ducks/metamask/metamask'; import useMultiPolling from './useMultiPolling'; -const useTokenRatesPolling = ({ chainIds }: { chainIds?: string[] } = {}) => { +const useTokenRatesPolling = () => { // Selectors to determine polling input const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); + const currentChainId = useSelector(getCurrentChainId); const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); @@ -30,10 +32,14 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: string[] } = {}) => { const enabled = completedOnboarding && isUnlocked && useCurrencyRateCheck; + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + useMultiPolling({ startPolling: tokenRatesStartPolling, stopPollingByPollingToken: tokenRatesStopPollingByPollingToken, - input: enabled ? chainIds ?? Object.keys(networkConfigurations) : [], + input: enabled ? chainIds : [], }); return { From f2706214cc5d311a57e3074f3d08c060dde02076 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 20 Nov 2024 23:07:15 +0100 Subject: [PATCH 019/148] fix: fix coin-overview display when price setting is off (#28569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes display bug on coin overview and account list item when user toggles OFF the Show balance and token price checker setting [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28569?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28567 ## **Manual testing steps** 1. Go to settings turn off `Show balance and token price checker ` 2. Go back to home page you should see correct balance in crypto 3. Click on account item menu and you should see balance in crypto 4. Go to settings and turn it ON and you should see correct fiat balance. 5. Go to settings and turn on the "show native token as main balance" 6. You should see balance in crypto 7. Go to settings and turn OFF "show native token as main balance" also turn OFF "Show balance and token price checker" 8. You should see balance in crypto ## **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. --- .../app/wallet-overview/coin-overview.tsx | 18 +++++++++++++++--- .../account-list-item/account-list-item.js | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index b06f0c06b374..bf054d993e74 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -62,7 +62,10 @@ import { 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 { + getMultichainIsEvm, + getMultichainShouldShowFiat, +} from '../../../selectors/multichain'; import { setAggregatedBalancePopoverShown, setPrivacyMode, @@ -73,6 +76,7 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import WalletOverview from './wallet-overview'; import CoinButtons from './coin-buttons'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; @@ -160,9 +164,15 @@ export const CoinOverview = ({ formattedTokensWithBalancesPerChain, ); + const shouldShowFiat = useMultichainSelector( + getMultichainShouldShowFiat, + account, + ); + const isEvm = useSelector(getMultichainIsEvm); const isNotAggregatedFiatBalance = - showNativeTokenAsMainBalance || isTestnet || !isEvm; + !shouldShowFiat || showNativeTokenAsMainBalance || isTestnet || !isEvm; + let balanceToDisplay; if (isNotAggregatedFiatBalance) { balanceToDisplay = balance; @@ -300,7 +310,9 @@ export const CoinOverview = ({ hideTitle shouldCheckShowNativeToken isAggregatedFiatOverviewBalance={ - !showNativeTokenAsMainBalance && !isTestnet + !showNativeTokenAsMainBalance && + !isTestnet && + shouldShowFiat } privacyMode={privacyMode} /> diff --git a/ui/components/multichain/account-list-item/account-list-item.js b/ui/components/multichain/account-list-item/account-list-item.js index e63266080be5..6be8e3f67f0b 100644 --- a/ui/components/multichain/account-list-item/account-list-item.js +++ b/ui/components/multichain/account-list-item/account-list-item.js @@ -143,7 +143,7 @@ const AccountListItem = ({ let balanceToTranslate; if (isEvmNetwork) { balanceToTranslate = - isTestnet || !process.env.PORTFOLIO_VIEW + shouldShowFiat || isTestnet || !process.env.PORTFOLIO_VIEW ? account.balance : totalFiatBalance; } else { From d5c0aaa0a0f4a4897fe187eb9c39cca180973bc6 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:34:30 -0500 Subject: [PATCH 020/148] ci: limit playwright install to chromium browser only (#28580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Storybook CI jobs are failing due to the `playwright install` step timing out due to an AWS issue. We may be able to work around this issue by reducing the number of browsers we download. We only use chromium, so this PR limits it to just chromium [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28580?quickstart=1) --- .circleci/config.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7a81017489c1..a8185c00ee2f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -964,7 +964,7 @@ jobs: at: . - run: name: Install Playwright browsers - command: yarn exec playwright install + command: yarn exec playwright install chromium - run: name: Test Storybook command: yarn test-storybook:ci diff --git a/package.json b/package.json index 929cc375f683..7117462c957f 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "ts-migration:dashboard:watch": "yarn ts-migration:dashboard:build --watch", "ts-migration:enumerate": "ts-node development/ts-migration-dashboard/scripts/write-list-of-files-to-convert.ts", "test-storybook": "test-storybook -c .storybook", - "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn storybook:build && npx http-server storybook-build --port 6006 \" \"wait-on tcp:6006 && echo 'Build done. Running storybook tests...' && npx playwright install && yarn test-storybook --maxWorkers=2\"", + "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn storybook:build && npx http-server storybook-build --port 6006 \" \"wait-on tcp:6006 && echo 'Build done. Running storybook tests...' && yarn test-storybook --maxWorkers=2\"", "githooks:install": "husky install", "fitness-functions": "ts-node development/fitness-functions/index.ts", "generate-beta-commit": "node ./development/generate-beta-commit.js", From d440363914be3ef7fd9092154b2e37b5b193f3f7 Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Wed, 20 Nov 2024 15:18:02 -0800 Subject: [PATCH 021/148] fix: use PORTFOLIO_VIEW flag to determine token list polling (#28579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the token list hook to only poll across chains when `PORTFOLIO_VIEW` is set. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28579?quickstart=1) ## **Related issues** ## **Manual testing steps** 1. With `PORTFOLIO_VIEW=1`, requests should go to the token api across all chains. 2. Without `PORTFOLIO_VIEW=1`, requests should go to the token api on the current chain. ## **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. --- ...rs-after-init-opt-in-background-state.json | 6 +----- .../errors-after-init-opt-in-ui-state.json | 6 +----- ui/hooks/useTokenListPolling.test.ts | 21 +++++++++++-------- ui/hooks/useTokenListPolling.ts | 8 ++++++- 4 files changed, 21 insertions(+), 20 deletions(-) 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 39ec3fb2530b..d74eb479fbc5 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 @@ -321,11 +321,7 @@ "TokenListController": { "tokenList": "object", "tokensChainsCache": { - "0x1": "object", - "0x539": "object", - "0xaa36a7": "object", - "0xe705": "object", - "0xe708": "object" + "0x539": "object" }, "preventPollingOnNetworkRestart": false }, 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 615689cb6d19..96a80b021f0b 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 @@ -175,11 +175,7 @@ "nonRPCGasFeeApisDisabled": "boolean", "tokenList": "object", "tokensChainsCache": { - "0x1": "object", - "0x539": "object", - "0xaa36a7": "object", - "0xe705": "object", - "0xe708": "object" + "0x539": "object" }, "tokenBalances": "object", "preventPollingOnNetworkRestart": false, diff --git a/ui/hooks/useTokenListPolling.test.ts b/ui/hooks/useTokenListPolling.test.ts index 09a22fffea50..001dca71c80e 100644 --- a/ui/hooks/useTokenListPolling.test.ts +++ b/ui/hooks/useTokenListPolling.test.ts @@ -22,16 +22,23 @@ describe('useTokenListPolling', () => { jest.clearAllMocks(); }); - it('should poll for token lists on each chain when enabled, and stop on dismount', async () => { + it('should poll the selected network when enabled, and stop on dismount', async () => { const state = { metamask: { isUnlocked: true, completedOnboarding: true, useExternalServices: true, useTokenDetection: true, + selectedNetworkClientId: 'selectedNetworkClientId', networkConfigurationsByChainId: { - '0x1': {}, - '0x89': {}, + '0x1': { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, }, }, }; @@ -43,19 +50,15 @@ describe('useTokenListPolling', () => { // Should poll each chain await Promise.all(mockPromises); - expect(tokenListStartPolling).toHaveBeenCalledTimes(2); + expect(tokenListStartPolling).toHaveBeenCalledTimes(1); expect(tokenListStartPolling).toHaveBeenCalledWith('0x1'); - expect(tokenListStartPolling).toHaveBeenCalledWith('0x89'); // Stop polling on dismount unmount(); - expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(2); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(1); expect(tokenListStopPollingByPollingToken).toHaveBeenCalledWith( '0x1_token', ); - expect(tokenListStopPollingByPollingToken).toHaveBeenCalledWith( - '0x89_token', - ); }); it('should not poll before onboarding is completed', async () => { diff --git a/ui/hooks/useTokenListPolling.ts b/ui/hooks/useTokenListPolling.ts index 9b43c3c6959a..7f7de517c304 100644 --- a/ui/hooks/useTokenListPolling.ts +++ b/ui/hooks/useTokenListPolling.ts @@ -1,5 +1,6 @@ import { useSelector } from 'react-redux'; import { + getCurrentChainId, getNetworkConfigurationsByChainId, getPetnamesEnabled, getUseExternalServices, @@ -17,6 +18,7 @@ import { import useMultiPolling from './useMultiPolling'; const useTokenListPolling = () => { + const currentChainId = useSelector(getCurrentChainId); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const useTokenDetection = useSelector(getUseTokenDetection); const useTransactionSimulations = useSelector(getUseTransactionSimulations); @@ -31,10 +33,14 @@ const useTokenListPolling = () => { useExternalServices && (useTokenDetection || petnamesEnabled || useTransactionSimulations); + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + useMultiPolling({ startPolling: tokenListStartPolling, stopPollingByPollingToken: tokenListStopPollingByPollingToken, - input: enabled ? Object.keys(networkConfigurations) : [], + input: enabled ? chainIds : [], }); return {}; From 1975c85f2b078e30d7bf12c7bcbb585d37021bb9 Mon Sep 17 00:00:00 2001 From: jiexi Date: Wed, 20 Nov 2024 15:42:36 -0800 Subject: [PATCH 022/148] fix: Gracefully handle bad responses from `net_version` calls to RPC endpoint when getting Provider Network State (#27509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Currently some users are seeing `Error: Cannot parse as a valid network ID: 'undefined'` errors when connected to RPC endpoints that fail to provide a value from `net_version` calls that can be parsed into a decimal string. This PR makes our internally handling of this case more flexibly by using `null` as the network version value sent to the inpage provider when receiving an unexpected `net_version` call value. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27509?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/27487 ## **Manual testing steps** 1. Open extension background 2. Intercept a request for net_version (you can use a proxy like Burpsuite) and make it return a bad value 3. There should be no error in extension background related to this 4. Open a dapp 5. Open the dapp developer console 6. Expect to see an error like `JsonRpcError: MetaMask: Disconnected from chain. Attempting to connect.` 7. Test that `window.ethereum.request` works as expected despite this ## **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: legobeat <109787230+legobeat@users.noreply.github.com> --- app/scripts/metamask-controller.js | 2 +- shared/modules/network.utils.test.ts | 49 ++++++++++++++++++++++++++++ shared/modules/network.utils.ts | 10 +++--- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 3b75349091ad..0d0344d4e7e3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3209,7 +3209,7 @@ export default class MetamaskController extends EventEmitter { const { completedOnboarding } = this.onboardingController.state; let networkVersion = this.deprecatedNetworkVersions[networkClientId]; - if (!networkVersion && completedOnboarding) { + if (networkVersion === undefined && completedOnboarding) { const ethQuery = new EthQuery(networkClient.provider); networkVersion = await new Promise((resolve) => { ethQuery.sendAsync({ method: 'net_version' }, (error, result) => { diff --git a/shared/modules/network.utils.test.ts b/shared/modules/network.utils.test.ts index ee4ef3f8399e..783d764f8825 100644 --- a/shared/modules/network.utils.test.ts +++ b/shared/modules/network.utils.test.ts @@ -3,6 +3,7 @@ import { isSafeChainId, isPrefixedFormattedHexString, isTokenDetectionEnabledForNetwork, + convertNetworkId, } from './network.utils'; describe('network utils', () => { @@ -83,4 +84,52 @@ describe('network utils', () => { expect(isTokenDetectionEnabledForNetwork(undefined)).toBe(false); }); }); + + describe('convertNetworkId', () => { + it('returns decimal strings for postive integer number values', () => { + expect(convertNetworkId(0)).toStrictEqual('0'); + expect(convertNetworkId(123)).toStrictEqual('123'); + expect(convertNetworkId(1337)).toStrictEqual('1337'); + }); + + it('returns null for negative numbers', () => { + expect(convertNetworkId(-1)).toStrictEqual(null); + }); + + it('returns null for non integer numbers', () => { + expect(convertNetworkId(0.1)).toStrictEqual(null); + expect(convertNetworkId(1.1)).toStrictEqual(null); + }); + + it('returns null for NaN', () => { + expect(convertNetworkId(Number.NaN)).toStrictEqual(null); + }); + + it('returns decimal strings for strict valid hex values', () => { + expect(convertNetworkId('0x0')).toStrictEqual('0'); + expect(convertNetworkId('0x1')).toStrictEqual('1'); + expect(convertNetworkId('0x539')).toStrictEqual('1337'); + }); + + it('returns null for invalid hex values', () => { + expect(convertNetworkId('0xG')).toStrictEqual(null); + expect(convertNetworkId('0x@')).toStrictEqual(null); + expect(convertNetworkId('0xx1')).toStrictEqual(null); + }); + + it('returns the value as is if already a postive decimal string', () => { + expect(convertNetworkId('0')).toStrictEqual('0'); + expect(convertNetworkId('1')).toStrictEqual('1'); + expect(convertNetworkId('1337')).toStrictEqual('1337'); + }); + + it('returns null for negative number strings', () => { + expect(convertNetworkId('-1')).toStrictEqual(null); + }); + + it('returns null for non integer number strings', () => { + expect(convertNetworkId('0.1')).toStrictEqual(null); + expect(convertNetworkId('1.1')).toStrictEqual(null); + }); + }); }); diff --git a/shared/modules/network.utils.ts b/shared/modules/network.utils.ts index 8985bdcf7f5a..764a34bb8520 100644 --- a/shared/modules/network.utils.ts +++ b/shared/modules/network.utils.ts @@ -78,16 +78,16 @@ function isSafeInteger(value: unknown): value is number { * as either a number, a decimal string, or a 0x-prefixed hex string. * * @param value - The network ID to convert, in an unknown format. - * @returns A valid network ID (as a decimal string) - * @throws If the given value cannot be safely parsed. + * @returns A valid network ID (as a decimal string) or null if + * the given value cannot be parsed. */ -export function convertNetworkId(value: unknown): string { - if (typeof value === 'number' && !Number.isNaN(value)) { +export function convertNetworkId(value: unknown): string | null { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { return `${value}`; } else if (isStrictHexString(value)) { return `${convertHexToDecimal(value)}`; } else if (typeof value === 'string' && /^\d+$/u.test(value)) { return value; } - throw new Error(`Cannot parse as a valid network ID: '${value}'`); + return null; } From bd2248dbc42dcf943370c13567568141802b9ff1 Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:54:06 -0800 Subject: [PATCH 023/148] refactor: Cherry pick asset-list-control-bar updates (#28575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry picks design updates for `AssetListControlBar` introduced from https://github.com/MetaMask/metamask-extension/pull/28386 separately in it's own PR to help minimize diff in main feature branch. Also includes unit test and e2e updates impacted from these changes. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28575?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** Run with feature flag and without feature flag: `yarn webpack --watch` `PORTFOLIO_VIEW=1 yarn webpack --watch` Validate that sort works, validate that import works, validate that refresh list works. ## **Screenshots/Recordings** Without feature flag: https://github.com/user-attachments/assets/445d4fd1-93d1-4cee-bd7b-bcc36518d7ca With feature flag (network filter not yet integrated) https://github.com/user-attachments/assets/d1aa8812-9787-49b5-9696-39e56d82ed56 ## **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: Jonathan Bursztyn --- .storybook/test-data.js | 27 +++ app/scripts/constants/sentry-state.ts | 1 + test/e2e/fixture-builder.js | 3 + .../erc20-approve-redesign.spec.ts | 1 + ...rs-after-init-opt-in-background-state.json | 8 +- .../errors-after-init-opt-in-ui-state.json | 8 +- ...s-before-init-opt-in-background-state.json | 2 +- .../errors-before-init-opt-in-ui-state.json | 2 +- .../tests/privacy/basic-functionality.spec.js | 6 +- test/e2e/tests/tokens/add-hide-token.spec.js | 1 + .../tokens/custom-token-add-approve.spec.js | 1 + test/e2e/tests/tokens/import-tokens.spec.js | 1 + test/e2e/tests/tokens/token-details.spec.ts | 1 + test/e2e/tests/tokens/token-list.spec.ts | 1 + test/e2e/tests/tokens/token-sort.spec.ts | 1 + .../asset-list-control-bar.tsx | 176 ++++++++++++++---- .../asset-list-control-bar/index.scss | 3 +- .../app/assets/asset-list/asset-list.test.tsx | 36 +++- .../app/assets/asset-list/asset-list.tsx | 20 +- .../import-control/import-control.tsx | 32 +--- .../asset-list/sort-control/sort-control.tsx | 4 +- .../account-overview-btc.test.tsx | 29 ++- .../account-overview-eth.test.tsx | 29 ++- .../import-token-link.test.js.snap | 47 ----- .../import-token-link.stories.tsx | 14 -- .../import-token-link.test.js | 93 --------- .../import-token-link/import-token-link.tsx | 40 ---- .../import-token-link.types.ts | 8 - .../multichain/import-token-link/index.ts | 1 - ui/components/multichain/index.js | 1 - ui/pages/routes/routes.component.test.js | 70 +++++++ 31 files changed, 373 insertions(+), 294 deletions(-) delete mode 100644 ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap delete mode 100644 ui/components/multichain/import-token-link/import-token-link.stories.tsx delete mode 100644 ui/components/multichain/import-token-link/import-token-link.test.js delete mode 100644 ui/components/multichain/import-token-link/import-token-link.tsx delete mode 100644 ui/components/multichain/import-token-link/import-token-link.types.ts delete mode 100644 ui/components/multichain/import-token-link/index.ts diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 13006e5d1ff7..a36cbf944981 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -487,6 +487,32 @@ const state = { }, }, }, + allTokens: { + '0x1': { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + aggregators: [], + decimals: 18, + symbol: 'YFI', + }, + ], + }, + }, + tokenBalances: { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xbdbd', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501b4176a64d6', + }, + }, + }, tokens: [ { address: '0xaD6D458402F60fD3Bd25163575031ACDce07538A', @@ -682,6 +708,7 @@ const state = { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }, incomingTransactionsPreferences: { [CHAIN_IDS.MAINNET]: true, diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index bd953e72d49c..d0fbe7bcb085 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -245,6 +245,7 @@ export const SENTRY_BACKGROUND_STATE = { showFiatInTestnets: true, showTestNetworks: true, smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, showNativeTokenAsMainBalance: true, petnamesEnabled: true, showConfirmationAdvancedDetails: true, diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index bea9e9bad77f..844c4766db3e 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -89,6 +89,7 @@ function onboardingFixture() { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, shouldShowAggregatedBalancePopover: true, }, useExternalServices: true, @@ -126,6 +127,7 @@ function onboardingFixture() { }, showTestNetworks: false, smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, }, QueuedRequestController: { queuedRequestCount: 0, @@ -664,6 +666,7 @@ class FixtureBuilder { return this.withPreferencesController({ preferences: { smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, }, }); } diff --git a/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts index baa3638330b6..4e340f5ef3ac 100644 --- a/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts @@ -118,6 +118,7 @@ async function mocks(server: MockttpServer) { export async function importTST(driver: Driver) { await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); await driver.clickElement('[data-testid="import-token-button"]'); + await driver.clickElement('[data-testid="importTokens"]'); await driver.waitForSelector({ css: '.import-tokens-modal__button-tab', 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 d74eb479fbc5..cea439180b5b 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 @@ -237,7 +237,13 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", - "tokenNetworkFilter": "object", + "tokenNetworkFilter": { + "0x1": "boolean", + "0xaa36a7": "boolean", + "0xe705": "boolean", + "0xe708": "boolean", + "0x539": "boolean" + }, "shouldShowAggregatedBalancePopover": "boolean" }, "ipfsGateway": "string", 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 96a80b021f0b..3bc7057435c8 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 @@ -38,7 +38,13 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", - "tokenNetworkFilter": "object", + "tokenNetworkFilter": { + "0x1": "boolean", + "0xaa36a7": "boolean", + "0xe705": "boolean", + "0xe708": "boolean", + "0x539": "boolean" + }, "shouldShowAggregatedBalancePopover": "boolean" }, "firstTimeFlowType": "import", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 91c994e9ab66..5f5f47f3e7ee 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -120,7 +120,7 @@ "tokenSortConfig": "object", "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean", - "tokenNetworkFilter": "object" + "tokenNetworkFilter": {} }, "selectedAddress": "string", "theme": "light", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 552f089c6604..f997b89bcd28 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -135,7 +135,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", - "tokenNetworkFilter": "object", + "tokenNetworkFilter": {}, "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/privacy/basic-functionality.spec.js b/test/e2e/tests/privacy/basic-functionality.spec.js index e6439c569339..77e9dad0b46f 100644 --- a/test/e2e/tests/privacy/basic-functionality.spec.js +++ b/test/e2e/tests/privacy/basic-functionality.spec.js @@ -102,7 +102,8 @@ describe('MetaMask onboarding @no-mmi', function () { // Wait until network is fully switched and refresh tokens before asserting to mitigate flakiness await driver.assertElementNotPresent('.loading-overlay'); - await driver.clickElement('[data-testid="refresh-list-button"]'); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement('[data-testid="refreshList"]'); for (let i = 0; i < mockedEndpoints.length; i += 1) { const requests = await mockedEndpoints[i].getSeenRequests(); @@ -157,7 +158,8 @@ describe('MetaMask onboarding @no-mmi', function () { // Wait until network is fully switched and refresh tokens before asserting to mitigate flakiness await driver.assertElementNotPresent('.loading-overlay'); - await driver.clickElement('[data-testid="refresh-list-button"]'); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement('[data-testid="refreshList"]'); // intended delay to allow for network requests to complete await driver.delay(1000); for (let i = 0; i < mockedEndpoints.length; i += 1) { diff --git a/test/e2e/tests/tokens/add-hide-token.spec.js b/test/e2e/tests/tokens/add-hide-token.spec.js index 6bd0c8744fba..c9a1f26ad9eb 100644 --- a/test/e2e/tests/tokens/add-hide-token.spec.js +++ b/test/e2e/tests/tokens/add-hide-token.spec.js @@ -130,6 +130,7 @@ describe('Add existing token using search', function () { await unlockWallet(driver); await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await driver.fill('input[placeholder="Search tokens"]', 'BAT'); await driver.clickElement({ text: 'BAT', diff --git a/test/e2e/tests/tokens/custom-token-add-approve.spec.js b/test/e2e/tests/tokens/custom-token-add-approve.spec.js index 8fa765a36164..4e85aae76fd6 100644 --- a/test/e2e/tests/tokens/custom-token-add-approve.spec.js +++ b/test/e2e/tests/tokens/custom-token-add-approve.spec.js @@ -36,6 +36,7 @@ describe('Create token, approve token and approve token without gas', function ( await clickNestedButton(driver, 'Tokens'); await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/import-tokens.spec.js b/test/e2e/tests/tokens/import-tokens.spec.js index 3055f7109551..7b1bf60964ab 100644 --- a/test/e2e/tests/tokens/import-tokens.spec.js +++ b/test/e2e/tests/tokens/import-tokens.spec.js @@ -69,6 +69,7 @@ describe('Import flow', function () { await driver.assertElementNotPresent('.loading-overlay'); await driver.clickElement('[data-testid="import-token-button"]'); + await driver.clickElement('[data-testid="importTokens"]'); await driver.fill('input[placeholder="Search tokens"]', 'cha'); diff --git a/test/e2e/tests/tokens/token-details.spec.ts b/test/e2e/tests/tokens/token-details.spec.ts index 56d9515e727d..2ee84d339ea8 100644 --- a/test/e2e/tests/tokens/token-details.spec.ts +++ b/test/e2e/tests/tokens/token-details.spec.ts @@ -28,6 +28,7 @@ describe('Token Details', function () { const importToken = async (driver: Driver) => { await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-list.spec.ts b/test/e2e/tests/tokens/token-list.spec.ts index c20a9f13b0e3..f7b032c92a4c 100644 --- a/test/e2e/tests/tokens/token-list.spec.ts +++ b/test/e2e/tests/tokens/token-list.spec.ts @@ -28,6 +28,7 @@ describe('Token List', function () { const importToken = async (driver: Driver) => { await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-sort.spec.ts b/test/e2e/tests/tokens/token-sort.spec.ts index 1fc1df7efd2c..ed6005a710ad 100644 --- a/test/e2e/tests/tokens/token-sort.spec.ts +++ b/test/e2e/tests/tokens/token-sort.spec.ts @@ -26,6 +26,7 @@ describe('Token List', function () { const importToken = async (driver: Driver) => { await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', 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 7722eff36870..2925277c14bd 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,6 +1,10 @@ -import React, { useMemo, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; +import React, { useEffect, useRef, useState, useContext, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + getCurrentNetwork, + getNetworkConfigurationsByChainId, + getPreferences, +} from '../../../../../selectors'; import { Box, ButtonBase, @@ -9,17 +13,22 @@ import { Popover, PopoverPosition, } from '../../../../component-library'; -import SortControl from '../sort-control'; +import SortControl, { SelectableListItem } from '../sort-control/sort-control'; import { BackgroundColor, - BorderColor, - BorderStyle, Display, JustifyContent, TextColor, + TextVariant, } from '../../../../../helpers/constants/design-system'; import ImportControl from '../import-control'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { MetaMetricsContext } from '../../../../../contexts/metametrics'; +import { TEST_CHAINS } from '../../../../../../shared/constants/network'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../../../shared/constants/metametrics'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../../../../app/scripts/lib/util'; @@ -28,7 +37,12 @@ import { ENVIRONMENT_TYPE_POPUP, } from '../../../../../../shared/constants/app'; import NetworkFilter from '../network-filter'; -import { TEST_CHAINS } from '../../../../../../shared/constants/network'; +import { + detectTokens, + setTokenNetworkFilter, + showImportTokensModal, +} from '../../../../../store/actions'; +import Tooltip from '../../../../ui/tooltip'; type AssetListControlBarProps = { showTokensLinks?: boolean; @@ -36,18 +50,55 @@ type AssetListControlBarProps = { const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const t = useI18nContext(); + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); const popoverRef = useRef(null); const currentNetwork = useSelector(getCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); const [isTokenSortPopoverOpen, setIsTokenSortPopoverOpen] = useState(false); + const [isImportTokensPopoverOpen, setIsImportTokensPopoverOpen] = + useState(false); const [isNetworkFilterPopoverOpen, setIsNetworkFilterPopoverOpen] = useState(false); - const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; const isTestNetwork = useMemo(() => { return (TEST_CHAINS as string[]).includes(currentNetwork.chainId); }, [currentNetwork.chainId, TEST_CHAINS]); + const allOpts: Record = {}; + Object.keys(allNetworks).forEach((chainId) => { + allOpts[chainId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter).length !== Object.keys(allOpts).length; + + useEffect(() => { + if (isTestNetwork) { + const testnetFilter = { [currentNetwork.chainId]: true }; + dispatch(setTokenNetworkFilter(testnetFilter)); + } + }, [isTestNetwork, currentNetwork.chainId, dispatch]); + + // TODO: This useEffect should be a migration + // We need to set the default filter for all users to be all included networks, rather than defaulting to empty object + // This effect is to unblock and derisk in the short-term + useEffect(() => { + if (Object.keys(tokenNetworkFilter).length === 0) { + dispatch(setTokenNetworkFilter(allOpts)); + } + }, []); + + // When a network gets added/removed we want to make sure that we switch to the filtered list of the current network + // We only want to do this if the "Current Network" filter is selected + useEffect(() => { + if (Object.keys(tokenNetworkFilter).length === 1) { + dispatch(setTokenNetworkFilter({ [currentNetwork.chainId]: true })); + } + }, [Object.keys(allNetworks).length]); + const windowType = getEnvironmentType(); const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && @@ -55,37 +106,65 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const toggleTokenSortPopover = () => { setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(false); setIsTokenSortPopoverOpen(!isTokenSortPopoverOpen); }; const toggleNetworkFilterPopover = () => { setIsTokenSortPopoverOpen(false); + setIsImportTokensPopoverOpen(false); setIsNetworkFilterPopoverOpen(!isNetworkFilterPopoverOpen); }; + const toggleImportTokensPopover = () => { + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(!isImportTokensPopoverOpen); + }; + const closePopover = () => { setIsTokenSortPopoverOpen(false); setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(false); + }; + + const handleImport = () => { + dispatch(showImportTokensModal()); + trackEvent({ + category: MetaMetricsEventCategory.Navigation, + event: MetaMetricsEventName.TokenImportButtonClicked, + properties: { + location: 'HOME', + }, + }); + closePopover(); + }; + + const handleRefresh = () => { + dispatch(detectTokens()); + closePopover(); }; return ( {process.env.PORTFOLIO_VIEW && ( { ? BackgroundColor.backgroundPressed : BackgroundColor.backgroundDefault } - borderColor={BorderColor.borderMuted} - borderStyle={BorderStyle.solid} color={TextColor.textDefault} marginRight={isFullScreen ? 2 : null} ellipsis @@ -107,26 +184,33 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { )} - - {t('sortBy')} - + + + - + + { { > + + + + {t('importTokensCamelCase')} + + + {t('refreshList')} + + ); }; diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss index da8376679356..1fee45c33a87 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss @@ -8,7 +8,8 @@ &__network_control { justify-content: space-between; - min-width: 185px; + width: auto; + min-width: auto; border-radius: 8px; padding: 0 8px !important; gap: 5px; diff --git a/ui/components/app/assets/asset-list/asset-list.test.tsx b/ui/components/app/assets/asset-list/asset-list.test.tsx index fd65e740238d..00a47df1c633 100644 --- a/ui/components/app/assets/asset-list/asset-list.test.tsx +++ b/ui/components/app/assets/asset-list/asset-list.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { screen, act, waitFor } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/jest'; -import configureStore, { MetaMaskReduxState } from '../../../../store/store'; +import { MetaMaskReduxState } from '../../../../store/store'; import mockState from '../../../../../test/data/mock-state.json'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import { useIsOriginalNativeTokenSymbol } from '../../../../hooks/useIsOriginalNativeTokenSymbol'; +import useMultiPolling from '../../../../hooks/useMultiPolling'; import { getTokenSymbol } from '../../../../store/actions'; import { getSelectedInternalAccountFromMockState } from '../../../../../test/jest/mocks'; import { mockNetworkState } from '../../../../../test/stub/networks'; @@ -64,11 +67,19 @@ jest.mock('../../../../hooks/useIsOriginalNativeTokenSymbol', () => { jest.mock('../../../../store/actions', () => { return { getTokenSymbol: jest.fn(), + setTokenNetworkFilter: jest.fn(() => ({ + type: 'TOKEN_NETWORK_FILTER', + })), tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), tokenBalancesStopPollingByPollingToken: jest.fn(), }; }); +jest.mock('../../../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const mockSelectedInternalAccount = getSelectedInternalAccountFromMockState( mockState as unknown as MetaMaskReduxState, ); @@ -103,7 +114,7 @@ const render = (balance = ETH_BALANCE, chainId = CHAIN_IDS.MAINNET) => { }, }, }; - const store = configureStore(state); + const store = configureMockStore([thunk])(state); return renderWithProvider( undefined} showTokensLinks />, store, @@ -111,6 +122,22 @@ const render = (balance = ETH_BALANCE, chainId = CHAIN_IDS.MAINNET) => { }; describe('AssetList', () => { + (useMultiPolling as jest.Mock).mockClear(); + + // Mock implementation for useMultiPolling + (useMultiPolling as jest.Mock).mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem: string) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); (useIsOriginalNativeTokenSymbol as jest.Mock).mockReturnValue(true); (getTokenSymbol as jest.Mock).mockImplementation(async (address) => { @@ -126,13 +153,14 @@ describe('AssetList', () => { return null; }); - it('renders AssetList component and shows Refresh List text', async () => { + it('renders AssetList component and shows AssetList control bar', async () => { await act(async () => { render(); }); await waitFor(() => { - expect(screen.getByText('Refresh list')).toBeInTheDocument(); + expect(screen.getByTestId('sort-by-popover-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('import-token-button')).toBeInTheDocument(); }); }); }); diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 1d265ffd4a4a..9ed6b718cbd8 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -13,8 +13,8 @@ import { getMultichainSelectedAccountCachedBalance, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getMultichainIsBitcoin, - ///: END:ONLY_INCLUDE_IF getMultichainSelectedAccountCachedBalanceIsZero, + ///: END:ONLY_INCLUDE_IF } from '../../../../selectors/multichain'; import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; @@ -23,11 +23,7 @@ import { MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; import DetectedToken from '../../detected-token/detected-token'; -import { - DetectedTokensBanner, - ImportTokenLink, - ReceiveModal, -} from '../../../multichain'; +import { DetectedTokensBanner, ReceiveModal } from '../../../multichain'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -88,11 +84,10 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { setShowReceiveModal(true); }; + ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const balanceIsZero = useSelector( getMultichainSelectedAccountCachedBalanceIsZero, ); - - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const isBuyableChain = useSelector(getIsNativeTokenBuyable); const shouldShowBuy = isBuyableChain && balanceIsZero; const isBtc = useSelector(getMultichainIsBitcoin); @@ -113,7 +108,7 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { margin={4} /> )} - + } onTokenClick={(chainId: string, tokenAddress: string) => { @@ -144,13 +139,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ) : null ///: END:ONLY_INCLUDE_IF } - {shouldShowTokensLinks && ( - 0 && !balanceIsZero ? 0 : 2} - /> - )} {showDetectedTokens && ( )} diff --git a/ui/components/app/assets/asset-list/import-control/import-control.tsx b/ui/components/app/assets/asset-list/import-control/import-control.tsx index ca5e3a09051a..d3a9bfd9ccb7 100644 --- a/ui/components/app/assets/asset-list/import-control/import-control.tsx +++ b/ui/components/app/assets/asset-list/import-control/import-control.tsx @@ -1,5 +1,5 @@ -import React, { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React from 'react'; +import { useSelector } from 'react-redux'; import { ButtonBase, ButtonBaseSize, @@ -9,21 +9,18 @@ import { BackgroundColor, TextColor, } from '../../../../../helpers/constants/design-system'; -import { showImportTokensModal } from '../../../../../store/actions'; -import { MetaMetricsContext } from '../../../../../contexts/metametrics'; -import { - MetaMetricsEventCategory, - MetaMetricsEventName, -} from '../../../../../../shared/constants/metametrics'; + import { getMultichainIsEvm } from '../../../../../selectors/multichain'; type AssetListControlBarProps = { showTokensLinks?: boolean; + onClick?: () => void; }; -const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { - const dispatch = useDispatch(); - const trackEvent = useContext(MetaMetricsContext); +const AssetListControlBar = ({ + showTokensLinks, + onClick, +}: AssetListControlBarProps) => { const isEvm = useSelector(getMultichainIsEvm); // NOTE: Since we can parametrize it now, we keep the original behavior // for EVM assets @@ -35,19 +32,10 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { data-testid="import-token-button" disabled={!shouldShowTokensLinks} size={ButtonBaseSize.Sm} - startIconName={IconName.Add} + startIconName={IconName.MoreVertical} backgroundColor={BackgroundColor.backgroundDefault} color={TextColor.textDefault} - onClick={() => { - dispatch(showImportTokensModal()); - trackEvent({ - category: MetaMetricsEventCategory.Navigation, - event: MetaMetricsEventName.TokenImportButtonClicked, - properties: { - location: 'HOME', - }, - }); - }} + onClick={onClick} /> ); }; 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 8e216b5ed6c2..7e2bd48e7c4b 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 @@ -22,7 +22,7 @@ import { getCurrencySymbol } from '../../../../../helpers/utils/common.util'; // inspired from ui/components/multichain/network-list-item // should probably be broken out into component library type SelectableListItemProps = { - isSelected: boolean; + isSelected?: boolean; onClick?: React.MouseEventHandler; testId?: string; children: ReactNode; @@ -39,7 +39,7 @@ export const SelectableListItem = ({ diff --git a/ui/components/multichain/account-overview/account-overview-btc.test.tsx b/ui/components/multichain/account-overview/account-overview-btc.test.tsx index fa32883ce773..b171840a540e 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-btc.test.tsx @@ -3,6 +3,7 @@ import mockState from '../../../../test/data/mock-state.json'; import configureStore from '../../../store/store'; import { renderWithProvider } from '../../../../test/jest/rendering'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { AccountOverviewBtc, AccountOverviewBtcProps, @@ -11,8 +12,20 @@ import { jest.mock('../../../store/actions', () => ({ tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), })); +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + const defaultProps: AccountOverviewBtcProps = { defaultHomeActiveTabName: null, onTabClick: jest.fn(), @@ -22,7 +35,16 @@ const defaultProps: AccountOverviewBtcProps = { const render = (props: AccountOverviewBtcProps = defaultProps) => { const store = configureStore({ - metamask: mockState.metamask, + metamask: { + ...mockState.metamask, + preferences: { + ...mockState.metamask.preferences, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + }, }); return renderWithProvider(, store); @@ -30,7 +52,10 @@ const render = (props: AccountOverviewBtcProps = defaultProps) => { describe('AccountOverviewBtc', () => { beforeEach(() => { - setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); + setBackgroundConnection({ + setBridgeFeatureFlags: jest.fn(), + tokenBalancesStartPolling: jest.fn(), + } as never); }); it('shows only Tokens and Activity tabs', () => { diff --git a/ui/components/multichain/account-overview/account-overview-eth.test.tsx b/ui/components/multichain/account-overview/account-overview-eth.test.tsx index f9b53665e753..a886608ec169 100644 --- a/ui/components/multichain/account-overview/account-overview-eth.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-eth.test.tsx @@ -3,6 +3,7 @@ import mockState from '../../../../test/data/mock-state.json'; import configureStore from '../../../store/store'; import { renderWithProvider } from '../../../../test/jest/rendering'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { AccountOverviewEth, AccountOverviewEthProps, @@ -11,11 +12,32 @@ import { jest.mock('../../../store/actions', () => ({ tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), })); +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + const render = (props: AccountOverviewEthProps) => { const store = configureStore({ - metamask: mockState.metamask, + metamask: { + ...mockState.metamask, + preferences: { + ...mockState.metamask.preferences, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + }, }); return renderWithProvider(, store); @@ -23,7 +45,10 @@ const render = (props: AccountOverviewEthProps) => { describe('AccountOverviewEth', () => { beforeEach(() => { - setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); + setBackgroundConnection({ + setBridgeFeatureFlags: jest.fn(), + tokenBalancesStartPolling: jest.fn(), + } as never); }); it('shows all tabs', () => { const { queryByTestId } = render({ diff --git a/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap b/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap deleted file mode 100644 index e8fa1e945dba..000000000000 --- a/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Import Token Link should match snapshot for goerli chainId 1`] = ` -
- -
-`; - -exports[`Import Token Link should match snapshot for mainnet chainId 1`] = ` -
- -
-`; diff --git a/ui/components/multichain/import-token-link/import-token-link.stories.tsx b/ui/components/multichain/import-token-link/import-token-link.stories.tsx deleted file mode 100644 index 85c09e2184dd..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { StoryFn, Meta } from '@storybook/react'; -import { ImportTokenLink } from '.'; - -export default { - title: 'Components/Multichain/ImportTokenLink', - component: ImportTokenLink, -} as Meta; - -export const DefaultStory: StoryFn = () => ( - -); - -DefaultStory.storyName = 'Default'; diff --git a/ui/components/multichain/import-token-link/import-token-link.test.js b/ui/components/multichain/import-token-link/import-token-link.test.js deleted file mode 100644 index 2539f98e6491..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import configureMockStore from 'redux-mock-store'; -import { fireEvent, screen } from '@testing-library/react'; -import { detectTokens } from '../../../store/actions'; -import { renderWithProvider } from '../../../../test/lib/render-helpers'; -import { CHAIN_IDS } from '../../../../shared/constants/network'; -import { mockNetworkState } from '../../../../test/stub/networks'; -import ImportControl from '../../app/assets/asset-list/import-control'; -import { ImportTokenLink } from '.'; - -const mockPushHistory = jest.fn(); - -jest.mock('react-router-dom', () => { - const original = jest.requireActual('react-router-dom'); - return { - ...original, - useLocation: jest.fn(() => ({ search: '' })), - useHistory: () => ({ - push: mockPushHistory, - }), - }; -}); - -jest.mock('../../../store/actions.ts', () => ({ - detectTokens: jest.fn().mockImplementation(() => ({ type: 'DETECT_TOKENS' })), - showImportTokensModal: jest - .fn() - .mockImplementation(() => ({ type: 'UI_IMPORT_TOKENS_POPOVER_OPEN' })), -})); - -describe('Import Token Link', () => { - it('should match snapshot for goerli chainId', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - const { container } = renderWithProvider(, store); - - expect(container).toMatchSnapshot(); - }); - - it('should match snapshot for mainnet chainId', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - }, - }; - - const store = configureMockStore()(mockState); - - const { container } = renderWithProvider(, store); - - expect(container).toMatchSnapshot(); - }); - - it('should detectTokens when clicking refresh', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - renderWithProvider(, store); // should this be RefreshTokenLink? - - const refreshList = screen.getByTestId('refresh-list-button'); - fireEvent.click(refreshList); - - expect(detectTokens).toHaveBeenCalled(); - }); - - it('should push import token route', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - renderWithProvider(, store); - - const importToken = screen.getByTestId('import-token-button'); - fireEvent.click(importToken); - - expect(screen.getByTestId('import-token-button')).toBeInTheDocument(); - }); -}); diff --git a/ui/components/multichain/import-token-link/import-token-link.tsx b/ui/components/multichain/import-token-link/import-token-link.tsx deleted file mode 100644 index 022369dd5002..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { useDispatch } from 'react-redux'; -import classnames from 'classnames'; -import { - ButtonLink, - IconName, - Box, - ButtonLinkSize, -} from '../../component-library'; -import { AlignItems, Display } from '../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../hooks/useI18nContext'; -import { detectTokens } from '../../../store/actions'; -import type { BoxProps } from '../../component-library/box'; -import type { ImportTokenLinkProps } from './import-token-link.types'; - -export const ImportTokenLink: React.FC = ({ - className = '', - ...props -}): JSX.Element => { - const t = useI18nContext(); - const dispatch = useDispatch(); - - return ( - )} - > - - dispatch(detectTokens())} - > - {t('refreshList')} - - - - ); -}; diff --git a/ui/components/multichain/import-token-link/import-token-link.types.ts b/ui/components/multichain/import-token-link/import-token-link.types.ts deleted file mode 100644 index 0ad9350ca998..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { StyleUtilityProps } from '../../component-library/box'; - -// TODO: Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ImportTokenLinkProps extends StyleUtilityProps { - /** * Additional class name for the ImportTokenLink component. */ - className?: string; -} diff --git a/ui/components/multichain/import-token-link/index.ts b/ui/components/multichain/import-token-link/index.ts deleted file mode 100644 index db139df76890..000000000000 --- a/ui/components/multichain/import-token-link/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ImportTokenLink } from './import-token-link'; diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index 5ecc5a2a7d3a..10b0a61b3eef 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -7,7 +7,6 @@ export { ActivityListItem } from './activity-list-item'; export { AppHeader } from './app-header'; export { DetectedTokensBanner } from './detected-token-banner'; export { GlobalMenu } from './global-menu'; -export { ImportTokenLink } from './import-token-link'; export { TokenListItem } from './token-list-item'; export { AddressCopyButton } from './address-copy-button'; export { ConnectedSiteMenu } from './connected-site-menu'; diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 6c08c9130761..1b1823728a2f 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -15,6 +15,7 @@ import { useIsOriginalNativeTokenSymbol } from '../../hooks/useIsOriginalNativeT import { createMockInternalAccount } from '../../../test/jest/mocks'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { mockNetworkState } from '../../../test/stub/networks'; +import useMultiPolling from '../../hooks/useMultiPolling'; import Routes from '.'; const middlewares = [thunk]; @@ -45,8 +46,20 @@ jest.mock('../../store/actions', () => ({ hideNetworkDropdown: () => mockHideNetworkDropdown, tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), })); +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + jest.mock('../../ducks/bridge/actions', () => ({ setBridgeFeatureFlags: () => jest.fn(), })); @@ -79,6 +92,11 @@ jest.mock( '../../components/app/metamask-template-renderer/safe-component-list', ); +jest.mock('../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const render = async (route, state) => { const store = configureMockStore(middlewares)({ ...mockSendState, @@ -97,6 +115,26 @@ const render = async (route, state) => { describe('Routes Component', () => { useIsOriginalNativeTokenSymbol.mockImplementation(() => true); + beforeEach(() => { + // Clear previous mock implementations + useMultiPolling.mockClear(); + + // Mock implementation for useMultiPolling + useMultiPolling.mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); + }); + afterEach(() => { mockShowNetworkDropdown.mockClear(); mockHideNetworkDropdown.mockClear(); @@ -126,6 +164,9 @@ describe('Routes Component', () => { }, tokenNetworkFilter: {}, }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', + }, }, send: { ...mockSendState.send, @@ -160,12 +201,27 @@ describe('toast display', () => { ...mockState, metamask: { ...mockState.metamask, + allTokens: {}, announcements: {}, approvalFlows: [], completedOnboarding: true, usedNetworks: [], pendingApprovals: {}, pendingApprovalCount: 0, + preferences: { + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', + }, swapsState: { swapsFeatureIsLive: true }, newPrivacyPolicyToastShownDate: date, }, @@ -184,6 +240,17 @@ describe('toast display', () => { swapsState: { swapsFeatureIsLive: true }, newPrivacyPolicyToastShownDate: new Date(0), newPrivacyPolicyToastClickedOrClosed: true, + preferences: { + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, surveyLinkLastClickedOrClosed: true, showPrivacyPolicyToast: false, showSurveyToast: false, @@ -193,6 +260,9 @@ describe('toast display', () => { unconnectedAccount: true, }, termsOfUseLastAgreed: new Date(0).getTime(), + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', + }, internalAccounts: { accounts: { [mockAccount.id]: mockAccount, From 8bcd777d0b060267d19ae1c3d25a36ab99f6ea87 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Wed, 20 Nov 2024 18:46:57 -0600 Subject: [PATCH 024/148] fix: PortfolioView: Remove pausedChainIds from selector (#28552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** After speaking with Infura, we no longer need this `pausedChainIds` property from the remote API. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28552?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. No manual testing, simply removing property and its tests ## **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/selectors/selectors.js | 18 ++++-------------- ui/selectors/selectors.test.js | 26 -------------------------- 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 7e4d04eeb3de..3c49befb6dd3 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2240,30 +2240,23 @@ export const getAllEnabledNetworks = createDeepEqualSelector( ); export const getChainIdsToPoll = createDeepEqualSelector( - getPreferences, getNetworkConfigurationsByChainId, getCurrentChainId, - (preferences, networkConfigurations, currentChainId) => { - const { pausedChainIds = [] } = preferences; - + (networkConfigurations, currentChainId) => { if (!process.env.PORTFOLIO_VIEW) { return [currentChainId]; } return Object.keys(networkConfigurations).filter( - (chainId) => - !TEST_CHAINS.includes(chainId) && !pausedChainIds.includes(chainId), + (chainId) => !TEST_CHAINS.includes(chainId), ); }, ); export const getNetworkClientIdsToPoll = createDeepEqualSelector( - getPreferences, getNetworkConfigurationsByChainId, getCurrentChainId, - (preferences, networkConfigurations, currentChainId) => { - const { pausedChainIds = [] } = preferences; - + (networkConfigurations, currentChainId) => { if (!process.env.PORTFOLIO_VIEW) { const networkConfiguration = networkConfigurations[currentChainId]; return [ @@ -2275,10 +2268,7 @@ export const getNetworkClientIdsToPoll = createDeepEqualSelector( return Object.entries(networkConfigurations).reduce( (acc, [chainId, network]) => { - if ( - !TEST_CHAINS.includes(chainId) && - !pausedChainIds.includes(chainId) - ) { + if (!TEST_CHAINS.includes(chainId)) { acc.push( network.rpcEndpoints[network.defaultRpcEndpointIndex] .networkClientId, diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index 85180dec45f4..d3799885eaf6 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -873,7 +873,6 @@ describe('Selectors', () => { it('returns only non-test chain IDs', () => { const chainIds = selectors.getChainIdsToPoll({ metamask: { - preferences: { pausedChainIds: [] }, networkConfigurationsByChainId, selectedNetworkClientId: 'mainnet', }, @@ -884,18 +883,6 @@ describe('Selectors', () => { CHAIN_IDS.LINEA_MAINNET, ]); }); - - it('does not return paused chain IDs', () => { - const chainIds = selectors.getChainIdsToPoll({ - metamask: { - preferences: { pausedChainIds: [CHAIN_IDS.LINEA_MAINNET] }, - networkConfigurationsByChainId, - selectedNetworkClientId: 'mainnet', - }, - }); - expect(Object.values(chainIds)).toHaveLength(1); - expect(chainIds).toStrictEqual([CHAIN_IDS.MAINNET]); - }); }); describe('#getNetworkClientIdsToPoll', () => { @@ -933,7 +920,6 @@ describe('Selectors', () => { it('returns only non-test chain IDs', () => { const chainIds = selectors.getNetworkClientIdsToPoll({ metamask: { - preferences: { pausedChainIds: [] }, networkConfigurationsByChainId, selectedNetworkClientId: 'mainnet', }, @@ -941,18 +927,6 @@ describe('Selectors', () => { expect(Object.values(chainIds)).toHaveLength(2); expect(chainIds).toStrictEqual(['mainnet', 'linea-mainnet']); }); - - it('does not return paused chain IDs', () => { - const chainIds = selectors.getNetworkClientIdsToPoll({ - metamask: { - preferences: { pausedChainIds: [CHAIN_IDS.LINEA_MAINNET] }, - networkConfigurationsByChainId, - selectedNetworkClientId: 'mainnet', - }, - }); - expect(Object.values(chainIds)).toHaveLength(1); - expect(chainIds).toStrictEqual(['mainnet']); - }); }); describe('#isHardwareWallet', () => { From f455a6ebe1dc56e53865f94e89f74fc15fcb430a Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 21 Nov 2024 12:01:31 +0000 Subject: [PATCH 025/148] feat: Better handle very long names in the name component (#28560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Truncates long names (>15 characters). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28560?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3630 ## **Manual testing steps** 1. Trigger a new confirmation 2. Add a long petname, by clicking an address and writing it in the input field 3. The name should be truncated with an ellipsis. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-11-20 at 11 24 21 ## **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/name/__snapshots__/name.test.tsx.snap | 62 +++++++++++++++++++ ui/components/app/name/name.test.tsx | 18 ++++++ ui/components/app/name/name.tsx | 10 ++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/ui/components/app/name/__snapshots__/name.test.tsx.snap b/ui/components/app/name/__snapshots__/name.test.tsx.snap index 379c00faab11..7e3f98c8576e 100644 --- a/ui/components/app/name/__snapshots__/name.test.tsx.snap +++ b/ui/components/app/name/__snapshots__/name.test.tsx.snap @@ -24,6 +24,68 @@ exports[`Name renders address with image 1`] = ` `; +exports[`Name renders address with long saved name 1`] = ` +
+
+
+
+
+
+ + + + + +
+
+
+

+ Very long and l... +

+
+
+
+`; + exports[`Name renders address with no saved name 1`] = `
{ expect(container).toMatchSnapshot(); }); + it('renders address with long saved name', () => { + useDisplayNameMock.mockReturnValue({ + name: "Very long and length saved name that doesn't seem to end, really.", + hasPetname: true, + }); + + const { container } = renderWithProvider( + , + store, + ); + + expect(container).toMatchSnapshot(); + }); + it('renders address with image', () => { useDisplayNameMock.mockReturnValue({ name: SAVED_NAME_MOCK, diff --git a/ui/components/app/name/name.tsx b/ui/components/app/name/name.tsx index 2097d21faf07..75f2a2f79c15 100644 --- a/ui/components/app/name/name.tsx +++ b/ui/components/app/name/name.tsx @@ -9,7 +9,7 @@ import { NameType } from '@metamask/name-controller'; import classnames from 'classnames'; import { toChecksumAddress } from 'ethereumjs-util'; import { Box, Icon, IconName, IconSize, Text } from '../../component-library'; -import { shortenAddress } from '../../../helpers/utils/util'; +import { shortenAddress, shortenString } from '../../../helpers/utils/util'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { MetaMetricsEventCategory, @@ -103,6 +103,12 @@ const Name = memo( }, [setModalOpen]); const formattedValue = formatValue(value, type); + const formattedName = shortenString(name || undefined, { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + }); const hasDisplayName = Boolean(name); return ( @@ -135,7 +141,7 @@ const Name = memo( )} {hasDisplayName ? ( - {name} + {formattedName} ) : ( From ba11881e3f1a7963db6d54534c647e60a834ea85 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 21 Nov 2024 13:23:10 +0100 Subject: [PATCH 026/148] fix: fix account list item for portfolio view (#28598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes account list item wit Portfolio view feature flag. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28598?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28592 ## **Manual testing steps** Make sure you run the steps with AND without the PORTFOLIO_VIEW flag 1. Go to settings => Security and privacy => and disable "Show balance and token price checker" 2. Open account picker make sure you see crypto values 3. Enable the setting again and you should see Fiat values ## **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. --- .../tests/settings/account-token-list.spec.js | 37 +++++++++++++++++++ .../account-list-item/account-list-item.js | 4 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/test/e2e/tests/settings/account-token-list.spec.js b/test/e2e/tests/settings/account-token-list.spec.js index ddd905501f50..ec24119dd44b 100644 --- a/test/e2e/tests/settings/account-token-list.spec.js +++ b/test/e2e/tests/settings/account-token-list.spec.js @@ -116,4 +116,41 @@ describe('Settings', function () { }, ); }); + + it('Should show crypto value when price checker setting is off', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withConversionRateEnabled() + .withShowFiatTestnetEnabled() + .withPreferencesControllerShowNativeTokenAsMainBalanceDisabled() + .withConversionRateDisabled() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + testSpecificMock: mockInfuraResponses, + }, + async ({ driver }) => { + await unlockWallet(driver); + + await driver.clickElement('[data-testid="popover-close"]'); + await driver.clickElement( + '[data-testid="account-overview__asset-tab"]', + ); + + const tokenListAmount = await driver.findElement( + '.eth-overview__primary-container', + ); + await driver.delay(1000); + assert.equal(await tokenListAmount.getText(), '25\nETH'); + + await driver.clickElement('[data-testid="account-menu-icon"]'); + const accountTokenValue = await driver.waitForSelector( + '.multichain-account-list-item .multichain-account-list-item__asset', + ); + + assert.equal(await accountTokenValue.getText(), '25ETH'); + }, + ); + }); }); diff --git a/ui/components/multichain/account-list-item/account-list-item.js b/ui/components/multichain/account-list-item/account-list-item.js index 6be8e3f67f0b..207b8fa0fe58 100644 --- a/ui/components/multichain/account-list-item/account-list-item.js +++ b/ui/components/multichain/account-list-item/account-list-item.js @@ -143,7 +143,7 @@ const AccountListItem = ({ let balanceToTranslate; if (isEvmNetwork) { balanceToTranslate = - shouldShowFiat || isTestnet || !process.env.PORTFOLIO_VIEW + !shouldShowFiat || isTestnet || !process.env.PORTFOLIO_VIEW ? account.balance : totalFiatBalance; } else { @@ -345,7 +345,7 @@ const AccountListItem = ({ type={PRIMARY} showFiat={showFiat} isAggregatedFiatOverviewBalance={ - !isTestnet && process.env.PORTFOLIO_VIEW + !isTestnet && process.env.PORTFOLIO_VIEW && shouldShowFiat } data-testid="first-currency-display" privacyMode={privacyMode} From c20ac9958b395559a42a00f3b05ca94806c0a21e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 21 Nov 2024 13:25:27 +0000 Subject: [PATCH 027/148] chore: upgrade transaction controller to increase polling rate (#28452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Upgrade `@metamask/transaction-controller` to increase the pending transaction polling rate. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28452?quickstart=1) ## **Related issues** Fixes: [#3629](https://github.com/MetaMask/MetaMask-planning/issues/3629) ## **Manual testing steps** Regression of pending transaction polling including: - Alternate Chains - Queued Transactions - Sequential Transactions - Multiple Transactions ## **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 | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 7117462c957f..fb6218ebb5c6 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "@metamask/snaps-sdk": "^6.11.0", "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", - "@metamask/transaction-controller": "^38.3.0", + "@metamask/transaction-controller": "^39.1.0", "@metamask/user-operation-controller": "^13.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.12", diff --git a/yarn.lock b/yarn.lock index 0147c3106fa4..527af97c08f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6741,9 +6741,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^38.3.0": - version: 38.3.0 - resolution: "@metamask/transaction-controller@npm:38.3.0" +"@metamask/transaction-controller@npm:^39.1.0": + version: 39.1.0 + resolution: "@metamask/transaction-controller@npm:39.1.0" dependencies: "@ethereumjs/common": "npm:^3.2.0" "@ethereumjs/tx": "npm:^4.2.0" @@ -6752,7 +6752,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/base-controller": "npm:^7.0.2" - "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/nonce-tracker": "npm:^6.0.0" @@ -6766,11 +6766,11 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.23.9 - "@metamask/accounts-controller": ^18.0.0 + "@metamask/accounts-controller": ^19.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/gas-fee-controller": ^22.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/f4e8e3a1a31e3e62b0d1a59bbe15ebfa4dc3e4cf077fb95c1815c00661c60ef4676046c49f57eab9749cd31d3e55ac3fed7bc247e3f5a3d459f2dcb03998633d + checksum: 10/9c18f01167ca70556323190c3b3b8df29d5c1d45846e6d50208b49d27bd3d361ab89f103d5f4a784bbc70cee3e5ef595bab8cf568926c790236d32ace07a1283 languageName: node linkType: hard @@ -26893,7 +26893,7 @@ __metadata: "@metamask/solana-wallet-snap": "npm:^0.1.9" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:8.13.0" - "@metamask/transaction-controller": "npm:^38.3.0" + "@metamask/transaction-controller": "npm:^39.1.0" "@metamask/user-operation-controller": "npm:^13.0.0" "@metamask/utils": "npm:^10.0.1" "@ngraveio/bc-ur": "npm:^1.1.12" From 6023787fc836cdc297f970608fda0073f66a07a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Tavares?= Date: Thu, 21 Nov 2024 15:10:11 +0000 Subject: [PATCH 028/148] chore: centralize redesigned confirmation decision logic (#28445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR centralizes the redesigned confirmation decision logic to improve code organization and reduce duplication across the codebase. Currently, redesign confirmation decision logic is scattered across multiple files, making it harder to maintain and more prone to inconsistencies. #### Motivation The existing implementation has several issues: 1. Duplicate logic for handling confirmation decisions across different transaction types 2. Prevent inconsistent handling of redesigned confirmation flows Key changes: - Move supported redesigned confirmation decision logic to the shared dir (to be used both in the `ui` and `app` bundles) - Updated signature metrics tracking to support developer mode enabled Types of changes: - Code style update (changes that do not affect the meaning of the code) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28445?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Ensure that when either the experimental confirmations redesign toggle, developer option confirmations redesign toggle or the env ENABLE_CONFIRMATIONS_REDESIGN is enabled the we are presenting the new redesign confirmations. ## **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 | 20 +- .../createRPCMethodTrackingMiddleware.test.js | 13 ++ app/scripts/lib/transaction/metrics.ts | 32 +-- app/scripts/metamask-controller.js | 43 ++-- shared/lib/confirmation.utils.test.ts | 208 ++++++++++++++++++ shared/lib/confirmation.utils.ts | 169 ++++++++++++++ .../components/confirm/footer/footer.tsx | 7 +- .../components/confirm/header/header-info.tsx | 11 +- .../components/confirm/nav/nav.tsx | 5 +- .../scroll-to-bottom/scroll-to-bottom.tsx | 7 +- .../usePendingTransactionAlerts.ts | 11 +- .../useSigningOrSubmittingAlerts.ts | 11 +- .../hooks/alerts/useBlockaidAlerts.ts | 14 +- .../hooks/useCurrentConfirmation.ts | 55 ++--- ui/pages/confirmations/utils/confirm.test.ts | 22 -- ui/pages/confirmations/utils/confirm.ts | 33 --- ui/pages/routes/routes.component.js | 19 +- 17 files changed, 485 insertions(+), 195 deletions(-) create mode 100644 shared/lib/confirmation.utils.test.ts create mode 100644 shared/lib/confirmation.utils.ts diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.js index 27358d18f543..5ca2374db05b 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.js @@ -27,7 +27,7 @@ import { } from '../../../ui/helpers/utils/metrics'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { REDESIGN_APPROVAL_TYPES } from '../../../ui/pages/confirmations/utils/confirm'; +import { shouldUseRedesignForSignatures } from '../../../shared/lib/confirmation.utils'; import { getSnapAndHardwareInfoForMetrics } from './snap-keyring/metrics'; /** @@ -196,6 +196,7 @@ function finalizeSignatureFragment( * @param {Function} opts.getAccountType * @param {Function} opts.getDeviceModel * @param {Function} opts.isConfirmationRedesignEnabled + * @param {Function} opts.isRedesignedConfirmationsDeveloperEnabled * @param {RestrictedControllerMessenger} opts.snapAndHardwareMessenger * @param {number} [opts.globalRateLimitTimeout] - time, in milliseconds, of the sliding * time window that should limit the number of method calls tracked to globalRateLimitMaxAmount. @@ -214,6 +215,7 @@ export default function createRPCMethodTrackingMiddleware({ getAccountType, getDeviceModel, isConfirmationRedesignEnabled, + isRedesignedConfirmationsDeveloperEnabled, snapAndHardwareMessenger, appStateController, metaMetricsController, @@ -315,13 +317,15 @@ export default function createRPCMethodTrackingMiddleware({ req.securityAlertResponse.description; } - const isConfirmationRedesign = - isConfirmationRedesignEnabled() && - REDESIGN_APPROVAL_TYPES.find( - (type) => type === MESSAGE_TYPE_TO_APPROVAL_TYPE[method], - ); - - if (isConfirmationRedesign) { + if ( + shouldUseRedesignForSignatures({ + approvalType: MESSAGE_TYPE_TO_APPROVAL_TYPE[method], + isRedesignedSignaturesUserSettingEnabled: + isConfirmationRedesignEnabled(), + isRedesignedConfirmationsDeveloperEnabled: + isRedesignedConfirmationsDeveloperEnabled(), + }) + ) { eventProperties.ui_customizations = [ ...(eventProperties.ui_customizations || []), MetaMetricsEventUiCustomization.RedesignedConfirmation, diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index 9ebb3f92130b..1949ca7f876e 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -118,6 +118,7 @@ const createHandler = (opts) => appStateController, metaMetricsController, isConfirmationRedesignEnabled: () => false, + isRedesignedConfirmationsDeveloperEnabled: () => false, ...opts, }); @@ -217,10 +218,22 @@ describe('createRPCMethodTrackingMiddleware', () => { }); describe('participateInMetaMetrics is set to true', () => { + const originalEnableConfirmationRedesign = + process.env.ENABLE_CONFIRMATION_REDESIGN; + beforeEach(() => { metaMetricsController.setParticipateInMetaMetrics(true); }); + beforeAll(() => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterAll(() => { + process.env.ENABLE_CONFIRMATION_REDESIGN = + originalEnableConfirmationRedesign; + }); + it(`should immediately track a ${MetaMetricsEventName.SignatureRequested} event`, async () => { const req = { id: MOCK_ID, diff --git a/app/scripts/lib/transaction/metrics.ts b/app/scripts/lib/transaction/metrics.ts index 3cdc14619b0d..375a8bd4a8ab 100644 --- a/app/scripts/lib/transaction/metrics.ts +++ b/app/scripts/lib/transaction/metrics.ts @@ -43,16 +43,12 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../../ui/helpers/utils/metrics'; -import { - REDESIGN_DEV_TRANSACTION_TYPES, - REDESIGN_USER_TRANSACTION_TYPES, - // TODO: Remove restricted import - // eslint-disable-next-line import/no-restricted-paths -} from '../../../../ui/pages/confirmations/utils'; + import { getSnapAndHardwareInfoForMetrics, type SnapAndHardwareMessenger, } from '../snap-keyring/metrics'; +import { shouldUseRedesignForTransactions } from '../../../../shared/lib/confirmation.utils'; export type TransactionMetricsRequest = { createEventFragment: ( @@ -996,23 +992,15 @@ async function buildEventFragmentProperties({ if (simulationFails) { uiCustomizations.push(MetaMetricsEventUiCustomization.GasEstimationFailed); } - const isRedesignedConfirmationsDeveloperSettingEnabled = - transactionMetricsRequest.getIsRedesignedConfirmationsDeveloperEnabled() || - process.env.ENABLE_CONFIRMATION_REDESIGN === 'true'; - const isRedesignedTransactionsUserSettingEnabled = - transactionMetricsRequest.getRedesignedTransactionsEnabled(); - - if ( - (isRedesignedConfirmationsDeveloperSettingEnabled && - REDESIGN_DEV_TRANSACTION_TYPES.includes( - transactionMeta.type as TransactionType, - )) || - (isRedesignedTransactionsUserSettingEnabled && - REDESIGN_USER_TRANSACTION_TYPES.includes( - transactionMeta.type as TransactionType, - )) - ) { + const isRedesignedForTransaction = shouldUseRedesignForTransactions({ + transactionMetadataType: transactionMeta.type as TransactionType, + isRedesignedTransactionsUserSettingEnabled: + transactionMetricsRequest.getRedesignedTransactionsEnabled(), + isRedesignedConfirmationsDeveloperEnabled: + transactionMetricsRequest.getIsRedesignedConfirmationsDeveloperEnabled(), + }); + if (isRedesignedForTransaction) { uiCustomizations.push( MetaMetricsEventUiCustomization.RedesignedConfirmation, ); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 0d0344d4e7e3..e297a016fd76 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -5789,16 +5789,14 @@ export default class MetamaskController extends EventEmitter { ), ); - const isConfirmationRedesignEnabled = () => { - return this.preferencesController.state.preferences - .redesignedConfirmationsEnabled; - }; - engine.push( createRPCMethodTrackingMiddleware({ getAccountType: this.getAccountType.bind(this), getDeviceModel: this.getDeviceModel.bind(this), - isConfirmationRedesignEnabled, + isConfirmationRedesignEnabled: + this.isConfirmationRedesignEnabled.bind(this), + isRedesignedConfirmationsDeveloperEnabled: + this.isConfirmationRedesignDeveloperEnabled.bind(this), snapAndHardwareMessenger: this.controllerMessenger.getRestricted({ name: 'SnapAndHardwareMessenger', allowedActions: [ @@ -6436,6 +6434,21 @@ export default class MetamaskController extends EventEmitter { }); } + isConfirmationRedesignEnabled() { + return this.preferencesController.state.preferences + .redesignedConfirmationsEnabled; + } + + isTransactionsRedesignEnabled() { + return this.preferencesController.state.preferences + .redesignedTransactionsEnabled; + } + + isConfirmationRedesignDeveloperEnabled() { + return this.preferencesController.state.preferences + .isRedesignedConfirmationsDeveloperEnabled; + } + /** * The chain list is fetched live at runtime, falling back to a cache. * This preseeds the cache at startup with a static list provided at build. @@ -6620,14 +6633,10 @@ export default class MetamaskController extends EventEmitter { txHash, ); }, - getRedesignedConfirmationsEnabled: () => { - return this.preferencesController.state.preferences - .redesignedConfirmationsEnabled; - }, - getRedesignedTransactionsEnabled: () => { - return this.preferencesController.state.preferences - .redesignedTransactionsEnabled; - }, + getRedesignedConfirmationsEnabled: + this.isConfirmationRedesignEnabled.bind(this), + getRedesignedTransactionsEnabled: + this.isTransactionsRedesignEnabled.bind(this), getMethodData: (data) => { if (!data) { return null; @@ -6645,10 +6654,8 @@ export default class MetamaskController extends EventEmitter { this.provider, ); }, - getIsRedesignedConfirmationsDeveloperEnabled: () => { - return this.preferencesController.state.preferences - .isRedesignedConfirmationsDeveloperEnabled; - }, + getIsRedesignedConfirmationsDeveloperEnabled: + this.isConfirmationRedesignDeveloperEnabled.bind(this), getIsConfirmationAdvancedDetailsOpen: () => { return this.preferencesController.state.preferences .showConfirmationAdvancedDetails; diff --git a/shared/lib/confirmation.utils.test.ts b/shared/lib/confirmation.utils.test.ts new file mode 100644 index 000000000000..552d78827a2e --- /dev/null +++ b/shared/lib/confirmation.utils.test.ts @@ -0,0 +1,208 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { + shouldUseRedesignForTransactions, + shouldUseRedesignForSignatures, +} from './confirmation.utils'; + +describe('confirmation.utils', () => { + describe('shouldUseRedesignForTransactions', () => { + const supportedTransactionTypes = [ + TransactionType.contractInteraction, + TransactionType.deployContract, + TransactionType.tokenMethodApprove, + TransactionType.tokenMethodIncreaseAllowance, + TransactionType.tokenMethodSetApprovalForAll, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, + ]; + + const unsupportedTransactionType = TransactionType.swap; + + describe('when user setting is enabled', () => { + it('should return true for supported transaction types', () => { + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer mode disabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported transaction types', () => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: unsupportedTransactionType, + isRedesignedTransactionsUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer mode disabled + }), + ).toBe(false); + }); + }); + + describe('when developer mode is enabled', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return true for supported transaction types when ENABLE_CONFIRMATION_REDESIGN is true', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported transaction types when developer setting is enabled', () => { + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported transaction types even if developer mode is enabled', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: unsupportedTransactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(false); + }); + }); + + describe('when both user setting and developer mode are disabled', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return false for all transaction types', () => { + [...supportedTransactionTypes, unsupportedTransactionType].forEach( + (transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(false); + }, + ); + }); + }); + }); + + describe('shouldUseRedesignForSignatures', () => { + const originalEnv = process.env; + + const supportedSignatureApprovalTypes = [ + ApprovalType.EthSignTypedData, + ApprovalType.PersonalSign, + ]; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return true for supported approval types when user setting is enabled', () => { + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported approval types when developer mode is enabled via env', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported approval types when developer setting is enabled', () => { + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported approval types', () => { + const unsupportedApprovalType = ApprovalType.AddEthereumChain; + + expect( + shouldUseRedesignForSignatures({ + approvalType: unsupportedApprovalType, + isRedesignedSignaturesUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(false); + }); + + it('should return false when both user setting and developer mode are disabled', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/shared/lib/confirmation.utils.ts b/shared/lib/confirmation.utils.ts new file mode 100644 index 000000000000..24c5f258a5d0 --- /dev/null +++ b/shared/lib/confirmation.utils.ts @@ -0,0 +1,169 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { ApprovalType } from '@metamask/controller-utils'; + +/* eslint-disable jsdoc/require-param, jsdoc/check-param-names */ + +/** List of signature approval types that support the redesigned confirmation flow */ +const REDESIGN_SIGNATURE_APPROVAL_TYPES = [ + ApprovalType.EthSignTypedData, + ApprovalType.PersonalSign, +]; + +/** List of transaction types that support the redesigned confirmation flow for users */ +const REDESIGN_USER_TRANSACTION_TYPES = [ + TransactionType.contractInteraction, + TransactionType.deployContract, + TransactionType.tokenMethodApprove, + TransactionType.tokenMethodIncreaseAllowance, + TransactionType.tokenMethodSetApprovalForAll, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, +]; + +/** List of transaction types that support the redesigned confirmation flow for developers */ +const REDESIGN_DEV_TRANSACTION_TYPES = [...REDESIGN_USER_TRANSACTION_TYPES]; + +/** + * Determines whether to use the redesigned confirmation flow for a given transaction + * based on user settings and developer mode + * + * @param opts.transactionMetadataType - The type of transaction to check + * @param opts.isRedesignedTransactionsUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param opts.isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + */ +export function shouldUseRedesignForTransactions({ + transactionMetadataType, + isRedesignedTransactionsUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, +}: { + transactionMetadataType?: TransactionType; + isRedesignedTransactionsUserSettingEnabled: boolean; + isRedesignedConfirmationsDeveloperEnabled: boolean; +}): boolean { + return ( + shouldUseRedesignForTransactionsUserMode( + isRedesignedTransactionsUserSettingEnabled, + transactionMetadataType, + ) || + shouldUseRedesignForTransactionsDeveloperMode( + isRedesignedConfirmationsDeveloperEnabled, + transactionMetadataType, + ) + ); +} + +/** + * Determines whether to use the redesigned confirmation flow for a given signature + * based on user settings and developer mode + * + * @param opts.approvalType - The type of signature approval to check + * @param opts.isRedesignedSignaturesUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param opts.isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + */ +export function shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, +}: { + approvalType?: ApprovalType; + isRedesignedSignaturesUserSettingEnabled: boolean; + isRedesignedConfirmationsDeveloperEnabled: boolean; +}): boolean { + const isRedesignedConfirmationsDeveloperSettingEnabled = + process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || + isRedesignedConfirmationsDeveloperEnabled; + + if (!isCorrectSignatureApprovalType(approvalType)) { + return false; + } + + return ( + isRedesignedSignaturesUserSettingEnabled || + isRedesignedConfirmationsDeveloperSettingEnabled + ); +} + +/** + * Checks if an redesign approval type is supported for signature redesign + * + * @param approvalType - The type of approval to check + */ +export function isCorrectSignatureApprovalType( + approvalType?: ApprovalType, +): boolean { + if (!approvalType) { + return false; + } + + return REDESIGN_SIGNATURE_APPROVAL_TYPES.includes(approvalType); +} + +/** + * Checks if a redesigned transaction type is supported in developer mode + * + * @param transactionMetadataType - The type of transaction to check + */ +export function isCorrectDeveloperTransactionType( + transactionMetadataType?: TransactionType, +): boolean { + if (!transactionMetadataType) { + return false; + } + + return REDESIGN_DEV_TRANSACTION_TYPES.includes(transactionMetadataType); +} + +/** + * Checks if a redesigned transaction type is supported in user mode + * + * @param transactionMetadataType - The type of transaction to check + */ +function isCorrectUserTransactionType( + transactionMetadataType?: TransactionType, +): boolean { + if (!transactionMetadataType) { + return false; + } + + return REDESIGN_USER_TRANSACTION_TYPES.includes(transactionMetadataType); +} + +/** + * Determines if the redesigned confirmation flow should be used for transactions + * when in developer mode + * + * @param isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + * @param transactionMetadataType - The type of transaction to check + */ +function shouldUseRedesignForTransactionsDeveloperMode( + isRedesignedConfirmationsDeveloperEnabled: boolean, + transactionMetadataType?: TransactionType, +): boolean { + const isDeveloperModeEnabled = + process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || + isRedesignedConfirmationsDeveloperEnabled; + + return ( + isDeveloperModeEnabled && + isCorrectDeveloperTransactionType(transactionMetadataType) + ); +} + +/** + * Determines if the redesigned confirmation flow should be used for transactions + * when in user mode + * + * @param isRedesignedTransactionsUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param transactionMetadataType - The type of transaction to check + */ +function shouldUseRedesignForTransactionsUserMode( + isRedesignedTransactionsUserSettingEnabled: boolean, + transactionMetadataType?: TransactionType, +): boolean { + return ( + isRedesignedTransactionsUserSettingEnabled && + isCorrectUserTransactionType(transactionMetadataType) + ); +} diff --git a/ui/pages/confirmations/components/confirm/footer/footer.tsx b/ui/pages/confirmations/components/confirm/footer/footer.tsx index a37812899ec9..a9aea54c03f7 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.tsx @@ -34,13 +34,13 @@ import { selectUseTransactionSimulations } from '../../../selectors/preferences' import { isPermitSignatureRequest, isSIWESignatureRequest, - REDESIGN_DEV_TRANSACTION_TYPES, } from '../../../utils'; import { useConfirmContext } from '../../../context/confirm'; import { getConfirmationSender } from '../utils'; import { MetaMetricsEventLocation } from '../../../../../../shared/constants/metametrics'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; export type OnCancelHandler = ({ location, @@ -218,9 +218,10 @@ const Footer = () => { return; } - const isTransactionConfirmation = REDESIGN_DEV_TRANSACTION_TYPES.find( - (type) => type === currentConfirmation?.type, + const isTransactionConfirmation = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); + if (isTransactionConfirmation) { const mergeTxDataWithNonce = (transactionData: TransactionMeta) => customNonceValue diff --git a/ui/pages/confirmations/components/confirm/header/header-info.tsx b/ui/pages/confirmations/components/confirm/header/header-info.tsx index 9cc50b0fe676..03eabacef42e 100644 --- a/ui/pages/confirmations/components/confirm/header/header-info.tsx +++ b/ui/pages/confirmations/components/confirm/header/header-info.tsx @@ -1,4 +1,3 @@ -import { TransactionType } from '@metamask/transaction-controller'; import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; import { @@ -42,10 +41,8 @@ import { useConfirmContext } from '../../../context/confirm'; import { useBalance } from '../../../hooks/useBalance'; import useConfirmationRecipientInfo from '../../../hooks/useConfirmationRecipientInfo'; import { SignatureRequestType } from '../../../types/confirm'; -import { - isSignatureTransactionType, - REDESIGN_DEV_TRANSACTION_TYPES, -} from '../../../utils/confirm'; +import { isSignatureTransactionType } from '../../../utils/confirm'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; import { AdvancedDetailsButton } from './advanced-details-button'; const HeaderInfo = () => { @@ -89,8 +86,8 @@ const HeaderInfo = () => { trackEvent(event); } - const isShowAdvancedDetailsToggle = REDESIGN_DEV_TRANSACTION_TYPES.includes( - currentConfirmation?.type as TransactionType, + const isShowAdvancedDetailsToggle = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); return ( diff --git a/ui/pages/confirmations/components/confirm/nav/nav.tsx b/ui/pages/confirmations/components/confirm/nav/nav.tsx index de0637a9f641..4a58e1c364dd 100644 --- a/ui/pages/confirmations/components/confirm/nav/nav.tsx +++ b/ui/pages/confirmations/components/confirm/nav/nav.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { ApprovalType } from '@metamask/controller-utils'; import { QueueType } from '../../../../../../shared/constants/metametrics'; import { Box, @@ -34,7 +35,7 @@ import { pendingConfirmationsSortedSelector } from '../../../../../selectors'; import { rejectPendingApproval } from '../../../../../store/actions'; import { useConfirmContext } from '../../../context/confirm'; import { useQueuedConfirmationsEvent } from '../../../hooks/useQueuedConfirmationEvents'; -import { isSignatureApprovalRequest } from '../../../utils'; +import { isCorrectSignatureApprovalType } from '../../../../../../shared/lib/confirmation.utils'; const Nav = () => { const history = useHistory(); @@ -64,7 +65,7 @@ const Nav = () => { // "/confirm-transaction/" history.replace( `${CONFIRM_TRANSACTION_ROUTE}/${nextConfirmation.id}${ - isSignatureApprovalRequest(nextConfirmation) + isCorrectSignatureApprovalType(nextConfirmation.type as ApprovalType) ? SIGNATURE_REQUEST_PATH : '' }`, diff --git a/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx b/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx index c61053818923..61cb8f65563c 100644 --- a/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx +++ b/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx @@ -1,6 +1,5 @@ import React, { useContext, useEffect } from 'react'; import { useSelector } from 'react-redux'; -import { TransactionType } from '@metamask/transaction-controller'; import { Box, ButtonIcon, @@ -21,7 +20,7 @@ import { usePrevious } from '../../../../../hooks/usePrevious'; import { useScrollRequired } from '../../../../../hooks/useScrollRequired'; import { useConfirmContext } from '../../../context/confirm'; import { selectConfirmationAdvancedDetailsOpen } from '../../../selectors/preferences'; -import { REDESIGN_DEV_TRANSACTION_TYPES } from '../../../utils'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; type ContentProps = { /** @@ -51,8 +50,8 @@ const ScrollToBottom = ({ children }: ContentProps) => { offsetPxFromBottom: 0, }); - const isTransactionRedesign = REDESIGN_DEV_TRANSACTION_TYPES.includes( - currentConfirmation?.type as TransactionType, + const isTransactionRedesign = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); const showScrollToBottom = diff --git a/ui/pages/confirmations/hooks/alerts/transactions/usePendingTransactionAlerts.ts b/ui/pages/confirmations/hooks/alerts/transactions/usePendingTransactionAlerts.ts index 7430f3ff4fd0..5753b5329ea8 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/usePendingTransactionAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/usePendingTransactionAlerts.ts @@ -1,7 +1,4 @@ -import { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; @@ -9,9 +6,9 @@ import { submittedPendingTransactionsSelector } from '../../../../../selectors'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; -import { REDESIGN_DEV_TRANSACTION_TYPES } from '../../../utils'; import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; import { useConfirmContext } from '../../../context/confirm'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; export function usePendingTransactionAlerts(): Alert[] { const t = useI18nContext(); @@ -19,9 +16,7 @@ export function usePendingTransactionAlerts(): Alert[] { const { type } = currentConfirmation ?? ({} as TransactionMeta); const pendingTransactions = useSelector(submittedPendingTransactionsSelector); - const isValidType = REDESIGN_DEV_TRANSACTION_TYPES.includes( - type as TransactionType, - ); + const isValidType = isCorrectDeveloperTransactionType(type); const hasPendingTransactions = isValidType && Boolean(pendingTransactions.length); diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useSigningOrSubmittingAlerts.ts b/ui/pages/confirmations/hooks/alerts/transactions/useSigningOrSubmittingAlerts.ts index 11d9f1697a6e..3cb6c45b31e1 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useSigningOrSubmittingAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useSigningOrSubmittingAlerts.ts @@ -1,7 +1,4 @@ -import { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; @@ -9,8 +6,8 @@ import { getApprovedAndSignedTransactions } from '../../../../../selectors'; import { Severity } from '../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; -import { REDESIGN_DEV_TRANSACTION_TYPES } from '../../../utils'; import { useConfirmContext } from '../../../context/confirm'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; export function useSigningOrSubmittingAlerts(): Alert[] { const t = useI18nContext(); @@ -21,9 +18,7 @@ export function useSigningOrSubmittingAlerts(): Alert[] { getApprovedAndSignedTransactions, ); - const isValidType = REDESIGN_DEV_TRANSACTION_TYPES.includes( - type as TransactionType, - ); + const isValidType = isCorrectDeveloperTransactionType(type); const isSigningOrSubmitting = isValidType && signingOrSubmittingTransactions.length > 0; diff --git a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts index 2f26fcefbee9..30ab2e71947e 100644 --- a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts @@ -15,10 +15,8 @@ import { import { Alert } from '../../../../ducks/confirm-alerts/confirm-alerts'; import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { - SIGNATURE_TRANSACTION_TYPES, - REDESIGN_DEV_TRANSACTION_TYPES, -} from '../../utils'; +import { SIGNATURE_TRANSACTION_TYPES } from '../../utils'; +import { isCorrectDeveloperTransactionType } from '../../../../../shared/lib/confirmation.utils'; import { SecurityAlertResponse, SignatureRequestType, @@ -30,11 +28,6 @@ import { normalizeProviderAlert } from './utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const zlib = require('zlib'); -const SUPPORTED_TRANSACTION_TYPES = [ - ...SIGNATURE_TRANSACTION_TYPES, - ...REDESIGN_DEV_TRANSACTION_TYPES, -]; - const IGNORED_RESULT_TYPES = [ BlockaidResultType.Benign, BlockaidResultType.Loading, @@ -74,7 +67,8 @@ const useBlockaidAlerts = (): Alert[] => { signatureSecurityAlertResponse || transactionSecurityAlertResponse; const isTransactionTypeSupported = - SUPPORTED_TRANSACTION_TYPES.includes(transactionType); + isCorrectDeveloperTransactionType(transactionType) || + SIGNATURE_TRANSACTION_TYPES.includes(transactionType); const isResultTypeIgnored = IGNORED_RESULT_TYPES.includes( securityAlertResponse?.result_type as BlockaidResultType, diff --git a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts index cf5e8a1383a2..1771f807de25 100644 --- a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts +++ b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts @@ -1,8 +1,5 @@ import { ApprovalType } from '@metamask/controller-utils'; -import { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -17,10 +14,9 @@ import { } from '../../../selectors'; import { selectUnapprovedMessage } from '../../../selectors/signatures'; import { - REDESIGN_APPROVAL_TYPES, - REDESIGN_DEV_TRANSACTION_TYPES, - REDESIGN_USER_TRANSACTION_TYPES, -} from '../utils'; + shouldUseRedesignForSignatures, + shouldUseRedesignForTransactions, +} from '../../../../shared/lib/confirmation.utils'; /** * Determine the current confirmation based on the pending approvals and controller state. @@ -47,10 +43,6 @@ const useCurrentConfirmation = () => { getIsRedesignedConfirmationsDeveloperEnabled, ); - const isRedesignedConfirmationsDeveloperSettingEnabled = - process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || - isRedesignedConfirmationsDeveloperEnabled; - const pendingApproval = useSelector((state) => selectPendingApproval(state as ApprovalsMetaMaskState, confirmationId), ); @@ -64,37 +56,20 @@ const useCurrentConfirmation = () => { selectUnapprovedMessage(state, confirmationId), ); - const isCorrectUserTransactionType = REDESIGN_USER_TRANSACTION_TYPES.includes( - transactionMetadata?.type as TransactionType, - ); - - const isCorrectDeveloperTransactionType = - REDESIGN_DEV_TRANSACTION_TYPES.includes( - transactionMetadata?.type as TransactionType, - ); - - const isCorrectApprovalType = REDESIGN_APPROVAL_TYPES.includes( - pendingApproval?.type as ApprovalType, - ); - - const shouldUseRedesignForSignatures = - (isRedesignedSignaturesUserSettingEnabled && isCorrectApprovalType) || - (isRedesignedConfirmationsDeveloperSettingEnabled && isCorrectApprovalType); + const useRedesignedForSignatures = shouldUseRedesignForSignatures({ + approvalType: pendingApproval?.type as ApprovalType, + isRedesignedSignaturesUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, + }); - const shouldUseRedesignForTransactions = - (isRedesignedTransactionsUserSettingEnabled && - isCorrectUserTransactionType) || - (isRedesignedConfirmationsDeveloperSettingEnabled && - isCorrectDeveloperTransactionType); + const useRedesignedForTransaction = shouldUseRedesignForTransactions({ + transactionMetadataType: transactionMetadata?.type, + isRedesignedTransactionsUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, + }); - // If the developer toggle or the build time environment variable are enabled, - // all the signatures and transactions in development are shown. If the user - // facing feature toggles for signature or transactions are enabled, we show - // only confirmations that shipped (contained in `REDESIGN_APPROVAL_TYPES` and - // `REDESIGN_USER_TRANSACTION_TYPES` or `REDESIGN_DEV_TRANSACTION_TYPES` - // respectively). const shouldUseRedesign = - shouldUseRedesignForSignatures || shouldUseRedesignForTransactions; + useRedesignedForSignatures || useRedesignedForTransaction; return useMemo(() => { if (!shouldUseRedesign) { diff --git a/ui/pages/confirmations/utils/confirm.test.ts b/ui/pages/confirmations/utils/confirm.test.ts index b1f6494ca627..9a8b3d1a0f8a 100644 --- a/ui/pages/confirmations/utils/confirm.test.ts +++ b/ui/pages/confirmations/utils/confirm.test.ts @@ -1,5 +1,3 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; -import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import { @@ -11,7 +9,6 @@ import { SignatureRequestType } from '../types/confirm'; import { isOrderSignatureRequest, isPermitSignatureRequest, - isSignatureApprovalRequest, isSignatureTransactionType, parseSanitizeTypedDataMessage, isValidASCIIURL, @@ -22,25 +19,6 @@ const typedDataMsg = '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"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"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; describe('confirm util', () => { - describe('isSignatureApprovalRequest', () => { - it('returns true for signature approval requests', () => { - const result = isSignatureApprovalRequest({ - type: ApprovalType.PersonalSign, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as ApprovalRequest); - expect(result).toStrictEqual(true); - }); - it('returns false for request not of type signature', () => { - const result = isSignatureApprovalRequest({ - type: ApprovalType.Transaction, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as ApprovalRequest); - expect(result).toStrictEqual(false); - }); - }); - describe('parseSanitizeTypedDataMessage', () => { it('parses and sanitizes data passed correctly', () => { const result = parseSanitizeTypedDataMessage(typedDataMsg); diff --git a/ui/pages/confirmations/utils/confirm.ts b/ui/pages/confirmations/utils/confirm.ts index a007ca0aa0b2..f464f51e8159 100644 --- a/ui/pages/confirmations/utils/confirm.ts +++ b/ui/pages/confirmations/utils/confirm.ts @@ -1,7 +1,4 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; -import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; -import { Json } from '@metamask/utils'; import { PRIMARY_TYPES_ORDER, PRIMARY_TYPES_PERMIT, @@ -11,36 +8,6 @@ import { sanitizeMessage } from '../../../helpers/utils/util'; import { Confirmation, SignatureRequestType } from '../types/confirm'; import { TYPED_SIGNATURE_VERSIONS } from '../constants'; -export const REDESIGN_APPROVAL_TYPES = [ - ApprovalType.EthSignTypedData, - ApprovalType.PersonalSign, -]; - -export const REDESIGN_USER_TRANSACTION_TYPES = [ - TransactionType.contractInteraction, - TransactionType.deployContract, - TransactionType.tokenMethodApprove, - TransactionType.tokenMethodIncreaseAllowance, - TransactionType.tokenMethodSetApprovalForAll, - TransactionType.tokenMethodTransfer, - TransactionType.tokenMethodTransferFrom, - TransactionType.tokenMethodSafeTransferFrom, - TransactionType.simpleSend, -]; - -export const REDESIGN_DEV_TRANSACTION_TYPES = [ - ...REDESIGN_USER_TRANSACTION_TYPES, -]; - -const SIGNATURE_APPROVAL_TYPES = [ - ApprovalType.PersonalSign, - ApprovalType.EthSignTypedData, -]; - -export const isSignatureApprovalRequest = ( - request: ApprovalRequest>, -) => SIGNATURE_APPROVAL_TYPES.includes(request.type as ApprovalType); - export const SIGNATURE_TRANSACTION_TYPES = [ TransactionType.personalSign, TransactionType.signTypedData, diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index 3a2bc2989182..ef0ebbfa9ee3 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -115,9 +115,9 @@ import NftFullImage from '../../components/app/assets/nfts/nft-details/nft-full- import CrossChainSwap from '../bridge'; import { ToastMaster } from '../../components/app/toast-master/toast-master'; import { - REDESIGN_APPROVAL_TYPES, - REDESIGN_DEV_TRANSACTION_TYPES, -} from '../confirmations/utils'; + isCorrectDeveloperTransactionType, + isCorrectSignatureApprovalType, +} from '../../../shared/lib/confirmation.utils'; import { getConnectingLabel, hideAppHeader, @@ -471,13 +471,12 @@ export default class Routes extends Component { const pendingApproval = pendingApprovals.find( (approval) => approval.id === confirmationId, ); - const isCorrectApprovalType = REDESIGN_APPROVAL_TYPES.includes( + const isCorrectApprovalType = isCorrectSignatureApprovalType( pendingApproval?.type, ); - const isCorrectDeveloperTransactionType = - REDESIGN_DEV_TRANSACTION_TYPES.includes( - transactionsMetadata[confirmationId]?.type, - ); + const isCorrectTransactionType = isCorrectDeveloperTransactionType( + transactionsMetadata[confirmationId]?.type, + ); let isLoadingShown = isLoading && @@ -485,7 +484,7 @@ export default class Routes extends Component { // In the redesigned screens, we hide the general loading spinner and the // loading states are on a component by component basis. !isCorrectApprovalType && - !isCorrectDeveloperTransactionType; + !isCorrectTransactionType; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) isLoadingShown = @@ -499,7 +498,7 @@ export default class Routes extends Component { // In the redesigned screens, we hide the general loading spinner and the // loading states are on a component by component basis. !isCorrectApprovalType && - !isCorrectDeveloperTransactionType; + !isCorrectTransactionType; ///: END:ONLY_INCLUDE_IF return ( From a1b6ba7cce7b5fbbd9b50aace2e821384a0edcef Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:18:23 -0500 Subject: [PATCH 029/148] feat: cross chain swaps - tx submit (#27262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR implements the following: 1. Submit bridge transaction for normal transactions 3. Submit bridge transaction for native gas tokens that don't require approval 4. Submit bridge transaction for ERC20s that require approval Does not fully: 1. Submit bridge transaction for smart transactions - You can submit an STX, but the status screens don't make the most sense right now. - Improved STX support be handled by https://github.com/MetaMask/metamask-extension/pull/28460 and https://github.com/MetaMask/core/pull/4918/ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27262?quickstart=1) ## **Related issues** - Targeting: #27522 ## **Manual testing steps** 1. Go to Bridge 2. Fill in source/dest token and amounts 3. Get a quote 4. Execute Bridge ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/b73f917d-e3e4-468b-b0fa-29f41f559488 ## **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 + .../bridge/bridge-controller.test.ts | 49 +- .../controllers/bridge/bridge-controller.ts | 38 +- app/scripts/controllers/bridge/constants.ts | 7 + app/scripts/controllers/bridge/types.ts | 2 + app/scripts/metamask-controller.js | 5 + shared/constants/bridge.ts | 4 + shared/constants/metametrics.ts | 1 + shared/constants/security-provider.ts | 2 + test/data/bridge/dummy-quotes.ts | 4005 +++++++++++++++++ ui/ducks/bridge/actions.ts | 13 +- ui/ducks/bridge/selectors.ts | 2 +- ui/ducks/bridge/utils.ts | 47 + ui/hooks/useTransactionDisplayData.js | 9 + ui/pages/bridge/bridge.util.ts | 24 + ui/pages/bridge/hooks/useAddToken.ts | 84 + ui/pages/bridge/hooks/useHandleApprovalTx.ts | 94 + ui/pages/bridge/hooks/useHandleBridgeTx.ts | 48 + ui/pages/bridge/hooks/useHandleTx.ts | 79 + .../hooks/useSubmitBridgeTransaction.test.tsx | 485 ++ .../hooks/useSubmitBridgeTransaction.ts | 49 + ui/pages/bridge/index.tsx | 3 + ui/pages/bridge/prepare/bridge-cta-button.tsx | 11 +- ui/pages/bridge/types.ts | 5 +- ui/pages/swaps/hooks/useSwapsFeatureFlags.ts | 14 + ui/pages/swaps/swaps.util.ts | 2 +- ui/store/actions.ts | 7 +- 27 files changed, 5082 insertions(+), 11 deletions(-) create mode 100644 test/data/bridge/dummy-quotes.ts create mode 100644 ui/ducks/bridge/utils.ts create mode 100644 ui/pages/bridge/hooks/useAddToken.ts create mode 100644 ui/pages/bridge/hooks/useHandleApprovalTx.ts create mode 100644 ui/pages/bridge/hooks/useHandleBridgeTx.ts create mode 100644 ui/pages/bridge/hooks/useHandleTx.ts create mode 100644 ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx create mode 100644 ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts create mode 100644 ui/pages/swaps/hooks/useSwapsFeatureFlags.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a59a48a21afe..bdba7b1214a3 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -847,6 +847,10 @@ "bridge": { "message": "Bridge" }, + "bridgeApproval": { + "message": "Approve $1 for bridge", + "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be bridged. $1 is the symbol of a token that has been approved." + }, "bridgeCalculatingAmount": { "message": "Calculating..." }, diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 07224ae43c2e..8369d910f78b 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -22,11 +22,27 @@ const messengerMock = { publish: jest.fn(), } as unknown as jest.Mocked; +jest.mock('@ethersproject/contracts', () => { + return { + Contract: jest.fn(() => ({ + allowance: jest.fn(() => '100000000000000000000'), + })), + }; +}); + +jest.mock('@ethersproject/providers', () => { + return { + Web3Provider: jest.fn(), + }; +}); + describe('BridgeController', function () { let bridgeController: BridgeController; beforeAll(function () { - bridgeController = new BridgeController({ messenger: messengerMock }); + bridgeController = new BridgeController({ + messenger: messengerMock, + }); }); beforeEach(() => { @@ -43,6 +59,18 @@ describe('BridgeController', function () { 'extension-support': true, 'src-network-allowlist': [10, 534352], 'dest-network-allowlist': [137, 42161], + 'approval-gas-multiplier': { + '137': 1.1, + '42161': 1.2, + '10': 1.3, + '534352': 1.4, + }, + 'bridge-gas-multiplier': { + '137': 2.1, + '42161': 2.2, + '10': 2.3, + '534352': 2.4, + }, }); nock(BRIDGE_API_BASE_URL) .get('/getTokens?chainId=10') @@ -507,7 +535,10 @@ describe('BridgeController', function () { bridgeController, 'startPollingByNetworkClientId', ); - messengerMock.call.mockReturnValueOnce({ address: '0x123' } as never); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); bridgeController.updateBridgeQuoteRequestParams({ srcChainId: 1, @@ -536,4 +567,18 @@ describe('BridgeController', function () { }), ); }); + + describe('getBridgeERC20Allowance', () => { + it('should return the atomic allowance of the ERC20 token contract', async () => { + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + const allowance = await bridgeController.getBridgeERC20Allowance( + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + '0xa', + ); + expect(allowance).toBe('100000000000000000000'); + }); + }); }); diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 00d28b044457..2518e9caa9bd 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -1,7 +1,11 @@ -import { StateMetadata } from '@metamask/base-controller'; import { add0x, Hex } from '@metamask/utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { NetworkClientId } from '@metamask/network-controller'; +import { StateMetadata } from '@metamask/base-controller'; +import { Contract } from '@ethersproject/contracts'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { Web3Provider } from '@ethersproject/providers'; +import { BigNumber } from '@ethersproject/bignumber'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -25,6 +29,7 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, REFRESH_INTERVAL_MS, RequestStatus, + METABRIDGE_CHAIN_TO_ADDRESS_MAP, } from './constants'; import { BridgeControllerState, @@ -60,6 +65,8 @@ export default class BridgeController extends StaticIntervalPollingController< this.setIntervalLength(REFRESH_INTERVAL_MS); + this.#abortController = new AbortController(); + // Register action handlers this.messagingSystem.registerActionHandler( `${BRIDGE_CONTROLLER_NAME}:setBridgeFeatureFlags`, this.setBridgeFeatureFlags.bind(this), @@ -80,6 +87,10 @@ export default class BridgeController extends StaticIntervalPollingController< `${BRIDGE_CONTROLLER_NAME}:resetState`, this.resetState.bind(this), ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:getBridgeERC20Allowance`, + this.getBridgeERC20Allowance.bind(this), + ); } _executePoll = async ( @@ -277,4 +288,29 @@ export default class BridgeController extends StaticIntervalPollingController< chainId, ); } + + /** + * + * @param contractAddress - The address of the ERC20 token contract + * @param chainId - The hex chain ID of the bridge network + * @returns The atomic allowance of the ERC20 token contract + */ + getBridgeERC20Allowance = async ( + contractAddress: string, + chainId: Hex, + ): Promise => { + const provider = this.#getSelectedNetworkClient()?.provider; + if (!provider) { + throw new Error('No provider found'); + } + + const web3Provider = new Web3Provider(provider); + const contract = new Contract(contractAddress, abiERC20, web3Provider); + const { address: walletAddress } = this.#getSelectedAccount(); + const allowance = await contract.allowance( + walletAddress, + METABRIDGE_CHAIN_TO_ADDRESS_MAP[chainId], + ); + return BigNumber.from(allowance).toString(); + }; } diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index aa2d74f0a08e..ec60f8e6a0a4 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -1,4 +1,7 @@ import { zeroAddress } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; +import { METABRIDGE_ETHEREUM_ADDRESS } from '../../../../shared/constants/bridge'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { BridgeControllerState, BridgeFeatureFlagsKey } from './types'; export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; @@ -36,3 +39,7 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { quotesLoadingStatus: undefined, quotesRefreshCount: 0, }; + +export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record = { + [CHAIN_IDS.MAINNET]: METABRIDGE_ETHEREUM_ADDRESS, +}; diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 2ab9ef8e7a37..577a9fa99836 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -53,6 +53,7 @@ export enum BridgeUserAction { export enum BridgeBackgroundAction { SET_FEATURE_FLAGS = 'setBridgeFeatureFlags', RESET_STATE = 'resetState', + GET_BRIDGE_ERC20_ALLOWANCE = 'getBridgeERC20Allowance', } type BridgeControllerAction = { @@ -64,6 +65,7 @@ type BridgeControllerAction = { type BridgeControllerActions = | BridgeControllerAction | BridgeControllerAction + | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e297a016fd76..676595dfbff3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3985,6 +3985,11 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger, `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.RESET_STATE}`, ), + [BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE]: + this.controllerMessenger.call.bind( + this.controllerMessenger, + `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE}`, + ), [BridgeUserAction.SELECT_SRC_NETWORK]: this.controllerMessenger.call.bind( this.controllerMessenger, `${BRIDGE_CONTROLLER_NAME}:${BridgeUserAction.SELECT_SRC_NETWORK}`, diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index e87b0689777f..ed3b21c6a581 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -20,3 +20,7 @@ export const BRIDGE_API_BASE_URL = process.env.BRIDGE_USE_DEV_APIS : BRIDGE_PROD_API_BASE_URL; export const BRIDGE_CLIENT_ID = 'extension'; + +export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; +export const METABRIDGE_ETHEREUM_ADDRESS = + '0x0439e60F02a8900a951603950d8D4527f400C3f1'; diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 8688b8cfa8ae..760e3c26a31f 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -952,6 +952,7 @@ export enum MetaMetricsNetworkEventSource { Dapp = 'dapp', DeprecatedNetworkModal = 'deprecated_network_modal', NewAddNetworkFlow = 'new_add_network_flow', + Bridge = 'bridge', } export enum MetaMetricsSwapsEventSource { diff --git a/shared/constants/security-provider.ts b/shared/constants/security-provider.ts index c7c4a8df3b2c..82f8814da9da 100644 --- a/shared/constants/security-provider.ts +++ b/shared/constants/security-provider.ts @@ -112,6 +112,8 @@ export const SECURITY_PROVIDER_EXCLUDED_TRANSACTION_TYPES = [ TransactionType.swap, TransactionType.swapApproval, TransactionType.swapAndSend, + TransactionType.bridgeApproval, + TransactionType.bridge, ]; export const LOADING_SECURITY_ALERT_RESPONSE: SecurityAlertResponse = { diff --git a/test/data/bridge/dummy-quotes.ts b/test/data/bridge/dummy-quotes.ts new file mode 100644 index 000000000000..3328960a9b73 --- /dev/null +++ b/test/data/bridge/dummy-quotes.ts @@ -0,0 +1,4005 @@ +export const DummyQuotesNoApproval = { + OP_0_005_ETH_TO_ARB: [ + { + quote: { + requestId: 'be448070-7849-4d14-bb35-8dcdaf7a4d69', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4927504629714929', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4927504629714929', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000006c5a39b10ad191481350ef776ac5fe6ef47965741f6f7a4734bf784bf3ae3f24520000a4b100149ae8681b4efd66f30743ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000000000000a98f352a08c48ebdbb94ced425b06cf909d74f41e3dfea3c72061a0d88423d867545801fa572404f63d531757a740fd1fd0f12a89217856e4d59f771328fd4bb1c', + gasLimit: 155983, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: '7e33348d-726c-4f91-b8a0-152828c565ff', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4955000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4955000000000000', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11e6cbb0321441', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011e6cbb032144100000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000003e414d53077000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002005828fdd30895f9a246394c8876a30c0a6debe54a9aaed574de790d6e9fe2c1f60000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000002352785194410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080c2800c127d224651f5dd852ca8a4abd8a3804aff686dcb794b566b7ea694865f1edd9965ed1810678d845410548d5cae2acc3f8ea98c936e7e566923c1229d1c', + gasLimit: 515457, + }, + estimatedProcessingTimeInSeconds: 51, + }, + { + quote: { + requestId: 'f49a25e4-e396-40d8-a2bf-95ef5ec03d9f', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4852705984263432', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['hop'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'hop', + displayName: 'Hop', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4852705984263432', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000006ef81a18e1e432c289dc0d1a670b78e8bbf9aa350000000000000000000000006ef81a18e1e432c289dc0d1a670b78e8bbf9aa35000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000044161be542b8c35a6e235f7b26c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000a4b1000000000000000000005eb7c7b0c1c6000000000000000000113dac153d909300000000000000000000000000000000000000000000000000000000d7e5c567b37ba289b97e1a2eb3cda9ebd9811a004255d1fe44b1ccb372b6b41c3aea5aa0d70ad19378485c6e31e10df0eb3a7f957c8441d6474853e81acd4a991b', + gasLimit: 338772, + }, + estimatedProcessingTimeInSeconds: 56, + }, + { + quote: { + requestId: '625e7eb4-065c-4661-90d1-d94f6eb4adcc', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + srcTokenAmount: '4956250000000000', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + destTokenAmount: '4852928026153929', + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['hop'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'hop', + displayName: 'Hop', + icon: 'https://bridgelogos.s3.ap-south-1.amazonaws.com/hop.png', + }, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + srcAmount: '4956250000000000', + destAmount: '4852928026153929', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000014800000011c8a6285e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000086ca30bef97fb651b8d866d45503684b90cb331200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000060dcd8031258000000000000000000000000000000000000000000000000001185250b108f80000000000000000000000000000000000000000000000000000001924963f1dd00000000000000000000000000000000000000000000000000112728d1e75e79000000000000000000000000000000000000000000000000000001924963f1dd00000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000007d6791da46aae617c18c7b5987f2f25e8bc35083c3c973e71bfa0f7bd70088b0558f63bef3e55bc881d14bd276f41690d864e3d0380bfd4ac557b8b9dde896c51c', + gasLimit: 414453, + }, + estimatedProcessingTimeInSeconds: 60, + }, + ], +}; + +export const DummyQuotesWithApproval = { + ETH_11_USDC_TO_ARB: [ + { + quote: { + requestId: '0cd5caf6-9844-465b-89ad-9c89b639f432', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10876521', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10876521', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + gasLimit: 209923, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: 'f197aa3f-a1ed-46fe-8d5f-80866a860a9b', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10803750', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10803750', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000001e4bab657d800000000000000000000000000000000000000000000000000000000000000202210951480e39a2501daae3e15f254f5431a326e0e6ceb775feb685843012458000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d627269646765000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5f6d5c0d29059e2e6f6c5f03c44aa894aa0b2d888b46f8844d855a89c83b372148e183ab8a90043501865ea48326990c0783e195c85330e0f2f12db7953df991b', + gasLimit: 430753, + }, + estimatedProcessingTimeInSeconds: 989.412, + }, + { + quote: { + requestId: '4a954e96-a11d-4879-a1c0-54b24ae14ebb', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10903640', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10903640', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x1be3c54359bf', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000003e414d53077000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002002df432cfa7217ed9dc0aae2d324260c237970c8f9c97439d3faf2a000347d96e000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001be3c54359bf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f4e69f41f7ff5673a2df84fb3f246a59101ad7cc25219d596995a85d89c4a1684e9d9e9b2fb8775295686d52b5189794fc16a1dea348e5487eddeeaaebaec7441b', + gasLimit: 634343, + }, + estimatedProcessingTimeInSeconds: 197, + }, + { + quote: { + requestId: '32ad49ef-d710-4fb9-904a-a5d9cc95bd78', + srcChainId: 1, + srcAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + srcTokenAmount: '10903750', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10503750', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: 'USDC', + }, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '10903750', + destAmount: '10503750', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000e800000197b7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000002f0cd4583d78e68889d534cc878714e5c66fd0a2867f348f8e969d35f7fddfde3d108c064ffb227225c3e4faaf59aa5df6a967534e63143227b9a9a00c4dd6671c', + gasLimit: 285725, + }, + estimatedProcessingTimeInSeconds: 1020, + }, + ], + ARB_11_USDC_TO_ETH: [ + { + quote: { + requestId: 'edbef62a-d3e6-4b33-aad5-9cdb81f85f53', + srcChainId: 42161, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '10903750', + destChainId: 1, + destAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + destTokenAmount: '7821920', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 1, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: 'USDC', + }, + srcAmount: '10903750', + destAmount: '7821920', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 116676, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002f06660000000000000000000000000000000000000000000000000459142ce9a5e109850e867866af8a8e49ef715600981974e0b7f4e48c2e17044dd8921fac00ee87aae63468a52727e978cdf9debbe4cef61ae994d103092e881c', + gasLimit: 409790, + }, + estimatedProcessingTimeInSeconds: 1140, + }, + ], + ARB_11_USDC_TO_OP: [ + { + quote: { + requestId: 'dc63e7e6-dc9b-4aa8-80bb-714192ecd801', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10897534', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10897534', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000902340ab8fc7e68ebe53823219c5fe6ef47965741f6f7a4734bf784bf3ae3f2452af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000a660c60000000a0002067997b930636705a29bffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000ec7648fdf42336c0860c532fb076be8938251a3349d81d611f21e620d8e1ab9f5719d1f40aac527243e6883beb9d22af9a584b5fb419af9d0970804b97976cbb1c', + gasLimit: 734160, + }, + estimatedProcessingTimeInSeconds: 51, + }, + { + quote: { + requestId: 'dd718a05-ee10-4ec4-99f2-9bc3676640a1', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10903640', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10903640', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x132018b59ef1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000003e414d5307700000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200c564a18849d43f86f9dde5b38ad12801f40e4f909559b6195db0903f7398ef75000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000132018b59ef10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759f000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d28b64c753bb8cd4ac8863f4da513edc5d10a619ea4af9623a5f8fdb86f4296a4735ddf5981acb464fc0f35741dd26fb730eb9b5323586499631edf7870587ed1b', + gasLimit: 1232971, + }, + estimatedProcessingTimeInSeconds: 33, + }, + { + quote: { + requestId: 'f07aefdc-2be7-4c41-a0ab-87ac2ec16e3a', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10803750', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10803750', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000001e4bab657d80000000000000000000000000000000000000000000000000000000000000020cc4b6b89255288b4450ce670297679230238eca5fc5572f0b3fdca8fcd60081f000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d627269646765000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000454801e91c4c31ee115b5b1dadfa6abefc87bb88079879d7a68d6ac78ac9eb917678a2589c527e4819b0d2fd2bd3bf071d761f64f85ff8d77b90cca67ccbb5f01b', + gasLimit: 967319, + }, + estimatedProcessingTimeInSeconds: 1073, + }, + { + quote: { + requestId: 'ef05128f-c693-4d4a-adec-2b103f931a43', + srcChainId: 42161, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '10903750', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10703750', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '10903750', + destAmount: '10703750', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000030d40000000000000000000000000000000000000000000000000a798e0436c3787ba6e14fd44badd42b591886ba135ebdb6a441494c203e848c61c89a6a55b51f66c46023f5afda568446168e121a4acdfa1c60d7b8c31f0507c1c', + gasLimit: 783830, + }, + estimatedProcessingTimeInSeconds: 1080, + }, + { + quote: { + requestId: '4a15ec74e270a7ffc07aaad0bd59853e', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destChainId: 10, + destTokenAmount: '10900626', + destAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + }, + }, + bridgeId: 'squid', + bridges: ['axelar'], + steps: [ + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Pancakeswap V3', + displayName: 'Pancakeswap V3', + }, + srcAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009320510897841, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10903750', + destAmount: '10901792', + }, + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'axelar', + displayName: 'Axelar', + }, + srcAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009320510897841, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009026800874075, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10901792', + destAmount: '10901792', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009026800874075, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10901792', + destAmount: '10902122', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10902122', + destAmount: '10900626', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x3bcba906c4fc', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e737175696441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010e0000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf0000000000000000000000000000000000000000000000000000000000000f94846a1bc6000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000520000000000000000000000000000000000000000000000000000000000000056000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d6660000000000000000000000000000000000000000000000000000000000a660c60000000000000000000000000000000000000000000000000000000000a614fd0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c555344430000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086f7074696d69736d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078636531364636393337353532306162303133373763653742383866354241384334384638443636360000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000054000000000000000000000000000000000000000000000000000000000000006c000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000000000000a659200000000000000000000000000000000000000000000000000000000000a616460000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000064000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a65a6a0000000000000000000000000000000000000000000000000000000000a6107000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c3160700000000000000000000000000000000000000000000000000000000000000044a15ec74e270a7ffc07aaad0bd59853e000000000000000000000000000000004a15ec74e270a7ffc07aaad0bd59853e00000000000000000000000051039353c0cc77f171c42153ba91fbce7169a04b7d7aca7d0c6eeb24afe83301731e1c6247d68398068d4e73a5ab2d6e9abc76ed5a0fc5ddf1e6e340ee59bf3c1c', + gasLimit: 1491274, + }, + estimatedProcessingTimeInSeconds: 20, + }, + ], + OP_11_USDC_TO_ARB: [ + { + quote: { + requestId: '01fa78fd-ed49-42b3-ab0e-94c7108feea9', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10950676', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10950676', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f15c1311a1d882e68c5fe6ef47965741f6f7a4734bf784bf3ae3f24520b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000a7d8c00000a4b1000fee2f88fb6d2f6705a29bffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000e901efe0f8781a535ac71317aa666e079e2b867f1a0f1aae7db8afdf38c6f5a663f8638a8fa1f578ba4f5613853bb4ff7b831b0cfeccdcf47bb3e46feff039371c', + gasLimit: 196468, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: '04064397-73e1-44c0-a2ed-f938e5fe62f0', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10999889', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10999889', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x1f7968e0913f', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000003e414d5307700000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200f02847b1891a30122096ac33055ab7e4286cae991a862dbd35a181006f45b44d0000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001f7968e0913f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ca2568629f30c9780c694cf80af2799e1836b70fd6a221915056dacf1584d63a531f3049719aaeb572635cf719df4410100859da7b9033ba52805691cace86ef1b', + gasLimit: 619670, + }, + estimatedProcessingTimeInSeconds: 50, + }, + { + quote: { + requestId: '26d1486d-1979-4a24-b066-aa87ea6a9cbf', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10900000', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10900000', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000001e4bab657d80000000000000000000000000000000000000000000000000000000000000020af6b3cbb61978d928e0a59f45df6e973d36326c48aaa054412683aba82adbed60000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000e2d38d411f459d41f584802754a11be4cff908688ddd18e4d274400fd0de6d9ff9a6eeb426c654afae9fdb3f99e511bb288a7246018e54432afb60be63691b', + gasLimit: 415725, + }, + estimatedProcessingTimeInSeconds: 1134, + }, + { + quote: { + requestId: '544ebf94-e5d4-4553-8c64-af881b55c6ff', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '11000000', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10600000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '11000000', + destAmount: '10600000', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000000aaa45c155ccfb09c996a750c7d53dc975065d8841b58669213d7f345318d2395a5796ece3d8c0729e128bd0a33dface658f5846a3496dcfbeeca77394fe9b5a1b', + gasLimit: 290954, + }, + estimatedProcessingTimeInSeconds: 1500, + }, + { + quote: { + requestId: '389140aaaebab60eca1d15b4134c27fa', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destChainId: 42161, + destTokenAmount: '10996548', + destAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + }, + }, + bridgeId: 'squid', + bridges: ['axelar'], + steps: [ + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '11000000', + destAmount: '10999308', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10999308', + destAmount: '10996775', + }, + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'axelar', + displayName: 'Axelar', + }, + srcAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10996775', + destAmount: '10996775', + }, + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Pancakeswap V3', + displayName: 'Pancakeswap V3', + }, + srcAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10996775', + destAmount: '10996548', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x46366a86b7c6', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e737175696441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010e0000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000f94846a1bc60000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000940000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000009e0000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000000000000a7d8c00000000000000000000000000000000000000000000000000000000000a7914d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d6660000000000000000000000000000000000000000000000000000000000a7d60c0000000000000000000000000000000000000000000000000000000000a7876c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078636531364636393337353532306162303133373763653742383866354241384334384638443636360000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005700000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7cc270000000000000000000000000000000000000000000000000000000000a786890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000004389140aaaebab60eca1d15b4134c27fa00000000000000000000000000000000389140aaaebab60eca1d15b4134c27fa0000000000000000000000004f83a94a67ba9e24e0fee7799e54ddb2575d8454082cf56a7c0292457ce280df23aa57920d0bef49660cda25b12442e1fc410a745097e2ef21491f05082aa8661b', + gasLimit: 565594, + }, + estimatedProcessingTimeInSeconds: 20, + }, + ], +}; diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index f045c0dfbc12..a61d2fdcd8fd 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -7,11 +7,10 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; - import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; -import { MetaMaskReduxDispatch } from '../../store/store'; import { QuoteRequest } from '../../pages/bridge/types'; +import { MetaMaskReduxDispatch } from '../../store/store'; import { bridgeSlice } from './bridge'; const { @@ -86,3 +85,13 @@ export const updateQuoteRequestParams = (params: Partial) => { ); }; }; + +export const getBridgeERC20Allowance = async ( + contractAddress: string, + chainId: Hex, +): Promise => { + return await submitRequestToBackground( + BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE, + [contractAddress, chainId], + ); +}; diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 60704aa6f094..5624a0ec5569 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -26,7 +26,7 @@ import { calcTokenAmount } from '../../../shared/lib/transactions-controller-uti import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { BridgeState } from './bridge'; -type BridgeAppState = { +export type BridgeAppState = { metamask: NetworkState & { bridgeState: BridgeControllerState } & { useExternalServices: boolean; }; diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts new file mode 100644 index 000000000000..853c344310fe --- /dev/null +++ b/ui/ducks/bridge/utils.ts @@ -0,0 +1,47 @@ +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; +import { Numeric } from '../../../shared/modules/Numeric'; +import { TxData } from '../../pages/bridge/types'; +import { getTransaction1559GasFeeEstimates } from '../../pages/swaps/swaps.util'; + +// We don't need to use gas multipliers here because the gasLimit from Bridge API already included it +export const getHexMaxGasLimit = (gasLimit: number) => { + return new Numeric( + new BigNumber(gasLimit).toString(), + 10, + ).toPrefixedHexString() as Hex; +}; +export const getTxGasEstimates = async ({ + networkAndAccountSupports1559, + networkGasFeeEstimates, + txParams, + hexChainId, +}: { + networkAndAccountSupports1559: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + networkGasFeeEstimates: any; + txParams: TxData; + hexChainId: Hex; +}) => { + if (networkAndAccountSupports1559) { + const { estimatedBaseFeeGwei = '0' } = networkGasFeeEstimates; + const hexEstimatedBaseFee = decGWEIToHexWEI(estimatedBaseFeeGwei) as Hex; + const txGasFeeEstimates = await getTransaction1559GasFeeEstimates( + { + ...txParams, + chainId: hexChainId, + gasLimit: txParams.gasLimit?.toString(), + }, + hexEstimatedBaseFee, + hexChainId, + ); + return txGasFeeEstimates; + } + + return { + baseAndPriorityFeePerGas: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }; +}; diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index 4b551b318b62..b008f2fdaf7d 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -357,6 +357,15 @@ export function useTransactionDisplayData(transactionGroup) { category = TransactionGroupCategory.send; title = t('send'); subtitle = t('toAddress', [shortenAddress(recipientAddress)]); + } else if (type === TransactionType.bridgeApproval) { + title = t('bridgeApproval'); + category = TransactionGroupCategory.approval; + title = t('bridgeApproval', [primaryTransaction.sourceTokenSymbol]); + subtitle = origin; + subtitleContainsOrigin = true; + primarySuffix = primaryTransaction.sourceTokenSymbol; // TODO this will be undefined right now + } else if (type === TransactionType.bridge) { + title = t('bridge'); } else { dispatch( captureSingleException( diff --git a/ui/pages/bridge/bridge.util.ts b/ui/pages/bridge/bridge.util.ts index bbdddab53658..577b1827b160 100644 --- a/ui/pages/bridge/bridge.util.ts +++ b/ui/pages/bridge/bridge.util.ts @@ -1,4 +1,6 @@ +import { Contract } from '@ethersproject/contracts'; import { Hex, add0x } from '@metamask/utils'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import { BridgeFeatureFlagsKey, BridgeFeatureFlags, @@ -8,6 +10,8 @@ import { import { BRIDGE_API_BASE_URL, BRIDGE_CLIENT_ID, + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, } from '../../../shared/constants/bridge'; import { MINUTE } from '../../../shared/constants/time'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; @@ -26,6 +30,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { REFRESH_INTERVAL_MS } from '../../../app/scripts/controllers/bridge/constants'; +import { CHAIN_IDS } from '../../../shared/constants/network'; import { BridgeAsset, BridgeFlag, @@ -185,3 +190,22 @@ export async function fetchBridgeQuotes( }); return filteredQuotes; } +/** + * A function to return the txParam data for setting allowance to 0 for USDT on Ethereum + * + * @returns The txParam data that will reset allowance to 0, combine it with the approval tx params received from Bridge API + */ +export const getEthUsdtResetData = () => { + const UsdtContractInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) + .interface; + const data = UsdtContractInterface.encodeFunctionData('approve', [ + METABRIDGE_ETHEREUM_ADDRESS, + '0', + ]); + + return data; +}; + +export const isEthUsdt = (chainId: Hex, address: string) => + chainId === CHAIN_IDS.MAINNET && + address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); diff --git a/ui/pages/bridge/hooks/useAddToken.ts b/ui/pages/bridge/hooks/useAddToken.ts new file mode 100644 index 000000000000..597149b16e49 --- /dev/null +++ b/ui/pages/bridge/hooks/useAddToken.ts @@ -0,0 +1,84 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { QuoteResponse } from '../types'; +import { + getNetworkConfigurationsByChainId, + getSelectedNetworkClientId, +} from '../../../selectors'; +import { FEATURED_RPCS } from '../../../../shared/constants/network'; +import { addToken, addNetwork } from '../../../store/actions'; + +export default function useAddToken() { + const dispatch = useDispatch(); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const sourceNetworkClientId = useSelector(getSelectedNetworkClientId); + + const addSourceToken = (quoteResponse: QuoteResponse) => { + const { + address, + decimals, + symbol, + icon: image, + } = quoteResponse.quote.srcAsset; + dispatch( + addToken({ + address, + decimals, + symbol, + image, + networkClientId: sourceNetworkClientId, + }), + ); + }; + + const addDestToken = async (quoteResponse: QuoteResponse) => { + // Look up the destination chain + const hexDestChainId = new Numeric(quoteResponse.quote.destChainId, 10) + .toPrefixedHexString() + .toLowerCase() as `0x${string}`; + const foundDestNetworkConfig: NetworkConfiguration | undefined = + networkConfigurations[hexDestChainId]; + let addedDestNetworkConfig: NetworkConfiguration | undefined; + + // If user has not added the network in MetaMask, add it for them silently + if (!foundDestNetworkConfig) { + const featuredRpc = FEATURED_RPCS.find( + (rpc) => rpc.chainId === hexDestChainId, + ); + if (!featuredRpc) { + throw new Error('No featured RPC found'); + } + addedDestNetworkConfig = (await dispatch( + addNetwork(featuredRpc), + )) as unknown as NetworkConfiguration; + } + + const destNetworkConfig = foundDestNetworkConfig || addedDestNetworkConfig; + if (!destNetworkConfig) { + throw new Error('No destination network configuration found'); + } + + // Add the token after network is guaranteed to exist + const rpcEndpointIndex = destNetworkConfig.defaultRpcEndpointIndex; + const destNetworkClientId = + destNetworkConfig.rpcEndpoints[rpcEndpointIndex].networkClientId; + const { + address, + decimals, + symbol, + icon: image, + } = quoteResponse.quote.destAsset; + await dispatch( + addToken({ + address, + decimals, + symbol, + image, + networkClientId: destNetworkClientId, + }), + ); + }; + + return { addSourceToken, addDestToken }; +} diff --git a/ui/pages/bridge/hooks/useHandleApprovalTx.ts b/ui/pages/bridge/hooks/useHandleApprovalTx.ts new file mode 100644 index 000000000000..67f2abf67e7e --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleApprovalTx.ts @@ -0,0 +1,94 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { TxData, QuoteResponse, FeeType } from '../types'; +import { isEthUsdt, getEthUsdtResetData } from '../bridge.util'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { ETH_USDT_ADDRESS } from '../../../../shared/constants/bridge'; +import { getBridgeERC20Allowance } from '../../../ducks/bridge/actions'; +import useHandleTx from './useHandleTx'; + +export default function useHandleApprovalTx() { + const { handleTx } = useHandleTx(); + + const handleEthUsdtAllowanceReset = async ({ + approval, + quoteResponse, + hexChainId, + }: { + approval: TxData; + quoteResponse: QuoteResponse; + hexChainId: Hex; + }) => { + const allowance = new BigNumber( + await getBridgeERC20Allowance(ETH_USDT_ADDRESS, hexChainId), + ); + + // quote.srcTokenAmount is actually after the fees + // so we need to add fees back in for total allowance to give + const sentAmount = new BigNumber(quoteResponse.quote.srcTokenAmount) + .plus(quoteResponse.quote.feeData[FeeType.METABRIDGE].amount) + .toString(); + + const shouldResetApproval = allowance.lt(sentAmount) && allowance.gt(0); + + if (shouldResetApproval) { + const resetData = getEthUsdtResetData(); + const txParams = { + ...approval, + data: resetData, + }; + + await handleTx({ + txType: TransactionType.bridgeApproval, + txParams, + swapsOptions: { + hasApproveTx: true, + meta: { + type: TransactionType.bridgeApproval, + }, + }, + }); + } + }; + + const handleApprovalTx = async ({ + approval, + quoteResponse, + }: { + approval: TxData; + quoteResponse: QuoteResponse; + }) => { + const hexChainId = new Numeric( + approval.chainId, + 10, + ).toPrefixedHexString() as `0x${string}`; + + // On Ethereum, we need to reset the allowance to 0 for USDT first if we need to set a new allowance + // https://www.google.com/url?q=https://docs.unizen.io/trade-api/before-you-get-started/token-allowance-management-for-non-updatable-allowance-tokens&sa=D&source=docs&ust=1727386175513609&usg=AOvVaw3Opm6BSJeu7qO0Ve5iLTOh + if (isEthUsdt(hexChainId, quoteResponse.quote.srcAsset.address)) { + await handleEthUsdtAllowanceReset({ + approval, + quoteResponse, + hexChainId, + }); + } + + const txMeta = await handleTx({ + txType: TransactionType.bridgeApproval, + txParams: approval, + swapsOptions: { + hasApproveTx: true, + meta: { + type: TransactionType.bridgeApproval, + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + }, + }, + }); + + return txMeta.id; + }; + return { + handleApprovalTx, + }; +} diff --git a/ui/pages/bridge/hooks/useHandleBridgeTx.ts b/ui/pages/bridge/hooks/useHandleBridgeTx.ts new file mode 100644 index 000000000000..22b2a74fa077 --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleBridgeTx.ts @@ -0,0 +1,48 @@ +import { BigNumber } from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { FeeType, QuoteResponse } from '../types'; +import useHandleTx from './useHandleTx'; + +export default function useHandleBridgeTx() { + const { handleTx } = useHandleTx(); + + const handleBridgeTx = async ({ + quoteResponse, + approvalTxId, + }: { + quoteResponse: QuoteResponse; + approvalTxId: string | undefined; + }) => { + const sentAmount = new BigNumber(quoteResponse.quote.srcTokenAmount).plus( + quoteResponse.quote.feeData[FeeType.METABRIDGE].amount, + ); + const sentAmountDec = new Numeric(sentAmount, 10) + .shiftedBy(quoteResponse.quote.srcAsset.decimals) + .toString(); + + const txMeta = await handleTx({ + txType: TransactionType.bridge, + txParams: quoteResponse.trade, + swapsOptions: { + hasApproveTx: Boolean(quoteResponse?.approval), + meta: { + // estimatedBaseFee: decEstimatedBaseFee, + // swapMetaData, + type: TransactionType.bridge, + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + destinationTokenSymbol: quoteResponse.quote.destAsset.symbol, + destinationTokenDecimals: quoteResponse.quote.destAsset.decimals, + destinationTokenAddress: quoteResponse.quote.destAsset.address, + approvalTxId, + // this is the decimal (non atomic) amount (not USD value) of source token to swap + swapTokenValue: sentAmountDec, + }, + }, + }); + + return txMeta.id; + }; + + return { handleBridgeTx }; +} diff --git a/ui/pages/bridge/hooks/useHandleTx.ts b/ui/pages/bridge/hooks/useHandleTx.ts new file mode 100644 index 000000000000..a4cbf631c338 --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleTx.ts @@ -0,0 +1,79 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { useDispatch, useSelector } from 'react-redux'; +import { + forceUpdateMetamaskState, + addTransactionAndWaitForPublish, +} from '../../../store/actions'; +import { + getHexMaxGasLimit, + getTxGasEstimates, +} from '../../../ducks/bridge/utils'; +import { getGasFeeEstimates } from '../../../ducks/metamask/metamask'; +import { checkNetworkAndAccountSupports1559 } from '../../../selectors'; +import { ChainId } from '../types'; +import { Numeric } from '../../../../shared/modules/Numeric'; + +export default function useHandleTx() { + const dispatch = useDispatch(); + const networkAndAccountSupports1559 = useSelector( + checkNetworkAndAccountSupports1559, + ); + const networkGasFeeEstimates = useSelector(getGasFeeEstimates); + + const handleTx = async ({ + txType, + txParams, + swapsOptions, + }: { + txType: TransactionType.bridgeApproval | TransactionType.bridge; + txParams: { + chainId: ChainId; + to: string; + from: string; + value: string; + data: string; + gasLimit: number | null; + }; + swapsOptions: { + hasApproveTx: boolean; + meta: Partial; + }; + }) => { + const hexChainId = new Numeric( + txParams.chainId, + 10, + ).toPrefixedHexString() as `0x${string}`; + + const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates({ + networkAndAccountSupports1559, + networkGasFeeEstimates, + txParams, + hexChainId, + }); + const maxGasLimit = getHexMaxGasLimit(txParams.gasLimit ?? 0); + + const finalTxParams = { + ...txParams, + chainId: hexChainId, + gasLimit: maxGasLimit, + gas: maxGasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + }; + + const txMeta = await addTransactionAndWaitForPublish(finalTxParams, { + requireApproval: false, + type: txType, + swaps: swapsOptions, + }); + + await forceUpdateMetamaskState(dispatch); + + return txMeta; + }; + + return { handleTx }; +} diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx new file mode 100644 index 000000000000..20f471b1065b --- /dev/null +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { MemoryRouter, useHistory } from 'react-router-dom'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import * as actions from '../../../store/actions'; +import * as selectors from '../../../selectors'; +import { + DummyQuotesNoApproval, + DummyQuotesWithApproval, +} from '../../../../test/data/bridge/dummy-quotes'; +import useSubmitBridgeTransaction from './useSubmitBridgeTransaction'; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: jest.fn().mockImplementation(original.useHistory), + }; +}); + +jest.mock('../../../ducks/bridge/utils', () => ({ + ...jest.requireActual('../../../ducks/bridge/utils'), + getTxGasEstimates: jest.fn(() => ({ + baseAndPriorityFeePerGas: '0', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + })), +})); + +jest.mock('../../../store/actions', () => { + const original = jest.requireActual('../../../store/actions'); + return { + ...original, + addTransactionAndWaitForPublish: jest.fn(), + addToken: jest.fn().mockImplementation(original.addToken), + addNetwork: jest.fn().mockImplementation(original.addNetwork), + }; +}); + +jest.mock('../../../selectors', () => { + const original = jest.requireActual('../../../selectors'); + return { + ...original, + getIsBridgeEnabled: () => true, + getIsBridgeChain: () => true, + checkNetworkAndAccountSupports1559: () => true, + getSelectedNetworkClientId: () => 'mainnet', + getNetworkConfigurationsByChainId: jest.fn(() => ({ + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + '0xa4b1': { + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + })), + }; +}); + +const middleware = [thunk]; + +const makeMockStore = () => { + const store = configureMockStore(middleware)( + createBridgeMockStore( + {}, + {}, + {}, + { + gasFeeEstimates: { + high: { + maxWaitTimeEstimate: 30000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '14.226414113', + suggestedMaxPriorityFeePerGas: '2', + }, + }, + useExternalServices: true, + }, + ), + ); + return store; +}; + +const makeWrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); + }; + +describe('ui/pages/bridge/hooks/useSubmitBridgeTransaction', () => { + describe('submitBridgeTransaction', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('executes bridge transaction', async () => { + // Setup + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockAddTransactionAndWaitForPublish).toHaveBeenLastCalledWith( + { + chainId: '0x1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0x33403', + gasLimit: '0x33403', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { + approvalTxId: 'txMetaId-01', + destinationTokenAddress: + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + destinationTokenDecimals: 6, + destinationTokenSymbol: 'USDC', + sourceTokenSymbol: 'USDC', + swapTokenValue: '11', + type: 'bridge', + }, + }, + type: 'bridge', + }, + ); + }); + it('executes approval transaction if it exists', async () => { + // Setup + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockAddTransactionAndWaitForPublish).toHaveBeenNthCalledWith( + 1, + { + chainId: '0x1', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0xdc1d', + gasLimit: '0xdc1d', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { sourceTokenSymbol: 'USDC', type: 'bridgeApproval' }, + }, + type: 'bridgeApproval', + }, + ); + expect(mockAddTransactionAndWaitForPublish).toHaveBeenNthCalledWith( + 2, + { + chainId: '0x1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0x33403', + gasLimit: '0x33403', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { + approvalTxId: 'txMetaId-01', + destinationTokenAddress: + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + destinationTokenDecimals: 6, + destinationTokenSymbol: 'USDC', + sourceTokenSymbol: 'USDC', + swapTokenValue: '11', + type: 'bridge', + }, + }, + type: 'bridge', + }, + ); + }); + it('adds source token if it not the native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).toHaveBeenCalledWith({ + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + networkClientId: 'mainnet', + symbol: 'USDC', + }); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('does not add source token if source token is native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).not.toHaveBeenCalled(); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('adds dest token if it not the native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).toHaveBeenCalledWith({ + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + decimals: 6, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + symbol: 'USDC', + }); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('does not add dest token if dest token is native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).not.toHaveBeenCalled(); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('adds dest network if it does not exist', async () => { + // Setup + const store = makeMockStore(); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const mockedGetNetworkConfigurationsByChainId = + // @ts-expect-error this is a jest mock + selectors.getNetworkConfigurationsByChainId as jest.Mock; + mockedGetNetworkConfigurationsByChainId.mockImplementationOnce(() => ({ + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + })); + (actions.addNetwork as jest.Mock).mockImplementationOnce( + () => async () => ({ + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/infuraProjectId', + }, + ], + }), + ); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addNetwork).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/undefined', + }, + ], + }); + }); + it('routes to activity tab', async () => { + const store = makeMockStore(); + + const mockHistory = { + push: jest.fn(), + }; + (useHistory as jest.Mock).mockImplementationOnce(() => mockHistory); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts new file mode 100644 index 000000000000..db3b1c86ca06 --- /dev/null +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts @@ -0,0 +1,49 @@ +import { useDispatch } from 'react-redux'; +import { zeroAddress } from 'ethereumjs-util'; +import { useHistory } from 'react-router-dom'; +import { QuoteResponse } from '../types'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { setDefaultHomeActiveTabName } from '../../../store/actions'; +import useAddToken from './useAddToken'; +import useHandleApprovalTx from './useHandleApprovalTx'; +import useHandleBridgeTx from './useHandleBridgeTx'; + +export default function useSubmitBridgeTransaction() { + const history = useHistory(); + const dispatch = useDispatch(); + const { addSourceToken, addDestToken } = useAddToken(); + const { handleApprovalTx } = useHandleApprovalTx(); + const { handleBridgeTx } = useHandleBridgeTx(); + + const submitBridgeTransaction = async (quoteResponse: QuoteResponse) => { + // Execute transaction(s) + let approvalTxId: string | undefined; + if (quoteResponse?.approval) { + approvalTxId = await handleApprovalTx({ + approval: quoteResponse.approval, + quoteResponse, + }); + } + + await handleBridgeTx({ + quoteResponse, + approvalTxId, + }); + + // Add tokens if not the native gas token + if (quoteResponse.quote.srcAsset.address !== zeroAddress()) { + addSourceToken(quoteResponse); + } + if (quoteResponse.quote.destAsset.address !== zeroAddress()) { + await addDestToken(quoteResponse); + } + + // Route user to activity tab on Home page + await dispatch(setDefaultHomeActiveTabName('activity')); + history.push(DEFAULT_ROUTE); + }; + + return { + submitBridgeTransaction, + }; +} diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 62244e5793e5..2c9f082519e9 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -25,12 +25,15 @@ import { } from '../../components/multichain/pages/page'; import { getProviderConfig } from '../../ducks/metamask/metamask'; import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; +import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; const CrossChainSwap = () => { const t = useContext(I18nContext); + // Load swaps feature flags so that we can use smart transactions + useSwapsFeatureFlags(); useBridging(); const history = useHistory(); diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 28a1a2c1fbd6..06d784f2e0ea 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -1,19 +1,23 @@ import React, { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Button } from '../../../components/component-library'; import { getBridgeQuotes, getFromAmount, getFromChain, getFromToken, + getRecommendedQuote, getToAmount, getToChain, getToToken, } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; export const BridgeCTAButton = () => { + const dispatch = useDispatch(); const t = useI18nContext(); + const fromToken = useSelector(getFromToken); const toToken = useSelector(getToToken); @@ -24,6 +28,9 @@ export const BridgeCTAButton = () => { const toAmount = useSelector(getToAmount); const { isLoading } = useSelector(getBridgeQuotes); + const quoteResponse = useSelector(getRecommendedQuote); + + const { submitBridgeTransaction } = useSubmitBridgeTransaction(); const isTxSubmittable = fromToken && toToken && fromChain && toChain && fromAmount && toAmount; @@ -52,7 +59,7 @@ export const BridgeCTAButton = () => { data-testid="bridge-cta-button" onClick={() => { if (isTxSubmittable) { - // dispatch tx submission + dispatch(submitBridgeTransaction(quoteResponse)); } }} disabled={!isTxSubmittable} diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index 5d001e7ef7fc..a1ee163eca48 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -6,6 +6,9 @@ export enum BridgeFlag { NETWORK_DEST_ALLOWLIST = 'dest-network-allowlist', } +type DecimalChainId = string; +export type GasMultiplierByChainId = Record; + export type FeatureFlagResponse = { [BridgeFlag.EXTENSION_CONFIG]: { refreshRate: number; @@ -89,7 +92,7 @@ export type QuoteResponse = { estimatedProcessingTimeInSeconds: number; }; -enum ChainId { +export enum ChainId { ETH = 1, OPTIMISM = 10, BSC = 56, diff --git a/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts b/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts new file mode 100644 index 000000000000..93460c892d7f --- /dev/null +++ b/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { fetchSwapsLivenessAndFeatureFlags } from '../../../ducks/swaps/swaps'; + +export function useSwapsFeatureFlags() { + const dispatch = useDispatch(); + + useEffect(() => { + const fetchSwapsLivenessAndFeatureFlagsWrapper = async () => { + await dispatch(fetchSwapsLivenessAndFeatureFlags()); + }; + fetchSwapsLivenessAndFeatureFlagsWrapper(); + }, [dispatch]); +} diff --git a/ui/pages/swaps/swaps.util.ts b/ui/pages/swaps/swaps.util.ts index 9cbf0b67a867..7065c7ae90dc 100644 --- a/ui/pages/swaps/swaps.util.ts +++ b/ui/pages/swaps/swaps.util.ts @@ -823,7 +823,7 @@ export const getSwap1559GasFeeEstimates = async ( }; }; -async function getTransaction1559GasFeeEstimates( +export async function getTransaction1559GasFeeEstimates( transactionParams: TransactionParams, estimatedBaseFee: Hex, chainId: Hex, diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 6344f823aa02..01c34dc2fe3d 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -2448,7 +2448,12 @@ export function createRetryTransaction( export function addNetwork( networkConfiguration: AddNetworkFields | UpdateNetworkFields, -): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { +): ThunkAction< + Promise, + MetaMaskReduxState, + unknown, + AnyAction +> { return async (dispatch: MetaMaskReduxDispatch) => { log.debug(`background.addNetwork`, networkConfiguration); try { From 02d52b49c06a6b1d8b65e4ee4450aa7db2357220 Mon Sep 17 00:00:00 2001 From: julesat22 <142838415+julesat22@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:38:43 -0800 Subject: [PATCH 030/148] feat: Hook in Portfolio Entry Points (#27607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. What is the reason for the change? Portfolio has requested to add in some entry points into the extension, so users can easily navigate to the Portfolio to view/ manage their spending caps. 2. What is the improvement/solution? This adds value for the users who would like to view/ manage their spending caps as well as their portfolio. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27607?quickstart=1) ## **Manual testing steps** 1. Connect an account 2. Go to this the assets page in the extension 3. Click on an asset 4. Under "Token details", there should be a category for all native and non-native token types for "Spending caps" 5. Next to Spending caps, check the there is a link that routes the user to the portfolio with the "spendingCaps" tab and the user's account address passed as query params 6. Check that the link redirects to Portfolio ## **Screenshots/Recordings** ### **Before** Screenshot 2024-10-03 at 10 45 22 AM ### **After** Screenshot 2024-10-03 at 9 37 54 AM Screenshot 2024-10-03 at 9 33 21 AM Screenshot 2024-10-03 at 9 32 20 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 - [ ] 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: Julia Collins Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com> Co-authored-by: Ziad Saab Co-authored-by: georgewrmarshall --- app/_locales/en/messages.json | 6 + ui/pages/asset/asset.scss | 20 +- .../__snapshots__/asset-page.test.tsx.snap | 224 ++++++++++++------ ui/pages/asset/components/asset-page.tsx | 77 ++++-- 4 files changed, 237 insertions(+), 90 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index bdba7b1214a3..624c7e2b163b 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1830,6 +1830,9 @@ "editGasTooLow": { "message": "Unknown processing time" }, + "editInPortfolio": { + "message": "Edit in Portfolio" + }, "editNetworkLink": { "message": "edit the original network" }, @@ -5382,6 +5385,9 @@ "spendingCapTooltipDesc": { "message": "This is the amount of tokens the spender will be able to access on your behalf." }, + "spendingCaps": { + "message": "Spending caps" + }, "srpInputNumberOfWords": { "message": "I have a $1-word phrase", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." diff --git a/ui/pages/asset/asset.scss b/ui/pages/asset/asset.scss index 8d682beae58b..d6f6a3d39d9b 100644 --- a/ui/pages/asset/asset.scss +++ b/ui/pages/asset/asset.scss @@ -1,4 +1,4 @@ -@use "design-system"; +@use 'design-system'; .asset { &__container { @@ -42,5 +42,19 @@ } } -.chart-up { stroke: var(--color-success-default); } -.chart-down { stroke: var(--color-error-default); } +.chart-up { + stroke: var(--color-success-default); +} + +.chart-down { + stroke: var(--color-error-default); +} + +.asset-page__spending-caps { + text-decoration: none; + + &:hover { + color: var(--color-primary-alternative); + text-decoration: underline; + } +} 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 b5ebc0a83eb6..8a5c60e340cb 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -254,6 +254,36 @@ exports[`AssetPage should render a native asset 1`] = `
+
+

+ Token details +

+
+
+

+ Spending caps +

+ + Edit in Portfolio + +
+
+
@@ -555,59 +585,84 @@ exports[`AssetPage should render an ERC20 asset without prices 1`] = ` Token details

-

- Contract address -

-
- + +
+ 0x30937...C4936 +
+
+ + +
+
+
+
+
+

+ Token decimal +

+

+ 18 +

+
- -

- Token decimal + Spending caps

-

- 18 -

+ Edit in Portfolio +
@@ -1038,59 +1093,84 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = ` Token details
-

- Contract address -

-
- + +
+ 0xe4246...85f55 +
+
+ + +
+
+
+
+
+

+ Token decimal +

+

+ 18 +

+
- -

- Token decimal + Spending caps

-

- 18 -

+ Edit in Portfolio +
diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 29078e3a248c..818ceb792ec3 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; @@ -6,8 +6,11 @@ import { EthMethod } from '@metamask/keyring-api'; import { isEqual } from 'lodash'; import { getCurrentCurrency, + getDataCollectionForMarketing, getIsBridgeChain, getIsSwapsChain, + getMetaMetricsId, + getParticipateInMetaMetrics, getSelectedInternalAccount, getSwapsDefaultToken, getTokensMarketData, @@ -24,6 +27,7 @@ import { Box, ButtonIcon, ButtonIconSize, + ButtonLink, IconName, Text, } from '../../../components/component-library'; @@ -42,6 +46,7 @@ import { getConversionRate } from '../../../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import CoinButtons from '../../../components/app/wallet-overview/coin-buttons'; import { getIsNativeTokenBuyable } from '../../../ducks/ramps'; +import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; import AssetChart from './chart/asset-chart'; import TokenButtons from './token-buttons'; @@ -110,6 +115,10 @@ const AssetPage = ({ account.methods.includes(EthMethod.SignTransaction) || account.methods.includes(EthMethod.SignUserOperation); + const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); + const isMarketingEnabled = useSelector(getDataCollectionForMarketing); + const metaMetricsId = useSelector(getMetaMetricsId); + const { chainId, type, symbol, name, image, balance } = asset; const address = @@ -124,6 +133,20 @@ const AssetPage = ({ ? conversionRate * marketData.price : undefined; + const portfolioSpendingCapsUrl = useMemo( + () => + getPortfolioUrl( + '', + 'asset_page', + metaMetricsId, + isMetaMetricsEnabled, + isMarketingEnabled, + account.address, + 'spending-caps', + ), + [account.address, isMarketingEnabled, isMetaMetricsEnabled, metaMetricsId], + ); + return ( - {type === AssetType.token && ( + {[AssetType.token, AssetType.native].includes(type) && ( {t('tokenDetails')} - {renderRow( - t('contractAddress'), - , - )} - {asset.decimals !== undefined && - renderRow(t('tokenDecimal'), {asset.decimals})} - {asset.aggregators && asset.aggregators?.length > 0 && ( + {type === AssetType.token && ( - , + )} + - {t('tokenList')} - - {asset.aggregators?.join(', ')} + {asset.decimals !== undefined && + renderRow( + t('tokenDecimal'), + {asset.decimals}, + )} + {asset.aggregators && asset.aggregators.length > 0 && ( + + + {t('tokenList')} + + {asset.aggregators.join(', ')} + + )} + )} + {renderRow( + t('spendingCaps'), + + {t('editInPortfolio')} + , + )} )} From 9f8d61bd61687d8133c2bc0ae8d1809aa0d98a7a Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:59:11 -0500 Subject: [PATCH 031/148] fix: replace unreliable setTimeout usage with waitFor (#28620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to improve a flaky test for the `add-contact` component. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28620?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Run the test locally ## **Screenshots/Recordings** ### **Before** N/A ### **After** 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 - [ ] 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. --- .../contact-list-tab/add-contact/add-contact.test.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js index 09a0e0d96692..e162f84ba40f 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js @@ -1,5 +1,5 @@ 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 { renderWithProvider } from '../../../../../test/lib/render-helpers'; @@ -55,7 +55,7 @@ describe('AddContact component', () => { expect(getByText('Ethereum public address')).toBeInTheDocument(); }); - it('should validate the address correctly', () => { + it('should validate the address correctly', async () => { const store = configureMockStore(middleware)(state); const { getByText, getByTestId } = renderWithProvider( , @@ -64,9 +64,10 @@ describe('AddContact component', () => { const input = getByTestId('ens-input'); fireEvent.change(input, { target: { value: 'invalid address' } }); - setTimeout(() => { - expect(getByText('Recipient address is invalid')).toBeInTheDocument(); - }, 600); + + await waitFor(() => + expect(getByText('Recipient address is invalid')).toBeInTheDocument(), + ); }); it('should get disabled submit button when username field is empty', () => { From a04b34bc3e6c59892b1a817d4beca0098db306e8 Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:54:05 -0800 Subject: [PATCH 032/148] feat: `PortfolioView` (#28593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This consolidates the changes from a series of 3 Multichain Asset List PRs that built on each other: 1. Product code (feature branch): https://github.com/MetaMask/metamask-extension/pull/28386 2. Unit tests: https://github.com/MetaMask/metamask-extension/pull/28451 3. e2e tests: https://github.com/MetaMask/metamask-extension/pull/28524 We created separate branches for rapid iteration and isolated testing. The code is now cleaner and stable enough for review and merge into develop, gated by the `PORTFOLIO_VIEW` feature flag. We will introduce another PR to remove this feature flag when we are ready to ship it. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28593?quickstart=1) ## **Related issues** Fixes: https://github.com/orgs/MetaMask/projects/85/views/35?pane=issue&itemId=82217837 ## **Manual testing steps** `PORTFOLIO_VIEW=1 yarn webpack --watch` 1. View tokens across all networks in one unified list. 2. Filter tokens by selected network 3. Crosschain navigation: - Token detail pages update to display data from the appropriate network. - Send/Swap actions automatically adjust the selected network for user convenience. - Ensure that network switch is functional, and sends/swaps happen on correct chain. Known caveats: 1. POL native token market data not populating. Will be addressed here: https://github.com/MetaMask/metamask-extension/pull/28584 and https://github.com/MetaMask/core/pull/4952 2. Native token swapping on different network than selected network swaps incorrect token: https://github.com/MetaMask/metamask-extension/pull/28587 3. Multichain token detection experimental draft: https://github.com/MetaMask/metamask-extension/pull/28380 ## **Screenshots/Recordings** https://github.com/user-attachments/assets/79e7fd2d-9908-4c7a-8134-089cbe6593cc https://github.com/user-attachments/assets/dfb4a54f-a8ae-48a4-a9e7-50327f56054a ## **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: Jonathan Bursztyn Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com> Co-authored-by: seaona <54408225+seaona@users.noreply.github.com> Co-authored-by: Monte Lai Co-authored-by: Charly Chevalier Co-authored-by: Pedro Figueiredo Co-authored-by: MetaMask Bot Co-authored-by: NidhiKJha Co-authored-by: sahar-fehri --- app/_locales/en/messages.json | 8 +- shared/constants/network.ts | 3 + test/data/mock-state.json | 5 +- ...rs-after-init-opt-in-background-state.json | 1 - ...s-before-init-opt-in-background-state.json | 1 + .../tests/privacy-mode/privacy-mode.spec.js | 10 +- test/e2e/tests/tokens/token-sort.spec.ts | 14 +- test/jest/mock-store.js | 1 + .../asset-list-control-bar.tsx | 9 +- .../asset-list/asset-list.ramps-card.test.js | 10 + .../app/assets/asset-list/asset-list.tsx | 4 +- .../asset-list/native-token/native-token.tsx | 12 +- .../network-filter/network-filter.tsx | 67 +++-- .../app/assets/nfts/nfts-items/nfts-items.js | 4 +- .../assets/nfts/nfts-items/nfts-items.test.js | 4 +- .../__snapshots__/token-cell.test.tsx.snap | 8 +- .../app/assets/token-cell/token-cell.test.tsx | 13 +- .../app/assets/token-cell/token-cell.tsx | 115 ++++++-- .../app/assets/token-list/token-list.tsx | 252 ++++++++++++----- .../app/assets/util/calculateTokenBalance.ts | 51 ++++ .../assets/util/calculateTokenFiatAmount.ts | 36 +++ .../app/toast-master/toast-master.js | 43 ++- ...-preferenced-currency-display.component.js | 2 +- .../app/wallet-overview/btc-overview.test.tsx | 23 ++ .../app/wallet-overview/coin-buttons.tsx | 44 ++- .../app/wallet-overview/eth-overview.test.js | 37 ++- .../account-overview-tabs.tsx | 4 +- .../asset-picker-amount.tsx | 8 +- .../asset-picker-modal/AssetList.tsx | 1 - .../asset-picker-modal/asset-picker-modal.tsx | 6 + .../asset-picker/asset-picker.tsx | 3 + .../network-list-menu.test.js | 6 + .../network-list-menu/network-list-menu.tsx | 17 ++ .../send/components/recipient-content.tsx | 1 + ui/components/multichain/pages/send/send.js | 1 + .../token-list-item.test.tsx.snap | 4 +- .../token-list-item/token-list-item.test.tsx | 33 ++- .../token-list-item/token-list-item.tsx | 47 ++-- ui/hooks/useAccountTotalFiatBalance.test.js | 42 ++- ...MultichainAccountTotalFiatBalance.test.tsx | 41 ++- ui/pages/asset/asset.tsx | 46 ++-- .../__snapshots__/asset-page.test.tsx.snap | 65 +++-- ui/pages/asset/components/asset-page.test.tsx | 136 ++++++--- ui/pages/asset/components/asset-page.tsx | 260 ++++++++++-------- ui/pages/asset/components/native-asset.tsx | 44 +-- ui/pages/asset/components/token-asset.tsx | 44 ++- ui/pages/asset/components/token-buttons.tsx | 49 +++- ui/pages/asset/util.test.ts | 8 +- ui/pages/asset/util.ts | 3 +- ui/pages/routes/routes.component.js | 11 +- ui/selectors/selectors.js | 145 +++++++++- ui/store/actions.ts | 2 +- 52 files changed, 1306 insertions(+), 498 deletions(-) create mode 100644 ui/components/app/assets/util/calculateTokenBalance.ts create mode 100644 ui/components/app/assets/util/calculateTokenFiatAmount.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 624c7e2b163b..f51c9708fc20 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -501,7 +501,7 @@ "message": "No accounts available to connect" }, "allNetworks": { - "message": "All Networks", + "message": "All networks", "description": "Speicifies to token network filter to filter by all Networks" }, "allOfYour": { @@ -1383,7 +1383,7 @@ "message": "Current language" }, "currentNetwork": { - "message": "Current Network", + "message": "Current network", "description": "Speicifies to token network filter to filter by current Network. Will render when network nickname is not available" }, "currentRpcUrlDeprecated": { @@ -6023,6 +6023,10 @@ "message": "$1 is now active on $2", "description": "$1 represents the account name, $2 represents the network name" }, + "switchedNetworkToastMessageNoOrigin": { + "message": "You're now using $1", + "description": "$1 represents the network name" + }, "switchedTo": { "message": "You're now using" }, diff --git a/shared/constants/network.ts b/shared/constants/network.ts index 4844e7c2e981..aef122ea67bc 100644 --- a/shared/constants/network.ts +++ b/shared/constants/network.ts @@ -816,6 +816,9 @@ export const CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP = { export const CHAIN_ID_TOKEN_IMAGE_MAP = { [CHAIN_IDS.MAINNET]: ETH_TOKEN_IMAGE_URL, [CHAIN_IDS.TEST_ETH]: TEST_ETH_TOKEN_IMAGE_URL, + [CHAIN_IDS.ARBITRUM]: ETH_TOKEN_IMAGE_URL, + [CHAIN_IDS.BASE]: ETH_TOKEN_IMAGE_URL, + [CHAIN_IDS.LINEA_MAINNET]: ETH_TOKEN_IMAGE_URL, [CHAIN_IDS.BSC]: BNB_TOKEN_IMAGE_URL, [CHAIN_IDS.POLYGON]: POL_TOKEN_IMAGE_URL, [CHAIN_IDS.AVALANCHE]: AVAX_TOKEN_IMAGE_URL, diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 2932e5fc56d9..d2b66cee3108 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -55,6 +55,8 @@ } }, "metamask": { + "allTokens": {}, + "tokenBalances": {}, "use4ByteResolution": true, "ipfsGateway": "dweb.link", "dismissSeedBackUpReminder": false, @@ -384,7 +386,8 @@ "key": "tokenFiatAmount", "order": "dsc", "sortCallback": "stringNumeric" - } + }, + "tokenNetworkFilter": {} }, "ensResolutionsByAddress": {}, "isAccountMenuOpen": false, 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 cea439180b5b..6252c91e73a2 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 @@ -342,7 +342,6 @@ }, "TxController": { "methodData": "object", - "submitHistory": "object", "transactions": "object", "lastFetchedBlockNumbers": "object", "submitHistory": "object" diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 5f5f47f3e7ee..5d6be06b692e 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -118,6 +118,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", + "tokenNetworkFilter": {}, "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean", "tokenNetworkFilter": {} diff --git a/test/e2e/tests/privacy-mode/privacy-mode.spec.js b/test/e2e/tests/privacy-mode/privacy-mode.spec.js index a4d2c2245752..ede37f900e66 100644 --- a/test/e2e/tests/privacy-mode/privacy-mode.spec.js +++ b/test/e2e/tests/privacy-mode/privacy-mode.spec.js @@ -18,7 +18,7 @@ describe('Privacy Mode', function () { async ({ driver }) => { async function checkForHeaderValue(value) { const balanceElement = await driver.findElement( - '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + '[data-testid="account-value-and-suffix"]', ); const surveyText = await balanceElement.getText(); assert.equal( @@ -30,7 +30,7 @@ describe('Privacy Mode', function () { async function checkForTokenValue(value) { const balanceElement = await driver.findElement( - '[data-testid="multichain-token-list-item-secondary-value"]', + '[data-testid="multichain-token-list-item-value"]', ); const surveyText = await balanceElement.getText(); assert.equal(surveyText, value, `Token balance should be "${value}"`); @@ -38,7 +38,7 @@ describe('Privacy Mode', function () { async function checkForPrivacy() { await checkForHeaderValue('••••••'); - await checkForTokenValue('•••••••••'); + await checkForTokenValue('••••••'); } async function checkForNoPrivacy() { @@ -48,7 +48,7 @@ describe('Privacy Mode', function () { async function togglePrivacy() { const balanceElement = await driver.findElement( - '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + '[data-testid="account-value-and-suffix"]', ); const initialText = await balanceElement.getText(); @@ -81,7 +81,7 @@ describe('Privacy Mode', function () { async function togglePrivacy() { const balanceElement = await driver.findElement( - '[data-testid="eth-overview__primary-currency"] .currency-display-component__text', + '[data-testid="account-value-and-suffix"]', ); const initialText = await balanceElement.getText(); diff --git a/test/e2e/tests/tokens/token-sort.spec.ts b/test/e2e/tests/tokens/token-sort.spec.ts index ed6005a710ad..ff2d35a917dc 100644 --- a/test/e2e/tests/tokens/token-sort.spec.ts +++ b/test/e2e/tests/tokens/token-sort.spec.ts @@ -66,10 +66,8 @@ describe('Token List', function () { assert.ok(tokenSymbolsBeforeSorting[0].includes('Ethereum')); - await await driver.clickElement( - '[data-testid="sort-by-popover-toggle"]', - ); - await await driver.clickElement('[data-testid="sortByAlphabetically"]'); + await driver.clickElement('[data-testid="sort-by-popover-toggle"]'); + await driver.clickElement('[data-testid="sortByAlphabetically"]'); await driver.delay(regularDelayMs); const tokenListAfterSortingAlphabetically = await driver.findElements( @@ -85,12 +83,8 @@ describe('Token List', function () { tokenListSymbolsAfterSortingAlphabetically[0].includes('ABC'), ); - await await driver.clickElement( - '[data-testid="sort-by-popover-toggle"]', - ); - await await driver.clickElement( - '[data-testid="sortByDecliningBalance"]', - ); + await driver.clickElement('[data-testid="sort-by-popover-toggle"]'); + await driver.clickElement('[data-testid="sortByDecliningBalance"]'); await driver.delay(regularDelayMs); const tokenListBeforeSortingByDecliningBalance = diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 3fe524634075..4720bf427372 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -139,6 +139,7 @@ export const createSwapsMockStore = () => { preferences: { showFiatInTestnets: true, smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, showMultiRpcModal: false, }, transactions: [ 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 2925277c14bd..e5c43aad281d 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 @@ -68,12 +68,13 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { }, [currentNetwork.chainId, TEST_CHAINS]); const allOpts: Record = {}; - Object.keys(allNetworks).forEach((chainId) => { + Object.keys(allNetworks || {}).forEach((chainId) => { allOpts[chainId] = true; }); const allNetworksFilterShown = - Object.keys(tokenNetworkFilter).length !== Object.keys(allOpts).length; + Object.keys(tokenNetworkFilter || {}).length !== + Object.keys(allOpts || {}).length; useEffect(() => { if (isTestNetwork) { @@ -86,7 +87,7 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { // We need to set the default filter for all users to be all included networks, rather than defaulting to empty object // This effect is to unblock and derisk in the short-term useEffect(() => { - if (Object.keys(tokenNetworkFilter).length === 0) { + if (Object.keys(tokenNetworkFilter || {}).length === 0) { dispatch(setTokenNetworkFilter(allOpts)); } }, []); @@ -162,7 +163,7 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { > {process.env.PORTFOLIO_VIEW && ( { )} } + // nativeToken is still needed to avoid breaking flask build's support for bitcoin + // TODO: refactor this to no longer be needed for non-evm chains + nativeToken={!isEvm && } onTokenClick={(chainId: string, tokenAddress: string) => { onClickAsset(chainId, tokenAddress); trackEvent({ 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 b1e86479bb79..29bc1ea8c1bd 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 @@ -10,23 +10,14 @@ import { } 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'; const NativeToken = ({ onClickAsset }: AssetListProps) => { const nativeCurrency = useSelector(getMultichainNativeCurrency); const isMainnet = useSelector(getMultichainIsMainnet); - const { chainId, ticker, type, rpcUrl } = useSelector( - getMultichainCurrentNetwork, - ); + const { chainId } = useSelector(getMultichainCurrentNetwork); const { privacyMode } = useSelector(getPreferences); - const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( - chainId, - ticker, - type, - rpcUrl, - ); const balance = useSelector(getMultichainSelectedAccountCachedBalance); const balanceIsLoading = !balance; @@ -50,7 +41,6 @@ const NativeToken = ({ onClickAsset }: AssetListProps) => { tokenSymbol={symbol} secondary={secondary} tokenImage={balanceIsLoading ? null : primaryTokenImage} - isOriginalTokenSymbol={isOriginalNativeSymbol} isNativeCurrency isStakeable={isStakeable} showPercentage diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index 8b9fc06b33e7..29e4c97e2c82 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -1,28 +1,35 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { setTokenNetworkFilter } from '../../../../../store/actions'; import { getCurrentChainId, getCurrentNetwork, getPreferences, - getSelectedInternalAccount, - getShouldHideZeroBalanceTokens, getNetworkConfigurationsByChainId, getChainIdsToPoll, + getShouldHideZeroBalanceTokens, + getSelectedAccount, } from '../../../../../selectors'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { SelectableListItem } from '../sort-control/sort-control'; import { Text } from '../../../../component-library/text/text'; import { + AlignItems, Display, JustifyContent, TextColor, TextVariant, } from '../../../../../helpers/constants/design-system'; import { Box } from '../../../../component-library/box/box'; -import { AvatarNetwork } from '../../../../component-library'; +import { + AvatarNetwork, + AvatarNetworkSize, +} from '../../../../component-library'; import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display'; -import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../shared/constants/network'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + TEST_CHAINS, +} from '../../../../../../shared/constants/network'; import { useGetFormattedTokensPerChain } from '../../../../../hooks/useGetFormattedTokensPerChain'; import { useAccountTotalCrossChainFiatBalance } from '../../../../../hooks/useAccountTotalCrossChainFiatBalance'; @@ -34,9 +41,10 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { const t = useI18nContext(); const dispatch = useDispatch(); const chainId = useSelector(getCurrentChainId); - const selectedAccount = useSelector(getSelectedInternalAccount); const currentNetwork = useSelector(getCurrentNetwork); + const selectedAccount = useSelector(getSelectedAccount); const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const [chainsToShow, setChainsToShow] = useState([]); const { tokenNetworkFilter } = useSelector(getPreferences); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, @@ -67,9 +75,6 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { formattedTokensForAllNetworks, ); - // TODO: fetch balances across networks - // const multiNetworkAccountBalance = useMultichainAccountBalance() - const handleFilter = (chainFilters: Record) => { dispatch(setTokenNetworkFilter(chainFilters)); @@ -77,11 +82,28 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { handleClose(); }; + useEffect(() => { + const testnetChains: string[] = TEST_CHAINS; + const mainnetChainIds = Object.keys(allNetworks || {}).filter( + (chain) => !testnetChains.includes(chain), + ); + setChainsToShow(mainnetChainIds); + }, []); + + const allOpts: Record = {}; + Object.keys(allNetworks || {}).forEach((chain) => { + allOpts[chain] = true; + }); + return ( <> handleFilter({})} + isSelected={ + Object.keys(tokenNetworkFilter || {}).length === + Object.keys(allNetworks || {}).length + } + onClick={() => handleFilter(allOpts)} + testId="network-filter-all" > { {t('allNetworks')} {/* TODO: Should query cross chain account balance */} @@ -110,19 +133,20 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { /> - - {Object.values(allNetworks) + + {chainsToShow .slice(0, 5) // only show a max of 5 icons overlapping - .map((network, index) => { + .map((chain, index) => { const networkImageUrl = CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ - network.chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + chain as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP ]; return ( { handleFilter({ [chainId]: true })} + testId="network-filter-current" > { } const firstNft = nfts[0]; - const nftRoute = `/asset/${firstNft.address}/${firstNft.tokenId}`; + const nftRoute = `/asset/${toHex(5)}/${firstNft.address}/${ + firstNft.tokenId + }`; expect(mockHistoryPush).toHaveBeenCalledWith(nftRoute); }); diff --git a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap index dfed6aeffa98..30efc922ec7e 100644 --- a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap +++ b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap @@ -29,7 +29,11 @@ exports[`Token Cell should match snapshot 1`] = `
- ? + network logo
@@ -64,7 +68,7 @@ exports[`Token Cell should match snapshot 1`] = ` class="mm-box mm-text mm-text--body-md mm-text--font-weight-medium mm-text--text-align-end mm-box--width-2/3 mm-box--color-text-default" data-testid="multichain-token-list-item-secondary-value" > - 5.00 + $5.00

{ if (selector === getIntlLocale) { return 'en-US'; } + if (selector === getCurrentCurrency) { + return 'usd'; + } + if (selector === getCurrencyRates) { + return { POL: '' }; + } return undefined; }); (useTokenFiatAmount as jest.Mock).mockReturnValue('5.00'); diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 81c237b441d9..56d6555258bd 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -1,62 +1,139 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { getTokenList } from '../../../../selectors'; -import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; +import { BigNumber } from 'bignumber.js'; +import { + getCurrentCurrency, + getTokenList, + selectERC20TokensByChain, + getNativeCurrencyForChain, +} from '../../../../selectors'; +import { + isChainIdMainnet, + getImageForChainId, + getMultichainIsEvm, +} from '../../../../selectors/multichain'; import { TokenListItem } from '../../../multichain'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; -import { useIsOriginalTokenSymbol } from '../../../../hooks/useIsOriginalTokenSymbol'; import { getIntlLocale } from '../../../../ducks/locale/locale'; +import { formatAmount } from '../../../../pages/confirmations/components/simulation-details/formatAmount'; type TokenCellProps = { address: string; symbol: string; string?: string; chainId: string; + tokenFiatAmount: number | null; image: string; + isNative?: boolean; privacyMode?: boolean; onClick?: (chainId: string, address: string) => void; }; +export const formatWithThreshold = ( + amount: number | null, + threshold: number, + locale: string, + options: Intl.NumberFormatOptions, +): string => { + if (amount === null) { + return ''; + } + if (amount === 0) { + return new Intl.NumberFormat(locale, options).format(0); + } + return amount < threshold + ? `<${new Intl.NumberFormat(locale, options).format(threshold)}` + : new Intl.NumberFormat(locale, options).format(amount); +}; + export default function TokenCell({ address, image, symbol, chainId, string, + tokenFiatAmount, + isNative, privacyMode = false, onClick, }: TokenCellProps) { + const locale = useSelector(getIntlLocale); + const currentCurrency = useSelector(getCurrentCurrency); const tokenList = useSelector(getTokenList); + const isEvm = useSelector(getMultichainIsEvm); + const erc20TokensByChain = useSelector(selectERC20TokensByChain); + const isMainnet = chainId ? isChainIdMainnet(chainId) : false; const tokenData = Object.values(tokenList).find( (token) => isEqualCaseInsensitive(token.symbol, symbol) && isEqualCaseInsensitive(token.address, address), ); - const title = tokenData?.name || symbol; - const tokenImage = tokenData?.iconUrl || image; - const formattedFiat = useTokenFiatAmount(address, string, symbol, {}, false); - const locale = useSelector(getIntlLocale); - const primary = new Intl.NumberFormat(locale, { - minimumSignificantDigits: 1, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - }).format(string.toString()); - const isOriginalTokenSymbol = useIsOriginalTokenSymbol(address, symbol); + const title = + tokenData?.name || + (chainId === '0x1' && symbol === 'ETH' + ? 'Ethereum' + : chainId && + erc20TokensByChain?.[chainId]?.data?.[address.toLowerCase()]?.name) || + symbol; + + const tokenImage = + tokenData?.iconUrl || + (chainId && + erc20TokensByChain?.[chainId]?.data?.[address.toLowerCase()]?.iconUrl) || + image; + + const secondaryThreshold = 0.01; + + // Format for fiat balance with currency style + const secondary = formatWithThreshold( + tokenFiatAmount, + secondaryThreshold, + locale, + { + style: 'currency', + currency: currentCurrency.toUpperCase(), + }, + ); + + const primary = formatAmount( + locale, + new BigNumber(Number(string) || '0', 10), + ); + + let isStakeable = isMainnet && isEvm && isNative; + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + isStakeable = false; + ///: END:ONLY_INCLUDE_IF + + function handleOnClick() { + if (!onClick || !chainId) { + return; + } + onClick(chainId, address); + } + + if (!chainId) { + return null; + } + + const tokenChainImage = getImageForChainId(chainId); return ( onClick(chainId, address) : undefined} + onClick={handleOnClick} tokenSymbol={symbol} - tokenImage={tokenImage} - primary={`${primary || 0}`} - secondary={isOriginalTokenSymbol ? formattedFiat : null} + tokenImage={isNative ? getNativeCurrencyForChain(chainId) : tokenImage} + tokenChainImage={tokenChainImage || undefined} + primary={primary} + secondary={secondary} title={title} - isOriginalTokenSymbol={isOriginalTokenSymbol} address={address} + isStakeable={isStakeable} showPercentage privacyMode={privacyMode} + isNativeCurrency={isNative} + chainId={chainId} /> ); } diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 638c66610a80..08b5675dfe94 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -1,30 +1,77 @@ import React, { ReactNode, useEffect, useMemo } from 'react'; -import { shallowEqual, useSelector } from 'react-redux'; +import { shallowEqual, useSelector, useDispatch } from 'react-redux'; +import { Hex } from '@metamask/utils'; import TokenCell from '../token-cell'; -import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { Box } from '../../../component-library'; -import { - AlignItems, - Display, - JustifyContent, -} from '../../../../helpers/constants/design-system'; -import { TokenWithBalance } from '../asset-list/asset-list'; +import { TEST_CHAINS } from '../../../../../shared/constants/network'; import { sortAssets } from '../util/sort'; import { - getCurrentChainId, + getCurrencyRates, + getCurrentNetwork, + getMarketData, + getNetworkConfigurationIdByChainId, + getNewTokensImported, getPreferences, getSelectedAccount, - getShouldHideZeroBalanceTokens, + getSelectedAccountNativeTokenCachedBalanceByChainId, + getSelectedAccountTokensAcrossChains, getTokenExchangeRates, } from '../../../../selectors'; -import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; import { getConversionRate } from '../../../../ducks/metamask/metamask'; -import { useNativeTokenBalance } from '../asset-list/native-token/use-native-token-balance'; +import { filterAssets } from '../util/filter'; +import { calculateTokenBalance } from '../util/calculateTokenBalance'; +import { calculateTokenFiatAmount } from '../util/calculateTokenFiatAmount'; import { endTrace, TraceName } from '../../../../../shared/lib/trace'; +import { useTokenBalances } from '../../../../hooks/useTokenBalances'; +import { setTokenNetworkFilter } from '../../../../store/actions'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; type TokenListProps = { onTokenClick: (chainId: string, address: string) => void; - nativeToken: ReactNode; + nativeToken?: ReactNode; +}; + +export type Token = { + address: Hex; + aggregators: string[]; + chainId: Hex; + decimals: number; + isNative: boolean; + symbol: string; + image: string; +}; + +export type TokenWithFiatAmount = Token & { + tokenFiatAmount: number | null; + balance?: string; + string: string; // needed for backwards compatability TODO: fix this +}; + +export type AddressBalanceMapping = Record>; +export type ChainAddressMarketData = Record< + Hex, + Record> +>; + +const useFilteredAccountTokens = (currentNetwork: { chainId: string }) => { + const isTestNetwork = useMemo(() => { + return (TEST_CHAINS as string[]).includes(currentNetwork.chainId); + }, [currentNetwork.chainId, TEST_CHAINS]); + + const selectedAccountTokensChains: Record = useSelector( + getSelectedAccountTokensAcrossChains, + ) as Record; + + const filteredAccountTokensChains = useMemo(() => { + return Object.fromEntries( + Object.entries(selectedAccountTokensChains).filter(([chainId]) => + isTestNetwork + ? (TEST_CHAINS as string[]).includes(chainId) + : !(TEST_CHAINS as string[]).includes(chainId), + ), + ); + }, [selectedAccountTokensChains, isTestNetwork, TEST_CHAINS]); + + return filteredAccountTokensChains; }; export default function TokenList({ @@ -32,78 +79,155 @@ export default function TokenList({ nativeToken, }: TokenListProps) { const t = useI18nContext(); - const currentChainId = useSelector(getCurrentChainId); + const dispatch = useDispatch(); + const currentNetwork = useSelector(getCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationIdByChainId); const { tokenSortConfig, tokenNetworkFilter, privacyMode } = useSelector(getPreferences); const selectedAccount = useSelector(getSelectedAccount); const conversionRate = useSelector(getConversionRate); - const nativeTokenWithBalance = useNativeTokenBalance(); - const shouldHideZeroBalanceTokens = useSelector( - getShouldHideZeroBalanceTokens, - ); const contractExchangeRates = useSelector( getTokenExchangeRates, shallowEqual, ); - const { tokensWithBalances, loading } = useAccountTotalFiatBalance( - selectedAccount, - shouldHideZeroBalanceTokens, - ) as { - tokensWithBalances: TokenWithBalance[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mergedRates: any; - loading: boolean; + const newTokensImported = useSelector(getNewTokensImported); + const selectedAccountTokensChains = useFilteredAccountTokens(currentNetwork); + + const { tokenBalances } = useTokenBalances(); + const selectedAccountTokenBalancesAcrossChains = + tokenBalances[selectedAccount.address]; + + const marketData: ChainAddressMarketData = useSelector( + getMarketData, + ) as ChainAddressMarketData; + + const currencyRates = useSelector(getCurrencyRates); + const nativeBalances: Record = useSelector( + getSelectedAccountNativeTokenCachedBalanceByChainId, + ) as Record; + + // Ensure newly added networks are included in the tokenNetworkFilter + useEffect(() => { + const allNetworkFilters = Object.fromEntries( + Object.keys(allNetworks).map((chainId) => [chainId, true]), + ); + + if (Object.keys(tokenNetworkFilter).length > 1) { + dispatch(setTokenNetworkFilter(allNetworkFilters)); + } + }, [Object.keys(allNetworks).length]); + + const consolidatedBalances = () => { + const tokensWithBalance: TokenWithFiatAmount[] = []; + + Object.entries(selectedAccountTokensChains).forEach( + ([stringChainKey, tokens]) => { + const chainId = stringChainKey as Hex; + tokens.forEach((token: Token) => { + const { isNative, address, decimals } = token; + const balance = + calculateTokenBalance({ + isNative, + chainId, + address, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + }) || '0'; + + const tokenFiatAmount = calculateTokenFiatAmount({ + token, + chainId, + balance, + marketData, + currencyRates, + }); + + // Append processed token with balance and fiat amount + tokensWithBalance.push({ + ...token, + balance, + tokenFiatAmount, + chainId, + string: String(balance), + }); + }); + }, + ); + + return tokensWithBalance; }; - const sortedTokens = useMemo(() => { - // TODO filter assets by networkTokenFilter before sorting - return sortAssets( - [nativeTokenWithBalance, ...tokensWithBalances], - tokenSortConfig, + const sortedFilteredTokens = useMemo(() => { + const consolidatedTokensWithBalances = consolidatedBalances(); + const filteredAssets = filterAssets(consolidatedTokensWithBalances, [ + { + key: 'chainId', + opts: tokenNetworkFilter, + filterCallback: 'inclusive', + }, + ]); + + const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{ + nativeTokens: TokenWithFiatAmount[]; + nonNativeTokens: TokenWithFiatAmount[]; + }>( + (acc, token) => { + if (token.isNative) { + acc.nativeTokens.push(token); + } else { + acc.nonNativeTokens.push(token); + } + return acc; + }, + { nativeTokens: [], nonNativeTokens: [] }, ); + const assets = [...nativeTokens, ...nonNativeTokens]; + return sortAssets(assets, tokenSortConfig); }, [ - tokensWithBalances, tokenSortConfig, tokenNetworkFilter, conversionRate, contractExchangeRates, + currentNetwork, + selectedAccount, + selectedAccountTokensChains, + newTokensImported, ]); useEffect(() => { - if (!loading) { + if (sortedFilteredTokens) { endTrace({ name: TraceName.AccountOverviewAssetListTab }); } - }, [loading]); - - return loading ? ( - - {t('loadingTokens')} - - ) : ( + }, [sortedFilteredTokens]); + + // Displays nativeToken if provided + if (nativeToken) { + return React.cloneElement(nativeToken as React.ReactElement); + } + + // TODO: We can remove this string. However it will result in a huge file 50+ file diff + // Lets remove it in a separate PR + if (sortedFilteredTokens === undefined) { + console.log(t('loadingTokens')); + } + + return (

- {sortedTokens.map((tokenData) => { - if (tokenData?.isNative) { - // we need cloneElement so that we can pass the unique key - return React.cloneElement(nativeToken as React.ReactElement, { - key: `${tokenData.symbol}-${tokenData.address}`, - }); - } - return ( - - ); - })} + {sortedFilteredTokens.map((tokenData) => ( + + ))}
); } diff --git a/ui/components/app/assets/util/calculateTokenBalance.ts b/ui/components/app/assets/util/calculateTokenBalance.ts new file mode 100644 index 000000000000..3eb1fc7373e7 --- /dev/null +++ b/ui/components/app/assets/util/calculateTokenBalance.ts @@ -0,0 +1,51 @@ +import BN from 'bn.js'; +import { Hex } from '@metamask/utils'; +import { stringifyBalance } from '../../../../hooks/useTokenBalances'; +import { hexToDecimal } from '../../../../../shared/modules/conversion.utils'; +import { AddressBalanceMapping } from '../token-list/token-list'; + +type CalculateTokenBalanceParams = { + isNative: boolean; + chainId: Hex; + address: Hex; + decimals: number; + nativeBalances: Record; + selectedAccountTokenBalancesAcrossChains: AddressBalanceMapping; +}; + +export function calculateTokenBalance({ + isNative, + chainId, + address, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, +}: CalculateTokenBalanceParams): string | undefined { + let balance; + + if (isNative) { + const nativeTokenBalanceHex = nativeBalances?.[chainId]; + if (nativeTokenBalanceHex && nativeTokenBalanceHex !== '0x0') { + balance = stringifyBalance( + new BN(hexToDecimal(nativeTokenBalanceHex)), + new BN(decimals), + 5, // precision for native token balance + ); + } else { + balance = '0'; + } + } else { + const hexBalance = + selectedAccountTokenBalancesAcrossChains?.[chainId]?.[address]; + if (hexBalance && hexBalance !== '0x0') { + balance = stringifyBalance( + new BN(hexToDecimal(hexBalance)), + new BN(decimals), + ); + } else { + balance = '0'; + } + } + + return balance; +} diff --git a/ui/components/app/assets/util/calculateTokenFiatAmount.ts b/ui/components/app/assets/util/calculateTokenFiatAmount.ts new file mode 100644 index 000000000000..279fae37f582 --- /dev/null +++ b/ui/components/app/assets/util/calculateTokenFiatAmount.ts @@ -0,0 +1,36 @@ +import { Hex } from '@metamask/utils'; +import { ChainAddressMarketData, Token } from '../token-list/token-list'; + +type SymbolCurrencyRateMapping = Record>; + +type CalculateTokenFiatAmountParams = { + token: Token; + chainId: Hex; + balance: string | undefined; + marketData: ChainAddressMarketData; + currencyRates: SymbolCurrencyRateMapping; +}; + +export function calculateTokenFiatAmount({ + token, + chainId, + balance, + marketData, + currencyRates, +}: CalculateTokenFiatAmountParams): number | null { + const { address, isNative, symbol } = token; + + // Market and conversion rate data + const baseCurrency = marketData[chainId]?.[address]?.currency; + const tokenMarketPrice = Number(marketData[chainId]?.[address]?.price) || 0; + const tokenExchangeRate = currencyRates[baseCurrency]?.conversionRate || 0; + const parsedBalance = parseFloat(String(balance)); + + if (isNative && currencyRates) { + return (currencyRates[symbol]?.conversionRate || 0) * parsedBalance; + } + if (!tokenMarketPrice) { + return null; // when no market price is available, we don't want to render the fiat amount + } + return tokenMarketPrice * tokenExchangeRate * parsedBalance; +} diff --git a/ui/components/app/toast-master/toast-master.js b/ui/components/app/toast-master/toast-master.js index 584f1cc25983..18182f37b477 100644 --- a/ui/components/app/toast-master/toast-master.js +++ b/ui/components/app/toast-master/toast-master.js @@ -17,6 +17,9 @@ import { import { DEFAULT_ROUTE, REVIEW_PERMISSIONS, + SEND_ROUTE, + SWAPS_ROUTE, + PREPARE_SWAP_ROUTE, } from '../../../helpers/constants/routes'; import { getURLHost } from '../../../helpers/utils/util'; import { useI18nContext } from '../../../hooks/useI18nContext'; @@ -61,9 +64,13 @@ export function ToastMaster() { const location = useLocation(); const onHomeScreen = location.pathname === DEFAULT_ROUTE; + const onSendScreen = location.pathname === SEND_ROUTE; + const onSwapsScreen = + location.pathname === SWAPS_ROUTE || + location.pathname === PREPARE_SWAP_ROUTE; - return ( - onHomeScreen && ( + if (onHomeScreen) { + return ( @@ -73,8 +80,18 @@ export function ToastMaster() { - ) - ); + ); + } + + if (onSendScreen || onSwapsScreen) { + return ( + + + + ); + } + + return null; } function ConnectAccountToast() { @@ -204,6 +221,19 @@ function SwitchedNetworkToast() { ); const isShown = switchedNetworkDetails && !switchedNetworkNeverShowMessage; + const hasOrigin = Boolean(switchedNetworkDetails?.origin); + + function getMessage() { + if (hasOrigin) { + return t('switchedNetworkToastMessage', [ + switchedNetworkDetails.nickname, + getURLHost(switchedNetworkDetails.origin), + ]); + } + return t('switchedNetworkToastMessageNoOrigin', [ + switchedNetworkDetails.nickname, + ]); + } return ( isShown && ( @@ -217,10 +247,7 @@ function SwitchedNetworkToast() { name={switchedNetworkDetails?.nickname} /> } - text={t('switchedNetworkToastMessage', [ - switchedNetworkDetails.nickname, - getURLHost(switchedNetworkDetails.origin), - ])} + text={getMessage()} actionText={t('switchedNetworkToastDecline')} onActionClick={setSwitchedNetworkNeverShowMessage} onClose={() => dispatch(clearSwitchedNetworkDetails())} diff --git a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js index a466f7813672..2ec894ab702a 100644 --- a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js +++ b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js @@ -26,8 +26,8 @@ export default function UserPreferencedCurrencyDisplay({ type, showFiat, showNative, - showCurrencySuffix, shouldCheckShowNativeToken, + showCurrencySuffix, privacyMode = false, ...restProps }) { diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index 93c9e09ff0fd..a1c069948074 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -16,6 +16,7 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; +import useMultiPolling from '../../../hooks/useMultiPolling'; import BtcOverview from './btc-overview'; // We need to mock `dispatch` since we use it for `setDefaultHomeActiveTabName`. @@ -33,6 +34,11 @@ jest.mock('../../../store/actions', () => ({ tokenBalancesStopPollingByPollingToken: jest.fn(), })); +jest.mock('../../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const PORTOFOLIO_URL = 'https://portfolio.test'; const BTC_OVERVIEW_BUY = 'coin-overview-buy'; @@ -131,6 +137,23 @@ function makePortfolioUrl(path: string, getParams: Record) { describe('BtcOverview', () => { beforeEach(() => { setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); + // Clear previous mock implementations + (useMultiPolling as jest.Mock).mockClear(); + + // Mock implementation for useMultiPolling + (useMultiPolling as jest.Mock).mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem: string) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); }); it('shows the primary balance as BTC when showNativeTokenAsMainBalance if true', async () => { diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index 88696a14e83f..c3708eaef344 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -54,6 +54,8 @@ import { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) getMemoizedUnapprovedTemplatedConfirmations, ///: END:ONLY_INCLUDE_IF + getNetworkConfigurationIdByChainId, + getCurrentChainId, } from '../../../selectors'; import Tooltip from '../../ui/tooltip'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -82,11 +84,15 @@ import useRamps from '../../../hooks/ramps/useRamps/useRamps'; import useBridging from '../../../hooks/bridge/useBridging'; ///: END:ONLY_INCLUDE_IF import { ReceiveModal } from '../../multichain/receive-modal'; -///: BEGIN:ONLY_INCLUDE_IF(build-flask) import { + setActiveNetwork, + setSwitchedNetworkDetails, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) sendMultichainTransaction, setDefaultHomeActiveTabName, + ///: END:ONLY_INCLUDE_IF } from '../../../store/actions'; +///: BEGIN:ONLY_INCLUDE_IF(build-flask) import { BITCOIN_WALLET_SNAP_ID } from '../../../../shared/lib/accounts/bitcoin-wallet-snap'; ///: END:ONLY_INCLUDE_IF import { @@ -132,6 +138,11 @@ const CoinButtons = ({ const { address: selectedAddress } = account; const history = useHistory(); + const networks = useSelector(getNetworkConfigurationIdByChainId) as Record< + string, + string + >; + const currentChainId = useSelector(getCurrentChainId); ///: BEGIN:ONLY_INCLUDE_IF(build-flask) const currentActivityTabName = useSelector( // @ts-expect-error TODO: fix state type @@ -315,6 +326,18 @@ const CoinButtons = ({ }, [unapprovedTemplatedConfirmations, history]); ///: END:ONLY_INCLUDE_IF + const setCorrectChain = useCallback(async () => { + if (currentChainId !== chainId) { + const networkConfigurationId = networks[chainId]; + await dispatch(setActiveNetwork(networkConfigurationId)); + await dispatch( + setSwitchedNetworkDetails({ + networkClientId: networkConfigurationId, + }), + ); + } + }, [currentChainId, chainId, networks, dispatch]); + const handleSendOnClick = useCallback(async () => { trackEvent( { @@ -352,13 +375,29 @@ const CoinButtons = ({ } ///: END:ONLY_INCLUDE_IF default: { + trackEvent( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + token_symbol: 'ETH', + location: 'Home', + text: 'Send', + chain_id: chainId, + }, + }, + { excludeMetaMetricsId: false }, + ); + await setCorrectChain(); await dispatch(startNewDraftTransaction({ type: AssetType.native })); history.push(SEND_ROUTE); } } - }, [chainId, account]); + }, [chainId, account, setCorrectChain]); const handleSwapOnClick = useCallback(async () => { + await setCorrectChain(); + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) global.platform.openTab({ url: `${mmiPortfolioUrl}/swap`, @@ -388,6 +427,7 @@ const CoinButtons = ({ } ///: END:ONLY_INCLUDE_IF }, [ + setCorrectChain, isSwapsChain, chainId, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) diff --git a/ui/components/app/wallet-overview/eth-overview.test.js b/ui/components/app/wallet-overview/eth-overview.test.js index 40c0b818649c..5af6990d4499 100644 --- a/ui/components/app/wallet-overview/eth-overview.test.js +++ b/ui/components/app/wallet-overview/eth-overview.test.js @@ -1,12 +1,13 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent, waitFor, act } from '@testing-library/react'; import { EthAccountType, EthMethod } from '@metamask/keyring-api'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { renderWithProvider } from '../../../../test/jest/rendering'; import { KeyringType } from '../../../../shared/constants/keyring'; import { useIsOriginalNativeTokenSymbol } from '../../../hooks/useIsOriginalNativeTokenSymbol'; +import useMultiPolling from '../../../hooks/useMultiPolling'; import { defaultBuyableChains } from '../../../ducks/ramps/constants'; import { ETH_EOA_METHODS } from '../../../../shared/constants/eth-methods'; import { getIntlLocale } from '../../../ducks/locale/locale'; @@ -42,6 +43,11 @@ jest.mock('../../../store/actions', () => ({ tokenBalancesStopPollingByPollingToken: jest.fn(), })); +jest.mock('../../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const mockGetIntlLocale = getIntlLocale; let openTabSpy; @@ -160,6 +166,23 @@ describe('EthOverview', () => { beforeEach(() => { openTabSpy.mockClear(); + // Clear previous mock implementations + useMultiPolling.mockClear(); + + // Mock implementation for useMultiPolling + useMultiPolling.mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); }); it('should show the primary balance', async () => { @@ -283,14 +306,16 @@ describe('EthOverview', () => { expect(swapButton).toBeInTheDocument(); expect(swapButton).not.toBeDisabled(); - fireEvent.click(swapButton); - expect(openTabSpy).toHaveBeenCalledTimes(1); + await act(async () => { + fireEvent.click(swapButton); + }); - await waitFor(() => + await waitFor(() => { + expect(openTabSpy).toHaveBeenCalledTimes(1); expect(openTabSpy).toHaveBeenCalledWith({ url: 'https://metamask-institutional.io/swap', - }), - ); + }); + }); }); it('should have the Bridge button disabled if chain id is not part of supported chains', () => { diff --git a/ui/components/multichain/account-overview/account-overview-tabs.tsx b/ui/components/multichain/account-overview/account-overview-tabs.tsx index 8c356f9f9141..994bb3d22692 100644 --- a/ui/components/multichain/account-overview/account-overview-tabs.tsx +++ b/ui/components/multichain/account-overview/account-overview-tabs.tsx @@ -149,10 +149,8 @@ export const AccountOverviewTabs = ({ - history.push(`${ASSET_ROUTE}/${asset}`) + history.push(`${ASSET_ROUTE}/${chainId}/${asset}`) } /> { diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-amount.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-amount.tsx index 24ba5067971d..07d564daca7f 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-amount.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-amount.tsx @@ -49,6 +49,7 @@ type AssetPickerAmountProps = OverridingUnion< asset: Asset; amount: Amount; isAmountLoading?: boolean; + action?: 'send' | 'receive'; error?: string; /** * Callback for when the amount changes; disables the input when undefined @@ -65,6 +66,7 @@ export const AssetPickerAmount = ({ asset, amount, onAmountChange, + action, isAmountLoading, error: passedError, ...assetPickerProps @@ -210,7 +212,11 @@ export const AssetPickerAmount = ({ paddingTop={asset.details?.standard === TokenStandard.ERC721 ? 4 : 1} paddingBottom={asset.details?.standard === TokenStandard.ERC721 ? 4 : 1} > - + ) : ( diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx index d9a1a3a08588..729a580f269e 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx @@ -68,6 +68,7 @@ type AssetPickerModalProps = { header: JSX.Element | string | null; isOpen: boolean; onClose: () => void; + action?: 'send' | 'receive'; asset?: ERC20Asset | NativeAsset | Pick; onAssetChange: ( asset: AssetWithDisplayData | AssetWithDisplayData, @@ -102,6 +103,7 @@ export function AssetPickerModal({ onAssetChange, sendingAsset, network, + action, onNetworkPickerClick, customTokenListGenerator, ...tabProps @@ -262,6 +264,10 @@ export function AssetPickerModal({ for (const token of (customTokenListGenerator ?? tokenListGenerator)( shouldAddToken, )) { + if (action === 'send' && token.balance === undefined) { + continue; + } + filteredTokensAddresses.add(token.address?.toLowerCase()); filteredTokens.push( customTokenListGenerator diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx index e2965687fed5..ff665ce0f8f8 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx @@ -66,6 +66,7 @@ export type AssetPickerProps = { ) => void; onClick?: () => void; isDisabled?: boolean; + action?: 'send' | 'receive'; networkProps?: Pick< React.ComponentProps, 'network' | 'networks' | 'onNetworkChange' @@ -82,6 +83,7 @@ export function AssetPicker({ onAssetChange, networkProps, sendingAsset, + action, onClick, isDisabled = false, visibleTabs, @@ -143,6 +145,7 @@ export function AssetPicker({ setShowAssetPickerModal(false)} asset={asset} diff --git a/ui/components/multichain/network-list-menu/network-list-menu.test.js b/ui/components/multichain/network-list-menu/network-list-menu.test.js index c140189cbf81..93a55538a4ab 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.test.js +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -21,6 +21,7 @@ const mockSetNetworkClientIdForDomain = jest.fn(); const mockSetActiveNetwork = jest.fn(); const mockUpdateCustomNonce = jest.fn(); const mockSetNextNonce = jest.fn(); +const mockSetTokenNetworkFilter = jest.fn(); jest.mock('../../../store/actions.ts', () => ({ setShowTestNetworks: () => mockSetShowTestNetworks, @@ -30,6 +31,7 @@ jest.mock('../../../store/actions.ts', () => ({ setNextNonce: () => mockSetNextNonce, setNetworkClientIdForDomain: (network, id) => mockSetNetworkClientIdForDomain(network, id), + setTokenNetworkFilter: () => mockSetTokenNetworkFilter, })); const MOCK_ORIGIN = 'https://portfolio.metamask.io'; @@ -134,6 +136,10 @@ const render = ({ selectedNetworkClientId: NETWORK_TYPES.MAINNET, preferences: { showTestNetworks, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, }, useRequestQueue: true, domains: { diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 518f9f19387a..4dcecbf696e7 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -28,6 +28,7 @@ import { showPermittedNetworkToast, updateCustomNonce, setNextNonce, + setTokenNetworkFilter, } from '../../../store/actions'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, @@ -50,6 +51,7 @@ import { getAllDomains, getPermittedChainsForSelectedTab, getPermittedAccountsForSelectedTab, + getPreferences, } from '../../../selectors'; import ToggleButton from '../../ui/toggle-button'; import { @@ -111,6 +113,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); + const { tokenNetworkFilter } = useSelector(getPreferences); const showTestNetworks = useSelector(getShowTestNetworks); const currentChainId = useSelector(getCurrentChainId); const selectedTabOrigin = useSelector(getOriginOfCurrentTab); @@ -123,6 +126,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const completedOnboarding = useSelector(getCompletedOnboarding); const onboardedInThisUISession = useSelector(getOnboardedInThisUISession); const showNetworkBanner = useSelector(getShowNetworkBanner); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const { chainId: editingChainId, editCompleted } = useSelector(getEditedNetwork) ?? {}; @@ -253,6 +257,11 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const canDeleteNetwork = isUnlocked && !isCurrentNetwork && network.chainId !== CHAIN_IDS.MAINNET; + const allOpts: Record = {}; + Object.keys(allNetworks).forEach((chainId) => { + allOpts[chainId] = true; + }); + return ( void }) => { dispatch(updateCustomNonce('')); dispatch(setNextNonce('')); + // as a user, I don't want my network selection to force update my filter when I have "All Networks" toggled on + // however, if I am already filtered on "Current Network", we'll want to filter by the selected network when the network changes + if (Object.keys(tokenNetworkFilter).length <= 1) { + dispatch(setTokenNetworkFilter({ [network.chainId]: true })); + } else { + dispatch(setTokenNetworkFilter(allOpts)); + } + if (permittedAccountAddresses.length > 0) { grantPermittedChain(selectedTabOrigin, network.chainId); if (!permittedChainIds.includes(network.chainId)) { diff --git a/ui/components/multichain/pages/send/components/recipient-content.tsx b/ui/components/multichain/pages/send/components/recipient-content.tsx index 5c32bb5f3b66..6fb1b00134a4 100644 --- a/ui/components/multichain/pages/send/components/recipient-content.tsx +++ b/ui/components/multichain/pages/send/components/recipient-content.tsx @@ -165,6 +165,7 @@ export const SendPageRecipientContent = ({ { {isSendFormShown && ( Ethereum Mainnet logo diff --git a/ui/components/multichain/token-list-item/token-list-item.test.tsx b/ui/components/multichain/token-list-item/token-list-item.test.tsx index 7864c221e90e..6f08f276a302 100644 --- a/ui/components/multichain/token-list-item/token-list-item.test.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.test.tsx @@ -1,12 +1,17 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; - import { fireEvent, waitFor } from '@testing-library/react'; +import { useSelector } from 'react-redux'; import { renderWithProvider } from '../../../../test/lib/render-helpers'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { getIntlLocale } from '../../../ducks/locale/locale'; import { mockNetworkState } from '../../../../test/stub/networks'; import { useSafeChains } from '../../../pages/settings/networks-tab/networks-form/use-safe-chains'; +import { + getCurrencyRates, + getNetworkConfigurationIdByChainId, +} from '../../../selectors'; +import { getMultichainIsEvm } from '../../../selectors/multichain'; import { TokenListItem } from '.'; const state = { @@ -55,6 +60,13 @@ jest.mock( }), }), ); +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: jest.fn(), + }; +}); const mockGetIntlLocale = getIntlLocale; const mockGetSafeChains = useSafeChains; @@ -70,9 +82,19 @@ describe('TokenListItem', () => { tokenImage: '', title: '', chainId: '0x1', + tokenChainImage: './eth-logo.png', }; it('should render correctly', () => { const store = configureMockStore()(state); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === getNetworkConfigurationIdByChainId) { + return '0x1'; + } + if (selector === getMultichainIsEvm) { + return true; + } + return undefined; + }); const { getByTestId, container } = renderWithProvider( , store, @@ -111,6 +133,15 @@ describe('TokenListItem', () => { it('should display warning scam modal', () => { const store = configureMockStore()(state); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === getCurrencyRates) { + return { ETH: '' }; + } + if (selector === getMultichainIsEvm) { + return true; + } + return undefined; + }); const propsToUse = { primary: '11.9751 ETH', isNativeCurrency: true, 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 1f9f0b18bed5..540d2d8be98a 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -41,14 +41,13 @@ import { import { getMetaMetricsId, getTestNetworkBackgroundColor, - getTokensMarketData, getParticipateInMetaMetrics, getDataCollectionForMarketing, + getMarketData, + getNetworkConfigurationIdByChainId, + getCurrencyRates, } from '../../../selectors'; -import { - getMultichainCurrentNetwork, - getMultichainIsEvm, -} from '../../../selectors/multichain'; +import { getMultichainIsEvm } from '../../../selectors/multichain'; import Tooltip from '../../ui/tooltip'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { MetaMetricsContext } from '../../../contexts/metametrics'; @@ -77,10 +76,10 @@ type TokenListItemProps = { secondary?: string | null; title: string; tooltipText?: string; - chainId: string; - isOriginalTokenSymbol?: boolean | null; isNativeCurrency?: boolean; isStakeable?: boolean; + tokenChainImage?: string; + chainId: string; address?: string | null; showPercentage?: boolean; isPrimaryTokenSymbolHidden?: boolean; @@ -96,8 +95,8 @@ export const TokenListItem = ({ secondary, title, tooltipText, + tokenChainImage, chainId, - isOriginalTokenSymbol, isPrimaryTokenSymbolHidden = false, isNativeCurrency = false, isStakeable = false, @@ -112,6 +111,7 @@ export const TokenListItem = ({ const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); const { safeChains } = useSafeChains(); + const currencyRates = useSelector(getCurrencyRates); const decimalChainId = isEvm && parseInt(hexToDecimal(chainId), 10); @@ -126,6 +126,8 @@ export const TokenListItem = ({ // we only use this option for EVM here: const shouldShowPercentage = isEvm && showPercentage; + const isOriginalTokenSymbol = tokenSymbol && currencyRates[tokenSymbol]; + // Scam warning const showScamWarning = isNativeCurrency && !isOriginalTokenSymbol && shouldShowPercentage; @@ -135,10 +137,6 @@ export const TokenListItem = ({ const history = useHistory(); const getTokenTitle = () => { - if (!isOriginalTokenSymbol) { - return title; - } - // We only consider native token symbols! switch (title) { case CURRENCY_SYMBOLS.ETH: return t('networkNameEthereum'); @@ -149,10 +147,10 @@ export const TokenListItem = ({ } }; - const tokensMarketData = useSelector(getTokensMarketData); + const multiChainMarketData = useSelector(getMarketData); const tokenPercentageChange = address - ? tokensMarketData?.[address]?.pricePercentChange1d + ? multiChainMarketData?.[chainId]?.[address]?.pricePercentChange1d : null; const tokenTitle = getTokenTitle(); @@ -212,7 +210,9 @@ export const TokenListItem = ({ ); // Used for badge icon - const currentNetwork = useSelector(getMultichainCurrentNetwork); + const allNetworks: Record = useSelector( + getNetworkConfigurationIdByChainId, + ); const testNetworkBackgroundColor = useSelector(getTestNetworkBackgroundColor); return ( @@ -264,8 +264,8 @@ export const TokenListItem = ({ badge={ @@ -336,7 +336,8 @@ export const TokenListItem = ({ - {primary}{' '} - {isNativeCurrency || isPrimaryTokenSymbolHidden - ? '' - : tokenSymbol} + {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
) : ( @@ -421,10 +419,7 @@ export const TokenListItem = ({ isHidden={privacyMode} length={SensitiveTextLength.Short} > - {primary}{' '} - {isNativeCurrency || isPrimaryTokenSymbolHidden - ? '' - : tokenSymbol} + {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
)} diff --git a/ui/hooks/useAccountTotalFiatBalance.test.js b/ui/hooks/useAccountTotalFiatBalance.test.js index 6ac93cd08e33..b7913ac662f6 100644 --- a/ui/hooks/useAccountTotalFiatBalance.test.js +++ b/ui/hooks/useAccountTotalFiatBalance.test.js @@ -25,6 +25,24 @@ const renderUseAccountTotalFiatBalance = (address) => { conversionRate: 1612.92, }, }, + allTokens: { + [CHAIN_IDS.MAINNET]: { + [mockAccount.address]: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + aggregators: [], + decimals: 18, + symbol: 'YFI', + }, + ], + }, + }, internalAccounts: { accounts: { [mockAccount.id]: mockAccount, @@ -47,10 +65,18 @@ const renderUseAccountTotalFiatBalance = (address) => { }, }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xbdbd', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501b4176a64d6', + }, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - allTokens: { - '0x1': { + detectedTokens: { + [CHAIN_IDS.MAINNET]: { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -67,14 +93,6 @@ const renderUseAccountTotalFiatBalance = (address) => { ], }, }, - tokenBalances: { - [mockAccount.address]: { - [CHAIN_IDS.MAINNET]: { - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', - '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', - }, - }, - }, }, }; @@ -101,18 +119,18 @@ describe('useAccountTotalFiatBalance', () => { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', balance: '48573', + balanceError: null, decimals: 6, string: 0.04857, - balanceError: null, tokenFiatAmount: '0.05', }, { address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', symbol: 'YFI', balance: '1409247882142934', + balanceError: null, decimals: 18, string: 0.00141, - balanceError: null, tokenFiatAmount: '7.52', }, ], diff --git a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx index e46eff925e50..921baaebab08 100644 --- a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx +++ b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx @@ -23,9 +23,9 @@ const mockTokenBalances = [ address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', symbol: 'YFI', balance: '1409247882142934', + balanceError: null, decimals: 18, string: 0.00141, - balanceError: null, tokenFiatAmount: '7.52', }, ]; @@ -49,6 +49,24 @@ const renderUseMultichainAccountTotalFiatBalance = ( metamask: { ...mockState.metamask, completedOnboarding: true, + allTokens: { + [CHAIN_IDS.MAINNET]: { + [mockAccount.address]: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + aggregators: [], + decimals: 18, + symbol: 'YFI', + }, + ], + }, + }, internalAccounts: { accounts: { [mockAccount.id]: mockAccount, @@ -92,10 +110,17 @@ const renderUseMultichainAccountTotalFiatBalance = ( }, }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xbdbd', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501b4176a64d6', + }, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - - allTokens: { - '0x1': { + detectedTokens: { + [CHAIN_IDS.MAINNET]: { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -112,14 +137,6 @@ const renderUseMultichainAccountTotalFiatBalance = ( ], }, }, - tokenBalances: { - [mockAccount.address]: { - [CHAIN_IDS.MAINNET]: { - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', - '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', - }, - }, - }, }, }; diff --git a/ui/pages/asset/asset.tsx b/ui/pages/asset/asset.tsx index 933c39872265..1d1acdaaa582 100644 --- a/ui/pages/asset/asset.tsx +++ b/ui/pages/asset/asset.tsx @@ -1,32 +1,36 @@ import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Redirect, useParams } from 'react-router-dom'; +import { Hex } from '@metamask/utils'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; import NftDetails from '../../components/app/assets/nfts/nft-details/nft-details'; -import { - getNativeCurrency, - getNfts, - getTokens, -} from '../../ducks/metamask/metamask'; +import { getSelectedAccountTokensAcrossChains } from '../../selectors'; +import { getNFTsByChainId } from '../../ducks/metamask/metamask'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; -import NativeAsset from './components/native-asset'; +import { Token } from '../../components/app/assets/token-list/token-list'; import TokenAsset from './components/token-asset'; +import { findAssetByAddress } from './util'; +import NativeAsset from './components/native-asset'; /** A page representing a native, token, or NFT asset */ const Asset = () => { - const nativeCurrency = useSelector(getNativeCurrency); - const tokens = useSelector(getTokens); - const nfts = useSelector(getNfts); - const { asset, id } = useParams<{ asset: string; id: string }>(); - - const token = tokens.find(({ address }: { address: string }) => - // @ts-expect-error TODO: Fix this type error by handling undefined parameters - isEqualCaseInsensitive(address, asset), - ); + const selectedAccountTokensChains: Record = useSelector( + getSelectedAccountTokensAcrossChains, + ) as Record; + const params = useParams<{ + chainId: Hex; + asset: string; + id: string; + }>(); + const { chainId, asset, id } = params; + + const nfts = useSelector((state) => getNFTsByChainId(state, chainId)); + + const token = findAssetByAddress(selectedAccountTokensChains, asset, chainId); const nft = nfts.find( - ({ address, tokenId }: { address: string; tokenId: string }) => + ({ address, tokenId }: { address: Hex; tokenId: string }) => // @ts-expect-error TODO: Fix this type error by handling undefined parameters isEqualCaseInsensitive(address, asset) && id === tokenId.toString(), ); @@ -39,10 +43,12 @@ const Asset = () => { let content; if (nft) { content = ; - } else if (token) { - content = ; - } else if (asset === nativeCurrency) { - content = ; + } else if (token && chainId) { + if (token?.address) { + content = ; + } else { + content = ; + } } else { content = ; } 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 8a5c60e340cb..a5965d2fbe30 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -180,9 +180,10 @@ exports[`AssetPage should render a native asset 1`] = ` class="mm-box multichain-token-list-item mm-box--display-flex mm-box--gap-4 mm-box--flex-direction-column" data-testid="multichain-token-list-item" > -
Ethereum Mainnet logo @@ -221,12 +222,14 @@ exports[`AssetPage should render a native asset 1`] = ` > TEST -

- TEST -

+

+

+ > + $0.00 +

- 0 TEST + 0 - + TEST

@@ -249,7 +254,7 @@ exports[`AssetPage should render a native asset 1`] = ` class="mm-box mm-box--display-flex mm-box--gap-1 mm-box--flex-direction-row mm-box--justify-content-space-between" /> - +
diff --git a/ui/pages/asset/components/asset-page.test.tsx b/ui/pages/asset/components/asset-page.test.tsx index 5df516184004..60e322f8825e 100644 --- a/ui/pages/asset/components/asset-page.test.tsx +++ b/ui/pages/asset/components/asset-page.test.tsx @@ -11,6 +11,7 @@ import { AssetType } from '../../../../shared/constants/transaction'; import { ETH_EOA_METHODS } from '../../../../shared/constants/eth-methods'; import { setBackgroundConnection } from '../../../store/background-connection'; import { mockNetworkState } from '../../../../test/stub/networks'; +import useMultiPolling from '../../../hooks/useMultiPolling'; import AssetPage from './asset-page'; jest.mock('../../../store/actions', () => ({ @@ -39,6 +40,11 @@ jest.mock('../../../../shared/constants/network', () => ({ }, })); +jest.mock('../../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + describe('AssetPage', () => { const mockStore = { localeMessages: { @@ -46,12 +52,28 @@ describe('AssetPage', () => { }, metamask: { tokenList: {}, + tokenBalances: {}, + marketData: {}, + allTokens: {}, + accountsByChainId: { + '0x1': { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + balance: '0x00', + }, + }, + }, currentCurrency: 'usd', accounts: {}, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), currencyRates: { + TEST: { + conversionRate: 123, + ticker: 'ETH', + }, ETH: { conversionRate: 123, + ticker: 'ETH', }, }, useCurrencyRateCheck: true, @@ -59,7 +81,7 @@ describe('AssetPage', () => { internalAccounts: { accounts: { 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x1', + address: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', metadata: { name: 'Test Account', @@ -125,6 +147,23 @@ describe('AssetPage', () => { formatRangeToParts: jest.fn(), }; }); + // Clear previous mock implementations + (useMultiPolling as jest.Mock).mockClear(); + + // Mock implementation for useMultiPolling + (useMultiPolling as jest.Mock).mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem: string) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); }); afterEach(() => { @@ -287,6 +326,10 @@ describe('AssetPage', () => { , store, ); + const dynamicImages = container.querySelectorAll('img[alt*="logo"]'); + dynamicImages.forEach((img) => { + img.setAttribute('alt', 'static-logo'); + }); expect(container).toMatchSnapshot(); }); @@ -322,54 +365,67 @@ describe('AssetPage', () => { expect(chart).toBeNull(); }); + const dynamicImages = container.querySelectorAll('img[alt*="logo"]'); + dynamicImages.forEach((img) => { + img.setAttribute('alt', 'static-logo'); + }); + const elementsWithAria = container.querySelectorAll('[aria-describedby]'); + elementsWithAria.forEach((el) => + el.setAttribute('aria-describedby', 'static-tooltip-id'), + ); + expect(container).toMatchSnapshot(); }); it('should render an ERC20 token with prices', async () => { - // jest.useFakeTimers(); - try { - const address = '0xe4246B1Ac0Ba6839d9efA41a8A30AE3007185f55'; - const marketCap = 456; - - // Mock price history - nock('https://price.api.cx.metamask.io') - .get(`/v1/chains/${CHAIN_IDS.MAINNET}/historical-prices/${address}`) - .query(true) - .reply(200, { prices: [[1, 1]] }); - - const { queryByTestId, container } = renderWithProvider( - , - configureMockStore([thunk])({ - ...mockStore, - metamask: { - ...mockStore.metamask, - marketData: { - [CHAIN_IDS.MAINNET]: { - [address]: { - price: 123, - marketCap, - }, + const address = '0xe4246B1Ac0Ba6839d9efA41a8A30AE3007185f55'; + const marketCap = 456; + + // Mock price history + nock('https://price.api.cx.metamask.io') + .get(`/v1/chains/${CHAIN_IDS.MAINNET}/historical-prices/${address}`) + .query(true) + .reply(200, { prices: [[1, 1]] }); + + const { queryByTestId, container } = renderWithProvider( + , + configureMockStore([thunk])({ + ...mockStore, + metamask: { + ...mockStore.metamask, + marketData: { + [CHAIN_IDS.MAINNET]: { + [address]: { + price: 123, + marketCap, + currency: 'ETH', }, }, }, - }), - ); + }, + }), + ); - // Verify chart is rendered - await waitFor(() => { - const chart = queryByTestId('asset-price-chart'); - expect(chart).toHaveClass('mm-box--background-color-transparent'); - }); + // Verify chart is rendered + await waitFor(() => { + const chart = queryByTestId('asset-price-chart'); + expect(chart).toHaveClass('mm-box--background-color-transparent'); + }); - // Verify market data is rendered - const marketCapElement = queryByTestId('asset-market-cap'); - expect(marketCapElement).toHaveTextContent( - `${marketCap * mockStore.metamask.currencyRates.ETH.conversionRate}`, - ); + // Verify market data is rendered + const marketCapElement = queryByTestId('asset-market-cap'); + expect(marketCapElement).toHaveTextContent( + `${marketCap * mockStore.metamask.currencyRates.ETH.conversionRate}`, + ); - expect(container).toMatchSnapshot(); - } finally { - // jest.useRealTimers(); - } + const dynamicImages = container.querySelectorAll('img[alt*="logo"]'); + dynamicImages.forEach((img) => { + img.setAttribute('alt', 'static-logo'); + }); + const elementsWithAria = container.querySelectorAll('[aria-describedby]'); + elementsWithAria.forEach((el) => + el.setAttribute('aria-describedby', 'static-tooltip-id'), + ); + expect(container).toMatchSnapshot(); }); }); diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 818ceb792ec3..d7ad8e568f4e 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -1,9 +1,10 @@ import React, { ReactNode, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; - import { useSelector } from 'react-redux'; import { EthMethod } from '@metamask/keyring-api'; import { isEqual } from 'lodash'; +import { Hex } from '@metamask/utils'; +import { zeroAddress } from 'ethereumjs-util'; import { getCurrentCurrency, getDataCollectionForMarketing, @@ -13,7 +14,10 @@ import { getParticipateInMetaMetrics, getSelectedInternalAccount, getSwapsDefaultToken, - getTokensMarketData, + getMarketData, + getCurrencyRates, + getSelectedAccountNativeTokenCachedBalanceByChainId, + getSelectedAccount, } from '../../../selectors'; import { Display, @@ -33,10 +37,7 @@ import { } from '../../../components/component-library'; import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { - AddressCopyButton, - TokenListItem, -} from '../../../components/multichain'; +import { AddressCopyButton } from '../../../components/multichain'; import { AssetType } from '../../../../shared/constants/transaction'; import TokenCell from '../../../components/app/assets/token-cell'; import TransactionList from '../../../components/app/transaction-list'; @@ -46,6 +47,8 @@ import { getConversionRate } from '../../../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import CoinButtons from '../../../components/app/wallet-overview/coin-buttons'; import { getIsNativeTokenBuyable } from '../../../ducks/ramps'; +import { calculateTokenBalance } from '../../../components/app/assets/util/calculateTokenBalance'; +import { useTokenBalances } from '../../../hooks/useTokenBalances'; import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; import AssetChart from './chart/asset-chart'; import TokenButtons from './token-buttons'; @@ -56,6 +59,7 @@ export type Asset = ( type: AssetType.native; /** Whether the symbol has been verified to match the chain */ isOriginalNativeSymbol: boolean; + decimals: number; } | { type: AssetType.token; @@ -68,29 +72,16 @@ export type Asset = ( } ) & { /** The hexadecimal chain id */ - chainId: `0x${string}`; + chainId: Hex; /** The asset's symbol, e.g. 'ETH' */ symbol: string; /** The asset's name, e.g. 'Ethereum' */ name?: string; /** A URL to the asset's image */ image: string; - balance: { - /** - * A decimal representation of the balance before applying - * decimals e.g. '12300000000000000' for 0.0123 ETH - */ - value: string; - /** - * A displayable representation of the balance after applying - * decimals e.g. '0.0123' for 12300000000000000 WEI - */ - display: string; - /** The balance's localized value in fiat e.g. '$12.34' or '56,78 €' */ - fiat?: string; - }; /** True if the asset implements ERC721 */ isERC721?: boolean; + balance?: { value: string; display: string; fiat: string }; }; // A page representing a native or token asset @@ -103,9 +94,9 @@ const AssetPage = ({ }) => { const t = useI18nContext(); const history = useHistory(); + const selectedAccount = useSelector(getSelectedAccount); const currency = useSelector(getCurrentCurrency); const conversionRate = useSelector(getConversionRate); - const allMarketData = useSelector(getTokensMarketData); const isBridgeChain = useSelector(getIsBridgeChain); const isBuyableChain = useSelector(getIsNativeTokenBuyable); const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual); @@ -115,24 +106,70 @@ const AssetPage = ({ account.methods.includes(EthMethod.SignTransaction) || account.methods.includes(EthMethod.SignUserOperation); + const marketData = useSelector(getMarketData); + const currencyRates = useSelector(getCurrencyRates); + + const nativeBalances: Record = useSelector( + getSelectedAccountNativeTokenCachedBalanceByChainId, + ) as Record; + + const { tokenBalances } = useTokenBalances(); + const selectedAccountTokenBalancesAcrossChains = + tokenBalances[selectedAccount.address]; + + const { chainId, type, symbol, name, image, decimals } = asset; const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); const metaMetricsId = useSelector(getMetaMetricsId); - const { chainId, type, symbol, name, image, balance } = asset; - const address = type === AssetType.token ? toChecksumHexAddress(asset.address) - : '0x0000000000000000000000000000000000000000'; + : zeroAddress(); + + const balance = calculateTokenBalance({ + isNative: type === AssetType.native, + chainId, + address: address as Hex, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + }); - const marketData = allMarketData?.[address]; + // Market and conversion rate data + const baseCurrency = marketData[chainId]?.[address]?.currency; + const tokenMarketPrice = marketData[chainId]?.[address]?.price || 0; + const tokenExchangeRate = + type === AssetType.native + ? currencyRates[symbol]?.conversionRate + : currencyRates[baseCurrency]?.conversionRate || 0; + + // Calculate fiat amount + const tokenFiatAmount = + tokenMarketPrice * tokenExchangeRate * parseFloat(String(balance)); const currentPrice = - conversionRate !== undefined && marketData?.price !== undefined - ? conversionRate * marketData.price + tokenExchangeRate !== undefined && tokenMarketPrice !== undefined + ? tokenExchangeRate * tokenMarketPrice : undefined; + const tokenMarketDetails = marketData[chainId]?.[address]; + const shouldDisplayMarketData = + conversionRate > 0 && + tokenMarketDetails && + (tokenMarketDetails.marketCap > 0 || + tokenMarketDetails.totalVolume > 0 || + tokenMarketDetails.circulatingSupply > 0 || + tokenMarketDetails.allTimeHigh > 0 || + tokenMarketDetails.allTimeLow > 0); + + // this is needed in order to assign the correct balances to TokenButtons before sending/swapping + // without this, the balances we be populated as zero until the user refreshes the screen: https://github.com/MetaMask/metamask-extension/issues/28509 + asset.balance = { + value: '', // decimal value not needed + display: String(balance), + fiat: String(tokenFiatAmount), + }; const portfolioSpendingCapsUrl = useMemo( () => getPortfolioUrl( @@ -211,26 +248,15 @@ const AssetPage = ({ {t('yourBalance')} - {type === AssetType.native ? ( - - ) : ( - - )} + )} - {conversionRate > 0 && - (marketData?.marketCap > 0 || - marketData?.totalVolume > 0 || - marketData?.circulatingSupply > 0 || - marketData?.allTimeHigh > 0 || - marketData?.allTimeLow > 0) && ( - - - {t('marketDetails')} - - - {marketData?.marketCap > 0 && - renderRow( - t('marketCap'), - - {localizeLargeNumber( - t, - conversionRate * marketData.marketCap, - )} - , - )} - {marketData?.totalVolume > 0 && - renderRow( - t('totalVolume'), - - {localizeLargeNumber( - t, - conversionRate * marketData.totalVolume, - )} - , - )} - {marketData?.circulatingSupply > 0 && - renderRow( - t('circulatingSupply'), - - {localizeLargeNumber(t, marketData.circulatingSupply)} - , - )} - {marketData?.allTimeHigh > 0 && - renderRow( - t('allTimeHigh'), - - {formatCurrency( - `${conversionRate * marketData.allTimeHigh}`, - currency, - getPricePrecision( - conversionRate * marketData.allTimeHigh, - ), - )} - , - )} - {marketData?.allTimeLow > 0 && - renderRow( - t('allTimeLow'), - - {formatCurrency( - `${conversionRate * marketData.allTimeLow}`, - currency, - getPricePrecision( - conversionRate * marketData.allTimeLow, - ), - )} - , - )} - + {shouldDisplayMarketData && ( + + + {t('marketDetails')} + + + {tokenMarketDetails.marketCap > 0 && + renderRow( + t('marketCap'), + + {localizeLargeNumber( + t, + tokenExchangeRate * tokenMarketDetails.marketCap, + )} + , + )} + {tokenMarketDetails.totalVolume > 0 && + renderRow( + t('totalVolume'), + + {localizeLargeNumber( + t, + tokenExchangeRate * tokenMarketDetails.totalVolume, + )} + , + )} + {tokenMarketDetails.circulatingSupply > 0 && + renderRow( + t('circulatingSupply'), + + {localizeLargeNumber( + t, + tokenMarketDetails.circulatingSupply, + )} + , + )} + {tokenMarketDetails.allTimeHigh > 0 && + renderRow( + t('allTimeHigh'), + + {formatCurrency( + `${tokenExchangeRate * tokenMarketDetails.allTimeHigh}`, + currency, + getPricePrecision( + tokenExchangeRate * tokenMarketDetails.allTimeHigh, + ), + )} + , + )} + {tokenMarketDetails.allTimeLow > 0 && + renderRow( + t('allTimeLow'), + + {formatCurrency( + `${tokenExchangeRate * tokenMarketDetails.allTimeLow}`, + currency, + getPricePrecision( + tokenExchangeRate * tokenMarketDetails.allTimeLow, + ), + )} + , + )} - )} + + )} { - const nativeCurrency = useSelector(getNativeCurrency); - const balance = useSelector(getSelectedAccountCachedBalance); - const image = useSelector(getNativeCurrencyImage); - const showFiat = useSelector(getShouldShowFiat); - const currentCurrency = useSelector(getCurrentCurrency); - const chainId = useSelector(getCurrentChainId); - const { ticker, type } = useSelector(getProviderConfig) ?? {}; +const NativeAsset = ({ token, chainId }: { token: Token; chainId: Hex }) => { + const { symbol } = token; + const image = getNativeCurrencyForChain(chainId); + const { type } = useSelector(getProviderConfig) ?? {}; const { address } = useSelector(getSelectedInternalAccount); const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); @@ -39,29 +28,18 @@ const NativeAsset = () => { const trackEvent = useContext(MetaMetricsContext); const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( chainId, - ticker, + symbol, type, ); - const [, { value: balanceDisplay }] = useCurrencyDisplay(balance, { - currency: nativeCurrency, - }); - const [fiatDisplay] = useCurrencyDisplay(balance, { - currency: currentCurrency, - }); - return ( { +const TokenAsset = ({ token, chainId }: { token: Token; chainId: Hex }) => { const { address, symbol, isERC721 } = token; const tokenList = useSelector(getTokenList); - const chainId = useSelector(getCurrentChainId); - const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); + const allNetworks: { + [key: `0x${string}`]: NetworkConfiguration; + } = useSelector(getNetworkConfigurationsByChainId); + // get the correct rpc url for the current token + const defaultIdx = allNetworks[chainId]?.defaultBlockExplorerUrlIndex; + const currentTokenBlockExplorer = + defaultIdx === undefined + ? null + : allNetworks[chainId]?.blockExplorerUrls[defaultIdx]; + const { address: walletAddress } = useSelector(getSelectedInternalAccount); + const erc20TokensByChain = useSelector(selectERC20TokensByChain); const history = useHistory(); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); - const { name, iconUrl, aggregators } = - Object.values(tokenList).find( - (t) => - isEqualCaseInsensitive(t.symbol, symbol) && - isEqualCaseInsensitive(t.address, address), - ) ?? {}; + // Fetch token data from tokenList + const tokenData = Object.values(tokenList).find( + (t) => + isEqualCaseInsensitive(t.symbol, symbol) && + isEqualCaseInsensitive(t.address, address), + ); + + // If not found in tokenList, try erc20TokensByChain + const tokenDataFromChain = + erc20TokensByChain?.[chainId]?.data?.[address.toLowerCase()]; + + const name = tokenData?.name || tokenDataFromChain?.name || symbol; + const iconUrl = tokenData?.iconUrl || tokenDataFromChain?.iconUrl || ''; + const aggregators = tokenData?.aggregators; const { tokensWithBalances, @@ -55,8 +74,9 @@ const TokenAsset = ({ token }: { token: Token }) => { chainId, '', walletAddress, - rpcPrefs, + { blockExplorerUrl: currentTokenBlockExplorer ?? '' }, ); + console.log(tokenTrackerLink); return ( ; const isSwapsChain = useSelector(getIsSwapsChain); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const isBridgeChain = useSelector(getIsBridgeChain); @@ -114,6 +122,18 @@ const TokenButtons = ({ } }, [token.isERC721, token.address, dispatch]); + const setCorrectChain = async () => { + if (currentChainId !== token.chainId) { + const networkConfigurationId = networks[token.chainId]; + await dispatch(setActiveNetwork(networkConfigurationId)); + await dispatch( + setSwitchedNetworkDetails({ + networkClientId: networkConfigurationId, + }), + ); + } + }; + return ( { @@ -137,7 +157,7 @@ const TokenButtons = ({ properties: { location: 'Token Overview', text: 'Buy', - chain_id: chainId, + chain_id: currentChainId, token_symbol: token.symbol, }, }); @@ -206,12 +226,13 @@ const TokenButtons = ({ token_symbol: token.symbol, location: MetaMetricsSwapsEventSource.TokenView, text: 'Send', - chain_id: chainId, + chain_id: token.chainId, }, }, { excludeMetaMetricsId: false }, ); try { + await setCorrectChain(); await dispatch( startNewDraftTransaction({ type: AssetType.token, @@ -248,7 +269,9 @@ const TokenButtons = ({ size={IconSize.Sm} /> } - onClick={() => { + onClick={async () => { + await setCorrectChain(); + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) global.platform.openTab({ url: `${mmiPortfolioUrl}/swap`, @@ -263,16 +286,16 @@ const TokenButtons = ({ token_symbol: token.symbol, location: MetaMetricsSwapsEventSource.TokenView, text: 'Swap', - chain_id: chainId, + chain_id: currentChainId, }, }); dispatch( setSwapsFromToken({ ...token, - address: token.address.toLowerCase(), + address: token.address?.toLowerCase(), iconUrl: token.image, - balance: token.balance.value, - string: token.balance.display, + balance: token?.balance?.value, + string: token?.balance?.display, }), ); if (usingHardwareWallet) { @@ -309,8 +332,8 @@ const TokenButtons = ({ openBridgeExperience(MetaMetricsSwapsEventSource.TokenView, { ...token, iconUrl: token.image, - balance: token.balance.value, - string: token.balance.display, + balance: token?.balance?.value, + string: token?.balance?.display, name: token.name ?? '', }); }} diff --git a/ui/pages/asset/util.test.ts b/ui/pages/asset/util.test.ts index ecf4f2de5381..e5d4f6964567 100644 --- a/ui/pages/asset/util.test.ts +++ b/ui/pages/asset/util.test.ts @@ -28,7 +28,7 @@ describe('findAssetByAddress', () => { }); it('should return undefined if address is not provided and no token without address is found', () => { - expect(findAssetByAddress(mockTokens, undefined, '0x1')).toBeNull(); + expect(findAssetByAddress(mockTokens, undefined, '0x1')).toBeUndefined(); }); it('should return the token without address if address is not provided and a token without address exists', () => { @@ -37,9 +37,9 @@ describe('findAssetByAddress', () => { { address: '', decimals: 18, symbol: 'NULL', name: 'Token NULL' }, ], }; - expect( - findAssetByAddress(tokensWithNullAddress, undefined, '0x1'), - ).toBeNull(); + expect(findAssetByAddress(tokensWithNullAddress, undefined, '0x1')).toEqual( + { address: '', decimals: 18, name: 'Token NULL', symbol: 'NULL' }, + ); }); it('should return the correct token when address and chainId are provided', () => { diff --git a/ui/pages/asset/util.ts b/ui/pages/asset/util.ts index 479c69015f35..c18d6e92dcfa 100644 --- a/ui/pages/asset/util.ts +++ b/ui/pages/asset/util.ts @@ -83,8 +83,7 @@ export const findAssetByAddress = ( } if (!address) { - console.warn(`No token found for address: ${address}`); - return null; + return tokens.find((token) => !token.address); } return tokens.find( diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index ef0ebbfa9ee3..edf1ff8bbe22 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -363,8 +363,15 @@ export default class Routes extends Component { component={NftFullImage} /> - - + + + balance + */ +export function getSelectedAccountNativeTokenCachedBalanceByChainId(state) { + const { accountsByChainId } = state.metamask; + const { address: selectedAddress } = getSelectedInternalAccount(state); + + const balancesByChainId = {}; + for (const [chainId, accounts] of Object.entries(accountsByChainId || {})) { + if (accounts[selectedAddress]) { + balancesByChainId[chainId] = accounts[selectedAddress].balance; + } + } + return balancesByChainId; +} + +/** + * Based on the current account address, query for all tokens across all chain networks on that account, + * including the native tokens, without hardcoding any native token information. + * + * @param {object} state - Redux state + * @returns {object} An object mapping chain IDs to arrays of tokens (including native tokens) with balances. + */ +export function getSelectedAccountTokensAcrossChains(state) { + const { allTokens } = state.metamask; + const selectedAddress = getSelectedInternalAccount(state).address; + + const tokensByChain = {}; + + const nativeTokenBalancesByChainId = + getSelectedAccountNativeTokenCachedBalanceByChainId(state); + + const chainIds = new Set([ + ...Object.keys(allTokens || {}), + ...Object.keys(nativeTokenBalancesByChainId || {}), + ]); + + chainIds.forEach((chainId) => { + if (!tokensByChain[chainId]) { + tokensByChain[chainId] = []; + } + + if (allTokens[chainId]?.[selectedAddress]) { + allTokens[chainId][selectedAddress].forEach((token) => { + const tokenWithChain = { ...token, chainId, isNative: false }; + tokensByChain[chainId].push(tokenWithChain); + }); + } + + const nativeBalance = nativeTokenBalancesByChainId[chainId]; + if (nativeBalance) { + const nativeTokenInfo = getNativeTokenInfo(state, chainId); + tokensByChain[chainId].push({ + ...nativeTokenInfo, + address: '', + balance: nativeBalance, + chainId, + isNative: true, + }); + } + }); + + return tokensByChain; +} + +/** + * Retrieves native token information (symbol, decimals, name) for a given chainId from the state, + * without hardcoding any values. + * + * @param {object} state - Redux state + * @param {string} chainId - Chain ID + * @returns {object} Native token information + */ +function getNativeTokenInfo(state, chainId) { + const { networkConfigurationsByChainId } = state.metamask; + + const networkConfig = networkConfigurationsByChainId?.[chainId]; + + if (networkConfig) { + const symbol = networkConfig.nativeCurrency || AssetType.native; + const decimals = 18; + const name = networkConfig.name || 'Native Token'; + + return { + symbol, + decimals, + name, + }; + } + + const { provider } = state.metamask; + if (provider?.chainId === chainId) { + const symbol = provider.ticker || AssetType.native; + const decimals = provider.nativeCurrency?.decimals || 18; + const name = provider.nickname || 'Native Token'; + + return { + symbol, + decimals, + name, + }; + } + + return { symbol: AssetType.native, decimals: 18, name: 'Native Token' }; +} + /** * @typedef {import('./selectors.types').InternalAccountWithBalance} InternalAccountWithBalance */ @@ -619,12 +729,6 @@ export const getTokensMarketData = (state) => { return state.metamask.marketData?.[chainId]; }; -/** - * Get market data for tokens across all chains - * - * @param state - * @returns {Record>} - */ export const getMarketData = (state) => { return state.metamask.marketData; }; @@ -742,6 +846,20 @@ export const getNetworkConfigurationsByChainId = createDeepEqualSelector( (networkConfigurationsByChainId) => networkConfigurationsByChainId, ); +export const getNetworkConfigurationIdByChainId = createDeepEqualSelector( + (state) => state.metamask.networkConfigurationsByChainId, + (networkConfigurationsByChainId) => + Object.entries(networkConfigurationsByChainId).reduce( + (acc, [_chainId, network]) => { + const selectedRpcEndpoint = + network.rpcEndpoints[network.defaultRpcEndpointIndex]; + acc[_chainId] = selectedRpcEndpoint.networkClientId; + return acc; + }, + {}, + ), +); + /** * @type (state: any, chainId: string) => import('@metamask/network-controller').NetworkConfiguration */ @@ -1370,6 +1488,10 @@ export function getUSDConversionRate(state) { ?.usdConversionRate; } +export function getCurrencyRates(state) { + return state.metamask.currencyRates; +} + export function getWeb3ShimUsageStateForOrigin(state, origin) { return state.metamask.web3ShimUsageOrigins[origin]; } @@ -1460,6 +1582,10 @@ export function getNativeCurrencyImage(state) { return CHAIN_ID_TOKEN_IMAGE_MAP[chainId]; } +export function getNativeCurrencyForChain(chainId) { + return CHAIN_ID_TOKEN_IMAGE_MAP[chainId] ?? undefined; +} + export function getNextSuggestedNonce(state) { return Number(state.metamask.nextNonce); } @@ -1507,10 +1633,11 @@ export const selectERC20Tokens = createDeepEqualSelector( export const getTokenList = createSelector( selectERC20Tokens, getIsTokenDetectionInactiveOnMainnet, - (remoteTokenList, isTokenDetectionInactiveOnMainnet) => - isTokenDetectionInactiveOnMainnet + (remoteTokenList, isTokenDetectionInactiveOnMainnet) => { + return isTokenDetectionInactiveOnMainnet ? STATIC_MAINNET_TOKEN_LIST - : remoteTokenList, + : remoteTokenList; + }, ); export const getMemoizedMetadataContract = createSelector( diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 01c34dc2fe3d..a339ff856058 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -2257,7 +2257,7 @@ export function automaticallySwitchNetwork( */ export function setSwitchedNetworkDetails(switchedNetworkDetails: { networkClientId: string; - selectedTabOrigin: string; + selectedTabOrigin?: string; }): ThunkAction { return async (dispatch: MetaMaskReduxDispatch) => { await submitRequestToBackground('setSwitchedNetworkDetails', [ From df9e07d188f4a7024995f5e9c5eb41270f655b99 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 21 Nov 2024 11:33:13 -0700 Subject: [PATCH 033/148] feat(SwapsController): Remove reliance on global network (#28275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Currently, SwapsController relies on the global network: it uses the global provider from the NetworkController and feeds it into an Ethers contract object to get the allowance for a user's token, and it uses the chain ID of the global network (again, coming from NetworkController) to retrieve parameters that will ultimately be used to control when and how swaps are requested and how to transform the resulting data. This does not work long-term, as we want to remove the concept of the global network from the extension. So fundamentally, we need to have the parts of the API which rely on the global provider to take a network client ID, which will be used to fetch a provider and chain ID. One important note about SwapsController is that there is a kind of entry point to the API via `fetchAndSetQuotes`. This establishes the network that a polling loop will use, and it starts that loop. The controller also operates on one network at a time: it does not distinguish between one network or another when capturing the quote data in state, so when calling `fetchAndSetQuotes` for a different network, if the polling loop was already started for a different network, it will be reset, and all quotes captured for that network will be overwritten. We plan on keeping this behavior. Given these constraints, here is the list of changes: - Update the `FetchTradesInfoParams` type to replace `chainId` with `networkClientId`. - Update `fetchAndSetQuotes` to take a `networkClientId` instead of `chainId` within the `fetchParamsMetaData` (second argument). When called for the first time, this ID will be resolved to a network client. - Update `fetchAndSetQuotes` to no longer rely on the global provider proxy from NetworkController to get the allowance for a token, but use the network client obtained in the previous step. - Update signature for `getTopQuoteWithCalculatedSavings`: instead of taking a single quotes object, it now takes an options bag with keys `quotes` and `networkClientId`. Additionally, instead of relying on the global network to get the chain ID, it will resolve the network client ID to a network client and get the chain ID off of that. - Add new selector `getSelectedNetwork` which can be used to get information about the global network configuration + network client ID in a Redux action in one go (`getCurrentNetwork` was not sufficient to do this). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28275?quickstart=1) ## **Related issues** Progresses #23566. ## **Manual testing steps** 1. Switch to Ethereum Mainnet if you haven't already. 2. Click on the "Swap" button. 3. Open Dev Tools and go to the Network tab. 4. Fill in a monetary value for ETH. 5. Choose a destination token (it doesn't matter). 6. Wait for quotes to appear. 7. Observe that the text above the quotes says "Confirmed by X sources. Verify on Etherscan." 8. In the Dev Tools pane, search for "swap.api". Find any request that starts with `https://swap.api.cx.metamask.io/networks/`. Observe that the chain ID in the URL matches Ethereum Mainnet. 9. Now search for "gas.api". Find any request that starts with `https://gas.api.cx.metamask.io/networks/`. Observe that the chain ID in the URL matches Ethereum Mainnet. 10. Cancel the swap. 11. Switch to Linea Mainnet. 12. Fill in a monetary value for ETH. 13. Choose a destination token. 14. Wait for quotes to appear. 15. Observe that the text above the quotes says "Confirmed by X sources. Verify on LineaScan." 16. In the Dev Tools pane, search for "swap.api". Find any request that starts with `https://swap.api.cx.metamask.io/networks/`. Observe that the chain ID in the URL matches Linea Mainnet. 17. Now search for "gas.api". Find any request that starts with `https://gas.api.cx.metamask.io/networks/`. Observe that the chain ID in the URL matches Linea Mainnet. ## **Screenshots/Recordings** No screenshots/recordings necessary. Everything should work exactly the same as it does today. ## **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 --- app/scripts/controllers/swaps/index.ts | 141 +++--- app/scripts/controllers/swaps/swaps.test.ts | 478 +++++++++++++++---- app/scripts/controllers/swaps/swaps.types.ts | 8 +- app/scripts/metamask-controller.js | 1 - 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 | 1 + test/stub/provider.js | 13 +- ui/ducks/swaps/swaps.js | 26 +- ui/selectors/selectors.js | 29 ++ yarn.lock | 7 +- 13 files changed, 557 insertions(+), 219 deletions(-) diff --git a/app/scripts/controllers/swaps/index.ts b/app/scripts/controllers/swaps/index.ts index c2a947686ad3..308021cd14cf 100644 --- a/app/scripts/controllers/swaps/index.ts +++ b/app/scripts/controllers/swaps/index.ts @@ -1,17 +1,14 @@ import { Contract } from '@ethersproject/contracts'; -import { - ExternalProvider, - JsonRpcFetchFunc, - Web3Provider, -} from '@ethersproject/providers'; +import { Web3Provider } from '@ethersproject/providers'; import { BaseController, StateMetadata } from '@metamask/base-controller'; -import type { ChainId } from '@metamask/controller-utils'; import { GasFeeState } from '@metamask/gas-fee-controller'; import { TransactionParams } from '@metamask/transaction-controller'; import { captureException } from '@sentry/browser'; import { BigNumber } from 'bignumber.js'; import abi from 'human-standard-token-abi'; import { cloneDeep, mapValues } from 'lodash'; +import { NetworkClient, NetworkClientId } from '@metamask/network-controller'; +import { Hex } from '@metamask/utils'; import { EtherDenomination } from '../../../../shared/constants/common'; import { GasEstimateTypes } from '../../../../shared/constants/gas'; import { @@ -71,6 +68,13 @@ import type { Trade, } from './swaps.types'; +type Network = { + client: NetworkClient; + clientId: NetworkClientId; + chainId: Hex; + ethersProvider: Web3Provider; +}; + const metadata: StateMetadata = { swapsState: { persist: false, @@ -103,28 +107,27 @@ export default class SwapsController extends BaseController< properties: Record; }) => void; - #ethersProvider: Web3Provider; - - #ethersProviderChainId: ChainId; - #indexOfNewestCallInFlight: number; #pollCount: number; #pollingTimeout: ReturnType | null = null; - #provider: ExternalProvider | JsonRpcFetchFunc; - - #getEIP1559GasFeeEstimates: () => Promise; + #getEIP1559GasFeeEstimates: (options?: { + networkClientId?: NetworkClientId; + shouldUpdateState?: boolean; + }) => Promise; #getLayer1GasFee: (params: { transactionParams: TransactionParams; - chainId: ChainId; + networkClientId: NetworkClientId; }) => Promise; + #network: Network | undefined; + private _fetchTradesInfo: ( fetchParams: FetchTradesInfoParams, - fetchMetadata: { chainId: ChainId }, + fetchMetadata: { chainId: Hex }, ) => Promise<{ [aggId: string]: Quote; }> = defaultFetchTradesInfo; @@ -267,11 +270,8 @@ export default class SwapsController extends BaseController< this.#getEIP1559GasFeeEstimates = opts.getEIP1559GasFeeEstimates; this.#getLayer1GasFee = opts.getLayer1GasFee; - this.#ethersProvider = new Web3Provider(opts.provider); - this.#ethersProviderChainId = this._getCurrentChainId(); this.#indexOfNewestCallInFlight = 0; this.#pollCount = 0; - this.#provider = opts.provider; // TODO: this should be private, but since a lot of tests depends on spying on it // we cannot enforce privacy 100% @@ -295,11 +295,11 @@ export default class SwapsController extends BaseController< return null; } - const { chainId } = fetchParamsMetaData; - - if (chainId !== this.#ethersProviderChainId) { - this.#ethersProvider = new Web3Provider(this.#provider); - this.#ethersProviderChainId = chainId; + let network; + if (this.#network?.clientId === fetchParamsMetaData.networkClientId) { + network = this.#network; + } else { + network = this.#setNetwork(fetchParamsMetaData.networkClientId); } const { quotesPollingLimitEnabled, saveFetchedQuotes } = @@ -327,8 +327,8 @@ export default class SwapsController extends BaseController< } let [newQuotes] = await Promise.all([ - this._fetchTradesInfo(fetchParams, { ...fetchParamsMetaData }), - this._setSwapsNetworkConfig(), + this._fetchTradesInfo(fetchParams, { chainId: network.chainId }), + this._setSwapsNetworkConfig(network), ]); const { saveFetchedQuotes: saveFetchedQuotesAfterResponse } = @@ -349,8 +349,8 @@ export default class SwapsController extends BaseController< destinationTokenInfo: fetchParamsMetaData?.destinationTokenInfo, })); - const isOptimism = chainId === CHAIN_IDS.OPTIMISM.toString(); - const isBase = chainId === CHAIN_IDS.BASE.toString(); + const isOptimism = network.chainId === CHAIN_IDS.OPTIMISM.toString(); + const isBase = network.chainId === CHAIN_IDS.BASE.toString(); if ((isOptimism || isBase) && Object.values(newQuotes).length > 0) { await Promise.all( @@ -358,7 +358,7 @@ export default class SwapsController extends BaseController< if (quote.trade) { const multiLayerL1TradeFeeTotal = await this.#getLayer1GasFee({ transactionParams: quote.trade, - chainId, + networkClientId: network.clientId, }); quote.multiLayerL1TradeFeeTotal = multiLayerL1TradeFeeTotal; @@ -372,13 +372,13 @@ export default class SwapsController extends BaseController< let approvalRequired = false; if ( - !isSwapsDefaultTokenAddress(fetchParams.sourceToken, chainId) && + !isSwapsDefaultTokenAddress(fetchParams.sourceToken, network.chainId) && Object.values(newQuotes).length ) { const allowance = await this._getERC20Allowance( fetchParams.sourceToken, fetchParams.fromAddress, - chainId, + network, ); const [firstQuote] = Object.values(newQuotes); @@ -428,9 +428,9 @@ export default class SwapsController extends BaseController< if (Object.values(newQuotes).length === 0) { this.setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR); } else { - const topQuoteAndSavings = await this.getTopQuoteWithCalculatedSavings( - newQuotes, - ); + const topQuoteAndSavings = await this.getTopQuoteWithCalculatedSavings({ + quotes: newQuotes, + }); if (Array.isArray(topQuoteAndSavings)) { topAggId = topQuoteAndSavings[0]; newQuotes = topQuoteAndSavings[1]; @@ -476,11 +476,27 @@ export default class SwapsController extends BaseController< return [newQuotes, topAggId]; } - public async getTopQuoteWithCalculatedSavings( - quotes: Record = {}, - ): Promise<[string | null, Record] | Record> { + public async getTopQuoteWithCalculatedSavings({ + quotes, + networkClientId, + }: { + quotes: Record; + networkClientId?: NetworkClientId; + }): Promise<[string | null, Record] | Record> { + let chainId; + if (networkClientId) { + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + chainId = networkClient.configuration.chainId; + } else if (this.#network === undefined) { + throw new Error('There is no network set'); + } else { + chainId = this.#network.chainId; + } + const { marketData } = this._getTokenRatesState(); - const chainId = this._getCurrentChainId(); const tokenConversionRates = marketData?.[chainId] ?? {}; const { customGasPrice, customMaxPriorityFeePerGas } = @@ -494,7 +510,7 @@ export default class SwapsController extends BaseController< const newQuotes = cloneDeep(quotes); const { gasFeeEstimates, gasEstimateType } = - await this.#getEIP1559GasFeeEstimates(); + await this.#getEIP1559GasFeeEstimates({ networkClientId }); let usedGasPrice = '0x0'; @@ -911,9 +927,9 @@ export default class SwapsController extends BaseController< }; // Private Methods - private async _fetchSwapsNetworkConfig(chainId: ChainId) { + private async _fetchSwapsNetworkConfig(network: Network) { const response = await fetchWithCache({ - url: getBaseApi('network', chainId), + url: getBaseApi('network', network.chainId), fetchOptions: { method: 'GET' }, cacheOptions: { cacheRefreshTime: 600000 }, functionName: '_fetchSwapsNetworkConfig', @@ -983,29 +999,16 @@ export default class SwapsController extends BaseController< return newQuotes; } - private _getCurrentChainId(): ChainId { - const { selectedNetworkClientId } = this.messagingSystem.call( - 'NetworkController:getState', - ); - const { - configuration: { chainId }, - } = this.messagingSystem.call( - 'NetworkController:getNetworkClientById', - selectedNetworkClientId, - ); - return chainId as ChainId; - } - private async _getERC20Allowance( contractAddress: string, walletAddress: string, - chainId: ChainId, + network: Network, ) { - const contract = new Contract(contractAddress, abi, this.#ethersProvider); + const contract = new Contract(contractAddress, abi, network.ethersProvider); return await contract.allowance( walletAddress, SWAPS_CHAINID_CONTRACT_ADDRESS_MAP[ - chainId as keyof typeof SWAPS_CHAINID_CONTRACT_ADDRESS_MAP + network.chainId as keyof typeof SWAPS_CHAINID_CONTRACT_ADDRESS_MAP ], ); } @@ -1053,8 +1056,7 @@ export default class SwapsController extends BaseController< } // Sets the network config from the MetaSwap API. - private async _setSwapsNetworkConfig() { - const chainId = this._getCurrentChainId(); + private async _setSwapsNetworkConfig(network: Network) { let swapsNetworkConfig: { quotes: number; quotesPrefetching: number; @@ -1065,7 +1067,7 @@ export default class SwapsController extends BaseController< } | null = null; try { - swapsNetworkConfig = await this._fetchSwapsNetworkConfig(chainId); + swapsNetworkConfig = await this._fetchSwapsNetworkConfig(network); } catch (e) { console.error('Request for Swaps network config failed: ', e); } @@ -1142,4 +1144,25 @@ export default class SwapsController extends BaseController< }); }); } + + #setNetwork(networkClientId: NetworkClientId) { + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const { chainId } = networkClient.configuration; + // Web3Provider (via JsonRpcProvider) creates two extra network requests, so + // we cache the object so that we can reuse it for subsequent contract + // interactions for the same network + const ethersProvider = new Web3Provider(networkClient.provider); + + const network = { + client: networkClient, + clientId: networkClientId, + chainId, + ethersProvider, + }; + this.#network = network; + return network; + } } diff --git a/app/scripts/controllers/swaps/swaps.test.ts b/app/scripts/controllers/swaps/swaps.test.ts index 4ed1b545f170..1a71e3c995ae 100644 --- a/app/scripts/controllers/swaps/swaps.test.ts +++ b/app/scripts/controllers/swaps/swaps.test.ts @@ -1,18 +1,24 @@ import { BigNumber } from '@ethersproject/bignumber'; -import { ExternalProvider, JsonRpcFetchFunc } from '@ethersproject/providers'; -import { ChainId } from '@metamask/controller-utils'; +import { ChainId, InfuraNetworkType } from '@metamask/controller-utils'; import BigNumberjs from 'bignumber.js'; import { mapValues } from 'lodash'; +import * as ethersProviders from '@ethersproject/providers'; +import { Hex } from '@metamask/utils'; +import { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; +import { NetworkClientId } from '@metamask/network-controller'; import { GasEstimateTypes } from '../../../../shared/constants/gas'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { ETH_SWAPS_TOKEN_OBJECT } from '../../../../shared/constants/swaps'; import { createTestProviderTools } from '../../../../test/stub/provider'; +import * as fetchWithCacheModule from '../../../../shared/lib/fetch-with-cache'; import { getDefaultSwapsControllerState } from './swaps.constants'; import { FetchTradesInfoParams, FetchTradesInfoParamsMetadata, Quote, SwapsControllerMessenger, + SwapsControllerOptions, + SwapsControllerState, } from './swaps.types'; import { getMedianEthValueQuote } from './swaps.utils'; import SwapsController from '.'; @@ -38,7 +44,12 @@ const TEST_AGG_ID_6 = 'TEST_AGG_6'; const TEST_AGG_ID_BEST = 'TEST_AGG_BEST'; const TEST_AGG_ID_APPROVAL = 'TEST_AGG_APPROVAL'; -// const POLLING_TIMEOUT = SECOND * 1000; +const MOCK_PROVIDER_RESULT_STUB = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', +}; const MOCK_APPROVAL_NEEDED = { data: '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00', @@ -87,7 +98,7 @@ const MOCK_FETCH_METADATA: FetchTradesInfoParamsMetadata = { decimals: 18, address: '0xSomeOtherAddress', }, - chainId: CHAIN_IDS.MAINNET, + networkClientId: InfuraNetworkType.mainnet, }; const MOCK_GET_BUFFERED_GAS_LIMIT = async () => ({ @@ -117,63 +128,95 @@ const networkControllerGetStateCallbackMock = jest .fn() .mockReturnValue({ selectedNetworkClientId: 'metamask' }); -const networkControllerGetNetworkClientByIdCallbackMock = jest - .fn() - .mockReturnValue({ configuration: { chainId: CHAIN_IDS.MAINNET } }); +const networkControllerGetNetworkClientByIdCallbackMock = jest.fn(); const tokenRatesControllerGetStateCallbackMock = jest.fn().mockReturnValue({ marketData: { - '0x1': { + [CHAIN_IDS.MAINNET]: { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { price: 2 }, + '0x1111111111111111111111111111111111111111': { price: 0.1 }, + }, + [CHAIN_IDS.OPTIMISM]: { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { price: 2 }, + '0x1111111111111111111111111111111111111111': { price: 0.1 }, + }, + [CHAIN_IDS.BASE]: { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { price: 2 }, '0x1111111111111111111111111111111111111111': { price: 0.1 }, }, }, }); -messengerMock.call.mockImplementation((actionName, ..._rest) => { +messengerMock.call.mockImplementation((actionName, ...args) => { if (actionName === 'NetworkController:getState') { - return networkControllerGetStateCallbackMock(); + return networkControllerGetStateCallbackMock(...args); } if (actionName === 'NetworkController:getNetworkClientById') { - return networkControllerGetNetworkClientByIdCallbackMock(); + return networkControllerGetNetworkClientByIdCallbackMock(...args); } if (actionName === 'TokenRatesController:getState') { - return tokenRatesControllerGetStateCallbackMock(); + return tokenRatesControllerGetStateCallbackMock(...args); } return undefined; }); +function mockNetworkControllerGetNetworkClientById( + networkClientsById: Record< + NetworkClientId, + { + provider: SafeEventEmitterProvider; + configuration: { + chainId: Hex; + }; + } + >, +) { + networkControllerGetNetworkClientByIdCallbackMock.mockImplementation( + (networkClientId) => { + const foundNetworkClient = networkClientsById[networkClientId]; + if (foundNetworkClient === undefined) { + throw new Error(`Unknown network client ID '${networkClientId}'`); + } + return foundNetworkClient; + }, + ); +} + describe('SwapsController', function () { - let provider: ExternalProvider | JsonRpcFetchFunc; - const getSwapsController = ( - _provider: ExternalProvider | JsonRpcFetchFunc = provider, - ) => { + const getSwapsController = ({ + options, + state, + }: { + options?: Partial; + state?: Partial; + } = {}) => { return new SwapsController( { getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT, - provider: _provider, fetchTradesInfo: fetchTradesInfoStub, getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub, getLayer1GasFee: getLayer1GasFeeStub, trackMetaMetricsEvent: trackMetaMetricsEventStub, messenger: messengerMock, + ...options, + }, + { + ...getDefaultSwapsControllerState(), + ...state, }, - getDefaultSwapsControllerState(), ); }; beforeEach(function () { - const providerResultStub = { - // 1 gwei - eth_gasPrice: '0x0de0b6b3a7640000', - // by default, all accounts are external accounts (not contracts) - eth_getCode: '0x', - }; - provider = createTestProviderTools({ - scaffold: providerResultStub, + const { provider } = createTestProviderTools({ + scaffold: MOCK_PROVIDER_RESULT_STUB, networkId: 1, chainId: CHAIN_IDS.MAINNET as ChainId, - }).provider; + }); + networkControllerGetNetworkClientByIdCallbackMock.mockReturnValue({ + provider, + configuration: { chainId: CHAIN_IDS.MAINNET }, + }); jest.useFakeTimers(); }); @@ -317,21 +360,37 @@ describe('SwapsController', function () { }); }); - it('returns empty object if passed undefined or empty object', async function () { - expect( - await swapsController.getTopQuoteWithCalculatedSavings(), - ).toStrictEqual({}); + it('returns an empty object if passed an empty set of quotes', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); expect( - await swapsController.getTopQuoteWithCalculatedSavings({}), + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: {}, + networkClientId, + }), ).toStrictEqual({}); }); it('returns the top aggId and quotes with savings and fee values if passed necessary data and an even number of quotes', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings( - getTopQuoteAndSavingsMockQuotes(), - ); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: getTopQuoteAndSavingsMockQuotes(), + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; @@ -342,6 +401,21 @@ describe('SwapsController', function () { }); it('returns the top aggId and quotes with savings and fee values if passed necessary data and an odd number of quotes', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const provider = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + networkControllerGetNetworkClientByIdCallbackMock.mockImplementation( + (givenNetworkClientId) => { + if (givenNetworkClientId === networkClientId) { + return networkClient; + } + throw new Error( + `Unknown network client ID '${givenNetworkClientId}'`, + ); + }, + ); + const completeTestInput = getTopQuoteAndSavingsMockQuotes(); const partialTestInput = { [TEST_AGG_ID_1]: completeTestInput[TEST_AGG_ID_1], @@ -370,9 +444,10 @@ describe('SwapsController', function () { }; const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings( - partialTestInput, - ); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: partialTestInput, + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; @@ -381,6 +456,14 @@ describe('SwapsController', function () { }); it('returns the top aggId, without best quote flagged, and quotes with fee values if passed necessary data but no custom convert rate exists', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const testInput = mapValues( getTopQuoteAndSavingsMockQuotes(), (quote) => ({ @@ -416,7 +499,10 @@ describe('SwapsController', function () { }; const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings(testInput); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: testInput, + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; expect(topAggId).toStrictEqual(TEST_AGG_ID_1); @@ -424,6 +510,14 @@ describe('SwapsController', function () { }); it('returns the top aggId and quotes with savings and fee values if passed necessary data and the source token is ETH', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const testInput = mapValues( getTopQuoteAndSavingsMockQuotes(), (quote) => ({ @@ -483,9 +577,10 @@ describe('SwapsController', function () { }; const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings( - testInput as Record, - ); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: testInput as Record, + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; expect(topAggId).toStrictEqual(TEST_AGG_ID_1); @@ -493,6 +588,14 @@ describe('SwapsController', function () { }); it('returns the top aggId and quotes with savings and fee values if passed necessary data and the source token is ETH and an ETH fee is included in the trade value of what would be the best quote', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const testInput = mapValues( getTopQuoteAndSavingsMockQuotes(), (quote) => ({ @@ -567,9 +670,10 @@ describe('SwapsController', function () { delete expectedResultQuotes[TEST_AGG_ID_1].savings; const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings( - testInput as Record, - ); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: testInput as Record, + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; @@ -578,6 +682,14 @@ describe('SwapsController', function () { }); it('returns the top aggId and quotes with savings and fee values if passed necessary data and the source token is not ETH and an ETH fee is included in the trade value of what would be the best quote', async function () { + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ chainId }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const testInput = getTopQuoteAndSavingsMockQuotes(); // 0.04 ETH fee included in trade value // @ts-expect-error - trade can be undefined but in this case since its mocked it will always be defined @@ -610,7 +722,10 @@ describe('SwapsController', function () { delete expectedResultQuotes[TEST_AGG_ID_1].savings; const topQuoteAndSavings = - await swapsController.getTopQuoteWithCalculatedSavings(testInput); + await swapsController.getTopQuoteWithCalculatedSavings({ + quotes: testInput, + networkClientId, + }); const topAggId = topQuoteAndSavings[0]; const resultQuotes = topQuoteAndSavings[1]; @@ -628,24 +743,25 @@ describe('SwapsController', function () { it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () { fetchTradesInfoStub.mockReset(); - const providerResultStub = { - // 1 gwei - eth_gasPrice: '0x0de0b6b3a7640000', - // by default, all accounts are external accounts (not contracts) - eth_getCode: '0x', - }; - const mainnetProvider = createTestProviderTools({ - scaffold: providerResultStub, + const chainId = CHAIN_IDS.MAINNET; + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ + scaffold: { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }, networkId: 1, - chainId: CHAIN_IDS.MAINNET as ChainId, - }).provider; - - swapsController = getSwapsController(mainnetProvider); + chainId: chainId as ChainId, + }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); - const fetchTradesInfoSpy = jest - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(swapsController as any, '_fetchTradesInfo') - .mockReturnValue(getMockQuotes()); + const fetchTradesInfo = jest.fn().mockReturnValue(getMockQuotes()); + swapsController = getSwapsController({ options: { fetchTradesInfo } }); // Make it so approval is not required jest @@ -661,7 +777,7 @@ describe('SwapsController', function () { const fetchResponse = await swapsController.fetchAndSetQuotes( MOCK_FETCH_PARAMS, - MOCK_FETCH_METADATA, + { ...MOCK_FETCH_METADATA, networkClientId }, ); if (!fetchResponse?.[0]) { @@ -699,38 +815,118 @@ describe('SwapsController', function () { }, }); - expect(fetchTradesInfoSpy).toHaveBeenCalledTimes(1); - expect(fetchTradesInfoSpy).toHaveBeenCalledWith(MOCK_FETCH_PARAMS, { - ...MOCK_FETCH_METADATA, + expect(fetchTradesInfo).toHaveBeenCalledTimes(1); + expect(fetchTradesInfo).toHaveBeenCalledWith(MOCK_FETCH_PARAMS, { + chainId, }); }); - it('calls returns the correct quotes on the optimism chain', async function () { + it('returns the correct quotes on the Optimism chain', async function () { fetchTradesInfoStub.mockReset(); - const OPTIMISM_MOCK_FETCH_METADATA = { - ...MOCK_FETCH_METADATA, - chainId: CHAIN_IDS.OPTIMISM as ChainId, - }; - const optimismProviderResultStub = { - // 1 gwei - eth_gasPrice: '0x0de0b6b3a7640000', - // by default, all accounts are external accounts (not contracts) - eth_getCode: '0x', - eth_call: - '0x000000000000000000000000000000000000000000000000000103c18816d4e8', - }; - const optimismProvider = createTestProviderTools({ - scaffold: optimismProviderResultStub, + const chainId = CHAIN_IDS.OPTIMISM; + const networkClientId = 'AAAA-BBBB-CCCC-DDDD'; + const { provider } = createTestProviderTools({ + scaffold: { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + eth_call: + '0x000000000000000000000000000000000000000000000000000103c18816d4e8', + }, networkId: 10, - chainId: CHAIN_IDS.OPTIMISM as ChainId, - }).provider; + chainId: chainId as ChainId, + }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); - swapsController = getSwapsController(optimismProvider); + const fetchTradesInfo = jest.fn().mockReturnValue(getMockQuotes()); + swapsController = getSwapsController({ options: { fetchTradesInfo } }); - const fetchTradesInfoSpy = jest + // Make it so approval is not required + jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(swapsController as any, '_fetchTradesInfo') - .mockReturnValue(getMockQuotes()); + .spyOn(swapsController as any, '_getERC20Allowance') + .mockReturnValue(BigNumber.from(1)); + + // Make the network fetch error message disappear + jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(swapsController as any, '_setSwapsNetworkConfig') + .mockReturnValue(undefined); + + const fetchResponse = await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + { ...MOCK_FETCH_METADATA, networkClientId }, + ); + + if (!fetchResponse?.[0]) { + throw new Error('Quotes should be defined'); + } + + const [newQuotes] = fetchResponse; + + expect(newQuotes[TEST_AGG_ID_BEST]).toStrictEqual({ + ...getMockQuotes()[TEST_AGG_ID_BEST], + destinationTokenInfo: { + address: '0xSomeAddress', + symbol: 'FOO', + decimals: 18, + }, + isBestQuote: true, + // TODO: find a way to calculate these values dynamically + gasEstimate: '2000000', + gasEstimateWithRefund: '0xb8cae', + savings: { + fee: '-0.061067', + metaMaskFee: '0.50505050505050505050505050505050505', + performance: '6', + total: '5.43388249494949494949494949494949495', + medianMetaMaskFee: '0.444444444444444444444444444444444444', + }, + ethFee: '0.113536', + multiLayerL1TradeFeeTotal: '0x1', + overallValueOfQuote: '49.886464', + metaMaskFeeInEth: '0.50505050505050505050505050505050505', + ethValueOfTokens: '50', + sourceTokenInfo: { + address: '0xSomeOtherAddress', + decimals: 18, + symbol: 'BAR', + }, + }); + + expect(fetchTradesInfo).toHaveBeenCalledTimes(1); + expect(fetchTradesInfo).toHaveBeenCalledWith(MOCK_FETCH_PARAMS, { + chainId, + }); + }); + + it('returns the correct quotes on the Base chain', async function () { + fetchTradesInfoStub.mockReset(); + const chainId = CHAIN_IDS.BASE; + const networkClientId = 'AAAA-BBBB-CCCC-DDDD'; + const { provider } = createTestProviderTools({ + scaffold: { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + eth_call: + '0x000000000000000000000000000000000000000000000000000103c18816d4e8', + }, + networkId: 8453, + chainId: chainId as ChainId, + }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + + const fetchTradesInfo = jest.fn().mockReturnValue(getMockQuotes()); + swapsController = getSwapsController({ options: { fetchTradesInfo } }); // Make it so approval is not required jest @@ -746,7 +942,7 @@ describe('SwapsController', function () { const fetchResponse = await swapsController.fetchAndSetQuotes( MOCK_FETCH_PARAMS, - OPTIMISM_MOCK_FETCH_METADATA, + { ...MOCK_FETCH_METADATA, networkClientId }, ); if (!fetchResponse?.[0]) { @@ -785,13 +981,98 @@ describe('SwapsController', function () { }, }); - expect(fetchTradesInfoSpy).toHaveBeenCalledTimes(1); - expect(fetchTradesInfoSpy).toHaveBeenCalledWith(MOCK_FETCH_PARAMS, { - ...OPTIMISM_MOCK_FETCH_METADATA, + expect(fetchTradesInfo).toHaveBeenCalledTimes(1); + expect(fetchTradesInfo).toHaveBeenCalledWith(MOCK_FETCH_PARAMS, { + chainId, + }); + }); + + it('copies network config from the Swaps API into state', async () => { + const chainId = '0x64' as const; // Gnosis + const networkClientId = InfuraNetworkType.mainnet; + const { provider } = createTestProviderTools({ + scaffold: { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }, + networkId: 100, + chainId: chainId as ChainId, + }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const fetchWithCacheSpy = jest + .spyOn(fetchWithCacheModule, 'default') + .mockResolvedValue({ + refreshRates: { + quotes: 1, + quotesPrefetching: 2, + stxGetTransactions: 3, + stxBatchStatus: 4, + stxStatusDeadline: 5, + }, + parameters: { + stxMaxFeeMultiplier: 6, + }, + }); + + swapsController = getSwapsController(); + + await swapsController.fetchAndSetQuotes(MOCK_FETCH_PARAMS, { + ...MOCK_FETCH_METADATA, + networkClientId, + }); + + expect(fetchWithCacheSpy).toHaveBeenCalledWith({ + url: 'https://swap.api.cx.metamask.io/networks/100', + fetchOptions: { + method: 'GET', + }, + cacheOptions: { + cacheRefreshTime: 600000, + }, + functionName: '_fetchSwapsNetworkConfig', + }); + expect(swapsController.state.swapsState).toMatchObject({ + swapsQuoteRefreshTime: 1000, + swapsQuotePrefetchingRefreshTime: 2000, + swapsStxGetTransactionsRefreshTime: 3000, + swapsStxBatchStatusRefreshTime: 4000, + swapsStxMaxFeeMultiplier: 6, + swapsStxStatusDeadline: 5, }); }); it('performs the allowance check', async function () { + const chainId = CHAIN_IDS.OPTIMISM; + const networkClientId = 'AAAA-BBBB-CCCC-DDDD'; + const { provider } = createTestProviderTools({ + scaffold: MOCK_PROVIDER_RESULT_STUB, + networkId: 10, + chainId: chainId as ChainId, + }); + const networkClient = { provider, configuration: { chainId } }; + mockNetworkControllerGetNetworkClientById({ + [networkClientId]: networkClient, + }); + const ethersProvider = new ethersProviders.Web3Provider(provider); + jest + .spyOn(ethersProviders, 'Web3Provider') + .mockImplementation((givenProvider) => { + if (givenProvider === provider) { + return ethersProvider; + } + throw new Error('Could not create a Web3Provider'); + }); + + jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(swapsController as any, '_fetchTradesInfo') + .mockReturnValue(getMockQuotes()); + // Make it so approval is not required const getERC20AllowanceSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -804,16 +1085,21 @@ describe('SwapsController', function () { .spyOn(swapsController as any, '_setSwapsNetworkConfig') .mockReturnValue(undefined); - await swapsController.fetchAndSetQuotes( - MOCK_FETCH_PARAMS, - MOCK_FETCH_METADATA, - ); + await swapsController.fetchAndSetQuotes(MOCK_FETCH_PARAMS, { + ...MOCK_FETCH_METADATA, + networkClientId, + }); expect(getERC20AllowanceSpy).toHaveBeenCalledTimes(1); expect(getERC20AllowanceSpy).toHaveBeenCalledWith( MOCK_FETCH_PARAMS.sourceToken, MOCK_FETCH_PARAMS.fromAddress, - CHAIN_IDS.MAINNET, + { + client: networkClient, + clientId: networkClientId, + chainId, + ethersProvider, + }, ); }); diff --git a/app/scripts/controllers/swaps/swaps.types.ts b/app/scripts/controllers/swaps/swaps.types.ts index ca059723277a..76da1fb7a6b9 100644 --- a/app/scripts/controllers/swaps/swaps.types.ts +++ b/app/scripts/controllers/swaps/swaps.types.ts @@ -1,13 +1,12 @@ -import { ExternalProvider, JsonRpcFetchFunc } from '@ethersproject/providers'; import { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import { ControllerGetStateAction, ControllerStateChangeEvent, RestrictedControllerMessenger, } from '@metamask/base-controller'; -import type { ChainId } from '@metamask/controller-utils'; import { GasFeeState } from '@metamask/gas-fee-controller'; import { + NetworkClientId, NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, } from '@metamask/network-controller'; @@ -312,7 +311,7 @@ export type FetchTradesInfoParams = { }; export type FetchTradesInfoParamsMetadata = { - chainId: ChainId; + networkClientId: NetworkClientId; sourceTokenInfo: { address: string; symbol: string; @@ -348,11 +347,10 @@ export type SwapsControllerOptions = { }, factor: number, ) => Promise<{ gasLimit: string; simulationFails: boolean }>; - provider: ExternalProvider | JsonRpcFetchFunc; fetchTradesInfo: typeof defaultFetchTradesInfo; getLayer1GasFee: (params: { transactionParams: TransactionParams; - chainId: ChainId; + networkClientId: NetworkClientId; }) => Promise; getEIP1559GasFeeEstimates: () => Promise; trackMetaMetricsEvent: (event: { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 676595dfbff3..ac633bbc8e5d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2143,7 +2143,6 @@ export default class MetamaskController extends EventEmitter { this.swapsController = new SwapsController( { messenger: swapsControllerMessenger, - provider: this.provider, // TODO: Remove once TransactionController exports this action type getBufferedGasLimit: async (txMeta, multiplier) => { const { gas: gasLimit, simulationFails } = diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index f87946a09c0f..a5581cd16a85 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -893,6 +893,14 @@ "semver": true } }, + "@metamask/eth-json-rpc-provider": { + "packages": { + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, + "@metamask/safe-event-emitter": true, + "uuid": true + } + }, "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, @@ -1780,11 +1788,11 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, + "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, "@metamask/network-controller>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, "@metamask/network-controller>@metamask/rpc-errors": true, "@metamask/network-controller>@metamask/swappable-obj-proxy": true, @@ -1917,14 +1925,6 @@ "semver": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": { - "packages": { - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "uuid": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index f87946a09c0f..a5581cd16a85 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -893,6 +893,14 @@ "semver": true } }, + "@metamask/eth-json-rpc-provider": { + "packages": { + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, + "@metamask/safe-event-emitter": true, + "uuid": true + } + }, "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, @@ -1780,11 +1788,11 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, + "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, "@metamask/network-controller>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, "@metamask/network-controller>@metamask/rpc-errors": true, "@metamask/network-controller>@metamask/swappable-obj-proxy": true, @@ -1917,14 +1925,6 @@ "semver": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": { - "packages": { - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "uuid": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index f87946a09c0f..a5581cd16a85 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -893,6 +893,14 @@ "semver": true } }, + "@metamask/eth-json-rpc-provider": { + "packages": { + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, + "@metamask/safe-event-emitter": true, + "uuid": true + } + }, "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, @@ -1780,11 +1788,11 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, + "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, "@metamask/network-controller>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, "@metamask/network-controller>@metamask/rpc-errors": true, "@metamask/network-controller>@metamask/swappable-obj-proxy": true, @@ -1917,14 +1925,6 @@ "semver": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": { - "packages": { - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "uuid": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 1bad9f3288a2..672c34d8faea 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -985,6 +985,14 @@ "semver": true } }, + "@metamask/eth-json-rpc-provider": { + "packages": { + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, + "@metamask/safe-event-emitter": true, + "uuid": true + } + }, "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, @@ -1872,11 +1880,11 @@ "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, + "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, "@metamask/network-controller>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, "@metamask/network-controller>@metamask/rpc-errors": true, "@metamask/network-controller>@metamask/swappable-obj-proxy": true, @@ -2009,14 +2017,6 @@ "semver": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider": { - "packages": { - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true, - "uuid": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, diff --git a/package.json b/package.json index fb6218ebb5c6..2f7498a1d537 100644 --- a/package.json +++ b/package.json @@ -471,6 +471,7 @@ "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", "@metamask/eslint-plugin-design-tokens": "^1.1.0", + "@metamask/eth-json-rpc-provider": "^4.1.6", "@metamask/forwarder": "^1.1.0", "@metamask/phishing-warning": "^4.1.0", "@metamask/preferences-controller": "^13.0.2", diff --git a/test/stub/provider.js b/test/stub/provider.js index f86762218adf..ff5e09944be5 100644 --- a/test/stub/provider.js +++ b/test/stub/provider.js @@ -3,7 +3,9 @@ import { createScaffoldMiddleware, } from '@metamask/json-rpc-engine'; import { providerAsMiddleware } from '@metamask/eth-json-rpc-middleware'; +import { providerFromEngine } from '@metamask/eth-json-rpc-provider'; import Ganache from 'ganache'; +import { CHAIN_IDS } from '../../shared/constants/network'; export function getTestSeed() { return 'people carpet cluster attract ankle motor ozone mass dove original primary mask'; @@ -39,22 +41,17 @@ export function createEngineForTestData() { return new JsonRpcEngine(); } -export function providerFromEngine(engine) { - const provider = { sendAsync: engine.handle.bind(engine) }; - return provider; -} - export function createTestProviderTools(opts = {}) { const engine = createEngineForTestData(); // handle provided hooks - engine.push(createScaffoldMiddleware(opts.scaffold || {})); + engine.push(createScaffoldMiddleware(opts.scaffold ?? {})); // handle block tracker methods engine.push( providerAsMiddleware( Ganache.provider({ mnemonic: getTestSeed(), - network_id: opts.networkId, - chain: { chainId: opts.chainId }, + network_id: opts.networkId ?? 1, + chain: { chainId: opts.chainId ?? CHAIN_IDS.MAINNET }, hardfork: 'muirGlacier', }), ), diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index d23c0ce69381..4886d1dbdad7 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -65,8 +65,8 @@ import { isHardwareWallet, getHardwareWalletType, checkNetworkAndAccountSupports1559, - getSelectedNetworkClientId, getSelectedInternalAccount, + getSelectedNetwork, } from '../../selectors'; import { getSmartTransactionsEnabled, @@ -628,14 +628,14 @@ export const fetchQuotesAndSetQuoteState = ( ) => { return async (dispatch, getState) => { const state = getState(); - const chainId = getCurrentChainId(state); + const selectedNetwork = getSelectedNetwork(state); let swapsLivenessForNetwork = { swapsFeatureIsLive: false, }; try { const swapsFeatureFlags = await fetchSwapsFeatureFlags(); swapsLivenessForNetwork = getSwapsLivenessForNetwork( - chainId, + selectedNetwork.configuration.chainId, swapsFeatureFlags, ); } catch (error) { @@ -650,7 +650,6 @@ export const fetchQuotesAndSetQuoteState = ( const fetchParams = getFetchParams(state); const selectedAccount = getSelectedAccount(state); - const networkClientId = getSelectedNetworkClientId(state); const balanceError = getBalanceError(state); const swapsDefaultToken = getSwapsDefaultToken(state); const fetchParamsFromToken = @@ -697,7 +696,7 @@ export const fetchQuotesAndSetQuoteState = ( symbol: toTokenSymbol, decimals: toTokenDecimals, image: toTokenIconUrl, - networkClientId, + networkClientId: selectedNetwork.clientId, }, true, ), @@ -725,7 +724,7 @@ export const fetchQuotesAndSetQuoteState = ( symbol: fromTokenSymbol, decimals: fromTokenDecimals, image: fromTokenIconUrl, - networkClientId, + networkClientId: selectedNetwork.clientId, }, true, ), @@ -791,7 +790,7 @@ export const fetchQuotesAndSetQuoteState = ( sourceTokenInfo, destinationTokenInfo, accountBalance: selectedAccount.balance, - chainId, + networkClientId: selectedNetwork.clientId, }, ), ); @@ -897,7 +896,7 @@ export const signAndSendSwapsSmartTransaction = ({ const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData; const usedQuote = getUsedQuote(state); const swapsNetworkConfig = getSwapsNetworkConfig(state); - const chainId = getCurrentChainId(state); + const selectedNetwork = getSelectedNetwork(state); dispatch( setSmartTransactionsRefreshInterval( @@ -948,7 +947,12 @@ export const signAndSendSwapsSmartTransaction = ({ sensitiveProperties: swapMetaData, }); - if (!isContractAddressValid(usedTradeTxParams.to, chainId)) { + if ( + !isContractAddressValid( + usedTradeTxParams.to, + selectedNetwork.configuration.chainId, + ) + ) { captureMessage('Invalid contract address', { extra: { token_from: swapMetaData.token_from, @@ -993,7 +997,7 @@ export const signAndSendSwapsSmartTransaction = ({ updatedApproveTxParams.gas = `0x${decimalToHex( fees.approvalTxFees?.gasLimit || 0, )}`; - updatedApproveTxParams.chainId = chainId; + updatedApproveTxParams.chainId = selectedNetwork.configuration.chainId; approvalTxUuid = await dispatch( signAndSendSmartTransaction({ unsignedTransaction: updatedApproveTxParams, @@ -1004,7 +1008,7 @@ export const signAndSendSwapsSmartTransaction = ({ unsignedTransaction.gas = `0x${decimalToHex( fees.tradeTxFees?.gasLimit || 0, )}`; - unsignedTransaction.chainId = chainId; + unsignedTransaction.chainId = selectedNetwork.configuration.chainId; const uuid = await dispatch( signAndSendSmartTransaction({ unsignedTransaction, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 9852d4ae7d25..96e880d52aef 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2139,6 +2139,35 @@ export const getCurrentNetwork = createDeepEqualSelector( }, ); +export const getSelectedNetwork = createDeepEqualSelector( + getSelectedNetworkClientId, + getNetworkConfigurationsByChainId, + (selectedNetworkClientId, networkConfigurationsByChainId) => { + if (selectedNetworkClientId === undefined) { + throw new Error('No network is selected'); + } + + // TODO: Add `networkConfigurationsByNetworkClientId` to NetworkController state so this is easier to do + const possibleNetworkConfiguration = Object.values( + networkConfigurationsByChainId, + ).find((networkConfiguration) => { + return networkConfiguration.rpcEndpoints.some((rpcEndpoint) => { + return rpcEndpoint.networkClientId === selectedNetworkClientId; + }); + }); + if (possibleNetworkConfiguration === undefined) { + throw new Error( + 'Could not find network configuration for selected network client', + ); + } + + return { + configuration: possibleNetworkConfiguration, + clientId: selectedNetworkClientId, + }; + }, +); + export const getConnectedSitesListWithNetworkInfo = createDeepEqualSelector( getConnectedSitesList, getAllDomains, diff --git a/yarn.lock b/yarn.lock index 527af97c08f5..adf55eaa9e85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6460,9 +6460,9 @@ __metadata: linkType: hard "@metamask/slip44@npm:^4.0.0": - version: 4.1.0 - resolution: "@metamask/slip44@npm:4.1.0" - checksum: 10/4265254a1800a24915bd1de15f86f196737132f9af2a084c2efc885decfc5dd87ad8f0687269d90b35e2ec64d3ea4fbff0caa793bcea6e585b1f3a290952b750 + version: 4.0.0 + resolution: "@metamask/slip44@npm:4.0.0" + checksum: 10/3e47e8834b0fbdabe1f126fd78665767847ddc1f9ccc8defb23007dd71fcd2e4899c8ca04857491be3630668a3765bad1e40fdfca9a61ef33236d8d08e51535e languageName: node linkType: hard @@ -26838,6 +26838,7 @@ __metadata: "@metamask/eslint-plugin-design-tokens": "npm:^1.1.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch" + "@metamask/eth-json-rpc-provider": "npm:^4.1.6" "@metamask/eth-ledger-bridge-keyring": "npm:^5.0.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^7.0.1" From 08cc205443af87abe258849a92cd7b2959eec9f9 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 21 Nov 2024 20:09:07 +0100 Subject: [PATCH 034/148] fix: fix test networks display for portfolio view (#28601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the display in the asset page and in the main token list when the price checker setting is off [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28601?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28594 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/a1213b6a-3f23-49f5-be15-a0b369b9016e ## **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/assets/token-cell/token-cell.tsx | 17 +++++++---------- .../app/assets/token-list/token-list.tsx | 18 +++++++++++++++++- ui/pages/asset/components/asset-page.tsx | 17 ++++++++++++++++- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 56d6555258bd..d470ef3dc21a 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -84,17 +84,14 @@ export default function TokenCell({ image; const secondaryThreshold = 0.01; - // Format for fiat balance with currency style - const secondary = formatWithThreshold( - tokenFiatAmount, - secondaryThreshold, - locale, - { - style: 'currency', - currency: currentCurrency.toUpperCase(), - }, - ); + const secondary = + tokenFiatAmount === null + ? undefined + : formatWithThreshold(tokenFiatAmount, secondaryThreshold, locale, { + style: 'currency', + currency: currentCurrency.toUpperCase(), + }); const primary = formatAmount( locale, diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 08b5675dfe94..3f094634743e 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -7,6 +7,7 @@ import { sortAssets } from '../util/sort'; import { getCurrencyRates, getCurrentNetwork, + getIsTestnet, getMarketData, getNetworkConfigurationIdByChainId, getNewTokensImported, @@ -14,6 +15,7 @@ import { getSelectedAccount, getSelectedAccountNativeTokenCachedBalanceByChainId, getSelectedAccountTokensAcrossChains, + getShowFiatInTestnets, getTokenExchangeRates, } from '../../../../selectors'; import { getConversionRate } from '../../../../ducks/metamask/metamask'; @@ -24,6 +26,8 @@ import { endTrace, TraceName } from '../../../../../shared/lib/trace'; import { useTokenBalances } from '../../../../hooks/useTokenBalances'; import { setTokenNetworkFilter } from '../../../../store/actions'; import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { useMultichainSelector } from '../../../../hooks/useMultichainSelector'; +import { getMultichainShouldShowFiat } from '../../../../selectors/multichain'; type TokenListProps = { onTokenClick: (chainId: string, address: string) => void; @@ -212,6 +216,18 @@ export default function TokenList({ console.log(t('loadingTokens')); } + // Check if testnet + const isTestnet = useSelector(getIsTestnet); + const shouldShowFiat = useMultichainSelector( + getMultichainShouldShowFiat, + selectedAccount, + ); + const isMainnet = !isTestnet; + // Check if show conversion is enabled + const showFiatInTestnets = useSelector(getShowFiatInTestnets); + const showFiat = + shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); + return (
{sortedFilteredTokens.map((tokenData) => ( @@ -220,7 +236,7 @@ export default function TokenList({ chainId={tokenData.chainId} address={tokenData.address} symbol={tokenData.symbol} - tokenFiatAmount={tokenData.tokenFiatAmount} + tokenFiatAmount={showFiat ? tokenData.tokenFiatAmount : null} image={tokenData?.image} isNative={tokenData.isNative} string={tokenData.string} diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index d7ad8e568f4e..19ce592f0071 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -18,6 +18,8 @@ import { getCurrencyRates, getSelectedAccountNativeTokenCachedBalanceByChainId, getSelectedAccount, + getIsTestnet, + getShowFiatInTestnets, } from '../../../selectors'; import { Display, @@ -49,6 +51,8 @@ import CoinButtons from '../../../components/app/wallet-overview/coin-buttons'; import { getIsNativeTokenBuyable } from '../../../ducks/ramps'; import { calculateTokenBalance } from '../../../components/app/assets/util/calculateTokenBalance'; import { useTokenBalances } from '../../../hooks/useTokenBalances'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { getMultichainShouldShowFiat } from '../../../selectors/multichain'; import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; import AssetChart from './chart/asset-chart'; import TokenButtons from './token-buttons'; @@ -109,6 +113,17 @@ const AssetPage = ({ const marketData = useSelector(getMarketData); const currencyRates = useSelector(getCurrencyRates); + const isTestnet = useSelector(getIsTestnet); + const shouldShowFiat = useMultichainSelector( + getMultichainShouldShowFiat, + selectedAccount, + ); + const isMainnet = !isTestnet; + // Check if show conversion is enabled + const showFiatInTestnets = useSelector(getShowFiatInTestnets); + const showFiat = + shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); + const nativeBalances: Record = useSelector( getSelectedAccountNativeTokenCachedBalanceByChainId, ) as Record; @@ -254,7 +269,7 @@ const AssetPage = ({ chainId={chainId} symbol={symbol} image={image} - tokenFiatAmount={tokenFiatAmount} + tokenFiatAmount={showFiat ? tokenFiatAmount : null} string={balance?.toString()} /> Date: Thu, 21 Nov 2024 21:44:49 +0100 Subject: [PATCH 035/148] feat: multichain token detection (#28380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** TokenDetectionController is responsible for detecting and keeping an updated list of all tokens across supported chains. This dataset is stored in the detectedTokens state variable within Metamask’s state. After completing this task, token detection will be enhanced by implementing periodic polling across all networks linked to the wallet, resulting in a more comprehensive dataset available to users. For each network added to the wallet, the polling loop will receive the network as a parameter and execute token autodetection for it. Once results are available, they will be stored in detectedTokensAllChains, organized by chainId. This approach enables us to retrieve a comprehensive list of detected tokens across all networks. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28258?quickstart=1) ## **Related issues** Fixes: [#3431](https://github.com/MetaMask/MetaMask-planning/issues/3431) ## **Manual testing steps** 1. install dependencies using `yarn` 2. start the project using` PORTFOLIO_VIEW=1 yarn start` 3. add other networks where you have tokens 4. the autodetection should be multichain ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/a0db910c-b3a3-456d-b1b9-0c6a3e472976 ## **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. --- .storybook/test-data.js | 7 + ...ts-controllers-npm-44.1.0-012aa448d8.patch | 71 +++++++ package.json | 2 +- .../app/assets/asset-list/asset-list.tsx | 44 ++++- .../detected-token-details.js | 6 +- .../detected-token-selection-popover.js | 85 ++++++-- ...etected-token-selection-popover.stories.js | 5 + .../detected-token-values.js | 24 ++- .../detected-token-values.stories.js | 27 ++- .../app/detected-token/detected-token.js | 184 +++++++++++++++--- .../app/detected-token/detected-token.test.js | 14 ++ .../hide-token-confirmation-modal.js | 37 +++- .../hide-token-confirmation-modal.test.js | 1 + .../detected-token-banner.js | 39 +++- .../import-nfts-modal/import-nfts-modal.js | 1 + ui/hooks/useTokenFiatAmount.js | 37 +++- ui/selectors/selectors.js | 36 ++++ ui/store/actions.test.js | 4 +- ui/store/actions.ts | 8 +- yarn.lock | 57 +----- 20 files changed, 556 insertions(+), 133 deletions(-) create mode 100644 .yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch diff --git a/.storybook/test-data.js b/.storybook/test-data.js index a36cbf944981..717109b77dac 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -525,6 +525,13 @@ const state = { decimals: 18, }, ], + tokenBalances: { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x25e4bc', + }, + }, + }, allDetectedTokens: { '0xaa36a7': { '0x9d0ba4ddac06032527b140912ec808ab9451b788': [ diff --git a/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch b/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch new file mode 100644 index 000000000000..faf388c89741 --- /dev/null +++ b/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch @@ -0,0 +1,71 @@ +diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs +index 48571b8c1b78e94d88e1837e986b5f8735ac651b..61246f51500c8cab48f18296a73629fb73454caa 100644 +--- a/dist/assetsUtil.cjs ++++ b/dist/assetsUtil.cjs +@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); ++function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } + exports.fetchTokenContractExchangeRates = exports.reduceInBatchesSerially = exports.divideIntoBatches = exports.ethersBigNumberToBN = exports.addUrlProtocolPrefix = exports.getFormattedIpfsUrl = exports.getIpfsCIDv1AndPath = exports.removeIpfsProtocolPrefix = exports.isTokenListSupportedForNetwork = exports.isTokenDetectionSupportedForNetwork = exports.SupportedStakedBalanceNetworks = exports.SupportedTokenDetectionNetworks = exports.formatIconUrlWithProxy = exports.formatAggregatorNames = exports.hasNewCollectionFields = exports.compareNftMetadata = exports.TOKEN_PRICES_BATCH_SIZE = void 0; + const controller_utils_1 = require("@metamask/controller-utils"); + const utils_1 = require("@metamask/utils"); +@@ -233,7 +234,7 @@ async function getIpfsCIDv1AndPath(ipfsUrl) { + const index = url.indexOf('/'); + const cid = index !== -1 ? url.substring(0, index) : url; + const path = index !== -1 ? url.substring(index) : undefined; +- const { CID } = await import("multiformats"); ++ const { CID } = _interopRequireWildcard(require("multiformats")); + // We want to ensure that the CID is v1 (https://docs.ipfs.io/concepts/content-addressing/#identifier-formats) + // because most cid v0s appear to be incompatible with IPFS subdomains + return { +diff --git a/dist/token-prices-service/codefi-v2.mjs b/dist/token-prices-service/codefi-v2.mjs +index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..bf8ec7819f678c2f185d6a85d7e3ea81f055a309 100644 +--- a/dist/token-prices-service/codefi-v2.mjs ++++ b/dist/token-prices-service/codefi-v2.mjs +@@ -12,8 +12,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( + var _CodefiTokenPricesServiceV2_tokenPricePolicy; + import { handleFetch } from "@metamask/controller-utils"; + import { hexToNumber } from "@metamask/utils"; +-import $cockatiel from "cockatiel"; +-const { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } = $cockatiel; ++import { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } from "cockatiel" + /** + * The list of currencies that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint, in lowercase form. +diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs +index 8fd5efde7a3c24080f8a43f79d10300e8c271245..a3c334ac7dd2e5698e6b54a73491b7145c2a9010 100644 +--- a/dist/TokenDetectionController.cjs ++++ b/dist/TokenDetectionController.cjs +@@ -250,17 +250,20 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ + } + }); + this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async (selectedAccount) => { +- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; +- if (isSelectedAccountIdChanged) { +- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- }); +- } +- }); ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async (selectedAccount) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; ++ if (isSelectedAccountIdChanged) { ++ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); + }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { + if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { + clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); diff --git a/package.json b/package.json index 2f7498a1d537..c12b8b40cdfb 100644 --- a/package.json +++ b/package.json @@ -293,7 +293,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@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 068813ee71c8..114ced0496ca 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -4,8 +4,11 @@ import TokenList from '../token-list'; import { PRIMARY } from '../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; import { + getAllDetectedTokensForSelectedAddress, getDetectedTokensInCurrentNetwork, getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, + getNetworkConfigurationsByChainId, + getPreferences, getSelectedAccount, } from '../../../../selectors'; import { @@ -76,6 +79,17 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, ); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allOpts: Record = {}; + Object.keys(allNetworks || {}).forEach((chainId) => { + allOpts[chainId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter || {}).length !== + Object.keys(allOpts || {}).length; + const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); const [showReceiveModal, setShowReceiveModal] = useState(false); @@ -98,16 +112,30 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { // for EVM assets const shouldShowTokensLinks = showTokensLinks ?? isEvm; + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, + ); + + const totalTokens = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? (Object.values(detectedTokensMultichain).reduce( + // @ts-expect-error TS18046: 'tokenArray' is of type 'unknown' + (count, tokenArray) => count + tokenArray.length, + 0, + ) as number) + : detectedTokens.length; + return ( <> - {detectedTokens.length > 0 && - !isTokenDetectionInactiveOnNonMainnetSupportedNetwork && ( - setShowDetectedTokens(true)} - margin={4} - /> - )} + {totalTokens && + totalTokens > 0 && + !isTokenDetectionInactiveOnNonMainnetSupportedNetwork ? ( + setShowDetectedTokens(true)} + margin={4} + /> + ) : null} { const tokenList = useSelector(getTokenList); const tokenData = tokenList[token.address?.toLowerCase()]; const testNetworkBackgroundColor = useSelector(getTestNetworkBackgroundColor); const currentNetwork = useSelector(getCurrentNetwork); - return ( } @@ -84,6 +85,7 @@ DetectedTokenDetails.propTypes = { }), handleTokenSelection: PropTypes.func.isRequired, tokensListDetected: PropTypes.object, + chainId: PropTypes.string, }; export default DetectedTokenDetails; diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js index 0229173050d8..5974d8ba71b6 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; @@ -10,8 +10,12 @@ import { MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import { + getAllDetectedTokensForSelectedAddress, getCurrentChainId, + getCurrentNetwork, getDetectedTokensInCurrentNetwork, + getNetworkConfigurationsByChainId, + getPreferences, } from '../../../../selectors'; import Popover from '../../../ui/popover'; @@ -34,10 +38,38 @@ const DetectedTokenSelectionPopover = ({ const chainId = useSelector(getCurrentChainId); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allOpts = {}; + Object.keys(allNetworks || {}).forEach((networkId) => { + allOpts[networkId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter || {}).length !== + Object.keys(allOpts || {}).length; + + const currentNetwork = useSelector(getCurrentNetwork); + + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, + ); + + const totalTokens = useMemo(() => { + return process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).reduce( + (count, tokenArray) => count + tokenArray.length, + 0, + ) + : detectedTokens.length; + }, [detectedTokensMultichain, detectedTokens, allNetworksFilterShown]); + const { selected: selectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); const onClose = () => { + const chainIds = Object.keys(detectedTokensMultichain); + setShowDetectedTokens(false); const eventTokensDetails = detectedTokens.map( ({ address, symbol }) => `${symbol} - ${address}`, @@ -47,8 +79,10 @@ const DetectedTokenSelectionPopover = ({ category: MetaMetricsEventCategory.Wallet, properties: { source_connection_method: MetaMetricsTokenEventSource.Detected, - chain_id: chainId, tokens: eventTokensDetails, + ...(process.env.PORTFOLIO_VIEW + ? { chain_ids: chainIds } + : { chain_id: chainId }), }, }); }; @@ -81,25 +115,44 @@ const DetectedTokenSelectionPopover = ({ - - {detectedTokens.map((token, index) => { - return ( - - ); - })} - + {process.env.PORTFOLIO_VIEW && !allNetworksFilterShown ? ( + + {Object.entries(detectedTokensMultichain).map( + ([networkId, tokens]) => { + return tokens.map((token, index) => ( + + )); + }, + )} + + ) : ( + + {detectedTokens.map((token, index) => { + return ( + + ); + })} + + )} ); }; diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js index 525e88fc2785..5d7def0f28e9 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js @@ -11,6 +11,11 @@ const store = configureStore({ ...testData, metamask: { ...testData.metamask, + currencyRates: { + SepoliaETH: { + conversionRate: 3910.28, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA }), }, }); diff --git a/ui/components/app/detected-token/detected-token-values/detected-token-values.js b/ui/components/app/detected-token/detected-token-values/detected-token-values.js index 667c356b0e1f..07c70edcf196 100644 --- a/ui/components/app/detected-token/detected-token-values/detected-token-values.js +++ b/ui/components/app/detected-token/detected-token-values/detected-token-values.js @@ -7,10 +7,14 @@ import { TextColor, TextVariant, } from '../../../../helpers/constants/design-system'; -import { useTokenTracker } from '../../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; -import { getUseCurrencyRateCheck } from '../../../../selectors'; +import { + getCurrentChainId, + getSelectedAddress, + getUseCurrencyRateCheck, +} from '../../../../selectors'; import { Box, Checkbox, Text } from '../../../component-library'; +import { useTokenTracker } from '../../../../hooks/useTokenBalances'; const DetectedTokenValues = ({ token, @@ -21,12 +25,25 @@ const DetectedTokenValues = ({ return tokensListDetected[token.address]?.selected; }); - const { tokensWithBalances } = useTokenTracker({ tokens: [token] }); + const selectedAddress = useSelector(getSelectedAddress); + const currentChainId = useSelector(getCurrentChainId); + const chainId = token.chainId ?? currentChainId; + + const { tokensWithBalances } = useTokenTracker({ + chainId, + tokens: [token], + address: selectedAddress, + hideZeroBalanceTokens: false, + }); + const balanceString = tokensWithBalances[0]?.string; const formattedFiatBalance = useTokenFiatAmount( token.address, balanceString, token.symbol, + {}, + false, + chainId, ); const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); @@ -73,6 +90,7 @@ DetectedTokenValues.propTypes = { symbol: PropTypes.string, iconUrl: PropTypes.string, aggregators: PropTypes.array, + chainId: PropTypes.string, }), handleTokenSelection: PropTypes.func.isRequired, tokensListDetected: PropTypes.object, diff --git a/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js b/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js index cf5039a7c2a6..32402631b7bf 100644 --- a/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js +++ b/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js @@ -1,14 +1,16 @@ import React from 'react'; - +import { Provider } from 'react-redux'; +import testData from '../../../../../.storybook/test-data'; +import configureStore from '../../../../store/store'; import DetectedTokenValues from './detected-token-values'; export default { title: 'Components/App/DetectedToken/DetectedTokenValues', - + component: DetectedTokenValues, argTypes: { token: { control: 'object' }, - handleTokenSelection: { control: 'func' }, - tokensListDetected: { control: 'array' }, + handleTokenSelection: { action: 'handleTokenSelection' }, // Action for interactions + tokensListDetected: { control: 'object' }, }, args: { token: { @@ -73,10 +75,21 @@ export default { }, }; -const Template = (args) => { - return ; +// Mock store data +const customData = { + ...testData, + metamask: { + ...testData.metamask, + }, }; -export const DefaultStory = Template.bind({}); +const customStore = configureStore(customData); + +const Template = (args) => ( + + + +); +export const DefaultStory = Template.bind({}); DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/detected-token/detected-token.js b/ui/components/app/detected-token/detected-token.js index 3d9038cc52b9..31e100547e12 100644 --- a/ui/components/app/detected-token/detected-token.js +++ b/ui/components/app/detected-token/detected-token.js @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector, useDispatch } from 'react-redux'; import { chain } from 'lodash'; @@ -9,8 +9,11 @@ import { setNewTokensImported, } from '../../../store/actions'; import { + getAllDetectedTokensForSelectedAddress, getCurrentChainId, getDetectedTokensInCurrentNetwork, + getNetworkConfigurationsByChainId, + getPreferences, getSelectedNetworkClientId, } from '../../../selectors'; import { MetaMetricsContext } from '../../../contexts/metametrics'; @@ -38,8 +41,8 @@ const sortingBasedOnTokenSelection = (tokensDetected) => { // ditch the 'selected' property and get just the tokens' .mapValues((group) => group.map(({ token }) => { - const { address, symbol, decimals, aggregators } = token; - return { address, symbol, decimals, aggregators }; + const { address, symbol, decimals, aggregators, chainId } = token; + return { address, symbol, decimals, aggregators, chainId }; }), ) // Exit the chain and get the underlying value, an object. @@ -51,16 +54,68 @@ const DetectedToken = ({ setShowDetectedTokens }) => { const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); - const chainId = useSelector(getCurrentChainId); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); const networkClientId = useSelector(getSelectedNetworkClientId); - - const [tokensListDetected, setTokensListDetected] = useState(() => - detectedTokens.reduce((tokenObj, token) => { - tokenObj[token.address] = { token, selected: true }; - return tokenObj; - }, {}), + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, ); + const currentChainId = useSelector(getCurrentChainId); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allOpts = {}; + Object.keys(allNetworks || {}).forEach((chainId) => { + allOpts[chainId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter || {}).length !== + Object.keys(allOpts || {}).length; + + const totalDetectedTokens = useMemo(() => { + return process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).flat().length + : detectedTokens.length; + }, [detectedTokens, detectedTokensMultichain, allNetworksFilterShown]); + + const [tokensListDetected, setTokensListDetected] = useState({}); + + useEffect(() => { + const newTokensList = () => { + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + return Object.entries(detectedTokensMultichain).reduce( + (acc, [chainId, tokens]) => { + if (Array.isArray(tokens)) { + tokens.forEach((token) => { + acc[token.address] = { + token: { ...token, chainId }, + selected: tokensListDetected[token.address]?.selected ?? true, + }; + }); + } + return acc; + }, + {}, + ); + } + + return detectedTokens.reduce((tokenObj, token) => { + tokenObj[token.address] = { + token, + selected: tokensListDetected[token.address]?.selected ?? true, + chainId: currentChainId, + }; + return tokenObj; + }, {}); + }; + + setTokensListDetected(newTokensList()); + }, [ + allNetworksFilterShown, + detectedTokensMultichain, + detectedTokens, + currentChainId, + ]); + const [showDetectedTokenIgnoredPopover, setShowDetectedTokenIgnoredPopover] = useState(false); const [partiallyIgnoreDetectedTokens, setPartiallyIgnoreDetectedTokens] = @@ -79,22 +134,53 @@ const DetectedToken = ({ setShowDetectedTokens }) => { token_standard: TokenStandard.ERC20, asset_type: AssetType.token, token_added_type: 'detected', - chain_id: chainId, + chain_id: importedToken.chainId, }, }); }); - await dispatch(addImportedTokens(selectedTokens, networkClientId)); - const tokenSymbols = selectedTokens.map(({ symbol }) => symbol); - dispatch(setNewTokensImported(tokenSymbols.join(', '))); + + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + const tokensByChainId = selectedTokens.reduce((acc, token) => { + const { chainId } = token; + + if (!acc[chainId]) { + acc[chainId] = { tokens: [] }; + } + + acc[chainId].tokens.push(token); + + return acc; + }, {}); + + const importPromises = Object.entries(tokensByChainId).map( + async ([networkId, { tokens }]) => { + const chainConfig = allNetworks[networkId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch(addImportedTokens(tokens, networkInstanceId)); + const tokenSymbols = tokens.map(({ symbol }) => symbol); + dispatch(setNewTokensImported(tokenSymbols.join(', '))); + }, + ); + + await Promise.all(importPromises); + } else { + await dispatch(addImportedTokens(selectedTokens, networkClientId)); + const tokenSymbols = selectedTokens.map(({ symbol }) => symbol); + dispatch(setNewTokensImported(tokenSymbols.join(', '))); + } }; const handleClearTokensSelection = async () => { const { selected: selectedTokens = [], deselected: deSelectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); - if (deSelectedTokens.length < detectedTokens.length) { + if (deSelectedTokens.length < totalDetectedTokens) { await importSelectedTokens(selectedTokens); } + const tokensDetailsList = deSelectedTokens.map( ({ symbol, address }) => `${symbol} - ${address}`, ); @@ -108,17 +194,53 @@ const DetectedToken = ({ setShowDetectedTokens }) => { asset_type: AssetType.token, }, }); - const deSelectedTokensAddresses = deSelectedTokens.map( - ({ address }) => address, - ); - await dispatch( - ignoreTokens({ - tokensToIgnore: deSelectedTokensAddresses, - dontShowLoadingIndicator: true, - }), - ); - setShowDetectedTokens(false); - setPartiallyIgnoreDetectedTokens(false); + + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + // group deselected tokens by chainId + const groupedByChainId = deSelectedTokens.reduce((acc, token) => { + const { chainId } = token; + if (!acc[chainId]) { + acc[chainId] = []; + } + acc[chainId].push(token); + return acc; + }, {}); + + const promises = Object.entries(groupedByChainId).map( + async ([chainId, tokens]) => { + const chainConfig = allNetworks[chainId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch( + ignoreTokens({ + tokensToIgnore: tokens, + dontShowLoadingIndicator: true, + networkClientId: networkInstanceId, + }), + ); + }, + ); + + await Promise.all(promises); + setShowDetectedTokens(false); + setPartiallyIgnoreDetectedTokens(false); + } else { + const deSelectedTokensAddresses = deSelectedTokens.map( + ({ address }) => address, + ); + + await dispatch( + ignoreTokens({ + tokensToIgnore: deSelectedTokensAddresses, + dontShowLoadingIndicator: true, + }), + ); + + setShowDetectedTokens(false); + setPartiallyIgnoreDetectedTokens(false); + } }; const handleTokenSelection = (token) => { @@ -135,7 +257,7 @@ const DetectedToken = ({ setShowDetectedTokens }) => { const { selected: selectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); - if (selectedTokens.length < detectedTokens.length) { + if (selectedTokens.length < totalDetectedTokens) { setShowDetectedTokenIgnoredPopover(true); setPartiallyIgnoreDetectedTokens(true); } else { @@ -169,9 +291,13 @@ const DetectedToken = ({ setShowDetectedTokens }) => { partiallyIgnoreDetectedTokens={partiallyIgnoreDetectedTokens} /> )} - {detectedTokens.length > 0 && ( + {totalDetectedTokens > 0 && ( { ...testData, metamask: { ...testData.metamask, + currencyRates: { + SepoliaETH: { + conversionDate: 1620710825.03, + conversionRate: 3910.28, + usdConversionRate: 3910.28, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA }), + tokenBalances: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + [CHAIN_IDS.SEPOLIA]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x25e4bc', + }, + }, + }, }, }); const props = { diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js index f9f15d211806..e1662a641ae7 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js @@ -9,27 +9,31 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; -import { getCurrentChainId } from '../../../../selectors'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../selectors'; function mapStateToProps(state) { return { chainId: getCurrentChainId(state), token: state.appState.modal.modalState.props.token, history: state.appState.modal.modalState.props.history, + networkConfigurationsByChainId: getNetworkConfigurationsByChainId(state), }; } function mapDispatchToProps(dispatch) { return { hideModal: () => dispatch(actions.hideModal()), - hideToken: (address) => { - dispatch( + hideToken: async (address, networkClientId) => { + await dispatch( actions.ignoreTokens({ tokensToIgnore: address, + networkClientId, }), - ).then(() => { - dispatch(actions.hideModal()); - }); + ); + dispatch(actions.hideModal()); }, }; } @@ -44,10 +48,12 @@ class HideTokenConfirmationModal extends Component { hideToken: PropTypes.func.isRequired, hideModal: PropTypes.func.isRequired, chainId: PropTypes.string.isRequired, + networkConfigurationsByChainId: PropTypes.object.isRequired, token: PropTypes.shape({ symbol: PropTypes.string, address: PropTypes.string, image: PropTypes.string, + chainId: PropTypes.string, }), history: PropTypes.object, }; @@ -55,8 +61,21 @@ class HideTokenConfirmationModal extends Component { state = {}; render() { - const { chainId, token, hideToken, hideModal, history } = this.props; - const { symbol, address, image } = token; + const { + chainId, + token, + hideToken, + hideModal, + history, + networkConfigurationsByChainId, + } = this.props; + const { symbol, address, image, chainId: tokenChainId } = token; + const chainIdToUse = tokenChainId || chainId; + + const chainConfig = networkConfigurationsByChainId[chainIdToUse]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; return (
@@ -96,7 +115,7 @@ class HideTokenConfirmationModal extends Component { token_symbol: symbol, }, }); - hideToken(address); + hideToken(address, networkInstanceId); history.push(DEFAULT_ROUTE); }} > diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js index 6a53c80a378d..982a6a272943 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js @@ -79,6 +79,7 @@ describe('Hide Token Confirmation Modal', () => { expect(mockHideModal).toHaveBeenCalled(); expect(actions.ignoreTokens).toHaveBeenCalledWith({ tokensToIgnore: tokenState.address, + networkClientId: 'goerli', }); }); }); diff --git a/ui/components/multichain/detected-token-banner/detected-token-banner.js b/ui/components/multichain/detected-token-banner/detected-token-banner.js index 8851614ec948..59db889d11ce 100644 --- a/ui/components/multichain/detected-token-banner/detected-token-banner.js +++ b/ui/components/multichain/detected-token-banner/detected-token-banner.js @@ -7,6 +7,9 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId, getDetectedTokensInCurrentNetwork, + getAllDetectedTokensForSelectedAddress, + getPreferences, + getNetworkConfigurationsByChainId, } from '../../../selectors'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { @@ -23,14 +26,40 @@ export const DetectedTokensBanner = ({ }) => { const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + + const allOpts = {}; + Object.keys(allNetworks || {}).forEach((chainId) => { + allOpts[chainId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter || {}).length !== + Object.keys(allOpts || {}).length; const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); - const detectedTokensDetails = detectedTokens.map( - ({ address, symbol }) => `${symbol} - ${address}`, - ); + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, + ); const chainId = useSelector(getCurrentChainId); + const detectedTokensDetails = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain) + .flat() + .map(({ address, symbol }) => `${symbol} - ${address}`) + : detectedTokens.map(({ address, symbol }) => `${symbol} - ${address}`); + + const totalTokens = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).reduce( + (count, tokenArray) => count + tokenArray.length, + 0, + ) + : detectedTokens.length; + const handleOnClick = () => { actionButtonOnClick(); trackEvent({ @@ -51,9 +80,9 @@ export const DetectedTokensBanner = ({ data-testid="detected-token-banner" {...props} > - {detectedTokens.length === 1 + {totalTokens === 1 ? t('numberOfNewTokensDetectedSingular') - : t('numberOfNewTokensDetectedPlural', [detectedTokens.length])} + : t('numberOfNewTokensDetectedPlural', [totalTokens])} ); }; diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js index 8b85e5cf1a4b..549f0ab5c70d 100644 --- a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js @@ -76,6 +76,7 @@ export const ImportNftsModal = ({ onClose }) => { const [disabled, setDisabled] = useState(true); const [nftAddFailed, setNftAddFailed] = useState(false); const trackEvent = useContext(MetaMetricsContext); + const [nftAddressValidationError, setNftAddressValidationError] = useState(null); const [duplicateTokenIdError, setDuplicateTokenIdError] = useState(null); diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index dfa4144b90e3..5abbbc608de0 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -5,6 +5,9 @@ import { getCurrentCurrency, getShouldShowFiat, getConfirmationExchangeRates, + getMarketData, + getCurrencyRates, + getNetworkConfigurationsByChainId, } from '../selectors'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { getConversionRate } from '../ducks/metamask/metamask'; @@ -22,6 +25,7 @@ import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; * @param {boolean} [overrides.showFiat] - If truthy, ensures the fiat value is shown even if the showFiat value from state is falsey * @param {boolean} hideCurrencySymbol - Indicates whether the returned formatted amount should include the trailing currency symbol * @returns {string} The formatted token amount in the user's chosen fiat currency + * @param {string} [chainId] - The chain id */ export function useTokenFiatAmount( tokenAddress, @@ -29,17 +33,44 @@ export function useTokenFiatAmount( tokenSymbol, overrides = {}, hideCurrencySymbol, + chainId = null, ) { + const allMarketData = useSelector(getMarketData); + const contractExchangeRates = useSelector( getTokenExchangeRates, shallowEqual, ); + + const contractMarketData = chainId + ? Object.entries(allMarketData[chainId]).reduce( + (acc, [address, marketData]) => { + acc[address] = marketData?.price ?? null; + return acc; + }, + {}, + ) + : null; + + const tokenMarketData = chainId ? contractMarketData : contractExchangeRates; + const confirmationExchangeRates = useSelector(getConfirmationExchangeRates); const mergedRates = { - ...contractExchangeRates, + ...tokenMarketData, ...confirmationExchangeRates, }; + + const currencyRates = useSelector(getCurrencyRates); const conversionRate = useSelector(getConversionRate); + const networkConfigurationsByChainId = useSelector( + getNetworkConfigurationsByChainId, + ); + + const tokenConversionRate = chainId + ? currencyRates[networkConfigurationsByChainId[chainId].nativeCurrency] + .conversionRate + : conversionRate; + const currentCurrency = useSelector(getCurrentCurrency); const userPrefersShownFiat = useSelector(getShouldShowFiat); const showFiat = overrides.showFiat ?? userPrefersShownFiat; @@ -53,7 +84,7 @@ export function useTokenFiatAmount( () => getTokenFiatAmount( tokenExchangeRate, - conversionRate, + tokenConversionRate, currentCurrency, tokenAmount, tokenSymbol, @@ -61,8 +92,8 @@ export function useTokenFiatAmount( hideCurrencySymbol, ), [ + tokenConversionRate, tokenExchangeRate, - conversionRate, currentCurrency, tokenAmount, tokenSymbol, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 96e880d52aef..27b3a878042b 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -86,6 +86,7 @@ import { getLedgerTransportType, isAddressLedger, getIsUnlocked, + getCompletedOnboarding, } from '../ducks/metamask/metamask'; import { getLedgerWebHidConnectedStatus, @@ -2554,6 +2555,41 @@ export function getDetectedTokensInCurrentNetwork(state) { return state.metamask.allDetectedTokens?.[currentChainId]?.[selectedAddress]; } +export function getAllDetectedTokens(state) { + return state.metamask.allDetectedTokens; +} + +/** + * To retrieve the list of tokens detected across all chains. + * + * @param {*} state + * @returns list of token objects on all networks + */ +export function getAllDetectedTokensForSelectedAddress(state) { + const completedOnboarding = getCompletedOnboarding(state); + + if (!completedOnboarding) { + return {}; + } + + const { address: selectedAddress } = getSelectedInternalAccount(state); + + const tokensByChainId = Object.entries( + state.metamask.allDetectedTokens || {}, + ).reduce((acc, [chainId, chainTokens]) => { + const tokensForAddress = chainTokens[selectedAddress]; + if (tokensForAddress) { + acc[chainId] = tokensForAddress.map((token) => ({ + ...token, + chainId, + })); + } + return acc; + }, {}); + + return tokensByChainId; +} + /** * To fetch the name of the tokens that are imported from tokens found page * diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 10391adad3df..c16df9838916 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1013,7 +1013,9 @@ describe('Actions', () => { const store = mockStore(); background.getApi.returns({ - ignoreTokens: sinon.stub().callsFake((_, cb) => cb(new Error('error'))), + ignoreTokens: sinon + .stub() + .callsFake((_, __, cb) => cb(new Error('error'))), getStatePatches: sinon.stub().callsFake((cb) => cb(null, [])), }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index a339ff856058..66962d46161d 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -1989,14 +1989,17 @@ export function addImportedTokens( * * @param options * @param options.tokensToIgnore + * @param options.networkClientId * @param options.dontShowLoadingIndicator */ export function ignoreTokens({ tokensToIgnore, dontShowLoadingIndicator = false, + networkClientId = null, }: { tokensToIgnore: string[]; dontShowLoadingIndicator: boolean; + networkClientId?: NetworkClientId; }): ThunkAction { const _tokensToIgnore = Array.isArray(tokensToIgnore) ? tokensToIgnore @@ -2007,7 +2010,10 @@ export function ignoreTokens({ dispatch(showLoadingIndication()); } try { - await submitRequestToBackground('ignoreTokens', [_tokensToIgnore]); + await submitRequestToBackground('ignoreTokens', [ + _tokensToIgnore, + networkClientId, + ]); } catch (error) { logErrorWithMessage(error); dispatch(displayWarning(error)); diff --git a/yarn.lock b/yarn.lock index adf55eaa9e85..65dc70847c4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4934,9 +4934,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:44.0.0": - version: 44.0.0 - resolution: "@metamask/assets-controllers@npm:44.0.0" +"@metamask/assets-controllers@npm:44.1.0": + version: 44.1.0 + resolution: "@metamask/assets-controllers@npm:44.1.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -4969,13 +4969,13 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/6f3d8712a90aa322aabd38d43663d299ad7ee98a6d838d72bfc3b426ea0e4e925bb78c1aaaa3c75d43e95d46993c47583a4a03f4c58aee155525424fa86207ae + checksum: 10/924c67fba204711ddde4be6615359318ed0fbdd05ebd8e5d98ae9d9ae288adad5cb6fc901b91d8e84f92a6ab62f0bfb25601b03c676044009f81a7fffa8087e7 languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2": - version: 44.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch": + version: 44.1.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch::version=44.1.0&hash=423db2" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5008,46 +5008,7 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/0d6c386a1f1e68ab339340fd8fa600827f55f234bc54b2224069a1819ab037641daa9696a0d62f187c0649317393efaeeb119a7852af51da3bb340e0e98cf9f6 - languageName: node - linkType: hard - -"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch": - version: 44.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch::version=44.0.0&hash=c4e407" - dependencies: - "@ethereumjs/util": "npm:^8.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@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.2" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.4.3" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@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" - bn.js: "npm:^5.2.1" - cockatiel: "npm:^3.1.2" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^13.1.0" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^19.0.0 - "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^18.0.0 - "@metamask/network-controller": ^22.0.0 - "@metamask/preferences-controller": ^14.0.0 - checksum: 10/11e8920bdf8ffce4a534c6aadfe768176c4e461a00bc06e6ece52f085755ff252194881d9edd308097186a05057075fd9812b6e4b1fd97dd731814ad205013da + checksum: 10/5e3b0109e6b5c0d65338a18b2c590d15229003e05c55cf0013d8e32687bbe774de05872a7b61038aa90177a6ce01b32814c3c680ee3c10cbad8cba9db2d796aa languageName: node linkType: hard @@ -26819,7 +26780,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@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" From 3eb37d9380b3d8554122ec5467ad0f491506f551 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 21 Nov 2024 17:57:37 -0330 Subject: [PATCH 036/148] test: Fix flakiness caused by display of newly switched to network modal (#28625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the flakiness seen, for example, in https://app.circleci.com/pipelines/github/MetaMask/metamask-extension/111818/workflows/f974462c-8208-4189-8592-928b21f0cfde/jobs/4189413/tests See this screenshot: ![Screenshot from 2024-11-21 16-39-14](https://github.com/user-attachments/assets/e5523440-82a8-462e-9ad3-fa2280e97826) The test failure error is: `ElementClickInterceptedError: Element
is not clickable at point (866,482) because another element
obscures it` So the test is attempting to clikc the "Import tokens" button in the opened menu behind the "You are now using Binance" modal, but the Import Tokens button cannot be clicked because the modal intercepts the click So there is a race condition whereby the test assumes that no modal will interfere with the click, and the test will pass if the click can occur before the modal is rendered, but it will fail the the click is attempted after the modal is rendered. Prior to the PR in question (https://github.com/MetaMask/metamask-extension/pull/28575) there was no menu, and the "Import tokens" button could be clicked directly. The PR added the menu and move "Import tokens" into that menu, so now the test has to wait for that menu to open before the "Import tokens" button can be clicked, exacerbating the race condition. That new network modal should not be shown in that scenario this is the logic that controls whether that network modal should be shown: ``` const shouldShowNetworkInfo = isUnlocked && currentChainId && !isTestNet && !isSendRoute && !isNetworkUsed && !isCurrentProviderCustom && completedOnboarding && allAccountsOnNetworkAreEmpty && switchedNetworkDetails === null; ``` (I think that is what folks are above referring to as the "Got it" modal) Meanwhile, in fixture-builder.js we have: ``` usedNetworks: { [CHAIN_IDS.MAINNET]: true, [CHAIN_IDS.LINEA_MAINNET]: true, [CHAIN_IDS.GOERLI]: true, [CHAIN_IDS.LOCALHOST]: true, }, ``` So for any test that sets the network to something other than those four networks, !isNetworkUsed will evaluate to true, which will result in that modal being shown. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28625?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** e2e tests should ## **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-interaction-redesign.spec.ts | 3 +++ test/e2e/tests/tokens/add-hide-token.spec.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts index 9b37e2ee5066..a668539d24dc 100644 --- a/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts @@ -146,6 +146,9 @@ describe('Confirmation Redesign Contract Interaction Component', function () { }, useTransactionSimulations: false, }) + .withAppStateController({ + [CHAIN_IDS.OPTIMISM]: true, + }) .withNetworkControllerOnOptimism() .build(), ganacheOptions: { diff --git a/test/e2e/tests/tokens/add-hide-token.spec.js b/test/e2e/tests/tokens/add-hide-token.spec.js index c9a1f26ad9eb..011d52849feb 100644 --- a/test/e2e/tests/tokens/add-hide-token.spec.js +++ b/test/e2e/tests/tokens/add-hide-token.spec.js @@ -118,6 +118,9 @@ describe('Add existing token using search', function () { }, ], }) + .withAppStateController({ + [CHAIN_IDS.OPTIMISM]: true, + }) .build(), ganacheOptions: { ...defaultGanacheOptions, From 175d42939d8cfca6a876e83b109723c3da433dcb Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:27:48 -0800 Subject: [PATCH 037/148] fix: Network filter must respect `PORTFOLIO_VIEW` feature flag (#28626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When feature flag is omitted, we should not show tokens across all chains, only the `currentNetwork.chainId` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28626?quickstart=1) ## **Related issues** Fixes: Multichain tokens being rendered when `PORTFOLIO_VIEW` feature is off (unexpected) ## **Manual testing steps** With feature flag off: `yarn webpack --watch` When running the app, the tokens displayed should always belong to the globally selected network. With feature flag on: `PORTFOLIO_VIEW=1 yarn webpack --watch` When running the app, you should see all tokens from all added networks, when "All Networks" filter is selected. When "Current filter" is selected, you should see only tokens for the globally selected network. ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-11-21 at 8 18 08 PM](https://github.com/user-attachments/assets/91305b19-8614-4bf1-a5c4-ec6a37633369) ### **After** https://github.com/user-attachments/assets/40fc641b-e8df-4eb8-beb9-9d907fd9888b ## **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. --- .../errors-after-init-opt-in-background-state.json | 4 ---- .../errors-after-init-opt-in-ui-state.json | 4 ---- ...errors-before-init-opt-in-background-state.json | 3 +-- .../asset-list-control-bar.tsx | 7 ++++++- ui/components/app/assets/token-list/token-list.tsx | 14 ++++++++------ .../network-list-menu/network-list-menu.tsx | 2 +- 6 files changed, 16 insertions(+), 18 deletions(-) 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 6252c91e73a2..e8ca8579a7a4 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 @@ -238,10 +238,6 @@ "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", "tokenNetworkFilter": { - "0x1": "boolean", - "0xaa36a7": "boolean", - "0xe705": "boolean", - "0xe708": "boolean", "0x539": "boolean" }, "shouldShowAggregatedBalancePopover": "boolean" 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 3bc7057435c8..cbc9ff5b74c4 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 @@ -39,10 +39,6 @@ "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", "tokenNetworkFilter": { - "0x1": "boolean", - "0xaa36a7": "boolean", - "0xe705": "boolean", - "0xe708": "boolean", "0x539": "boolean" }, "shouldShowAggregatedBalancePopover": "boolean" diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 5d6be06b692e..07b292d33b3b 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -120,8 +120,7 @@ "tokenSortConfig": "object", "tokenNetworkFilter": {}, "showMultiRpcModal": "boolean", - "shouldShowAggregatedBalancePopover": "boolean", - "tokenNetworkFilter": {} + "shouldShowAggregatedBalancePopover": "boolean" }, "selectedAddress": "string", "theme": "light", 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 e5c43aad281d..a09b5128396a 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 @@ -87,8 +87,13 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { // We need to set the default filter for all users to be all included networks, rather than defaulting to empty object // This effect is to unblock and derisk in the short-term useEffect(() => { - if (Object.keys(tokenNetworkFilter || {}).length === 0) { + if ( + process.env.PORTFOLIO_VIEW && + Object.keys(tokenNetworkFilter || {}).length === 0 + ) { dispatch(setTokenNetworkFilter(allOpts)); + } else { + dispatch(setTokenNetworkFilter({ [currentNetwork.chainId]: true })); } }, []); diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 3f094634743e..5e25c7ff5f41 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -112,12 +112,14 @@ export default function TokenList({ // Ensure newly added networks are included in the tokenNetworkFilter useEffect(() => { - const allNetworkFilters = Object.fromEntries( - Object.keys(allNetworks).map((chainId) => [chainId, true]), - ); - - if (Object.keys(tokenNetworkFilter).length > 1) { - dispatch(setTokenNetworkFilter(allNetworkFilters)); + if (process.env.PORTFOLIO_VIEW) { + const allNetworkFilters = Object.fromEntries( + Object.keys(allNetworks).map((chainId) => [chainId, true]), + ); + + if (Object.keys(tokenNetworkFilter).length > 1) { + dispatch(setTokenNetworkFilter(allNetworkFilters)); + } } }, [Object.keys(allNetworks).length]); diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 4dcecbf696e7..9c99b3048a95 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -292,7 +292,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { // however, if I am already filtered on "Current Network", we'll want to filter by the selected network when the network changes if (Object.keys(tokenNetworkFilter).length <= 1) { dispatch(setTokenNetworkFilter({ [network.chainId]: true })); - } else { + } else if (process.env.PORTFOLIO_VIEW) { dispatch(setTokenNetworkFilter(allOpts)); } From e748576e927a3c853c2f3662b207988c54e71387 Mon Sep 17 00:00:00 2001 From: Harika <153644847+hjetpoluru@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:31:07 -0500 Subject: [PATCH 038/148] test: Fixed artifacts issue due to switching window title (#28469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR addresses the issue where artifacts are not generated when a timeout occurs during the switching window and the dialog does not appear, making it difficult to determine what has happened during the test scenario. The fix ensures that an error is generated when a timeout occurs and is correctly returned. If the dialog is found, no error is generated. Special thanks to @davidmurdoch for pair programming, understanding the issue and suggesting the fix. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28469?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28116 ## **Manual testing steps** All the artifacts needs to be generated. ## **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 - [ ] 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. --- .../server-mocha-to-background.ts | 18 +++++++++++++++--- test/e2e/background-socket/types.ts | 1 + 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/test/e2e/background-socket/server-mocha-to-background.ts b/test/e2e/background-socket/server-mocha-to-background.ts index 3b4cc1c88b2f..ef8cc446a60c 100644 --- a/test/e2e/background-socket/server-mocha-to-background.ts +++ b/test/e2e/background-socket/server-mocha-to-background.ts @@ -83,9 +83,14 @@ class ServerMochaToBackground { if (message.command === 'openTabs' && message.tabs) { this.eventEmitter.emit('openTabs', message.tabs); } else if (message.command === 'notFound') { - throw new Error( + const error = new Error( `No window found by background script with ${message.property}: ${message.value}`, ); + if (this.eventEmitter.listenerCount('error') > 0) { + this.eventEmitter.emit('error', error); + } else { + throw error; + } } } @@ -108,8 +113,15 @@ class ServerMochaToBackground { // This is a way to wait for an event async, without timeouts or polling async waitForResponse() { - return new Promise((resolve) => { - this.eventEmitter.once('openTabs', resolve); + return new Promise((resolve, reject) => { + this.eventEmitter.once('error', (error) => { + this.eventEmitter.removeListener('openTabs', resolve); + reject(error); + }); + this.eventEmitter.once('openTabs', (result) => { + this.eventEmitter.removeListener('error', reject); + resolve(result); + }); }); } } diff --git a/test/e2e/background-socket/types.ts b/test/e2e/background-socket/types.ts index 00a734374c90..eba69ebc4873 100644 --- a/test/e2e/background-socket/types.ts +++ b/test/e2e/background-socket/types.ts @@ -19,6 +19,7 @@ export type Handle = { export type WindowProperties = 'title' | 'url'; export type ServerMochaEventEmitterType = { + error: [error: Error]; openTabs: [openTabs: chrome.tabs.Tab[]]; notFound: [openTabs: chrome.tabs.Tab[]]; }; From 5116c3249876a8098d8b96cbed8369500805b2b7 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:01:48 -0500 Subject: [PATCH 039/148] refactor: move `getProviderConfig` out of `ducks/metamask.js` to `shared/selectors/networks.ts` (#27646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts some from functions from JS to TS. Also updates some functions to match the actual expect return values. Why only these functions? I'm trying to solve circular dependency issues. `getProviderConfig` is so widely used in the codebase, and makes use of multiple selectors itself, it makes it very complicated to untangle. I've put it in a new file (`seelctors/networks.ts`) just to, hopefully temporarily, simplify untangling other circular dependency issues. --- app/scripts/lib/ppom/ppom-middleware.ts | 3 +- app/scripts/lib/ppom/ppom-util.ts | 2 +- app/scripts/metamask-controller.js | 6 +- shared/constants/bridge.ts | 2 + shared/constants/multichain/networks.ts | 2 + shared/constants/network.ts | 2 + shared/modules/selectors/networks.ts | 114 ++++++++++++++++ .../modules/selectors/smart-transactions.ts | 9 +- {ui => shared/modules}/selectors/util.js | 0 test/data/mock-state.json | 8 +- .../asset-list-control-bar.tsx | 7 +- .../app/assets/asset-list/asset-list.tsx | 2 +- .../network-filter/network-filter.tsx | 2 +- .../app/currency-input/currency-input.js | 6 +- .../detected-token-selection-popover.js | 2 +- .../app/detected-token/detected-token.js | 6 +- .../loading-network-screen.container.js | 6 +- .../confirm-delete-network.container.js | 2 +- .../hide-token-confirmation-modal.js | 6 +- .../transaction-already-confirmed.tsx | 9 +- .../multi-rpc-edit-modal.tsx | 2 +- .../selected-account-component.test.js | 7 - .../selected-account.container.js | 2 +- ...-percentage-overview-cross-chains.test.tsx | 7 +- .../wrong-network-notification.tsx | 9 +- .../address-copy-button.js | 2 +- .../asset-picker-modal-network.tsx | 6 +- .../asset-picker/asset-picker.tsx | 6 +- .../detected-token-banner.js | 2 +- .../import-tokens-modal.js | 2 +- .../network-list-menu/network-list-menu.tsx | 2 +- ...ification-detail-block-explorer-button.tsx | 2 +- .../review-permissions-page.tsx | 2 +- .../components/recipient-content.test.tsx | 2 +- .../deprecated-networks.js | 6 +- .../ui/new-network-info/new-network-info.js | 2 +- ui/ducks/bridge/selectors.ts | 18 +-- ui/ducks/metamask/metamask.js | 43 +----- ui/ducks/send/helpers.test.js | 1 - ui/ducks/send/send.js | 6 +- ui/hooks/bridge/useBridging.ts | 2 +- ...eAccountTotalCrossChainFiatBalance.test.ts | 7 +- .../useAccountTotalCrossChainFiatBalance.ts | 2 +- ui/hooks/useAccountTrackerPolling.ts | 6 +- ui/hooks/useCurrencyRatePolling.ts | 6 +- ui/hooks/useGasFeeEstimates.js | 2 +- ui/hooks/useGasFeeEstimates.test.js | 9 +- ui/hooks/useMMICustodySendTransaction.ts | 2 +- ui/hooks/useMultichainSelector.test.ts | 2 +- ui/hooks/useTokenBalances.ts | 2 +- ui/hooks/useTokenDetectionPolling.ts | 7 +- ui/hooks/useTokenFiatAmount.js | 2 +- ui/hooks/useTokenListPolling.ts | 2 +- ui/hooks/useTokenRatesPolling.ts | 2 +- ui/hooks/useTokenTracker.js | 2 +- .../asset/components/chart/asset-chart.tsx | 3 +- ui/pages/asset/components/native-asset.tsx | 2 +- ui/pages/asset/components/token-asset.tsx | 2 +- ui/pages/asset/useHistoricalPrices.ts | 3 +- ui/pages/asset/util.ts | 3 +- ui/pages/bridge/hooks/useAddToken.ts | 6 +- .../hooks/useSubmitBridgeTransaction.test.tsx | 23 +++- ui/pages/bridge/index.tsx | 2 +- .../bridge/prepare/prepare-bridge-page.tsx | 2 +- .../confirm/info/hooks/useSupportsEIP1559.ts | 6 +- .../nft-send-heading/nft-send-heading.tsx | 2 +- .../token-transfer/token-details-section.tsx | 2 +- .../simulation-details/asset-pill.tsx | 2 +- .../confirm-transaction-base.container.js | 2 +- .../confirm-transaction.component.js | 2 +- .../confirmation/confirmation.js | 2 +- ui/pages/confirmations/selectors/confirm.ts | 2 +- ui/pages/home/home.container.js | 3 +- .../privacy-settings/privacy-settings.js | 2 +- .../connect-page/connect-page.tsx | 2 +- ui/pages/routes/routes.container.js | 10 +- .../edit-contact/edit-contact.container.js | 2 +- .../networks-form/networks-form.tsx | 2 +- .../security-tab/security-tab.container.js | 2 +- .../settings-tab/settings-tab.container.js | 2 +- ui/pages/settings/settings.container.js | 2 +- ui/pages/swaps/hooks/useUpdateSwapsState.ts | 3 +- ui/selectors/approvals.ts | 2 +- ui/selectors/confirm-transaction.js | 2 +- ui/selectors/institutional/selectors.test.ts | 17 ++- ui/selectors/institutional/selectors.ts | 9 +- .../metamask-notifications.ts | 2 +- ui/selectors/multichain.test.ts | 6 +- ui/selectors/multichain.ts | 12 +- ui/selectors/networks.test.ts | 129 ++++++++++++++++++ ui/selectors/permissions.js | 2 +- ui/selectors/selectors.js | 46 +------ ui/selectors/selectors.test.js | 59 +------- ui/selectors/signatures.ts | 2 +- ui/selectors/snaps/accounts.ts | 2 +- ui/selectors/snaps/address-book.ts | 2 +- ui/selectors/transactions.js | 10 +- ui/store/actions.ts | 10 +- 98 files changed, 462 insertions(+), 326 deletions(-) create mode 100644 shared/modules/selectors/networks.ts rename {ui => shared/modules}/selectors/util.js (100%) create mode 100644 ui/selectors/networks.test.ts diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 44c7a8a965c4..f99cf675f534 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -14,8 +14,7 @@ import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import { PreferencesController } from '../../controllers/preferences-controller'; import { AppStateController } from '../../controllers/app-state-controller'; import { SECURITY_ALERT_RESPONSE_CHECKING_CHAIN } from '../../../../shared/constants/security-provider'; -// eslint-disable-next-line import/no-restricted-paths -import { getProviderConfig } from '../../../../ui/ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { trace, TraceContext, TraceName } from '../../../../shared/lib/trace'; import { generateSecurityAlertId, diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index b0bf6b03b4d9..d27ec6c8e505 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -158,7 +158,7 @@ export async function isChainSupported(chainId: Hex): Promise { `Error fetching supported chains from security alerts API`, ); } - return supportedChainIds.includes(chainId); + return supportedChainIds.includes(chainId as Hex); } function normalizePPOMRequest( diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index ac633bbc8e5d..548b2f9d940c 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -239,10 +239,8 @@ import { } from '../../shared/lib/transactions-controller-utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { getCurrentChainId } from '../../ui/selectors'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getProviderConfig } from '../../ui/ducks/metamask/metamask'; +import { getCurrentChainId } from '../../ui/selectors/selectors'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { endTrace, trace } from '../../shared/lib/trace'; // eslint-disable-next-line import/no-restricted-paths import { isSnapId } from '../../ui/helpers/utils/snaps'; diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index ed3b21c6a581..10f2587d3fbd 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -13,6 +13,8 @@ export const ALLOWED_BRIDGE_CHAIN_IDS = [ CHAIN_IDS.BASE, ]; +export type AllowedBridgeChainIds = (typeof ALLOWED_BRIDGE_CHAIN_IDS)[number]; + export const BRIDGE_DEV_API_BASE_URL = 'https://bridge.dev-api.cx.metamask.io'; export const BRIDGE_PROD_API_BASE_URL = 'https://bridge.api.cx.metamask.io'; export const BRIDGE_API_BASE_URL = process.env.BRIDGE_USE_DEV_APIS diff --git a/shared/constants/multichain/networks.ts b/shared/constants/multichain/networks.ts index 04ee1134c0b6..f5a45138d88a 100644 --- a/shared/constants/multichain/networks.ts +++ b/shared/constants/multichain/networks.ts @@ -22,6 +22,8 @@ export type MultichainProviderConfig = ProviderConfigWithImageUrl & { isAddressCompatible: (address: string) => boolean; }; +export type MultichainNetworkIds = `${MultichainNetworks}`; + export enum MultichainNetworks { BITCOIN = 'bip122:000000000019d6689c085ae165831e93', BITCOIN_TESTNET = 'bip122:000000000933ea01ad0ee984209779ba', diff --git a/shared/constants/network.ts b/shared/constants/network.ts index aef122ea67bc..3fca971c338e 100644 --- a/shared/constants/network.ts +++ b/shared/constants/network.ts @@ -87,6 +87,8 @@ export const NETWORK_TYPES = { LINEA_MAINNET: 'linea-mainnet', } as const; +export type NetworkTypes = (typeof NETWORK_TYPES)[keyof typeof NETWORK_TYPES]; + /** * An object containing shortcut names for any non-builtin network. We need * this to be able to differentiate between networks that require custom diff --git a/shared/modules/selectors/networks.ts b/shared/modules/selectors/networks.ts new file mode 100644 index 000000000000..41aad6da6948 --- /dev/null +++ b/shared/modules/selectors/networks.ts @@ -0,0 +1,114 @@ +import { + RpcEndpointType, + type NetworkConfiguration, + type NetworkState as _NetworkState, +} from '@metamask/network-controller'; +import { createSelector } from 'reselect'; +import { NetworkStatus } from '../../constants/network'; +import { createDeepEqualSelector } from './util'; + +export type NetworkState = { metamask: _NetworkState }; + +export type NetworkConfigurationsState = { + metamask: { + networkConfigurations: Record< + string, + MetaMaskExtensionNetworkConfiguration + >; + }; +}; + +export type SelectedNetworkClientIdState = { + metamask: { + selectedNetworkClientId: string; + }; +}; + +export type MetaMaskExtensionNetworkConfiguration = NetworkConfiguration; + +export type NetworkConfigurationsByChainIdState = { + metamask: Pick<_NetworkState, 'networkConfigurationsByChainId'>; +}; + +export type ProviderConfigState = NetworkConfigurationsByChainIdState & + SelectedNetworkClientIdState; + +export const getNetworkConfigurationsByChainId = createDeepEqualSelector( + (state: NetworkConfigurationsByChainIdState) => + state.metamask.networkConfigurationsByChainId, + (networkConfigurationsByChainId) => networkConfigurationsByChainId, +); + +export function getSelectedNetworkClientId( + state: SelectedNetworkClientIdState, +) { + return state.metamask.selectedNetworkClientId; +} + +/** + * Get the provider configuration for the current selected network. + * + * @param state - Redux state object. + */ +export const getProviderConfig = createSelector( + (state: ProviderConfigState) => getNetworkConfigurationsByChainId(state), + getSelectedNetworkClientId, + (networkConfigurationsByChainId, selectedNetworkClientId) => { + for (const network of Object.values(networkConfigurationsByChainId)) { + for (const rpcEndpoint of network.rpcEndpoints) { + if (rpcEndpoint.networkClientId === selectedNetworkClientId) { + const blockExplorerUrl = + network.defaultBlockExplorerUrlIndex === undefined + ? undefined + : network.blockExplorerUrls?.[ + network.defaultBlockExplorerUrlIndex + ]; + + return { + chainId: network.chainId, + ticker: network.nativeCurrency, + rpcPrefs: { ...(blockExplorerUrl && { blockExplorerUrl }) }, + type: + rpcEndpoint.type === RpcEndpointType.Custom + ? 'rpc' + : rpcEndpoint.networkClientId, + ...(rpcEndpoint.type === RpcEndpointType.Custom && { + id: rpcEndpoint.networkClientId, + nickname: network.name, + rpcUrl: rpcEndpoint.url, + }), + }; + } + } + } + return undefined; // should not be reachable + }, +); + +export function getNetworkConfigurations( + state: NetworkConfigurationsState, +): Record { + return state.metamask.networkConfigurations; +} + +/** + * Returns true if the currently selected network is inaccessible or whether no + * provider has been set yet for the currently selected network. + * + * @param state - Redux state object. + */ +export function isNetworkLoading(state: NetworkState) { + const selectedNetworkClientId = getSelectedNetworkClientId(state); + return ( + selectedNetworkClientId && + state.metamask.networksMetadata[selectedNetworkClientId].status !== + NetworkStatus.Available + ); +} + +export function getInfuraBlocked(state: NetworkState) { + return ( + state.metamask.networksMetadata[getSelectedNetworkClientId(state)] + .status === NetworkStatus.Blocked + ); +} diff --git a/shared/modules/selectors/smart-transactions.ts b/shared/modules/selectors/smart-transactions.ts index 4fb6d56fc87d..b88d5f7c029b 100644 --- a/shared/modules/selectors/smart-transactions.ts +++ b/shared/modules/selectors/smart-transactions.ts @@ -12,6 +12,7 @@ import { // eslint-disable-next-line import/no-restricted-paths } from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file. import { isProduction } from '../environment'; +import { NetworkState } from './networks'; type SmartTransactionsMetaMaskState = { metamask: { @@ -113,9 +114,7 @@ export const getCurrentChainSupportsSmartTransactions = ( return getAllowedSmartTransactionsChainIds().includes(chainId); }; -const getIsAllowedRpcUrlForSmartTransactions = ( - state: SmartTransactionsMetaMaskState, -) => { +const getIsAllowedRpcUrlForSmartTransactions = (state: NetworkState) => { const chainId = getCurrentChainId(state); if (!isProduction() || SKIP_STX_RPC_URL_CHECK_CHAIN_IDS.includes(chainId)) { // Allow any STX RPC URL in development and testing environments or for specific chain IDs. @@ -131,7 +130,7 @@ const getIsAllowedRpcUrlForSmartTransactions = ( }; export const getSmartTransactionsEnabled = ( - state: SmartTransactionsMetaMaskState, + state: SmartTransactionsMetaMaskState & NetworkState, ): boolean => { const supportedAccount = accountSupportsSmartTx(state); // TODO: Create a new proxy service only for MM feature flags. @@ -150,7 +149,7 @@ export const getSmartTransactionsEnabled = ( }; export const getIsSmartTransaction = ( - state: SmartTransactionsMetaMaskState, + state: SmartTransactionsMetaMaskState & NetworkState, ): boolean => { const smartTransactionsPreferenceEnabled = getSmartTransactionsPreferenceEnabled(state); diff --git a/ui/selectors/util.js b/shared/modules/selectors/util.js similarity index 100% rename from ui/selectors/util.js rename to shared/modules/selectors/util.js diff --git a/test/data/mock-state.json b/test/data/mock-state.json index d2b66cee3108..734845f0ca9a 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -402,26 +402,30 @@ "name": "Custom Mainnet RPC", "nativeCurrency": "ETH", "defaultRpcEndpointIndex": 0, + "ticker": "ETH", "rpcEndpoints": [ { "type": "custom", "url": "https://testrpc.com", "networkClientId": "testNetworkConfigurationId" } - ] + ], + "blockExplorerUrls": [] }, "0x5": { "chainId": "0x5", "name": "Goerli", "nativeCurrency": "ETH", "defaultRpcEndpointIndex": 0, + "ticker": "ETH", "rpcEndpoints": [ { "type": "custom", "url": "https://goerli.com", "networkClientId": "goerli" } - ] + ], + "blockExplorerUrls": [] } }, "internalAccounts": { 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 a09b5128396a..dede9004d29e 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,10 +1,7 @@ import React, { useEffect, useRef, useState, useContext, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { - getCurrentNetwork, - getNetworkConfigurationsByChainId, - getPreferences, -} from '../../../../../selectors'; +import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../../shared/modules/selectors/networks'; import { Box, ButtonBase, diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 114ced0496ca..6a3036f88764 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -7,10 +7,10 @@ import { getAllDetectedTokensForSelectedAddress, getDetectedTokensInCurrentNetwork, getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, - getNetworkConfigurationsByChainId, getPreferences, getSelectedAccount, } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import { getMultichainIsEvm, getMultichainSelectedAccountCachedBalance, diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index 29e4c97e2c82..4e9aa14eea25 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -5,11 +5,11 @@ import { getCurrentChainId, getCurrentNetwork, getPreferences, - getNetworkConfigurationsByChainId, getChainIdsToPoll, getShouldHideZeroBalanceTokens, getSelectedAccount, } from '../../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../../shared/modules/selectors/networks'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { SelectableListItem } from '../sort-control/sort-control'; import { Text } from '../../../../component-library/text/text'; diff --git a/ui/components/app/currency-input/currency-input.js b/ui/components/app/currency-input/currency-input.js index 43da00ad3ab0..3efcecb35150 100644 --- a/ui/components/app/currency-input/currency-input.js +++ b/ui/components/app/currency-input/currency-input.js @@ -5,10 +5,8 @@ import { Box } from '../../component-library'; import { BlockSize } from '../../../helpers/constants/design-system'; import UnitInput from '../../ui/unit-input'; import CurrencyDisplay from '../../ui/currency-display'; -import { - getNativeCurrency, - getProviderConfig, -} from '../../../ducks/metamask/metamask'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { getCurrentChainId, getCurrentCurrency, diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js index 5974d8ba71b6..ff205f7844f0 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js @@ -14,9 +14,9 @@ import { getCurrentChainId, getCurrentNetwork, getDetectedTokensInCurrentNetwork, - getNetworkConfigurationsByChainId, getPreferences, } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import Popover from '../../../ui/popover'; import Box from '../../../ui/box'; diff --git a/ui/components/app/detected-token/detected-token.js b/ui/components/app/detected-token/detected-token.js index 31e100547e12..bb215b4f7301 100644 --- a/ui/components/app/detected-token/detected-token.js +++ b/ui/components/app/detected-token/detected-token.js @@ -12,10 +12,12 @@ import { getAllDetectedTokensForSelectedAddress, getCurrentChainId, getDetectedTokensInCurrentNetwork, - getNetworkConfigurationsByChainId, getPreferences, - getSelectedNetworkClientId, } from '../../../selectors'; +import { + getSelectedNetworkClientId, + getNetworkConfigurationsByChainId, +} from '../../../../shared/modules/selectors/networks'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { diff --git a/ui/components/app/loading-network-screen/loading-network-screen.container.js b/ui/components/app/loading-network-screen/loading-network-screen.container.js index 34a626bc1b5e..77920ad68032 100644 --- a/ui/components/app/loading-network-screen/loading-network-screen.container.js +++ b/ui/components/app/loading-network-screen/loading-network-screen.container.js @@ -4,9 +4,11 @@ import * as actions from '../../../store/actions'; import { getAllEnabledNetworks, getNetworkIdentifier, - isNetworkLoading, } from '../../../selectors'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { + getProviderConfig, + isNetworkLoading, +} from '../../../../shared/modules/selectors/networks'; import LoadingNetworkScreen from './loading-network-screen.component'; const DEPRECATED_TEST_NET_CHAINIDS = ['0x3', '0x2a', '0x4']; diff --git a/ui/components/app/modals/confirm-delete-network/confirm-delete-network.container.js b/ui/components/app/modals/confirm-delete-network/confirm-delete-network.container.js index ac4c444692d2..ccca2935bf4d 100644 --- a/ui/components/app/modals/confirm-delete-network/confirm-delete-network.container.js +++ b/ui/components/app/modals/confirm-delete-network/confirm-delete-network.container.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import withModalProps from '../../../../helpers/higher-order-components/with-modal-props'; import { removeNetwork } from '../../../../store/actions'; -import { getNetworkConfigurationsByChainId } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import ConfirmDeleteNetwork from './confirm-delete-network.component'; const mapStateToProps = (state, ownProps) => { diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js index e1662a641ae7..c34e4897d50a 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js @@ -9,10 +9,8 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, -} from '../../../../selectors'; +import { getCurrentChainId } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; function mapStateToProps(state) { return { diff --git a/ui/components/app/modals/transaction-already-confirmed/transaction-already-confirmed.tsx b/ui/components/app/modals/transaction-already-confirmed/transaction-already-confirmed.tsx index 1bd2afc75926..031ffcf175c8 100644 --- a/ui/components/app/modals/transaction-already-confirmed/transaction-already-confirmed.tsx +++ b/ui/components/app/modals/transaction-already-confirmed/transaction-already-confirmed.tsx @@ -2,7 +2,6 @@ import React, { useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getBlockExplorerLink } from '@metamask/etherscan-link'; import { type TransactionMeta } from '@metamask/transaction-controller'; -import { type NetworkClientConfiguration } from '@metamask/network-controller'; import { getRpcPrefsForCurrentProvider, getTransaction, @@ -38,9 +37,7 @@ export default function TransactionAlreadyConfirmed() { // eslint-disable-next-line @typescript-eslint/no-explicit-any (getTransaction as any)(state, originalTransactionId), ); - const rpcPrefs: NetworkClientConfiguration = useSelector( - getRpcPrefsForCurrentProvider, - ); + const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); const viewTransaction = () => { // TODO: Fix getBlockExplorerLink arguments compatible with the actual controller types @@ -48,9 +45,7 @@ export default function TransactionAlreadyConfirmed() { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any transaction as any, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rpcPrefs as any, + rpcPrefs, ); global.platform.openTab({ url: blockExplorerLink, diff --git a/ui/components/app/multi-rpc-edit-modal/multi-rpc-edit-modal.tsx b/ui/components/app/multi-rpc-edit-modal/multi-rpc-edit-modal.tsx index 0d6985dbfb4d..3136c92993dd 100644 --- a/ui/components/app/multi-rpc-edit-modal/multi-rpc-edit-modal.tsx +++ b/ui/components/app/multi-rpc-edit-modal/multi-rpc-edit-modal.tsx @@ -24,7 +24,7 @@ import { setShowMultiRpcModal } from '../../../store/actions'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../../app/scripts/lib/util'; -import { getNetworkConfigurationsByChainId } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import NetworkListItem from './network-list-item/network-list-item'; 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 9545580bebbb..257ee44aa46a 100644 --- a/ui/components/app/selected-account/selected-account-component.test.js +++ b/ui/components/app/selected-account/selected-account-component.test.js @@ -52,13 +52,6 @@ jest.mock('../../../selectors', () => { getAccountType: mockGetAccountType, getSelectedInternalAccount: mockGetSelectedAccount, getCurrentChainId: jest.fn(() => '0x5'), - getSelectedNetworkClientId: jest.fn(() => 'goerli'), - getNetworkConfigurationsByChainId: jest.fn(() => ({ - '0x5': { - chainId: '0x5', - rpcEndpoints: [{ networkClientId: 'goerli' }], - }, - })), }; }); diff --git a/ui/components/app/selected-account/selected-account.container.js b/ui/components/app/selected-account/selected-account.container.js index a6e0c03a347a..2ba78fa4c2de 100644 --- a/ui/components/app/selected-account/selected-account.container.js +++ b/ui/components/app/selected-account/selected-account.container.js @@ -10,7 +10,7 @@ import { getCustodyAccountDetails, getIsCustodianSupportedChain, } from '../../../selectors/institutional/selectors'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; ///: END:ONLY_INCLUDE_IF import SelectedAccount from './selected-account.component'; diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx index 1a335de9c14b..72b2e89e49c6 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx @@ -8,11 +8,11 @@ import { getShouldHideZeroBalanceTokens, getPreferences, getMarketData, - getNetworkConfigurationsByChainId, getAllTokens, getChainIdsToPoll, } from '../../../selectors'; import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { AggregatedPercentageOverviewCrossChains } from './aggregated-percentage-overview-cross-chains'; jest.mock('react-redux', () => ({ @@ -36,11 +36,14 @@ jest.mock('../../../selectors', () => ({ getPreferences: jest.fn(), getShouldHideZeroBalanceTokens: jest.fn(), getMarketData: jest.fn(), - getNetworkConfigurationsByChainId: jest.fn(), getAllTokens: jest.fn(), getChainIdsToPoll: jest.fn(), })); +jest.mock('../../../../shared/modules/selectors/networks', () => ({ + getNetworkConfigurationsByChainId: jest.fn(), +})); + jest.mock('../../../hooks/useAccountTotalCrossChainFiatBalance', () => ({ useAccountTotalCrossChainFiatBalance: jest.fn(), })); diff --git a/ui/components/institutional/wrong-network-notification/wrong-network-notification.tsx b/ui/components/institutional/wrong-network-notification/wrong-network-notification.tsx index 217ce2e575c0..f5c412260bb2 100644 --- a/ui/components/institutional/wrong-network-notification/wrong-network-notification.tsx +++ b/ui/components/institutional/wrong-network-notification/wrong-network-notification.tsx @@ -11,16 +11,13 @@ import { import { getSelectedAccountCachedBalance } from '../../../selectors'; import { getIsCustodianSupportedChain } from '../../../selectors/institutional/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { Icon, IconName, IconSize, Box, Text } from '../../component-library'; const WrongNetworkNotification: React.FC = () => { const t = useI18nContext(); - const providerConfig = useSelector< - object, - { nickname?: string; type: string } | undefined - >(getProviderConfig); - const balance = useSelector(getSelectedAccountCachedBalance); + const providerConfig = useSelector(getProviderConfig); + const balance = useSelector(getSelectedAccountCachedBalance); const isCustodianSupportedChain = useSelector(getIsCustodianSupportedChain); diff --git a/ui/components/multichain/address-copy-button/address-copy-button.js b/ui/components/multichain/address-copy-button/address-copy-button.js index fa8b0803ee0b..8fb73c299fd2 100644 --- a/ui/components/multichain/address-copy-button/address-copy-button.js +++ b/ui/components/multichain/address-copy-button/address-copy-button.js @@ -8,7 +8,7 @@ import { getIsCustodianSupportedChain, getCustodianIconForAddress, } from '../../../selectors/institutional/selectors'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; ///: END:ONLY_INCLUDE_IF import { ButtonBase, IconName, Box } from '../../component-library'; import { diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx index d674fbef528e..f64209543557 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx @@ -19,8 +19,10 @@ import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constan import { useI18nContext } from '../../../../hooks/useI18nContext'; ///: END:ONLY_INCLUDE_IF import { NetworkListItem } from '../../network-list-item'; -import { getNetworkConfigurationsByChainId } from '../../../../selectors'; -import { getProviderConfig } from '../../../../ducks/metamask/metamask'; +import { + getNetworkConfigurationsByChainId, + getProviderConfig, +} from '../../../../../shared/modules/selectors/networks'; /** * AssetPickerModalNetwork component displays a modal for selecting a network in the asset picker. diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx index ff665ce0f8f8..cf87ee3ba7f1 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx @@ -24,10 +24,8 @@ import { } from '../../../../helpers/constants/design-system'; import { AssetType } from '../../../../../shared/constants/transaction'; import { AssetPickerModal } from '../asset-picker-modal/asset-picker-modal'; -import { - getCurrentNetwork, - getNetworkConfigurationsByChainId, -} from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; +import { getCurrentNetwork } from '../../../../selectors'; import Tooltip from '../../../ui/tooltip'; import { LARGE_SYMBOL_LENGTH } from '../constants'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) diff --git a/ui/components/multichain/detected-token-banner/detected-token-banner.js b/ui/components/multichain/detected-token-banner/detected-token-banner.js index 59db889d11ce..4f76fa22fb35 100644 --- a/ui/components/multichain/detected-token-banner/detected-token-banner.js +++ b/ui/components/multichain/detected-token-banner/detected-token-banner.js @@ -9,8 +9,8 @@ import { getDetectedTokensInCurrentNetwork, getAllDetectedTokensForSelectedAddress, getPreferences, - getNetworkConfigurationsByChainId, } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { MetaMetricsEventCategory, diff --git a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js index b9ef596b8f07..a1194f2285f2 100644 --- a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js +++ b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js @@ -21,13 +21,13 @@ import { getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, getRpcPrefsForCurrentProvider, getSelectedInternalAccount, - getSelectedNetworkClientId, getTokenDetectionSupportNetworkByChainId, getTokenList, getCurrentNetwork, getTestNetworkBackgroundColor, getTokenExchangeRates, } from '../../../selectors'; +import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { addImportedTokens, clearPendingTokens, diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 9c99b3048a95..8b18170adc6c 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -36,6 +36,7 @@ import { FEATURED_RPCS, TEST_CHAINS, } from '../../../../shared/constants/network'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { getCurrentChainId, getShowTestNetworks, @@ -44,7 +45,6 @@ import { getOriginOfCurrentTab, getUseRequestQueue, getEditedNetwork, - getNetworkConfigurationsByChainId, getOrderedNetworksList, getIsAddingNewNetwork, getIsMultiRpcOnboarding, diff --git a/ui/components/multichain/notification-detail-block-explorer-button/notification-detail-block-explorer-button.tsx b/ui/components/multichain/notification-detail-block-explorer-button/notification-detail-block-explorer-button.tsx index 6ca6e27c491f..8f0395abf230 100644 --- a/ui/components/multichain/notification-detail-block-explorer-button/notification-detail-block-explorer-button.tsx +++ b/ui/components/multichain/notification-detail-block-explorer-button/notification-detail-block-explorer-button.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import type { NotificationServicesController } from '@metamask/notification-services-controller'; import { toHex } from '@metamask/controller-utils'; -import { getNetworkConfigurationsByChainId } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { ButtonVariant } from '../../component-library'; import { useI18nContext } from '../../../hooks/useI18nContext'; diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx index 66e7cadd7546..f65dd7a662cf 100644 --- a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx @@ -11,10 +11,10 @@ import { FlexDirection, } from '../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import { getConnectedSitesList, getInternalAccounts, - getNetworkConfigurationsByChainId, getPermissionSubjects, getPermittedAccountsForSelectedTab, getPermittedChainsForSelectedTab, diff --git a/ui/components/multichain/pages/send/components/recipient-content.test.tsx b/ui/components/multichain/pages/send/components/recipient-content.test.tsx index d738aee3cb8a..fe3f1b539ea3 100644 --- a/ui/components/multichain/pages/send/components/recipient-content.test.tsx +++ b/ui/components/multichain/pages/send/components/recipient-content.test.tsx @@ -21,7 +21,7 @@ jest.mock('reselect', () => ({ createSelector: jest.fn(), })); -jest.mock('../../../../../selectors/util', () => ({ +jest.mock('../../../../../../shared/modules/selectors/util', () => ({ createDeepEqualSelector: jest.fn(), })); diff --git a/ui/components/ui/deprecated-networks/deprecated-networks.js b/ui/components/ui/deprecated-networks/deprecated-networks.js index 736b5badb6a0..de7bbec1fd28 100644 --- a/ui/components/ui/deprecated-networks/deprecated-networks.js +++ b/ui/components/ui/deprecated-networks/deprecated-networks.js @@ -7,10 +7,8 @@ import { Severity, } from '../../../helpers/constants/design-system'; -import { - getCurrentNetwork, - getNetworkConfigurationsByChainId, -} from '../../../selectors'; +import { getCurrentNetwork } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { getCompletedOnboarding } from '../../../ducks/metamask/metamask'; import { BannerAlert, Box } from '../../component-library'; import { diff --git a/ui/components/ui/new-network-info/new-network-info.js b/ui/components/ui/new-network-info/new-network-info.js index 51b96bdc5b36..15a11918afc1 100644 --- a/ui/components/ui/new-network-info/new-network-info.js +++ b/ui/components/ui/new-network-info/new-network-info.js @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { TOKEN_API_METASWAP_CODEFI_URL } from '../../../../shared/constants/tokens'; import fetchWithCache from '../../../../shared/lib/fetch-with-cache'; import { I18nContext } from '../../../contexts/i18n'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { AlignItems, BackgroundColor, diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 5624a0ec5569..86f4c8155b17 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,11 +1,7 @@ -import { - NetworkConfiguration, - NetworkState, -} from '@metamask/network-controller'; +import { NetworkConfiguration } from '@metamask/network-controller'; import { uniqBy } from 'lodash'; import { createSelector } from 'reselect'; import { - getNetworkConfigurationsByChainId, getIsBridgeEnabled, getSwapsDefaultToken, SwapsEthToken, @@ -17,8 +13,12 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; -import { createDeepEqualSelector } from '../../selectors/util'; -import { getProviderConfig } from '../metamask/metamask'; +import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; +import { + NetworkState, + getProviderConfig, + getNetworkConfigurationsByChainId, +} from '../../../shared/modules/selectors/networks'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; // TODO: Remove restricted import @@ -26,8 +26,8 @@ import { calcTokenAmount } from '../../../shared/lib/transactions-controller-uti import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { BridgeState } from './bridge'; -export type BridgeAppState = { - metamask: NetworkState & { bridgeState: BridgeControllerState } & { +type BridgeAppState = NetworkState & { + metamask: { bridgeState: BridgeControllerState } & { useExternalServices: boolean; }; bridge: BridgeState; diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index af456e29acbc..93550d45fc10 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -1,7 +1,6 @@ import { addHexPrefix, isHexString } from 'ethereumjs-util'; import { createSelector } from 'reselect'; import { mergeGasFeeEstimates } from '@metamask/transaction-controller'; -import { RpcEndpointType } from '@metamask/network-controller'; import { AlertTypes } from '../../../shared/constants/alerts'; import { GasEstimateTypes, @@ -16,9 +15,11 @@ import { accountsWithSendEtherInfoSelector, checkNetworkAndAccountSupports1559, getAddressBook, - getSelectedNetworkClientId, - getNetworkConfigurationsByChainId, } from '../../selectors/selectors'; +import { + getProviderConfig, + getSelectedNetworkClientId, +} from '../../../shared/modules/selectors/networks'; import { getSelectedInternalAccount } from '../../selectors/accounts'; import * as actionConstants from '../../store/actionConstants'; import { updateTransactionGasFees } from '../../store/actions'; @@ -274,42 +275,6 @@ export function updateGasFees({ export const getAlertEnabledness = (state) => state.metamask.alertEnabledness; -/** - * Get the provider configuration for the current selected network. - * - * @param {object} state - Redux state object. - */ -export const getProviderConfig = createSelector( - (state) => getNetworkConfigurationsByChainId(state), - (state) => getSelectedNetworkClientId(state), - (networkConfigurationsByChainId, selectedNetworkClientId) => { - for (const network of Object.values(networkConfigurationsByChainId)) { - for (const rpcEndpoint of network.rpcEndpoints) { - if (rpcEndpoint.networkClientId === selectedNetworkClientId) { - const blockExplorerUrl = - network.blockExplorerUrls?.[network.defaultBlockExplorerUrlIndex]; - - return { - chainId: network.chainId, - ticker: network.nativeCurrency, - rpcPrefs: { ...(blockExplorerUrl && { blockExplorerUrl }) }, - type: - rpcEndpoint.type === RpcEndpointType.Custom - ? 'rpc' - : rpcEndpoint.networkClientId, - ...(rpcEndpoint.type === RpcEndpointType.Custom && { - id: rpcEndpoint.networkClientId, - nickname: network.name, - rpcUrl: rpcEndpoint.url, - }), - }; - } - } - } - return undefined; // should not be reachable - }, -); - export const getUnconnectedAccountAlertEnabledness = (state) => getAlertEnabledness(state)[AlertTypes.unconnectedAccount]; diff --git a/ui/ducks/send/helpers.test.js b/ui/ducks/send/helpers.test.js index 74f660991cd7..7129ffca8194 100644 --- a/ui/ducks/send/helpers.test.js +++ b/ui/ducks/send/helpers.test.js @@ -64,7 +64,6 @@ jest.mock('../metamask/metamask', () => ({ ...jest.requireActual('../metamask/metamask'), getGasFeeEstimates: jest.fn(), getNativeCurrency: jest.fn(), - getProviderConfig: jest.fn(), })); jest.mock('../swaps/swaps', () => ({ diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 30cbc6eeb5dd..15acc3355d8f 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -53,10 +53,13 @@ import { getSelectedInternalAccount, getSelectedInternalAccountWithBalance, getUnapprovedTransactions, - getSelectedNetworkClientId, getIsSwapsChain, getUseExternalServices, } from '../../selectors'; +import { + getSelectedNetworkClientId, + getProviderConfig, +} from '../../../shared/modules/selectors/networks'; import { displayWarning, hideLoadingIndication, @@ -101,7 +104,6 @@ import { import { getGasEstimateType, getNativeCurrency, - getProviderConfig, getTokens, } from '../metamask/metamask'; diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index fe7a21e2206f..c4ae1cca57a3 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -30,7 +30,7 @@ import { isHardwareKeyring } from '../../helpers/utils/hardware'; import { getPortfolioUrl } from '../../helpers/utils/portfolio'; import { setSwapsFromToken } from '../../ducks/swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; -import { getProviderConfig } from '../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../shared/modules/selectors/networks'; ///: END:ONLY_INCLUDE_IF const useBridging = () => { diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts index 9fe819d92171..dd5b8aaab579 100644 --- a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts @@ -3,11 +3,11 @@ import { renderHook } from '@testing-library/react-hooks'; import { act } from 'react-dom/test-utils'; import { getCurrentCurrency, - getNetworkConfigurationsByChainId, getCrossChainTokenExchangeRates, getCrossChainMetaMaskCachedBalances, } from '../selectors'; import { getCurrencyRates } from '../ducks/metamask/metamask'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { FormattedTokensWithBalances, useAccountTotalCrossChainFiatBalance, @@ -19,13 +19,16 @@ jest.mock('react-redux', () => ({ jest.mock('../selectors', () => ({ getCurrentCurrency: jest.fn(), - getNetworkConfigurationsByChainId: jest.fn(), getCrossChainTokenExchangeRates: jest.fn(), getCrossChainMetaMaskCachedBalances: jest.fn(), })); jest.mock('../ducks/metamask/metamask', () => ({ getCurrencyRates: jest.fn(), })); +jest.mock('../../shared/modules/selectors/networks', () => ({ + getSelectedNetworkClientId: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), +})); const mockGetCurrencyRates = getCurrencyRates as jest.Mock; const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts index d63328e4fbcf..ac5658278946 100644 --- a/ui/hooks/useAccountTotalCrossChainFiatBalance.ts +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts @@ -2,7 +2,6 @@ import { shallowEqual, useSelector } from 'react-redux'; import { toChecksumAddress } from 'ethereumjs-util'; import { getCurrentCurrency, - getNetworkConfigurationsByChainId, getCrossChainTokenExchangeRates, getCrossChainMetaMaskCachedBalances, } from '../selectors'; @@ -13,6 +12,7 @@ import { import { getCurrencyRates } from '../ducks/metamask/metamask'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { TokenWithBalance } from '../components/app/assets/asset-list/asset-list'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; type AddressBalances = { [address: string]: number; diff --git a/ui/hooks/useAccountTrackerPolling.ts b/ui/hooks/useAccountTrackerPolling.ts index cc7f9aee3818..5dffe5dfc852 100644 --- a/ui/hooks/useAccountTrackerPolling.ts +++ b/ui/hooks/useAccountTrackerPolling.ts @@ -1,8 +1,5 @@ import { useSelector } from 'react-redux'; -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, -} from '../selectors'; +import { getCurrentChainId } from '../selectors'; import { accountTrackerStartPolling, accountTrackerStopPollingByPollingToken, @@ -11,6 +8,7 @@ import { getCompletedOnboarding, getIsUnlocked, } from '../ducks/metamask/metamask'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import useMultiPolling from './useMultiPolling'; const useAccountTrackerPolling = () => { diff --git a/ui/hooks/useCurrencyRatePolling.ts b/ui/hooks/useCurrencyRatePolling.ts index 34772a94a501..cd8c616cd4b9 100644 --- a/ui/hooks/useCurrencyRatePolling.ts +++ b/ui/hooks/useCurrencyRatePolling.ts @@ -1,8 +1,6 @@ import { useSelector } from 'react-redux'; -import { - getNetworkConfigurationsByChainId, - getUseCurrencyRateCheck, -} from '../selectors'; +import { getUseCurrencyRateCheck } from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { currencyRateStartPolling, currencyRateStopPollingByPollingToken, diff --git a/ui/hooks/useGasFeeEstimates.js b/ui/hooks/useGasFeeEstimates.js index abbaf0db0bb9..b1b686256059 100644 --- a/ui/hooks/useGasFeeEstimates.js +++ b/ui/hooks/useGasFeeEstimates.js @@ -12,7 +12,7 @@ import { gasFeeStopPollingByPollingToken, getNetworkConfigurationByNetworkClientId, } from '../store/actions'; -import { getSelectedNetworkClientId } from '../selectors'; +import { getSelectedNetworkClientId } from '../../shared/modules/selectors/networks'; import usePolling from './usePolling'; /** diff --git a/ui/hooks/useGasFeeEstimates.test.js b/ui/hooks/useGasFeeEstimates.test.js index dd63e10581d0..c09785ac36ab 100644 --- a/ui/hooks/useGasFeeEstimates.test.js +++ b/ui/hooks/useGasFeeEstimates.test.js @@ -36,13 +36,16 @@ jest.mock('../ducks/metamask/metamask', () => ({ .mockReturnValue('getIsNetworkBusyByChainId'), })); +jest.mock('../../shared/modules/selectors/networks', () => ({ + getSelectedNetworkClientId: jest + .fn() + .mockReturnValue('getSelectedNetworkClientId'), +})); + jest.mock('../selectors', () => ({ checkNetworkAndAccountSupports1559: jest .fn() .mockReturnValue('checkNetworkAndAccountSupports1559'), - getSelectedNetworkClientId: jest - .fn() - .mockReturnValue('getSelectedNetworkClientId'), })); jest.mock('react-redux', () => { diff --git a/ui/hooks/useMMICustodySendTransaction.ts b/ui/hooks/useMMICustodySendTransaction.ts index 49634fbf0174..ac434bb5d8db 100644 --- a/ui/hooks/useMMICustodySendTransaction.ts +++ b/ui/hooks/useMMICustodySendTransaction.ts @@ -19,7 +19,7 @@ import { getConfirmationSender } from '../pages/confirmations/components/confirm import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { getSmartTransactionsEnabled } from '../../shared/modules/selectors'; import { CHAIN_ID_TO_RPC_URL_MAP } from '../../shared/constants/network'; -import { getProviderConfig } from '../ducks/metamask/metamask'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; type MMITransactionMeta = TransactionMeta & { txParams: { from: string }; diff --git a/ui/hooks/useMultichainSelector.test.ts b/ui/hooks/useMultichainSelector.test.ts index 35ed5cf7b155..e5d7f197ffcb 100644 --- a/ui/hooks/useMultichainSelector.test.ts +++ b/ui/hooks/useMultichainSelector.test.ts @@ -1,7 +1,7 @@ import { InternalAccount } from '@metamask/keyring-api'; import { createMockInternalAccount } from '../../test/jest/mocks'; import { renderHookWithProvider } from '../../test/lib/render-helpers'; -import { getSelectedNetworkClientId } from '../selectors'; +import { getSelectedNetworkClientId } from '../../shared/modules/selectors/networks'; import { MultichainState, getMultichainIsEvm } from '../selectors/multichain'; import { CHAIN_IDS } from '../../shared/constants/network'; import { mockNetworkState } from '../../test/stub/networks'; diff --git a/ui/hooks/useTokenBalances.ts b/ui/hooks/useTokenBalances.ts index 8d3a078f8d07..52add5a99592 100644 --- a/ui/hooks/useTokenBalances.ts +++ b/ui/hooks/useTokenBalances.ts @@ -2,7 +2,7 @@ import { useSelector } from 'react-redux'; import BN from 'bn.js'; import { Token } from '@metamask/assets-controllers'; import { Hex } from '@metamask/utils'; -import { getNetworkConfigurationsByChainId } from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { tokenBalancesStartPolling, tokenBalancesStopPollingByPollingToken, diff --git a/ui/hooks/useTokenDetectionPolling.ts b/ui/hooks/useTokenDetectionPolling.ts index d2e08d01892d..66e027b5af6b 100644 --- a/ui/hooks/useTokenDetectionPolling.ts +++ b/ui/hooks/useTokenDetectionPolling.ts @@ -1,9 +1,6 @@ import { useSelector } from 'react-redux'; -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, - getUseTokenDetection, -} from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; +import { getCurrentChainId, getUseTokenDetection } from '../selectors'; import { tokenDetectionStartPolling, tokenDetectionStopPollingByPollingToken, diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index 5abbbc608de0..2728b2994781 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -7,8 +7,8 @@ import { getConfirmationExchangeRates, getMarketData, getCurrencyRates, - getNetworkConfigurationsByChainId, } from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { getConversionRate } from '../ducks/metamask/metamask'; import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; diff --git a/ui/hooks/useTokenListPolling.ts b/ui/hooks/useTokenListPolling.ts index 7f7de517c304..98ea0c324da4 100644 --- a/ui/hooks/useTokenListPolling.ts +++ b/ui/hooks/useTokenListPolling.ts @@ -1,12 +1,12 @@ import { useSelector } from 'react-redux'; import { getCurrentChainId, - getNetworkConfigurationsByChainId, getPetnamesEnabled, getUseExternalServices, getUseTokenDetection, getUseTransactionSimulations, } from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { tokenListStartPolling, tokenListStopPollingByPollingToken, diff --git a/ui/hooks/useTokenRatesPolling.ts b/ui/hooks/useTokenRatesPolling.ts index 37864ec89b82..bd57da835a99 100644 --- a/ui/hooks/useTokenRatesPolling.ts +++ b/ui/hooks/useTokenRatesPolling.ts @@ -2,11 +2,11 @@ import { useSelector } from 'react-redux'; import { getCurrentChainId, getMarketData, - getNetworkConfigurationsByChainId, getTokenExchangeRates, getTokensMarketData, getUseCurrencyRateCheck, } from '../selectors'; +import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { tokenRatesStartPolling, tokenRatesStopPollingByPollingToken, diff --git a/ui/hooks/useTokenTracker.js b/ui/hooks/useTokenTracker.js index 0ce2c9cbcac2..c38b3c295f58 100644 --- a/ui/hooks/useTokenTracker.js +++ b/ui/hooks/useTokenTracker.js @@ -2,9 +2,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import TokenTracker from '@metamask/eth-token-tracker'; import { shallowEqual, useSelector } from 'react-redux'; import { getSelectedInternalAccount } from '../selectors'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { SECOND } from '../../shared/constants/time'; import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; -import { getProviderConfig } from '../ducks/metamask/metamask'; import { useEqualityCheck } from './useEqualityCheck'; export function useTokenTracker({ diff --git a/ui/pages/asset/components/chart/asset-chart.tsx b/ui/pages/asset/components/chart/asset-chart.tsx index efa7adad03ad..b49eaf7ced90 100644 --- a/ui/pages/asset/components/chart/asset-chart.tsx +++ b/ui/pages/asset/components/chart/asset-chart.tsx @@ -14,6 +14,7 @@ import { import { Line } from 'react-chartjs-2'; import classnames from 'classnames'; import { brandColor } from '@metamask/design-tokens'; +import { Hex } from '@metamask/utils'; import { useTheme } from '../../../../hooks/useTheme'; import { BackgroundColor, @@ -80,7 +81,7 @@ const AssetChart = ({ currentPrice, currency, }: { - chainId: `0x${string}`; + chainId: Hex; address: string; currentPrice?: number; currency: string; diff --git a/ui/pages/asset/components/native-asset.tsx b/ui/pages/asset/components/native-asset.tsx index abd571b231a7..38f24f8d5bea 100644 --- a/ui/pages/asset/components/native-asset.tsx +++ b/ui/pages/asset/components/native-asset.tsx @@ -8,7 +8,7 @@ import { getSelectedInternalAccount, getNativeCurrencyForChain, } from '../../../selectors'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { AssetType } from '../../../../shared/constants/transaction'; import { useIsOriginalNativeTokenSymbol } from '../../../hooks/useIsOriginalNativeTokenSymbol'; import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; diff --git a/ui/pages/asset/components/token-asset.tsx b/ui/pages/asset/components/token-asset.tsx index ef91b92aae5d..80ff721a216c 100644 --- a/ui/pages/asset/components/token-asset.tsx +++ b/ui/pages/asset/components/token-asset.tsx @@ -6,7 +6,6 @@ import { useHistory } from 'react-router-dom'; import { Hex } from '@metamask/utils'; import { NetworkConfiguration } from '@metamask/network-controller'; import { - getNetworkConfigurationsByChainId, getSelectedInternalAccount, getTokenList, selectERC20TokensByChain, @@ -22,6 +21,7 @@ import { import { MetaMetricsContext } from '../../../contexts/metametrics'; import { showModal } from '../../../store/actions'; import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import AssetOptions from './asset-options'; import AssetPage from './asset-page'; diff --git a/ui/pages/asset/useHistoricalPrices.ts b/ui/pages/asset/useHistoricalPrices.ts index febf99a9daed..38c76fcf9de1 100644 --- a/ui/pages/asset/useHistoricalPrices.ts +++ b/ui/pages/asset/useHistoricalPrices.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; // @ts-expect-error suppress CommonJS vs ECMAScript error import { Point } from 'chart.js'; import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; import { MINUTE } from '../../../shared/constants/time'; import { getShouldShowFiat } from '../../selectors'; @@ -20,7 +21,7 @@ export const useHistoricalPrices = ({ currency, timeRange, }: { - chainId: `0x${string}`; + chainId: Hex; address: string; currency: string; timeRange: TimeRange; diff --git a/ui/pages/asset/util.ts b/ui/pages/asset/util.ts index c18d6e92dcfa..2f4a41df6cc9 100644 --- a/ui/pages/asset/util.ts +++ b/ui/pages/asset/util.ts @@ -1,4 +1,5 @@ import { SUPPORTED_CHAIN_IDS, Token } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; /** Formats a datetime in a short human readable format like 'Feb 8, 12:11 PM' */ export const getShortDateFormatter = () => @@ -59,7 +60,7 @@ export const getPricePrecision = (price: number) => { * * @param chainId - The hexadecimal chain id. */ -export const chainSupportsPricing = (chainId: `0x${string}`) => +export const chainSupportsPricing = (chainId: Hex) => (SUPPORTED_CHAIN_IDS as readonly string[]).includes(chainId); /** The opacity components should set during transition */ diff --git a/ui/pages/bridge/hooks/useAddToken.ts b/ui/pages/bridge/hooks/useAddToken.ts index 597149b16e49..d0d29847bf57 100644 --- a/ui/pages/bridge/hooks/useAddToken.ts +++ b/ui/pages/bridge/hooks/useAddToken.ts @@ -2,12 +2,12 @@ import { useDispatch, useSelector } from 'react-redux'; import { NetworkConfiguration } from '@metamask/network-controller'; import { Numeric } from '../../../../shared/modules/Numeric'; import { QuoteResponse } from '../types'; +import { FEATURED_RPCS } from '../../../../shared/constants/network'; +import { addToken, addNetwork } from '../../../store/actions'; import { getNetworkConfigurationsByChainId, getSelectedNetworkClientId, -} from '../../../selectors'; -import { FEATURED_RPCS } from '../../../../shared/constants/network'; -import { addToken, addNetwork } from '../../../store/actions'; +} from '../../../../shared/modules/selectors/networks'; export default function useAddToken() { const dispatch = useDispatch(); diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx index 20f471b1065b..54d7b9e96700 100644 --- a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter, useHistory } from 'react-router-dom'; import { createBridgeMockStore } from '../../../../test/jest/mock-store'; import * as actions from '../../../store/actions'; -import * as selectors from '../../../selectors'; +import * as networks from '../../../../shared/modules/selectors/networks'; import { DummyQuotesNoApproval, DummyQuotesWithApproval, @@ -41,13 +41,12 @@ jest.mock('../../../store/actions', () => { }; }); -jest.mock('../../../selectors', () => { - const original = jest.requireActual('../../../selectors'); +jest.mock('../../../../shared/modules/selectors/networks', () => { + const original = jest.requireActual( + '../../../../shared/modules/selectors/networks', + ); return { ...original, - getIsBridgeEnabled: () => true, - getIsBridgeChain: () => true, - checkNetworkAndAccountSupports1559: () => true, getSelectedNetworkClientId: () => 'mainnet', getNetworkConfigurationsByChainId: jest.fn(() => ({ '0x1': { @@ -84,6 +83,16 @@ jest.mock('../../../selectors', () => { }; }); +jest.mock('../../../selectors', () => { + const original = jest.requireActual('../../../selectors'); + return { + ...original, + getIsBridgeEnabled: () => true, + getIsBridgeChain: () => true, + checkNetworkAndAccountSupports1559: () => true, + }; +}); + const middleware = [thunk]; const makeMockStore = () => { @@ -402,7 +411,7 @@ describe('ui/pages/bridge/hooks/useSubmitBridgeTransaction', () => { ); const mockedGetNetworkConfigurationsByChainId = // @ts-expect-error this is a jest mock - selectors.getNetworkConfigurationsByChainId as jest.Mock; + networks.getNetworkConfigurationsByChainId as jest.Mock; mockedGetNetworkConfigurationsByChainId.mockImplementationOnce(() => ({ '0x1': { blockExplorerUrls: ['https://etherscan.io'], diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 2c9f082519e9..687057094005 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -17,13 +17,13 @@ import { IconName, } from '../../components/component-library'; import { getIsBridgeChain, getIsBridgeEnabled } from '../../selectors'; +import { getProviderConfig } from '../../../shared/modules/selectors/networks'; import useBridging from '../../hooks/bridge/useBridging'; import { Content, Footer, Header, } from '../../components/multichain/pages/page'; -import { getProviderConfig } from '../../ducks/metamask/metamask'; import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; import PrepareBridgePage from './prepare/prepare-bridge-page'; diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 46fbdd77786b..b0553407686d 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -41,7 +41,7 @@ import { QuoteRequest } from '../types'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { isValidQuoteRequest } from '../utils/quote'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { BridgeInputGroup } from './bridge-input-group'; const PrepareBridgePage = () => { diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts b/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts index 57fbd78de9fc..d492c5030354 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useSupportsEIP1559.ts @@ -4,10 +4,8 @@ import { } from '@metamask/transaction-controller'; import { useSelector } from 'react-redux'; import { isLegacyTransaction } from '../../../../../../helpers/utils/transactions.util'; -import { - checkNetworkAndAccountSupports1559, - getSelectedNetworkClientId, -} from '../../../../../../selectors'; +import { checkNetworkAndAccountSupports1559 } from '../../../../../../selectors'; +import { getSelectedNetworkClientId } from '../../../../../../../shared/modules/selectors/networks'; export function useSupportsEIP1559(transactionMeta: TransactionMeta) { const isLegacyTxn = diff --git a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx index 405236fe66da..09c5ffa922c6 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx @@ -17,9 +17,9 @@ import { TextVariant, } from '../../../../../../../helpers/constants/design-system'; import { getNftImageAlt } from '../../../../../../../helpers/utils/nfts'; -import { getNetworkConfigurationsByChainId } from '../../../../../../../selectors'; import { useConfirmContext } from '../../../../../context/confirm'; import { useAssetDetails } from '../../../../../hooks/useAssetDetails'; +import { getNetworkConfigurationsByChainId } from '../../../../../../../../shared/modules/selectors/networks'; const NFTSendHeading = () => { const { currentConfirmation: transactionMeta } = 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 b2ee988adde9..dc394fb1442b 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 @@ -26,7 +26,7 @@ import { TextVariant, } from '../../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../../hooks/useI18nContext'; -import { getNetworkConfigurationsByChainId } from '../../../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../../../shared/modules/selectors/networks'; import { useConfirmContext } from '../../../../context/confirm'; import { selectConfirmationAdvancedDetailsOpen } from '../../../../selectors/preferences'; import { useBalanceChanges } from '../../../simulation-details/useBalanceChanges'; diff --git a/ui/pages/confirmations/components/simulation-details/asset-pill.tsx b/ui/pages/confirmations/components/simulation-details/asset-pill.tsx index 99bd2b3af8ef..0b7efdf13282 100644 --- a/ui/pages/confirmations/components/simulation-details/asset-pill.tsx +++ b/ui/pages/confirmations/components/simulation-details/asset-pill.tsx @@ -19,7 +19,7 @@ import { } from '../../../../helpers/constants/design-system'; import Name from '../../../../components/app/name'; import { TokenStandard } from '../../../../../shared/constants/transaction'; -import { getNetworkConfigurationsByChainId } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../../../shared/constants/network'; import { AssetIdentifier } from './types'; diff --git a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js index c34025e2f0c5..d9a04ace67a9 100644 --- a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.container.js @@ -54,7 +54,6 @@ import { getUnapprovedTransactions, getInternalAccountByAddress, getApprovedAndSignedTransactions, - getSelectedNetworkClientId, getPrioritizedUnapprovedTemplatedConfirmations, } from '../../../selectors'; import { @@ -72,6 +71,7 @@ import { getSendToAccounts, findKeyringForAddress, } from '../../../ducks/metamask/metamask'; +import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { addHexPrefix, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) diff --git a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js index 12971f21a2af..ef08fb6bbba1 100644 --- a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js @@ -13,6 +13,7 @@ import { } from '../../../ducks/confirm-transaction/confirm-transaction.duck'; import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { getSendTo } from '../../../ducks/send'; +import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { CONFIRM_DEPLOY_CONTRACT_PATH, CONFIRM_SEND_ETHER_PATH, @@ -27,7 +28,6 @@ import { isTokenMethodAction } from '../../../helpers/utils/transactions.util'; import usePolling from '../../../hooks/usePolling'; import { usePrevious } from '../../../hooks/usePrevious'; import { - getSelectedNetworkClientId, unconfirmedTransactionsHashSelector, unconfirmedTransactionsListSelector, use4ByteResolutionSelector, diff --git a/ui/pages/confirmations/confirmation/confirmation.js b/ui/pages/confirmations/confirmation/confirmation.js index 30816acea1f2..c2d42be40e47 100644 --- a/ui/pages/confirmations/confirmation/confirmation.js +++ b/ui/pages/confirmations/confirmation/confirmation.js @@ -32,9 +32,9 @@ import { getTotalUnapprovedCount, useSafeChainsListValidationSelector, getSnapsMetadata, - getNetworkConfigurationsByChainId, getHideSnapBranding, } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import Callout from '../../../components/ui/callout'; import { Box, Icon, IconName } from '../../../components/component-library'; import Loading from '../../../components/ui/loading-screen'; diff --git a/ui/pages/confirmations/selectors/confirm.ts b/ui/pages/confirmations/selectors/confirm.ts index 57107bdf3021..e1a3282532d6 100644 --- a/ui/pages/confirmations/selectors/confirm.ts +++ b/ui/pages/confirmations/selectors/confirm.ts @@ -3,7 +3,7 @@ import { ApprovalType } from '@metamask/controller-utils'; import { createSelector } from 'reselect'; import { getPendingApprovals } from '../../../selectors/approvals'; import { getPreferences } from '../../../selectors/selectors'; -import { createDeepEqualSelector } from '../../../selectors/util'; +import { createDeepEqualSelector } from '../../../../shared/modules/selectors/util'; import { ConfirmMetamaskState } from '../types/confirm'; const ConfirmationApprovalTypes = [ diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 9d4511021529..da4677111e63 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -26,7 +26,6 @@ import { getTotalUnapprovedCount, getUnapprovedTemplatedConfirmations, getWeb3ShimUsageStateForOrigin, - getInfuraBlocked, getShowWhatsNewPopup, getSortedAnnouncementsToShow, getShowRecoveryPhraseReminder, @@ -51,7 +50,7 @@ import { getAccountType, ///: END:ONLY_INCLUDE_IF } from '../../selectors'; - +import { getInfuraBlocked } from '../../../shared/modules/selectors/networks'; import { closeNotificationPopup, setConnectedStatusPopoverHasBeenShown, diff --git a/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js b/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js index aed08f196957..3e0d2e19de48 100644 --- a/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js +++ b/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js @@ -51,8 +51,8 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { getPetnamesEnabled, getExternalServicesOnboardingToggleState, - getNetworkConfigurationsByChainId, } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { selectIsProfileSyncingEnabled } from '../../../selectors/metamask-notifications/profile-syncing'; import { setIpfsGateway, diff --git a/ui/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index e002e54ef34e..ba9bcc6bf674 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -5,10 +5,10 @@ import { NetworkConfiguration } from '@metamask/network-controller'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getInternalAccounts, - getNetworkConfigurationsByChainId, getSelectedInternalAccount, getUpdatedAndSortedAccounts, } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { Box, Button, diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index e0fe5575e2c2..bb02e0ebaaa9 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -6,7 +6,6 @@ import { getIsNetworkUsed, getNetworkIdentifier, getPreferences, - isNetworkLoading, getTheme, getIsTestnet, getCurrentChainId, @@ -26,6 +25,10 @@ import { getUnapprovedTransactions, getPendingApprovals, } from '../../selectors'; +import { + isNetworkLoading, + getProviderConfig, +} from '../../../shared/modules/selectors/networks'; import { lockMetamask, hideImportNftsModal, @@ -47,10 +50,7 @@ import { import { pageChanged } from '../../ducks/history/history'; import { prepareToLeaveSwaps } from '../../ducks/swaps/swaps'; import { getSendStage } from '../../ducks/send'; -import { - getIsUnlocked, - getProviderConfig, -} from '../../ducks/metamask/metamask'; +import { getIsUnlocked } from '../../ducks/metamask/metamask'; import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; import { selectSwitchedNetworkNeverShowMessage } from '../../components/app/toast-master/selectors'; import Routes from './routes.component'; diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js index ff10d850345e..259c503ef5eb 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js @@ -7,7 +7,7 @@ import { getInternalAccountByAddress, getInternalAccounts, } from '../../../../selectors'; -import { getProviderConfig } from '../../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../../shared/modules/selectors/networks'; import { CONTACT_VIEW_ROUTE, CONTACT_LIST_ROUTE, diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx index 6c5ecdad8c58..db3a86ce0ed4 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx @@ -28,7 +28,7 @@ import { import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { getNetworkConfigurationsByChainId } from '../../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import { addNetwork, setEditedNetwork, diff --git a/ui/pages/settings/security-tab/security-tab.container.js b/ui/pages/settings/security-tab/security-tab.container.js index 676a53097d4a..c70640c624e9 100644 --- a/ui/pages/settings/security-tab/security-tab.container.js +++ b/ui/pages/settings/security-tab/security-tab.container.js @@ -24,10 +24,10 @@ import { } from '../../../store/actions'; import { getIsSecurityAlertsEnabled, - getNetworkConfigurationsByChainId, getMetaMetricsDataDeletionId, getPetnamesEnabled, } from '../../../selectors/selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { openBasicFunctionalityModal } from '../../../ducks/app/app'; import SecurityTab from './security-tab.component'; diff --git a/ui/pages/settings/settings-tab/settings-tab.container.js b/ui/pages/settings/settings-tab/settings-tab.container.js index e6ad25f0df92..ec46a2f3cd5b 100644 --- a/ui/pages/settings/settings-tab/settings-tab.container.js +++ b/ui/pages/settings/settings-tab/settings-tab.container.js @@ -14,7 +14,7 @@ import { getTheme, getSelectedInternalAccount, } from '../../../selectors'; -import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import SettingsTab from './settings-tab.component'; const mapStateToProps = (state) => { diff --git a/ui/pages/settings/settings.container.js b/ui/pages/settings/settings.container.js index 58a35f37e616..db500f8b1d46 100644 --- a/ui/pages/settings/settings.container.js +++ b/ui/pages/settings/settings.container.js @@ -32,7 +32,7 @@ import { ADD_NETWORK_ROUTE, ADD_POPULAR_CUSTOM_NETWORK, } from '../../helpers/constants/routes'; -import { getProviderConfig } from '../../ducks/metamask/metamask'; +import { getProviderConfig } from '../../../shared/modules/selectors/networks'; import { toggleNetworkMenu } from '../../store/actions'; import Settings from './settings.component'; diff --git a/ui/pages/swaps/hooks/useUpdateSwapsState.ts b/ui/pages/swaps/hooks/useUpdateSwapsState.ts index ea72c8e273ab..dc44c51a8489 100644 --- a/ui/pages/swaps/hooks/useUpdateSwapsState.ts +++ b/ui/pages/swaps/hooks/useUpdateSwapsState.ts @@ -18,6 +18,7 @@ import { getIsSwapsChain, getUseExternalServices, } from '../../../selectors'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps'; export default function useUpdateSwapsState() { const dispatch = useDispatch(); @@ -35,7 +36,7 @@ export default function useUpdateSwapsState() { return undefined; } - fetchTokens(chainId) + fetchTokens(chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP) .then((tokens) => { dispatch(setSwapsTokens(tokens)); }) diff --git a/ui/selectors/approvals.ts b/ui/selectors/approvals.ts index 9ee42f769482..14e2362d0708 100644 --- a/ui/selectors/approvals.ts +++ b/ui/selectors/approvals.ts @@ -1,7 +1,7 @@ import { ApprovalControllerState } from '@metamask/approval-controller'; import { ApprovalType } from '@metamask/controller-utils'; import { createSelector } from 'reselect'; -import { createDeepEqualSelector } from './util'; +import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; export type ApprovalsMetaMaskState = { metamask: { diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index 9a1457e2bc66..b68d598a0839 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -11,7 +11,6 @@ import { getGasEstimateType, getGasFeeEstimates, getNativeCurrency, - getProviderConfig, } from '../ducks/metamask/metamask'; import { GasEstimateTypes, @@ -29,6 +28,7 @@ import { subtractHexes, sumHexes, } from '../../shared/modules/conversion.utils'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { getAveragePriceEstimateInHexWEI } from './custom-gas'; import { checkNetworkAndAccountSupports1559, diff --git a/ui/selectors/institutional/selectors.test.ts b/ui/selectors/institutional/selectors.test.ts index 52b27bb8871f..4c398b6fab0b 100644 --- a/ui/selectors/institutional/selectors.test.ts +++ b/ui/selectors/institutional/selectors.test.ts @@ -4,7 +4,11 @@ import { Hex } from '@metamask/utils'; import { toHex } from '@metamask/controller-utils'; import { ETH_EOA_METHODS } from '../../../shared/constants/eth-methods'; import { mockNetworkState } from '../../../test/stub/networks'; -import { CHAIN_IDS } from '../../../shared/constants/network'; +import { + CHAIN_IDS, + CURRENCY_SYMBOLS, + NETWORK_TO_NAME_MAP, +} from '../../../shared/constants/network'; import { getConfiguredCustodians, getCustodianIconForAddress, @@ -71,10 +75,15 @@ const custodianMock = { function buildState(overrides = {}) { const defaultState = { metamask: { + selectedNetworkClientId: '0x1', networkConfigurationsByChainId: { - [toHex(1)]: { - chainId: toHex(1), - rpcEndpoints: [{}], + [CHAIN_IDS.MAINNET]: { + chainId: CHAIN_IDS.MAINNET, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: NETWORK_TO_NAME_MAP[CHAIN_IDS.MAINNET], + nativeCurrency: CURRENCY_SYMBOLS.ETH, + rpcEndpoints: [], }, }, internalAccounts: { diff --git a/ui/selectors/institutional/selectors.ts b/ui/selectors/institutional/selectors.ts index 05bd13b52509..eb70e0fcd72c 100644 --- a/ui/selectors/institutional/selectors.ts +++ b/ui/selectors/institutional/selectors.ts @@ -1,7 +1,10 @@ import { toChecksumAddress } from 'ethereumjs-util'; import { getAccountType } from '../selectors'; import { getSelectedInternalAccount } from '../accounts'; -import { getProviderConfig } from '../../ducks/metamask/metamask'; +import { + ProviderConfigState, + getProviderConfig, +} from '../../../shared/modules/selectors/networks'; import { hexToDecimal } from '../../../shared/modules/conversion.utils'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths @@ -165,7 +168,9 @@ export function getCustodianIconForAddress(state: State, address: string) { return custodianIcon; } -export function getIsCustodianSupportedChain(state: State) { +export function getIsCustodianSupportedChain( + state: State & ProviderConfigState, +) { try { // @ts-expect-error state types don't match const selectedAccount = getSelectedInternalAccount(state); diff --git a/ui/selectors/metamask-notifications/metamask-notifications.ts b/ui/selectors/metamask-notifications/metamask-notifications.ts index ae71adaa8d36..10f4f337eabf 100644 --- a/ui/selectors/metamask-notifications/metamask-notifications.ts +++ b/ui/selectors/metamask-notifications/metamask-notifications.ts @@ -1,6 +1,6 @@ import { createSelector } from 'reselect'; import { NotificationServicesController } from '@metamask/notification-services-controller'; -import { createDeepEqualSelector } from '../util'; +import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; const { TRIGGER_TYPES } = NotificationServicesController.Constants; diff --git a/ui/selectors/multichain.test.ts b/ui/selectors/multichain.test.ts index 3097d61f9549..2fa47a8db9e8 100644 --- a/ui/selectors/multichain.test.ts +++ b/ui/selectors/multichain.test.ts @@ -2,10 +2,7 @@ import { Cryptocurrency } from '@metamask/assets-controllers'; import { InternalAccount } from '@metamask/keyring-api'; import { Hex } from '@metamask/utils'; import { NetworkConfiguration } from '@metamask/network-controller'; -import { - getNativeCurrency, - getProviderConfig, -} from '../ducks/metamask/metamask'; +import { getNativeCurrency } from '../ducks/metamask/metamask'; import { MULTICHAIN_PROVIDER_CONFIGS, MultichainNetworks, @@ -24,6 +21,7 @@ import { } from '../../shared/constants/network'; import { MultichainNativeAssets } from '../../shared/constants/multichain/assets'; import { mockNetworkState } from '../../test/stub/networks'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { AccountsState } from './accounts'; import { MultichainState, diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 08078be52f03..1914dbce2dd8 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -14,7 +14,6 @@ import { getCompletedOnboarding, getConversionRate, getNativeCurrency, - getProviderConfig, } from '../ducks/metamask/metamask'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths @@ -25,6 +24,11 @@ import { TEST_NETWORK_IDS, CHAIN_IDS, } from '../../shared/constants/network'; +import { + getProviderConfig, + NetworkState, + getNetworkConfigurationsByChainId, +} from '../../shared/modules/selectors/networks'; import { AccountsState, getSelectedInternalAccount } from './accounts'; import { getCurrentChainId, @@ -32,7 +36,6 @@ import { getIsMainnet, getMaybeSelectedInternalAccount, getNativeCurrencyImage, - getNetworkConfigurationsByChainId, getSelectedAccountCachedBalance, getShouldShowFiat, getShowFiatInTestnets, @@ -46,7 +49,10 @@ export type BalancesState = { metamask: BalancesControllerState; }; -export type MultichainState = AccountsState & RatesState & BalancesState; +export type MultichainState = AccountsState & + RatesState & + BalancesState & + NetworkState; // TODO: Remove after updating to @metamask/network-controller 20.0.0 export type ProviderConfigWithImageUrlAndExplorerUrl = { diff --git a/ui/selectors/networks.test.ts b/ui/selectors/networks.test.ts new file mode 100644 index 000000000000..2df8a23f302c --- /dev/null +++ b/ui/selectors/networks.test.ts @@ -0,0 +1,129 @@ +import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; +import mockState from '../../test/data/mock-state.json'; +import { mockNetworkState } from '../../test/stub/networks'; +import { CHAIN_IDS } from '../../shared/constants/network'; +import * as networks from '../../shared/modules/selectors/networks'; + +describe('Network Selectors', () => { + describe('#getNetworkConfigurationsByChainId', () => { + it('returns networkConfigurationsByChainId', () => { + const networkConfigurationsByChainId = { + '0x1351': { + name: 'TEST', + chainId: '0x1351' as const, + nativeCurrency: 'TEST', + defaultRpcEndpointUrl: 'https://mock-rpc-url-1', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom as const, + networkClientId: 'testNetworkConfigurationId1', + url: 'https://mock-rpc-url-1', + }, + ], + blockExplorerUrls: [], + }, + '0x1337': { + name: 'RPC', + chainId: '0x1337' as const, + nativeCurrency: 'RPC', + defaultRpcEndpointUrl: 'https://mock-rpc-url-2', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom as const, + networkClientId: 'testNetworkConfigurationId2', + url: 'https://mock-rpc-url-2', + }, + ], + blockExplorerUrls: [], + }, + }; + + expect( + networks.getNetworkConfigurationsByChainId({ + metamask: { + networkConfigurationsByChainId, + }, + }), + ).toStrictEqual(networkConfigurationsByChainId); + }); + }); + + describe('#getNetworkConfigurations', () => { + it('returns undefined if state.metamask.networkConfigurations is undefined', () => { + expect( + networks.getNetworkConfigurations({ + metamask: { + // @ts-expect-error the types forbid `undefined`. this is a strange test. + networkConfigurations: undefined, + }, + }), + ).toBeUndefined(); + }); + + it('returns networkConfigurations', () => { + const networkConfigurations = { + '0x1351': { + name: 'TEST', + chainId: '0x1351' as const, + nativeCurrency: 'TEST', + defaultRpcEndpointUrl: 'https://mock-rpc-url-1', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom as const, + networkClientId: 'testNetworkConfigurationId1', + url: 'https://mock-rpc-url-1', + }, + ], + blockExplorerUrls: [], + }, + '0x1337': { + name: 'RPC', + chainId: '0x1337' as const, + nativeCurrency: 'RPC', + defaultRpcEndpointUrl: 'https://mock-rpc-url-2', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom as const, + networkClientId: 'testNetworkConfigurationId2', + url: 'https://mock-rpc-url-2', + }, + ], + blockExplorerUrls: [], + }, + }; + expect( + networks.getNetworkConfigurations({ + metamask: { + networkConfigurations, + }, + }), + ).toStrictEqual(networkConfigurations); + }); + }); + + describe('#getInfuraBlocked', () => { + it('returns getInfuraBlocked', () => { + let isInfuraBlocked = networks.getInfuraBlocked( + mockState as networks.NetworkState, + ); + expect(isInfuraBlocked).toBe(false); + + const modifiedMockState = { + ...mockState, + metamask: { + ...mockState.metamask, + ...mockNetworkState({ + chainId: CHAIN_IDS.GOERLI, + metadata: { status: NetworkStatus.Blocked, EIPS: {} }, + }), + }, + }; + isInfuraBlocked = networks.getInfuraBlocked(modifiedMockState); + expect(isInfuraBlocked).toBe(true); + }); + }); +}); diff --git a/ui/selectors/permissions.js b/ui/selectors/permissions.js index 00468f2f948d..a0d69b7dc75e 100644 --- a/ui/selectors/permissions.js +++ b/ui/selectors/permissions.js @@ -4,8 +4,8 @@ import { isEvmAccountType } from '@metamask/keyring-api'; import { CaveatTypes } from '../../shared/constants/permissions'; // eslint-disable-next-line import/no-restricted-paths import { PermissionNames } from '../../app/scripts/controllers/permissions'; +import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { getApprovalRequestsByType } from './approvals'; -import { createDeepEqualSelector } from './util'; import { getInternalAccount, getMetaMaskAccountsOrdered, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 27b3a878042b..e4e9c7e27fa7 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -24,7 +24,6 @@ import { CHAIN_ID_TO_RPC_URL_MAP, CHAIN_IDS, NETWORK_TYPES, - NetworkStatus, SEPOLIA_DISPLAY_NAME, GOERLI_DISPLAY_NAME, LINEA_GOERLI_DISPLAY_NAME, @@ -79,7 +78,6 @@ import { STATIC_MAINNET_TOKEN_LIST } from '../../shared/constants/tokens'; import { DAY } from '../../shared/constants/time'; import { TERMS_OF_USE_LAST_UPDATED } from '../../shared/constants/terms'; import { - getProviderConfig, getConversionRate, isNotEIP1559Network, isEIP1559Network, @@ -106,6 +104,12 @@ import { MultichainNativeAssets } from '../../shared/constants/multichain/assets import { BridgeFeatureFlagsKey } from '../../app/scripts/controllers/bridge/types'; import { hasTransactionData } from '../../shared/modules/transaction.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; +import { + getProviderConfig, + getSelectedNetworkClientId, + getNetworkConfigurationsByChainId, +} from '../../shared/modules/selectors/networks'; +import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { getAllUnapprovedTransactions, getCurrentNetworkTransactions, @@ -120,28 +124,8 @@ import { getSubjectMetadata, } from './permissions'; import { getSelectedInternalAccount, getInternalAccounts } from './accounts'; -import { createDeepEqualSelector } from './util'; import { getMultichainBalances, getMultichainNetwork } from './multichain'; -/** - * Returns true if the currently selected network is inaccessible or whether no - * provider has been set yet for the currently selected network. - * - * @param {object} state - Redux state object. - */ -export function isNetworkLoading(state) { - const selectedNetworkClientId = getSelectedNetworkClientId(state); - return ( - selectedNetworkClientId && - state.metamask.networksMetadata[selectedNetworkClientId].status !== - NetworkStatus.Available - ); -} - -export function getSelectedNetworkClientId(state) { - return state.metamask.selectedNetworkClientId; -} - export function getNetworkIdentifier(state) { const { type, nickname, rpcUrl } = getProviderConfig(state); @@ -838,15 +822,6 @@ export function getGasIsLoading(state) { return state.appState.gasIsLoading; } -export const getNetworkConfigurationsByChainId = createDeepEqualSelector( - (state) => state.metamask.networkConfigurationsByChainId, - /** - * @param networkConfigurationsByChainId - * @returns { import('@metamask/network-controller').NetworkState['networkConfigurationsByChainId']} - */ - (networkConfigurationsByChainId) => networkConfigurationsByChainId, -); - export const getNetworkConfigurationIdByChainId = createDeepEqualSelector( (state) => state.metamask.networkConfigurationsByChainId, (networkConfigurationsByChainId) => @@ -1437,7 +1412,7 @@ export const getMultipleTargetsSubjectMetadata = createDeepEqualSelector( export function getRpcPrefsForCurrentProvider(state) { const { rpcPrefs } = getProviderConfig(state); - return rpcPrefs || {}; + return rpcPrefs; } export function getKnownMethodData(state, data) { @@ -1477,13 +1452,6 @@ export function getUseExternalServices(state) { return state.metamask.useExternalServices; } -export function getInfuraBlocked(state) { - return ( - state.metamask.networksMetadata[getSelectedNetworkClientId(state)] - .status === NetworkStatus.Blocked - ); -} - export function getUSDConversionRate(state) { return state.metamask.currencyRates[getProviderConfig(state).ticker] ?.usdConversionRate; diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index d3799885eaf6..e70e9cd7ff29 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -10,10 +10,10 @@ import { KeyringType } from '../../shared/constants/keyring'; 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 '../../shared/modules/selectors/networks'; import * as selectors from './selectors'; jest.mock('../../app/scripts/lib/util', () => ({ @@ -646,45 +646,6 @@ describe('Selectors', () => { }); }); - describe('#getNetworkConfigurationsByChainId', () => { - it('returns networkConfigurationsByChainId', () => { - const networkConfigurationsByChainId = { - '0xtest': { - chainId: '0xtest', - nativeCurrency: 'TEST', - defaultRpcEndpointUrl: 'https://mock-rpc-url-1', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'testNetworkConfigurationId1', - url: 'https://mock-rpc-url-1', - }, - ], - }, - '0x1337': { - chainId: '0x1337', - nativeCurrency: 'RPC', - defaultRpcEndpointUrl: 'https://mock-rpc-url-2', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'testNetworkConfigurationId2', - url: 'https://mock-rpc-url-2', - }, - ], - }, - }; - - expect( - selectors.getNetworkConfigurationsByChainId({ - metamask: { - networkConfigurationsByChainId, - }, - }), - ).toStrictEqual(networkConfigurationsByChainId); - }); - }); - describe('#getCurrentNetwork', () => { it('returns built-in network configuration', () => { const modifiedMockState = { @@ -1317,24 +1278,6 @@ describe('Selectors', () => { expect(selectors.getSnapsInstallPrivacyWarningShown(mockState)).toBe(false); }); - it('#getInfuraBlocked', () => { - let isInfuraBlocked = selectors.getInfuraBlocked(mockState); - expect(isInfuraBlocked).toBe(false); - - const modifiedMockState = { - ...mockState, - metamask: { - ...mockState.metamask, - ...mockNetworkState({ - chainId: CHAIN_IDS.GOERLI, - metadata: { status: 'blocked' }, - }), - }, - }; - isInfuraBlocked = selectors.getInfuraBlocked(modifiedMockState); - expect(isInfuraBlocked).toBe(true); - }); - it('#getSnapRegistryData', () => { const mockSnapId = 'npm:@metamask/test-snap-bip44'; expect(selectors.getSnapRegistryData(mockState, mockSnapId)).toStrictEqual( diff --git a/ui/selectors/signatures.ts b/ui/selectors/signatures.ts index 7325e5c038c0..0fd99eb4361a 100644 --- a/ui/selectors/signatures.ts +++ b/ui/selectors/signatures.ts @@ -1,10 +1,10 @@ import { createSelector } from 'reselect'; import { DefaultRootState } from 'react-redux'; +import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { unapprovedPersonalMsgsSelector, unapprovedTypedMessagesSelector, } from './transactions'; -import { createDeepEqualSelector } from './util'; export const selectUnapprovedMessages = createSelector( unapprovedPersonalMsgsSelector, diff --git a/ui/selectors/snaps/accounts.ts b/ui/selectors/snaps/accounts.ts index 55a30f0c72eb..0b0559b7d1dd 100644 --- a/ui/selectors/snaps/accounts.ts +++ b/ui/selectors/snaps/accounts.ts @@ -2,7 +2,7 @@ import { createSelector } from 'reselect'; import { AccountsControllerState } from '@metamask/accounts-controller'; import { getAccountName } from '../selectors'; import { getInternalAccounts } from '../accounts'; -import { createDeepEqualSelector } from '../util'; +import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; /** * The Metamask state for the accounts controller. diff --git a/ui/selectors/snaps/address-book.ts b/ui/selectors/snaps/address-book.ts index e002153d9e61..da2e4ac802c9 100644 --- a/ui/selectors/snaps/address-book.ts +++ b/ui/selectors/snaps/address-book.ts @@ -1,5 +1,5 @@ import { AddressBookController } from '@metamask/address-book-controller'; -import { createDeepEqualSelector } from '../util'; +import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; /** diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 3074fd4bfde4..9428a6fbd8c3 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -12,14 +12,14 @@ import { 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 } from './selectors'; -import { getSelectedInternalAccount } from './accounts'; -import { hasPendingApprovals, getApprovalRequestsByType } from './approvals'; +import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { createDeepEqualSelector, filterAndShapeUnapprovedTransactions, -} from './util'; +} from '../../shared/modules/selectors/util'; +import { getCurrentChainId } from './selectors'; +import { getSelectedInternalAccount } from './accounts'; +import { hasPendingApprovals, getApprovalRequestsByType } from './approvals'; const INVALID_INITIAL_TRANSACTION_TYPES = [ TransactionType.cancel, diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 66962d46161d..f3f4e712acf5 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -69,8 +69,11 @@ import { getInternalAccountByAddress, getSelectedInternalAccount, getInternalAccounts, - getSelectedNetworkClientId, } from '../selectors'; +import { + getSelectedNetworkClientId, + getProviderConfig, +} from '../../shared/modules/selectors/networks'; import { computeEstimatedGasLimit, initializeSendState, @@ -82,10 +85,7 @@ import { SEND_STAGES, } from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; -import { - getProviderConfig, - getUnconnectedAccountAlertEnabledness, -} from '../ducks/metamask/metamask'; +import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { HardwareDeviceNames, From aee0aa82d7a476ed70e16cf260bb7a092894e1b3 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Thu, 21 Nov 2024 19:33:52 -0330 Subject: [PATCH 040/148] chore: Branch off of "New Crowdin translations by Github Action" (#28390) This is a branch off of https://github.com/MetaMask/metamask-extension/pull/26964, as of commit 3ec2e61b06 This will make it easier to merge the branch, which regularly has new commits pushed to it This adds new translations from crowdin --------- Co-authored-by: metamaskbot Co-authored-by: Desi McAdam --- app/_locales/de/messages.json | 459 ++++++++++++++++++++++++++++++- app/_locales/el/messages.json | 459 ++++++++++++++++++++++++++++++- app/_locales/es/messages.json | 458 +++++++++++++++++++++++++++++- app/_locales/fr/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/hi/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/id/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/ja/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/ko/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/pt/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/ru/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/tl/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/tr/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/vi/messages.json | 455 +++++++++++++++++++++++++++++- app/_locales/zh_CN/messages.json | 455 +++++++++++++++++++++++++++++- 14 files changed, 6363 insertions(+), 18 deletions(-) diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index e8de6564304b..172fd05a5a73 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Verbinden Sie Ihre QR-basierte Hardware-Wallet." }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Die Adresse in der Anmeldeanfrage entspricht nicht der Adresse des Kontos, mit dem Sie sich anmelden." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Wählen Sie die Konten aus, über die Sie benachrichtigt werden möchten:" }, + "accountBalance": { + "message": "Kontostand" + }, "accountDetails": { "message": "Kontodetails" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Kontooptionen" }, + "accountPermissionToast": { + "message": "Kontogenehmigungen aktualisiert" + }, "accountSelectionRequired": { "message": "Sie müssen ein Konto auswählen!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Konten wurden verbunden" }, + "accountsPermissionsTitle": { + "message": "Ihre Konten einsehen und Transaktionen vorschlagen" + }, + "accountsSmallCase": { + "message": "Konten" + }, "active": { "message": "Aktiv" }, @@ -180,12 +195,18 @@ "add": { "message": "Hinzufügen" }, + "addACustomNetwork": { + "message": "Benutzerdefiniertes Netzwerk hinzufügen" + }, "addANetwork": { "message": "Ein neues Netzwerk hinzufügen" }, "addANickname": { "message": "Spitznamen hinzufügen" }, + "addAUrl": { + "message": "URL hinzufügen" + }, "addAccount": { "message": "Konto hinzufügen" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Einen Block-Explorer hinzufügen" }, + "addBlockExplorerUrl": { + "message": "Eine Block-Explorer-URL hinzufügen" + }, "addContact": { "message": "Kontakt hinzufügen" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Sie fügen einen neuen RPC-Anbieter für das Ethereum-Hauptnetz hinzu." }, + "addEthereumWatchOnlyAccount": { + "message": "Ein Ethereum-Konto ansehen (Beta)" + }, "addFriendsAndAddresses": { "message": "Freunde und Adressen hinzufügen, welchen Sie vertrauen" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Netzwerk hinzufügen" }, + "addNetworkConfirmationTitle": { + "message": "$1 hinzufügen", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Ein neues Konto hinzufügen" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Adresse wurde kopiert!" }, + "addressMismatch": { + "message": "Nichtübereinstimmung der Website-Adresse" + }, + "addressMismatchOriginal": { + "message": "Aktuelle URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Punycode-Version: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Erweitert" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Prioritätsgebühr (alias „Miner Tip“) geht direkt an Miner und veranlasst sie, Ihre Transaktion zu priorisieren." }, + "aggregatedBalancePopover": { + "message": "Dies spiegelt den Wert aller Tokens wider, über die Sie in einem bestimmten Netzwerk verfügen. Sollten Sie diesen Wert lieber in ETH oder anderen Währungen angezeigt bekommen wollen, wechseln Sie zu $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Ich stimme MetaMasks $1 zu", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Dies kann in „Einstellungen > Benachrichtigungen“ geändert werden." }, + "alertMessageAddressMismatchWarning": { + "message": "Angreifer imitieren manchmal Websites, indem sie kleine Änderungen an der Adresse der Website vornehmen. Vergewissern Sie sich, dass Sie mit der beabsichtigten Website interagieren, bevor Sie fortfahren." + }, "alertMessageGasEstimateFailed": { "message": "Wir sind nicht in der Lage, eine genaue Gebühr anzugeben, und diese Schätzung könnte zu hoch sein. Wir schlagen vor, dass Sie ein individuelles Gas-Limit eingeben, aber es besteht das Risiko, dass die Transaktion trotzdem fehlschlägt." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Um mit dieser Transaktion fortzufahren, müssen Sie das Gas-Limit auf 21.000 oder mehr erhöhen." }, + "alertMessageInsufficientBalance2": { + "message": "Sie haben nicht genug ETH auf Ihrem Konto, um die Netzwerk-Gebühren zu bezahlen." + }, "alertMessageNetworkBusy": { "message": "Die Gas-Preise sind hoch und die Schätzungen sind weniger genau." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Asset-Optionen" }, + "assets": { + "message": "Assets" + }, + "assetsDescription": { + "message": "Automatische Erkennung von Tokens in Ihrer Wallet, Anzeige von NFTs und stapelweise Aktualisierung des Kontostands" + }, "attemptSendingAssets": { "message": "Wenn Sie versuchen, Assets direkt von einem Netzwerk in ein anderes zu senden, kann dies zu einem dauerhaften Asset-Verlust führen. Verwenden Sie unbedingt eine Bridge." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Bridge, nicht senden" }, + "bridgeFrom": { + "message": "Bridge von" + }, + "bridgeSelectNetwork": { + "message": "Netzwerk wählen" + }, + "bridgeTo": { + "message": "Bridge nach" + }, "browserNotSupported": { "message": "Ihr Browser wird nicht unterstützt …" }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Geheime Wiederherstellungsphrase bestätigen" }, + "confirmTitleApproveTransaction": { + "message": "Bewilligungsanfrage" + }, + "confirmTitleDeployContract": { + "message": "Einen Kontrakt nutzen" + }, + "confirmTitleDescApproveTransaction": { + "message": "Diese Website möchte die Genehmigung, Ihre NFTs abzuheben." + }, + "confirmTitleDescDeployContract": { + "message": "Diese Website möchte, dass Sie einen Kontrakt nutzen." + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Diese Website möchte die Genehmigung, Ihre Tokens abzuheben." + }, "confirmTitleDescPermitSignature": { "message": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben." }, "confirmTitleDescSIWESignature": { "message": "Eine Website möchte, dass Sie sich anmelden, um zu beweisen, dass Sie dieses Konto besitzen." }, + "confirmTitleDescSign": { + "message": "Überprüfen Sie vor der Bestätigung die Details der Anfrage." + }, "confirmTitlePermitTokens": { "message": "Antrag auf Ausgabenobergrenze" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Genehmigung entfernen" + }, "confirmTitleSIWESignature": { "message": "Anmeldeanfrage" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Genehmigung entfernen" + }, "confirmTitleSignature": { "message": "Signaturanfrage" }, "confirmTitleTransaction": { "message": "Transaktionsanfrage" }, + "confirmationAlertModalDetails": { + "message": "Um Ihre Assets und Anmeldedaten zu schützen, empfehlen wir die Ablehnung der Anfrage." + }, + "confirmationAlertModalTitle": { + "message": "Diese Anfrage scheint verdächtig" + }, "confirmed": { "message": "Bestätigt" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Wir haben ein missverständliches Zeichen im ENS-Namen entdeckt. Prüfen Sie den ENS-Namen, um möglichen Betrug zu vermeiden." }, + "congratulations": { + "message": "Glückwunsch!" + }, "connect": { "message": "Verbinden" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Verbundene Seiten" }, + "connectedSitesAndSnaps": { + "message": "Verbundene Websites und Snaps" + }, "connectedSitesDescription": { "message": "$1 ist mit diesen Seiten verbunden. Sie können Ihre Konto-Adresse einsehen.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask ist mit dieser Seite verbunden, aber es sind noch keine Konten verbunden" }, + "connectedSnaps": { + "message": "Verbundene Snaps" + }, + "connectedWithAccount": { + "message": "$1 Konten verbunden", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Verbunden mit $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Verbinden" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Verbindung zum Sepolia-Testnetzwerk wird hergestellt" }, + "connectionDescription": { + "message": "Diese Website möchte" + }, "connectionFailed": { "message": "Verbindung fehlgeschlagen" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Adresse in die Zwischenablage kopieren" }, + "copyAddressShort": { + "message": "Adresse kopieren" + }, "copyPrivateKey": { "message": "Privaten Schlüssel kopieren" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "Standard-RPC-URL" }, + "defaultSettingsSubTitle": { + "message": "MetaMask verwendet Standardeinstellungen, um ein optimales Gleichgewicht zwischen Sicherheit und Benutzerfreundlichkeit herzustellen. Ändern Sie diese Einstellungen, um Ihre Privatsphäre weiter zu verbessern." + }, + "defaultSettingsTitle": { + "message": "Standard-Datenschutzeinstellungen" + }, "delete": { "message": "Löschen" }, "deleteContact": { "message": "Kontakt löschen" }, + "deleteMetaMetricsData": { + "message": "MetaMetrics-Daten löschen" + }, + "deleteMetaMetricsDataDescription": { + "message": "Dadurch werden historische, mit Ihrer Nutzung auf diesem Gerät verbundene MetaMetrics-Daten gelöscht. Ihre Wallet und Ihre Konten bleiben nach dem Löschen dieser Daten unverändert. Dieser Vorgang kann bis zu 30 Tage dauern. Lesen Sie unsere $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Diese Anfrage kann aufgrund eines Serverproblems des Analysesystems derzeit nicht bearbeitet werden. Bitte versuchen Sie es später erneut." + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Wir können diese Daten im Moment nicht löschen" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Wir sind dabei, Ihre gesamten MetaMetrics-Daten zu löschen. Sind Sie sicher?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "MetaMetrics-Daten löschen?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Sie haben diese Aktion am $1 initiiert. Dieser Vorgang kann bis zu 30 Tage dauern. $2 anzeigen", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Wenn Sie dieses Netzwerk löschen, müssen Sie es erneut hinzufügen, um Ihre Assets in diesem Netzwerk anzuzeigen." }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Einzahlung" }, + "depositCrypto": { + "message": "Zahlen Sie Krypto von einem anderen Konto über eine Wallet-Adresse oder einen QR-Code ein." + }, "deprecatedGoerliNtwrkMsg": { "message": "Aufgrund von Aktualisierungen des Ethereum-Systems wird das Goerli-Testnetzwerk bald eingestellt." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "Konten" }, + "disconnectAllDescriptionText": { + "message": "Falls Sie die Verbindung zu dieser Seite trennen, müssen Sie Ihre Konten und Netzwerke bei einer weiteren Nutzung dieser Website erneut verbinden." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Dadurch wird die Verbindung zu dieser Seite getrennt" + }, "disconnectPrompt": { "message": "$1 trennen" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Spitznamen bearbeiten" }, + "editAccounts": { + "message": "Konten bearbeiten" + }, "editAddressNickname": { "message": "Adressenspitznamen bearbeiten" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "Das ursprüngliche Netzwerk bearbeiten" }, + "editNetworksTitle": { + "message": "Netzwerke bearbeiten" + }, "editNonceField": { "message": "Nonce bearbeiten" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Genehmigung bearbeiten" }, + "editPermissions": { + "message": "Genehmigungen bearbeiten" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Beschleunigung der Gasgebühr bearbeiten" }, + "editSpendingCap": { + "message": "Ausgabenobergrenze bearbeiten" + }, + "editSpendingCapAccountBalance": { + "message": "Kontostand: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Geben Sie den von Ihnen gewünschten Betrag ein, der in Ihrem Namen ausgegeben werden soll." + }, + "editSpendingCapError": { + "message": "Die Ausgabenobergrenze darf nicht mehr als $1 Dezimalstellen überschreiten. Entfernen Sie die Dezimalstellen, um fortzufahren." + }, "enableAutoDetect": { "message": " Automatische Erkennung aktivieren" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENS-Lookup fehlgeschlagen." }, + "enterANameToIdentifyTheUrl": { + "message": "Geben Sie zur Identifizierung der URL einen Namen ein" + }, "enterANumber": { "message": "Nummer eingeben" }, + "enterChainId": { + "message": "Chain-ID eingeben" + }, "enterCustodianToken": { "message": "$1-Token eingeben oder neues Token hinzufügen" }, "enterMaxSpendLimit": { "message": "Max. Ausgabenlimit eingeben" }, + "enterNetworkName": { + "message": "Netzwerkname eingeben" + }, "enterOptionalPassword": { "message": "Optionales Passwort eingeben" }, "enterPasswordContinue": { "message": "Zum Fortfahren Passwort eingeben" }, + "enterRpcUrl": { + "message": "RPC-URL eingeben" + }, + "enterSymbol": { + "message": "Symbol eingeben" + }, "enterTokenNameOrAddress": { "message": "Tokennamen eingeben oder Adresse einfügen" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Experimentell" }, + "exportYourData": { + "message": "Ihre Daten exportieren" + }, + "exportYourDataButton": { + "message": "Herunterladen" + }, + "exportYourDataDescription": { + "message": "Sie können Daten wie Ihre Kontakte und Einstellungen exportieren." + }, "extendWalletWithSnaps": { "message": "Erkunden Sie die von der Community erstellten Snaps, um Ihr web3-Erlebnis individuell zu gestalten.", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Diese Gas-Gebühr wurde von $1 vorgeschlagen. Dies kann ein Problem mit Ihrer Transaktion verursachen. Bei Fragen wenden Sie sich bitte an $1.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Gas-Gebühr" + }, "gasIsETH": { "message": "Gas ist $1" }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Etwas ist schiefgelaufen ...." }, + "generalDescription": { + "message": "Synchronisieren Sie Einstellungen geräteübergreifend, wählen Sie Netzwerkeinstellungen aus und verfolgen Sie Token-Daten" + }, "genericExplorerView": { "message": "Konto auf $1 ansehen" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Wenn Sie aus der App ausgesperrt werden oder ein neues Gerät erhalten, verlieren Sie Ihre Gelder. Sichern Sie unbedingt Ihre geheime Wiederherstellungsphrase in $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Alle ignorieren" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "in Ihren Einstellungen" }, + "included": { + "message": "einschließlich" + }, "infuraBlockedNotification": { "message": "MetaMask kann sich nicht mit dem Blockchain Host verbinden. Überprüfen Sie mögliche Gründe $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON Datei", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Bewahren Sie eine Erinnerung an Ihre geheime Wiederherstellungsphrase an einem sicheren Ort auf. Wenn Sie sie verlieren, kann Ihnen niemand helfen, sie wiederzubekommen. Schlimmer noch, Sie werden nie wieder Zugang zu Ihrer Wallet haben. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Kontoname" }, @@ -2402,6 +2622,9 @@ "message": "Erfahren Sie, wie Sie $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Erfahren Sie, wie es funktioniert" + }, "learnMore": { "message": "Mehr erfahren" }, @@ -2409,6 +2632,9 @@ "message": "Wollen Sie $1 über Gas?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Erfahren Sie mehr über bewährte Datenschutzpraktiken." + }, "learnMoreKeystone": { "message": "Mehr erfahren" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Link" }, + "linkCentralizedExchanges": { + "message": "Verlinken Sie Ihre Coinbase- oder Binance-Konten, um Kryptos kostenfrei an MetaMask zu übweisen." + }, "links": { "message": "Links" }, @@ -2557,6 +2786,9 @@ "message": "Stellen Sie sicher, dass niemand zuschaut.", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Standard-Datenschutzeinstellungen verwalten" + }, "marketCap": { "message": "Marktkapitalisierung" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Die Schaltfläche Verbindungsstatus zeigt an, ob die Webseite, die Sie besuchen, mit Ihrem aktuell ausgewählten Konto verbunden ist." }, + "metaMetricsIdNotAvailableError": { + "message": "Da Sie sich noch nie für MetaMetrics angemeldet haben, gibt es hier keine Daten zu löschen." + }, "metadataModalSourceTooltip": { "message": "$1 wird auf npm gehostet und $2 ist die einzige Kennung dieses Snaps.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "mehr" }, + "moreAccounts": { + "message": "Über $1 mehr Konten", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "Über $1 mehr Netzwerke", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Sie fügen dieses Netzwerk zu MetaMask hinzu und geben dieser Website die Genehmigung, es zu nutzen." + }, "multipleSnapConnectionWarning": { "message": "$1 möchte $2 Snaps verwenden", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Netzwerkdetails bearbeiten" }, "nativeTokenScamWarningDescription": { - "message": "Dieses Netzwerk passt nicht zu seiner zugehörigen Chain-ID oder seinem Namen. Viele beliebte Tokens verwenden den Namen $1, was sie zu einem Ziel für Betrüger macht. Betrüger könnten Sie dazu verleiten, ihnen im Gegenzug wertvollere Währung zu schicken. Überprüfen Sie alles, bevor Sie fortfahren.", + "message": "Das native Token-Symbol stimmt nicht mit dem erwarteten Symbol des nativen Tokens für das Netzwerk mit der zugehörigen Chain-ID überein. Sie haben $1 eingegeben, während das erwartete Token-Symbol $2 ist. Überprüfen Sie bitte, ob Sie mit der richtigen Chain verbunden sind.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "etwas anderes", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Dies ist ein möglicher Betrug", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Netzwerkdetails" }, + "networkFee": { + "message": "Netzwerkgebühr" + }, "networkIsBusy": { "message": "Das Netzwerk ist ausgelastet. Die Gas-Preise sind hoch und die Schätzungen sind weniger genau." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Netzwerkoptionen" }, + "networkPermissionToast": { + "message": "Netzwerkgenehmigungen aktualisiert" + }, "networkProvider": { "message": "Netzwerkanbieter" }, @@ -2865,15 +3121,26 @@ "message": "Wir können keine Verbindung zu $1 aufbauen.", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Netzwerk zu $1 gewechselt", + "description": "$1 represents the network name" + }, "networkURL": { "message": "Netzwerk-URL" }, "networkURLDefinition": { "message": "Die URL, die für den Zugriff auf dieses Netzwerk verwendet wird." }, + "networkUrlErrorWarning": { + "message": "Angreifer imitieren manchmal Websites, indem sie kleine Änderungen an der Adresse der Website vornehmen. Vergewissern Sie sich, dass Sie mit der beabsichtigten Website interagieren, bevor Sie fortfahren. Punycode-Version: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Netzwerke" }, + "networksSmallCase": { + "message": "Netzwerke" + }, "nevermind": { "message": "Schon gut" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Wir haben unsere Datenschutzrichtlinie aktualisiert" }, + "newRpcUrl": { + "message": "Neue RPC-URL" + }, "newTokensImportedMessage": { "message": "Sie haben $1 erfolgreich importiert.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask ist nicht mit dieser Website verbunden" }, + "noConnectionDescription": { + "message": "Um eine Verbindung zu einer Website herzustellen, suchen und wählen Sie die Schaltfläche „Verbinden“. Beachten Sie, dass MetaMask nur Verbindungen zu Websites auf Web3 herstellen kann" + }, "noConversionRateAvailable": { "message": "Kein Umrechnungskurs verfügbar" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Eigene Nonce" }, + "none": { + "message": "Keine" + }, "notBusy": { "message": "Nicht ausgelastet" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Genehmigungsdetails" }, + "permissionFor": { + "message": "Genehmigung für" + }, + "permissionFrom": { + "message": "Genehmigung von" + }, "permissionRequest": { "message": "Genehmigungsanfrage" }, @@ -3593,6 +3875,14 @@ "message": "Lassen Sie $1 über Ihre MetaMask-Einstellungen auf Ihre bevorzugte Sprache zugreifen. Dies kann verwendet werden, um den Inhalt von $1 in Ihrer Sprache zu lokalisieren und anzuzeigen.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Sehen Sie Informationen wie Ihre bevorzugte Sprache und Fiat-Währung.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Geben Sie $1 in Ihren MetaMask-Einstellungen Zugriff auf Informationen wie die von Ihnen bevorzugte Sprache und Fiat-Währung. Auf diese Weise kann $1 die auf Ihre Präferenzen zugeschnittenen Inhalte anzeigen. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Anzeige eines benutzerdefinierten Bildschirms", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Sie erteilen dem Spender die Genehmigung, diese Menge an Tokens von Ihrem Konto auszugeben." }, + "permittedChainToastUpdate": { + "message": "$1 hat Zugang zu $2." + }, "personalAddressDetected": { "message": "Personalisierte Adresse identifiziert. Bitte füge die Token-Contract-Adresse ein." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Empfangen" }, + "receiveCrypto": { + "message": "Krypto empfangen" + }, + "recipientAddressPlaceholderNew": { + "message": "Öffentliche Adresse (0x) oder Domainname eingeben" + }, "recommendedGasLabel": { "message": "Empfohlen" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Abgelehnt" }, + "rememberSRPIfYouLooseAccess": { + "message": "Denken Sie daran: Sollten Sie Ihre geheime Wiederherstellungsphrase verlieren, verlieren Sie auch den Zugriff auf Ihr Wallet. $1, um diese Wörter sicher zu verwahren, damit Sie jederzeit auf Ihr Geld zugreifen können." + }, + "reminderSet": { + "message": "Erinnerung eingestellt!" + }, "remove": { "message": "Entfernen" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Aufgrund eines Fehlers wurde diese Anfrage nicht vom Sicherheitsanbieter verifiziert. Gehen Sie behutsam vor." }, + "requestingFor": { + "message": "Anfordern für" + }, + "requestingForAccount": { + "message": "Anfordern für $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "Anfragen warten auf Bestätigung" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Seed-Phrase anzeigen" }, + "review": { + "message": "Überprüfen" + }, + "reviewAlert": { + "message": "Benachrichtigung überprüfen" + }, "reviewAlerts": { "message": "Benachrichtigungen überprüfen" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Genehmigung widerrufen" }, + "revokeSimulationDetailsDesc": { + "message": "Sie entziehen einer Person die Genehmigung, Tokens von Ihrem Konto auszugeben." + }, "revokeSpendingCap": { "message": "Ausgabenobergrenze für Ihr $1 widerrufen", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Dieser Drittanbieter kann keine weiteren Ihrer aktuellen oder zukünftigen Tokens ausgeben." }, + "rpcNameOptional": { + "message": "RPC-Name (Optional)" + }, "rpcUrl": { "message": "Neue RPC-URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Sicherheit und Datenschutz" }, + "securityDescription": { + "message": "Verringern Sie das Risiko, sich mit unsicheren Netzwerken zu verbinden und sichern Sie Ihre Konten" + }, + "securityMessageLinkForNetworks": { + "message": "Netzwerk-Betrügereien und Sicherheitsrisiken" + }, + "securityPrivacyPath": { + "message": "Einstellungen > Sicherheit und Datenschutz." + }, "securityProviderPoweredBy": { "message": "Unterstützt durch $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Alle Genehmigungen ansehen", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Details anzeigen" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Wenn Sie nicht die Konten sehen, die Sie erwarten, versuchen Sie, den HD-Pfad oder das aktuell ausgewählte Netzwerk zu ändern." }, + "selectRpcUrl": { + "message": "RPC-URL auswählen" + }, "selectType": { "message": "Typ auswählen" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Erlaubnis für alle erteilen" }, + "setApprovalForAllRedesignedTitle": { + "message": "Auszahlungsanfrage" + }, "setApprovalForAllTitle": { "message": "$1 ohne Ausgabenlimit genehmigen", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Einstellungen" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Die Einstellungen sind für Benutzerfreundlichkeit und Sicherheit optimiert. Sie können diese jederzeit ändern." + }, "settingsSearchMatchingNotFound": { "message": "Keine passenden Ergebnisse gefunden." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Mehr anzeigen" }, + "showNativeTokenAsMainBalance": { + "message": "Natives Token als Hauptsaldo anzeigen" + }, "showNft": { "message": "NFT anzeigen" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Anmelden mit" }, + "simulationApproveHeading": { + "message": "Abheben" + }, + "simulationDetailsApproveDesc": { + "message": "Sie erteilen einer anderen Person die Genehmigung, NFTs von Ihrem Konto abzuheben." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Sie erteilen einer anderen Person die Genehmigung, diesen Betrag von Ihrem Konto auszugeben." + }, "simulationDetailsFiatNotAvailable": { "message": "Nicht verfügbar" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Sie senden" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Sie entziehen einer anderen Person die Genehmigung, NFTs von Ihrem Konto abzuheben." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Sie erteilen einer anderen Person die Genehmigung, NFTs von Ihrem Konto abzuheben." + }, "simulationDetailsTitle": { "message": "Geschätzte Änderungen" }, @@ -4755,7 +5119,7 @@ "message": "Snaps wurden verbunden" }, "snapsNoInsight": { - "message": "Der Snap brachte keine Einsicht." + "message": "Keine Einsichten möglich" }, "snapsPrivacyWarningFirstMessage": { "message": "Sie erkennen an, dass es sich – sofern nicht anders angegeben – bei jedem von Ihnen installierten Snap um einen Drittanbieter-Service handelt, gemäß der in Consensys $1 genannten Definition. Ihre Nutzung von Drittanbieter-Services unterliegt separaten Bedingungen, die vom Anbieter des jeweiligen Drittanbieter-Service festgelegt werden. Consensys empfiehlt keiner bestimmten Person aus irgendeinem bestimmten Grund die Verwendung eines Snaps. Sie nehmen Zugriff auf, verlassen sich auf und verwenden die Dienste Dritter auf eigenes Risiko. Consensys lehnt jede Verantwortung und Haftung für Verluste ab, die sich aus Ihrer Nutzung von Drittanbieter-Diensten ergeben.", @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Hoppla! Etwas ist schiefgelaufen." }, + "sortBy": { + "message": "Sortieren nach" + }, + "sortByAlphabetically": { + "message": "In alphabetischer Reihenfolge (A–Z)" + }, + "sortByDecliningBalance": { + "message": "Abnehmender Saldo ($1 Hoch-Tief)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Quelle" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Spender" }, + "spenderTooltipDesc": { + "message": "Dies ist die Adresse, an die Sie Ihre NFTs abheben können." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Dies ist die Adresse, die Ihre Tokens in Ihrem Namen ausgeben kann." + }, "spendingCap": { "message": "Ausgabenobergrenze" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Ausgabenobergrenze-Anfrage für $1" }, + "spendingCapTooltipDesc": { + "message": "Dies ist die Menge an Tokens, auf die der Spender in Ihrem Namen zugreifen kann." + }, "srpInputNumberOfWords": { "message": "Ich habe eine $1-Wort-Phrase.", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Vorgeschlagen von $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Empfohlenes Währungssymbol:" + }, "suggestedTokenName": { "message": "Vorgeschlagener Name:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Besuchen Sie unser Support Center." }, + "supportMultiRpcInformation": { + "message": "Wir unterstützen nun mehrere RPCs für ein einzelnes Netzwerk. Ihr aktuellster RPC (ferngesteuerter Prozeduraufruf) wurde standardmäßig ausgewählt, um widersprüchliche Informationen aufzulösen." + }, "surveyConversion": { "message": "Nehmen Sie an unserer Umfrage teil" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Die Gas-Gebühren werden geschätzt und werden aufgrund der Komplexität des Netzwerk-Traffics und der Transaktionskomplexität schwanken." }, + "swapGasFeesExplanation": { + "message": "MetaMask verdient kein Geld mit Gas-Gebühren. Bei diesen Gebühren handelt es sich um Schätzwerte, die sich je nach Auslastung des Netzwerks und der Komplexität einer Transaktion ändern können. Erfahren Sie mehr über $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "hier", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Erfahren Sie mehr über Gasgebühren" }, @@ -5186,9 +5583,19 @@ "message": "Gasgebühren werden an Krypto-Miner gezahlt, die Transaktionen im $1-Netzwerk verarbeiten. MetaMask profitiert nicht von den Gasgebühren.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "In diesem Angebot sind die Gas-Gebühren enthalten, indem der gesendete bzw. empfangene Tokenbetrag entsprechend angepasst wird. Sie können ETH in einer separaten Transaktion auf Ihrer Aktivitätsliste erhalten." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Erfahren Sie mehr über Gas-Gebühren" + }, "swapHighSlippage": { "message": "Hohe Slippage" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Enthält Gas und eine MetaMask-Gebühr von $1%", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Enthält eine MetaMask-Gebühr von $1%.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Unsere Nutzungsbedingungen wurden aktualisiert." }, + "testnets": { + "message": "Testnets" + }, "theme": { "message": "Motiv" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Transaktionsgebühr" }, + "transactionFlowNetwork": { + "message": "Netzwerk" + }, "transactionHistoryBaseFee": { "message": "Grundgebühr (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Übertragung" }, + "transferCrypto": { + "message": "Krypto überweisen" + }, "transferFrom": { "message": "Übertragung von" }, + "transferRequest": { + "message": "Überweisungsanfrage" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5766,7 +6185,7 @@ "message": "Bleiben Sie mit Benachrichtigungen auf dem Laufenden darüber, was in Ihrer Wallet passiert." }, "turnOnMetamaskNotificationsMessagePrivacyBold": { - "message": "Einstellungen > Benachrichtigungen." + "message": "Benachrichtigungseinstellungen." }, "turnOnMetamaskNotificationsMessagePrivacyLink": { "message": "Erfahren Sie, wie wir Ihre Privatsphäre bei der Nutzung dieser Funktion schützen." @@ -5844,12 +6263,22 @@ "update": { "message": "Update" }, + "updateEthereumChainConfirmationDescription": { + "message": "Diese Website fordert Sie zur Aktualisierung Ihrer Standard-Netzwerk-URL auf. Sie können die Standardeinstellungen und Netzwerkinformationen jederzeit ändern." + }, + "updateNetworkConfirmationTitle": { + "message": "$1 aktualisieren", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Aktualisieren Sie Ihre Informationen oder" }, "updateRequest": { "message": "Aktualisierungsanfrage" }, + "updatedRpcForNetworks": { + "message": "Netzwerk-RPCs aktualisiert" + }, "uploadDropFile": { "message": "Legen Sie Ihre Datei hier ab" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "unsere Hardware-Wallet-Verbindungsanleitung" }, + "walletProtectedAndReadyToUse": { + "message": "Ihr Wallet ist geschützt und einsatzbereit. Sie finden Ihre geheime Wiederherstellungsphrase unter $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Möchten Sie dieses Netzwerk hinzufügen?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Der Dritte könnte Ihr gesamtes Token-Guthaben ohne weitere Benachrichtigung oder Zustimmung ausgeben. Schützen Sie sich, indem Sie eine niedrigere Ausgabenobergrenze festlegen.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Durch das Einschalten dieser Option können Sie Ethereum-Konten über eine öffentliche Adresse oder einen ENS-Namen ansehen. Für Feedback zu dieser Beta-Funktion füllen Sie bitte diese $1 aus.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Ethereum-Konten ansehen (Beta)" + }, + "watchOutMessage": { + "message": "Vorsicht vor $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Schwach" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Was ist das?" }, + "withdrawing": { + "message": "Auszahlung" + }, "wrongNetworkName": { "message": "Laut unseren Aufzeichnungen stimmt dieser Netzwerkname nicht mit dieser Chain-ID überein." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Ihr Kontostand" }, + "yourBalanceIsAggregated": { + "message": "Ihr Kontostand wird aggregiert" + }, "yourNFTmayBeAtRisk": { "message": "Ihr NFT könnte gefährdet sein" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Wir waren nicht in der Lage, Ihre Transaktion zu stornieren, bevor sie in der Blockchain bestätigt wurde." }, + "yourWalletIsReady": { + "message": "Ihre Wallet ist bereit" + }, "zeroGasPriceOnSpeedUpError": { "message": "Keine Gas-Kosten bei Beschleunigung" } diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 5d2ba61516fa..132265ee4167 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Συνδέστε το πορτοφόλι υλικού μέσω QR" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Η διεύθυνση στο αίτημα σύνδεσης δεν ταιριάζει με τη διεύθυνση του λογαριασμού που χρησιμοποιείτε για να συνδεθείτε." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Επιλέξτε τους λογαριασμούς για τους οποίους θέλετε να λαμβάνετε ειδοποιήσεις:" }, + "accountBalance": { + "message": "Υπόλοιπο λογαριασμού" + }, "accountDetails": { "message": "Στοιχεία λογαριασμού" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Επιλογές λογαριασμού" }, + "accountPermissionToast": { + "message": "Ενημέρωση αδειών λογαριασμού" + }, "accountSelectionRequired": { "message": "Πρέπει να επιλέξετε έναν λογαριασμό!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Συνδεδεμένοι λογαριασμοί" }, + "accountsPermissionsTitle": { + "message": "Δείτε τους λογαριασμούς σας και προτείνετε συναλλαγές" + }, + "accountsSmallCase": { + "message": "λογαριασμοί" + }, "active": { "message": "Ενεργό" }, @@ -180,12 +195,18 @@ "add": { "message": "Προσθήκη" }, + "addACustomNetwork": { + "message": "Προσθήκη προσαρμοσμένου δικτύου" + }, "addANetwork": { "message": "Προσθήκη ενός δικτύου" }, "addANickname": { "message": "Προσθήκη ενός ψευδωνύμου" }, + "addAUrl": { + "message": "Προσθήκη διεύθυνσης URL" + }, "addAccount": { "message": "Προσθήκη λογαριασμού" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Προσθήκη ενός block explorer" }, + "addBlockExplorerUrl": { + "message": "Προσθήκη διεύθυνσης URL του block explorer" + }, "addContact": { "message": "Προσθήκη επαφής" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Προσθέτετε έναν νέο πάροχο RPC για το Ethereum Mainnet" }, + "addEthereumWatchOnlyAccount": { + "message": "Παρακολουθήστε έναν λογαριασμό Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Προσθέστε φίλους και διευθύνσεις που εμπιστεύεστε" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Προσθήκη δικτύου" }, + "addNetworkConfirmationTitle": { + "message": "Προσθήκη $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Προσθήκη νέου λογαριασμού Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Η διεύθυνση αντιγράφηκε!" }, + "addressMismatch": { + "message": "Αναντιστοιχία διεύθυνσης ιστότοπου" + }, + "addressMismatchOriginal": { + "message": "Τρέχουσα διεύθυνση URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Έκδοση Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Σύνθετες" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Το τέλος προτεραιότητας (γνωστό και ως “miner tip”) πηγαίνει άμεσα στους miner και τους ενθαρρύνει να δώσουν προτεραιότητα στη συναλλαγή σας." }, + "aggregatedBalancePopover": { + "message": "Αυτό αντικατοπτρίζει την αξία όλων των tokens που έχετε στην κατοχή σας σε ένα συγκεκριμένο δίκτυο. Εάν προτιμάτε να βλέπετε αυτή την αξία σε ETH ή άλλα νομίσματα, μεταβείτε στις $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Συμφωνώ με το $1 του MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Αυτό μπορεί να αλλάξει στις \"Ρυθμίσεις > Ειδοποιήσεις\"" }, + "alertMessageAddressMismatchWarning": { + "message": "Οι εισβολείς μερικές φορές αντιγράφουν ιστότοπους κάνοντας μικρές αλλαγές στη διεύθυνση του ιστότοπου. Βεβαιωθείτε ότι αλληλεπιδράτε με τον ιστότοπο που θέλετε πριν συνεχίσετε." + }, "alertMessageGasEstimateFailed": { "message": "Δεν μπορούμε να παράσχουμε τα τέλη με ακρίβεια και αυτή η εκτίμηση μπορεί να είναι υψηλή. Σας προτείνουμε να εισάγετε ένα προσαρμοσμένο όριο τελών συναλλαγών, αλλά υπάρχει κίνδυνος η συναλλαγή να αποτύχει και πάλι." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Για να συνεχίσετε με αυτή τη συναλλαγή, θα πρέπει να αυξήσετε το όριο των τελών συναλλαγών σε 21000 ή περισσότερο." }, + "alertMessageInsufficientBalance2": { + "message": "Δεν έχετε αρκετά ETH στον λογαριασμό σας για να πληρώσετε τα τέλη δικτύου." + }, "alertMessageNetworkBusy": { "message": "Οι τιμές των τελών συναλλαγών είναι υψηλές και οι εκτιμήσεις είναι λιγότερο ακριβείς." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Επιλογές περιουσιακών στοιχείων" }, + "assets": { + "message": "Περιουσιακά στοιχεία" + }, + "assetsDescription": { + "message": "Αυτόματος εντοπισμός tokens στο πορτοφόλι σας, εμφάνιση NFT και ομαδοποιημένες ενημερώσεις υπολοίπων λογαριασμών" + }, "attemptSendingAssets": { "message": "Ενδέχεται να χάσετε τα περιουσιακά σας στοιχεία εάν προσπαθήσετε να τα στείλετε από άλλο δίκτυο. Μεταφέρετε κεφάλαια με ασφάλεια μεταξύ δικτύων χρησιμοποιώντας μια διασύνδεση." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Μην στείλετε χωρίς διασύνδεση" }, + "bridgeFrom": { + "message": "Γέφυρα από" + }, + "bridgeSelectNetwork": { + "message": "Επιλέξτε δίκτυο" + }, + "bridgeTo": { + "message": "Γέφυρα σε" + }, "browserNotSupported": { "message": "Το Πρόγραμμα Περιήγησής σας δεν υποστηρίζεται..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Επιβεβαιώστε τη Μυστική Φράση Ανάκτησης" }, + "confirmTitleApproveTransaction": { + "message": "Αίτημα χορήγησης άδειας" + }, + "confirmTitleDeployContract": { + "message": "Ανάπτυξη συμβολαίου" + }, + "confirmTitleDescApproveTransaction": { + "message": "Αυτός ο ιστότοπος ζητάει άδεια για να αποσύρει τα NFT σας" + }, + "confirmTitleDescDeployContract": { + "message": "Αυτός ο ιστότοπος θέλει να αναπτύξετε ένα συμβόλαιο" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Αυτός ο ιστότοπος ζητάει άδεια για την απόσυρση των tokens σας" + }, "confirmTitleDescPermitSignature": { "message": "Αυτός ο ιστότοπος ζητάει άδεια για να δαπανήσει τα tokens σας." }, "confirmTitleDescSIWESignature": { "message": "Ένας ιστότοπος θέλει να συνδεθείτε για να αποδείξετε ότι είστε ο κάτοχος αυτού του λογαριασμού." }, + "confirmTitleDescSign": { + "message": "Ελέγξτε τις λεπτομέρειες του αιτήματος πριν επιβεβαιώσετε." + }, "confirmTitlePermitTokens": { "message": "Αίτημα ανώτατου ορίου δαπανών" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Κατάργηση άδειας" + }, "confirmTitleSIWESignature": { "message": "Αίτημα σύνδεσης" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Κατάργηση άδειας" + }, "confirmTitleSignature": { "message": "Αίτημα υπογραφής" }, "confirmTitleTransaction": { "message": "Αίτημα συναλλαγής" }, + "confirmationAlertModalDetails": { + "message": "Για να προστατεύσετε τα περιουσιακά σας στοιχεία και τις πληροφορίες σύνδεσης, σας προτείνουμε να απορρίψετε το αίτημα." + }, + "confirmationAlertModalTitle": { + "message": "Αυτό το αίτημα είναι ύποπτο" + }, "confirmed": { "message": "Επιβεβαιωμένο" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Εντοπίσαμε έναν παράξενο χαρακτήρα στο όνομα ENS. Ελέγξτε το όνομα ENS για να αποφύγετε μια πιθανή απάτη." }, + "congratulations": { + "message": "Συγχαρητήρια!" + }, "connect": { "message": "Σύνδεση" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Συνδεδεμένοι ιστότοποι" }, + "connectedSitesAndSnaps": { + "message": "Συνδεδεμένοι ιστότοποι και Snaps" + }, "connectedSitesDescription": { "message": "Το $1 είναι συνδεδεμένο σε αυτές τις ιστοσελίδες. Μπορούν να δουν τη διεύθυνση του λογαριασμού σας.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "Το MetaMask είναι συνδεδεμένο σε αυτόν τον ιστότοπο, αλλά δεν έχουν συνδεθεί ακόμα λογαριασμοί" }, + "connectedSnaps": { + "message": "Συνδεδεμένα Snaps" + }, + "connectedWithAccount": { + "message": "$1 λογαριασμοί συνδεδεμένοι", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Συνδέεται με $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Σύνδεση" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Σύνδεση στο δίκτυο δοκιμών Sepolia" }, + "connectionDescription": { + "message": "Αυτός ο ιστότοπος θέλει να" + }, "connectionFailed": { "message": "Η σύνδεση απέτυχε" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Αντιγραφή διεύθυνσης στο πρόχειρο" }, + "copyAddressShort": { + "message": "Αντιγραφή διεύθυνσης" + }, "copyPrivateKey": { "message": "Αντιγραφή ιδιωτικού κλειδιού" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "Προεπιλεγμένη διεύθυνση URL RPC" }, + "defaultSettingsSubTitle": { + "message": "Το MetaMask χρησιμοποιεί προεπιλεγμένες ρυθμίσεις για την καλύτερη δυνατή εξισορρόπηση της ασφάλειας και της ευκολίας χρήσης. Αλλάξτε αυτές τις ρυθμίσεις για να ενισχύσετε ακόμη περισσότερο το απόρρητό σας." + }, + "defaultSettingsTitle": { + "message": "Προεπιλεγμένες ρυθμίσεις απορρήτου" + }, "delete": { "message": "Διαγραφή" }, "deleteContact": { "message": "Διαγραφή επαφής" }, + "deleteMetaMetricsData": { + "message": "Διαγραφή δεδομένων από το MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Αυτό θα διαγράψει τα ιστορικά δεδομένα από το MetaMetrics που σχετίζονται με τη χρήση σας σε αυτή τη συσκευή. Το πορτοφόλι και οι λογαριασμοί σας θα παραμείνουν ακριβώς όπως είναι τώρα μετά τη διαγραφή αυτών των δεδομένων. Η διαδικασία αυτή μπορεί να διαρκέσει έως και 30 ημέρες. Δείτε την $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Αυτό το αίτημα δεν μπορεί να ολοκληρωθεί αυτή τη στιγμή λόγω προβλήματος του διακομιστή στο σύστημα ανάλυσης, προσπαθήστε ξανά αργότερα" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Δεν μπορούμε να διαγράψουμε αυτά τα δεδομένα προς το παρόν" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Πρόκειται να αφαιρέσουμε όλα τα δεδομένα σας από το MetaMetrics. Είστε σίγουροι;" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Διαγραφή των δεδομένων από το MetaMetrics;" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Ξεκινήσατε αυτή την ενέργεια στις $1. Αυτή η διαδικασία μπορεί να διαρκέσει έως και 30 ημέρες. Δείτε την $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Εάν διαγράψετε αυτό το δίκτυο, θα πρέπει να το προσθέσετε ξανά για να δείτε τα περιουσιακά σας στοιχεία σε αυτό το δίκτυο" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Κατάθεση" }, + "depositCrypto": { + "message": "Κατάθεση κρυπτονομισμάτων από άλλο λογαριασμό με διεύθυνση πορτοφολιού ή κωδικό QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Εξαιτίας των ενημερώσεων στο σύστημα Ethereum, το δίκτυο δοκιμών Goerli θα καταργηθεί σύντομα." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "λογαριασμοί" }, + "disconnectAllDescriptionText": { + "message": "Εάν αποσυνδεθείτε από αυτόν τον ιστότοπο, θα πρέπει να επανασυνδέσετε τους λογαριασμούς και τα δίκτυά σας για να χρησιμοποιήσετε ξανά αυτόν τον ιστότοπο." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Αυτό θα σας αποσυνδέσει από αυτόν το ιστότοπο" + }, "disconnectPrompt": { "message": "Αποσύνδεση $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Επεξεργασία ψευδώνυμου" }, + "editAccounts": { + "message": "Επεξεργασία λογαριασμών" + }, "editAddressNickname": { "message": "Επεξεργασία διεύθυνσης ψευδώνυμου" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "επεξεργασία του αρχικού δικτύου" }, + "editNetworksTitle": { + "message": "Επεξεργασία δικτύων" + }, "editNonceField": { "message": "Επεξεργασία Nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Επεξεργασία αδειών" }, + "editPermissions": { + "message": "Επεξεργασία αδειών" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Επεξεργασία τελών επίσπευσης συναλλαγής" }, + "editSpendingCap": { + "message": "Επεξεργασία ανώτατου ορίου δαπανών" + }, + "editSpendingCapAccountBalance": { + "message": "Υπόλοιπο λογαριασμού: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Εισαγάγετε το ποσό που αισθάνεστε άνετα να δαπανήσει για λογαριασμό σας." + }, + "editSpendingCapError": { + "message": "Το ανώτατο όριο δαπανών δεν μπορεί να υπερβαίνει τα $1 δεκαδικά ψηφία. Αφαιρέστε τα δεκαδικά ψηφία για να συνεχίσετε." + }, "enableAutoDetect": { "message": " Ενεργοποίηση αυτόματου εντοπισμού" }, @@ -1650,7 +1829,7 @@ "message": "Αίτημα δημόσιου κλειδιού κρυπτογράφησης" }, "endpointReturnedDifferentChainId": { - "message": "Η διεύθυνση URL του RPC που εισαγάγατε επέστρεψε ένα διαφορετικό αναγνωριστικό αλυσίδας ($1). Ενημερώστε το αναγνωριστικό αλυσίδας ώστε να ταιριάζει με την διεύθυνση URL του RPC του δικτύου που προσπαθείτε να προσθέσετε.", + "message": "Η διεύθυνση URL του RPC που εισαγάγατε επέστρεψε ένα διαφορετικό αναγνωριστικό αλυσίδας ($1).", "description": "$1 is the return value of eth_chainId from an RPC endpoint" }, "enhancedTokenDetectionAlertMessage": { @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Η αναζήτηση ENS απέτυχε." }, + "enterANameToIdentifyTheUrl": { + "message": "Εισαγάγετε ένα όνομα για τον προσδιορισμό της διεύθυνσης URL" + }, "enterANumber": { "message": "Εισάγετε έναν αριθμό" }, + "enterChainId": { + "message": "Εισαγάγετε το αναγνωριστικό αλυσίδας" + }, "enterCustodianToken": { "message": "Πληκτρολογήστε το token $1 ή προσθέστε ένα νέο token" }, "enterMaxSpendLimit": { "message": "Εισάγετε το μέγιστο όριο δαπανών" }, + "enterNetworkName": { + "message": "Εισαγάγετε το όνομα δικτύου" + }, "enterOptionalPassword": { "message": "Πληκτρολογήστε προαιρετικό κωδικό πρόσβασης" }, "enterPasswordContinue": { "message": "Πληκτρολογήστε τον κωδικό πρόσβασης για να συνεχίσετε" }, + "enterRpcUrl": { + "message": "Εισαγάγετε τη διεύθυνση URL του RPC" + }, + "enterSymbol": { + "message": "Εισάγετε το σύμβολο" + }, "enterTokenNameOrAddress": { "message": "Πληκτρολογήστε το όνομα του token ή επικολλήστε τη διεύθυνση" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Πειραματικά" }, + "exportYourData": { + "message": "Εξαγωγή των δεδομένων σας" + }, + "exportYourDataButton": { + "message": "Λήψη" + }, + "exportYourDataDescription": { + "message": "Μπορείτε να εξάγετε δεδομένα όπως οι επαφές και οι προτιμήσεις σας." + }, "extendWalletWithSnaps": { "message": "Προσαρμόστε την εμπειρία του πορτοφολιού σας.", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Αυτό το τέλος συναλλαγής έχει προταθεί από το $1. Η παράκαμψη μπορεί να προκαλέσει προβλήματα με τη συναλλαγή σας. Εάν έχετε απορίες, επικοινωνήστε με $1.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Τέλη συναλλαγών" + }, "gasIsETH": { "message": "Τέλη συναλλαγής $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Κάτι πήγε στραβά...." }, + "generalDescription": { + "message": "Συγχρονισμός ρυθμίσεων σε όλες τις συσκευές, επιλογή προτιμήσεων δικτύου και παρακολούθηση δεδομένων των tokens" + }, "genericExplorerView": { "message": "Προβολή λογαριασμού σε $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "Αναγνωριστικό" }, + "ifYouGetLockedOut": { + "message": "Εάν δεν μπορείτε να χρησιμοποιήσετε την εφαρμογή ή αποκτήσατε νέα συσκευή, θα χάσετε τα κεφάλαιά σας. Βεβαιωθείτε ότι έχετε δημιουργήσει αντίγραφα ασφαλείας της Μυστικής σας Φράσης Ανάκτησης στο $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Αγνόηση όλων" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "στις Ρυθμίσεις σας" }, + "included": { + "message": "περιλαμβάνεται" + }, "infuraBlockedNotification": { "message": "Το MetaMask δεν μπορεί να συνδεθεί με τον διακομιστή του blockchain. Εξετάστε τους πιθανούς λόγους $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "Αρχείο JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Κρατήστε μια υπενθύμιση της Μυστικής σας Φράσης Ανάκτησης σε ασφαλές μέρος. Εάν την χάσετε, κανείς δεν μπορεί να σας βοηθήσει να την επαναφέρετε. Ακόμα χειρότερα, δεν θα έχετε ποτέ ξανά πρόσβαση στο πορτοφόλι σας. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Όνομα λογαριασμού" }, @@ -2402,6 +2622,9 @@ "message": "Μάθετε πώς να $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Μάθετε πως" + }, "learnMore": { "message": "μάθετε περισσότερα" }, @@ -2409,6 +2632,9 @@ "message": "Θέλετε να $1 για το τέλος συναλλαγής;", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": " Μάθετε περισσότερα για τις βέλτιστες πρακτικές απορρήτου." + }, "learnMoreKeystone": { "message": "Μάθετε περισσότερα" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Σύνδεσμος" }, + "linkCentralizedExchanges": { + "message": "Συνδέστε τους λογαριασμούς σας στο Coinbase ή Binance για να μεταφέρετε κρυπτονομίσματα στο MetaMask δωρεάν." + }, "links": { "message": "Σύνδεσμοι" }, @@ -2557,6 +2786,9 @@ "message": "Βεβαιωθείτε ότι κανείς δεν κοιτάει", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Διαχείριση προεπιλεγμένων ρυθμίσεων απορρήτου" + }, "marketCap": { "message": "Κεφαλαιοποίηση αγοράς" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Το κουμπί Κατάστασης Σύνδεσης δείχνει αν ο ιστότοπος που επισκέπτεστε είναι συνδεδεμένος με τον τρέχοντα επιλεγμένο λογαριασμό σας." }, + "metaMetricsIdNotAvailableError": { + "message": "Εφόσον δεν έχετε επιλέξει ποτέ το MetaMetrics, δεν υπάρχουν δεδομένα προς διαγραφή εδώ." + }, "metadataModalSourceTooltip": { "message": "Το $1 φιλοξενείται στο npm και το $2 είναι το μοναδικό αναγνωριστικό αυτού του Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "περισσότερα" }, + "moreAccounts": { + "message": "+ $1 περισσότεροι λογαριασμοί", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 περισσότερα δίκτυα", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Προσθέτετε αυτό το δίκτυο στο MetaMask και δίνετε σε αυτόν τον ιστότοπο την άδεια να το χρησιμοποιεί." + }, "multipleSnapConnectionWarning": { "message": "Το $1 θέλει να χρησιμοποιήσει $2 Snaps", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Επεξεργασία λεπτομερειών δικτύου" }, "nativeTokenScamWarningDescription": { - "message": "Αυτό το δίκτυο δεν ταιριάζει με το αναγνωριστικό ή το όνομα της σχετικής αλυσίδας. Πολλά δημοφιλή tokens χρησιμοποιούν το όνομα $1, καθιστώντας το στόχο για απάτες. Οι απατεώνες μπορεί να σας ξεγελάσουν για να τους στείλετε πιο πολύτιμα νομίσματα σε αντάλλαγμα. Επαληθεύστε τα πάντα προτού συνεχίσετε.", + "message": "Το σύμβολο του εγγενούς token δεν ταιριάζει με το αναμενόμενο σύμβολο του εγγενούς token για το δίκτυο με το σχετικό αναγνωριστικό αλυσίδας. Έχετε εισαγάγει $1 ενώ το αναμενόμενο σύμβολο του token είναι $2. Επαληθεύστε ότι έχετε συνδεθεί στη σωστή αλυσίδα.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "κάτι άλλο", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Πρόκειται για πιθανή απάτη", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Λεπτομέρειες Δικτύου" }, + "networkFee": { + "message": "Τέλη δικτύου" + }, "networkIsBusy": { "message": "Το δίκτυο είναι απασχολημένο. Τα τέλη συναλλαγής είναι υψηλά και οι εκτιμήσεις λιγότερο ακριβείς." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Επιλογές δικτύου" }, + "networkPermissionToast": { + "message": "Ενημέρωση αδειών δικτύου" + }, "networkProvider": { "message": "Πάροχος δικτύου" }, @@ -2865,15 +3121,26 @@ "message": "Δεν μπορούμε να συνδεθούμε στο $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Το δίκτυο άλλαξε σε $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "Διεύθυνση URL του δικτύου" }, "networkURLDefinition": { "message": "Η διεύθυνση URL που χρησιμοποιείται για την πρόσβαση σε αυτό το δίκτυο." }, + "networkUrlErrorWarning": { + "message": "Οι εισβολείς μερικές φορές αντιγράφουν ιστότοπους κάνοντας μικρές αλλαγές στη διεύθυνση του ιστότοπου. Βεβαιωθείτε ότι αλληλεπιδράτε με τον ιστότοπο που θέλετε πριν συνεχίσετε. Έκδοση Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Δίκτυα" }, + "networksSmallCase": { + "message": "δίκτυα" + }, "nevermind": { "message": "Δεν πειράζει" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Ενημερώσαμε την πολιτική απορρήτου μας" }, + "newRpcUrl": { + "message": "Νέα διεύθυνση URL του RPC" + }, "newTokensImportedMessage": { "message": "Έχετε εισάγει με επιτυχία το $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "Το MetaMask δεν συνδέεται με αυτόν τον ιστότοπο" }, + "noConnectionDescription": { + "message": "Για να συνδεθείτε σε έναν ιστότοπο, βρείτε και επιλέξτε το κουμπί \"Σύνδεση\". Να θυμάστε ότι το MetaMask μπορεί να συνδεθεί μόνο με ιστότοπους στο web3" + }, "noConversionRateAvailable": { "message": "Δεν υπάρχει διαθέσιμη ισοτιμία μετατροπής" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Προσαρμοσμένο Nonce" }, + "none": { + "message": "Κανένα" + }, "notBusy": { "message": "Δεν είναι απασχολημένο" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Λεπτομέρειες άδειας χρήσης" }, + "permissionFor": { + "message": "Άδεια για" + }, + "permissionFrom": { + "message": "Άδεια από" + }, "permissionRequest": { "message": "Αίτημα άδειας" }, @@ -3593,6 +3875,14 @@ "message": "Επιτρέψτε στο $1 να έχει πρόσβαση στη γλώσσα προτίμησής σας από τις ρυθμίσεις του MetaMask. Αυτό μπορεί να χρησιμεύει για την τοπική προσαρμογή και την εμφάνιση του περιεχομένου του $1 στη γλώσσα σας.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Δείτε πληροφορίες όπως η προτιμώμενη γλώσσα και το νόμισμα.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Επιτρέψτε στο $1 να έχει πρόσβαση σε πληροφορίες όπως η προτιμώμενη γλώσσα και το νόμισμα στις ρυθμίσεις του MetaMask. Αυτό βοηθά το $1 να εμφανίζει περιεχόμενο προσαρμοσμένο στις προτιμήσεις σας. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Εμφάνιση προσαρμοσμένης οθόνης", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Δίνετε στον διαθέτη την άδεια να δαπανήσει τα tokens από τον λογαριασμό σας." }, + "permittedChainToastUpdate": { + "message": "Το $1 έχει πρόσβαση στο $2." + }, "personalAddressDetected": { "message": "Η προσωπική διεύθυνση εντοπίστηκε. Καταχωρίστε τη διεύθυνση συμβολαίου του token." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Λήψη" }, + "receiveCrypto": { + "message": "Λάβετε κρυπτονομίσματα" + }, + "recipientAddressPlaceholderNew": { + "message": "Εισαγάγετε τη δημόσια διεύθυνση (0x) ή το όνομα τομέα" + }, "recommendedGasLabel": { "message": "Προτεινόμενο" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Απορρίφθηκε" }, + "rememberSRPIfYouLooseAccess": { + "message": "Να θυμάστε, αν χάσετε τη Μυστική Φράση Ανάκτησης, δεν θα έχετε πρόσβαση στο πορτοφόλι σας. $1 για να κρατήσετε αυτό το σύνολο λέξεων ασφαλές, ώστε να έχετε πάντα πρόσβαση στα κεφάλαιά σας." + }, + "reminderSet": { + "message": "Ορισμός υπενθύμισης!" + }, "remove": { "message": "Κατάργηση" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Λόγω σφάλματος, αυτό το αίτημα δεν επαληθεύτηκε από τον πάροχο ασφαλείας. Προχωρήστε με προσοχή." }, + "requestingFor": { + "message": "Ζητήθηκε για" + }, + "requestingForAccount": { + "message": "Ζητήθηκε για $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "αιτήματα που περιμένουν να επιβεβαιωθούν" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Αποκάλυψη φράσης ανάκτησης" }, + "review": { + "message": "Επανέλεγχος" + }, + "reviewAlert": { + "message": "Ειδοποίηση επανέλεγχου" + }, "reviewAlerts": { "message": "Έλεγχος ειδοποιήσεων" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Ανάκληση άδειας" }, + "revokeSimulationDetailsDesc": { + "message": "Αφαιρείτε την άδεια κάποιου να ξοδεύει tokens από τον λογαριασμό σας." + }, "revokeSpendingCap": { "message": "Ανάκληση του ανώτατου ορίου δαπανών για το $1", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Αυτός ο τρίτος δεν θα μπορεί να ξοδέψει άλλα από τα τρέχοντα ή μελλοντικά σας tokens." }, + "rpcNameOptional": { + "message": "Όνομα RPC (προαιρετικά)" + }, "rpcUrl": { "message": "Νέα διεύθυνση URL του RPC" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Ασφάλεια και απόρρητο" }, + "securityDescription": { + "message": "Μειώστε τις πιθανότητες σύνδεσης σε μη ασφαλή δίκτυα και προστατέψτε τους λογαριασμούς σας" + }, + "securityMessageLinkForNetworks": { + "message": "διαδικτυακές απάτες και κίνδυνοι ασφαλείας" + }, + "securityPrivacyPath": { + "message": "Ρυθμίσεις > Ασφάλεια & Απόρρητο." + }, "securityProviderPoweredBy": { "message": "Με την υποστήριξη του $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Δείτε όλες τις άδειες", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Δείτε λεπτομέρειες" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Αν δεν βλέπετε τους λογαριασμούς που περιμένετε, δοκιμάστε να αλλάξετε τη διαδρομή HD ή το τρέχον επιλεγμένο δίκτυο." }, + "selectRpcUrl": { + "message": "Επιλέξτε την διεύθυνση URL του RPC" + }, "selectType": { "message": "Επιλέξτε Τύπο" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Ρύθμιση έγκρισης για όλους" }, + "setApprovalForAllRedesignedTitle": { + "message": "Αίτημα ανάληψης" + }, "setApprovalForAllTitle": { "message": "Έγκριση $1 χωρίς όριο δαπανών", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Ρυθμίσεις" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Οι ρυθμίσεις έχουν βελτιστοποιηθεί για ευκολία χρήσης και ασφάλεια. Μπορείτε να τις αλλάξετε ανά πάσα στιγμή." + }, "settingsSearchMatchingNotFound": { "message": "Δε βρέθηκαν αποτελέσματα που να ταιριάζουν." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Εμφάνιση περισσότερων" }, + "showNativeTokenAsMainBalance": { + "message": "Εμφάνιση των εγγενών tokens ως κύριο υπόλοιπο" + }, "showNft": { "message": "Εμφάνιση των NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Σύνδεση με" }, + "simulationApproveHeading": { + "message": "Ανάληψη" + }, + "simulationDetailsApproveDesc": { + "message": "Δίνετε σε κάποιον άλλο την άδεια να κάνει ανάληψη τα NFT από τον λογαριασμό σας." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Δίνετε σε κάποιον άλλον την άδεια να ξοδέψει αυτό το ποσό από τον λογαριασμό σας." + }, "simulationDetailsFiatNotAvailable": { "message": "Μη διαθέσιμο" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Στέλνετε" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Αφαιρείτε την άδεια από κάποιον άλλο να αποσύρει NFT από τον λογαριασμό σας." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Δίνετε την άδεια σε κάποιον άλλο να κάνει ανάληψη NFT από τον λογαριασμό σας." + }, "simulationDetailsTitle": { "message": "Εκτιμώμενες αλλαγές" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Ουπς! Κάτι πήγε στραβά." }, + "sortBy": { + "message": "Ταξινόμηση κατά" + }, + "sortByAlphabetically": { + "message": "Αλφαβητικά (Α-Ω)" + }, + "sortByDecliningBalance": { + "message": "Φθίνουσα απόσβεση ($1 υψηλό-χαμηλό)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Πηγή" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Διαθέτης" }, + "spenderTooltipDesc": { + "message": "Αυτή είναι η διεύθυνση που θα μπορεί να αποσύρει τα NFT σας." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Αυτή είναι η διεύθυνση που θα μπορεί να ξοδεύει τα tokens για λογαριασμό σας." + }, "spendingCap": { "message": "Ανώτατο όριο δαπανών" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Αίτημα ανώτατου ορίου δαπανών για το $1" }, + "spendingCapTooltipDesc": { + "message": "Αυτό είναι το ποσό των tokens που θα μπορεί να έχει πρόσβαση ο διαθέτης για λογαριασμό σας." + }, "srpInputNumberOfWords": { "message": "Έχω μια φράση $1 λέξεων", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Προτείνεται από $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Προτεινόμενο σύμβολο νομίσματος:" + }, "suggestedTokenName": { "message": "Προτεινόμενο όνομα:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Επισκεφθείτε το Κέντρο Υποστήριξής μας" }, + "supportMultiRpcInformation": { + "message": "Υποστηρίζουμε πλέον πολλαπλά RPC για ένα μόνο δίκτυο. Το πιο πρόσφατο RPC έχει επιλεχθεί ως το προεπιλεγμένο για την επίλυση αντικρουόμενων πληροφοριών." + }, "surveyConversion": { "message": "Πάρτε μέρος στην έρευνά μας" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Τα τέλη συναλλαγών είναι ενδεικτικά και θα αυξομειώνονται ανάλογα με την κίνηση του δικτύου και την πολυπλοκότητα των συναλλαγών." }, + "swapGasFeesExplanation": { + "message": "Το MetaMask δεν κερδίζει χρήματα από τα τέλη συναλλαγών. Αυτές οι χρεώσεις είναι εκτιμήσεις και μπορούν να αλλάξουν ανάλογα με το πόσο απασχολημένο είναι το δίκτυο και πόσο περίπλοκη είναι μια συναλλαγή. Μάθετε περισσότερα $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "εδώ", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Μάθετε περισσότερα σχετικά με τα τέλη συναλλαγών" }, @@ -5186,9 +5583,19 @@ "message": "Τα τέλη συναλλαγών καταβάλλονται σε κρυπτονομίσματα στους αναλυτές που επεξεργάζονται συναλλαγές στο δίκτυο $1. Το MetaMask δεν επωφελείται από τα τέλη συναλλαγών.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Αυτή η τιμή ενσωματώνει τα τέλη συναλλαγών προσαρμόζοντας το ποσό των tokens που αποστέλλονται ή λαμβάνονται. Μπορείτε να λάβετε ETH σε ξεχωριστή συναλλαγή στη λίστα δραστηριοτήτων σας." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Μάθετε περισσότερα για τα τέλη συναλλαγών" + }, "swapHighSlippage": { "message": "Υψηλή ολίσθηση" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Περιλαμβάνει τα τέλη και μια χρέωση $1% του MetaMask", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Περιλαμβάνει μια χρέωση $1% στο MetaMask.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Οι Όροι Χρήσης μας έχουν ενημερωθεί" }, + "testnets": { + "message": "Testnets" + }, "theme": { "message": "Θέμα" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Χρέωση συναλλαγής" }, + "transactionFlowNetwork": { + "message": "Δίκτυο" + }, "transactionHistoryBaseFee": { "message": "Βασική χρέωση (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Μεταφορά" }, + "transferCrypto": { + "message": "Μεταφορά κρυπτονομισμάτων" + }, "transferFrom": { "message": "Μεταφορά από" }, + "transferRequest": { + "message": "Μεταβίβαση αιτήματος" + }, "trillionAbbreviation": { "message": "Τ", "description": "Shortened form of 'trillion'" @@ -5766,7 +6185,7 @@ "message": "Μείνετε ενήμεροι για ό,τι συμβαίνει στο πορτοφόλι σας με τις ειδοποιήσεις." }, "turnOnMetamaskNotificationsMessagePrivacyBold": { - "message": "Ρυθμίσεις > Ειδοποιήσεις." + "message": "ρυθμίσεις ειδοποιήσεων." }, "turnOnMetamaskNotificationsMessagePrivacyLink": { "message": "Μάθετε πώς προστατεύουμε το απόρρητό σας κατά τη χρήση αυτής της λειτουργίας." @@ -5844,12 +6263,22 @@ "update": { "message": "Ενημέρωση" }, + "updateEthereumChainConfirmationDescription": { + "message": "Αυτός ο ιστότοπος ζητά να ενημερώσει την προεπιλεγμένη διεύθυνση URL του δικτύου σας. Μπορείτε να επεξεργαστείτε τις προεπιλογές και τις πληροφορίες δικτύου ανά πάσα στιγμή." + }, + "updateNetworkConfirmationTitle": { + "message": "Ενημέρωση του $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Ενημερώστε τα στοιχεία σας ή" }, "updateRequest": { "message": "Αίτημα ενημέρωσης" }, + "updatedRpcForNetworks": { + "message": "Ενημέρωση του RPC δικτύου" + }, "uploadDropFile": { "message": "Αφήστε το αρχείο σας εδώ" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "ο οδηγός μας σύνδεσης πορτοφολιού υλικού" }, + "walletProtectedAndReadyToUse": { + "message": "Το πορτοφόλι σας είναι προστατευμένο και έτοιμο προς χρήση. Μπορείτε να βρείτε τη Μυστική σας Φράση Ανάκτησης στο $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Θέλετε να προσθέσετε αυτό το δίκτυο;" }, @@ -5991,6 +6424,17 @@ "message": "$1 Ο τρίτος θα μπορούσε να ξοδέψει ολόκληρο το υπόλοιπο των tokens σας χωρίς περαιτέρω ειδοποίηση ή συγκατάθεση. Προστατέψτε τον εαυτό σας προσαρμόζοντας ένα χαμηλότερο όριο δαπανών.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Η ενεργοποίηση αυτής της επιλογής θα σας δώσει τη δυνατότητα να παρακολουθείτε λογαριασμούς Ethereum μέσω μιας δημόσιας διεύθυνσης ή ενός ονόματος ENS. Για σχόλια σχετικά με αυτή τη λειτουργία Beta συμπληρώστε αυτή την $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Παρακολούθηση λογαριασμών Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Προσοχή στο $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Αδύναμο" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Τι είναι αυτό;" }, + "withdrawing": { + "message": "Ανάληψη" + }, "wrongNetworkName": { "message": "Σύμφωνα με τα αρχεία μας, το όνομα του δικτύου ενδέχεται να μην αντιστοιχεί με αυτό το αναγνωριστικό αλυσίδας." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Το υπόλοιπό σας" }, + "yourBalanceIsAggregated": { + "message": "Το υπόλοιπό σας είναι συγκεντρωτικό" + }, "yourNFTmayBeAtRisk": { "message": "Τα NFT μπορεί να κινδυνεύουν" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Δεν μπορέσαμε να ακυρώσουμε τη συναλλαγή σας πριν επιβεβαιωθεί από το blockchain." }, + "yourWalletIsReady": { + "message": "Το πορτοφόλι σας είναι έτοιμο" + }, "zeroGasPriceOnSpeedUpError": { "message": "Μηδενική τιμή συναλλαγών για επίσπευση" } diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 604396f2c6eb..746c099ae75e 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Conecte su monedero físico QR" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "La dirección de la solicitud de inicio de sesión no coincide con la dirección de la cuenta que está utilizando para iniciar sesión." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Seleccione las cuentas sobre las que desea recibir notificaciones:" }, + "accountBalance": { + "message": "Saldo de la cuenta" + }, "accountDetails": { "message": "Detalles de la cuenta" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Opciones de la cuenta" }, + "accountPermissionToast": { + "message": "Se actualizaron los permisos de la cuenta" + }, "accountSelectionRequired": { "message": "Debe seleccionar una cuenta." }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Cuentas conectadas" }, + "accountsPermissionsTitle": { + "message": "Ver sus cuentas y sugerir transacciones" + }, + "accountsSmallCase": { + "message": "cuentas" + }, "active": { "message": "Activo" }, @@ -180,12 +195,18 @@ "add": { "message": "Agregar" }, + "addACustomNetwork": { + "message": "Agregar una red personalizada" + }, "addANetwork": { "message": "Agregar una red" }, "addANickname": { "message": "Añadir un apodo" }, + "addAUrl": { + "message": "Agregar una URL" + }, "addAccount": { "message": "Añadir cuenta" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Agregar un explorador de bloque" }, + "addBlockExplorerUrl": { + "message": "Agregar una URL del explorador de bloques" + }, "addContact": { "message": "Agregar contacto" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Va a agregar un nuevo proveedor de RPC para la red principal de Ethereum" }, + "addEthereumWatchOnlyAccount": { + "message": "Observar una cuenta Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Agregue amigos y direcciones de confianza" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Agregar red" }, + "addNetworkConfirmationTitle": { + "message": "Agregar $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Añadir una cuenta nueva de Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "¡Dirección copiada!" }, + "addressMismatch": { + "message": "La dirección del sitio no coincide" + }, + "addressMismatchOriginal": { + "message": "URL actual: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Versión de Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Avanzado" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "La tarifa de prioridad (también llamada “propina del minero”) va directamente a los mineros para incentivarlos a priorizar su transacción." }, + "aggregatedBalancePopover": { + "message": "Esto refleja el valor de todos los tokens que posee en una red determinada. Si prefiere ver este valor en ETH u otras monedas, vaya a $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Acepto los $1 de MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Esto se puede modificar en \"Configuración > Alertas\"" }, + "alertMessageAddressMismatchWarning": { + "message": "A veces, los atacantes imitan sitios web haciendo pequeños cambios en la dirección del sitio. Asegúrese de estar interactuando con el sitio deseado antes de continuar." + }, "alertMessageGasEstimateFailed": { "message": "No podemos proporcionar una tarifa exacta y esta estimación podría ser alta. Le sugerimos que ingrese un límite de gas personalizado, pero existe el riesgo de que la transacción aún falle." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Para continuar con esta transacción, deberá aumentar el límite de gas a 21000 o más." }, + "alertMessageInsufficientBalance2": { + "message": "No tiene suficiente ETH en su cuenta para pagar las tarifas de red." + }, "alertMessageNetworkBusy": { "message": "Los precios del gas son altos y las estimaciones son menos precisas." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Opciones de activos" }, + "assets": { + "message": "Activos" + }, + "assetsDescription": { + "message": "Detectar automáticamente tokens en su monedero, mostrar NFT y recibir actualizaciones de saldo de cuenta por lotes" + }, "attemptSendingAssets": { "message": "Puede perder sus activos si intenta enviarlos desde otra red. Transfiera fondos de forma segura entre redes mediante el uso de un puente." }, @@ -621,6 +679,9 @@ "backupKeyringSnapReminder": { "message": "Asegúrese de poder acceder a cualquier cuenta creada por este Snap por si mismo antes de eliminarlo" }, + "backupNow": { + "message": "Respaldar ahora" + }, "balance": { "message": "Saldo" }, @@ -779,6 +840,15 @@ "bridgeDontSend": { "message": "Puente, no enviar" }, + "bridgeFrom": { + "message": "Puentear desde" + }, + "bridgeSelectNetwork": { + "message": "Seleccionar red" + }, + "bridgeTo": { + "message": "Puentear hacia" + }, "browserNotSupported": { "message": "Su explorador no es compatible..." }, @@ -942,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Confirmar frase secreta de recuperación" }, + "confirmTitleApproveTransaction": { + "message": "Solicitud de asignación" + }, + "confirmTitleDeployContract": { + "message": "Implementar un contrato" + }, + "confirmTitleDescApproveTransaction": { + "message": "Este sitio solicita permiso para retirar sus NFT" + }, + "confirmTitleDescDeployContract": { + "message": "Este sitio quiere que implemente un contrato" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Este sitio solicita permiso para retirar sus tokens" + }, "confirmTitleDescPermitSignature": { "message": "Este sitio solicita permiso para gastar sus tokens." }, "confirmTitleDescSIWESignature": { "message": "Un sitio quiere que inicie sesión para demostrar que es el propietario de esta cuenta." }, + "confirmTitleDescSign": { + "message": "Revise los detalles de la solicitud antes de confirmar." + }, "confirmTitlePermitTokens": { "message": "Solicitud de límite de gasto" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Eliminar permiso" + }, "confirmTitleSIWESignature": { "message": "Solicitud de inicio de sesión" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Eliminar permiso" + }, "confirmTitleSignature": { "message": "Solicitud de firma" }, "confirmTitleTransaction": { "message": "Solicitud de transacción" }, + "confirmationAlertModalDetails": { + "message": "Para proteger sus activos e información de inicio de sesión, le sugerimos que rechace la solicitud." + }, + "confirmationAlertModalTitle": { + "message": "Esta solicitud es sospechosa" + }, "confirmed": { "message": "Confirmado" }, @@ -972,6 +1072,9 @@ "confusingEnsDomain": { "message": "Se detectó un carácter que puede confundirse con otro similar en el nombre de ENS. Verifique el nombre de ENS para evitar una posible estafa." }, + "congratulations": { + "message": "¡Felicidades!" + }, "connect": { "message": "Conectar" }, @@ -1032,6 +1135,9 @@ "connectedSites": { "message": "Sitios conectados" }, + "connectedSitesAndSnaps": { + "message": "Sitios conectados y Snaps" + }, "connectedSitesDescription": { "message": "$1 está conectado a estos sitios. Pueden ver la dirección de su cuenta.", "description": "$1 is the account name" @@ -1043,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask está conectado a este sitio, pero aún no hay cuentas conectadas" }, + "connectedSnaps": { + "message": "Snaps conectados" + }, + "connectedWithAccount": { + "message": "$1 cuentas conectadas", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Conectado con $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Conectando" }, @@ -1070,6 +1187,9 @@ "connectingToSepolia": { "message": "Conectando a la red de prueba Sepolia" }, + "connectionDescription": { + "message": "Este sitio quiere" + }, "connectionFailed": { "message": "Conexión fallida" }, @@ -1150,6 +1270,9 @@ "copyAddress": { "message": "Copiar dirección al Portapapeles" }, + "copyAddressShort": { + "message": "Copiar dirección" + }, "copyPrivateKey": { "message": "Copiar clave privada" }, @@ -1399,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL RPC por defecto" }, + "defaultSettingsSubTitle": { + "message": "MetaMask utiliza la configuración por defecto para equilibrar mejor la seguridad y la facilidad de uso. Cambie esta configuración para aumentar aún más su privacidad." + }, + "defaultSettingsTitle": { + "message": "Configuración de privacidad por defecto" + }, "delete": { "message": "Eliminar" }, "deleteContact": { "message": "Eliminar contacto" }, + "deleteMetaMetricsData": { + "message": "Eliminar datos de MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Esto eliminará los datos históricos de MetaMetrics asociados con su uso en este dispositivo. Su monedero y cuentas permanecerán exactamente como están ahora después de que se eliminen estos datos. Este proceso puede tardar hasta 30 días. Consulte nuestra $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Esta solicitud no se puede ejecutar en este momento por un problema con el servidor del sistema de análisis. Vuelva a intentarlo más tarde" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "No podemos eliminar estos datos en este momento" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Estamos a punto de eliminar todos sus datos de MetaMetrics. ¿Está seguro?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "¿Eliminar datos de MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Usted inició esta acción el $1. Este proceso puede tardar hasta 30 días. Consulte la $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Si elimina esta red, deberá volver a agregarla para ver sus activos en esta red" }, @@ -1415,6 +1567,9 @@ "deposit": { "message": "Depositar" }, + "depositCrypto": { + "message": "Deposite criptomonedas desde otra cuenta con una dirección de monedero o un código QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Debido a las actualizaciones del sistema Ethereum, la red de prueba Goerli se eliminará pronto." }, @@ -1456,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "cuentas" }, + "disconnectAllDescriptionText": { + "message": "Si se desconecta de este sitio, tendrá que volver a conectar sus cuentas y redes para volver a utilizarlo." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Esto lo desconectará de este sitio" + }, "disconnectPrompt": { "message": "Desconectar $1" }, @@ -1528,6 +1689,9 @@ "editANickname": { "message": "Editar alias" }, + "editAccounts": { + "message": "Editar cuentas" + }, "editAddressNickname": { "message": "Editar apodo de dirección" }, @@ -1608,6 +1772,9 @@ "editNetworkLink": { "message": "editar la red original" }, + "editNetworksTitle": { + "message": "Editar redes" + }, "editNonceField": { "message": "Editar nonce" }, @@ -1617,9 +1784,24 @@ "editPermission": { "message": "Editar permiso" }, + "editPermissions": { + "message": "Editar permisos" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Editar la tarifa de aceleración de gas" }, + "editSpendingCap": { + "message": "Editar límite de gasto" + }, + "editSpendingCapAccountBalance": { + "message": "Saldo de la cuenta: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Ingrese la cantidad que considere conveniente que se gaste en su nombre." + }, + "editSpendingCapError": { + "message": "El límite de gasto no puede superar $1 dígitos decimales. Elimine los dígitos decimales para continuar." + }, "enableAutoDetect": { "message": " Activar autodetección" }, @@ -1671,21 +1853,36 @@ "ensUnknownError": { "message": "Error al buscar ENS." }, + "enterANameToIdentifyTheUrl": { + "message": "Ingrese un nombre para identificar la URL" + }, "enterANumber": { "message": "Ingrese un número" }, + "enterChainId": { + "message": "Ingrese el ID de cadena" + }, "enterCustodianToken": { "message": "Ingrese su token $1 o agregue un token nuevo" }, "enterMaxSpendLimit": { "message": "Escribir límite máximo de gastos" }, + "enterNetworkName": { + "message": "Ingrese el nombre de la red" + }, "enterOptionalPassword": { "message": "Ingrese la contraseña opcional" }, "enterPasswordContinue": { "message": "Escribir contraseña para continuar" }, + "enterRpcUrl": { + "message": "Ingrese el URL de RPC" + }, + "enterSymbol": { + "message": "Ingrese el símbolo" + }, "enterTokenNameOrAddress": { "message": "Ingrese el nombre del token o pegue la dirección" }, @@ -1759,6 +1956,15 @@ "experimental": { "message": "Experimental" }, + "exportYourData": { + "message": "Exporte sus datos" + }, + "exportYourDataButton": { + "message": "Descargar" + }, + "exportYourDataDescription": { + "message": "Puede exportar datos como sus contactos y preferencias." + }, "extendWalletWithSnaps": { "message": "Explore los Snaps creados por la comunidad para personalizar su experiencia web3", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1874,6 +2080,9 @@ "message": "Esta tarifa de gas ha sido sugerida por $1. Anularla puede causar un problema con su transacción. Comuníquese con $1 si tiene preguntas.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Tarifa de gas" + }, "gasIsETH": { "message": "El gas es $1 " }, @@ -1944,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Algo salió mal..." }, + "generalDescription": { + "message": "Sincronizar configuraciones entre dispositivos, seleccionar preferencias de red y dar seguimiento a los datos de tokens" + }, "genericExplorerView": { "message": "Ver cuenta en $1" }, @@ -2090,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Si se le bloquea el acceso a la aplicación o adquiere un nuevo dispositivo, perderá sus fondos. Asegúrese de hacer una copia de seguridad de su frase secreta de recuperación en $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Ignorar todo" }, @@ -2171,6 +2387,9 @@ "inYourSettings": { "message": "en su Configuración" }, + "included": { + "message": "incluido" + }, "infuraBlockedNotification": { "message": "MetaMask no se pudo conectar al host de la cadena de bloques. Revise las razones posibles $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2341,6 +2560,10 @@ "message": "Archivo JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Guarde un recordatorio de su frase secreta de recuperación en algún lugar seguro. Si la pierde, nadie podrá ayudarlo a recuperarla. Y lo que es peor, no podrá volver a acceder a su monedero. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Nombre de la cuenta" }, @@ -2399,6 +2622,9 @@ "message": "Aprenda cómo $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Más información" + }, "learnMore": { "message": "Más información" }, @@ -2406,6 +2632,9 @@ "message": "¿Quiere $1 sobre gas?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Obtenga más información sobre las mejores prácticas de privacidad." + }, "learnMoreKeystone": { "message": "Más información" }, @@ -2494,6 +2723,9 @@ "link": { "message": "Vínculo" }, + "linkCentralizedExchanges": { + "message": "Vincule sus cuentas de Coinbase o Binance para transferir criptomonedas a MetaMask de forma gratuita." + }, "links": { "message": "Vínculos" }, @@ -2554,6 +2786,9 @@ "message": "Asegúrese de que nadie esté mirando", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Gestionar la configuración de privacidad por defecto" + }, "marketCap": { "message": "Capitalización bursátil" }, @@ -2597,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "El botón de estado de la conexión muestra si el sitio web que visita está conectado a la cuenta seleccionada actualmente." }, + "metaMetricsIdNotAvailableError": { + "message": "Como nunca ha utilizado MetaMetrics, no hay datos que eliminar aquí." + }, "metadataModalSourceTooltip": { "message": "$1 está alojado en npm y $2 es el identificador único de este Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2673,6 +2911,17 @@ "more": { "message": "más" }, + "moreAccounts": { + "message": "Más de $1 cuentas adicionales", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "Más de $1 redes adicionales", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Está agregando esta red a MetaMask y otorgando permiso a este sitio para usarla." + }, "multipleSnapConnectionWarning": { "message": "$1 quiere conectarse a $2", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2751,9 +3000,13 @@ "message": "Editar detalles de la red" }, "nativeTokenScamWarningDescription": { - "message": "Esta red no coincide con su ID de cadena o nombre asociado. Muchos tokens populares usan el nombre $1, lo que los convierte en blanco de estafas. Los estafadores pueden engañarlo para que les envíe dinero más valioso a cambio. Verifique todo antes de continuar.", + "message": "El símbolo del token nativo no coincide con el símbolo esperado del token nativo para la red con el ID de cadena asociado. Ingresó $1 mientras que el símbolo del token esperado es $2. Verifique que esté conectado a la cadena correcta.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "algo más", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Esto es una estafa potencial", "description": "Title for nativeTokenScamWarningDescription" @@ -2787,6 +3040,9 @@ "networkDetails": { "message": "Detalles de la red" }, + "networkFee": { + "message": "Tarifa de red" + }, "networkIsBusy": { "message": "La red está ocupada. Los precios del gas son altos y las estimaciones son menos precisas." }, @@ -2841,6 +3097,9 @@ "networkOptions": { "message": "Opciones de red" }, + "networkPermissionToast": { + "message": "Se actualizaron los permisos de la red" + }, "networkProvider": { "message": "Proveedor de red" }, @@ -2862,15 +3121,26 @@ "message": "No podemos conectar a $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "La red cambió a $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "Dirección URL de la red" }, "networkURLDefinition": { "message": "La dirección URL que se utilizó para acceder a esta red." }, + "networkUrlErrorWarning": { + "message": "A veces, los atacantes imitan sitios web haciendo pequeños cambios en la dirección del sitio. Asegúrese de estar interactuando con el sitio deseado antes de continuar. Versión de Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Redes" }, + "networksSmallCase": { + "message": "redes" + }, "nevermind": { "message": "No es importante" }, @@ -2921,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Hemos actualizado nuestra política de privacidad" }, + "newRpcUrl": { + "message": "Nueva URL de RPC" + }, "newTokensImportedMessage": { "message": "Ha importado $1 exitosamente.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2983,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask no está conectado a este sitio" }, + "noConnectionDescription": { + "message": "Para conectarse a un sitio, busque y seleccione el botón \"conectar\". Recuerde que MetaMask solo puede conectarse a sitios en Web3" + }, "noConversionRateAvailable": { "message": "No hay tasa de conversión disponible" }, @@ -3028,6 +3304,9 @@ "nonceFieldHeading": { "message": "Nonce personalizado" }, + "none": { + "message": "Ninguna" + }, "notBusy": { "message": "No ocupado" }, @@ -3509,6 +3788,12 @@ "permissionDetails": { "message": "Detalles del permiso" }, + "permissionFor": { + "message": "Permiso para" + }, + "permissionFrom": { + "message": "Permiso de" + }, "permissionRequest": { "message": "Solicitud de permiso" }, @@ -3590,6 +3875,14 @@ "message": "Permita que $1 acceda a su idioma preferido desde la configuración de MetaMask. Esto se puede usar para localizar y mostrar el contenido de $1 usando su idioma.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Vea información como su idioma preferido y moneda fiduciaria.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Permita que $1 acceda a información como su idioma preferido y moneda fiduciaria en su configuración de MetaMask. Esto ayuda a que $1 muestre contenido adaptado a sus preferencias. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Mostrar una pantalla personalizada", "description": "The description for the `endowment:page-home` permission" @@ -3748,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Le está dando permiso al gastador para gastar esta cantidad de tokens de su cuenta." }, + "permittedChainToastUpdate": { + "message": "$1 tiene acceso a $2." + }, "personalAddressDetected": { "message": "Se detectó una dirección personal. Ingrese la dirección de contrato del token." }, @@ -3960,6 +4256,12 @@ "receive": { "message": "Recibir" }, + "receiveCrypto": { + "message": "Recibir criptomonedas" + }, + "recipientAddressPlaceholderNew": { + "message": "Ingrese la dirección pública (0x) o el nombre del dominio" + }, "recommendedGasLabel": { "message": "Recomendado" }, @@ -4023,6 +4325,12 @@ "rejected": { "message": "Rechazado" }, + "rememberSRPIfYouLooseAccess": { + "message": "Recuerde que si pierde su frase secreta de recuperación, perderá el acceso a su monedero. $1 para mantener seguro este conjunto de palabras y poder acceder siempre a sus fondos." + }, + "reminderSet": { + "message": "¡Recordatorio creado!" + }, "remove": { "message": "Quitar" }, @@ -4102,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Debido a un error, el proveedor de seguridad no verificó esta solicitud. Proceda con precaución." }, + "requestingFor": { + "message": "Solicitando para" + }, + "requestingForAccount": { + "message": "Solicitando para $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "solicitudes en espera de confirmación" }, @@ -4190,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Revelar frase semilla" }, + "review": { + "message": "Revisar" + }, + "reviewAlert": { + "message": "Alerta de revisión" + }, "reviewAlerts": { "message": "Revisar alertas" }, @@ -4215,6 +4536,9 @@ "revokePermission": { "message": "Revocar permiso" }, + "revokeSimulationDetailsDesc": { + "message": "Está eliminando el permiso de una persona para gastar tokens de su cuenta." + }, "revokeSpendingCap": { "message": "Revocar un límite de gasto para su $1", "description": "$1 is a token symbol" @@ -4222,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Este tercero no podrá gastar más de sus tokens actuales o futuros." }, + "rpcNameOptional": { + "message": "Nombre de RPC (opcional)" + }, "rpcUrl": { "message": "Nueva dirección URL de RPC" }, @@ -4274,10 +4601,23 @@ "securityAndPrivacy": { "message": "Seguridad y privacidad" }, + "securityDescription": { + "message": "Reduzca sus posibilidades de unirse a redes inseguras y proteja sus cuentas" + }, + "securityMessageLinkForNetworks": { + "message": "estafas en la red y riesgos de seguridad" + }, + "securityPrivacyPath": { + "message": "Configuración > Seguridad y privacidad." + }, "securityProviderPoweredBy": { "message": "Impulsado por $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Ver todos los permisos", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Ver detalles" }, @@ -4371,6 +4711,9 @@ "selectPathHelp": { "message": "Si no ve las cuentas previstas, intente cambiar la ruta HD o la red seleccionada actualmente." }, + "selectRpcUrl": { + "message": "Seleccionar URL de RPC" + }, "selectType": { "message": "Seleccionar tipo" }, @@ -4433,6 +4776,9 @@ "setApprovalForAll": { "message": "Establecer aprobación para todos" }, + "setApprovalForAllRedesignedTitle": { + "message": "Solicitud de retiro" + }, "setApprovalForAllTitle": { "message": "Aprobar $1 sin límite preestablecido", "description": "The token symbol that is being approved" @@ -4443,6 +4789,9 @@ "settings": { "message": "Configuración" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "La configuración está optimizada para facilitar su uso y garantizar la seguridad. Cámbiela en cualquier momento." + }, "settingsSearchMatchingNotFound": { "message": "No se encontraron resultados coincidentes." }, @@ -4489,6 +4838,9 @@ "showMore": { "message": "Mostrar más" }, + "showNativeTokenAsMainBalance": { + "message": "Mostrar el token nativo como saldo principal" + }, "showNft": { "message": "Mostrar NFT" }, @@ -4528,6 +4880,15 @@ "signingInWith": { "message": "Iniciar sesión con" }, + "simulationApproveHeading": { + "message": "Retirar" + }, + "simulationDetailsApproveDesc": { + "message": "Le está dando permiso a otra persona para que retire NFT de su cuenta." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Le está dando permiso a otra persona para que gaste este monto de su cuenta." + }, "simulationDetailsFiatNotAvailable": { "message": "No disponible" }, @@ -4537,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Envía" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Está eliminando el permiso de otra persona para retirar NFT de su cuenta." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Está dando permiso a otra persona para que retire NFT de su cuenta." + }, "simulationDetailsTitle": { "message": "Cambios estimados" }, @@ -4789,6 +5156,16 @@ "somethingWentWrong": { "message": "Lo lamentamos, se produjo un error." }, + "sortBy": { + "message": "Ordenar por" + }, + "sortByAlphabetically": { + "message": "Alfabéticamente (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Saldo decreciente ($1 alto-bajo)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Fuente" }, @@ -4832,6 +5209,12 @@ "spender": { "message": "Gastador" }, + "spenderTooltipDesc": { + "message": "Esta es la dirección que podrá retirar sus NFT." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Esta es la dirección que podrá gastar sus tokens en su nombre." + }, "spendingCap": { "message": "Límite de gasto" }, @@ -4845,6 +5228,9 @@ "spendingCapRequest": { "message": "Solicitud de límite de gastos para su $1" }, + "spendingCapTooltipDesc": { + "message": "Esta es la cantidad de tokens a la que el gastador podrá acceder en su nombre." + }, "srpInputNumberOfWords": { "message": "Tengo una frase de $1 palabras", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5048,6 +5434,9 @@ "message": "Sugerido por $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Símbolo de moneda sugerido:" + }, "suggestedTokenName": { "message": "Nombre sugerido:" }, @@ -5057,6 +5446,9 @@ "supportCenter": { "message": "Visite nuestro Centro de soporte técnico" }, + "supportMultiRpcInformation": { + "message": "Ahora admitimos varias RPC para una sola red. Su RPC más reciente se ha seleccionado como predeterminada para resolver información conflictiva." + }, "surveyConversion": { "message": "Responda a nuestra encuesta" }, @@ -5173,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Las tarifas de gas son estimadas y fluctuarán en función del tráfico de la red y la complejidad de las transacciones." }, + "swapGasFeesExplanation": { + "message": "MetaMask no obtiene dinero de las tarifas de gas. Estas tarifas son estimaciones y pueden cambiar según el nivel de tráfico de la red y la complejidad de la transacción. Más información $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "aquí", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Obtenga más información sobre las tarifas de gas" }, @@ -5183,9 +5583,19 @@ "message": "Las tarifas de gas se pagan a los mineros de criptomonedas que procesan transacciones en la red $1. MetaMask no se beneficia de las tarifas de gas.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Esta cotización incorpora las tarifas de gas al ajustar la cantidad de tokens enviada o recibida. Puede recibir ETH en una transacción separada en su lista de actividades." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Más información sobre las tarifas de gas" + }, "swapHighSlippage": { "message": "Deslizamiento alto" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Incluye gas y una tasa de MetaMask del $1%", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Incluye una tasa de MetaMask del $1%.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5482,6 +5892,9 @@ "termsOfUseTitle": { "message": "Nuestros Términos de uso han sido actualizados" }, + "testnets": { + "message": "Red de pruebas" + }, "theme": { "message": "Tema" }, @@ -5663,6 +6076,9 @@ "transactionFee": { "message": "Tarifa de transacción" }, + "transactionFlowNetwork": { + "message": "Red" + }, "transactionHistoryBaseFee": { "message": "Tarifa base (GWEI)" }, @@ -5705,9 +6121,15 @@ "transfer": { "message": "Transferir" }, + "transferCrypto": { + "message": "Transferir criptomonedas" + }, "transferFrom": { "message": "Transferir desde" }, + "transferRequest": { + "message": "Solicitud de transferencia" + }, "trillionAbbreviation": { "message": "b", "description": "Shortened form of 'trillion'" @@ -5841,12 +6263,22 @@ "update": { "message": "Actualizar" }, + "updateEthereumChainConfirmationDescription": { + "message": "Este sitio solicita actualizar la URL de su red predeterminada. Puede editar los valores predeterminados y la información de la red en cualquier momento." + }, + "updateNetworkConfirmationTitle": { + "message": "Actualizar $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Actualice su información o" }, "updateRequest": { "message": "Solicitud de actualización" }, + "updatedRpcForNetworks": { + "message": "RPC de red actualizadas" + }, "uploadDropFile": { "message": "Ingrese su archivo aquí" }, @@ -5971,6 +6403,10 @@ "walletConnectionGuide": { "message": "nuestra guía de conexión del monedero físico" }, + "walletProtectedAndReadyToUse": { + "message": "Su monedero está protegido y listo para usar. Puede encontrar su frase secreta de recuperación en $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "¿Desea añadir esta red?" }, @@ -5988,6 +6424,17 @@ "message": "$1 El tercero podría gastar todo su saldo de tokens sin previo aviso o consentimiento. Protéjase personalizando un límite de gasto más bajo.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Al activar esta opción, podrá ver las cuentas de Ethereum a través de una dirección pública o un nombre de ENS. Para recibir comentarios sobre esta función Beta, complete este $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Observar cuentas Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Cuidado con $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Débil" }, @@ -6034,6 +6481,9 @@ "whatsThis": { "message": "¿Qué es esto?" }, + "withdrawing": { + "message": "Retirando" + }, "wrongNetworkName": { "message": "Según nuestros registros, es posible que el nombre de la red no coincida correctamente con este ID de cadena." }, @@ -6062,6 +6512,9 @@ "yourBalance": { "message": "Su saldo" }, + "yourBalanceIsAggregated": { + "message": "Su saldo es un acumulado" + }, "yourNFTmayBeAtRisk": { "message": "Sus NFT podrían estar en riesgo" }, @@ -6074,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "No pudimos cancelar su transacción antes de que se confirmara en la cadena de bloques." }, + "yourWalletIsReady": { + "message": "Su monedero está listo" + }, "zeroGasPriceOnSpeedUpError": { "message": "No hay entradas sobre el precio del gas al acelerar la transacción" } diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index 7c4db83af9e8..bcfa71f5661b 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Connectez votre portefeuille électronique QR" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "L’adresse figurant dans la demande de connexion ne correspond pas à l’adresse du compte que vous utilisez pour vous connecter." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Sélectionnez les comptes pour lesquels vous souhaitez recevoir des notifications :" }, + "accountBalance": { + "message": "Solde du compte" + }, "accountDetails": { "message": "Détails du compte" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Options du compte" }, + "accountPermissionToast": { + "message": "Les autorisations accordées au compte ont été mises à jour" + }, "accountSelectionRequired": { "message": "Vous devez sélectionner un compte !" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Comptes connectés" }, + "accountsPermissionsTitle": { + "message": "Consultez vos comptes et suggérez des transactions" + }, + "accountsSmallCase": { + "message": "comptes" + }, "active": { "message": "Actif" }, @@ -180,12 +195,18 @@ "add": { "message": "Ajouter" }, + "addACustomNetwork": { + "message": "Ajouter un réseau personnalisé" + }, "addANetwork": { "message": "Ajouter un réseau" }, "addANickname": { "message": "Ajouter un pseudo" }, + "addAUrl": { + "message": "Ajouter une URL" + }, "addAccount": { "message": "Ajouter un compte" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Ajouter un explorateur de blocs" }, + "addBlockExplorerUrl": { + "message": "Ajouter une URL d’explorateur de blocs" + }, "addContact": { "message": "Ajouter un contact" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Vous êtes en train d’ajouter un nouveau fournisseur de RPC pour le réseau principal de la blockchain Ethereum" }, + "addEthereumWatchOnlyAccount": { + "message": "Surveiller un compte Ethereum (Bêta)" + }, "addFriendsAndAddresses": { "message": "Ajoutez uniquement des amis et des adresses de confiance" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Ajouter un réseau" }, + "addNetworkConfirmationTitle": { + "message": "Ajouter $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Ajouter un nouveau compte Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Adresse copiée !" }, + "addressMismatch": { + "message": "Inadéquation de l’adresse du site" + }, + "addressMismatchOriginal": { + "message": "URL actuelle : $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Version Punycode : $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Paramètres avancés" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Les frais de priorité (aussi appelés « pourboire du mineur ») vont directement aux mineurs et les incitent à accorder la priorité à votre transaction." }, + "aggregatedBalancePopover": { + "message": "Cette valeur reflète la valeur de tous les jetons que vous possédez sur un réseau donné. Si vous préférez que cette valeur soit affichée en ETH ou dans d’autres monnaies, veuillez accéder à $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "J’accepte les $1 de MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Vous pouvez modifier ceci dans « Paramètres > Alertes »" }, + "alertMessageAddressMismatchWarning": { + "message": "Les pirates informatiques imitent parfois les sites en modifiant légèrement l’adresse du site. Assurez-vous que vous interagissez avec le site voulu avant de continuer." + }, "alertMessageGasEstimateFailed": { "message": "Nous ne sommes pas en mesure de déterminer le montant exact des frais et cette estimation peut être élevée. Nous vous suggérons d’entrer une limite de gaz personnalisée, mais la transaction pourrait quand même échouer." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Pour effectuer cette transaction, vous devez augmenter la limite de gaz à 21 000 ou plus." }, + "alertMessageInsufficientBalance2": { + "message": "Vous n’avez pas assez d’ETH sur votre compte pour payer les frais de réseau." + }, "alertMessageNetworkBusy": { "message": "Les prix du gaz sont élevés et les estimations sont moins précises." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Options d’actifs" }, + "assets": { + "message": "Actifs" + }, + "assetsDescription": { + "message": "Détection automatique des jetons dans votre portefeuille, affichage des NFT et mise à jour du solde de plusieurs comptes" + }, "attemptSendingAssets": { "message": "Si vous essayez d’envoyer des actifs directement d’un réseau à un autre, une perte permanente des actifs pourrait en résulter. Assurez-vous d’utiliser une passerelle." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Passerelle, ne pas envoyer" }, + "bridgeFrom": { + "message": "Passerelle depuis" + }, + "bridgeSelectNetwork": { + "message": "Sélectionner un réseau" + }, + "bridgeTo": { + "message": "Passerelle vers" + }, "browserNotSupported": { "message": "Votre navigateur internet n’est pas compatible..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Confirmer la phrase secrète de récupération" }, + "confirmTitleApproveTransaction": { + "message": "Demande de provision" + }, + "confirmTitleDeployContract": { + "message": "Déployer un contrat" + }, + "confirmTitleDescApproveTransaction": { + "message": "Ce site demande l’autorisation de retirer vos NFT" + }, + "confirmTitleDescDeployContract": { + "message": "Ce site veut que vous déployiez un contrat" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Ce site demande l’autorisation de retirer vos jetons" + }, "confirmTitleDescPermitSignature": { "message": "Ce site demande que vous lui accordiez l'autorisation de dépenser vos jetons." }, "confirmTitleDescSIWESignature": { "message": "Un site vous demande de vous connecter pour prouver que vous êtes le titulaire de ce compte." }, + "confirmTitleDescSign": { + "message": "Vérifiez les détails de la demande avant de confirmer." + }, "confirmTitlePermitTokens": { "message": "Demande de plafonnement des dépenses" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Retirer l'autorisation" + }, "confirmTitleSIWESignature": { "message": "Demande de connexion" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Retirer l'autorisation" + }, "confirmTitleSignature": { "message": "Demande de signature" }, "confirmTitleTransaction": { "message": "Demande de transaction" }, + "confirmationAlertModalDetails": { + "message": "Pour protéger vos actifs et vos informations de connexion, nous vous suggérons de rejeter la demande." + }, + "confirmationAlertModalTitle": { + "message": "Cette demande est suspecte" + }, "confirmed": { "message": "Confirmé" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Nous avons détecté un caractère pouvant prêter à confusion dans le nom de l’ENS. Vérifiez le nom de l’ENS pour éviter toute fraude potentielle." }, + "congratulations": { + "message": "Félicitations !" + }, "connect": { "message": "Connecter" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Sites connectés" }, + "connectedSitesAndSnaps": { + "message": "Sites et Snaps connectés" + }, "connectedSitesDescription": { "message": "$1 est connecté à ces sites. Ils peuvent voir l’adresse de votre compte.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask est connecté à ce site, mais aucun compte n’est encore connecté" }, + "connectedSnaps": { + "message": "Snaps connectés" + }, + "connectedWithAccount": { + "message": "$1 comptes connectés", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Connecté avec $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Connexion…" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Connexion au réseau de test Sepolia" }, + "connectionDescription": { + "message": "Ce site veut" + }, "connectionFailed": { "message": "Échec de la connexion" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Copier l’addresse dans le presse-papier" }, + "copyAddressShort": { + "message": "Copier l’adresse" + }, "copyPrivateKey": { "message": "Copier la clé privée" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL par défaut du RPC" }, + "defaultSettingsSubTitle": { + "message": "MetaMask utilise des paramètres par défaut pour établir un équilibre entre sécurité et facilité d’utilisation. Modifiez ces paramètres pour renforcer les mesures de protection de la confidentialité." + }, + "defaultSettingsTitle": { + "message": "Paramètres de confidentialité par défaut" + }, "delete": { "message": "Supprimer" }, "deleteContact": { "message": "Supprimer le contact" }, + "deleteMetaMetricsData": { + "message": "Supprimer les données MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Cela supprimera les données historiques de MetaMetrics associées à votre utilisation de cet appareil. Aucun changement ne sera apporté à votre portefeuille et à vos comptes après la suppression de ces données. Ce processus peut prendre jusqu’à 30 jours. Veuillez consulter notre $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Cette demande ne peut être traitée pour l’instant, car un problème est survenu avec le serveur du système d’analyse, veuillez réessayer plus tard" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Pour le moment, il nous est impossible de supprimer ces données" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Nous sommes sur le point de supprimer toutes vos données MetaMetrics. Êtes-vous sûr(e) de vouloir continuer ?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Voulez-vous supprimer les données MetaMetrics ?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Vous avez initié cette action le $1. Ce processus peut prendre jusqu’à 30 jours. Veuillez consulter notre $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Si vous supprimez ce réseau, vous devrez l’ajouter à nouveau pour voir vos actifs sur ce réseau" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Effectuez un dépôt" }, + "depositCrypto": { + "message": "Déposez des crypto-monnaies à partir d’un autre compte à l’aide d’une adresse de portefeuille ou d’un code QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Les mises à jour du système Ethereum rendront bientôt le testnet Goerli obsolète." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "comptes" }, + "disconnectAllDescriptionText": { + "message": "Si vous vous déconnectez de ce site, vous devrez reconnecter vos comptes et réseaux pour pouvoir l’utiliser à nouveau." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Cela vous déconnectera de ce site" + }, "disconnectPrompt": { "message": "Déconnecter $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Modifier le pseudo" }, + "editAccounts": { + "message": "Modifier les comptes" + }, "editAddressNickname": { "message": "Modifier le pseudo de l’adresse" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "modifier le réseau d’origine" }, + "editNetworksTitle": { + "message": "Modifier les réseaux" + }, "editNonceField": { "message": "Modifier le nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Modifier l’autorisation" }, + "editPermissions": { + "message": "Modifier les autorisations" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Modifier les gas fees d’accélération" }, + "editSpendingCap": { + "message": "Modifier le plafond des dépenses" + }, + "editSpendingCapAccountBalance": { + "message": "Solde du compte : $1 $2" + }, + "editSpendingCapDesc": { + "message": "Indiquez le montant maximum qui pourra être dépensé en votre nom." + }, + "editSpendingCapError": { + "message": "Le plafond des dépenses ne peut contenir plus de $1 chiffres décimaux. Supprimez les chiffres décimaux excédentaires pour continuer." + }, "enableAutoDetect": { "message": " Activer la détection automatique" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "La recherche d’ENS a échoué." }, + "enterANameToIdentifyTheUrl": { + "message": "Saisissez un nom pour identifier l’URL" + }, "enterANumber": { "message": "Saisissez un nombre" }, + "enterChainId": { + "message": "Saisissez l’ID de chaîne" + }, "enterCustodianToken": { "message": "Saisissez votre jeton de $1 ou ajoutez un nouveau jeton" }, "enterMaxSpendLimit": { "message": "Saisissez la limite de dépenses maximale" }, + "enterNetworkName": { + "message": "Saisissez le nom du réseau" + }, "enterOptionalPassword": { "message": "Entrez le mot de passe facultatif" }, "enterPasswordContinue": { "message": "Entrez votre mot de passe pour continuer" }, + "enterRpcUrl": { + "message": "Saisissez l’URL du RPC" + }, + "enterSymbol": { + "message": "Saisissez le symbole" + }, "enterTokenNameOrAddress": { "message": "Saisissez le nom du jeton ou copiez-collez l’adresse" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Expérimental" }, + "exportYourData": { + "message": "Exportez vos données" + }, + "exportYourDataButton": { + "message": "Télécharger" + }, + "exportYourDataDescription": { + "message": "Vous pouvez exporter des données telles que vos contacts et vos préférences." + }, "extendWalletWithSnaps": { "message": "Explorez les Snaps créés par la communauté pour personnaliser votre expérience web3", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Ce prix de carburant a été suggéré par $1. Si vous n’en tenez pas compte, vous risquez de rencontrer des difficultés lors de votre transaction. Veuillez contacter $1 pour toute question.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Frais de gaz" + }, "gasIsETH": { "message": "Le gaz est $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Quelque chose a mal tourné…" }, + "generalDescription": { + "message": "Synchronisez les paramètres entre différents appareils, sélectionnez les préférences réseau et suivez les données relatives aux jetons" + }, "genericExplorerView": { "message": "Voir le compte sur $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Si vous ne pouvez plus accéder à votre compte ou si vous changez d’appareil, vous perdrez vos fonds. Veillez à sauvegarder votre phrase secrète de récupération dans $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Ignorer tout" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "dans vos paramètres" }, + "included": { + "message": "inclus" + }, "infuraBlockedNotification": { "message": "MetaMask ne peut pas se connecter à l’hôte de la blockchain. Vérifiez les éventuelles raisons $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "Fichier JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Conservez votre phrase secrète de récupération dans un endroit sûr. Si vous la perdez, personne ne pourra vous aider à la récupérer. Pire encore, vous ne pourrez plus jamais accéder à votre portefeuille. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Nom du compte" }, @@ -2402,6 +2622,9 @@ "message": "Découvrir comment $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Découvrir comment" + }, "learnMore": { "message": "En savoir plus" }, @@ -2409,6 +2632,9 @@ "message": "Vous voulez $1 sur les frais de gaz ?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "En savoir plus sur les bonnes pratiques en matière de protection des données personnelles." + }, "learnMoreKeystone": { "message": "En savoir plus" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Associer" }, + "linkCentralizedExchanges": { + "message": "Liez votre compte Coinbase ou Binance pour transférer gratuitement des crypto-monnaies vers MetaMask." + }, "links": { "message": "Liens" }, @@ -2557,6 +2786,9 @@ "message": "Assurez-vous que personne ne regarde votre écran", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Gérer les paramètres de confidentialité par défaut" + }, "marketCap": { "message": "Capitalisation boursière" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Le bouton d’état de connexion indique si le site Web que vous visitez est connecté à votre compte actuellement sélectionné." }, + "metaMetricsIdNotAvailableError": { + "message": "Comme vous n’avez jamais adhéré à MetaMetrics, il n’y a pas de données à supprimer ici." + }, "metadataModalSourceTooltip": { "message": "$1 est hébergé sur npm et $2 est l’identifiant unique de ce Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "plus" }, + "moreAccounts": { + "message": "+ $1 comptes supplémentaires", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 réseaux supplémentaires", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Vous allez ajouter ce réseau à MetaMask et donner à ce site l’autorisation de l’utiliser." + }, "multipleSnapConnectionWarning": { "message": "$1 veut utiliser $2 Snaps", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Modifier les détails du réseau" }, "nativeTokenScamWarningDescription": { - "message": "L’ID ou le nom de la chaîne associée à ce réseau n’est pas correct. De nombreux jetons populaires utilisent le nom $1, ce qui en fait une cible pour les escrocs. Vérifiez tout avant de continuer, car vous risquez de vous faire arnaquer.", + "message": "Le symbole du jeton natif ne correspond pas au symbole attendu pour le jeton natif du réseau associé à cet ID de chaîne. Vous avez saisi $1 alors que le symbole attendu pour le jeton est $2. Veuillez vérifier que vous êtes connecté à la bonne chaîne.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "autre chose", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Il pourrait s’agir d’une arnaque", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Détails du réseau" }, + "networkFee": { + "message": "Frais de réseau" + }, "networkIsBusy": { "message": "Le réseau est occupé. Les gas fees sont élevés et les estimations sont moins précises." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Options du réseau" }, + "networkPermissionToast": { + "message": "Les autorisations réseau ont été mises à jour" + }, "networkProvider": { "message": "Fournisseur de réseau" }, @@ -2865,15 +3121,26 @@ "message": "Nous ne pouvons pas nous connecter à $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Le réseau est passé à $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL du réseau" }, "networkURLDefinition": { "message": "L’URL utilisée pour accéder à ce réseau." }, + "networkUrlErrorWarning": { + "message": "Les pirates informatiques imitent parfois les sites en modifiant légèrement l’adresse du site. Assurez-vous que vous interagissez avec le site voulu avant de continuer. Version Punycode : $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Réseaux" }, + "networksSmallCase": { + "message": "réseaux" + }, "nevermind": { "message": "Abandonner" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Nous avons mis à jour notre politique de confidentialité" }, + "newRpcUrl": { + "message": "Nouvelle URL de RPC" + }, "newTokensImportedMessage": { "message": "Vous avez importé avec succès $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask n’est pas connecté à ce site" }, + "noConnectionDescription": { + "message": "Pour vous connecter à un site, trouvez et sélectionnez le bouton « connecter ». N’oubliez pas que MetaMask ne peut se connecter qu’à des sites Web3" + }, "noConversionRateAvailable": { "message": "Aucun taux de conversion disponible" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Nonce personnalisé" }, + "none": { + "message": "Aucun" + }, "notBusy": { "message": "Pas occupé" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Informations sur les autorisations" }, + "permissionFor": { + "message": "Autorisation pour" + }, + "permissionFrom": { + "message": "Autorisation de" + }, "permissionRequest": { "message": "Demande d’autorisation" }, @@ -3593,6 +3875,14 @@ "message": "Autoriser le Snap $1 à accéder à vos paramètres MetaMask pour qu’il puisse identifier votre langue préférée. Cela permet de localiser et d’afficher le contenu du Snap $1 dans votre langue.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Vous pouvez consulter des informations telles que votre langue et votre monnaie fiat préférées.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Autorisez $1 à accéder à des informations telles que votre langue et votre monnaie fiat préférées dans les paramètres de votre compte MetaMask. Cela permet à $1 d’afficher un contenu adapté à vos préférences. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Afficher un écran personnalisé", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Vous autorisez la dépenseur à dépenser ce nombre de jetons de votre compte." }, + "permittedChainToastUpdate": { + "message": "$1 a accès à $2." + }, "personalAddressDetected": { "message": "Votre adresse personnelle a été détectée. Veuillez saisir à la place l’adresse du contrat du jeton." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Recevoir" }, + "receiveCrypto": { + "message": "Recevoir des crypto-monnaies" + }, + "recipientAddressPlaceholderNew": { + "message": "Saisissez l’adresse publique (0x) ou le nom de domaine" + }, "recommendedGasLabel": { "message": "Recommandé" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Rejeté" }, + "rememberSRPIfYouLooseAccess": { + "message": "N’oubliez pas que si vous perdez votre phrase secrète de récupération, vous perdez l’accès à votre portefeuille. $1 pour sécuriser cet ensemble de mots et éviter de perdre l’accès à vos fonds." + }, + "reminderSet": { + "message": "Rappel défini !" + }, "remove": { "message": "Supprimer" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Suite à une erreur, cette demande n’a pas été vérifiée par le fournisseur de sécurité. Veuillez agir avec prudence." }, + "requestingFor": { + "message": "Demande de" + }, + "requestingForAccount": { + "message": "Demande de $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "demandes en attente d’un accusé de réception" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Révéler la phrase mnémonique" }, + "review": { + "message": "Examiner" + }, + "reviewAlert": { + "message": "Examiner l’alerte" + }, "reviewAlerts": { "message": "Examiner les alertes" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Retirer l'autorisation" }, + "revokeSimulationDetailsDesc": { + "message": "Vous révoquez l’autorisation que vous avez accordée à quelqu’un d’autre de dépenser des jetons à partir de votre compte." + }, "revokeSpendingCap": { "message": "Supprimez le plafond des dépenses pour vos $1", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Ce tiers ne pourra plus dépenser vos jetons actuels ou futurs." }, + "rpcNameOptional": { + "message": "Nom du RPC (facultatif)" + }, "rpcUrl": { "message": "Nouvelle URL de RPC" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Sécurité et confidentialité" }, + "securityDescription": { + "message": "Réduisez le risque de rejoindre des réseaux dangereux et protégez vos comptes" + }, + "securityMessageLinkForNetworks": { + "message": "arnaques et risques de piratage informatique" + }, + "securityPrivacyPath": { + "message": "Paramètres > Sécurité et confidentialité." + }, "securityProviderPoweredBy": { "message": "Service fourni par $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Voir toutes les autorisations", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Voir les détails" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Si vous ne voyez pas les comptes auxquels vous vous attendez, essayez de changer le chemin d’accès au portefeuille HD ou le réseau sélectionné." }, + "selectRpcUrl": { + "message": "Sélectionner l’URL du RPC" + }, "selectType": { "message": "Sélectionner le type" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Définir l’approbation pour tous" }, + "setApprovalForAllRedesignedTitle": { + "message": "Demande de retrait" + }, "setApprovalForAllTitle": { "message": "Approuver $1 sans limite de dépenses", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Paramètres" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Les paramètres sont optimisés pour sécuriser l’application et faciliter son utilisation. Vous pouvez modifier les paramètres à tout moment." + }, "settingsSearchMatchingNotFound": { "message": "Aucun résultat correspondant trouvé." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Afficher plus" }, + "showNativeTokenAsMainBalance": { + "message": "Afficher le solde principal en jeton natif" + }, "showNft": { "message": "Afficher le NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Se connecter avec" }, + "simulationApproveHeading": { + "message": "Retirer" + }, + "simulationDetailsApproveDesc": { + "message": "Vous donnez à quelqu’un d’autre l’autorisation de retirer des NFT de votre compte." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Vous accordez à quelqu’un d’autre l’autorisation de dépenser ce montant qui sera débité de votre compte." + }, "simulationDetailsFiatNotAvailable": { "message": "Non disponible" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Vous envoyez" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Vous révoquez l’autorisation que vous avez accordée à quelqu’un d’autre de retirer des NFT de votre compte." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Vous accordez à quelqu’un d’autre l’autorisation de retirer des NFT de votre compte." + }, "simulationDetailsTitle": { "message": "Changements estimés" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Oups ! Quelque chose a mal tourné. " }, + "sortBy": { + "message": "Trier par" + }, + "sortByAlphabetically": { + "message": "Ordre alphabétique (de A à Z)" + }, + "sortByDecliningBalance": { + "message": "Solde décroissant (du solde le plus élevé au solde le moins élevé en $1)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Source" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Dépenseur" }, + "spenderTooltipDesc": { + "message": "Il s’agit de l’adresse qui pourra retirer vos NFT." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Il s’agit de l’adresse qui pourra dépenser vos jetons en votre nom." + }, "spendingCap": { "message": "Plafond de dépenses" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Demande de fixer un plafond de dépenses pour votre $1" }, + "spendingCapTooltipDesc": { + "message": "Il s’agit du nombre de jetons auquel le dépenseur pourra accéder en votre nom." + }, "srpInputNumberOfWords": { "message": "J’ai une phrase de $1 mots", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Suggéré par $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Symbole monétaire suggéré :" + }, "suggestedTokenName": { "message": "Nom suggéré :" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Visitez notre centre d’aide" }, + "supportMultiRpcInformation": { + "message": "Nous prenons désormais en charge plusieurs RPC pour un même réseau. Votre RPC le plus récent a été sélectionné par défaut afin de résoudre les problèmes liés à des informations contradictoires." + }, "surveyConversion": { "message": "Répondez à notre sondage" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Les frais de carburant sont estimés et fluctueront selon le trafic réseau et la complexité de la transaction." }, + "swapGasFeesExplanation": { + "message": "MetaMask ne tire aucun profit des frais de gaz. Ces frais sont des estimations et peuvent changer en fonction de l’activité du réseau et de la complexité de la transaction. En savoir plus $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "ici", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "En savoir plus sur les frais de carburant" }, @@ -5186,9 +5583,19 @@ "message": "Les frais de carburant sont payés aux mineurs de cryptomonnaies qui traitent les transactions sur le réseau $1. MetaMask ne tire aucun profit des frais de carburant.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Cette cotation incorpore les frais de gaz en ajustant le nombre de jetons envoyés ou reçus. Vous pouvez recevoir des ETH dans une transaction séparée sur votre liste d’activités." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "En savoir plus sur les frais de gaz" + }, "swapHighSlippage": { "message": "Important effet de glissement" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Inclut les frais de gaz et les frais MetaMask qui s’élèvent à $1 %", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Comprend des frais MetaMask à hauteur de $1 %.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Nos conditions d’utilisation ont été mises à jour" }, + "testnets": { + "message": "Testnets" + }, "theme": { "message": "Thème" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Frais de transaction" }, + "transactionFlowNetwork": { + "message": "Réseau" + }, "transactionHistoryBaseFee": { "message": "Frais de base (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Transfert" }, + "transferCrypto": { + "message": "Transférer des crypto-monnaies" + }, "transferFrom": { "message": "Transfert depuis" }, + "transferRequest": { + "message": "Demande de transfert" + }, "trillionAbbreviation": { "message": "B", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Mise à jour" }, + "updateEthereumChainConfirmationDescription": { + "message": "Ce site demande à mettre à jour l’URL par défaut de votre réseau. Vous pouvez modifier les paramètres par défaut et les informations du réseau à tout moment." + }, + "updateNetworkConfirmationTitle": { + "message": "Mettre à jour $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Mettez à jour vos informations ou" }, "updateRequest": { "message": "Demande de mise à jour" }, + "updatedRpcForNetworks": { + "message": "Les RPC du réseau ont été mis à jour" + }, "uploadDropFile": { "message": "Déposez votre fichier ici" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "notre guide de connexion des portefeuilles matériels" }, + "walletProtectedAndReadyToUse": { + "message": "Votre portefeuille est protégé et prêt à être utilisé. Vous trouverez votre phrase secrète de récupération dans $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Voulez-vous ajouter ce réseau ?" }, @@ -5991,6 +6424,17 @@ "message": "$1 L’autre partie au contrat peut dépenser la totalité de votre solde de jetons sans préavis et sans demander votre consentement. Protégez-vous en abaissant le plafond des dépenses.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "En activant cette option, vous pourrez surveiller des comptes Ethereum via une adresse publique ou un nom ENS. Pour nous faire part de vos commentaires sur cette fonctionnalité bêta, veuillez remplir ce $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Surveiller des comptes Ethereum (Bêta)" + }, + "watchOutMessage": { + "message": "Méfiez-vous de $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Faible" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Qu’est-ce que c’est ?" }, + "withdrawing": { + "message": "Retrait en cours" + }, "wrongNetworkName": { "message": "Selon nos informations, il se peut que le nom du réseau ne corresponde pas exactement à l’ID de chaîne." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Votre solde" }, + "yourBalanceIsAggregated": { + "message": "Votre solde est agrégé" + }, "yourNFTmayBeAtRisk": { "message": "Il peut y avoir des risques associés à votre NFT" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Nous n'avons pas pu annuler votre transaction avant qu'elle ne soit confirmée sur la blockchain." }, + "yourWalletIsReady": { + "message": "Votre portefeuille est prêt" + }, "zeroGasPriceOnSpeedUpError": { "message": "Prix de carburant zéro sur l’accélération" } diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 9f3800ef6b61..f5484e624348 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "अपने QR hardware wallet को कनेक्ट करें" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave ज़ीरो" + }, "SIWEAddressInvalid": { "message": "साइन-इन रिक्वेस्ट का एड्रेस उस अकाउंट के एड्रेस से मेल नहीं खाता जिसका इस्तेमाल आप साइन इन करने के लिए कर रहे हैं।" }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "वे अकाउंट चुनें जिनके बारे में आप सूचित होना चाहते हैं:" }, + "accountBalance": { + "message": "अकाउंट बैलेंस" + }, "accountDetails": { "message": "अकाउंट की जानकारी" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "अकाउंट विकल्प" }, + "accountPermissionToast": { + "message": "अकाउंट अनुमतियां अपडेट की गईं" + }, "accountSelectionRequired": { "message": "आपको एक अकाउंट चुनना होगा!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "अकाउंट्स कनेक्ट किए गए।" }, + "accountsPermissionsTitle": { + "message": "अपने अकाउंट्स देखें और ट्रांसेक्शन के सुझाव दें" + }, + "accountsSmallCase": { + "message": "अकाउंट" + }, "active": { "message": "एक्टिव है" }, @@ -180,12 +195,18 @@ "add": { "message": "जोड़ें" }, + "addACustomNetwork": { + "message": "एक कस्टम नेटवर्क जोड़ें" + }, "addANetwork": { "message": "एक नेटवर्क जोड़ें" }, "addANickname": { "message": "एक उपनाम जोड़ें" }, + "addAUrl": { + "message": "एक URL जोड़ें" + }, "addAccount": { "message": "अकाउंट जोड़ें" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "ब्लॉक एक्सप्लोरर जोड़ें" }, + "addBlockExplorerUrl": { + "message": "एक ब्लॉक एक्सप्लोरर URL जोड़ें" + }, "addContact": { "message": "कॉन्टेक्ट जोड़ें" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "आप Ethereum Mainnet के लिए एक नया RPC प्रोवाइडर जोड़ रहे हैं" }, + "addEthereumWatchOnlyAccount": { + "message": "कोई Ethereum अकाउंट देखें (Beta)" + }, "addFriendsAndAddresses": { "message": "उन मित्रों और पतों को जोड़ें, जिन पर आप भरोसा करते हैं" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "नेटवर्क जोड़ें" }, + "addNetworkConfirmationTitle": { + "message": "$1 जोड़ें", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "एक नया Ethereum अकाउंट जोड़ें" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "एड्रेस कॉपी किया गया!" }, + "addressMismatch": { + "message": "साइट एड्रेस का मैच न होना" + }, + "addressMismatchOriginal": { + "message": "वर्तमान URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "पुनीकोड ​​वर्शन: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "एडवांस्ड" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "प्रायोरिटी फ़ीस (उर्फ \"माइनर टिप\") सीधे माइनरों के पास जाती है और उन्हें आपके ट्रांसेक्शन को प्राथमिकता देने के लिए प्रोत्साहित करती है।" }, + "aggregatedBalancePopover": { + "message": "यह किसी दिए गए नेटवर्क पर आपके स्वामित्व वाले सभी टोकन के मूल्य को दर्शाता है। यदि आप इस मूल्य को ETH या अन्य मुद्राओं में देखना पसंद करते हैं, तो $1 पर जाएं।", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "मैं MetaMask के $1 से सहमत हूं", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "इसे \"सेटिंग > अलर्ट\" में बदला जा सकता है" }, + "alertMessageAddressMismatchWarning": { + "message": "हमला करने वाले कभी-कभी साइट के एड्रेस में छोटे-छोटे बदलाव करके साइटों की नकल करते हैं। जारी रखने से पहले सुनिश्चित करें कि आप इच्छित साइट के साथ इंटरैक्ट कर रहे हैं।" + }, "alertMessageGasEstimateFailed": { "message": "हम सटीक शुल्क प्रदान करने में असमर्थ हैं और यह अनुमान अधिक हो सकता है। हम आपको एक कस्टम गैस लिमिट दर्ज करने का सुझाव देते हैं, लेकिन जोखिम है कि ट्रांसेक्शन अभी भी विफल हो जाएगा।" }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "इस ट्रांसेक्शन को जारी रखने के लिए, आपको गैस लिमिट को 21000 या अधिक तक बढ़ाना होगा।" }, + "alertMessageInsufficientBalance2": { + "message": "नेटवर्क फीस का भुगतान करने के लिए आपके अकाउंट में पर्याप्त ETH नहीं है।" + }, "alertMessageNetworkBusy": { "message": "गैस प्राइसें अधिक हैं और अनुमान कम सटीक हैं।" }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "एसेट के विकल्प" }, + "assets": { + "message": "एसेट्स" + }, + "assetsDescription": { + "message": "अपने वॉलेट में टोकन ऑटोडिटेक्ट करें, NFT प्रदर्शित करें, और बैच अकाउंट बैलेंस संबंधी अपडेट प्राप्त करें" + }, "attemptSendingAssets": { "message": "अगर आप एसेट्स को सीधे एक नेटवर्क से दूसरे नेटवर्क पर भेजने की कोशिश करते हैं, तो ऐसा करने से आपको एसेट्स का नुकसान हो सकता है। ब्रिज का इस्तेमाल करके नेटवर्कों के बीच फंड्स को सुरक्षित तरीके से ट्रांसफ़र करें।" }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "ब्रिज, न भेजें" }, + "bridgeFrom": { + "message": "इससे ब्रिज करें" + }, + "bridgeSelectNetwork": { + "message": "नेटवर्क को चुनें" + }, + "bridgeTo": { + "message": "इसपर ब्रिज करें" + }, "browserNotSupported": { "message": "आपका ब्राउज़र सपोर्टेड नहीं है..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "सीक्रेट रिकवरी फ्रेज कन्फर्म करें" }, + "confirmTitleApproveTransaction": { + "message": "भत्ता का अनुरोध" + }, + "confirmTitleDeployContract": { + "message": "एक कॉन्ट्रैक्ट करें" + }, + "confirmTitleDescApproveTransaction": { + "message": "यह साइट आपके NFTs को निकालने की अनुमति चाहती है" + }, + "confirmTitleDescDeployContract": { + "message": "यह साइट चाहती है कि आप एक कॉन्ट्रैक्ट करें" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "यह साइट आपके टोकन को निकालने की अनुमति चाहती है" + }, "confirmTitleDescPermitSignature": { "message": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।" }, "confirmTitleDescSIWESignature": { "message": "एक साइट चाहती है कि आप यह साबित करने के लिए साइन इन करें कि यह आपका अकाउंट है।" }, + "confirmTitleDescSign": { + "message": "कन्फर्म करने से पहले अनुरोध के विवरण की समीक्षा करें।" + }, "confirmTitlePermitTokens": { "message": "खर्च करने की सीमा का अनुरोध" }, + "confirmTitleRevokeApproveTransaction": { + "message": "अनुमति हटाएं" + }, "confirmTitleSIWESignature": { "message": "साइन-इन अनुरोध" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "अनुमति हटाएं" + }, "confirmTitleSignature": { "message": "सिग्नेचर अनुरोध" }, "confirmTitleTransaction": { "message": "ट्रांसेक्शन अनुरोध" }, + "confirmationAlertModalDetails": { + "message": "आपकी एसेट्स और लॉगिन जानकारी की सुरक्षा के लिए, हम आपको अनुरोध को रिजेक्ट करने का सुझाव देते हैं।" + }, + "confirmationAlertModalTitle": { + "message": "यह अनुरोध संदिग्ध है" + }, "confirmed": { "message": "कन्फर्म किया गया" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "हमने ENS नाम में एक कंफ्यूज़ करने वाले करैक्टर का पता लगाया है। संभावित धोखाधड़ी से बचने के लिए ENS नाम की जाँच करें।" }, + "congratulations": { + "message": "बधाइयां!" + }, "connect": { "message": "कनेक्ट करें" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "कनेक्ट की गई साइटें" }, + "connectedSitesAndSnaps": { + "message": "कनेक्टेड साइटें और Snaps" + }, "connectedSitesDescription": { "message": "$1 इन साइटों से कनेक्ट है। वे आपके अकाउंट का एड्रेस देख सकते हैं।", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask इस साइट से कनेक्टेड है, लेकिन अभी तक कोई अकाउंट कनेक्ट नहीं किया गया है" }, + "connectedSnaps": { + "message": "कनेक्टेड Snaps" + }, + "connectedWithAccount": { + "message": "$1 अकाउंट्स कनेक्ट किए गए", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "$1 से कनेक्ट किए गए", + "description": "$1 represents account name" + }, "connecting": { "message": "कनेक्ट किया जा रहा है" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Sepolia टेस्ट नेटवर्क से कनेक्ट कर रहा है" }, + "connectionDescription": { + "message": "यह साइट निम्नलिखित करना चाहती है:" + }, "connectionFailed": { "message": "कनेक्शन नहीं हो पाया" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "क्लिपबोर्ड पर एड्रेस कॉपी करें" }, + "copyAddressShort": { + "message": "एड्रेस कॉपी करें" + }, "copyPrivateKey": { "message": "प्राइवेट की (key) को कॉपी करें" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "डिफॉल्ट RPC URL" }, + "defaultSettingsSubTitle": { + "message": "MetaMask सुरक्षा और उपयोग में आसानी को संतुलित करने के लिए डिफॉल्ट सेटिंग्स का उपयोग करता है। अपनी गोपनीयता को और बढ़ाने के लिए इन सेटिंग्स को बदलें।" + }, + "defaultSettingsTitle": { + "message": "डिफॉल्ट गोपनीयता सेटिंग्स" + }, "delete": { "message": "मिटाएं" }, "deleteContact": { "message": "कॉन्टेक्ट मिटाएं" }, + "deleteMetaMetricsData": { + "message": "MetaMetrics डेटा डिलीट करें" + }, + "deleteMetaMetricsDataDescription": { + "message": "यह इस डिवाइस पर आपके उपयोग से जुड़ा ऐतिहासिक MetaMetrics डेटा डिलीट कर देगा। इस डेटा को डिलीट किए जाने के बाद आपका वॉलेट और एकाउंट्स बिल्कुल वैसे ही रहेंगे जैसे वे अभी हैं। इस प्रक्रिया में 30 दिन तक का समय लग सकता है। हमारी $1 देखें।", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "एनालिटिक्स सिस्टम सर्वर की समस्या के कारण यह अनुरोध अभी पूरा नहीं किया जा सकता है, कृपया बाद में फिर से प्रयास करें" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "हम अभी इस डेटा को डिलीट करने में असमर्थ हैं" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "हम आपके सभी MetaMetrics डेटा को हटाने वाले हैं। क्या आप वाकई ऐसा चाहते हैं?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "MetaMetrics डेटा डिलीट करें?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "आपने यह कार्रवाई $1 पर शुरू की। इस प्रक्रिया में 30 दिन तक का समय लग सकता है। $2 देखें", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "अगर आप इस नेटवर्क को हटाते हैं, तो आपको इस नेटवर्क में अपने एसेट देखने के लिए इसे फिर से जोड़ना होगा" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "डिपॉज़िट करें" }, + "depositCrypto": { + "message": "वॉलेट एड्रेस या QR कोड के साथ किसी अन्य अकाउंट से क्रिप्टो डिपॉज़िट करना।" + }, "deprecatedGoerliNtwrkMsg": { "message": "Ethereum सिस्टम में हुए अपडेट के कारण, Goerli टेस्ट नेटवर्क को जल्द ही चरणबद्ध तरीके से हटा दिया जाएगा।" }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "अकाउंट्स" }, + "disconnectAllDescriptionText": { + "message": "यदि आप इस साइट से डिस्कनेक्ट हो जाते हैं, तो आपको इस साइट का दोबारा उपयोग करने के लिए अपने एकाउंट्स और नेटवर्क को फिर से कनेक्ट करना होगा।" + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "यह आपको इस साइट से डिस्कनेक्ट कर देगा" + }, "disconnectPrompt": { "message": "$1 डिस्कनेक्ट करें" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "उपनाम बदलें" }, + "editAccounts": { + "message": "एकाउंट्स बदलें" + }, "editAddressNickname": { "message": "एड्रेस उपनाम बदलें" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "मूल नेटवर्क को संपादित करें" }, + "editNetworksTitle": { + "message": "नेटवर्क बदलें" + }, "editNonceField": { "message": "Nonce बदलें" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "अनुमति बदलें" }, + "editPermissions": { + "message": "अनुमतियां बदलें" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "गैस फ़ीस स्पीड अप को बदलें" }, + "editSpendingCap": { + "message": "खर्च करने की लिमिट बदलें" + }, + "editSpendingCapAccountBalance": { + "message": "अकाउंट बैलेंस: $1 $2" + }, + "editSpendingCapDesc": { + "message": "वह राशि डालें जिसे आप अपनी ओर से खर्च किए जाने में सहज महसूस करते हैं।" + }, + "editSpendingCapError": { + "message": "खर्च करने की सीमा $1 दशमलव अंक से अधिक नहीं हो सकती। जारी रखने के लिए दशमलव अंक हटाएं।" + }, "enableAutoDetect": { "message": " ऑटो डिटेक्ट इनेबल करें" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENS लुकअप नहीं हो पाया।" }, + "enterANameToIdentifyTheUrl": { + "message": "URL की पहचान करने के लिए नाम डालें" + }, "enterANumber": { "message": "कोई संख्या डालें" }, + "enterChainId": { + "message": "चेन ID डालें" + }, "enterCustodianToken": { "message": "अपना $1 टोकन डालें या एक नया टोकन जोड़ें" }, "enterMaxSpendLimit": { "message": "अधिकतम खर्च की लिमिट डालें" }, + "enterNetworkName": { + "message": "नेटवर्क का नाम डालें" + }, "enterOptionalPassword": { "message": "वैकल्पिक पासवर्ड डालें" }, "enterPasswordContinue": { "message": "जारी रखने के लिए पासवर्ड डालें" }, + "enterRpcUrl": { + "message": "RPC URL डालें" + }, + "enterSymbol": { + "message": "सिंबल डालें" + }, "enterTokenNameOrAddress": { "message": "टोकन नाम डालें या एड्रेस पेस्ट करें" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "एक्सपेरिमेंटल" }, + "exportYourData": { + "message": "अपना डेटा एक्सपोर्ट करें" + }, + "exportYourDataButton": { + "message": "डाउनलोड करें" + }, + "exportYourDataDescription": { + "message": "आप अपने डेटा, जैसे कि अपने कॉन्टेक्ट्स और अपनी प्रेफेरेंस को एक्सपोर्ट कर सकते हैं।" + }, "extendWalletWithSnaps": { "message": "अपने Web3 एक्सपीरियंस को कस्टमाइज़ करने के लिए कम्युनिटी-बिल्ट Snaps को एक्सप्लोर करें।", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "यह गैस फ़ीस $1 द्वारा सुझाया गया है। इसे ओवरराइड करने से आपके ट्रांसेक्शन में समस्या हो सकती है। यदि आपके पास कोई सवाल हैं तो कृपया $1 से इंटरैक्ट करें।", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "गैस फीस" + }, "gasIsETH": { "message": "गैस $1 है " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "कुछ गलत हो गया..." }, + "generalDescription": { + "message": "सभी डिवाइसों में सेटिंग्स सिंक करें, नेटवर्क प्राथमिकताएं चुनें और टोकन डेटा ट्रैक करें" + }, "genericExplorerView": { "message": "$1 पर अकाउंट देखें" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "यदि आप ऐप से बाहर हो जाते हैं या कोई नया डिवाइस प्राप्त करते हैं, तो आप अपना फंड खो देंगे। $1 में अपना सीक्रेट रिकवरी फ्रेज़ का बैकअप लेना सुनिश्चित करें ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "सभी को अनदेखा करें" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "आपके सेटिंग्स में" }, + "included": { + "message": "शामिल है" + }, "infuraBlockedNotification": { "message": "MetaMask ब्लॉकचेन होस्ट से कनेक्ट करने में असमर्थ है। संभावित कारणों की समीक्षा करें $1।", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON फाइल", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "अपने सीक्रेट रिकवरी फ्रेज़ का रिमाइंडर कहीं सुरक्षित रखें। यदि आप इसे खो देते हैं, तो इसे वापस पाने में कोई आपकी मदद नहीं कर सकता। इससे भी बुरी बात यह है कि आप कभी भी अपने वॉलेट तक पहुंच नहीं पाएंगे। $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "अकाउंट का नाम" }, @@ -2402,6 +2622,9 @@ "message": "$1 करने का तरीका जानें", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "जानें कैसे" + }, "learnMore": { "message": "अधिक जानें" }, @@ -2409,6 +2632,9 @@ "message": "गैस के बारे में $1 चाहते हैं?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "गोपनीयता की सर्वोत्तम प्रथाओं के बारे में और जानें।" + }, "learnMoreKeystone": { "message": "और अधिक जानें" }, @@ -2497,6 +2723,9 @@ "link": { "message": "लिंक" }, + "linkCentralizedExchanges": { + "message": "क्रिप्टो को मुफ्त में MetaMask में ट्रांसफ़र करने के लिए अपने Coinbase या Binance अकाउंट को लिंक करें।" + }, "links": { "message": "लिंक" }, @@ -2557,6 +2786,9 @@ "message": "पक्का करें कि इसे कोई भी नहीं देख रहा है", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "डिफॉल्ट गोपनीयता सेटिंग्स मैनेज करें" + }, "marketCap": { "message": "मार्केट कैप" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "कनेक्शन स्थिति बटन दिखाता है कि आप जिस वेबसाइट पर जा रहे हैं, वह आपके वर्तमान में चुना गया अकाउंट से कनेक्ट है।" }, + "metaMetricsIdNotAvailableError": { + "message": "चूंकि आपने कभी भी MetaMetrics का विकल्प नहीं चुना है, इसलिए यहां डिलीट करने के लिए कोई डेटा नहीं है।" + }, "metadataModalSourceTooltip": { "message": "$1 को npm पर होस्ट किया गया है और $2 इस Snap का यूनीक आइडेंटिफायर है।", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "अधिक" }, + "moreAccounts": { + "message": "+ $1 और अकाउंट", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 और नेटवर्क", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "आप इस नेटवर्क को MetaMask में जोड़ रहे हैं और इस साइट को इसका उपयोग करने की अनुमति दे रहे हैं।" + }, "multipleSnapConnectionWarning": { "message": "$1 $2 Snaps का उपयोग करना चाहता है", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "नेटवर्क का ब्यौरा बदलें" }, "nativeTokenScamWarningDescription": { - "message": "यह नेटवर्क अपनी एसोसिएटेड चेन ID या नाम से मेल नहीं खाता है। कई लोकप्रिय टोकन $1 नाम का उपयोग करते हैं, जिससे इसमें स्कैम किया जा सकता है। स्कैम करने वाले आपको बदले में ज़्यादा कीमती करेंसी भेजने का झांसा दे सकते हैं। आगे बढ़ने से पहले सब कुछ वेरीफाई करें।", + "message": "मूल टोकन सिंबल, संबंधित चेन ID वाले नेटवर्क के लिए मूल टोकन के अपेक्षित सिंबल से मेल नहीं खाता। आपने $1 डाला है जबकि अपेक्षित टोकन सिंबल $2 है। कृपया वेरीफाई करें कि आप सही चेन से जुड़े हैं।", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "कुछ और", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "यह एक बड़ा स्कैम हो सकता है", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "नेटवर्क विवरण" }, + "networkFee": { + "message": "नेटवर्क फी" + }, "networkIsBusy": { "message": "नेटवर्क व्यस्त है। गैस प्राइसें अधिक हैं और अनुमान कम सटीक हैं।" }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "नेटवर्क के विकल्प" }, + "networkPermissionToast": { + "message": "नेटवर्क अनुमतियां अपडेट की गईं" + }, "networkProvider": { "message": "नेटवर्क प्रोवाइडर" }, @@ -2865,15 +3121,26 @@ "message": "हम $1 से कनेक्ट नहीं कर सकते", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "नेटवर्क $1 पर स्विच किया गया", + "description": "$1 represents the network name" + }, "networkURL": { "message": "नेटवर्क URL" }, "networkURLDefinition": { "message": "इस नेटवर्क तक पहुंचने के लिए इस्तेमाल किया जाने वाला URL।" }, + "networkUrlErrorWarning": { + "message": "हमला करने वाले कभी-कभी साइट के एड्रेस में छोटे-छोटे बदलाव करके साइटों की नकल करते हैं। जारी रखने से पहले सुनिश्चित करें कि आप इच्छित साइट के साथ इंटरैक्ट कर रहे हैं। पुनीकोड ​​वर्शन: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "नेटवर्क" }, + "networksSmallCase": { + "message": "नेटवर्क" + }, "nevermind": { "message": "कोई बात नहीं" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "हमने अपनी गोपनीयता नीति अपडेट कर दी है" }, + "newRpcUrl": { + "message": "नया RPC URL" + }, "newTokensImportedMessage": { "message": "आपने सफलतापूर्वक $1 इम्पोर्ट कर लिया है।", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask इस साइट से कनेक्टेड नहीं है।" }, + "noConnectionDescription": { + "message": "किसी साइट से कनेक्ट करने के लिए, \"कनेक्ट करें\" बटन ढूंढें और चुनें। याद रखें MetaMask केवल web3 पर साइटों से कनेक्ट हो सकता है" + }, "noConversionRateAvailable": { "message": "कोई भी कन्वर्शन दर उपलब्ध नहीं है" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "कस्टम Nonce" }, + "none": { + "message": "कुछ नहीं" + }, "notBusy": { "message": "व्यस्त नहीं" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "अनुमति का ब्यौरा" }, + "permissionFor": { + "message": "के लिए अनुमतियां" + }, + "permissionFrom": { + "message": "से अनुमतियां" + }, "permissionRequest": { "message": "अनुमति अनुरोध" }, @@ -3593,6 +3875,14 @@ "message": "$1 को आपकी MetaMask सेटिंग्स से आपकी पसंदीदा भाषा ऐक्सेस करने दें। इसे आपकी भाषा का उपयोग करके $1 के कंटेंट का स्थानीय भाषा में अनुवाद करने और उसे दिखाने के लिए इस्तेमाल किया जा सकता है।", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "अपनी पसंदीदा भाषा और फिएट करेंसी जैसी जानकारी देखें।", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "$1 को अपनी MetaMask सेटिंग्स में अपनी पसंदीदा भाषा और फिएट करेंसी जैसी जानकारी तक पहुंचने दें। यह $1 को आपकी प्राथमिकताओं के अनुरूप सामग्री प्रदर्शित करने में मदद करता है। ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "एक कस्टम स्क्रीन दिखाएं", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "आप खर्च करने वाले को अपने अकाउंट से इतने सारे टोकन खर्च करने की अनुमति दे रहे हैं।" }, + "permittedChainToastUpdate": { + "message": "$1 की पहुंच $2 तक है।" + }, "personalAddressDetected": { "message": "व्यक्तिगत एड्रेस का एड्रेस चला। टोकन कॉन्ट्रैक्ट एड्रेस डालें।" }, @@ -3963,6 +4256,12 @@ "receive": { "message": "प्राप्त करें" }, + "receiveCrypto": { + "message": "क्रिप्टो प्राप्त करें" + }, + "recipientAddressPlaceholderNew": { + "message": "पब्लिक एड्रेस (0x) या डोमेन नाम डालें" + }, "recommendedGasLabel": { "message": "अनुशंसित" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "रिजेक्ट" }, + "rememberSRPIfYouLooseAccess": { + "message": "याद रखें, यदि आप अपना सीक्रेट रिकवरी फ्रेज़ खो देते हैं, तो आप अपने वॉलेट तक पहुंच खो देते हैं। शब्दों के इस सेट को सुरक्षित रखने के लिए $1 ताकि आप हमेशा अपने फंड तक पहुंच सकें।" + }, + "reminderSet": { + "message": "रिमाइंडर सेट किया गया!" + }, "remove": { "message": "हटाएं" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "किसी गड़बड़ी के कारण, इस रिक्वेस्ट को सुरक्षा प्रोवाइडर द्वारा वेरीफ़ाई नहीं किया गया था। सावधानी के साथ आगे बढ़ना।" }, + "requestingFor": { + "message": "के लिए अनुरोध कर रहे हैं" + }, + "requestingForAccount": { + "message": "$1 के लिए अनुरोध कर रहे हैं", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "रिक्वेस्ट्स के स्वीकार किए जाने की प्रतीक्षा की जा रही है" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "सीड फ़्रेज़ दिखाएं" }, + "review": { + "message": "समीक्षा करें" + }, + "reviewAlert": { + "message": "एलर्ट की समीक्षा करें" + }, "reviewAlerts": { "message": "एलर्ट की समीक्षा करें" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "अनुमति कैंसिल करने की" }, + "revokeSimulationDetailsDesc": { + "message": "आप किसी अन्य को आपके अकाउंट से टोकन ख़र्च करने के लिए दी गई अनुमति हटा रहे हैं।" + }, "revokeSpendingCap": { "message": "अपने $1 के लिए खर्च करने की लिमिट को हटा दें", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "यह थर्ड पार्टी आपके वर्तमान या भविष्य के और ज्यादा टोकन खर्च करने में असमर्थ होगा।" }, + "rpcNameOptional": { + "message": "RPC का नाम (वैकल्पिक)" + }, "rpcUrl": { "message": "नया RPC URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "सुरक्षा और गोपनीयता" }, + "securityDescription": { + "message": "असुरक्षित नेटवर्क में शामिल होने की संभावना कम करें और अपने एकाउंट्स की सुरक्षा करें" + }, + "securityMessageLinkForNetworks": { + "message": "नेटवर्क संबंधी स्कैम और सुरक्षा जोखिम" + }, + "securityPrivacyPath": { + "message": "सेटिंग्स > सुरक्षा और गोपनीयता।" + }, "securityProviderPoweredBy": { "message": "$1 द्वारा पावर्ड", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "सभी अनुमतियां देखें", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "ब्यौरा देखें" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "यदि आपको अपनी पसंद के हिसाब से अकाउंट दिखाई नहीं देते हैं, तो HD पाथ या मौजूदा चुना गया नेटवर्क बदलने की कोशिश करें।" }, + "selectRpcUrl": { + "message": "RPC URL को चुनें" + }, "selectType": { "message": "प्रकार को चुनें" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "सभी के लिए स्वीकृति सेट करें" }, + "setApprovalForAllRedesignedTitle": { + "message": "विदड्रॉवल का अनुरोध" + }, "setApprovalForAllTitle": { "message": "बिना किसी खर्च की लिमिट के $1 एप्रूव करें", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "सेटिंग" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "उपयोग में आसानी और सुरक्षा के लिए सेटिंग्स को अनुकूलित किया गया है। इन्हें किसी भी समय बदलें।" + }, "settingsSearchMatchingNotFound": { "message": "कोई मेल खाने वाला परिणाम नहीं मिला।" }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "अधिक दिखाएं" }, + "showNativeTokenAsMainBalance": { + "message": "मूल टोकन को मुख्य बैलेंस के रूप में दिखाएं" + }, "showNft": { "message": "NFT दिखाएं" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "के साथ साइन इन करना" }, + "simulationApproveHeading": { + "message": "निकालें" + }, + "simulationDetailsApproveDesc": { + "message": "आप किसी अन्य को अपने अकाउंट से NFTs निकालने की अनुमति दे रहे हैं।" + }, + "simulationDetailsERC20ApproveDesc": { + "message": "आप किसी अन्य को आपके अकाउंट से यह राशि ख़र्च करने की अनुमति दे रहे हैं।" + }, "simulationDetailsFiatNotAvailable": { "message": "उपलब्ध नहीं है" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "आप भेजते हैं" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "आप किसी अन्य को आपके अकाउंट से NFTs निकालने के लिए दी गई अनुमति हटा रहे हैं।" + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "आप किसी अन्य के लिए, आपके अकाउंट से NFTs निकालने की अनुमति दे रहे हैं।" + }, "simulationDetailsTitle": { "message": "अनुमानित बदलाव" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "ओह! कुछ गलत हो गया।" }, + "sortBy": { + "message": "इसके अनुसार क्रमबद्ध करें" + }, + "sortByAlphabetically": { + "message": "वर्णानुक्रम में (A-Z)" + }, + "sortByDecliningBalance": { + "message": "घटते हुए बैलेंस के क्रम में ($1 उच्च-निम्न)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "स्त्रोत" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "खर्च करने वाला" }, + "spenderTooltipDesc": { + "message": "यही वह एड्रेस है जो आपके NFTs को निकाल पाएगा।" + }, + "spenderTooltipERC20ApproveDesc": { + "message": "यह वह एड्रेस है जो आपकी ओर से आपके टोकन खर्च करने में सक्षम होगा।" + }, "spendingCap": { "message": "खर्च करने की लिमिट" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "आपके $1 के लिए खर्च करने की लिमिट का अनुरोध" }, + "spendingCapTooltipDesc": { + "message": "यह टोकन की वह राशि है जिसे खर्च करने वाला आपकी ओर से प्राप्त कर सकेगा।" + }, "srpInputNumberOfWords": { "message": "मेरे पास एक $1-शब्द का फ़्रेज़ है", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "$1 के द्वारा सुझाव दिया गया", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "सुझाया गया करेंसी सिंबल:" + }, "suggestedTokenName": { "message": "सुझाया गया नाम:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "हमारे सहायता केंद्र पर जाएं" }, + "supportMultiRpcInformation": { + "message": "अब हम एक ही नेटवर्क के लिए कई RPC को सपोर्ट करते हैं। विरोधाभासी जानकारी को हल करने के लिए आपके सबसे हाल के RPC को डिफ़ॉल्ट के रूप में चुना गया है।" + }, "surveyConversion": { "message": "हमारे सर्वे में भाग लें" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "गैस फ़ीस का अनुमान लगाया जाता है और नेटवर्क ट्रैफिक और ट्रांसेक्शन की जटिलता के आधार पर इसमें उतार-चढ़ाव आएगा।" }, + "swapGasFeesExplanation": { + "message": "MetaMask गैस फीस से पैसे नहीं कमाता है। ये शुल्क अनुमानित हैं और इस आधार पर बदल सकते हैं कि नेटवर्क कितना व्यस्त है और ट्रांसेक्शन कितना जटिल है। अधिक जानें $1।", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "यहाँ", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "गैस फ़ीस के बारे में और अधिक जानें" }, @@ -5186,9 +5583,19 @@ "message": "क्रिप्टो माइनरों को गैस फ़ीस का पेमेंट किया जाता है जो $1 नेटवर्क पर ट्रांसेक्शन की प्रक्रिया करते हैं। MetaMask को गैस फ़ीस से लाभ नहीं होता है।", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "यह कोटेशन भेजे गए या प्राप्त टोकन राशि को एडजस्ट करके गैस फीस को शामिल करता है। आप अपनी गतिविधि सूची में एक अलग ट्रांसेक्शन में ETH प्राप्त कर सकते हैं।" + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "गैस फीस के बारे में और अधिक जानें" + }, "swapHighSlippage": { "message": "अधिक स्लिपेज" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "इसमें गैस और $1% MetaMask फीस शामिल है", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "$1% MetaMask फ़ीस शामिल है।", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "हमारी इस्तेमाल की शर्तों को अपडेट कर दिया गया है" }, + "testnets": { + "message": "Testnets" + }, "theme": { "message": "थीम" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "ट्रांसेक्शन फ़ीस" }, + "transactionFlowNetwork": { + "message": "नेटवर्क" + }, "transactionHistoryBaseFee": { "message": "बेस फ़ीस (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "ट्रांसफ़र" }, + "transferCrypto": { + "message": "क्रिप्टो ट्रांसफ़र करें" + }, "transferFrom": { "message": "इससे ट्रांसफ़र करें" }, + "transferRequest": { + "message": "अनुरोध ट्रांसफर करें" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "अपडेट करें" }, + "updateEthereumChainConfirmationDescription": { + "message": "यह साइट आपके डिफ़ॉल्ट नेटवर्क URL को अपडेट करने का अनुरोध कर रही है। आप किसी भी समय डिफ़ॉल्ट और नेटवर्क जानकारी बदल सकते हैं।" + }, + "updateNetworkConfirmationTitle": { + "message": "$1 अपडेट करें", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "अपनी जानकारी अपडेट करें या" }, "updateRequest": { "message": "अपडेट का अनुरोध" }, + "updatedRpcForNetworks": { + "message": "नेटवर्क RPC अपडेट किया गया" + }, "uploadDropFile": { "message": "अपनी फ़ाइल यहां छोड़ें" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "हमारी hardware wallet कनेक्शन गाइड" }, + "walletProtectedAndReadyToUse": { + "message": "आपका वॉलेट सुरक्षित है और उपयोग के लिए तैयार है। आप अपना सीक्रेट रिकवरी फ्रेज़ $1 में पा सकते हैं ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "इस नेटवर्क को जोड़ना चाहते हैं?" }, @@ -5991,6 +6424,17 @@ "message": "$1 थर्ड पार्टी आपकी संपूर्ण टोकन बैलेंस को बिना किसी सूचना या सहमति के खर्च कर सकता है। खर्च की कम सीमा को कस्टमाइज़ करके खुद को सुरक्षित रखें।", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "इस विकल्प को चालू करने से आपको पब्लिक एड्रेस या ENS नाम के माध्यम से Ethereum एकाउंट्स को देखने की सुविधा मिलेगी। इस Beta फीचर पर प्रतिक्रिया के लिए कृपया इस $1 को पूरा करें।", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Ethereum एकाउंट्स देखें (Beta)" + }, + "watchOutMessage": { + "message": "$1 से सावधान रहें।", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "कमज़ोर" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "यह क्या है?" }, + "withdrawing": { + "message": "निकाला जा रहा है" + }, "wrongNetworkName": { "message": "हमारे रिकॉर्ड के अनुसार, नेटवर्क का नाम इस चेन ID से ठीक से मेल नहीं खा सकता है।" }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "आपका बैलेंस" }, + "yourBalanceIsAggregated": { + "message": "आपका बैलेंस एकत्रित हो गया है" + }, "yourNFTmayBeAtRisk": { "message": "आपका NFT खतरे में हो सकता है" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "हम आपके ट्रांसेक्शन को रद्द नहीं कर सके क्योंकि ब्लॉकचेन पर यह पहले ही कन्फर्म हो चुका है।" }, + "yourWalletIsReady": { + "message": "आपका वॉलेट तैयार है" + }, "zeroGasPriceOnSpeedUpError": { "message": "जीरो गैस प्राइस में तेज़ी" } diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index b070fb14cda9..e7c719318f69 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Hubungkan dompet perangkat keras QR Anda" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Alamat pada permintaan masuk tidak sesuai dengan alamat akun yang Anda gunakan untuk masuk." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Pilih akun yang ingin mendapatkan notifikasi:" }, + "accountBalance": { + "message": "Saldo akun" + }, "accountDetails": { "message": "Detail akun" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Opsi akun" }, + "accountPermissionToast": { + "message": "Izin akun diperbarui" + }, "accountSelectionRequired": { "message": "Anda harus memilih satu akun!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Akun terhubung" }, + "accountsPermissionsTitle": { + "message": "Melihat akun Anda dan menyarankan transaksi" + }, + "accountsSmallCase": { + "message": "akun" + }, "active": { "message": "Aktif" }, @@ -180,12 +195,18 @@ "add": { "message": "Tambah" }, + "addACustomNetwork": { + "message": "Tambahkan jaringan khusus" + }, "addANetwork": { "message": "Tambahkan jaringan" }, "addANickname": { "message": "Tambahkan nama panggilan" }, + "addAUrl": { + "message": "Tambahkan URL" + }, "addAccount": { "message": "Tambahkan akun" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Tambahkan block explorer" }, + "addBlockExplorerUrl": { + "message": "Tambahkan URL block explorer" + }, "addContact": { "message": "Tambah kontak" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Anda menambahkan penyedia RPC baru untuk Ethereum Mainnet" }, + "addEthereumWatchOnlyAccount": { + "message": "Pantau akun Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Tambahkan teman dan alamat yang Anda percayai" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Tambahkan jaringan" }, + "addNetworkConfirmationTitle": { + "message": "Tambahkan $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Tambahkan akun Ethereum baru" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Alamat disalin!" }, + "addressMismatch": { + "message": "Ketidakcocokan alamat situs" + }, + "addressMismatchOriginal": { + "message": "URL saat ini: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Versi Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Lanjutan" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Biaya prioritas (alias “tip penambang”) langsung masuk ke penambang dan memberi insentif kepada mereka untuk memprioritaskan transaksi Anda." }, + "aggregatedBalancePopover": { + "message": "Ini mencerminkan nilai semua token yang Anda miliki di jaringan tertentu. Jika Anda lebih suka melihat nilai ini dalam ETH atau mata uang lainnya, pilih $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Saya menyetujui $1 MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Ini dapat diubah dalam \"Pengaturan > Peringatan\"" }, + "alertMessageAddressMismatchWarning": { + "message": "Penyerang terkadang meniru situs dengan membuat perubahan kecil pada alamat situs. Pastikan Anda berinteraksi dengan situs yang dituju sebelum melanjutkan." + }, "alertMessageGasEstimateFailed": { "message": "Kami tidak dapat memberikan biaya akurat dan estimasi ini mungkin tinggi. Kami menyarankan Anda untuk memasukkan batas gas kustom, tetapi ada risiko transaksi tetap gagal." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Untuk melanjutkan transaksi ini, Anda perlu meningkatkan batas gas menjadi 21000 atau lebih tinggi." }, + "alertMessageInsufficientBalance2": { + "message": "Anda tidak memiliki cukup ETH di akun untuk membayar biaya jaringan." + }, "alertMessageNetworkBusy": { "message": "Harga gas tinggi dan estimasinya kurang akurat." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Opsi aset" }, + "assets": { + "message": "Aset" + }, + "assetsDescription": { + "message": "Autodeteksi token di dompet Anda, tampilkan NFT, dan dapatkan pembaruan saldo akun secara batch" + }, "attemptSendingAssets": { "message": "Aset Anda berpotensi hilang jika mencoba mengirimnya dari jaringan lain. Transfer dana secara aman antar jaringan menggunakan bridge." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Bridge, jangan kirim" }, + "bridgeFrom": { + "message": "Bridge dari" + }, + "bridgeSelectNetwork": { + "message": "Pilih jaringan" + }, + "bridgeTo": { + "message": "Bridge ke" + }, "browserNotSupported": { "message": "Browser Anda tidak didukung..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Konfirmasikan Frasa Pemulihan Rahasia" }, + "confirmTitleApproveTransaction": { + "message": "Permintaan izin" + }, + "confirmTitleDeployContract": { + "message": "Terapkan kontrak" + }, + "confirmTitleDescApproveTransaction": { + "message": "Situs ini meminta izin untuk menarik NFT Anda" + }, + "confirmTitleDescDeployContract": { + "message": "Situs ini meminta Anda untuk menerapkan kontrak" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Situs ini meminta izin untuk menarik token Anda" + }, "confirmTitleDescPermitSignature": { "message": "Situs ini meminta izin untuk menggunakan token Anda." }, "confirmTitleDescSIWESignature": { "message": "Sebuah situs ingin Anda masuk untuk membuktikan Anda pemilik akun ini." }, + "confirmTitleDescSign": { + "message": "Tinjau detail permintaan sebelum Anda mengonfirmasi." + }, "confirmTitlePermitTokens": { "message": "Permintaan batas penggunaan" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Hapus izin" + }, "confirmTitleSIWESignature": { "message": "Permintaan masuk" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Hapus izin" + }, "confirmTitleSignature": { "message": "Permintaan tanda tangan" }, "confirmTitleTransaction": { "message": "Permintaan transaksi" }, + "confirmationAlertModalDetails": { + "message": "Untuk melindungi aset dan informasi login Anda, sebaiknya tolak permintaan tersebut." + }, + "confirmationAlertModalTitle": { + "message": "Permintaan ini mencurigakan" + }, "confirmed": { "message": "Dikonfirmasikan" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Kami telah mendeteksi karakter yang membingungkan di nama ENS. Periksa nama ENS untuk menghindari kemungkinan penipuan." }, + "congratulations": { + "message": "Selamat!" + }, "connect": { "message": "Hubungkan" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Situs yang terhubung" }, + "connectedSitesAndSnaps": { + "message": "Situs dan Snaps yang terhubung" + }, "connectedSitesDescription": { "message": "$1 terhubung ke situs ini. Mereka dapat melihat alamat akun Anda.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask terhubung ke situs ini, tetapi belum ada akun yang terhubung" }, + "connectedSnaps": { + "message": "Snaps yang Terhubung" + }, + "connectedWithAccount": { + "message": "$1 akun terhubung", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Terhubung dengan $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Menghubungkan" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Menghubungkan ke jaringan uji Sepolia" }, + "connectionDescription": { + "message": "Situs ini ingin" + }, "connectionFailed": { "message": "Koneksi gagal" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Salin alamat ke papan klip" }, + "copyAddressShort": { + "message": "Salin alamat" + }, "copyPrivateKey": { "message": "Salin kunci pribadi" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL RPC Default" }, + "defaultSettingsSubTitle": { + "message": "MetaMask menggunakan pengaturan default untuk menyeimbangkan keamanan dan kemudahan penggunaan. Ubah pengaturan ini untuk lebih meningkatkan privasi Anda." + }, + "defaultSettingsTitle": { + "message": "Pengaturan privasi default" + }, "delete": { "message": "Hapus" }, "deleteContact": { "message": "Hapus kontak" }, + "deleteMetaMetricsData": { + "message": "Hapus data MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Ini akan menghapus data MetaMetrics historis yang terkait dengan penggunaan Anda pada perangkat ini. Dompet dan akun Anda akan tetap sama seperti sekarang setelah data ini dihapus. Proses ini dapat memakan waktu hingga 30 hari. Lihat $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Permintaan ini tidak dapat diselesaikan sekarang karena masalah server sistem analitis, coba lagi nanti" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Kami tidak dapat menghapus data ini sekarang" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Kami akan menghapus semua data MetaMetrics Anda. Lanjutkan?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Hapus data MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Anda memulai tindakan ini pada $1. Proses ini dapat memakan waktu hingga 30 hari. Lihat $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Jika menghapus jaringan ini, Anda harus menambahkannya lagi untuk melihat aset Anda di jaringan ini" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Deposit" }, + "depositCrypto": { + "message": "Mendeposit kripto dari akun lain dengan alamat dompet atau kode QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Karena pembaruan pada sistem Ethereum, jaringan uji Goerli akan segera dihentikan." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "akun" }, + "disconnectAllDescriptionText": { + "message": "Jika Anda memutuskan koneksi dari situs ini, Anda harus menghubungkan kembali akun dan jaringan agar dapat menggunakan situs ini lagi." + }, "disconnectAllSnapsText": { "message": "Snap" }, + "disconnectMessage": { + "message": "Ini akan memutus koneksi dari situs ini" + }, "disconnectPrompt": { "message": "Putuskan koneksi $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Edit nama panggilan" }, + "editAccounts": { + "message": "Edit akun" + }, "editAddressNickname": { "message": "Edit nama panggilan alamat" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "edit jaringan asli" }, + "editNetworksTitle": { + "message": "Edit jaringan" + }, "editNonceField": { "message": "Edit nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Edit izin" }, + "editPermissions": { + "message": "Edit izin" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Edit biaya gas percepatan" }, + "editSpendingCap": { + "message": "Edit batas penggunaan" + }, + "editSpendingCapAccountBalance": { + "message": "Saldo akun: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Masukkan jumlah yang layak untuk digunakan atas nama Anda." + }, + "editSpendingCapError": { + "message": "Batas penggunaan tidak boleh melebihi $1 digit desimal. Hapus digit desimal untuk melanjutkan." + }, "enableAutoDetect": { "message": " Aktifkan deteksi otomatis" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Pencarian ENS gagal." }, + "enterANameToIdentifyTheUrl": { + "message": "Masukkan nama untuk mengidentifikasi URL" + }, "enterANumber": { "message": "Masukkan angka" }, + "enterChainId": { + "message": "Masukkan ID Chain" + }, "enterCustodianToken": { "message": "Masukkan token $1 atau tambahkan token baru" }, "enterMaxSpendLimit": { "message": "Masukkan batas penggunaan maksimum" }, + "enterNetworkName": { + "message": "Masukkan nama jaringan" + }, "enterOptionalPassword": { "message": "Masukkan kata sandi opsional" }, "enterPasswordContinue": { "message": "Masukkan kata sandi untuk melanjutkan" }, + "enterRpcUrl": { + "message": "Masukkan URL RPC" + }, + "enterSymbol": { + "message": "Masukkan simbol" + }, "enterTokenNameOrAddress": { "message": "Masukkan nama token atau tempel alamat" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Eksperimental" }, + "exportYourData": { + "message": "Ekspor data Anda" + }, + "exportYourDataButton": { + "message": "Unduh" + }, + "exportYourDataDescription": { + "message": "Anda dapat mengekspor data seperti kontak dan preferensi Anda." + }, "extendWalletWithSnaps": { "message": "Jelajahi Snap yang dibuat oleh komunitas untuk menyesuaikan pengalaman web3 Anda", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Biaya gas ini telah disarankan oleh $1. Pengabaian dapat menyebabkan masalah pada transaksi Anda. Hubungi $1 jika ada pertanyaan.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Biaya gas" + }, "gasIsETH": { "message": "Gas bernilai $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Terjadi kesalahan...." }, + "generalDescription": { + "message": "Sinkronkan pengaturan di seluruh perangkat, pilih preferensi jaringan, dan lacak data token" + }, "genericExplorerView": { "message": "Lihat akun di $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Dana akan hilang jika Anda terkunci dari aplikasi atau menggunakan perangkat baru. Pastikan untuk mencadangkan Frasa Pemulihan Rahasia di $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Abaikan semua" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "di Pengaturan Anda" }, + "included": { + "message": "termasuk" + }, "infuraBlockedNotification": { "message": "MetaMask tidak dapat terhubung ke host blockchain. Tinjau alasan yang mungkin $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "File JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Simpan pengingat Frasa Pemulihan Rahasia di tempat yang aman. Jika hilang, tidak ada yang bisa membantu Anda mendapatkannya kembali. Lebih buruknya, dompet Anda tidak dapat diakses lagi. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Nama akun" }, @@ -2402,6 +2622,9 @@ "message": "Pelajari cara $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Pelajari caranya" + }, "learnMore": { "message": "pelajari selengkapnya" }, @@ -2409,6 +2632,9 @@ "message": "Ingin $1 seputar gas?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Selengkapnya seputar praktik terbaik privasi." + }, "learnMoreKeystone": { "message": "Pelajari Selengkapnya" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Tautan" }, + "linkCentralizedExchanges": { + "message": "Tautkan akun Coinbase atau Binance untuk mentransfer kripto ke MetaMask secara gratis." + }, "links": { "message": "Tautan" }, @@ -2557,6 +2786,9 @@ "message": "Pastikan tidak ada yang melihat layar Anda", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Kelola pengaturan privasi default" + }, "marketCap": { "message": "Kap pasar" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Tombol status koneksi menunjukkan apakah situs web yang Anda kunjungi terhubung ke akun Anda yang dipilih saat ini." }, + "metaMetricsIdNotAvailableError": { + "message": "Karena Anda belum pernah ikut serta dalam MetaMetrics, tidak ada data yang dapat dihapus di sini." + }, "metadataModalSourceTooltip": { "message": "$1 dihosting pada npm dan $2 merupakan pengenal unik Snap ini.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "selengkapnya" }, + "moreAccounts": { + "message": "+ $1 akun lain", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 jaringan lain", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Anda menambahkan jaringan ini ke MetaMask dan memberikan situs ini izin untuk menggunakannya." + }, "multipleSnapConnectionWarning": { "message": "$1 ingin menggunakan Snap $2", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Edit detail jaringan" }, "nativeTokenScamWarningDescription": { - "message": "Jaringan ini tidak cocok dengan ID chain atau nama yang terkait. Banyak token populer menggunakan nama $1, menjadikannya target penipuan. Penipu dapat mengelabui Anda agar mengirimkan mata uang yang lebih berharga sebagai imbalannya. Verifikasikan semuanya sebelum melanjutkan.", + "message": "Simbol token asli tidak cocok dengan simbol token asli yang diharapkan untuk jaringan dengan ID chain yang terkait. Anda telah memasukkan $1 sementara simbol token yang diharapkan adalah $2. Verifikasikan bahwa Anda terhubung ke chain yang benar.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "suatu hal lainnya", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Ini berpotensi penipuan", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Detail jaringan" }, + "networkFee": { + "message": "Biaya jaringan" + }, "networkIsBusy": { "message": "Jaringan sibuk. Harga gas tinggi dan estimasinya kurang akurat." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Opsi jaringan" }, + "networkPermissionToast": { + "message": "Izin jaringan diperbarui" + }, "networkProvider": { "message": "Penyedia jaringan" }, @@ -2865,15 +3121,26 @@ "message": "Kami tidak dapat terhubung ke $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Jaringan beralih ke $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL Jaringan" }, "networkURLDefinition": { "message": "URL yang digunakan untuk mengakses jaringan ini." }, + "networkUrlErrorWarning": { + "message": "Penyerang terkadang meniru situs dengan membuat perubahan kecil pada alamat situs. Pastikan Anda berinteraksi dengan situs yang dituju sebelum melanjutkan. Versi Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Jaringan" }, + "networksSmallCase": { + "message": "jaringan" + }, "nevermind": { "message": "Lupakan" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Kami telah memperbarui kebijakan privasi" }, + "newRpcUrl": { + "message": "URL RPC Baru" + }, "newTokensImportedMessage": { "message": "Anda berhasil mengimpor $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask tidak terhubung ke situs ini" }, + "noConnectionDescription": { + "message": "Untuk terhubung ke situs, cari dan pilih tombol \"hubungkan\". Ingat MetaMask hanya dapat terhubung ke situs di web3" + }, "noConversionRateAvailable": { "message": "Nilai konversi tidak tersedia" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Nonce kustom" }, + "none": { + "message": "Tidak ada" + }, "notBusy": { "message": "Tidak sibuk" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Detail izin" }, + "permissionFor": { + "message": "Izin untuk" + }, + "permissionFrom": { + "message": "Izin dari" + }, "permissionRequest": { "message": "Permintaan izin" }, @@ -3593,6 +3875,14 @@ "message": "Izinkan $1 mengakses bahasa pilihan Anda dari pengaturan MetaMask. Ini dapat digunakan untuk melokalisasi dan menampilkan konten $1 menggunakan bahasa Anda.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Lihat informasi seperti bahasa pilihan Anda dan mata uang fiat.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Izinkan $1 mengakses informasi seperti bahasa pilihan dan mata uang fiat di pengaturan MetaMask Anda. Ini membantu $1 menampilkan konten yang disesuaikan dengan preferensi Anda. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Tampilkan layar khusus", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Anda memberikan izin kepada pengguna untuk menggunakan token sebanyak ini dari akun." }, + "permittedChainToastUpdate": { + "message": "$1 memiliki akses ke $2." + }, "personalAddressDetected": { "message": "Alamat pribadi terdeteksi. Masukkan alamat kontrak token." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Terima" }, + "receiveCrypto": { + "message": "Terima kripto" + }, + "recipientAddressPlaceholderNew": { + "message": "Masukkan alamat publik (0x) atau nama domain" + }, "recommendedGasLabel": { "message": "Direkomendasikan" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Ditolak" }, + "rememberSRPIfYouLooseAccess": { + "message": "Ingat, jika Anda kehilangan Frasa Pemulihan Rahasia, akses ke dompet Anda akan hilang. $1 untuk menyimpan rangkaian kata-kata ini dengan aman sehingga Anda dapat mengakses dana setiap saat." + }, + "reminderSet": { + "message": "Pengingat diatur!" + }, "remove": { "message": "Hapus" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Karena terjadi kesalahan, permintaan ini tidak diverifikasi oleh penyedia keamanan. Lanjutkan dengan hati-hati." }, + "requestingFor": { + "message": "Meminta untuk" + }, + "requestingForAccount": { + "message": "Meminta untuk $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "permintaan menunggu untuk disetujui" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Ungkap frasa seed" }, + "review": { + "message": "Tinjau" + }, + "reviewAlert": { + "message": "Tinjau peringatan" + }, "reviewAlerts": { "message": "Tinjau peringatan" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Cabut izin" }, + "revokeSimulationDetailsDesc": { + "message": "Anda menghapus izin orang lain untuk menggunakan token dari akun Anda." + }, "revokeSpendingCap": { "message": "Cabut batas penggunaan untuk $1 Anda", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Pihak ketiga ini tidak akan dapat menggunakan token Anda saat ini atau di masa mendatang." }, + "rpcNameOptional": { + "message": "Nama RPC (Opsional)" + }, "rpcUrl": { "message": "URL RPC Baru" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Keamanan & privasi" }, + "securityDescription": { + "message": "Kurangi kemungkinan Anda bergabung dengan jaringan yang tidak aman dan lindungi akun Anda" + }, + "securityMessageLinkForNetworks": { + "message": "penipuan jaringan dan risiko keamanan" + }, + "securityPrivacyPath": { + "message": "Pengaturan > Keamanan & Privasi." + }, "securityProviderPoweredBy": { "message": "Didukung oleh $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Lihat semua izin", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Lihat detailnya" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Jika Anda tidak menemukan akun yang diharapkan, coba alihkan jalur HD atau jaringan yang dipilih saat ini." }, + "selectRpcUrl": { + "message": "Pilih URL RPC" + }, "selectType": { "message": "Pilih Jenis" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Atur persetujuan untuk semua" }, + "setApprovalForAllRedesignedTitle": { + "message": "Permintaan penarikan" + }, "setApprovalForAllTitle": { "message": "Setujui $1 tanpa batas penggunaan", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Pengaturan" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Pengaturan dioptimalkan untuk kemudahan penggunaan dan keamanan. Ubah pengaturan ini kapan saja." + }, "settingsSearchMatchingNotFound": { "message": "Tidak menemukan hasil yang cocok." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Tampilkan selengkapnya" }, + "showNativeTokenAsMainBalance": { + "message": "Tampilkan token asli sebagai saldo utama" + }, "showNft": { "message": "Tampilkan NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Masuk dengan" }, + "simulationApproveHeading": { + "message": "Tarik" + }, + "simulationDetailsApproveDesc": { + "message": "Anda memberi orang lain izin untuk menarik NFT dari akun Anda." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Anda memberi orang lain izin untuk menggunakan jumlah ini dari akun Anda." + }, "simulationDetailsFiatNotAvailable": { "message": "Tidak Tersedia" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Anda mengirim" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Anda menghapus izin orang lain untuk menarik NFT dari akun Anda." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Anda memberikan izin kepada orang lain untuk menarik NFT dari akun Anda." + }, "simulationDetailsTitle": { "message": "Estimasi perubahan" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Ups! Terjadi kesalahan." }, + "sortBy": { + "message": "Urutkan sesuai" + }, + "sortByAlphabetically": { + "message": "Sesuai abjad (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Saldo menurun ($1 tinggi-rendah)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Sumber" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Pengguna" }, + "spenderTooltipDesc": { + "message": "Ini adalah alamat yang dapat digunakan untuk menarik NFT Anda." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Ini adalah alamat yang dapat menggunakan token Anda atas nama Anda." + }, "spendingCap": { "message": "Batas penggunaan" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Permintaan batas penggunaan untuk $1 Anda" }, + "spendingCapTooltipDesc": { + "message": "Ini adalah jumlah token yang dapat diakses oleh pembeli atas nama Anda." + }, "srpInputNumberOfWords": { "message": "Frasa milik saya memiliki $1 kata", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Disarankan oleh $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Simbol mata uang yang disarankan:" + }, "suggestedTokenName": { "message": "Nama yang disarankan:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Kunjungi pusat dukungan kami" }, + "supportMultiRpcInformation": { + "message": "Saat ini kami mendukung beberapa RPC untuk satu jaringan. RPC terbaru Anda telah dipilih sebagai RPC default untuk mengatasi informasi yang saling bertentangan." + }, "surveyConversion": { "message": "Ikuti survei kami" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Biaya gas diperkirakan dan akan berfluktuasi berdasarkan lalu lintas jaringan dan kompleksitas transaksi." }, + "swapGasFeesExplanation": { + "message": "MetaMask tidak menghasilkan uang dari biaya gas. Biaya ini merupakan estimasi dan dapat berubah berdasarkan seberapa sibuk jaringan dan seberapa rumit transaksinya. Pelajari selengkapnya $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "di sini", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Pelajari selengkapnya seputar biaya gas" }, @@ -5186,9 +5583,19 @@ "message": "Biaya gas dibayarkan kepada penambang kripto yang memproses transaksi di jaringan $1. MetaMask tidak mengambil keuntungan dari biaya gas.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Kuotasi ini mencakup biaya gas dengan menyesuaikan jumlah token yang dikirim atau diterima. Anda bisa menerima ETH dalam transaksi terpisah pada daftar aktivitas Anda." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Pelajari selengkapnya seputar biaya gas" + }, "swapHighSlippage": { "message": "Selip tinggi" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Termasuk biaya gas dan MetaMask sebesar $1%", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Termasuk $1% biaya MetaMask.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Syarat Penggunaan kami telah diperbarui" }, + "testnets": { + "message": "Testnet" + }, "theme": { "message": " Saya menyetujui Ketentuan Penggunaan, yang berlaku untuk penggunaan atas MetaMask dan semua fiturnya" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Biaya transaksi" }, + "transactionFlowNetwork": { + "message": "Jaringan" + }, "transactionHistoryBaseFee": { "message": "Biaya dasar (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Transfer" }, + "transferCrypto": { + "message": "Transfer kripto" + }, "transferFrom": { "message": "Transfer dari" }, + "transferRequest": { + "message": "Permintaan transfer" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Perbarui" }, + "updateEthereumChainConfirmationDescription": { + "message": "Situs ini meminta pembaruan URL jaringan default Anda. Anda dapat mengedit informasi default dan jaringan setiap saat." + }, + "updateNetworkConfirmationTitle": { + "message": "Perbarui $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Perbarui informasi Anda atau" }, "updateRequest": { "message": "Permintaan pembaruan" }, + "updatedRpcForNetworks": { + "message": "RPC Jaringan Diperbarui" + }, "uploadDropFile": { "message": "Letakkan fail di sini" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "panduan koneksi dompet perangkat keras kami" }, + "walletProtectedAndReadyToUse": { + "message": "Dompet Anda terlindungi dan siap digunakan. Temukan Frasa Pemulihan Rahasia di $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Ingin menambahkan jaringan ini?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Pihak ketiga dapat mempergunakan seluruh saldo token Anda tanpa pemberitahuan atau persetujuan lebih lanjut. Lindungi diri Anda dengan menyesuaikan batas penggunaan yang lebih rendah.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Mengaktifkan opsi ini akan memberi Anda kemampuan untuk memantau akun Ethereum melalui alamat publik atau nama ENS. Untuk masukan tentang fitur Beta ini, silakan isi formulir $1 ini.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Pantau Akun Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Waspadalah terhadap $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Lemah" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Apa ini?" }, + "withdrawing": { + "message": "Penarikan" + }, "wrongNetworkName": { "message": "Menurut catatan kami, nama jaringan mungkin tidak cocok dengan ID chain ini." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Saldo Anda" }, + "yourBalanceIsAggregated": { + "message": "Saldo Anda diagregasi" + }, "yourNFTmayBeAtRisk": { "message": "NFT Anda mungkin berisiko" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Kami tidak dapat membatalkan transaksi Anda sebelum dikonfirmasi di blockchain." }, + "yourWalletIsReady": { + "message": "Dompet Anda sudah siap" + }, "zeroGasPriceOnSpeedUpError": { "message": "Biaya gas nol pada percepatan" } diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index ad77733372b6..a3e917d46600 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "QRハードウェアウォレットを接続" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "サインインリクエストのアドレスが、サインインに使用しているアカウントのアドレスと一致していません。" }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "通知を受けたいアカウントを選択してください:" }, + "accountBalance": { + "message": "アカウント残高" + }, "accountDetails": { "message": "アカウントの詳細" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "アカウントのオプション" }, + "accountPermissionToast": { + "message": "アカウントのアクセス許可が更新されました" + }, "accountSelectionRequired": { "message": "アカウントを選択する必要があります!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "アカウントが接続されました" }, + "accountsPermissionsTitle": { + "message": "アカウントを確認しトランザクションを提案する" + }, + "accountsSmallCase": { + "message": "アカウント" + }, "active": { "message": "アクティブ" }, @@ -180,12 +195,18 @@ "add": { "message": "追加" }, + "addACustomNetwork": { + "message": "カスタムネットワークを追加" + }, "addANetwork": { "message": "ネットワークを追加" }, "addANickname": { "message": "ニックネームを追加" }, + "addAUrl": { + "message": "URLを追加" + }, "addAccount": { "message": "アカウントを追加" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "ブロックエクスプローラーを追加" }, + "addBlockExplorerUrl": { + "message": "ブロックエクスプローラーURLを追加" + }, "addContact": { "message": "連絡先を追加" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "イーサリアムメインネット用の新しいRPCプロバイダーを追加しようとしています" }, + "addEthereumWatchOnlyAccount": { + "message": "イーサリアムアカウントの監視 (ベータ)" + }, "addFriendsAndAddresses": { "message": "信頼できる友達とアドレスを追加する" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "ネットワークを追加" }, + "addNetworkConfirmationTitle": { + "message": "$1の追加", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "新しいイーサリアムアカウントを追加" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "アドレスがコピーされました!" }, + "addressMismatch": { + "message": "サイトアドレスの不一致" + }, + "addressMismatchOriginal": { + "message": "現在のURL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Punycodeバージョン: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "高度な設定" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "優先手数料 (別名「マイナーチップ」) はマイナーに直接支払われ、トランザクションを優先するインセンティブとなります。" }, + "aggregatedBalancePopover": { + "message": "これには特定のネットワークで所有しているすべてのトークンの価値が反映されています。この値をETHまたはその他通貨で表示したい場合は、$1に移動します。", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "MetaMaskの$1に同意します", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "これは「設定」>「アラート」で変更できます" }, + "alertMessageAddressMismatchWarning": { + "message": "攻撃者は、サイトのアドレスに若干の変更を加えてサイトを模倣することがあります。続行する前に、意図したサイトとやり取りしていることを確認してください。" + }, "alertMessageGasEstimateFailed": { "message": "正確な手数料を提供できず、この見積もりは高い可能性があります。カスタムガスリミットの入力をお勧めしますが、それでもトランザクションが失敗するリスクがあります。" }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "このトランザクションを続行するには、ガスリミットを21000以上に上げる必要があります。" }, + "alertMessageInsufficientBalance2": { + "message": "アカウントにネットワーク手数料を支払うのに十分なETHがありません。" + }, "alertMessageNetworkBusy": { "message": "ガス価格が高く、見積もりはあまり正確ではありません。" }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "アセットのオプション" }, + "assets": { + "message": "アセット" + }, + "assetsDescription": { + "message": "ウォレットのトークンを自動検出し、NFTを表示して、アカウント残高の最新情報を一括で取得します" + }, "attemptSendingAssets": { "message": "別のネットワークからアセットを送ろうとすると、アセットが失われる可能性があります。ネットワーク間で安全に資金を移動するには、必ずブリッジを使用してください。" }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "ブリッジを使用してください" }, + "bridgeFrom": { + "message": "ブリッジ元:" + }, + "bridgeSelectNetwork": { + "message": "ネットワークを選択" + }, + "bridgeTo": { + "message": "ブリッジ先:" + }, "browserNotSupported": { "message": "ご使用のブラウザはサポートされていません..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "シークレットリカバリーフレーズの確認" }, + "confirmTitleApproveTransaction": { + "message": "許容額のリクエスト" + }, + "confirmTitleDeployContract": { + "message": "コントラクトを展開" + }, + "confirmTitleDescApproveTransaction": { + "message": "このサイトがNFTの引き出し許可を求めています。" + }, + "confirmTitleDescDeployContract": { + "message": "このサイトがコントラクトの展開を求めています" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "このサイトがトークンの引き出し許可を求めています。" + }, "confirmTitleDescPermitSignature": { "message": "このサイトがトークンの使用許可を求めています。" }, "confirmTitleDescSIWESignature": { "message": "サイトがこのアカウントを所有することを証明するためにサインインを求めています。" }, + "confirmTitleDescSign": { + "message": "確定する前に、要求の詳細を確認してください。" + }, "confirmTitlePermitTokens": { "message": "使用上限リクエスト" }, + "confirmTitleRevokeApproveTransaction": { + "message": "アクセス許可を取り消す" + }, "confirmTitleSIWESignature": { "message": "サインインリクエスト" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "アクセス許可を取り消す" + }, "confirmTitleSignature": { "message": "署名要求" }, "confirmTitleTransaction": { "message": "トランザクションの要求" }, + "confirmationAlertModalDetails": { + "message": "資産とログイン情報を守るため、要求を拒否することをお勧めします。" + }, + "confirmationAlertModalTitle": { + "message": "この要求は不審です" + }, "confirmed": { "message": "確認されました" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "ENS名に混乱しやすい文字が発見されました。詐欺を防ぐためにENS名を確認して下さい。" }, + "congratulations": { + "message": "おめでとうございます!" + }, "connect": { "message": "接続" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "接続済みのサイト" }, + "connectedSitesAndSnaps": { + "message": "接続されているサイトとSnap" + }, "connectedSitesDescription": { "message": "$1はこれらのサイトに接続されています。これらのサイトは、アカウントアドレスを把握できます。", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMaskはこのサイトに接続されていますが、まだアカウントは接続されていません" }, + "connectedSnaps": { + "message": "接続されているSnap" + }, + "connectedWithAccount": { + "message": "$1個のアカウントが接続されました", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "$1と接続されました", + "description": "$1 represents account name" + }, "connecting": { "message": "接続中..." }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Sepoliaテストネットワークに接続中" }, + "connectionDescription": { + "message": "このサイトは次のことを求めています:" + }, "connectionFailed": { "message": "接続できませんでした" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "アドレスをクリップボードにコピー" }, + "copyAddressShort": { + "message": "アドレスをコピー" + }, "copyPrivateKey": { "message": "秘密鍵をコピー" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "デフォルトのRPC URL" }, + "defaultSettingsSubTitle": { + "message": "MetaMaskは、デフォルト設定を使用して安全と使いやすさのバランスを最適化しています。プライバシーをさらに強化したい場合は、これらの設定を変更してください。" + }, + "defaultSettingsTitle": { + "message": "デフォルトのプライバシー設定" + }, "delete": { "message": "削除" }, "deleteContact": { "message": "連絡先を削除" }, + "deleteMetaMetricsData": { + "message": "MetaMetricsデータを削除" + }, + "deleteMetaMetricsDataDescription": { + "message": "これにより、このデバイスでの使用に関連した過去のMetaMetricsデータが削除されます。このデータが削除された後も、ウォレットとアカウントに変化はありません。このプロセスには最長30日間かかる場合があります。$1をご覧ください。", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "アナリティクスシステムサーバーの問題により、現在このリクエストを完了させることができません。後ほど再度お試しください" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "現在このデータを削除できません" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "すべてのMetaMetricsデータを削除しようとしています。よろしいですか?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "MetaMetricsデータを削除しますか?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "このアクションは$1に開始しました。このプロセスには最長30日間かかる場合があります。$2をご覧ください", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "このネットワークを削除した場合、このネットワーク内の資産を見るには、再度ネットワークの追加が必要になります。" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "入金" }, + "depositCrypto": { + "message": "ウォレットアドレスまたはQRコードを使った別のアカウントからの仮想通貨のデポジット" + }, "deprecatedGoerliNtwrkMsg": { "message": "イーサリアムシステムのアップデートに伴い、Goerliテストネットワークはまもなく段階的に廃止される予定です。" }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "アカウント" }, + "disconnectAllDescriptionText": { + "message": "このサイトとの接続を解除すると、このサイトをもう一度使用するには、アカウントとネットワークを接続しなおす必要があります。" + }, "disconnectAllSnapsText": { "message": "Snap" }, + "disconnectMessage": { + "message": "これにより、このサイトへの接続が解除されます" + }, "disconnectPrompt": { "message": "$1を接続解除" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "ニックネームを編集" }, + "editAccounts": { + "message": "アカウントを編集" + }, "editAddressNickname": { "message": "アドレスのニックネームを編集" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "元のネットワークを編集" }, + "editNetworksTitle": { + "message": "ネットワークを編集" + }, "editNonceField": { "message": "ナンスを編集" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "アクセス許可の編集" }, + "editPermissions": { + "message": "アクセス許可の編集" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "高速化用のガス代を編集" }, + "editSpendingCap": { + "message": "使用上限の編集" + }, + "editSpendingCapAccountBalance": { + "message": "アカウント残高: $1 $2" + }, + "editSpendingCapDesc": { + "message": "代理で使用されても構わない金額を入力してください。" + }, + "editSpendingCapError": { + "message": "使用限度は小数点以下$1桁を超えることができません。続けるには、小数点以下の桁を削除してください。" + }, "enableAutoDetect": { "message": " 自動検出を有効にする" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENSの検索に失敗しました。" }, + "enterANameToIdentifyTheUrl": { + "message": "URLを識別するための名前を入力してください" + }, "enterANumber": { "message": "数字を入力してください" }, + "enterChainId": { + "message": "チェーンIDを入力してください" + }, "enterCustodianToken": { "message": "$1トークンを入力するか、新しいトークンを追加してください" }, "enterMaxSpendLimit": { "message": "使用限度額の最大値を入力してください" }, + "enterNetworkName": { + "message": "ネットワーク名を入力してください" + }, "enterOptionalPassword": { "message": "オプションのパスワードを入力してください" }, "enterPasswordContinue": { "message": "続行するには、パスワードを入力してください" }, + "enterRpcUrl": { + "message": "RPC URLを入力してください" + }, + "enterSymbol": { + "message": "シンボルを入力してください" + }, "enterTokenNameOrAddress": { "message": "トークン名を入力するか、アドレスを貼り付けてください" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "試験運用" }, + "exportYourData": { + "message": "データのエクスポート" + }, + "exportYourDataButton": { + "message": "ダウンロード" + }, + "exportYourDataDescription": { + "message": "連絡先やユーザー設定などのデータをエクスポートできます。" + }, "extendWalletWithSnaps": { "message": "Web3のエクスペリエンスをカスタマイズする、コミュニティが開発したSnapをご覧ください", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "このガス代は$1により提案されています。これを上書きすると、トランザクションに問題が発生する可能性があります。ご質問がございましたら、$1までお問い合わせください。", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "ガス代" + }, "gasIsETH": { "message": "ガス代は$1です" }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "問題が発生しました...." }, + "generalDescription": { + "message": "デバイス間で設定を同期して、ネットワーク設定を選択し、トークンデータを追跡します" + }, "genericExplorerView": { "message": "$1でアカウントを表示" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "アプリからロックアウトされてしまったり、新しいデバイスを入手した場合、資金が失われます。シークレットリカバリーフレーズは必ず$1にバックアップしてください ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "すべて無視" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "設定で" }, + "included": { + "message": "含む" + }, "infuraBlockedNotification": { "message": "MetaMaskがブロックチェーンのホストに接続できません。考えられる理由$1を確認してください。", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSONファイル", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "シークレットリカバリーフレーズは忘れないように安全な場所に保管してください。なくしてしまうと、誰にも取り戻すことはできません。さらに、ウォレットに二度とアクセスできなくなります。$1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "アカウント名" }, @@ -2402,6 +2622,9 @@ "message": "$1の方法を学ぶ", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "方法" + }, "learnMore": { "message": "詳細" }, @@ -2409,6 +2632,9 @@ "message": "ガスに関する$1をご希望ですか?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "プライバシーのベストプラクティスに関する詳細をご覧ください。" + }, "learnMoreKeystone": { "message": "詳細" }, @@ -2497,6 +2723,9 @@ "link": { "message": "リンク" }, + "linkCentralizedExchanges": { + "message": "CoinbaseまたはBinanceアカウントをリンクして、無料でMetaMaskに仮想通貨を送金します。" + }, "links": { "message": "リンク" }, @@ -2557,6 +2786,9 @@ "message": "誰にも見られていないことを確認してください", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "デフォルトのプライバシー設定の管理" + }, "marketCap": { "message": "時価総額" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "訪問しているWebサイトが現在選択しているアカウントに接続されている場合、接続ステータスボタンが表示されます。" }, + "metaMetricsIdNotAvailableError": { + "message": "MetaMetricsにオプトインしていないため、ここで削除するデータはありません。" + }, "metadataModalSourceTooltip": { "message": "$1はnpmでホストされていて、$2はこのSnapの一意のIDです。", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "他" }, + "moreAccounts": { + "message": "+ $1個のアカウント", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1個のネットワーク", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "このネットワークをMetaMaskに追加し、サイトがそれを使用することを許可しようとしています。" + }, "multipleSnapConnectionWarning": { "message": "$1が$2 Snapの使用を求めています", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "ネットワークの詳細を編集" }, "nativeTokenScamWarningDescription": { - "message": "このネットワークは関連付けられているチェーンIDまたは名前と一致していません。人気のトークンの多くが「$1」という名前を使用しているため、詐欺の対象となっています。詐欺師はより価値の高い通貨を送り返すよう仕向けてくる可能性があります。続行する前にすべてを確認してください。", + "message": "ネイティブトークンシンボルが、関連付けられているチェーンIDのネットワークで予想されるネイティブトークンのシンボルと一致していません。予想されるトークンシンボルは$2ですが、$1と入力されました。正しいチェーンに接続されていることを確認してください。", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "別のシンボル", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "これは詐欺の可能性があります", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "ネットワークの詳細" }, + "networkFee": { + "message": "ネットワーク手数料" + }, "networkIsBusy": { "message": "ネットワークが混み合っています。ガス代が高く、見積もりはあまり正確ではありません。" }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "ネットワークオプション" }, + "networkPermissionToast": { + "message": "ネットワークへのアクセス許可が更新されました" + }, "networkProvider": { "message": "ネットワークプロバイダー" }, @@ -2865,15 +3121,26 @@ "message": "$1に接続できません", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "ネットワークが$1に切り替わりました", + "description": "$1 represents the network name" + }, "networkURL": { "message": "ネットワークURL" }, "networkURLDefinition": { "message": "このネットワークへのアクセスに使用されるURLです。" }, + "networkUrlErrorWarning": { + "message": "攻撃者は、サイトのアドレスに若干の変更を加えてサイトを模倣することがあります。続行する前に、意図したサイトとやり取りしていることを確認してください。Punycodeバージョン: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "ネットワーク" }, + "networksSmallCase": { + "message": "ネットワーク" + }, "nevermind": { "message": "取り消し" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "プライバシーポリシーが更新されました" }, + "newRpcUrl": { + "message": "新しいRPC URL" + }, "newTokensImportedMessage": { "message": "$1をインポートしました。", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMaskはこのサイトに接続されていません" }, + "noConnectionDescription": { + "message": "サイトに接続するには、「接続」ボタンを見つけて選択します。MetaMaskはWeb3のサイトにしか接続できないのでご注意ください" + }, "noConversionRateAvailable": { "message": "利用可能な換算レートがありません" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "カスタムナンス" }, + "none": { + "message": "なし" + }, "notBusy": { "message": "ビジー状態ではありません" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "アクセス許可の詳細" }, + "permissionFor": { + "message": "アクセス許可:" + }, + "permissionFrom": { + "message": "次からのアクセス許可:" + }, "permissionRequest": { "message": "許可のリクエスト" }, @@ -3593,6 +3875,14 @@ "message": "$1がMetaMaskの言語設定にアクセスできるようにします。これは、$1のコンテンツをユーザーの言語にローカライズして表示するために使用されます。", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "優先言語や法定通貨などの情報の表示", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "$1によるMetaMask設定の優先言語や法定通貨などの情報へのアクセスを許可します。これにより、$1がユーザーの設定に合わせたコンテンツを表示できるようになります。", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "カスタム画面の表示", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "この数量のトークンをアカウントから転送する権限を使用者に付与しようとしています。" }, + "permittedChainToastUpdate": { + "message": "$1は$2にアクセスできます。" + }, "personalAddressDetected": { "message": "個人アドレスが検出されました。トークンコントラクトアドレスを入力してください。" }, @@ -3963,6 +4256,12 @@ "receive": { "message": "受取" }, + "receiveCrypto": { + "message": "仮想通貨を受け取る" + }, + "recipientAddressPlaceholderNew": { + "message": "パブリックアドレス (0x) またはドメイン名を入力してください" + }, "recommendedGasLabel": { "message": "推奨" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "拒否されました" }, + "rememberSRPIfYouLooseAccess": { + "message": "シークレットリカバリーフレーズをなくしてしまうと、ウォレットにアクセスできなくなります。この単語のセットを安全に保管し、いつでも資金にアクセスできるように、$1してください。" + }, + "reminderSet": { + "message": "リマインダーが設定されました!" + }, "remove": { "message": "削除" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "エラーが発生したため、このリクエストはセキュリティプロバイダーにより検証されませんでした。慎重に進めてください。" }, + "requestingFor": { + "message": "次の要求:" + }, + "requestingForAccount": { + "message": "$1の要求", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "リクエストの承認待ち" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "シードフレーズを確認" }, + "review": { + "message": "確認" + }, + "reviewAlert": { + "message": "アラートを確認" + }, "reviewAlerts": { "message": "アラートを確認する" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "許可を取り消す" }, + "revokeSimulationDetailsDesc": { + "message": "別のユーザーに付与したアカウントのトークン使用許可を取り消そうとしています。" + }, "revokeSpendingCap": { "message": "$1の使用上限を取り消す", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "このサードパーティは、現在または今後のトークンをこれ以上使用できなくなります。" }, + "rpcNameOptional": { + "message": "RPC名 (オプション)" + }, "rpcUrl": { "message": "新しいRPC URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "セキュリティとプライバシー" }, + "securityDescription": { + "message": "安全ではないネットワークに参加してしまう可能性を減らし、アカウントを守ります" + }, + "securityMessageLinkForNetworks": { + "message": "ネットワーク詐欺とセキュリティのリスク" + }, + "securityPrivacyPath": { + "message": "「設定」>「セキュリティとプライバシー」で確認できます。" + }, "securityProviderPoweredBy": { "message": "データソース: $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "すべてのアクセス許可を表示", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "詳細を表示" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "アカウントが見当たらない場合は、HDパスまたは現在選択されているネットワークを切り替えてみてください。" }, + "selectRpcUrl": { + "message": "RPC URLを選択" + }, "selectType": { "message": "種類を選択" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "すべてを承認に設定" }, + "setApprovalForAllRedesignedTitle": { + "message": "出金のリクエスト" + }, "setApprovalForAllTitle": { "message": "使用限度額なしで$1を承認", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "設定" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "設定は使いやすさとセキュリティを確保するために最適化されています。これらはいつでも変更できます。" + }, "settingsSearchMatchingNotFound": { "message": "一致する結果が見つかりませんでした。" }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "他を表示" }, + "showNativeTokenAsMainBalance": { + "message": "ネイティブトークンをメイン残高として表示" + }, "showNft": { "message": "NFTを表示" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "サインイン方法:" }, + "simulationApproveHeading": { + "message": "引き出し" + }, + "simulationDetailsApproveDesc": { + "message": "別のユーザーに、アカウントからのNFTの引き出しを許可しようとしています。" + }, + "simulationDetailsERC20ApproveDesc": { + "message": "別のユーザーに、アカウントからこの数量を使用する許可を与えようとしています。" + }, "simulationDetailsFiatNotAvailable": { "message": "利用できません" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "送金額" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "別のユーザーに付与したアカウントからのNFT引き出し許可を取り消そうとしています。" + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "別のユーザーに、アカウントからのNFTの引き出しを許可しようとしています。" + }, "simulationDetailsTitle": { "message": "予測される増減額" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "申し訳ありません。問題が発生しました。" }, + "sortBy": { + "message": "並べ替え基準" + }, + "sortByAlphabetically": { + "message": "アルファベット順 (A~Z)" + }, + "sortByDecliningBalance": { + "message": "残高の多い順 ($1 高~低)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "ソース" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "使用者" }, + "spenderTooltipDesc": { + "message": "これは、NFTを引き出せるようになるアドレスです。" + }, + "spenderTooltipERC20ApproveDesc": { + "message": "これが、トークンを代理で使用できるようになるアドレスです。" + }, "spendingCap": { "message": "使用上限" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "$1の使用上限のリクエスト" }, + "spendingCapTooltipDesc": { + "message": "これは、使用者が代理でアクセスできるようになるトークンの金額です。" + }, "srpInputNumberOfWords": { "message": "$1語のフレーズがあります", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "$1による提案", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "推奨通貨シンボル。" + }, "suggestedTokenName": { "message": "提案された名前:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "サポートセンターをご利用ください" }, + "supportMultiRpcInformation": { + "message": "1つのネットワークで複数のRPCがサポートされるようになりました。情報の矛盾を解決するため、最も最近のRPCがデフォルトのRPCとして選択されています。" + }, "surveyConversion": { "message": "アンケートに回答する" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "ガス代は、ネットワークトラフィックとトランザクションの複雑さに基づき推定され、変動します。" }, + "swapGasFeesExplanation": { + "message": "MetaMaskはガス代から収益を得ません。これらの手数料は見積もりであり、ネットワークの混雑状況やトランザクションの複雑さによって変わる可能性があります。詳細は$1をご覧ください。", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "こちら", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "ガス代に関する詳細" }, @@ -5186,9 +5583,19 @@ "message": "ガス代は、$1ネットワークでトランザクションを処理するクリプトマイナーに支払われます。MetaMaskはガス代から利益を得ません。", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "このクォートは、やり取りするトークンの数量を調整し、ガス代込みで提示されています。アクティビティリストの別のトランザクションでETHを受け取る場合があります。" + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "ガス代に関する詳細" + }, "swapHighSlippage": { "message": "高スリッページ" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "ガス代と$1%のMetaMask手数料が含まれています", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "$1%のMetaMask手数料が含まれています。", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "利用規約が更新されました" }, + "testnets": { + "message": "テストネット" + }, "theme": { "message": "テーマ" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "トランザクション手数料" }, + "transactionFlowNetwork": { + "message": "ネットワーク" + }, "transactionHistoryBaseFee": { "message": "基本料金 (Gwei)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "送金" }, + "transferCrypto": { + "message": "仮想通貨の送金" + }, "transferFrom": { "message": "送金元" }, + "transferRequest": { + "message": "送金要求" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "更新" }, + "updateEthereumChainConfirmationDescription": { + "message": "このサイトがデフォルトのネットワークURLの更新を要求しています。デフォルトとネットワーク情報はいつでも編集できます。" + }, + "updateNetworkConfirmationTitle": { + "message": "$1を更新", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "情報を更新するか" }, "updateRequest": { "message": "更新リクエスト" }, + "updatedRpcForNetworks": { + "message": "ネットワークRPCが更新されました" + }, "uploadDropFile": { "message": "ここにファイルをドロップします" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "弊社のハードウェアウォレット接続ガイド" }, + "walletProtectedAndReadyToUse": { + "message": "ウォレットが保護され、使用する準備ができました。シークレットリカバリーフレーズは、$1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "このネットワークを追加しますか?" }, @@ -5991,6 +6424,17 @@ "message": "$1 このサードパーティは今後、通知や承諾なしにトークン残高全額を使用できます。使用上限をより低い金額にカスタマイズして、自分の身を守りましょう。", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "このオプションをオンにすると、パブリックアドレスまたはENS名でイーサリアムアカウントを監視できるようになります。ベータ機能に関するフィードバックは、こちらの$1に入力してください。", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "イーサリアムアカウントの監視 (ベータ)" + }, + "watchOutMessage": { + "message": "$1にご注意ください。", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "弱" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "これは何ですか?" }, + "withdrawing": { + "message": "引き出し中" + }, "wrongNetworkName": { "message": "弊社の記録によると、ネットワーク名がこのチェーンIDと正しく一致していない可能性があります。" }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "残高" }, + "yourBalanceIsAggregated": { + "message": "残高は集計されます" + }, "yourNFTmayBeAtRisk": { "message": "NFTが危険にさらされている可能性があります" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "ブロックチェーン上で確定しているため、トランザクションをキャンセルできませんでした。" }, + "yourWalletIsReady": { + "message": "ウォレットの準備ができました" + }, "zeroGasPriceOnSpeedUpError": { "message": "高速化用のガス価格がゼロです" } diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index 86077780926a..e1eeda4487a6 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "QR 하드웨어 지갑을 연결하세요" }, + "QRHardwareWalletSteps2Description": { + "message": "N그레이브 제로" + }, "SIWEAddressInvalid": { "message": "로그인 요청 주소가 현재 로그인 계정의 주소와 일치하지 않습니다." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "알림을 수신할 계정을 선택하세요." }, + "accountBalance": { + "message": "계정 잔액" + }, "accountDetails": { "message": "계정 세부 정보" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "계정 옵션" }, + "accountPermissionToast": { + "message": "계정 권한이 업데이트됨" + }, "accountSelectionRequired": { "message": "계정을 선택해야 합니다!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "계정 연결됨" }, + "accountsPermissionsTitle": { + "message": "계정 보기 및 트랜잭션 제안" + }, + "accountsSmallCase": { + "message": "계정" + }, "active": { "message": "활성" }, @@ -180,12 +195,18 @@ "add": { "message": "추가" }, + "addACustomNetwork": { + "message": "사용자 지정 네트워크 추가" + }, "addANetwork": { "message": "네트워크 추가" }, "addANickname": { "message": "닉네임 추가" }, + "addAUrl": { + "message": "URL 추가" + }, "addAccount": { "message": "계정 추가" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "블록 탐색기 추가" }, + "addBlockExplorerUrl": { + "message": "블록 탐색기 URL 추가" + }, "addContact": { "message": "연락처 추가" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "이더리움 메인넷에 새로운 RPC 공급업체를 추가하려고 합니다" }, + "addEthereumWatchOnlyAccount": { + "message": "이더리움 계정 모니터(베타)" + }, "addFriendsAndAddresses": { "message": "신뢰하는 친구 및 주소 추가" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "네트워크 추가" }, + "addNetworkConfirmationTitle": { + "message": "$1 추가", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "새 이더리움 계정 추가" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "주소 복사 완료!" }, + "addressMismatch": { + "message": "사이트 주소 불일치" + }, + "addressMismatchOriginal": { + "message": "현재 URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Punycode 버전: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "고급" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "우선 요금(일명 \"채굴자 팁\")이란 내 트랜잭션을 우선 거래한 것에 대한 인센티브로 채굴자에게 직접 전달되는 금액입니다." }, + "aggregatedBalancePopover": { + "message": "이는 특정 네트워크에서 소유한 모든 토큰의 가치를 반영합니다. 이 가치를 ETH 또는 다른 통화로 보고 싶다면 $1(으)로 이동하세요.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "MetaMask의 $1에 동의합니다", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "\"설정 > 경고\"에서 변경할 수 있습니다" }, + "alertMessageAddressMismatchWarning": { + "message": "공격자는 사이트 주소를 약간 변경하여 유사 사이트를 만들기도 합니다. 계속하기 전에 정상적인 사이트와 상호 작용하고 있는지 확인하세요." + }, "alertMessageGasEstimateFailed": { "message": "정확한 수수료를 제공할 수 없으며 예상 수수료가 높을 수 있습니다. 사용자 지정 가스 한도를 입력하는 것이 좋지만 트랜잭션이 여전히 실패할 위험이 있습니다." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "이 트랜잭션을 계속 진행하려면, 가스 한도를 21000 이상으로 늘려야 합니다." }, + "alertMessageInsufficientBalance2": { + "message": "계정에 네트워크 수수료로 지불할 ETH가 부족합니다." + }, "alertMessageNetworkBusy": { "message": "가스비가 높고 견적의 정확도도 떨어집니다." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "자산 옵션" }, + "assets": { + "message": "자산" + }, + "assetsDescription": { + "message": "지갑에서 토큰을 자동으로 감지하고, NFT를 표시하며, 계정 잔액을 일괄 업데이트하세요." + }, "attemptSendingAssets": { "message": "다른 네트워크로 자산을 직접 전송하면 자산이 영구적으로 손실될 수 있습니다. 브릿지를 이용하여 네트워크 간에 자금을 안전하게 전송하세요." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "전송하지 마세요, 브릿지 하세요" }, + "bridgeFrom": { + "message": "브릿지 출처" + }, + "bridgeSelectNetwork": { + "message": "네트워크 선택" + }, + "bridgeTo": { + "message": "브릿지 대상" + }, "browserNotSupported": { "message": "지원되지 않는 브라우저입니다..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "비밀복구구문 컨펌" }, + "confirmTitleApproveTransaction": { + "message": "수당 청구" + }, + "confirmTitleDeployContract": { + "message": "계약 배포" + }, + "confirmTitleDescApproveTransaction": { + "message": "이 사이트에서 NFT 인출 권한을 요청합니다" + }, + "confirmTitleDescDeployContract": { + "message": "이 사이트에서 계약을 배포하려고 합니다" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "이 사이트에서 토큰 인출 권한을 요청합니다" + }, "confirmTitleDescPermitSignature": { "message": "해당 사이트에서 토큰 사용 승인을 요청합니다." }, "confirmTitleDescSIWESignature": { "message": "회원님이 이 계정을 소유하고 있음을 확인하기 위해 로그인을 요청하는 사이트가 있습니다." }, + "confirmTitleDescSign": { + "message": "컨펌 전에 요청 세부 정보를 검토하세요." + }, "confirmTitlePermitTokens": { "message": "지출 한도 요청" }, + "confirmTitleRevokeApproveTransaction": { + "message": "권한 제거" + }, "confirmTitleSIWESignature": { "message": "로그인 요청" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "권한 제거" + }, "confirmTitleSignature": { "message": "서명 요청" }, "confirmTitleTransaction": { "message": "트랜젝션 요청" }, + "confirmationAlertModalDetails": { + "message": "자산과 로그인 정보 보호를 위해 요청을 거부하는 것이 좋습니다." + }, + "confirmationAlertModalTitle": { + "message": "의심스러운 요청입니다" + }, "confirmed": { "message": "컨펌됨" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "ENS 이름에 혼동하기 쉬운 문자가 있습니다. 잠재적 사기를 막기 위해 ENS 이름을 확인하세요." }, + "congratulations": { + "message": "축하합니다!" + }, "connect": { "message": "연결" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "연결된 사이트" }, + "connectedSitesAndSnaps": { + "message": "연결된 사이트 및 Snap" + }, "connectedSitesDescription": { "message": "$1 계정이 이 사이트에 연결되었습니다. 해당 계정 주소도 볼 수 있습니다.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask는 이 사이트와 연결되어 있지만, 아직 연결된 계정이 없습니다" }, + "connectedSnaps": { + "message": "연결된 Snap" + }, + "connectedWithAccount": { + "message": "$1 계정 연결됨", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "$1 계정과 연결됨", + "description": "$1 represents account name" + }, "connecting": { "message": "연결 중" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Sepolia 테스트 네트워크에 연결 중" }, + "connectionDescription": { + "message": "이 사이트에서 요청하는 사항:" + }, "connectionFailed": { "message": "연결 실패" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "주소를 클립보드에 복사" }, + "copyAddressShort": { + "message": "주소 복사" + }, "copyPrivateKey": { "message": "개인 키 복사" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "기본 RPC URL" }, + "defaultSettingsSubTitle": { + "message": "MetaMask의 기본 설정은 보안과 사용의 편의성 사이에서 최적의 균형을 맞추기 위한 설정입니다. 개인정보 보호 기능을 강화하려면 설정을 변경하세요." + }, + "defaultSettingsTitle": { + "message": "기본 개인정보 보호 설정" + }, "delete": { "message": "삭제" }, "deleteContact": { "message": "연락처 삭제" }, + "deleteMetaMetricsData": { + "message": "MetaMetrics 데이터 삭제" + }, + "deleteMetaMetricsDataDescription": { + "message": "이렇게 하면 이 기기에서 사용한 과거 MetaMetrics 데이터가 삭제됩니다. 이 데이터를 삭제해도 지갑과 계정에는 아무런 영향이 없습니다. 삭제는 최대 30일 정도 소요됩니다. 다음을 참고하세요: $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "분석 시스템 서버 문제로 지금 요청을 처리할 수 없습니다. 나중에 다시 시도하세요" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "지금은 데이터를 삭제할 수 없습니다" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "모든 MetaMetrics 데이터를 제거합니다. 정말 제거하시겠습니까?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "MetaMetrics 데이터를 삭제하시겠습니까?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "$1에서 이 작업을 시작했습니다. 이 과정에는 최대 30일이 소요됩니다. 다음을 참고하세요: $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "이 네트워크를 삭제하면 나중에 이 네트워크에 있는 자산을 보고 싶을 때 네트워크를 다시 추가해야 합니다" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "예치" }, + "depositCrypto": { + "message": "지갑 주소 또는 QR 코드를 사용하여 다른 계정에서 암호화폐를 입금합니다." + }, "deprecatedGoerliNtwrkMsg": { "message": "이더리움 시스템 업데이트로 인해 Goerli 테스트 네트워크는 곧 단계적으로 지원 중단될 예정입니다." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "계정" }, + "disconnectAllDescriptionText": { + "message": "이 사이트에서 연결을 해제하면, 이 사이트를 다시 사용하기 위해 계정과 네트워크를 다시 연결해야 합니다." + }, "disconnectAllSnapsText": { "message": "Snap" }, + "disconnectMessage": { + "message": "이렇게 하면 이 사이트와의 연결이 해제됩니다" + }, "disconnectPrompt": { "message": "$1 연결 해제" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "닉네임 편집" }, + "editAccounts": { + "message": "계정 편집" + }, "editAddressNickname": { "message": "주소 닉네임 편집" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "원본 네트워크 편집" }, + "editNetworksTitle": { + "message": "네트워크 편집" + }, "editNonceField": { "message": "논스 편집" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "권한 편집" }, + "editPermissions": { + "message": "권한 편집" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "가스비 가속 편집" }, + "editSpendingCap": { + "message": "지출 한도 편집" + }, + "editSpendingCapAccountBalance": { + "message": "계정 잔액: $1 $2" + }, + "editSpendingCapDesc": { + "message": "회원님을 대신하여 지출해도 부담되지 않는 금액을 입력하세요." + }, + "editSpendingCapError": { + "message": "지출 한도는 소수점 이하 $1 자리를 초과할 수 없습니다. 계속하려면 소수점 이하 숫자를 제거하세요." + }, "enableAutoDetect": { "message": " 자동 감지 활성화" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENS를 조회하지 못했습니다." }, + "enterANameToIdentifyTheUrl": { + "message": "URL을 식별할 이름을 입력하세요" + }, "enterANumber": { "message": "금액 입력" }, + "enterChainId": { + "message": "체인 ID 입력" + }, "enterCustodianToken": { "message": "$1 토큰을 입력하거나 새로운 토큰을 추가하세요" }, "enterMaxSpendLimit": { "message": "최대 지출 한도 입력" }, + "enterNetworkName": { + "message": "네트워크 이름 입력" + }, "enterOptionalPassword": { "message": "선택적 비밀번호를 입력하세요" }, "enterPasswordContinue": { "message": "계속하려면 비밀번호를 입력하세요" }, + "enterRpcUrl": { + "message": "RPC URL 입력" + }, + "enterSymbol": { + "message": "심볼 입력" + }, "enterTokenNameOrAddress": { "message": "토큰 이름 입력 또는 주소 붙여넣기" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "실험적" }, + "exportYourData": { + "message": "데이터 내보내기" + }, + "exportYourDataButton": { + "message": "다운로드" + }, + "exportYourDataDescription": { + "message": "계약 및 환경설정 등의 데이터를 내보낼 수 있습니다." + }, "extendWalletWithSnaps": { "message": "커뮤니티에서 만들어진 Snap을 알아보고 웹3 경험을 개인 맞춤하세요.", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "$1에서 이 가스비를 제안했습니다. 이를 무시하면 트랜잭션에 문제가 발생할 수 있습니다. 질문이 있는 경우 $1에 문의하세요.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "가스비" + }, "gasIsETH": { "message": "가스는 $1입니다 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "오류가 발생했습니다...." }, + "generalDescription": { + "message": "여러 기기의 설정을 동기화하고, 네트워크 환경을 선택하며, 토큰 데이터를 추적하세요" + }, "genericExplorerView": { "message": "$1에서 계정 보기" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "앱에 로그인하지 못하거나 새로운 기기를 사용할 경우 자금을 잃을 수 있습니다. 반드시 $1에 비밀 복구 구문을 백업하세요.", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "모두 무시" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "설정에서" }, + "included": { + "message": "포함됨" + }, "infuraBlockedNotification": { "message": "MetaMask이 블록체인 호스트에 연결할 수 없습니다. $1 오류 가능성을 검토하세요.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON 파일", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "비밀복구구문을 안전한 장소에 보관하세요. 비밀복구구문을 분실하는 경우 아무도 복구를 도울 수 없으며, 영원히 지갑에 액세스하지 못할 수도 있습니다. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "계정 이름" }, @@ -2402,6 +2622,9 @@ "message": "$1 방법 알아보기", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "방법 확인하기" + }, "learnMore": { "message": "자세히 알아보기" }, @@ -2409,6 +2632,9 @@ "message": "가스에 대해 $1하시겠습니까?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "개인정보 보호 모범 사례에 대해 자세히 알아보세요." + }, "learnMoreKeystone": { "message": "자세히 알아보기" }, @@ -2497,6 +2723,9 @@ "link": { "message": "링크" }, + "linkCentralizedExchanges": { + "message": "Coinbase나 Binance 계정을 연결하여 무료로 암호화폐를 MetaMask로 전송하세요." + }, "links": { "message": "링크" }, @@ -2557,6 +2786,9 @@ "message": "다른 사람이 이 화면을 보고 있지는 않은지 확인하세요.", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "기본 개인정보 보호 설정 관리" + }, "marketCap": { "message": "시가 총액" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "방문 중인 웹사이트가 현재 선택한 계정에 연결되어 있다면 연결 상태 버튼이 표시됩니다." }, + "metaMetricsIdNotAvailableError": { + "message": "MetaMetrics에 가입한 적이 없으므로 여기에는 삭제할 데이터가 없습니다" + }, "metadataModalSourceTooltip": { "message": "$1 스냅은 npm이 호스팅합니다. 이 Snap의 고유 식별자는 $2 입니다.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "그 외" }, + "moreAccounts": { + "message": "$1개의 계정 추가", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "$1개의 네트워크 추가", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "MetaMask에 이 네트워크를 추가하고 이 사이트에 사용 권한을 허용합니다." + }, "multipleSnapConnectionWarning": { "message": "$1에서 $2 Snap 사용을 원합니다.", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "네트워크 세부 정보 편집" }, "nativeTokenScamWarningDescription": { - "message": "이 네트워크는 연결된 체인 ID 또는 이름이 일치하지 않습니다. 여러 인기 토큰이 $1(이)라는 이름을 사용하기 때문에 사기의 표적이 되고 있습니다. 사기꾼은 더 가격이 높은 암호화폐를 주겠다고 속일 수 있습니다. 계속하기 전에 모든 사항을 확인하세요.", + "message": "토큰의 네이티브 심볼이 체인 ID와 연관된 네트워크의 토큰의 네이티브 심볼과 일치하지 않습니다. $1을(를) 입력하셨지만 예상되는 토큰의 네이티브 심볼은 $2입니다. 올바른 체인에 연결되어 있는지 확인해 주세요.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "다른 선택", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "사기일 수 있습니다", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "네트워크 세부 정보" }, + "networkFee": { + "message": "네트워크 수수료" + }, "networkIsBusy": { "message": "네트워크 사용량이 많습니다. 가스비가 높고 견적의 정확도도 떨어집니다." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "네트워크 옵션" }, + "networkPermissionToast": { + "message": "네트워크 권한이 업데이트됨" + }, "networkProvider": { "message": "네트워크 공급업체" }, @@ -2865,15 +3121,26 @@ "message": "$1 연결이 불가능합니다", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "네트워크가 $1(으)로 전환됨", + "description": "$1 represents the network name" + }, "networkURL": { "message": "네트워크 URL" }, "networkURLDefinition": { "message": "이 네트워크에 액세스하는 데 사용되는 URL입니다." }, + "networkUrlErrorWarning": { + "message": "공격자는 사이트 주소를 약간 변경하여 유사 사이트를 만들기도 합니다. 계속하기 전에 정상적인 사이트와 상호 작용하고 있는지 확인하세요. Punycode 버전: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "네트워크" }, + "networksSmallCase": { + "message": "네트워크" + }, "nevermind": { "message": "괜찮습니다" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "개인정보 처리방침이 개정되었습니다" }, + "newRpcUrl": { + "message": "새 RPC URL" + }, "newTokensImportedMessage": { "message": "$1 토큰을 성공적으로 불러왔습니다.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask가 이 사이트와 연결되어 있지 않습니다" }, + "noConnectionDescription": { + "message": "사이트에 연결하려면 \"연결\" 버튼을 찾아 클릭하세요. MetaMask는 웹3의 사이트에만 연결할 수 있습니다." + }, "noConversionRateAvailable": { "message": "사용 가능한 환율 없음" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "맞춤 논스" }, + "none": { + "message": "없음" + }, "notBusy": { "message": "바쁘지 않음" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "권한 세부 정보" }, + "permissionFor": { + "message": "승인:" + }, + "permissionFrom": { + "message": "승인자:" + }, "permissionRequest": { "message": "권한 요청" }, @@ -3593,6 +3875,14 @@ "message": "MetaMask 설정에서 원하는 언어로 $1 스냅을 사용하세요. 이 기능을 사용하면 선택한 언어로 $1의 콘텐츠를 현지화하고 표시할 수 있습니다.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "선호하는 언어 및 명목화폐와 같은 정보를 확인하세요.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "MetaMask 설정에서 $1이(가) 회원님이 선호하는 언어 및 명목화폐와 같은 정보에 접근할 수 있도록 허용하세요. 이렇게 하면 $1이(가) 회원님의 선호도에 맞는 콘텐츠를 표시하는 데 도움이 됩니다. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "사용자 지정 화면 표시", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "내 계정에서 이만큼의 토큰을 사용할 수 있도록 승인합니다." }, + "permittedChainToastUpdate": { + "message": "$1은(는) $2에 액세스 할 수 있습니다." + }, "personalAddressDetected": { "message": "개인 주소가 발견되었습니다. 토큰 계약 주소를 입력하세요." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "받기" }, + "receiveCrypto": { + "message": "암호화폐 받기" + }, + "recipientAddressPlaceholderNew": { + "message": "공개 주소(0x) 또는 도메인 이름 입력" + }, "recommendedGasLabel": { "message": "권장됨" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "거부됨" }, + "rememberSRPIfYouLooseAccess": { + "message": "비밀 복구 구문을 분실하면 지갑에 액세스할 수 없습니다. 언제든지 자금에 접근할 수 있도록 $1(을)를 통해 이 단어들을 안전하게 보관하세요" + }, + "reminderSet": { + "message": "알림 설정 완료!" + }, "remove": { "message": "제거" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "오류로 인해 보안업체가 이 요청을 확인하지 못했습니다. 주의하여 진행하세요." }, + "requestingFor": { + "message": "요청 중:" + }, + "requestingForAccount": { + "message": "$1 요청 중", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "확인 대기 중인 요청" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "시드 구문 보기" }, + "review": { + "message": "검토" + }, + "reviewAlert": { + "message": "경고 검토" + }, "reviewAlerts": { "message": "경고 검토하기" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "권한 철회" }, + "revokeSimulationDetailsDesc": { + "message": "계정에서 토큰을 사용할 수 있는 다른 사람의 권한을 제거합니다." + }, "revokeSpendingCap": { "message": "$1에 대한 지출 한도 취소", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "해당 타사는 현재는 물론 미래에도 토큰을 더 이상 사용할 수 없습니다." }, + "rpcNameOptional": { + "message": "RPC 이름(선택)" + }, "rpcUrl": { "message": "새 RPC URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "보안 및 프라이버시" }, + "securityDescription": { + "message": "안전하지 않은 네트워크에 가입할 가능성을 줄이고 계정을 보호하세요" + }, + "securityMessageLinkForNetworks": { + "message": "네트워크 사기 및 보안 위험" + }, + "securityPrivacyPath": { + "message": "설정 > 보안 및 개인정보" + }, "securityProviderPoweredBy": { "message": "$1 제공", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "모든 권한 보기", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "세부 정보 보기" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "원하는 계정이 표시되지 않는다면 HD 경로 또는 현재 선택한 네트워크를 전환해 보세요." }, + "selectRpcUrl": { + "message": "RPC URL 선택" + }, "selectType": { "message": "유형 선택" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "모두 승인 설정" }, + "setApprovalForAllRedesignedTitle": { + "message": "인출 요청" + }, "setApprovalForAllTitle": { "message": "$1 무제한 지출 승인", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "설정" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "손쉬운 사용과 보안을 위해 설정이 최적화되었습니다. 언제든지 변경할 수 있습니다." + }, "settingsSearchMatchingNotFound": { "message": "검색 결과가 없습니다." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "더 보기" }, + "showNativeTokenAsMainBalance": { + "message": "네이티브 토큰을 기본 잔액으로 표시" + }, "showNft": { "message": "NFT 표시" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "다음으로 로그인:" }, + "simulationApproveHeading": { + "message": "인출" + }, + "simulationDetailsApproveDesc": { + "message": "다른 사람에게 내 계정에서 NFT를 인출할 수 있는 권한을 부여합니다." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "다른 사람에게 계정에서 이 금액을 사용할 수 있는 권한을 부여합니다." + }, "simulationDetailsFiatNotAvailable": { "message": "이용할 수 없음" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "보냄:" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "계정에서 NFT를 인출할 수 있는 다른 사람의 권한을 제거합니다." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "다른 사람에게 계정에서 NFT를 인출할 수 있도록 권한을 부여합니다." + }, "simulationDetailsTitle": { "message": "예상 변동 사항" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "죄송합니다! 문제가 생겼습니다." }, + "sortBy": { + "message": "정렬 기준:" + }, + "sortByAlphabetically": { + "message": "알파벳순(A-Z)" + }, + "sortByDecliningBalance": { + "message": "잔액 내림차순($1 최고-최저)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "소스" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "사용자" }, + "spenderTooltipDesc": { + "message": "NFT를 인출할 수 있는 주소입니다." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "이 주소는 회원님을 대신하여 토큰을 사용할 수 있는 주소입니다." + }, "spendingCap": { "message": "지출 한도" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "$1 관련 지출 한도 요청" }, + "spendingCapTooltipDesc": { + "message": "지출자가 회원님을 대신하여 접근할 수 있는 토큰의 양입니다." + }, "srpInputNumberOfWords": { "message": "제 구문은 $1개의 단어로 이루어져 있습니다", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "제안인: $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "제안된 통화 심볼:" + }, "suggestedTokenName": { "message": "추천 이름:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "지원 센터 방문하기" }, + "supportMultiRpcInformation": { + "message": "이제 단일 네트워크에 대해 여러 개의 RPC를 지원합니다. 충돌하는 정보를 해결하기 위해 최근의 RPC를 기본값으로 선택했습니다." + }, "surveyConversion": { "message": "설문조사에 참여하세요" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "가스비는 예상치이며 네트워크 트래픽 및 트랜잭션 복잡성에 따라 변동됩니다." }, + "swapGasFeesExplanation": { + "message": "MetaMask는 가스비로 수익을 얻지 않습니다. 이 가스비는 추정치이며 네트워크의 혼잡도와 거래의 복잡성에 따라 변할 수 있습니다. 자세히 알아보기 $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "여기", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "가스비 자세히 알아보기" }, @@ -5186,9 +5583,19 @@ "message": "가스비는 $1 네트워크에서 트랜잭션을 처리하는 암호화폐 채굴자에게 지급됩니다. MetaMask는 가스비로 수익을 창출하지 않습니다.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "이 견적에는 보내거나 받는 토큰 금액을 조정하는 방식으로 가스비가 포함됩니다. 활동 목록에서 별도의 거래로 ETH를 받을 수 있습니다." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "가스비에 대해 더 자세히 알아보기" + }, "swapHighSlippage": { "message": "높은 슬리피지" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "가스비 및 $1%의 MetaMask 수수료가 포함됩니다", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "$1%의 MetaMask 요금이 포함됩니다.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "이용약관이 개정되었습니다" }, + "testnets": { + "message": "테스트넷" + }, "theme": { "message": "테마" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "트랜잭션 수수료" }, + "transactionFlowNetwork": { + "message": "네트워크" + }, "transactionHistoryBaseFee": { "message": "기본 수수료(GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "전송" }, + "transferCrypto": { + "message": "암호화폐 전송" + }, "transferFrom": { "message": "전송 위치" }, + "transferRequest": { + "message": "전송 요청" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "업데이트" }, + "updateEthereumChainConfirmationDescription": { + "message": "이 사이트에서 기본 네트워크 URL 업데이트를 요청하고 있습니다. 기본값 및 네트워크 정보는 언제든지 수정할 수 있습니다." + }, + "updateNetworkConfirmationTitle": { + "message": "$1 업데이트", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "정보를 업데이트하거나" }, "updateRequest": { "message": "업데이트 요청" }, + "updatedRpcForNetworks": { + "message": "네트워크 RPC 업데이트 완료" + }, "uploadDropFile": { "message": "여기에 파일을 드롭" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "당사의 하드웨어 지갑 연결 가이드" }, + "walletProtectedAndReadyToUse": { + "message": "지갑이 보호되고 있으며 사용할 준비가 되었습니다. 다음에서 비밀 복구 구문을 확인할 수 있습니다: $1", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "이 네트워크를 추가하시겠습니까?" }, @@ -5991,6 +6424,17 @@ "message": "$1 타사는 추가 통보나 동의 없이도 남은 토큰 전체를 사용할 수 있습니다. 보호를 위해 지출 한도를 하향하세요.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "이 옵션을 켜면 공개 주소 또는 ENS 이름을 통해 이더리움 계정을 볼 수 있게 됩니다. 이 베타 기능에 대한 피드백을 보내주시려면 이 $1을(를) 작성해 주세요.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "이더리움 계정 모니터(베타)" + }, + "watchOutMessage": { + "message": "$1에 주의하세요.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "약함" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "이것은 무엇인가요?" }, + "withdrawing": { + "message": "인출" + }, "wrongNetworkName": { "message": "기록에 따르면 네트워크 이름이 이 체인 ID와 일치하지 않습니다." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "내 잔액" }, + "yourBalanceIsAggregated": { + "message": "잔액이 집계되었습니다." + }, "yourNFTmayBeAtRisk": { "message": "NFT가 위험할 수 있습니다" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "블록체인에서 거래가 확인되기 전에는 거래를 취소할 수 없습니다." }, + "yourWalletIsReady": { + "message": "지갑이 준비되었습니다" + }, "zeroGasPriceOnSpeedUpError": { "message": "가속화 시 가스 가격 0" } diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index e868c22a817e..1e39077afa51 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Conecte sua carteira de hardware QR" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "O endereço na solicitação de entrada não coincide com o endereço da conta que você está usando para entrar." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Selecione as contas das quais deseja receber notificações:" }, + "accountBalance": { + "message": "Saldo da conta" + }, "accountDetails": { "message": "Detalhes da conta" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Opções da conta" }, + "accountPermissionToast": { + "message": "Permissões de conta atualizadas" + }, "accountSelectionRequired": { "message": "Você precisa selecionar uma conta!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Contas conectadas" }, + "accountsPermissionsTitle": { + "message": "Ver suas contas e sugerir transações" + }, + "accountsSmallCase": { + "message": "contas" + }, "active": { "message": "Ativo" }, @@ -180,12 +195,18 @@ "add": { "message": "Adicionar" }, + "addACustomNetwork": { + "message": "Adicionar uma rede personalizada" + }, "addANetwork": { "message": "Adicionar uma rede" }, "addANickname": { "message": "Adicionar um apelido" }, + "addAUrl": { + "message": "Adicionar um URL" + }, "addAccount": { "message": "Adicionar conta" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Adicionar um explorador de blocos" }, + "addBlockExplorerUrl": { + "message": "Adicionar URL de um explorador de blocos" + }, "addContact": { "message": "Adicionar contato" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Você está adicionando um novo provedor RPC para a Mainnet da Ethereum" }, + "addEthereumWatchOnlyAccount": { + "message": "Visualize uma conta Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Adicionar amigos e endereços confiáveis" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Adicionar rede" }, + "addNetworkConfirmationTitle": { + "message": "Adicionar $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Adicionar uma nova conta Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Endereço copiado!" }, + "addressMismatch": { + "message": "Divergência no endereço do site" + }, + "addressMismatchOriginal": { + "message": "URL atual: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Versão Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Avançado" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "A taxa de prioridade (ou seja, \"gorjeta do minerador\") vai diretamente para os mineradores e os incentiva a priorizar a sua transação." }, + "aggregatedBalancePopover": { + "message": "Isso reflete o valor de todos os tokens que você possui em uma dada rede. Se você preferir ver esse valor em ETH ou outras moedas, acesse a $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Concordo com os $1 da MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Isso pode ser alterado em \"Configurações > Alertas\"" }, + "alertMessageAddressMismatchWarning": { + "message": "Golpistas às vezes imitam os sites fazendo pequenas alterações em seus endereços. Certifique-se de estar interagindo com o site correto antes de continuar." + }, "alertMessageGasEstimateFailed": { "message": "Não conseguimos fornecer uma taxa precisa, e essa estimativa pode estar alta. Sugerimos que você informe um limite de gás personalizado, mas há o risco de a transação falhar mesmo assim." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Para continuar com essa transação, você precisará aumentar o limite de gás para 21000 ou mais." }, + "alertMessageInsufficientBalance2": { + "message": "Você não tem ETH suficiente em sua conta para pagar as taxas de rede." + }, "alertMessageNetworkBusy": { "message": "Os preços do gás são altos e as estimativas são menos precisas." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Opções do ativo" }, + "assets": { + "message": "Ativos" + }, + "assetsDescription": { + "message": "Detecte automaticamente os tokens em sua carteira, exiba NFTs e receba atualizações de saldo de contas em lote" + }, "attemptSendingAssets": { "message": "Você poderá perder seus ativos se tentar enviá-los a partir de outra rede. Transfira fundos entre redes com segurança usando uma ponte." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Usar uma ponte, não enviar" }, + "bridgeFrom": { + "message": "Ponte de" + }, + "bridgeSelectNetwork": { + "message": "Selecionar rede" + }, + "bridgeTo": { + "message": "Ponte para" + }, "browserNotSupported": { "message": "Seu navegador não é compatível..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Confirmar Frase de Recuperação Secreta" }, + "confirmTitleApproveTransaction": { + "message": "Solicitação de limite de gasto" + }, + "confirmTitleDeployContract": { + "message": "Implementar um contrato" + }, + "confirmTitleDescApproveTransaction": { + "message": "Este site quer permissão para sacar seus NFTs" + }, + "confirmTitleDescDeployContract": { + "message": "Este site quer que você implemente um contrato" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Este site quer permissão para sacar seus tokens" + }, "confirmTitleDescPermitSignature": { "message": "Este site quer permissão para gastar seus tokens." }, "confirmTitleDescSIWESignature": { "message": "Um site quer que você faça login para comprovar que é titular desta conta." }, + "confirmTitleDescSign": { + "message": "Revise os detalhes da solicitação antes de confirmar." + }, "confirmTitlePermitTokens": { "message": "Solicitação de limite de gastos" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Remover permissão" + }, "confirmTitleSIWESignature": { "message": "Solicitação de entrada" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Remover permissão" + }, "confirmTitleSignature": { "message": "Solicitação de assinatura" }, "confirmTitleTransaction": { "message": "Solicitação de transação" }, + "confirmationAlertModalDetails": { + "message": "Para proteger seus ativos e informações de login, é recomendável que você recuse a solicitação." + }, + "confirmationAlertModalTitle": { + "message": "Esta solicitação é suspeita" + }, "confirmed": { "message": "Confirmada" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Detectamos um caractere ambíguo no nome ENS. Verifique o nome ENS para evitar um possível golpe." }, + "congratulations": { + "message": "Parabéns!" + }, "connect": { "message": "Conectar" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Sites conectados" }, + "connectedSitesAndSnaps": { + "message": "Sites e Snaps conectados" + }, "connectedSitesDescription": { "message": "$1 está conectada a esses sites. Eles podem visualizar o endereço da sua conta.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "A MetaMask está conectada a este site, mas nenhuma conta está conectada ainda" }, + "connectedSnaps": { + "message": "Snaps conectados" + }, + "connectedWithAccount": { + "message": "$1 contas conectadas", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Conectado com $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Conectando" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Conectando à rede de teste Sepolia" }, + "connectionDescription": { + "message": "Este site quer" + }, "connectionFailed": { "message": "Falha na conexão" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Copiar endereço para a área de transferência" }, + "copyAddressShort": { + "message": "Copiar endereço" + }, "copyPrivateKey": { "message": "Copiar chave privada" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL padrão da RPC" }, + "defaultSettingsSubTitle": { + "message": "A MetaMask usa as configurações padrão para melhor equilibrar a segurança e a facilidade de uso. Altere essas configurações para aumentar ainda mais sua privacidade." + }, + "defaultSettingsTitle": { + "message": "Configurações de privacidade padrão" + }, "delete": { "message": "Excluir" }, "deleteContact": { "message": "Excluir contato" }, + "deleteMetaMetricsData": { + "message": "Excluir dados do MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Isso excluirá dados históricos do MetaMetrics associados ao seu uso neste dispositivo. Sua carteira e contas continuarão exatamente como estão agora após a exclusão desses dados. Esse processo pode levar até 30 dias. Veja nossa $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Não é possível atender a essa solicitação no momento devido a um problema no servidor do sistema de análises. Tente novamente mais tarde" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Não é possível excluir estes dados no momento" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Estamos prestes a remover todos os seus dados do MetaMetrics. Tem certeza?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Excluir dados do MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Você iniciou essa ação em $1. Esse processo pode levar até 30 dias. Consulte a $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Se excluir essa rede, você precisará adicioná-la novamente para ver seus ativos nela" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Depositar" }, + "depositCrypto": { + "message": "Deposite criptomoedas de outra conta com um endereço de carteira ou código QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Devido a atualizações no sistema Ethereum, a rede de teste Goerli será descontinuada em breve." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "contas" }, + "disconnectAllDescriptionText": { + "message": "Se você se desconectar deste site, precisará reconectar suas contas e redes para usar este site novamente." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Isso desconectará você deste site" + }, "disconnectPrompt": { "message": "Desconectar $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Editar apelido" }, + "editAccounts": { + "message": "Editar contas" + }, "editAddressNickname": { "message": "Editar apelido do endereço" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "editar a rede original" }, + "editNetworksTitle": { + "message": "Editar redes" + }, "editNonceField": { "message": "Editar nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Editar permissão" }, + "editPermissions": { + "message": "Editar permissões" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Editar taxa de gás para aceleração" }, + "editSpendingCap": { + "message": "Editar limite de gastos" + }, + "editSpendingCapAccountBalance": { + "message": "Saldo da conta: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Insira o valor que você considera adequado que seja gasto em seu nome." + }, + "editSpendingCapError": { + "message": "O limite de gastos não pode exceder $1 dígitos decimais. Remova os dígitos decimais para continuar." + }, "enableAutoDetect": { "message": " Ativar detecção automática" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Falha na busca de ENS." }, + "enterANameToIdentifyTheUrl": { + "message": "Insira um nome para identificar o URL" + }, "enterANumber": { "message": "Insira um número" }, + "enterChainId": { + "message": "Insira a ID da cadeia" + }, "enterCustodianToken": { "message": "Insira seu token $1 ou adicione um novo" }, "enterMaxSpendLimit": { "message": "Digite um limite máximo de gastos" }, + "enterNetworkName": { + "message": "Insira o nome da rede" + }, "enterOptionalPassword": { "message": "Insira a senha opcional" }, "enterPasswordContinue": { "message": "Insira a senha para continuar" }, + "enterRpcUrl": { + "message": "Insira o URL da RPC" + }, + "enterSymbol": { + "message": "Insira o símbolo" + }, "enterTokenNameOrAddress": { "message": "Insira o nome do token ou cole o endereço" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Experimental" }, + "exportYourData": { + "message": "Exportar seus dados" + }, + "exportYourDataButton": { + "message": "Baixar" + }, + "exportYourDataDescription": { + "message": "Você pode exportar dados como seus contatos e preferências." + }, "extendWalletWithSnaps": { "message": "Explore Snaps desenvolvidos pela comunidade para personalizar sua experiência na web3", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Essa taxa de gás foi sugerida por $1. Sua substituição pode causar um problema com a sua transação. Entre em contato com $1 se tiver perguntas.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Taxa de gás" + }, "gasIsETH": { "message": "O gás é $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Algo deu errado..." }, + "generalDescription": { + "message": "Sincronize as configurações entre dispositivos, selecione as preferências de rede e rastreie dados de tokens" + }, "genericExplorerView": { "message": "Ver conta na $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Se você ficar impedido de entrar no app ou se usar um novo dispositivo, perderá seus fundos. Certifique-se de fazer backup da sua Frase de Recuperação Secreta em $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Ignorar tudo" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "em suas Configurações" }, + "included": { + "message": "incluída" + }, "infuraBlockedNotification": { "message": "Não foi possível conectar a MetaMask ao servidor da blockchain. Revise possíveis motivos $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "Arquivo JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Guarde um lembrete da sua Frase de Recuperação Secreta em algum lugar seguro. Se você a perder, ninguém poderá ajudar a recuperá-la. Pior ainda, você nunca mais poderá acessar sua carteira. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Nome da conta" }, @@ -2402,6 +2622,9 @@ "message": "Saiba como $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Saiba como" + }, "learnMore": { "message": "saiba mais" }, @@ -2409,6 +2632,9 @@ "message": "Quer $1 sobre gás?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Saiba mais sobre as melhores práticas de privacidade." + }, "learnMoreKeystone": { "message": "Saiba mais" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Link" }, + "linkCentralizedExchanges": { + "message": "Vincule suas contas Coinbase ou Binance para transferir gratuitamente criptomoedas para a MetaMask." + }, "links": { "message": "Links" }, @@ -2557,6 +2786,9 @@ "message": "Certifique-se de que ninguém está olhando", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Gerenciar configurações de privacidade padrão" + }, "marketCap": { "message": "Capitalização de mercado" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "O botão de status da conexão mostra se o website que você está visitando está conectado à conta selecionada no momento." }, + "metaMetricsIdNotAvailableError": { + "message": "Visto que você nunca aceitou participar do MetaMetrics, não há dados para excluir aqui." + }, "metadataModalSourceTooltip": { "message": "$1 está hospedado no npm e $2 é o identificador específico deste Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "mais" }, + "moreAccounts": { + "message": "Mais $1 contas adicionais", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "Mais $1 redes adicionais", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Você está adicionando esta rede à MetaMask e dando a este site permissão para usá-la." + }, "multipleSnapConnectionWarning": { "message": "$1 quer usar Snaps de $2", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Editar detalhes da rede" }, "nativeTokenScamWarningDescription": { - "message": "Esta rede não corresponde ao seu ID da cadeia ou nome associado. Como muitos tokens populares usam o nome $1, ele é visado para golpes. Golpistas podem enganar você para que envie a eles alguma moeda mais valiosa em troca. Confirme todas as informações antes de prosseguir.", + "message": "O símbolo do token nativo é diferente do símbolo esperado do token nativo da rede com a ID da cadeia associada. Você inseriu $1, enquanto o símbolo de token esperado é $2. Verifique se você está conectado à cadeia correta.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "outra coisa", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Isto é um possível golpe", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Detalhes da rede" }, + "networkFee": { + "message": "Taxa de rede" + }, "networkIsBusy": { "message": "A rede está ocupada. Os preços de gás estão altos e as estimativas estão menos exatas." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Opções da rede" }, + "networkPermissionToast": { + "message": "Permissões de rede atualizadas" + }, "networkProvider": { "message": "Provedor de rede" }, @@ -2865,15 +3121,26 @@ "message": "Não podemos conectar a $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Rede mudou para $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL da rede" }, "networkURLDefinition": { "message": "O URL usado para acessar essa rede." }, + "networkUrlErrorWarning": { + "message": "Golpistas às vezes imitam os sites fazendo pequenas alterações em seus endereços. Certifique-se de estar interagindo com o site correto antes de continuar. Versão Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Redes" }, + "networksSmallCase": { + "message": "redes" + }, "nevermind": { "message": "Desistir" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Atualizamos nossa política de privacidade" }, + "newRpcUrl": { + "message": "URL da nova RPC" + }, "newTokensImportedMessage": { "message": "Você importou $1 com sucesso.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "A MetaMask não está conectada a este site" }, + "noConnectionDescription": { + "message": "Para se conectar a um site, encontre e selecione o botão \"conectar\". Lembre-se de que a MetaMask só pode se conectar a sites na web3" + }, "noConversionRateAvailable": { "message": "Não há uma taxa de conversão disponível" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Nonce personalizado" }, + "none": { + "message": "Nenhum" + }, "notBusy": { "message": "Não ocupado" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Detalhes da permissão" }, + "permissionFor": { + "message": "Permissão para" + }, + "permissionFrom": { + "message": "Permissão de" + }, "permissionRequest": { "message": "Solicitação de permissão" }, @@ -3593,6 +3875,14 @@ "message": "Permita que $1 acesse seu idioma de preferência a partir de suas configurações da MetaMask. Isso pode ser usado para traduzir e exibir o conteúdo de $1 usando seu idioma.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Ver informações como seu idioma preferencial e moeda fiduciária.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Permita que $1 acesse informações como seu idioma preferencial e moeda fiduciária em suas configurações da MetaMask. Isso ajuda $1 a exibir conteúdo ajustado às suas preferências. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Exibe uma tela personalizada", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Você está autorizando o consumidor a gastar esta quantidade de tokens de sua conta." }, + "permittedChainToastUpdate": { + "message": "$1 tem acesso a $2." + }, "personalAddressDetected": { "message": "Endereço pessoal detectado. Insira o endereço de contrato do token." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Receber" }, + "receiveCrypto": { + "message": "Receber criptomoeda" + }, + "recipientAddressPlaceholderNew": { + "message": "Insira o endereço público (0x) ou nome de domínio" + }, "recommendedGasLabel": { "message": "Recomendado" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Recusada" }, + "rememberSRPIfYouLooseAccess": { + "message": "Lembre-se, se você perder sua Frase de Recuperação Secreta, perderá acesso à sua carteira. $1 manter esse conjunto de palavras em segurança, para que possa acessar seus fundos a qualquer momento." + }, + "reminderSet": { + "message": "Lembrete definido!" + }, "remove": { "message": "Remover" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Por causa de um erro, essa solicitação não foi verificada pelo provedor de segurança. Prossiga com cautela." }, + "requestingFor": { + "message": "Solicitando para" + }, + "requestingForAccount": { + "message": "Solicitando para $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "solicitações aguardando confirmação" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Revelar a frase-semente" }, + "review": { + "message": "Revisar" + }, + "reviewAlert": { + "message": "Rever alerta" + }, "reviewAlerts": { "message": "Conferir alertas" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Revogar permissão" }, + "revokeSimulationDetailsDesc": { + "message": "Você está removendo a permissão para alguém gastar tokens da sua conta." + }, "revokeSpendingCap": { "message": "Revogar limite de gastos de seu $1", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Esse terceiro não poderá gastar mais nenhum dos seus tokens atuais ou futuros." }, + "rpcNameOptional": { + "message": "Nome da RPC (opcional)" + }, "rpcUrl": { "message": "Novo URL da RPC" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Segurança e Privacidade" }, + "securityDescription": { + "message": "Reduza suas chances de ingressar em redes perigosas e proteja suas contas" + }, + "securityMessageLinkForNetworks": { + "message": "golpes de rede e riscos de segurança" + }, + "securityPrivacyPath": { + "message": "Configurações > Segurança e Privacidade." + }, "securityProviderPoweredBy": { "message": "Com tecnologia da $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Ver todas as permissões", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Ver detalhes" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Se as contas que você esperava não forem exibidas, tente mudar o caminho do HD ou a rede atualmente selecionada." }, + "selectRpcUrl": { + "message": "Selecionar URL da RPC" + }, "selectType": { "message": "Selecione o tipo" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Definir aprovação para todos" }, + "setApprovalForAllRedesignedTitle": { + "message": "Solicitação de saque" + }, "setApprovalForAllTitle": { "message": "Aprovar $1 sem limite de gastos", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Configurações" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "As configurações são otimizadas para facilidade de uso e segurança. Altere-as quando quiser." + }, "settingsSearchMatchingNotFound": { "message": "Nenhum resultado correspondente encontrado." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Exibir mais" }, + "showNativeTokenAsMainBalance": { + "message": "Exibir tokens nativos como saldo principal" + }, "showNft": { "message": "Exibir NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Assinando com" }, + "simulationApproveHeading": { + "message": "Sacar" + }, + "simulationDetailsApproveDesc": { + "message": "Você está dando a outra pessoa permissão para sacar NFTs da sua conta." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Você está dando a alguém permissão para gastar esse valor da sua conta." + }, "simulationDetailsFiatNotAvailable": { "message": "Não disponível" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Você envia" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Você está removendo a permissão para outra pessoa sacar NFTs da sua conta." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Você está dando permissão para outra pessoa sacar NFTs de sua conta." + }, "simulationDetailsTitle": { "message": "Alterações estimadas" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Ops! Algo deu errado." }, + "sortBy": { + "message": "Classificar por" + }, + "sortByAlphabetically": { + "message": "Alfabeticamente (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Saldo decrescente ($1 alto-baixo)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Fonte" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Consumidor" }, + "spenderTooltipDesc": { + "message": "Este é o endereço que poderá sacar seus NFTs." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Este é o endereço que poderá gastar seus tokens em seu nome." + }, "spendingCap": { "message": "Limite de gastos" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Solicitação de limite de gastos para seu $1" }, + "spendingCapTooltipDesc": { + "message": "Esta é a quantidade de tokens que o consumidor poderá acessar em seu nome." + }, "srpInputNumberOfWords": { "message": "Eu tenho uma frase com $1 palavras", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Sugerido por $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Símbolo sugerido para a moeda:" + }, "suggestedTokenName": { "message": "Nome sugerido:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Visite a nossa Central de Suporte" }, + "supportMultiRpcInformation": { + "message": "Agora oferecemos suporte a várias RPCs para uma única rede. Sua RPC mais recente foi selecionada como padrão para resolver informações conflitantes." + }, "surveyConversion": { "message": "Responda à nossa pesquisa" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "As taxas de gás são estimadas e oscilam com base no tráfego da rede e na complexidade da transação." }, + "swapGasFeesExplanation": { + "message": "A MetaMask não ganha dinheiro com taxas de gás. Essas taxas são estimativas e podem mudar de acordo com a carga de atividade da rede e a complexidade da transação. Saiba mais $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "aqui", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Saiba mais sobre as taxas de gás" }, @@ -5186,9 +5583,19 @@ "message": "As taxas de gás são pagas aos mineradores de criptoativos que processam as transações na rede de $1. A MetaMask não lucra com taxas de gás.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Esta cotação incorpora taxas de gás através de ajuste do valor do token enviado ou recebido. Você pode receber ETH em uma transação separada em sua lista de atividades." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Saiba mais sobre taxas de gás" + }, "swapHighSlippage": { "message": "Slippage alto" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Inclui taxa de gás e uma taxa de $1% da MetaMask", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Inclui uma taxa de $1% da MetaMask.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Nossos Termos de Uso foram atualizados" }, + "testnets": { + "message": "Testnets" + }, "theme": { "message": "Tema" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Taxa de transação" }, + "transactionFlowNetwork": { + "message": "Rede" + }, "transactionHistoryBaseFee": { "message": "Taxa-base (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Transferir" }, + "transferCrypto": { + "message": "Transferir criptomoedas" + }, "transferFrom": { "message": "Transferir de" }, + "transferRequest": { + "message": "Solicitação de transferência" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Atualizar" }, + "updateEthereumChainConfirmationDescription": { + "message": "Este site está solicitando a atualização do URL de rede padrão. Você pode editar padrões e informações de rede quando quiser." + }, + "updateNetworkConfirmationTitle": { + "message": "Atualizar $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Atualize suas informações ou" }, "updateRequest": { "message": "Solicitação de atualização" }, + "updatedRpcForNetworks": { + "message": "RPCs de rede atualizadas" + }, "uploadDropFile": { "message": "Solte seu arquivo aqui" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "nosso guia de conexão com a carteira de hardware" }, + "walletProtectedAndReadyToUse": { + "message": "Sua carteira está protegida e pronta para ser usada. Você pode encontrar sua Frase de Recuperação Secreta em $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Desejar adicionar esta rede?" }, @@ -5991,6 +6424,17 @@ "message": "$1 O terceiro pode gastar todo o seu saldo de tokens sem aviso ou consentimento. Proteja-se personalizando um limite de gastos menor.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Ativar esta opção permitirá que você visualize contas Ethereum através de um endereço público ou nome ENS. Para dar sua opinião sobre este recurso Beta, preencha este $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Visualizar contas Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Tome cuidado com $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Fraca" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "O que é isso?" }, + "withdrawing": { + "message": "Sacando" + }, "wrongNetworkName": { "message": "De acordo com os nossos registros, o nome da rede pode não corresponder a esta ID de cadeia." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Seu saldo" }, + "yourBalanceIsAggregated": { + "message": "Seu saldo é agregado" + }, "yourNFTmayBeAtRisk": { "message": "Seu NFT pode estar em risco" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Não pudemos cancelar sua transação antes de ser confirmada na blockchain." }, + "yourWalletIsReady": { + "message": "Sua carteira está pronta" + }, "zeroGasPriceOnSpeedUpError": { "message": "O preço do gás está zerado na aceleração" } diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index f995ce06a662..f68ee11792a1 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Подключите свой аппаратный кошелек на основе QR-кодов" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Адрес в запросе на вход не соответствует адресу счета, который вы используете для входа." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Выберите счета, о которых хотите получать уведомления:" }, + "accountBalance": { + "message": "Баланс счета" + }, "accountDetails": { "message": "Реквизиты счета" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Параметры счета" }, + "accountPermissionToast": { + "message": "Разрешения счета обновлены" + }, "accountSelectionRequired": { "message": "Вам необходимо выбрать счет!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Счета подключены" }, + "accountsPermissionsTitle": { + "message": "Просматривать ваши счета и предлагать транзакции" + }, + "accountsSmallCase": { + "message": "счета" + }, "active": { "message": "Активный" }, @@ -180,12 +195,18 @@ "add": { "message": "Добавить" }, + "addACustomNetwork": { + "message": "Добавить пользовательскую сеть" + }, "addANetwork": { "message": "Добавить сеть" }, "addANickname": { "message": "Добавить ник" }, + "addAUrl": { + "message": "Добавить URL" + }, "addAccount": { "message": "Добавить счет" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Добавить обозреватель блоков" }, + "addBlockExplorerUrl": { + "message": "Добавить URL обозревателя блоков" + }, "addContact": { "message": "Добавить контакт" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Вы добавляете нового поставщика RPC для Мейн-нета Ethereum" }, + "addEthereumWatchOnlyAccount": { + "message": "Следить за счетом Ethereum (бета)" + }, "addFriendsAndAddresses": { "message": "Добавьте друзей и адреса, которым доверяете" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Добавить сеть" }, + "addNetworkConfirmationTitle": { + "message": "Добавить $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Добавить новый счет Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Адрес скопирован!" }, + "addressMismatch": { + "message": "Несоответствие адреса сайта" + }, + "addressMismatchOriginal": { + "message": "Текущий URL-адрес: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Версия пьюникода: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Дополнительно" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Плата за приоритет (также известная как «чаевые» майнеру) направляется непосредственно майнерам, чтобы они уделили приоритетное внимание вашей транзакции." }, + "aggregatedBalancePopover": { + "message": "Это значение отражает стоимость всех токенов, которыми вы владеете в данной сети. Если вы предпочитаете видеть это значение в ETH или других валютах, перейдите к $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Я соглашаюсь с $1 MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Это можно изменить в разделе «Настройки» > «Оповещения»" }, + "alertMessageAddressMismatchWarning": { + "message": "Злоумышленники иногда имитируют сайты, внося небольшие изменения в адрес сайта. Прежде чем продолжить, убедитесь, что вы взаимодействуете с нужным сайтом." + }, "alertMessageGasEstimateFailed": { "message": "Мы не можем указать точную сумму комиссии, и эта оценка может быть высокой. Мы предлагаем вам ввести индивидуальный лимит газа, но существует риск, что транзакция все равно не удастся." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Чтобы продолжить эту транзакцию, вам необходимо увеличить лимит газа до 21 000 или выше." }, + "alertMessageInsufficientBalance2": { + "message": "У вас на счету недостаточно ETH для оплаты комиссий сети." + }, "alertMessageNetworkBusy": { "message": "Цены газа высоки, а оценки менее точны." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Параметры актива" }, + "assets": { + "message": "Активы" + }, + "assetsDescription": { + "message": "Автоматически обнаруживайте токены в своем кошельке, отображайте NFT и получайте пакетные обновления баланса счета" + }, "attemptSendingAssets": { "message": "Вы можете потерять свои активы, если попытаетесь отправить их из другой сети. Безопасно переводите средства между сетями с помощью моста." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Мост, не отправлять" }, + "bridgeFrom": { + "message": "Мост из" + }, + "bridgeSelectNetwork": { + "message": "Выбор сети" + }, + "bridgeTo": { + "message": "Мост в" + }, "browserNotSupported": { "message": "Ваш браузер не поддерживается..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Подтвердите секретную фразу для восстановления" }, + "confirmTitleApproveTransaction": { + "message": "Запрос квоты" + }, + "confirmTitleDeployContract": { + "message": "Развернуть контракт" + }, + "confirmTitleDescApproveTransaction": { + "message": "Этот сайт хочет получить разрешение на вывод ваших NFT" + }, + "confirmTitleDescDeployContract": { + "message": "Этот сайт хочет, чтобы вы развернули контракт" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Этот сайт хочет получить разрешение на вывод ваших токенов" + }, "confirmTitleDescPermitSignature": { "message": "Этот сайт хочет получить разрешение на расходование ваших токенов." }, "confirmTitleDescSIWESignature": { "message": "Сайт требует, чтобы вы вошли в систему и доказали, что вы являетесь владельцем этого счета." }, + "confirmTitleDescSign": { + "message": "Прежде чем подтвердить запрос, проверьте его реквизиты." + }, "confirmTitlePermitTokens": { "message": "Запрос лимита расходов" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Удалить разрешение" + }, "confirmTitleSIWESignature": { "message": "Запрос на вход" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Удалить разрешение" + }, "confirmTitleSignature": { "message": "Запрос подписи" }, "confirmTitleTransaction": { "message": "Запрос транзакции" }, + "confirmationAlertModalDetails": { + "message": "Чтобы защитить ваши активы и данные для входа, рекомендуем отклонить запрос." + }, + "confirmationAlertModalTitle": { + "message": "Этот запрос подозрительный" + }, "confirmed": { "message": "Подтвержден(-а/о)" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "В имени ENS обнаружен непонятный символ. Проверьте это имя, что исключить возможное мошенничество." }, + "congratulations": { + "message": "Поздравляем!" + }, "connect": { "message": "Подключиться" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Подключенные сайты" }, + "connectedSitesAndSnaps": { + "message": "Подключенные сайты и Snaps" + }, "connectedSitesDescription": { "message": "$1 подключен к этим сайтам. Они могут увидеть адрес вашего счета.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask подключен к этому сайту, но счета пока не подключены" }, + "connectedSnaps": { + "message": "Подключенные Snaps" + }, + "connectedWithAccount": { + "message": "Счета ($1) не подключены", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Подключен к $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Подключение..." }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Подключение к тестовой сети Sepolia..." }, + "connectionDescription": { + "message": "Этот сайт хочет" + }, "connectionFailed": { "message": "Не удалось установить подключение" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Скопировать адрес в буфер обмена" }, + "copyAddressShort": { + "message": "Копировать адрес" + }, "copyPrivateKey": { "message": "Копировать закрытый ключ" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL-адрес RPC по умолчанию" }, + "defaultSettingsSubTitle": { + "message": "MetaMask использует настройки по умолчанию, чтобы наилучшим образом сбалансировать безопасность и простоту использования. Измените эти настройки, чтобы еще больше повысить свою конфиденциальность." + }, + "defaultSettingsTitle": { + "message": "Настройки конфиденциальности по умолчанию" + }, "delete": { "message": "Удалить" }, "deleteContact": { "message": "Удалить контакт" }, + "deleteMetaMetricsData": { + "message": "Удалить данные MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Это приведет к удалению исторических данных MetaMetrics, связанных с использованием вами этого устройства. Ваш кошелек и счета останутся такими же, как сейчас, после удаления этих данных. Этот процесс может занять до 30 дней. Наша $1 содержит подробные сведения по этому вопросу.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Этот запрос нельзя выполнить сейчас из-за проблемы с сервером аналитической системы. Повторите попытку позже" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Мы не можем удалить эти данные прямо сейчас" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Мы собираемся удалить все ваши данные MetaMetrics. Вы уверены?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Удалить данные MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Вы инициировали это действие $1. Этот процесс может занять до 30 дней. $2 содержит подробности по этому вопросу", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Если вы удалите эту сеть, вам нужно будет добавить ее снова, чтобы просмотреть свои активы в этой сети" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Депозит" }, + "depositCrypto": { + "message": "Внесите криптовалюту с другого счета, используя адрес кошелька или QR-код." + }, "deprecatedGoerliNtwrkMsg": { "message": "Из-за обновлений системы Ethereum тестовая сеть Goerli скоро будет закрыта." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "счета" }, + "disconnectAllDescriptionText": { + "message": "Если вы отключитесь от этого сайта, вам придется повторно подключить свои счета и сети, чтобы снова использовать этот сайт." + }, "disconnectAllSnapsText": { "message": "Snaps" }, + "disconnectMessage": { + "message": "Это отключит вас от этого сайта" + }, "disconnectPrompt": { "message": "Отключить $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Изменить ник" }, + "editAccounts": { + "message": "Изменить аккаунты" + }, "editAddressNickname": { "message": "Изменить ник адреса" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "изменить исходную сеть" }, + "editNetworksTitle": { + "message": "Изменить сети" + }, "editNonceField": { "message": "Изменить одноразовый номер" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Изменить разрешение" }, + "editPermissions": { + "message": "Изменить разрешения" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Изменить плату за газ за ускорение" }, + "editSpendingCap": { + "message": "Изменить лимит расходов" + }, + "editSpendingCapAccountBalance": { + "message": "Баланс счета: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Введите сумму, которая может быть потрачена от вашего имени." + }, + "editSpendingCapError": { + "message": "Лимит расходов не может иметь более $1 десятичных знаков. Удалите лишние десятичные знаки, чтобы продолжить." + }, "enableAutoDetect": { "message": " Включить автоопределение" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Ошибка поиска ENS." }, + "enterANameToIdentifyTheUrl": { + "message": "Введите имя, чтобы идентифицировать URL" + }, "enterANumber": { "message": "Введите цифру" }, + "enterChainId": { + "message": "Введите ID блокчейна" + }, "enterCustodianToken": { "message": "Введите свой токен $1 или добавьте новый токен" }, "enterMaxSpendLimit": { "message": "Введите максимальный лимит расходов" }, + "enterNetworkName": { + "message": "Введите имя сети" + }, "enterOptionalPassword": { "message": "Введите необязательный пароль" }, "enterPasswordContinue": { "message": "Введите пароль, чтобы продолжить" }, + "enterRpcUrl": { + "message": "Введите URL RPC" + }, + "enterSymbol": { + "message": "Введите символ" + }, "enterTokenNameOrAddress": { "message": "Введите имя токена или вставьте адрес" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Экспериментальные" }, + "exportYourData": { + "message": "Экспортируйте свои данные" + }, + "exportYourDataButton": { + "message": "Скачать" + }, + "exportYourDataDescription": { + "message": "Вы можете экспортировать такие данные, как ваши контакты и предпочтения." + }, "extendWalletWithSnaps": { "message": "Изучите Snaps, созданные сообществом, чтобы персонализировать работу с web3", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Эта плата за газ была предложена $1. Ее переопредление может вызвать проблемы с вашей транзакцией. При наличии вопросов обратитесь к $1.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Плата за газ" + }, "gasIsETH": { "message": "Газ стоит $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Что-то пошло не так...." }, + "generalDescription": { + "message": "Синхронизируйте настройки между устройствами, выбирайте настройки сети и отслеживайте данные токенов" + }, "genericExplorerView": { "message": "Посмотреть счет на $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Если вы не сможете войти в приложение или приобретете новое устройство, вы потеряете свои средства. Обязательно сохраните резервную копию секретной фразы для восстановления в $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Игнорировать все" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "в ваших Настройках" }, + "included": { + "message": "включено" + }, "infuraBlockedNotification": { "message": "MetaMask не удалось подключиться к хосту блокчейна. Узнать возможные причины можно $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON-файл", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Сохраните напоминание о своей секретной фразе для восстановления в безопасном месте. Если вы потеряете ее, никто не сможет помочь вам вернуть ее. Хуже того, вы больше никогда не сможете получить доступ к своему кошельку. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Название счета" }, @@ -2402,6 +2622,9 @@ "message": "Узнайте подробнее, как $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Узнайте как" + }, "learnMore": { "message": "подробнее" }, @@ -2409,6 +2632,9 @@ "message": "Хотите $1 о газе?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Узнайте больше о лучших методах обеспечения конфиденциальности." + }, "learnMoreKeystone": { "message": "Подробнее" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Ссылка" }, + "linkCentralizedExchanges": { + "message": "Привяжите счета Coinbase или Binance, чтобы бесплатно переводить криптовалюту в MetaMask." + }, "links": { "message": "Ссылки" }, @@ -2557,6 +2786,9 @@ "message": "Убедитесь, что никто не смотрит", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Управление настройками конфиденциальности по умолчанию" + }, "marketCap": { "message": "Рыночная капитализация" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Кнопка статуса подключения показывает, подключен ли посещаемый вами веб-сайт, к выбранному вами в настоящее время счету." }, + "metaMetricsIdNotAvailableError": { + "message": "Поскольку вы никогда не включали MetaMetrics, здесь нет данных для удаления." + }, "metadataModalSourceTooltip": { "message": "$1 размещается на npm, а $2 — это уникальный идентификатор Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "больше" }, + "moreAccounts": { + "message": "+ еще $1 счета(-ов)", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ еще $1 сети(-ей)", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Вы добавляете эту сеть в MetaMask и разрешаете этому сайту использовать ее." + }, "multipleSnapConnectionWarning": { "message": "$1 хочет использовать $2 Snaps", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Изменить сведения о сети" }, "nativeTokenScamWarningDescription": { - "message": "Эта сеть не соответствует ID или имени связанного с ней блокчейна. Многие популярные токены используют название $1, что делает его объектом мошенничества. Мошенники могут обманом заставить вас отправить им взамен более ценную валюту. Проверьте все, прежде чем продолжить.", + "message": "Символ нативного токена не соответствует ожидаемому символу нативного токена для сети со связанным ID блокчейна. Вы ввели $1, а ожидаемый символ токена — $2. Убедитесь, что вы подключены к правильному блокчейну.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "что-то еще", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Это потенциальное мошенничество", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Сведения о сети" }, + "networkFee": { + "message": "Комиссия сети" + }, "networkIsBusy": { "message": "Сеть занята. Цены газа высоки, а оценки менее точны." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Параметры сети" }, + "networkPermissionToast": { + "message": "Обновлены сетевые разрешения" + }, "networkProvider": { "message": "Поставщик услуг сети" }, @@ -2865,15 +3121,26 @@ "message": "Нам не удается подключиться к $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Сеть изменена на $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL сети" }, "networkURLDefinition": { "message": "URL, используемый для доступа к этой сети." }, + "networkUrlErrorWarning": { + "message": "Злоумышленники иногда имитируют сайты, внося небольшие изменения в адрес сайта. Прежде чем продолжить, убедитесь, что вы взаимодействуете с нужным сайтом. Версия пьюникода: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Сети" }, + "networksSmallCase": { + "message": "сети" + }, "nevermind": { "message": "Неважно" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Мы обновили нашу политику конфиденциальности" }, + "newRpcUrl": { + "message": "Новый URL RPC" + }, "newTokensImportedMessage": { "message": "Вы успешно импортировали $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask не подключен к этому сайту" }, + "noConnectionDescription": { + "message": "Для подключения к сайту найдите и нажмите кнопку «подключиться». Помните, что MetaMask может подключаться только к сайтам в web3" + }, "noConversionRateAvailable": { "message": "Нет доступного обменного курса" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Пользовательский одноразовый номер" }, + "none": { + "message": "Нет" + }, "notBusy": { "message": "Не занят" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Сведения о разрешении" }, + "permissionFor": { + "message": "Разрешение для" + }, + "permissionFrom": { + "message": "Разрешение от" + }, "permissionRequest": { "message": "Запрос разрешения" }, @@ -3593,6 +3875,14 @@ "message": "Разрешите $1 получить доступ к предпочитаемому вами языку в настройках MetaMask. Его можно использовать для локализации и отображения содержимого $1 на вашем языке.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Просматривайте такую ​​информацию, как ваш предпочитаемый язык и фиатная валюта.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Предоставьте $1 доступ к такой информации, как ваш предпочитаемый язык и фиатная валюта, в настройках MetaMask. Это помогает $1 отображать контент с учетом ваших предпочтений. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Показать пользовательский экран", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Вы даёте расходующему лицу разрешение потратить именно столько токенов из вашего аккаунта" }, + "permittedChainToastUpdate": { + "message": "У $1 есть доступ к $2." + }, "personalAddressDetected": { "message": "Обнаружен личный адрес. Введите адрес контракта токена." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Получить" }, + "receiveCrypto": { + "message": "Получить криптовалюту" + }, + "recipientAddressPlaceholderNew": { + "message": "Введите публичный адрес (0x) или имя домена" + }, "recommendedGasLabel": { "message": "Рекомендовано" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Отклонено" }, + "rememberSRPIfYouLooseAccess": { + "message": "Помните: если вы потеряете секретную фразу для восстановления, вы утратите доступ к своему кошельку. $1, чтобы сохранить этот набор слов в безопасности и всегда иметь возможность доступа к своим средствам." + }, + "reminderSet": { + "message": "Напоминание установлено!" + }, "remove": { "message": "Удалить" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Из-за ошибки этот запрос не был подтвержден поставщиком услуг безопасности. Действуйте осторожно." }, + "requestingFor": { + "message": "Запрашивается для" + }, + "requestingForAccount": { + "message": "Запрашивается для $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "запросы, ожидающие подтверждения" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Показать сид-фразу" }, + "review": { + "message": "Проверить" + }, + "reviewAlert": { + "message": "Оповещение о проверке" + }, "reviewAlerts": { "message": "Просмотреть оповещения" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Отозвать разрешение" }, + "revokeSimulationDetailsDesc": { + "message": "Вы удаляете чье-либо разрешение на трату токенов с вашего счета." + }, "revokeSpendingCap": { "message": "Отменить верхний лимит расходов для вашего $1", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Эта третья сторона больше не сможет тратить ваши текущие или будущие токены." }, + "rpcNameOptional": { + "message": "Имя RPC (необязательно)" + }, "rpcUrl": { "message": "Новый URL RPC" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Безопасность и конфиденциальность" }, + "securityDescription": { + "message": "Уменьшите свои шансы присоединиться к небезопасным сетям и защитите свои счета" + }, + "securityMessageLinkForNetworks": { + "message": "мошенничества с сетью и угроз безопасности" + }, + "securityPrivacyPath": { + "message": "«Настройки» > «Безопасность и конфиденциальность»." + }, "securityProviderPoweredBy": { "message": "На основе $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Смотреть все разрешения", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "См. подробности" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Если вы не видите ожидаемые счета, попробуйте переключиться на путь HD или текущую выбранную сеть." }, + "selectRpcUrl": { + "message": "Выберите URL RPC" + }, "selectType": { "message": "Выбрать тип" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Установить одобрение для всех" }, + "setApprovalForAllRedesignedTitle": { + "message": "Запрос вывода средств" + }, "setApprovalForAllTitle": { "message": "Одобрить $1 без ограничений по расходам", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Настройки" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Настройки оптимизированы для простоты использования и безопасности. Изменяйте их в любое время." + }, "settingsSearchMatchingNotFound": { "message": "Совпадений не найдено." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Показать больше" }, + "showNativeTokenAsMainBalance": { + "message": "Показывать нативный токен в качестве основного баланса" + }, "showNft": { "message": "Показать NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Вход с помощью" }, + "simulationApproveHeading": { + "message": "Отозвать" + }, + "simulationDetailsApproveDesc": { + "message": "Вы даете кому-то другому разрешение на вывод NFT с вашего счета." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Вы даете кому-то разрешение на трату этой суммы с вашего счета." + }, "simulationDetailsFiatNotAvailable": { "message": "Недоступно" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Вы отправляете" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Вы отзываете чье-то разрешение на вывод NFT с вашего счета." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Вы даете кому-то разрешение на вывод NFT с вашего счета." + }, "simulationDetailsTitle": { "message": "Прогнозируемые изменения" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Ой! Что-то пошло не так." }, + "sortBy": { + "message": "Сортировать по" + }, + "sortByAlphabetically": { + "message": "По алфавиту (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Уменьшающийся баланс (от высокого к низкому в $1)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Источник" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Расходующее лицо" }, + "spenderTooltipDesc": { + "message": "Это адрес, по которому можно будет вывести ваши NFT." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Это адрес, который сможет потратить ваши токены от вашего имени." + }, "spendingCap": { "message": "Лимит расходов" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Запрос лимита расходов для вашего $1" }, + "spendingCapTooltipDesc": { + "message": "Это количество токенов, к которым покупатель сможет получить доступ от вашего имени." + }, "srpInputNumberOfWords": { "message": "У меня есть фраза из $1 слов(-а)", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Рекомендовано $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Рекомендуемый символ валюты:" + }, "suggestedTokenName": { "message": "Предлагаемое имя:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Посетите наш центр поддержки" }, + "supportMultiRpcInformation": { + "message": "Теперь мы поддерживаем несколько RPC для одной сети. Ваш последний RPC был выбран в качестве RPC по умолчанию для разрешения проблемы с противоречивой информацией." + }, "surveyConversion": { "message": "Пройдите наш опрос" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Плата за газ является примерной и будет колебаться в зависимости от сетевого трафика и сложности транзакции." }, + "swapGasFeesExplanation": { + "message": "MetaMask не получает плату за газ. Сумма этой платы являются приблизительной и может меняться в зависимости от загруженности сети и сложности транзакции. Узнайте подробнее $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "здесь", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Узнать больше о плате за газ" }, @@ -5186,9 +5583,19 @@ "message": "Плата за газ переводится криптомайнерам, которые обрабатывают транзакции в сети $1. MetaMask не получает прибыли от платы за газ.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Плата за газ включена в эту котировку путем корректировки суммы отправленных или полученных токенов. Вы можете получить ETH отдельной транзакцией в своем списке действий." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Подробнее о плате за газ" + }, "swapHighSlippage": { "message": "Высокое проскальзывание" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Включает плату за газ и комиссию MetaMask в размере $1%", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Включает комиссию MetaMask в размере $1%.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Наши Условия использования обновлены" }, + "testnets": { + "message": "Тестнеты" + }, "theme": { "message": "Тема" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Комиссия за транзакцию" }, + "transactionFlowNetwork": { + "message": "Сеть" + }, "transactionHistoryBaseFee": { "message": "Базовая комиссия (Гвей)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Перевести" }, + "transferCrypto": { + "message": "Перевести криптовалюту" + }, "transferFrom": { "message": "Перевести из" }, + "transferRequest": { + "message": "Запрос на перевод" + }, "trillionAbbreviation": { "message": "трлн", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Обновить" }, + "updateEthereumChainConfirmationDescription": { + "message": "Этот сайт запрашивает обновление URL вашей сети по умолчанию. Вы можете редактировать настройки по умолчанию и информацию о сети в любое время." + }, + "updateNetworkConfirmationTitle": { + "message": "Обновить $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Обновите свою информацию или" }, "updateRequest": { "message": "Запрос обновления" }, + "updatedRpcForNetworks": { + "message": "Обновлены сетевые RPC" + }, "uploadDropFile": { "message": "Переместите свой файл сюда" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "наше руководство по подключению аппаратного кошелька" }, + "walletProtectedAndReadyToUse": { + "message": "Ваш кошелек защищен и готов к использованию. Вы можете найти свою секретную фразу для восстановления в $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Хотите добавить эту сеть?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Третья сторона может потратить весь ваш баланс токенов без дополнительного уведомления или согласия. Защитите себя, установив более низкий лимит расходов.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Включение этой опции даст вам возможность отслеживать счета Ethereum через публичный адрес или имя ENS. Чтобы оставить отзыв об этой бета-функции, заполните этот $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Следить за счетами Ethereum (бета)" + }, + "watchOutMessage": { + "message": "Остерегайтесь $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Слабый" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Что это?" }, + "withdrawing": { + "message": "Выполняется вывод" + }, "wrongNetworkName": { "message": "Согласно нашим записям, имя сети может не соответствовать этому ID блокчейна." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Ваш баланс" }, + "yourBalanceIsAggregated": { + "message": "Ваш баланс суммируется" + }, "yourNFTmayBeAtRisk": { "message": "Ваш NFT могут быть в опасности" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Нам не удалось отменить вашу транзакцию до ее подтверждения в блокчейне." }, + "yourWalletIsReady": { + "message": "Ваш кошелек готов" + }, "zeroGasPriceOnSpeedUpError": { "message": "Нулевая цена газа при ускорении" } diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index f49c14518dc3..4f6e3098171d 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Ikonekta ang iyong QR na wallet na hardware" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Ang address sa kahilingan sa pag-sign in ay hindi tugma sa address ng account na ginagamit mo sa pag-sign in." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Piliin ang mga account na nais mong makakuha ng abiso:" }, + "accountBalance": { + "message": "Balanse ng account" + }, "accountDetails": { "message": "Mga detalye ng account" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Mga Opsyon sa Account" }, + "accountPermissionToast": { + "message": "In-update ang mga pahintulot ng account" + }, "accountSelectionRequired": { "message": "Kailangan mong pumili ng account!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Mga konektadong account" }, + "accountsPermissionsTitle": { + "message": "Tingnan ang mga account mo at magmungkahi ng mga transaksyon" + }, + "accountsSmallCase": { + "message": "mga account" + }, "active": { "message": "Aktibo" }, @@ -180,12 +195,18 @@ "add": { "message": "Magdagdag" }, + "addACustomNetwork": { + "message": "Magdagdag ng custom na network" + }, "addANetwork": { "message": "Magdagdag ng network" }, "addANickname": { "message": "Magdagdag ng palayaw" }, + "addAUrl": { + "message": "Magdagdag ng URL" + }, "addAccount": { "message": "Magdagdag ng account" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Magdagdag ng block explorer" }, + "addBlockExplorerUrl": { + "message": "Magdagdag ng block explorer URL" + }, "addContact": { "message": "Magdagdag ng contact" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Nagdaragdag ka ng bagong RPC provider para sa Mainnet ng Ethereum" }, + "addEthereumWatchOnlyAccount": { + "message": "Panoorin ang isang account sa Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Magdagdag ng mga kaibigan at address na pinagkakatiwalaan mo" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Magdagdag ng Network" }, + "addNetworkConfirmationTitle": { + "message": "Magdagdag ng $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Magdagdag ng bagong account sa Ethereum" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Nakopya ang address!" }, + "addressMismatch": { + "message": "Hindi tugma ang address ng site" + }, + "addressMismatchOriginal": { + "message": "Kasalukuyang URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Bersyon ng Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Makabago" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Ang bayad sa priyoridad (kilala rin bilang “tip ng minero”) ay direktang napupunta sa mga minero at ginagawang insentibo ang mga ito upang unahin ang iyong mga transaksyon." }, + "aggregatedBalancePopover": { + "message": "Ipinapakita nito ang halaga ng lahat ng token na pagmamay-ari mo sa ibinigay na network. Kung mas gusto mong makita ang halagang ito sa ETH o sa ibang currency, pumunta sa $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Sumasang-ayon ako sa $1 ng MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Puwede itong baguhin sa \"Mga Setting > Mga Alerto\"" }, + "alertMessageAddressMismatchWarning": { + "message": "Minsan ginagaya ng mga umaatake ang mga site sa pamamagitan ng paggawa ng maliliit na pagbabago sa address ng site. Tiyaking nakikipag-ugnayan ka sa nilalayong site bago ka magpatuloy." + }, "alertMessageGasEstimateFailed": { "message": "Hindi kami makapagbigay ng tumpak na bayad at ang pagtantiya na ito ay maaaring mataas. Iminumungkahi namin na maglagay ka ng naka-custom na gas limit, ngunit may panganib na mabigo pa rin ang transaksyon." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Para magpatuloy sa transaksyong ito, kakailanganin mong dagdagan ang gas limit sa 21000 o mas mataas." }, + "alertMessageInsufficientBalance2": { + "message": "Wala kang sapat na ETH sa iyong account para bayaran ang mga bayarin sa network." + }, "alertMessageNetworkBusy": { "message": "Ang mga presyo ng gas ay mataas at ang pagtantiya ay hindi gaanong tumpak." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Mga opsyon ng asset" }, + "assets": { + "message": "Mga asset" + }, + "assetsDescription": { + "message": "I-autodetect ang mga token sa wallet mo, ipakita ang mga NFT, at kumuha ng naka-batch na mga update sa balanse ng account" + }, "attemptSendingAssets": { "message": "Maaari mong mawala ang mga asset kung susubukan mo itong ipadala mula sa isang network papunta sa isa pa. Ligtas ng maglipat ng pondo sa pagitan ng mga network sa pamamagitan ng paggamit ng bridge." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Bridge, huwag ipadala" }, + "bridgeFrom": { + "message": "I-bridge mula sa" + }, + "bridgeSelectNetwork": { + "message": "Pumili ng network" + }, + "bridgeTo": { + "message": "I-bridge papunta sa" + }, "browserNotSupported": { "message": "Hindi sinusuportahan ang iyong browser..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Kumpirmahin ang Lihim na Parirala sa Pagbawi" }, + "confirmTitleApproveTransaction": { + "message": "Hiling sa allowance" + }, + "confirmTitleDeployContract": { + "message": "Mag-deploy ng kontrata" + }, + "confirmTitleDescApproveTransaction": { + "message": "Gusto ng site na ito ng pahintulot na i-withdraw ang iyong mga NFT" + }, + "confirmTitleDescDeployContract": { + "message": "Gusto ng site na ito na mag-deploy ka ng kontrata" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Gusto ng site na ito ng pahintulot na i-withdraw ang iyong mga token" + }, "confirmTitleDescPermitSignature": { "message": "Ang site na ito ay nais ng permiso para gamitin ang iyong mga token." }, "confirmTitleDescSIWESignature": { "message": "Nais ng site na mag-sign in ka upang patunayan na pag-aari mo ang account na ito." }, + "confirmTitleDescSign": { + "message": "Suriin ang mga detalye ng kahilingan bago mo kumpirmahin." + }, "confirmTitlePermitTokens": { "message": "Hiling sa limitasyon ng paggastos" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Alisin ang pahintulot" + }, "confirmTitleSIWESignature": { "message": "Kahilingan sa pag-sign in" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Alisin ang pahintulot" + }, "confirmTitleSignature": { "message": "Kahilingan sa paglagda" }, "confirmTitleTransaction": { "message": "Hiling na Transaksyon" }, + "confirmationAlertModalDetails": { + "message": "Para protektahan ang iyong mga asset at impormasyon sa pag-login, iminumungkahi namin na tanggihan mo ang kahilingan." + }, + "confirmationAlertModalTitle": { + "message": "Kahina-hinala ang kahilingang ito" + }, "confirmed": { "message": "Nakumpirma" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "May na-detect kami na nakakalitong character sa pangalan ng ENS. Suriin ang pangalan ng ENS para maiwasan ang potensyal na panloloko." }, + "congratulations": { + "message": "Congratulations!" + }, "connect": { "message": "Kumonekta" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Mga konektadong site" }, + "connectedSitesAndSnaps": { + "message": "Mga konektadong site at Snap" + }, "connectedSitesDescription": { "message": "Ang $1 ay nakakonekta sa mga site na ito. Matitingnan nila ang address ng iyong account.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "Konektado ang MetaMask sa site na ito, ngunit wala pang mga account ang konektado" }, + "connectedSnaps": { + "message": "Mga konektadong Snap" + }, + "connectedWithAccount": { + "message": "$1 (na) account ang nakakonekta", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Nakakonekta sa $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Kumokonekta" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Kumokonekta sa Sepolia test network" }, + "connectionDescription": { + "message": "Gusto ng site na ito na" + }, "connectionFailed": { "message": "Nabigo ang koneksyon" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Kopyahin ang address sa clipboard" }, + "copyAddressShort": { + "message": "Kopyahin ang address" + }, "copyPrivateKey": { "message": "Kopyahin ang pribadong key" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "Default na RPC URL" }, + "defaultSettingsSubTitle": { + "message": "Ginagamit ng MetaMask ang mga default na setting upang pinakamahusay na mabalanse ang kaligtasan at dali ng paggamit. Baguhin ang mga setting na ito para maging mahigpit ang iyong pagkapribado." + }, + "defaultSettingsTitle": { + "message": "Mga default na setting sa pagkapribado" + }, "delete": { "message": "Tanggalin" }, "deleteContact": { "message": "Tanggalin ang kontak" }, + "deleteMetaMetricsData": { + "message": "Tanggalin ang data ng MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Tatanggalin nito ang kasaysayan ng data ng MetaMetrics na may kaugnayan sa paggamit mo sa device na ito. Hindi magbabago ang wallet at mga account mo sa kung ano ito ngayon pagkatapos mabura ang data na ito. Ang prosesong ito ay maaaring tumagal nang hanggang 30 araw. Tingnan ang aming $1.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Hindi makumpleto ang kahilingang ito sa ngayon dahil sa isang isyu sa server ng analytics system, pakisubukang muli mamaya" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Hindi namin matanggal ang data na ito sa ngayon" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Aalisin na namin ang lahat ng iyong data ng MetaMetrics. Sigurado ka ba?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Tanggalin ang data ng MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Pinasimulan mo ang aksyon na ito sa $1. Ang prosesong ito ay maaaring tumagal nang hanggang 30 araw. Tingnan ang $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Kapag tinanggal mo ang network na ito, kakailanganin mo itong idagdag muli para makita ang mga asset mo sa network na ito" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Deposito" }, + "depositCrypto": { + "message": "Magdeposito ng crypto mula sa ibang account gamit ang wallet address o QR code." + }, "deprecatedGoerliNtwrkMsg": { "message": "Dahil sa mga update sa sistema ng Ethereum, ang Goerli test network malapit nang itigil." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "mga account" }, + "disconnectAllDescriptionText": { + "message": "Kapag idiniskonekta mo sa site na ito, kakailanganin mong muling ikonekta ang mga account at network mo para magamit muli ang site na ito." + }, "disconnectAllSnapsText": { "message": "Mga Snap" }, + "disconnectMessage": { + "message": "Ididiskonekta ka nito mula sa site na ito" + }, "disconnectPrompt": { "message": "Idiskonekta $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "I-edit ang palayaw" }, + "editAccounts": { + "message": "I-edit ang mga account" + }, "editAddressNickname": { "message": "I-edit ang address ng palayaw" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "i-edit ang orihinal na network" }, + "editNetworksTitle": { + "message": "I-edit ang mga network" + }, "editNonceField": { "message": "I-edit sa Nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Pahintulot sa pag-edit" }, + "editPermissions": { + "message": "Mga pahintulot sa pag-edit" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "I-edit ang pagpapabilis ng bayad sa gas" }, + "editSpendingCap": { + "message": "I-edit ang limitasyon sa paggastos" + }, + "editSpendingCapAccountBalance": { + "message": "Balanse ng account: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Ilagay ang halaga na sa tingin mo ay komportable kang gastusin sa ngalan mo." + }, + "editSpendingCapError": { + "message": "Ang cap sa paggastos ay hindi maaaring lumampas sa $1 (na) decimal digit. Alisin ang mga decimal digit para magpatuloy." + }, "enableAutoDetect": { "message": " Paganahin ang autodetect" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Nabigong makita ang ENS." }, + "enterANameToIdentifyTheUrl": { + "message": "Lagyan ng pangalan para matukoy ang URL" + }, "enterANumber": { "message": "Maglagay ng numero" }, + "enterChainId": { + "message": "Ilagay ang ID ng Chain" + }, "enterCustodianToken": { "message": "Ilagay ang iyong $1 na token o magdagdag ng bagong token" }, "enterMaxSpendLimit": { "message": "Ilagay ang max na limitasyon sa paggastos" }, + "enterNetworkName": { + "message": "Ilagay ang pangalan ng network" + }, "enterOptionalPassword": { "message": "Ilagay ang opsyonal na password" }, "enterPasswordContinue": { "message": "Ilagay ang password para magpatuloy" }, + "enterRpcUrl": { + "message": "Ilagay ang RPC URL" + }, + "enterSymbol": { + "message": "Ilagay ang simbolo" + }, "enterTokenNameOrAddress": { "message": "Ilagay ang pangalan ng token o i-paste ang address" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Eksperimental" }, + "exportYourData": { + "message": "I-export ang data mo" + }, + "exportYourDataButton": { + "message": "I-download" + }, + "exportYourDataDescription": { + "message": "Maaari mong i-export ang mga data mo gaya ng mga kontak at kagustuhan." + }, "extendWalletWithSnaps": { "message": "Tuklasin ang mga Snap na binuo ng komunidad para i-customize ang iyong karanasan sa web3", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Ang bayad sa gas na ito ay iminungkahi ng $1. Ang pag-override dito ay maaaring magdulot ng problema sa iyong transaksyon. Mangyaring makipag-ugnayan sa $1 kung mayroon kang mga tanong.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Bayad sa gas" + }, "gasIsETH": { "message": "Ang gas ay $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Nagkaproblema...." }, + "generalDescription": { + "message": "I-sync ang mga setting sa lahat ng device, pumili ng mga kagustuhan sa network, at subaybayan ang data ng token" + }, "genericExplorerView": { "message": "Tingnan ang account sa $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "Id" }, + "ifYouGetLockedOut": { + "message": "Kung ikaw ay na-lock out sa app o kumuha ng bagong device, mawawala ang iyong mga pondo. Siguraduhing i-back up ang iyong Lihim na Parirala sa Pagbawi sa $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Huwag pansinin ang lahat" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "sa iyong Mga Setting" }, + "included": { + "message": "kalakip" + }, "infuraBlockedNotification": { "message": "Hindi makakonekta ang MetaMask sa blockchain host. I-review ang posibleng mga dahilan $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "File na JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Gumawa ng paalala sa iyong Lihim na Parirala sa Pagbawi sa isang ligtas na lugar. Kung mawala mo ito, walang makakatulong sa iyo na mabawi ito. Ang mas masama pa, hindi mo na maaaring ma-access ang iyong wallet kahit kailan. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Pangalan ng Account" }, @@ -2402,6 +2622,9 @@ "message": "Alamin kung paano sa $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Alamin kung paano" + }, "learnMore": { "message": "matuto pa" }, @@ -2409,6 +2632,9 @@ "message": "Gusto mo bang $1 tungkol sa gas?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Matuto pa tungkol sa pinakamahusay na mga kasanayan sa pagkapribado." + }, "learnMoreKeystone": { "message": "Matuto Pa" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Link" }, + "linkCentralizedExchanges": { + "message": "I-link ang iyong Coinbase o mga account sa Binance para maglipat ng crypto sa MetaMask nang libre." + }, "links": { "message": "Mga Link" }, @@ -2557,6 +2786,9 @@ "message": "Siguraduhing walang tumitingin", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Pamahalaan ang mga default na setting sa pagkapribado" + }, "marketCap": { "message": "Market cap" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Makikita sa button ng katayuan ng koneksyon kung nakakonekta ang website na binibisita mo sa kasalukuyan mong napiling account." }, + "metaMetricsIdNotAvailableError": { + "message": "Dahil hindi mo kailanman sinubukan ang MetaMetrics, walang data na tatanggalin dito." + }, "metadataModalSourceTooltip": { "message": "Ang $1 ay naka-host sa npm at $2 ang natatanging pagkakailanlan ng Snap.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "higit pa" }, + "moreAccounts": { + "message": "+ $1 pang mga account", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 pa na mga network", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Idinadagdag mo ang network na ito sa MetaMask at binibigyan ng pahintulot ang site na ito na gamitin ito." + }, "multipleSnapConnectionWarning": { "message": "Si $1 ay gustong gumamit ng $2 Snaps", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "I-edit ang mga detalye ng network" }, "nativeTokenScamWarningDescription": { - "message": "Ang network na ito ay hindi tugma sa kaugnay na chain ID o pangalan nito. Maraming popular na token ang gumagamit ng pangalang $1, kaya nagiging puntirya ito ng mga scam. Maaari kang linlangin ng mga scammer na magpadala sa kanila ng mas mahal na currency bilang kapalit. I-verify ang lahat bago magpatuloy.", + "message": "Ang simbolo ng native token ay hindi tumutugma sa inaasahang simbolo ng native token para sa network na may nauugnay na ID ng chain. Ang inilagay mo ay $1 samantalang ang inaasahang simbolo ng token ay $2. Pakiberipika na konektado ka sa tamang chain.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "iba pa", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Isa itong potensyal na scam", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Mga Detalye ng Network" }, + "networkFee": { + "message": "Bayad sa network" + }, "networkIsBusy": { "message": "Busy ang network. Ang presyo ng gas ay mataas at ang pagtantya ay hindi gaanong tumpak." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Mga pagpipilian na network" }, + "networkPermissionToast": { + "message": "In-update ang mga pahintulot ng network" + }, "networkProvider": { "message": "Network provider" }, @@ -2865,15 +3121,26 @@ "message": "Hindi kami makakonekta sa $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Ang network ay lumipat sa $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL Network" }, "networkURLDefinition": { "message": "Ang URL ay ginamit upang ma-access ang network na ito." }, + "networkUrlErrorWarning": { + "message": "Minsan ginagaya ng mga umaatake ang mga site sa pamamagitan ng paggawa ng maliliit na pagbabago sa address ng site. Tiyaking nakikipag-ugnayan ka sa nilalayong site bago ka magpatuloy. Bersyon ng Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Mga Network" }, + "networksSmallCase": { + "message": "mga network" + }, "nevermind": { "message": "Huwag na" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Binago namin ang aming patakaran sa pagkapribado" }, + "newRpcUrl": { + "message": "Bagong RPC URL" + }, "newTokensImportedMessage": { "message": "Matagumpay mong na-import ang $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "Ang MetaMask ay hindi nakakonekta sa site na ito" }, + "noConnectionDescription": { + "message": "Para kumonekta sa site, hanapin at piliin ang \"connect\" button. Tandaan na ang MetaMask ay maaari lamang kumonekta sa mga site sa web3" + }, "noConversionRateAvailable": { "message": "Hindi available ang rate ng palitan" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "I-custom ang nonce" }, + "none": { + "message": "Wala" + }, "notBusy": { "message": "Hindi busy" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Mga detalye ng permiso" }, + "permissionFor": { + "message": "Pahintulot para sa" + }, + "permissionFrom": { + "message": "Pahintulot mula sa" + }, "permissionRequest": { "message": "Kahilingan sa pahintulot" }, @@ -3593,6 +3875,14 @@ "message": "Payagan ang $1 na i-access ang iyong mas gustong wika mula sa iyong mga setting sa MetaMask. Maaari itong gamitin para i-localize at ipakita ang content ng $1 gamit ang iyong wika.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Tingnan ang impormasyon tulad ng gusto mong wika at fiat na salapi.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Pahintulutan ang $1 na ma-access ang impormasyon tulad ng gusto mong wika at fiat na salapi sa iyong mga setting ng MetaMask. Tumutulong ito sa $1 na magpakita ng content na nakaayon sa iyong mga kagustuhan. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Magpakita ng custom na screen", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Binibigyan mo ang gumagastos ng permiso upang gumastos ng ganito karaming token mula sa iyong account." }, + "permittedChainToastUpdate": { + "message": "Ang $1 ay may access sa $2." + }, "personalAddressDetected": { "message": "Natukoy ang personal na address. Ilagay ang address ng kontrata ng token." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Tumanggap" }, + "receiveCrypto": { + "message": "Tumanggap ng crypto" + }, + "recipientAddressPlaceholderNew": { + "message": "Ilagay ang pampublikong address (0x) o pangalan ng domain" + }, "recommendedGasLabel": { "message": "Nirekomenda" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Tinanggihan" }, + "rememberSRPIfYouLooseAccess": { + "message": "Tandaan, kapag nawala mo ang iyong Lihim na Parirala sa Pagbawi, mawawala ang access mo sa iyong wallet. $1 upang mapanatiling ligtas ang pangkat ng mga salitang ito nang sa gayon lagi kang may access sa mga pondo mo." + }, + "reminderSet": { + "message": "Naitakda ang Paalala!" + }, "remove": { "message": "Alisin" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Dahil sa isang error, ang kahilingang ito ay hindi na-verify ng tagapagbigay ng seguridad. Magpatuloy nang may pag-iingat." }, + "requestingFor": { + "message": "Hinihiling para sa" + }, + "requestingForAccount": { + "message": "Hinihiling para sa $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "mga kahilingang hinihintay na tanggapin" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Ibunyag ang pariralang binhi" }, + "review": { + "message": "Suriin" + }, + "reviewAlert": { + "message": "Suriin ang alerto" + }, "reviewAlerts": { "message": "Suriin ang mga alerto" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Bawiin ang pahintulot" }, + "revokeSimulationDetailsDesc": { + "message": "Aalisin mo ang pahintulot sa ibang tao na gumastos ng mga token mula sa account mo." + }, "revokeSpendingCap": { "message": "Bawiin ang limitasyon sa paggastos para sa iyong $1", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Ang third party na ito ay hindi na makakagastos pa sa iyong kasalukuyan o hinaharap na mga token." }, + "rpcNameOptional": { + "message": "Pangalan ng RPC (Opsyonal)" + }, "rpcUrl": { "message": "Bagong RPC URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Seguridad at pagkapribado" }, + "securityDescription": { + "message": "Bawasan ang iyong mga tsansa ng pagsali sa mga hindi ligtas na network at protektahan ang iyong mga account" + }, + "securityMessageLinkForNetworks": { + "message": "mga panloloko sa network at panganib sa seguridad" + }, + "securityPrivacyPath": { + "message": "Mga Setting > Seguridad at Pagkapribado." + }, "securityProviderPoweredBy": { "message": "Pinapagana ng $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Tingnan ang lahat ng pahintulot", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Tingnan ang mga detalye" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Kung hindi mo makita ang mga account na inaasahan mo, subukang lumipat ng HD path o kasalukuyang piniling network." }, + "selectRpcUrl": { + "message": "Pumili ng RPC URL" + }, "selectType": { "message": "Pumili ng Uri" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Itakda ang Pag-apruba para sa Lahat" }, + "setApprovalForAllRedesignedTitle": { + "message": "Kahilingan sa pag-withdraw" + }, "setApprovalForAllTitle": { "message": "Aprubahan ang $1 nang walang limitasyon sa paggastos", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Mga Setting" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Ang mga setting ay nakaayos para sa dali ng paggamit at seguridad.\nBaguhin ang mga ito anumang oras." + }, "settingsSearchMatchingNotFound": { "message": "Walang nakitang katugmang resulta." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Ipakita ang iba pa" }, + "showNativeTokenAsMainBalance": { + "message": "Ipakita ang native token bilang pangunahing balanse" + }, "showNft": { "message": "Ipakita ang NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Nagsa-sign in gamit ang" }, + "simulationApproveHeading": { + "message": "I-withdraw" + }, + "simulationDetailsApproveDesc": { + "message": "Binibigyan mo ng pahintulot ang ibang tao na i-withdraw ang mga NFT mula sa iyong account." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Binibigyan mo ng pahintulot ang ibang tao na gumastos ng ganitong halaga mula sa account mo." + }, "simulationDetailsFiatNotAvailable": { "message": "Hindi Available" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Nagpadala ka" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Aalisin mo ang pahintulot sa ibang tao na i-withdraw ang mga NFT mula sa account mo." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Binibigyan mo ng pahintulot ang ibang tao na i-withdraw ang mga NFT mula sa account mo." + }, "simulationDetailsTitle": { "message": "Tinatayang mga pagbabago" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Oops! Nagkaproblema." }, + "sortBy": { + "message": "Isaayos ayon sa" + }, + "sortByAlphabetically": { + "message": "Ayon sa alpabeto (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Bumababa ang balanse ($1 high-low)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Pinagmulan" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Gumagastos" }, + "spenderTooltipDesc": { + "message": "Ito ang address na makakapag-withdraw ng iyong mga NFT." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Ito ang address na maaaring gumastos ng mga token mo sa ngalan mo." + }, "spendingCap": { "message": "Limitasyon sa paggastos" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Kahilingan na limitasyon sa paggastos para sa iyong $1" }, + "spendingCapTooltipDesc": { + "message": "Ito ang halaga ng mga token na maa-access ng gumagastos sa ngalan mo." + }, "srpInputNumberOfWords": { "message": "Mayroon akong word phrase ng $1", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Iminumungkahi ng $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Iminumungkahing simbolo ng salapi:" + }, "suggestedTokenName": { "message": "Iminumungkahing pangalan:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Bisitahin ang aming Sentro ng Suporta" }, + "supportMultiRpcInformation": { + "message": "Sinusuportahan na namin ngayon ang maramihang RPC para sa isang network. Pinili ang pinakabago mong RPC bilang ang default para lutasin ang magkakasalungat na impormasyon." + }, "surveyConversion": { "message": "Sagutan ang aming survey" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Ang mga bayad sa gas ay tinatantya at magbabago batay sa trapiko sa network at pagiging kumplikado ng transaksyon." }, + "swapGasFeesExplanation": { + "message": "Hindi kumikita ang MetaMask mula sa mga bayad sa gas. Ang mga bayarin na ito ay pagtatantiya at maaaring mabago batay sa kung gaano kaabala ang network at kung gaano kakumplikado ang transaksyon. Alamin pa ang $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "dito", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Alamin pa ang tungkol sa mga bayad sa gas" }, @@ -5186,9 +5583,19 @@ "message": "Ang mga bayad sa gas ay binabayaran sa mga minero ng crypto na nagpoproseso ng mga transaksyon sa $1 na network. Ang MetaMask ay hindi kumikita mula sa mga bayad sa gas.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Isinasama sa presyo na ito ang mga bayad sa gas sa pamamagitan ng pagsasaayos sa halaga ng ipinadala o natanggap na token. Maaari kang tumanggap ng ETH sa hiwalay na transaksyon sa iyong listahan ng aktibidad." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Alamin pa ang tungkol sa mga bayad sa gas" + }, "swapHighSlippage": { "message": "Mataas na slippage" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Kasama ang gas at $1% bayad sa MetaMask", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Kasama ang $1% MetaMask fee.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Na-update ang aming Mga Tuntunin sa Paggamit" }, + "testnets": { + "message": "Mga Testnet" + }, "theme": { "message": "Tema" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Bayad sa transaksyon" }, + "transactionFlowNetwork": { + "message": "Network" + }, "transactionHistoryBaseFee": { "message": "Batayang bayad (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Maglipat" }, + "transferCrypto": { + "message": "Ilipat ang crypto" + }, "transferFrom": { "message": "Maglipat mula kay/sa" }, + "transferRequest": { + "message": "Kahilingan na paglilipat" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "I-update" }, + "updateEthereumChainConfirmationDescription": { + "message": "Hinihiling ng site na ito na i-update ang iyong default na URL ng network. Maaari mong i-edit ang mga default at impormasyon ng network anumang oras." + }, + "updateNetworkConfirmationTitle": { + "message": "I-update ang $1 ", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "I-update ang iyong impormasyon o" }, "updateRequest": { "message": "Hiling sa pag-update" }, + "updatedRpcForNetworks": { + "message": "Na-update ang mga RPC ng Network" + }, "uploadDropFile": { "message": "I-drop ang file mo rito" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "ang aming gabay sa pagkonekta ng wallet na hardware" }, + "walletProtectedAndReadyToUse": { + "message": "Ang iyong wallet ay protektado at handa ng gamitin. Matatagpuan mo ang iyong Lihim na Parirala sa Pagbawi sa $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Gusto mo bang idagdag ang network na ito?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Maaaring gastusin ng third party ang iyong buong balanse ng token nang walang karagdagang abiso o pahintulot. Protektahan ang iyong sarili sa pamamagitan ng pag-customize ng mas mababang limitasyon sa paggastos.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Ang pag-on sa opsyon na ito ay magbibigay sa iyo ng kakayahan na panoorin ang mga account ng Ethereum sa pamamagitan ng isang pampublikong address o pangalan ng ENS. Para sa feedback sa Beta feature na ito, mangyaring sagutan ito $1.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Panoorin ang mga account sa Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Mag-ingat sa $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Madali" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Ano ito?" }, + "withdrawing": { + "message": "Wini-withdraw" + }, "wrongNetworkName": { "message": "Ayon sa aming mga talaan, ang pangalan ng network ay maaaring hindi tumugma nang tama sa ID ng chain na ito." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Iyong balanse" }, + "yourBalanceIsAggregated": { + "message": "Ang iyong balanse ay pinagsama-sama" + }, "yourNFTmayBeAtRisk": { "message": "Maaaring nasa panganib ang iyong NFT" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Hindi namin makansela ang iyong transaksyon bago ito nakumpirma sa blockchain." }, + "yourWalletIsReady": { + "message": "Handa na ang iyong wallet" + }, "zeroGasPriceOnSpeedUpError": { "message": "Walang presyo ng gas sa pagpapabilis" } diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 7af54fd3eedd..771089050317 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "QR donanım cüzdanınızı bağlayın" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Giriş talebindeki adres, giriş yapmak için kullandığınız hesapla uyumlu değil." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Hakkında bildirim almak istediğiniz hesapları seçin:" }, + "accountBalance": { + "message": "Hesap bakiyesi" + }, "accountDetails": { "message": "Hesap bilgileri" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Hesap Seçenekleri" }, + "accountPermissionToast": { + "message": "Hesap izinleri güncellendi" + }, "accountSelectionRequired": { "message": "Bir hesap seçmeniz gerekiyor!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Hesaplar bağlandı" }, + "accountsPermissionsTitle": { + "message": "Hesaplarınızı görmek ve işlem önermek" + }, + "accountsSmallCase": { + "message": "hesaplar" + }, "active": { "message": "Aktif" }, @@ -180,12 +195,18 @@ "add": { "message": "Ekle" }, + "addACustomNetwork": { + "message": "Özel bir ağ ekle" + }, "addANetwork": { "message": "Ağ ekle" }, "addANickname": { "message": "Takma ad ekle" }, + "addAUrl": { + "message": "URL ekle" + }, "addAccount": { "message": "Hesap ekleyin" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Bir blok gezgini ekle" }, + "addBlockExplorerUrl": { + "message": "Bir blok gezgini URL adresi ekle" + }, "addContact": { "message": "Kişi ekle" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Ethereum Ana Ağı için yeni bir RPC sağlayıcısı ekliyorsunuz" }, + "addEthereumWatchOnlyAccount": { + "message": "Bir Ethereum hesabını izle (Beta)" + }, "addFriendsAndAddresses": { "message": "Güvendiğiniz arkadaşlarınızı ve adresleri ekleyin" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Ağ ekle" }, + "addNetworkConfirmationTitle": { + "message": "$1 ekle", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Yeni bir Ethereum hesabı ekle" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Adres kopyalandı!" }, + "addressMismatch": { + "message": "Site adresi uyumsuzluğu" + }, + "addressMismatchOriginal": { + "message": "Mevcut URL: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Punycode sürümü: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Gelişmiş" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Maks. öncelik ücreti (başka bir deyişle \"madenci bahşişi\") doğrudan madencilere gider ve işleminizin öncelikli olarak gerçekleştirilmesini teşvik eder." }, + "aggregatedBalancePopover": { + "message": "Bu, belirli bir ağda sahip olduğunuz tüm token'lerin değerini yansıtır. Bu değeri ETH olarak veya diğer para birimlerinde görmeyi tercih ediyorsanız $1 alanına gidin.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "MetaMask'in $1 bölümünü kabul ediyorum", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "\"Ayarlar > Uyarılar\" kısmında değiştirilebilir" }, + "alertMessageAddressMismatchWarning": { + "message": "Saldırganlar bazen site adresinde küçük değişiklikler yaparak siteleri taklit edebilir. Devam etmeden önce planladığınız site ile etkileşim kurduğunuzdan emin olun." + }, "alertMessageGasEstimateFailed": { "message": "Kesin bir ücret sunamıyoruz ve bu tahmin yüksek olabilir. Özel bir gaz limiti girmenizi öneririz ancak işlemin yine de başarısız olma riski vardır." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Bu işlemle devam etmek için gaz limitini 21000 veya üzeri olacak şekilde artırmanız gerekecek." }, + "alertMessageInsufficientBalance2": { + "message": "Hesabınızda ağ ücretlerini ödemek için yeterli ETH yok." + }, "alertMessageNetworkBusy": { "message": "Gaz fiyatları yüksektir ve tahmin daha az kesindir." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Varlık seçenekleri" }, + "assets": { + "message": "Varlıklar" + }, + "assetsDescription": { + "message": "Cüzdanınızdaki token'ler otomatik algılansın, NFT'ler gösterilsin ve toplu hesap bakiye güncellemeleri alınsın" + }, "attemptSendingAssets": { "message": "Varlıklarınızı doğrudan bir ağdan diğerine göndermeye çalışırsanız onları kaybedebilirsiniz. Fonları köprü kullanarak ağlar arasında güvenli bir şekilde transfer edin." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Köprü yap, gönderme" }, + "bridgeFrom": { + "message": "Şuradan köprü:" + }, + "bridgeSelectNetwork": { + "message": "Ağ seç" + }, + "bridgeTo": { + "message": "Şuraya köprü:" + }, "browserNotSupported": { "message": "Tarayıcınız desteklenmiyor..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Gizli Kurtarma İfadesini Onayla" }, + "confirmTitleApproveTransaction": { + "message": "Ödenek talebi" + }, + "confirmTitleDeployContract": { + "message": "Bir sözleşme kurulumu gerçekleştirin" + }, + "confirmTitleDescApproveTransaction": { + "message": "Bu site NFT'lerinizi çekmek için izin istiyor" + }, + "confirmTitleDescDeployContract": { + "message": "Bu site sözleşme kurulumu gerçekleştirmenizi istiyor" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Bu site tokenlerinizi çekmek için izin istiyor" + }, "confirmTitleDescPermitSignature": { "message": "Bu site token'lerinizi harcamak için izin istiyor." }, "confirmTitleDescSIWESignature": { "message": "Bir site bu hesabın sahibi olduğunuzu kanıtlamak için giriş yapmanızı istiyor." }, + "confirmTitleDescSign": { + "message": "Onaylamadan önce talep bilgilerini inceleyin." + }, "confirmTitlePermitTokens": { "message": "Harcama üst limiti talebi" }, + "confirmTitleRevokeApproveTransaction": { + "message": "İzni kaldır" + }, "confirmTitleSIWESignature": { "message": "Giriş talebi" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "İzni kaldır" + }, "confirmTitleSignature": { "message": "İmza talebi" }, "confirmTitleTransaction": { "message": "İşlem talebi" }, + "confirmationAlertModalDetails": { + "message": "Varlıklarınızı ve oturum açma bilgilerinizi korumak için talebi reddetmenizi öneririz." + }, + "confirmationAlertModalTitle": { + "message": "Bu talep şüphelidir" + }, "confirmed": { "message": "Onaylandı" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "ENS adında karıştırılabilir bir karakter tespit ettik. Olası bir dolandırıcılığı önlemek için ENS adını kontrol edin." }, + "congratulations": { + "message": "Tebrikler!" + }, "connect": { "message": "Bağla" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Bağlı siteler" }, + "connectedSitesAndSnaps": { + "message": "Bağlı siteler ve Snap'ler" + }, "connectedSitesDescription": { "message": "$1 bu sitelere bağlanmış. Bu siteler hesap adresinizi görüntüleyebilir.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask bu siteye bağlı ancak henüz bağlı hesap yok" }, + "connectedSnaps": { + "message": "Bağlı Snap'ler" + }, + "connectedWithAccount": { + "message": "$1 hesap bağlandı", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "$1 ile bağlandı", + "description": "$1 represents account name" + }, "connecting": { "message": "Bağlanıyor" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Sepolia test ağına bağlanılıyor" }, + "connectionDescription": { + "message": "Bu site şunları yapmak istiyor:" + }, "connectionFailed": { "message": "Bağlantı başarısız oldu" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Adresi panoya kopyala" }, + "copyAddressShort": { + "message": "Adresi kopyala" + }, "copyPrivateKey": { "message": "Özel anahtarı kopyala" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "Varsayılan RPC URL adresi" }, + "defaultSettingsSubTitle": { + "message": "MetaMask, güvenlik ve kullanım kolaylığını en iyi şekilde dengelemek için varsayılan ayarları kullanır. Gizliliğinizi daha fazla artırmak için bu ayarları değiştirin." + }, + "defaultSettingsTitle": { + "message": "Varsayılan gizlilik ayarları" + }, "delete": { "message": "Sil" }, "deleteContact": { "message": "Kişiyi sil" }, + "deleteMetaMetricsData": { + "message": "MetaMetrics verilerini sil" + }, + "deleteMetaMetricsDataDescription": { + "message": "Bu işlem, bu cihazdaki kullanımınızla ilişkili geçmiş MetaMetrics verilerini silecektir. Bu veriler silindikten sonra cüzdanınız ve hesaplarınız tam olarak şu anda oldukları gibi kalacaktır. Bu süreç 30 güne kadar sürebilir. $1 bölümümüzü görüntüleyin.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Bu talep analitik bir sistem sunucusu sorunundan dolayı şu anda tamamlanamıyor, lütfen daha sonra tekrar deneyin" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Şu anda bu verileri silemiyoruz" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Tüm MetaMetrics verilerini kaldırmak üzeresiniz. Emin misiniz?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "MetaMetrics verilerini sil?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "$1 tarihinde bir eylem başlattınız. Bu süreç 30 güne kadar sürebilir. $2 bölümünü görüntüleyin", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Bu ağı silerseniz bu ağdaki varlıklarınızı görüntülemek için bu ağı tekrar eklemeniz gerekecek" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Para Yatır" }, + "depositCrypto": { + "message": "Cüzdan adresi veya QR kodu ile başka bir hesaptan kripto yatırın." + }, "deprecatedGoerliNtwrkMsg": { "message": "Ethereum sistemindeki güncellemelerden dolayı Goerli test ağı yakında aşamalı olarak devre dışı bırakılacaktır." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "hesaplar" }, + "disconnectAllDescriptionText": { + "message": "Bu site ile bağlantınızı keserseniz bu siteyi tekrar kullanabilmek için hesaplarınızı ve ağlarınızı tekrar bağlamanız gerekecek." + }, "disconnectAllSnapsText": { "message": "Snap'ler" }, + "disconnectMessage": { + "message": "Bu işlem bu siteyle bağlantınızı kesecektir" + }, "disconnectPrompt": { "message": "$1 bağlantısını kes" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Takma adı düzenle" }, + "editAccounts": { + "message": "Hesapları düzenle" + }, "editAddressNickname": { "message": "Adres takma adını düzenle" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "orijinal ağı düzenle" }, + "editNetworksTitle": { + "message": "Ağları düzenle" + }, "editNonceField": { "message": "Nonce'u düzenle" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "İzni düzenle" }, + "editPermissions": { + "message": "İzinleri düzenle" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Hızlandırma gaz ücretini düzenle" }, + "editSpendingCap": { + "message": "Harcama üst limitini düzenle" + }, + "editSpendingCapAccountBalance": { + "message": "Hesap bakiyesi: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Sizin adınıza harcanması konusunda rahat hissedeceğiniz tutarı girin." + }, + "editSpendingCapError": { + "message": "Harcama üst limiti $1 ondalık haneyi geçemez. Devam etmek için ondalık haneleri kaldırın." + }, "enableAutoDetect": { "message": " Otomatik algılamayı etkinleştir" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENS arama başarısız oldu." }, + "enterANameToIdentifyTheUrl": { + "message": "URL adresini tanımlamak için bir ad girin" + }, "enterANumber": { "message": "Bir sayı girin" }, + "enterChainId": { + "message": "Zincir kimliği girin" + }, "enterCustodianToken": { "message": "$1 tokeninizi girin veya yeni bir token ekleyin" }, "enterMaxSpendLimit": { "message": "Maks. harcama limiti gir" }, + "enterNetworkName": { + "message": "Ağ adını girin" + }, "enterOptionalPassword": { "message": "İsteğe bağlı şifreyi girin" }, "enterPasswordContinue": { "message": "Devam etmek için şifre girin" }, + "enterRpcUrl": { + "message": "RPC URL adresi girin" + }, + "enterSymbol": { + "message": "Sembol girin" + }, "enterTokenNameOrAddress": { "message": "Token adı girin veya adresi yapıştırın" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Deneysel" }, + "exportYourData": { + "message": "Verilerinizi dışa aktarın" + }, + "exportYourDataButton": { + "message": "İndir" + }, + "exportYourDataDescription": { + "message": "Kişileriniz ve tercihleriniz gibi verileri dışa aktarabilirsiniz." + }, "extendWalletWithSnaps": { "message": "web3 deneyiminizi kişiselleştirmek için topluluk tarafından oluşturulmuş Snapleri keşfedin", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Bu gaz ücreti $1 tarafından önerilmiştir. Bu değerin başka bir değerle değiştirilmesi işleminizle ilgili bir soruna neden olabilir. Sorularınız olursa lütfen $1 ile iletişime geçin.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Gaz ücreti" + }, "gasIsETH": { "message": "Gaz $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Bir şeyler ters gitti...." }, + "generalDescription": { + "message": "Cihazlar genelindeki ayarları senkronize edin, ağ tercihlerini seçin ve token verilerini takip edin" + }, "genericExplorerView": { "message": "Hesabı $1 üzerinde görüntüleyin" }, @@ -2093,6 +2302,10 @@ "id": { "message": "Kimlik" }, + "ifYouGetLockedOut": { + "message": "Uygulamaya giriş yapamazsanız veya yeni bir cihaz edinirseniz paranızı kaybedersiniz. Gizli Kurtarma İfadenizi şurada yedeklediğinizden emin olun: $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Tümünü yoksay" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "Ayarlar kısmında" }, + "included": { + "message": "dahil" + }, "infuraBlockedNotification": { "message": "MetaMask blokzinciri ana bilgisayarına bağlanamıyor. $1 olası nedenleri inceleyin.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON Dosyası", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Gizli Kurtarma İfadenizin hatırlatıcısını güvenli bir yerde tutun. Güvenli kurtarma ifadenizi kaybederseniz hiç kimse geri almanıza yardımcı olamaz. Daha da kötüsü, cüzdanınıza bir daha asla erişemezsiniz. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Hesap adı" }, @@ -2402,6 +2622,9 @@ "message": "Nasıl $1 yapacağınızı öğrenin", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Nasıl olduğunu öğrenin" + }, "learnMore": { "message": "daha fazla bilgi" }, @@ -2409,6 +2632,9 @@ "message": "Gaz hakkında $1 istiyor musun?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "En iyi gizlilik uygulamaları hakkında daha fazla bilgi edinin." + }, "learnMoreKeystone": { "message": "Daha Fazla Bilgi" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Bağlantı" }, + "linkCentralizedExchanges": { + "message": "MetaMask'e ücretsiz kripto transferi yapmak için Coinbase veya Binance hesaplarınızı bağlayın." + }, "links": { "message": "Bağlantılar" }, @@ -2557,6 +2786,9 @@ "message": "Hiç kimsenin bakmadığından emin olun", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Varsayılan gizlilik ayarlarını yönet" + }, "marketCap": { "message": "Piyasa değeri" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Bağlantı durumu düğmesi ziyaret ettiğiniz web sitesinin şu anda seçilen hesabınıza bağlı olup olmadığını gösterir." }, + "metaMetricsIdNotAvailableError": { + "message": "Hiçbir zaman MetaMetrics'e dahil olmadığınız için burada silinecek veri yok." + }, "metadataModalSourceTooltip": { "message": "$1 npm'de barındırılmaktadır ve $2 bu Snap'in eşsiz tanımlayıcısıdır.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "daha fazla" }, + "moreAccounts": { + "message": "+ $1 hesap daha", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 ağ daha", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Bu ağı MetaMask'e ekliyor ve bu siteye ağı kullanma izni veriyorsunuz." + }, "multipleSnapConnectionWarning": { "message": "$1 $2 Snap kullanmak istiyor", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Ağ bilgilerini düzenle" }, "nativeTokenScamWarningDescription": { - "message": "Bu ağ, ilişkili zincir kimliği veya adı ile uyumlu değil. Pek çok popüler token $1 adını kullanarak bunu dolandırıcılar için hedef haline getirir. Dolandırıcılar, karşılığında kendilerine daha değerli para birimi göndermek üzere sizi kandırabilir. Devam etmeden önce her şeyi doğrulayın.", + "message": "Yerel token sembolü, zincir kimliği ile ilişkili ağın yerel token'inin beklenen sembolü ile uyumlu değil. Sizin girdiğiniz $1 iken beklenen sembol $2 idi. Lütfen doğru zincire bağlandığınızdan emin olun.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "başka bir şey", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Bu potansiyel bir dolandırıcılıktır", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Ağ bilgileri" }, + "networkFee": { + "message": "Ağ ücreti" + }, "networkIsBusy": { "message": "Ağ meşgul. Gaz fiyatları yüksektir ve tahminler daha az doğrudur." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Ağ seçenekleri" }, + "networkPermissionToast": { + "message": "Ağ izinleri güncellendi" + }, "networkProvider": { "message": "Ağ sağlayıcısı" }, @@ -2865,15 +3121,26 @@ "message": "$1 ağına bağlanamıyoruz", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Ağ şuna geçti: $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "Ağ URL adresi" }, "networkURLDefinition": { "message": "Bu ağa erişim sağlamak için kullanılan URL adresi." }, + "networkUrlErrorWarning": { + "message": "Saldırganlar bazen site adresinde küçük değişiklikler yaparak siteleri taklit edebilir. Devam etmeden önce planladığınız site ile etkileşim kurduğunuzdan emin olun. Punycode sürümü: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Ağlar" }, + "networksSmallCase": { + "message": "ağ" + }, "nevermind": { "message": "Boşver" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Gizlilik politikamızı güncelledik" }, + "newRpcUrl": { + "message": "Yeni RPC URL adresi" + }, "newTokensImportedMessage": { "message": "$1 tokeni başarılı bir şekilde içe aktardın.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask bu siteye bağlı değil" }, + "noConnectionDescription": { + "message": "Bir siteye bağlanmak için \"bağlan\" düğmesini bularak seçin. MetaMask'in sadece web3 üzerindeki sitelere bağlanabildiğini unutmayın" + }, "noConversionRateAvailable": { "message": "Dönüşüm oranı mevcut değil" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Özel nonce" }, + "none": { + "message": "Yok" + }, "notBusy": { "message": "Meşgul değil" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "İzin ayrıntıları" }, + "permissionFor": { + "message": "Şunun için izin:" + }, + "permissionFrom": { + "message": "Şuradan izin:" + }, "permissionRequest": { "message": "İzin talebi" }, @@ -3593,6 +3875,14 @@ "message": "$1 adlı bu snap'in MetaMask ayarlarınızdan tercih ettiğiniz dile erişim sağlamasına izin verin. $1 içeriğini dilinizi kullanarak yerelleştirmek ve görüntülemek için bu özellik kullanılabilir.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Tercih ettiğiniz dil ve itibari para gibi bilgileri görün.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "$1 adlı snap'in MetaMask ayarlarınızda tercih ettiğiniz dil ve fiat para gibi bilgilere erişim sağlamasına izin verin. Bu durum $1 adlı snap'in sizin tercihlerinize uygun içerikler göstermesine yardımcı olur. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Özel bir ekran göster", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Harcama yapan tarafa hesabınızdan bu kadar çok token'i harcama izni veriyorsunuz." }, + "permittedChainToastUpdate": { + "message": "$1 için $2 erişimi var." + }, "personalAddressDetected": { "message": "Kişisel adres algılandı. Token sözleşme adresini girin." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Al" }, + "receiveCrypto": { + "message": "Kripto al" + }, + "recipientAddressPlaceholderNew": { + "message": "Genel adres (0x) veya alan adı gir" + }, "recommendedGasLabel": { "message": "Önerilen" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Reddedildi" }, + "rememberSRPIfYouLooseAccess": { + "message": "Unutmayın, Gizli Kurtarma İfadenizi kaybederseniz cüzdanınıza erişiminizi kaybedersiniz. Paranıza her zaman erişebilmeniz için bu sözcük dizisini saklamak amacıyla şunu yapın: $1." + }, + "reminderSet": { + "message": "Hatırlatıcı ayarlandı!" + }, "remove": { "message": "Kaldır" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Bu talep bir hatadan dolayı güvenlik sağlayıcısı tarafından doğrulanmadı. Dikkatli bir şekilde ilerleyin." }, + "requestingFor": { + "message": "Talep:" + }, + "requestingForAccount": { + "message": "$1 için talep ediliyor", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "onaylanmayı bekleyen talepler" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Anahtar cümleyi göster" }, + "review": { + "message": "İncele" + }, + "reviewAlert": { + "message": "Uyarıyı incele" + }, "reviewAlerts": { "message": "Uyarıları incele" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "İzni geri al" }, + "revokeSimulationDetailsDesc": { + "message": "Birisinin sizin hesabınızdan token harcama iznini kaldırıyorsunuz." + }, "revokeSpendingCap": { "message": "$1 için harcama üst limitini iptal et", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Bu üçüncü taraf şimdiki ya da gelecekteki tokenlerinizi artık kullanamayacak." }, + "rpcNameOptional": { + "message": "RPC Adı (İsteğe Bağlı)" + }, "rpcUrl": { "message": "Yeni RPC URL adresi" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Güvenlik ve gizlilik" }, + "securityDescription": { + "message": "Güvensiz ağlara katılma şansınızı azaltın ve hesaplarınızı koruyun" + }, + "securityMessageLinkForNetworks": { + "message": "ağ dolandırıcılıkları ve güvenlik riskleri" + }, + "securityPrivacyPath": { + "message": "Ayarlar > Güvenlik ve Gizlilik." + }, "securityProviderPoweredBy": { "message": "$1 hizmetidir", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Tüm izinleri gör", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Ayrıntılara bakın" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Beklediğiniz hesapları görmüyorsanız HD yoluna veya geçerli seçilen ağa geçmeyi deneyin." }, + "selectRpcUrl": { + "message": "RPC URL adresini seçin" + }, "selectType": { "message": "Tür Seç" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Tümüne onay ver" }, + "setApprovalForAllRedesignedTitle": { + "message": "Para çekme talebi" + }, "setApprovalForAllTitle": { "message": "$1 için harcama limiti olmadan onay ver", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Ayarlar" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Ayarlar, kullanım kolaylığı ve güvenlik için optimize edildi. Bunları dilediğiniz zaman değiştirin." + }, "settingsSearchMatchingNotFound": { "message": "Eşleşen sonuç bulunamadı." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Daha fazlasını göster" }, + "showNativeTokenAsMainBalance": { + "message": "Yerli token'i ana bakiye olarak göster" + }, "showNft": { "message": "NFT'yi göster" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Şununla giriş yap:" }, + "simulationApproveHeading": { + "message": "Çek" + }, + "simulationDetailsApproveDesc": { + "message": "Bir başkasına hesabınızdan NFT çekme izni veriyorsunuz." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Bir başkasına sizin hesabınızdan bu tutarı harcama izni veriyorsunuz." + }, "simulationDetailsFiatNotAvailable": { "message": "Mevcut Değil" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Gönderdiğiniz" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Bir başkasının sizin hesabınızdan NFT çekme iznini kaldırıyorsunuz." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Bir başkasına sizin hesabınızdan NFT çekme izni veriyorsunuz." + }, "simulationDetailsTitle": { "message": "Tahmini değişiklikler" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Eyvah! Bir şeyler ters gitti." }, + "sortBy": { + "message": "Sıralama ölçütü" + }, + "sortByAlphabetically": { + "message": "Alfabetik (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Azalan bakiye ($1 yüksek-düşük)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Kaynak" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Harcama Yapan Taraf" }, + "spenderTooltipDesc": { + "message": "Bu, NFT'lerinizi çekebilecek adrestir." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Bu, token'lerinizi sizin adınıza harcayabilecek adrestir." + }, "spendingCap": { "message": "Harcama üst limiti" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "$1 için harcama üst limiti talebi" }, + "spendingCapTooltipDesc": { + "message": "Bu, harcama yapan tarafın sizin adınıza erişim sağlayabileceği token miktarıdır." + }, "srpInputNumberOfWords": { "message": "$1 sözcükten oluşan bir ifadem var", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "$1 tarafından öneriliyor", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Önerilen para birimi sembolü:" + }, "suggestedTokenName": { "message": "Önerilen isim:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Destek Merkezi bölümümüzü ziyaret et" }, + "supportMultiRpcInformation": { + "message": "Şu anda tek bir ağ için birden fazla RPC destekliyoruz. Çelişkili bilgileri çözmek amacıyla en son RPC'niz varsayılan olarak seçilmiştir." + }, "surveyConversion": { "message": "Anketimize katılın" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Gaz ücretleri tahmini olup ağ trafiği ve işlem karmaşıklığına göre dalgalanır." }, + "swapGasFeesExplanation": { + "message": "MetaMask, gaz ücretlerinden para kazanmaz. Bu ücretler tahminlerdir ve ağın yoğunluğuna ve işlemin karmaşıklığına göre değişebilir. $1 daha fazla bilgi edinin.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "Buradan", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Gaz ücretleri hakkında daha fazla bilgi edinin" }, @@ -5186,9 +5583,19 @@ "message": "Gaz ücretleri, $1 ağında işlemleri gerçekleştiren kripto madencilerine ödenir. MetaMask gaz ücretlerinden herhangi bir kazanç elde etmemektedir.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Bu teklif, gönderilen veya alınan token tutarını ayarlayarak gaz ücretlerini dahil eder. Aktivite listenizde ayrı bir işlemde ETH alabilirsiniz." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Gaz ücretleri hakkında daha fazla bilgi edinin" + }, "swapHighSlippage": { "message": "Yüksek kayma" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Gaz ve %$1 MetaMask ücreti dahildir", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "%$1 MetaMask ücreti dahildir.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Kullanım Şartları bölümümüz güncellendi" }, + "testnets": { + "message": "Testnet'ler" + }, "theme": { "message": "Tema" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "İşlem ücreti" }, + "transactionFlowNetwork": { + "message": "Ağ" + }, "transactionHistoryBaseFee": { "message": "Baz ücret (GEWI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Transfer et" }, + "transferCrypto": { + "message": "Kripto transfer et" + }, "transferFrom": { "message": "Transfer kaynağı:" }, + "transferRequest": { + "message": "Transfer talebi" + }, "trillionAbbreviation": { "message": "T", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Güncelle" }, + "updateEthereumChainConfirmationDescription": { + "message": "Bu site, varsayılan ağ URL adresinizi güncellemeyi talep ediyor. Varsayılanları ve ağ bilgilerini dilediğiniz zaman düzenleyebilirsiniz." + }, + "updateNetworkConfirmationTitle": { + "message": "Güncelle: $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Bilgilerinizi güncelleyin veya" }, "updateRequest": { "message": "Talebi güncelle" }, + "updatedRpcForNetworks": { + "message": "Ağ RPC'leri Güncellendi" + }, "uploadDropFile": { "message": "Dosyanızı buraya sürükleyin" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "donanım cüzdanı bağlantı kılavuzumuz" }, + "walletProtectedAndReadyToUse": { + "message": "Cüzdanınız korunuyor ve kullanıma hazır. Gizli Kurtarma İfadenizi $1 bölümünde bulabilirsiniz ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Bu ağı eklemek istiyor musunuz?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Üçüncü taraf, başkaca bildiri ya da rıza olmaksızın tüm token bakiyenizi harcayabilir. Düşük bir harcama limitini özelleştirerek kendinizi koruyun.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Bu seçeneği açtığınızda size genel adres veya ENS adı ile Ethereum hesaplarını izleme yeteneği verilir. Bu Beta özellik hakkında geri bildirim için lütfen bu $1 formunu doldurun.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Ethereum Hesaplarını İzle (Beta)" + }, + "watchOutMessage": { + "message": "Şuraya dikkat edin: $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Zayıf" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Bu nedir?" }, + "withdrawing": { + "message": "Çekiliyor" + }, "wrongNetworkName": { "message": "Kayıtlarımıza göre ağ adı bu zincir kimliği ile doğru şekilde eşleşmiyor olabilir." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Bakiyeniz" }, + "yourBalanceIsAggregated": { + "message": "Bakiyeniz toplanıyor" + }, "yourNFTmayBeAtRisk": { "message": "NFT'niz tehlikede olabilir" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Blok zinciri üzerinde onaylanmadan işleminizi iptal edemedik." }, + "yourWalletIsReady": { + "message": "Cüzdanınız hazır" + }, "zeroGasPriceOnSpeedUpError": { "message": "Sıfır gaz fiyatı hızlandırmada" } diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 700d73f11649..1983185816c0 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "Kết nối với ví cứng QR của bạn" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "Địa chỉ trong yêu cầu đăng nhập không trùng khớp với địa chỉ của tài khoản bạn đang sử dụng để đăng nhập." }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "Chọn tài khoản mà bạn muốn nhận thông báo:" }, + "accountBalance": { + "message": "Số dư tài khoản" + }, "accountDetails": { "message": "Chi tiết tài khoản" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "Tùy chọn tài khoản" }, + "accountPermissionToast": { + "message": "Đã cập nhật quyền đối với tài khoản" + }, "accountSelectionRequired": { "message": "Bạn cần chọn một tài khoản!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "Đã kết nối tài khoản" }, + "accountsPermissionsTitle": { + "message": "Xem tài khoản của bạn và đề xuất giao dịch" + }, + "accountsSmallCase": { + "message": "tài khoản" + }, "active": { "message": "Đang hoạt động" }, @@ -180,12 +195,18 @@ "add": { "message": "Thêm" }, + "addACustomNetwork": { + "message": "Thêm mạng tùy chỉnh" + }, "addANetwork": { "message": "Thêm mạng" }, "addANickname": { "message": "Thêm biệt danh" }, + "addAUrl": { + "message": "Thêm URL" + }, "addAccount": { "message": "Thêm tài khoản" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "Thêm một trình khám phá khối" }, + "addBlockExplorerUrl": { + "message": "Thêm URL trình khám phá khối" + }, "addContact": { "message": "Thêm liên hệ" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "Bạn đang thêm một nhà cung cấp RPC mới cho Ethereum Mainnet" }, + "addEthereumWatchOnlyAccount": { + "message": "Theo dõi tài khoản Ethereum (Beta)" + }, "addFriendsAndAddresses": { "message": "Thêm bạn bè và địa chỉ mà bạn tin tưởng" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "Thêm mạng" }, + "addNetworkConfirmationTitle": { + "message": "Thêm $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "Thêm tài khoản Ethereum mới" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "Đã sao chép địa chỉ!" }, + "addressMismatch": { + "message": "Địa chỉ trang web không khớp" + }, + "addressMismatchOriginal": { + "message": "URL hiện tại: $1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Phiên bản Punycode: $1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "Nâng cao" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "Phí ưu tiên (hay còn được gọi là \"tiền thưởng cho thợ đào\") được chuyển trực tiếp cho các thợ đào và khuyến khích họ ưu tiên giao dịch của bạn." }, + "aggregatedBalancePopover": { + "message": "Điều này phản ánh giá trị của tất cả các token mà bạn sở hữu trên một mạng nhất định. Nếu bạn muốn xem giá trị này bằng ETH hoặc các loại tiền tệ khác, hãy truy cập $1.", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "Tôi đồng ý với $1 của MetaMask", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "Bạn có thể thay đổi trong phần \"Cài đặt > Cảnh báo\"" }, + "alertMessageAddressMismatchWarning": { + "message": "Kẻ tấn công đôi khi bắt chước các trang web bằng cách thực hiện những thay đổi nhỏ trong địa chỉ trang web. Đảm bảo bạn đang tương tác với trang web dự định trước khi tiếp tục." + }, "alertMessageGasEstimateFailed": { "message": "Chúng tôi không thể cung cấp phí chính xác và ước tính này có thể cao. Chúng tôi khuyên bạn nên nhập hạn mức phí gas tùy chỉnh, nhưng vẫn có rủi ro giao dịch sẽ thất bại." }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "Để tiếp tục giao dịch này, bạn cần tăng giới hạn phí gas lên 21000 hoặc cao hơn." }, + "alertMessageInsufficientBalance2": { + "message": "Bạn không có đủ ETH trong tài khoản để thanh toán phí mạng." + }, "alertMessageNetworkBusy": { "message": "Phí gas cao và ước tính kém chính xác hơn." }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "Tùy chọn tài sản" }, + "assets": { + "message": "Tài sản" + }, + "assetsDescription": { + "message": "Tự động phát hiện token trong ví của bạn, hiển thị NFT và nhận hàng loạt thông tin cập nhật về số dư tài khoản" + }, "attemptSendingAssets": { "message": "Bạn có thể bị mất tài sản nếu cố gắng gửi tài sản từ một mạng khác. Chuyển tiền an toàn giữa các mạng bằng cách sử dụng cầu nối." }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "Cầu nối, không gửi" }, + "bridgeFrom": { + "message": "Cầu nối từ" + }, + "bridgeSelectNetwork": { + "message": "Chọn mạng" + }, + "bridgeTo": { + "message": "Cầu nối đến" + }, "browserNotSupported": { "message": "Trình duyệt của bạn không được hỗ trợ..." }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "Xác nhận Cụm từ khôi phục bí mật" }, + "confirmTitleApproveTransaction": { + "message": "Yêu cầu cho phép" + }, + "confirmTitleDeployContract": { + "message": "Triển khai hợp đồng" + }, + "confirmTitleDescApproveTransaction": { + "message": "Trang web này muốn được cấp quyền để rút NFT của bạn" + }, + "confirmTitleDescDeployContract": { + "message": "Trang web này muốn bạn triển khai hợp đồng" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "Trang web này muốn được cấp quyền để rút token của bạn" + }, "confirmTitleDescPermitSignature": { "message": "Trang web này muốn được cấp quyền để chi tiêu số token của bạn." }, "confirmTitleDescSIWESignature": { "message": "Một trang web yêu cầu bạn đăng nhập để chứng minh quyền sở hữu tài khoản này." }, + "confirmTitleDescSign": { + "message": "Xem lại thông tin yêu cầu trước khi xác nhận." + }, "confirmTitlePermitTokens": { "message": "Yêu cầu hạn mức chi tiêu" }, + "confirmTitleRevokeApproveTransaction": { + "message": "Xóa quyền" + }, "confirmTitleSIWESignature": { "message": "Yêu cầu đăng nhập" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "Xóa quyền" + }, "confirmTitleSignature": { "message": "Yêu cầu chữ ký" }, "confirmTitleTransaction": { "message": "Yêu cầu giao dịch" }, + "confirmationAlertModalDetails": { + "message": "Để bảo vệ tài sản và thông tin đăng nhập của bạn, chúng tôi đề nghị bạn từ chối yêu cầu này." + }, + "confirmationAlertModalTitle": { + "message": "Yêu cầu này có dấu hiệu đáng ngờ" + }, "confirmed": { "message": "Đã xác nhận" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "Chúng tôi đã phát hiện thấy một ký tự có thể gây nhầm lẫn trong tên ENS. Hãy kiểm tra tên ENS để tránh nguy cơ bị lừa đảo." }, + "congratulations": { + "message": "Chúc mừng!" + }, "connect": { "message": "Kết nối" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "Trang web đã kết nối" }, + "connectedSitesAndSnaps": { + "message": "Trang web và Snap đã kết nối" + }, "connectedSitesDescription": { "message": "$1 đã được kết nối với các trang web này. Các trang web này có thể xem địa chỉ tài khoản của bạn.", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask được kết nối với trang web này, nhưng chưa có tài khoản nào được kết nối" }, + "connectedSnaps": { + "message": "Snap đã kết nối" + }, + "connectedWithAccount": { + "message": "Đã kết nối $1 tài khoản", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "Đã kết nối với $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Đang kết nối" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "Đang kết nối với mạng thử nghiệm Sepolia" }, + "connectionDescription": { + "message": "Trang web này muốn" + }, "connectionFailed": { "message": "Kết nối thất bại" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "Sao chép địa chỉ vào bộ nhớ đệm" }, + "copyAddressShort": { + "message": "Sao chép địa chỉ" + }, "copyPrivateKey": { "message": "Sao chép khóa riêng tư" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "URL RPC mặc định" }, + "defaultSettingsSubTitle": { + "message": "MetaMask sử dụng chế độ cài đặt mặc định để cân bằng tốt nhất giữa tính an toàn và dễ sử dụng. Thay đổi các chế độ cài đặt này để nâng cao quyền riêng tư của bạn hơn nữa." + }, + "defaultSettingsTitle": { + "message": "Cài đặt quyền riêng tư mặc định" + }, "delete": { "message": "Xóa" }, "deleteContact": { "message": "Xóa địa chỉ liên hệ" }, + "deleteMetaMetricsData": { + "message": "Xóa dữ liệu MetaMetrics" + }, + "deleteMetaMetricsDataDescription": { + "message": "Thao tác này sẽ xóa dữ liệu MetaMetrics trước đây liên quan đến quá trình sử dụng của bạn trên thiết bị này. Ví và tài khoản của bạn sẽ vẫn như hiện tại sau khi dữ liệu này bị xóa. Quá trình này có thể mất đến 30 ngày. Xem $1 của chúng tôi.", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "Không thể hoàn tất yêu cầu này ngay bây giờ do sự cố máy chủ hệ thống phân tích, vui lòng thử lại sau" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "Chúng tôi không thể xóa dữ liệu này bây giờ" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "Chúng tôi sắp xóa tất cả dữ liệu MetaMetrics của bạn. Bạn có chắc không?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "Xóa dữ liệu MetaMetrics?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "Bạn đã khởi tạo hành động này trên $1. Quá trình này có thể mất đến 30 ngày. Xem $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "Nếu xóa mạng này, bạn sẽ cần thêm lại mạng này để xem các tài sản của mình trong mạng này" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "Nạp" }, + "depositCrypto": { + "message": "Nạp tiền mã hóa từ tài khoản khác bằng địa chỉ ví hoặc mã QR." + }, "deprecatedGoerliNtwrkMsg": { "message": "Do các cập nhật của hệ thống Ethereum, mạng thử nghiệm Goerli sẽ sớm được loại bỏ dần." }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "tài khoản" }, + "disconnectAllDescriptionText": { + "message": "Nếu bạn ngắt kết nối khỏi trang web này, bạn sẽ cần kết nối lại tài khoản và mạng của bạn để sử dụng lại trang web này." + }, "disconnectAllSnapsText": { "message": "Snap" }, + "disconnectMessage": { + "message": "Hành động này sẽ ngắt kết nối bạn khỏi trang web này" + }, "disconnectPrompt": { "message": "Ngắt kết nối $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "Chỉnh sửa biệt danh" }, + "editAccounts": { + "message": "Chỉnh sửa tài khoản" + }, "editAddressNickname": { "message": "Chỉnh sửa biệt danh địa chỉ" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "chỉnh sửa mạng gốc" }, + "editNetworksTitle": { + "message": "Chỉnh sửa mạng" + }, "editNonceField": { "message": "Chỉnh sửa số nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "Chỉnh sửa quyền" }, + "editPermissions": { + "message": "Chỉnh sửa quyền" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Chỉnh sửa phí gas tăng tốc" }, + "editSpendingCap": { + "message": "Chỉnh sửa hạn mức chi tiêu" + }, + "editSpendingCapAccountBalance": { + "message": "Số dư tài khoản: $1 $2" + }, + "editSpendingCapDesc": { + "message": "Nhập số tiền mà bạn cảm thấy thoải mái khi được chi tiêu thay mặt cho bạn." + }, + "editSpendingCapError": { + "message": "Hạn mức chi tiêu không được vượt quá $1 chữ số thập phân. Xóa bớt chữ số thập phân để tiếp tục." + }, "enableAutoDetect": { "message": " Bật tự động phát hiện" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "Tra cứu ENS thất bại." }, + "enterANameToIdentifyTheUrl": { + "message": "Nhập tên để xác định URL" + }, "enterANumber": { "message": "Nhập số" }, + "enterChainId": { + "message": "Nhập ID chuỗi" + }, "enterCustodianToken": { "message": "Nhập token $1 của bạn hoặc thêm token mới" }, "enterMaxSpendLimit": { "message": "Nhập hạn mức chi tiêu tối đa" }, + "enterNetworkName": { + "message": "Nhập tên mạng" + }, "enterOptionalPassword": { "message": "Nhập mật khẩu tùy chọn" }, "enterPasswordContinue": { "message": "Nhập mật khẩu để tiếp tục" }, + "enterRpcUrl": { + "message": "Nhập URL RPC" + }, + "enterSymbol": { + "message": "Nhập ký hiệu" + }, "enterTokenNameOrAddress": { "message": "Nhập tên token hoặc dán địa chỉ" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "Thử nghiệm" }, + "exportYourData": { + "message": "Xuất dữ liệu của bạn" + }, + "exportYourDataButton": { + "message": "Tải xuống" + }, + "exportYourDataDescription": { + "message": "Bạn có thể xuất các dữ liệu như địa chỉ liên hệ và tùy chọn của bạn." + }, "extendWalletWithSnaps": { "message": "Khám phá các Snap do cộng đồng xây dựng để tùy chỉnh trải nghiệm web3 của bạn", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "Phí gas này đã được gợi ý bởi $1. Việc sửa đổi có thể khiến giao dịch của bạn gặp sự cố. Vui lòng liên hệ với $1 nếu bạn có câu hỏi.", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "Phí gas" + }, "gasIsETH": { "message": "Gas là $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "Đã xảy ra sự cố..." }, + "generalDescription": { + "message": "Đồng bộ chế độ cài đặt trên các thiết bị, chọn mạng ưa thích và theo dõi dữ liệu token" + }, "genericExplorerView": { "message": "Xem tài khoản trên $1" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "Nếu bạn bị khóa ứng dụng hoặc sử dụng thiết bị mới, bạn sẽ mất tiền. Nhớ sao lưu Cụm từ khôi phục bí mật của bạn trong $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "Bỏ qua tất cả" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "trong phần Cài đặt" }, + "included": { + "message": "đã bao gồm" + }, "infuraBlockedNotification": { "message": "MetaMask không thể kết nối với máy chủ chuỗi khối. Hãy xem xét các lý do tiềm ẩn $1.", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "Tập tin JSON", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "Cất giữ lời nhắc về Cụm từ khôi phục bí mật ở một nơi an toàn. Nếu bạn làm mất, không ai có thể giúp bạn lấy lại. Tệ hơn nữa, bạn sẽ không bao giờ truy cập được vào ví của bạn nữa. $1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "Tên tài khoản" }, @@ -2402,6 +2622,9 @@ "message": "Tìm hiểu cách $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "Tìm hiểu cách thức" + }, "learnMore": { "message": "tìm hiểu thêm" }, @@ -2409,6 +2632,9 @@ "message": "Muốn $1 về gas?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "Tìm hiểu thêm về các cách thức bảo mật tốt nhất." + }, "learnMoreKeystone": { "message": "Tìm hiểu thêm" }, @@ -2497,6 +2723,9 @@ "link": { "message": "Liên kết" }, + "linkCentralizedExchanges": { + "message": "Liên kết tài khoản Coinbase hoặc Binance của bạn để chuyển tiền mã hóa đến MetaMask miễn phí." + }, "links": { "message": "Liên kết" }, @@ -2557,6 +2786,9 @@ "message": "Đảm bảo không có ai đang nhìn", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "Quản lý chế độ cài đặt quyền riêng tư mặc định" + }, "marketCap": { "message": "Vốn hóa thị trường" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "Nút trạng thái kết nối sẽ hiển thị nếu trang web mà bạn đang truy cập được kết nối với tài khoản bạn đang chọn." }, + "metaMetricsIdNotAvailableError": { + "message": "Vì bạn chưa bao giờ chọn tham gia MetaMetrics, nên ở đây không có dữ liệu nào để xóa." + }, "metadataModalSourceTooltip": { "message": "$1 được lưu trữ trên npm và $2 là mã định danh duy nhất của Snap này.", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "thêm" }, + "moreAccounts": { + "message": "+ $1 tài khoản nữa", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 mạng nữa", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "Bạn đang thêm mạng này vào MetaMask và cấp cho trang web này quyền sử dụng mạng này." + }, "multipleSnapConnectionWarning": { "message": "$1 muốn sử dụng $2 Snap", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "Chỉnh sửa thông tin mạng" }, "nativeTokenScamWarningDescription": { - "message": "Mạng này không trùng khớp với tên hoặc ID chuỗi liên quan của nó. Nhiều token phổ biến sử dụng tên $1, khiến nó trở thành mục tiêu của các hành vi lừa đảo. Kẻ lừa đảo có thể lừa bạn gửi lại cho họ loại tiền tệ có giá trị hơn. Hãy xác minh mọi thứ trước khi tiếp tục.", + "message": "Ký hiệu token gốc không khớp với ký hiệu dự kiến của token gốc dành cho mạng với ID chuỗi liên quan. Bạn đã nhập $1 trong khi ký hiệu token dự kiến là $2. Vui lòng xác minh rằng bạn đã kết nối với đúng chuỗi.", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "cảnh báo khác", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "Đây có khả năng là một hành vi lừa đảo", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "Thông tin về mạng" }, + "networkFee": { + "message": "Phí mạng" + }, "networkIsBusy": { "message": "Mạng đang bận. Giá gas cao và ước tính kém chính xác hơn." }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "Tùy chọn mạng" }, + "networkPermissionToast": { + "message": "Đã cập nhật quyền đối với mạng" + }, "networkProvider": { "message": "Nhà cung cấp mạng" }, @@ -2865,15 +3121,26 @@ "message": "Chúng tôi không thể kết nối với $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "Mạng được chuyển thành $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "URL mạng" }, "networkURLDefinition": { "message": "URL dùng để truy cập vào mạng này." }, + "networkUrlErrorWarning": { + "message": "Kẻ tấn công đôi khi bắt chước các trang web bằng cách thực hiện những thay đổi nhỏ trong địa chỉ trang web. Đảm bảo bạn đang tương tác với trang web dự định trước khi tiếp tục. Phiên bản Punycode: $1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "Mạng" }, + "networksSmallCase": { + "message": "mạng" + }, "nevermind": { "message": "Bỏ qua" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "Chúng tôi đã cập nhật chính sách quyền riêng tư" }, + "newRpcUrl": { + "message": "URL RPC mới" + }, "newTokensImportedMessage": { "message": "Bạn đã nhập thành công $1.", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask không được kết nối với trang web này" }, + "noConnectionDescription": { + "message": "Để kết nối với trang web, hãy tìm và chọn nút \"kết nối\". Hãy nhớ rằng, MetaMask chỉ có thể kết nối với các trang web trên web3" + }, "noConversionRateAvailable": { "message": "Không có sẵn tỷ lệ quy đổi nào" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "Số nonce tùy chỉnh" }, + "none": { + "message": "Không có" + }, "notBusy": { "message": "Không bận" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "Chi tiết Quyền" }, + "permissionFor": { + "message": "Quyền cho" + }, + "permissionFrom": { + "message": "Quyền từ" + }, "permissionRequest": { "message": "Yêu cầu cấp quyền" }, @@ -3593,6 +3875,14 @@ "message": "Cho phép $1 truy cập ngôn ngữ ưa thích của bạn từ cài đặt MetaMask. Điều này có thể được sử dụng để dịch và hiển thị nội dung của $1 theo ngôn ngữ của bạn.", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "Xem các thông tin như ngôn ngữ ưu tiên và loại tiền pháp định của bạn.", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "Cho phép $1 truy cập các thông tin như ngôn ngữ ưu tiên và loại tiền pháp định trong cài đặt MetaMask của bạn. Điều này giúp $1 hiển thị nội dung phù hợp với sở thích của bạn. ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "Hiển thị màn hình tùy chỉnh", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "Bạn đang cấp cho người chi tiêu quyền chi tiêu số lượng token này từ tài khoản của bạn." }, + "permittedChainToastUpdate": { + "message": "$1 có quyền truy cập vào $2." + }, "personalAddressDetected": { "message": "Đã tìm thấy địa chỉ cá nhân. Nhập địa chỉ hợp đồng token." }, @@ -3963,6 +4256,12 @@ "receive": { "message": "Nhận" }, + "receiveCrypto": { + "message": "Nhận tiền mã hóa" + }, + "recipientAddressPlaceholderNew": { + "message": "Nhập địa chỉ công khai (0x) hoặc tên miền" + }, "recommendedGasLabel": { "message": "Được đề xuất" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "Đã từ chối" }, + "rememberSRPIfYouLooseAccess": { + "message": "Hãy nhớ rằng, nếu bạn làm mất Cụm từ khôi phục bí mật, bạn sẽ mất quyền truy cập vào ví. $1 để cất giữ cụm từ này ở nơi an toàn, nhờ đó bạn luôn có thể truy cập vào tiền của bạn." + }, + "reminderSet": { + "message": "Đã thiết lập lời nhắc!" + }, "remove": { "message": "Xóa" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "Do có lỗi, yêu cầu này đã không được nhà cung cấp bảo mật xác minh. Hãy thực hiện cẩn thận." }, + "requestingFor": { + "message": "Yêu cầu cho" + }, + "requestingForAccount": { + "message": "Yêu cầu cho $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "yêu cầu đang chờ xác nhận" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "Hiển thị cụm từ khôi phục bí mật" }, + "review": { + "message": "Xem lại" + }, + "reviewAlert": { + "message": "Xem lại cảnh báo" + }, "reviewAlerts": { "message": "Xem lại cảnh báo" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "Thu hồi quyền" }, + "revokeSimulationDetailsDesc": { + "message": "Bạn đang xóa quyền chi tiêu token của người khác khỏi tài khoản của bạn." + }, "revokeSpendingCap": { "message": "Thu hồi hạn mức chi tiêu cho $1 của bạn", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "Bên thứ ba này sẽ không thể chi tiêu thêm bất kỳ token hiện tại hoặc tương lai nào của bạn." }, + "rpcNameOptional": { + "message": "Tên RPC (Không bắt buộc)" + }, "rpcUrl": { "message": "URL RPC mới" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "Bảo mật và quyền riêng tư" }, + "securityDescription": { + "message": "Giảm khả năng tham gia các mạng không an toàn và bảo vệ tài khoản của bạn" + }, + "securityMessageLinkForNetworks": { + "message": "lừa đảo mạng và rủi ro bảo mật" + }, + "securityPrivacyPath": { + "message": "Cài đặt > Bảo mật & Quyền riêng tư." + }, "securityProviderPoweredBy": { "message": "Được cung cấp bởi $1", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "Xem tất cả quyền", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "Xem chi tiết" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "Nếu bạn không thấy các tài khoản như mong đợi, hãy chuyển sang đường dẫn HD hoặc mạng đã chọn hiện tại." }, + "selectRpcUrl": { + "message": "Chọn URL RPC" + }, "selectType": { "message": "Chọn loại" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "Thiết lập chấp thuận tất cả" }, + "setApprovalForAllRedesignedTitle": { + "message": "Yêu cầu rút tiền" + }, "setApprovalForAllTitle": { "message": "Chấp thuận $1 không có hạn mức chi tiêu", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "Cài đặt" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "Chế độ cài đặt được tối ưu hóa để đảm bảo an toàn và dễ sử dụng. Thay đổi các chế độ cài đặt này bất cứ lúc nào." + }, "settingsSearchMatchingNotFound": { "message": "Không tìm thấy kết quả trùng khớp." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "Hiển thị thêm" }, + "showNativeTokenAsMainBalance": { + "message": "Hiển thị token gốc làm số dư chính" + }, "showNft": { "message": "Hiển thị NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "Đăng nhập bằng" }, + "simulationApproveHeading": { + "message": "Rút" + }, + "simulationDetailsApproveDesc": { + "message": "Bạn đang cấp cho người khác quyền rút NFT khỏi tài khoản của bạn." + }, + "simulationDetailsERC20ApproveDesc": { + "message": "Bạn đang cấp cho người khác quyền chi tiêu số tiền này từ tài khoản của bạn." + }, "simulationDetailsFiatNotAvailable": { "message": "Không có sẵn" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "Bạn gửi" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "Bạn đang xóa quyền rút NFT của người khác khỏi tài khoản của bạn." + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "Bạn đang cấp quyền cho người khác rút NFT khỏi tài khoản của bạn." + }, "simulationDetailsTitle": { "message": "Thay đổi ước tính" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "Rất tiếc! Đã xảy ra sự cố." }, + "sortBy": { + "message": "Sắp xếp theo" + }, + "sortByAlphabetically": { + "message": "Bảng chữ cái (A - Z)" + }, + "sortByDecliningBalance": { + "message": "Số dư giảm dần ($1 cao - thấp)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Nguồn" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "Người chi tiêu" }, + "spenderTooltipDesc": { + "message": "Đây là địa chỉ có thể rút NFT của bạn." + }, + "spenderTooltipERC20ApproveDesc": { + "message": "Đây là địa chỉ sẽ có thể chi tiêu token thay mặt cho bạn." + }, "spendingCap": { "message": "Hạn mức chi tiêu" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "Yêu cầu hạn mức chi tiêu cho $1 của bạn" }, + "spendingCapTooltipDesc": { + "message": "Đây là số lượng token mà người chi tiêu có thể truy cập thay mặt cho bạn." + }, "srpInputNumberOfWords": { "message": "Tôi có một cụm từ gồm $1 từ", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "Được đề xuất bởi $1", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "Ký hiệu tiền tệ đề xuất:" + }, "suggestedTokenName": { "message": "Tên đề xuất:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "Truy cập trung tâm hỗ trợ của chúng tôi" }, + "supportMultiRpcInformation": { + "message": "Chúng tôi hiện hỗ trợ nhiều RPC cho một mạng duy nhất. RPC gần đây nhất của bạn đã được chọn làm RPC mặc định để giải quyết thông tin xung đột." + }, "surveyConversion": { "message": "Tham gia khảo sát của chúng tôi" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "Phí gas được ước tính và sẽ dao động dựa trên lưu lượng mạng và độ phức tạp của giao dịch." }, + "swapGasFeesExplanation": { + "message": "MetaMask không kiếm tiền từ phí gas. Các khoản phí này là ước tính và có thể thay đổi dựa trên lưu lượng của mạng và độ phức tạp của giao dịch. Tìm hiểu thêm $1.", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "tại đây", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "Tìm hiểu thêm về phí gas" }, @@ -5186,9 +5583,19 @@ "message": "Phí gas được trả cho thợ đào tiền điện tử, họ là những người xử lý các giao dịch trên mạng $1. MetaMask không thu lợi nhuận từ phí gas.", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "Báo giá này bao gồm phí gas bằng cách điều chỉnh số lượng token được gửi hoặc nhận. Bạn có thể nhận được ETH trong một giao dịch riêng biệt trên danh sách hoạt động của bạn." + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "Tìm hiểu thêm về phí gas" + }, "swapHighSlippage": { "message": "Mức trượt giá cao" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "Bao gồm phí gas và $1% phí của MetaMask", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "Bao gồm $1% phí của MetaMask.", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "Điều khoản sử dụng của chúng tôi đã được cập nhật" }, + "testnets": { + "message": "Mạng thử nghiệm" + }, "theme": { "message": "Chủ đề" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "Phí giao dịch" }, + "transactionFlowNetwork": { + "message": "Mạng" + }, "transactionHistoryBaseFee": { "message": "Phí cơ sở (GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "Chuyển" }, + "transferCrypto": { + "message": "Chuyển tiền mã hóa" + }, "transferFrom": { "message": "Chuyển từ" }, + "transferRequest": { + "message": "Yêu cầu chuyển tiền" + }, "trillionAbbreviation": { "message": "Nghìn Tỷ", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "Cập nhật" }, + "updateEthereumChainConfirmationDescription": { + "message": "Trang web này đang yêu cầu cập nhật URL mạng mặc định của bạn. Bạn có thể chỉnh sửa thông tin mặc định và mạng bất cứ lúc nào." + }, + "updateNetworkConfirmationTitle": { + "message": "Cập nhật $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "Cập nhật thông tin của bạn hoặc" }, "updateRequest": { "message": "Yêu cầu cập nhật" }, + "updatedRpcForNetworks": { + "message": "Đã cập nhật RPC mạng" + }, "uploadDropFile": { "message": "Thả tập tin của bạn vào đây" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "hướng dẫn của chúng tôi về cách kết nối ví cứng" }, + "walletProtectedAndReadyToUse": { + "message": "Ví của bạn đã được bảo vệ và sẵn sàng để sử dụng. Bạn có thể xem Cụm từ khôi phục bí mật trong $1 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "Bạn muốn thêm mạng này?" }, @@ -5991,6 +6424,17 @@ "message": "$1 Bên thứ ba có thể chi tiêu toàn bộ số dư token của bạn mà không cần thông báo hoặc đồng ý thêm. Hãy tự bảo vệ chính mình bằng cách điều chỉnh hạn mức chi tiêu thấp hơn.", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "Bật tùy chọn này sẽ cho phép bạn theo dõi tài khoản Ethereum thông qua địa chỉ công khai hoặc tên ENS. Để phản hồi về tính năng Beta này, vui lòng hoàn thành $1 này.", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "Theo dõi tài khoản Ethereum (Beta)" + }, + "watchOutMessage": { + "message": "Hãy cẩn thận với $1.", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "Yếu" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "Đây là gì?" }, + "withdrawing": { + "message": "Đang rút tiền" + }, "wrongNetworkName": { "message": "Theo hồ sơ của chúng tôi, tên mạng có thể không khớp chính xác với ID chuỗi này." }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "Số dư của bạn" }, + "yourBalanceIsAggregated": { + "message": "Số dư của bạn đã được tổng hợp" + }, "yourNFTmayBeAtRisk": { "message": "NFT của bạn có thể gặp rủi ro" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "Chúng tôi không thể hủy giao dịch của bạn trước khi nó được xác nhận trên chuỗi khối." }, + "yourWalletIsReady": { + "message": "Ví của bạn đã sẵn sàng" + }, "zeroGasPriceOnSpeedUpError": { "message": "Giá gas bằng 0 khi tăng tốc" } diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index a24b993874a0..b6c050f6b264 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -41,6 +41,9 @@ "QRHardwareWalletSteps1Title": { "message": "关联您的二维码硬件钱包" }, + "QRHardwareWalletSteps2Description": { + "message": "Ngrave Zero" + }, "SIWEAddressInvalid": { "message": "登录请求中的地址与您用于登录的账户地址不匹配。" }, @@ -133,6 +136,9 @@ "accountActivityText": { "message": "选择您想要接收通知的账户:" }, + "accountBalance": { + "message": "账户余额" + }, "accountDetails": { "message": "账户详情" }, @@ -156,6 +162,9 @@ "accountOptions": { "message": "账户选项" }, + "accountPermissionToast": { + "message": "账户许可已更新" + }, "accountSelectionRequired": { "message": "您需要选择一个账户!" }, @@ -168,6 +177,12 @@ "accountsConnected": { "message": "账户已连接" }, + "accountsPermissionsTitle": { + "message": "查看您的账户并建议交易" + }, + "accountsSmallCase": { + "message": "账户" + }, "active": { "message": "活跃" }, @@ -180,12 +195,18 @@ "add": { "message": "添加" }, + "addACustomNetwork": { + "message": "添加自定义网络" + }, "addANetwork": { "message": "添加网络" }, "addANickname": { "message": "添加昵称" }, + "addAUrl": { + "message": "添加 URL" + }, "addAccount": { "message": "添加账户" }, @@ -201,6 +222,9 @@ "addBlockExplorer": { "message": "添加区块浏览器" }, + "addBlockExplorerUrl": { + "message": "添加区块浏览器 URL" + }, "addContact": { "message": "添加联系信息" }, @@ -229,6 +253,9 @@ "addEthereumChainWarningModalTitle": { "message": "您正在为以太坊主网添加新的 RPC 提供商" }, + "addEthereumWatchOnlyAccount": { + "message": "查看以太坊账户(测试版)" + }, "addFriendsAndAddresses": { "message": "添加您信任的朋友和地址" }, @@ -247,6 +274,10 @@ "addNetwork": { "message": "添加网络" }, + "addNetworkConfirmationTitle": { + "message": "添加 $1", + "description": "$1 represents network name" + }, "addNewAccount": { "message": "添加新账户" }, @@ -311,6 +342,17 @@ "addressCopied": { "message": "地址已复制!" }, + "addressMismatch": { + "message": "网站地址不匹配" + }, + "addressMismatchOriginal": { + "message": "当前 URL:$1", + "description": "$1 replaced by origin URL in confirmation request" + }, + "addressMismatchPunycode": { + "message": "Punycode 版本:$1", + "description": "$1 replaced by punycode version of the URL in confirmation request" + }, "advanced": { "message": "高级" }, @@ -342,6 +384,10 @@ "advancedPriorityFeeToolTip": { "message": "优先费用(又称“矿工费”)直接向矿工支付,激励他们优先处理您的交易。" }, + "aggregatedBalancePopover": { + "message": "这反映了您在给定网络上拥有的所有代币的价值。如果您更愿意看到以 ETH 或其他货币反映代币价值,请转到 $1。", + "description": "$1 represents the settings page" + }, "agreeTermsOfUse": { "message": "我同意MetaMask的$1", "description": "$1 is the `terms` link" @@ -367,6 +413,9 @@ "alertDisableTooltip": { "message": "这可以在“设置 > 提醒”中进行更改" }, + "alertMessageAddressMismatchWarning": { + "message": "攻击者有时会通过对网站地址进行细微更改来模仿网站。在继续操作之前,请确保您是与目标网站进行交互。" + }, "alertMessageGasEstimateFailed": { "message": "我们无法提供准确的费用,估算可能较高。我们建议您输入自定义的燃料限制,但交易仍存在失败风险。" }, @@ -376,6 +425,9 @@ "alertMessageGasTooLow": { "message": "要继续此交易,您需要将燃料限制提高到 21000 或更高。" }, + "alertMessageInsufficientBalance2": { + "message": "您的账户中没有足够的 ETH 来支付网络费用。" + }, "alertMessageNetworkBusy": { "message": "燃料价格很高,估算不太准确。" }, @@ -569,6 +621,12 @@ "assetOptions": { "message": "资产选项" }, + "assets": { + "message": "资产" + }, + "assetsDescription": { + "message": "自动检测钱包中的代币,显示 NFT,并获取批量账户余额更新" + }, "attemptSendingAssets": { "message": "如果您试图将资产从一个网络直接发送到另一个网络,这可能会导致永久的资产损失。请务必使用跨链桥进行操作。" }, @@ -782,6 +840,15 @@ "bridgeDontSend": { "message": "跨链桥,不要发送" }, + "bridgeFrom": { + "message": "桥接自" + }, + "bridgeSelectNetwork": { + "message": "选择网络" + }, + "bridgeTo": { + "message": "桥接至" + }, "browserNotSupported": { "message": "您的浏览器不受支持……" }, @@ -945,24 +1012,54 @@ "confirmRecoveryPhrase": { "message": "确认私钥助记词" }, + "confirmTitleApproveTransaction": { + "message": "许可请求" + }, + "confirmTitleDeployContract": { + "message": "部署合约" + }, + "confirmTitleDescApproveTransaction": { + "message": "此网站需要获得许可来提取您的 NFT。" + }, + "confirmTitleDescDeployContract": { + "message": "该网站希望您部署一份合约" + }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "此网站需要获得许可来提取您的代币" + }, "confirmTitleDescPermitSignature": { "message": "此网站需要获得许可来使用您的代币。" }, "confirmTitleDescSIWESignature": { "message": "某个网站要求您登录以证明您拥有该账户。" }, + "confirmTitleDescSign": { + "message": "在确认之前,请查看请求详细信息。" + }, "confirmTitlePermitTokens": { "message": "支出上限请求" }, + "confirmTitleRevokeApproveTransaction": { + "message": "撤销权限" + }, "confirmTitleSIWESignature": { "message": "登录请求" }, + "confirmTitleSetApprovalForAllRevokeTransaction": { + "message": "撤销权限" + }, "confirmTitleSignature": { "message": "签名请求" }, "confirmTitleTransaction": { "message": "交易请求" }, + "confirmationAlertModalDetails": { + "message": "为了保护您的资产和登录信息,我们建议您拒绝该请求。" + }, + "confirmationAlertModalTitle": { + "message": "此请求可疑" + }, "confirmed": { "message": "已确认" }, @@ -975,6 +1072,9 @@ "confusingEnsDomain": { "message": "我们在 ENS 名称中检测到一个可令人混淆的字符。检查 ENS 名称以避免潜在的欺诈。" }, + "congratulations": { + "message": "恭喜!" + }, "connect": { "message": "连接" }, @@ -1035,6 +1135,9 @@ "connectedSites": { "message": "已连接的网站" }, + "connectedSitesAndSnaps": { + "message": "连接的网站和 Snap" + }, "connectedSitesDescription": { "message": "$1 已连接到这些网站。他们可以查看您的账户地址。", "description": "$1 is the account name" @@ -1046,6 +1149,17 @@ "connectedSnapAndNoAccountDescription": { "message": "MetaMask 已连接到此网站,但尚未连接任何账户" }, + "connectedSnaps": { + "message": "连接的 Snap" + }, + "connectedWithAccount": { + "message": "已连接 $1 账户", + "description": "$1 represents account length" + }, + "connectedWithAccountName": { + "message": "与 $1 连接", + "description": "$1 represents account name" + }, "connecting": { "message": "连接中……" }, @@ -1073,6 +1187,9 @@ "connectingToSepolia": { "message": "正在连接Sepolia测试网络" }, + "connectionDescription": { + "message": "此网站想要:" + }, "connectionFailed": { "message": "连接失败" }, @@ -1153,6 +1270,9 @@ "copyAddress": { "message": "复制地址到剪贴板" }, + "copyAddressShort": { + "message": "复制地址" + }, "copyPrivateKey": { "message": "复制私钥" }, @@ -1402,12 +1522,41 @@ "defaultRpcUrl": { "message": "默认 RPC(远程过程调用)URL" }, + "defaultSettingsSubTitle": { + "message": "MetaMask 使用默认设置达到安全性和易用性的最佳平衡。更改这些设置以进一步保护您的隐私。" + }, + "defaultSettingsTitle": { + "message": "默认隐私设置" + }, "delete": { "message": "删除" }, "deleteContact": { "message": "删除联系人" }, + "deleteMetaMetricsData": { + "message": "删除 MetaMetrics 数据" + }, + "deleteMetaMetricsDataDescription": { + "message": "这将删除与您在此设备上的使用相关的 MetaMetrics 历史数据。删除此数据后,您的钱包和账户将保持原样。此过程可能需要最多 30 天。查看我们的 $1。", + "description": "$1 will have text saying Privacy Policy " + }, + "deleteMetaMetricsDataErrorDesc": { + "message": "由于分析系统服务器问题,现在无法完成此请求,请稍后再试" + }, + "deleteMetaMetricsDataErrorTitle": { + "message": "我们现在无法删除此数据" + }, + "deleteMetaMetricsDataModalDesc": { + "message": "我们即将删除您所有的 MetaMetrics 数据。您确定吗?" + }, + "deleteMetaMetricsDataModalTitle": { + "message": "要删除 MetaMetrics 数据吗?" + }, + "deleteMetaMetricsDataRequestedDescription": { + "message": "您在 $1 启动了此操作。此过程可能需要最多 30 天。查看 $2", + "description": "$1 will be the date on which teh deletion is requested and $2 will have text saying Privacy Policy " + }, "deleteNetworkIntro": { "message": "如果您删除此网络,则需要再次添加此网络才能查看您在其中的资产" }, @@ -1418,6 +1567,9 @@ "deposit": { "message": "保证金" }, + "depositCrypto": { + "message": "使用钱包地址或二维码从另一账户存入加密货币。" + }, "deprecatedGoerliNtwrkMsg": { "message": "由于以太坊系统的升级,Goerli 测试网络将很快淘汰。" }, @@ -1459,9 +1611,15 @@ "disconnectAllAccountsText": { "message": "账户" }, + "disconnectAllDescriptionText": { + "message": "如果您断开与此网站的连接,则需要重新连接您的账户和网络才能再次使用此网站。" + }, "disconnectAllSnapsText": { "message": "Snap" }, + "disconnectMessage": { + "message": "这将断开您与此站点的连接" + }, "disconnectPrompt": { "message": "断开连接 $1" }, @@ -1531,6 +1689,9 @@ "editANickname": { "message": "编辑昵称" }, + "editAccounts": { + "message": "编辑账户" + }, "editAddressNickname": { "message": "编辑地址昵称" }, @@ -1611,6 +1772,9 @@ "editNetworkLink": { "message": "编辑原始网络" }, + "editNetworksTitle": { + "message": "编辑网络" + }, "editNonceField": { "message": "编辑 nonce" }, @@ -1620,9 +1784,24 @@ "editPermission": { "message": "编辑权限" }, + "editPermissions": { + "message": "编辑权限" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "编辑加速燃料费用" }, + "editSpendingCap": { + "message": "编辑支出上限" + }, + "editSpendingCapAccountBalance": { + "message": "账户余额:$1 $2" + }, + "editSpendingCapDesc": { + "message": "输入您觉得可以代您花费的适当金额。" + }, + "editSpendingCapError": { + "message": "支出上限不能超过 $1 小数位数。删除小数位数以继续。" + }, "enableAutoDetect": { "message": " 启用自动检测" }, @@ -1674,21 +1853,36 @@ "ensUnknownError": { "message": "ENS 查找失败。" }, + "enterANameToIdentifyTheUrl": { + "message": "输入名称以标识 URL" + }, "enterANumber": { "message": "输入一个数字" }, + "enterChainId": { + "message": "输入链 ID" + }, "enterCustodianToken": { "message": "输入您的 $1 代币或添加新代币" }, "enterMaxSpendLimit": { "message": "输入最大消费限额" }, + "enterNetworkName": { + "message": "输入网络名称" + }, "enterOptionalPassword": { "message": "输入可选密码" }, "enterPasswordContinue": { "message": "输入密码继续" }, + "enterRpcUrl": { + "message": "输入 RPC(远程过程调用)URL" + }, + "enterSymbol": { + "message": "输入符号" + }, "enterTokenNameOrAddress": { "message": "输入代币名称或粘贴地址" }, @@ -1762,6 +1956,15 @@ "experimental": { "message": "实验性" }, + "exportYourData": { + "message": "导出您的数据" + }, + "exportYourDataButton": { + "message": "下载" + }, + "exportYourDataDescription": { + "message": "您可以导出联系人和偏好等数据。" + }, "extendWalletWithSnaps": { "message": "探索社区构建的 Snap,定制您的 Web3 体验", "description": "Banner description displayed on Snaps list page in Settings when less than 6 Snaps is installed." @@ -1877,6 +2080,9 @@ "message": "这笔燃料费是由 $1 建议的。忽略它可能会导致您的交易出现问题。如果您有疑问,请联系 $1。", "description": "$1 represents the Dapp's origin" }, + "gasFee": { + "message": "燃料费" + }, "gasIsETH": { "message": "燃料是 $1 " }, @@ -1947,6 +2153,9 @@ "generalCameraErrorTitle": { "message": "出错了..." }, + "generalDescription": { + "message": "跨设备同步设置、选择网络首选项和跟踪代币数据" + }, "genericExplorerView": { "message": "在$1查看账户" }, @@ -2093,6 +2302,10 @@ "id": { "message": "ID" }, + "ifYouGetLockedOut": { + "message": "如果应用程序锁定导致无法登录或使用新设备,则会失去资金。请务必在 $1 备份您的私钥助记词 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "ignoreAll": { "message": "忽略所有" }, @@ -2174,6 +2387,9 @@ "inYourSettings": { "message": "在设置中" }, + "included": { + "message": "包括" + }, "infuraBlockedNotification": { "message": "MetaMask 无法连接到区块链主机。请检查可能的原因 $1。", "description": "$1 is a clickable link with with text defined by the 'here' key" @@ -2344,6 +2560,10 @@ "message": "JSON 文件", "description": "format for importing an account" }, + "keepReminderOfSRP": { + "message": "将您的私钥助记词提醒存放在安全的地方。如果遗失,没有人能帮您找回。更糟糕的是,您将无法再访问您的钱包。$1", + "description": "$1 is a learn more link" + }, "keyringAccountName": { "message": "账户名称" }, @@ -2402,6 +2622,9 @@ "message": "学习如何 $1", "description": "$1 is link to cancel or speed up transactions" }, + "learnHow": { + "message": "了解操作方法" + }, "learnMore": { "message": "了解更多" }, @@ -2409,6 +2632,9 @@ "message": "想了解有关燃料费的 $1?", "description": "$1 will be replaced by the learnMore translation key" }, + "learnMoreAboutPrivacy": { + "message": "了解有关隐私最佳实践的更多信息。" + }, "learnMoreKeystone": { "message": "了解更多" }, @@ -2497,6 +2723,9 @@ "link": { "message": "链接" }, + "linkCentralizedExchanges": { + "message": "链接您的 Coinbase 或 Binance 账户,将加密货币免费转移到 MetaMask。" + }, "links": { "message": "链接" }, @@ -2557,6 +2786,9 @@ "message": "请确保没有人在看您的屏幕", "description": "Warning to users to be care while creating and saving their new Secret Recovery Phrase" }, + "manageDefaultSettings": { + "message": "管理默认隐私设置" + }, "marketCap": { "message": "市值" }, @@ -2600,6 +2832,9 @@ "metaMaskConnectStatusParagraphTwo": { "message": "连接状态按钮显示所访问的网站是否与您当前选择的账户连接。" }, + "metaMetricsIdNotAvailableError": { + "message": "由于您从未选择过 MetaMetrics,因此此处没有要删除的数据。" + }, "metadataModalSourceTooltip": { "message": "$1 托管于 npm 上,$2 是此 Snap 的唯一标识符。", "description": "$1 is the snap name and $2 is the snap NPM id." @@ -2676,6 +2911,17 @@ "more": { "message": "更多" }, + "moreAccounts": { + "message": "+ 另外 $1 个账户", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ 另外 $1 个网络", + "description": "$1 is the number of networks" + }, + "multichainAddEthereumChainConfirmationDescription": { + "message": "您正在将此网络添加到 MetaMask 并授予该网站其使用许可。" + }, "multipleSnapConnectionWarning": { "message": "$1 想连接 $2 个 snap。", "description": "$1 is the dapp and $2 is the number of snaps it wants to connect to." @@ -2754,9 +3000,13 @@ "message": "编辑网络详情" }, "nativeTokenScamWarningDescription": { - "message": "此网络与其关联的链 ID 或名称不匹配。多种常用代币均使用名称 $1,使其成为欺诈目标。欺诈方可能会诱骗您向其发送更有价值的货币作为回报。在继续之前,请验证所有内容。", + "message": "原生代币符号与具有关联链 ID 网络的原生代币的预期符号不匹配。您输入了 $1,而预期的代币符号为 $2。请验证您是否连接到正确的链。", "description": "$1 represents the currency name, $2 represents the expected currency symbol" }, + "nativeTokenScamWarningDescriptionExpectedTokenFallback": { + "message": "其他", + "description": "graceful fallback for when token symbol isn't found" + }, "nativeTokenScamWarningTitle": { "message": "这可能是欺诈", "description": "Title for nativeTokenScamWarningDescription" @@ -2790,6 +3040,9 @@ "networkDetails": { "message": "网络详情" }, + "networkFee": { + "message": "网络费" + }, "networkIsBusy": { "message": "网络繁忙。燃料价格较高,估值较不准确。" }, @@ -2844,6 +3097,9 @@ "networkOptions": { "message": "网络选项" }, + "networkPermissionToast": { + "message": "网络许可已更新" + }, "networkProvider": { "message": "网络提供商" }, @@ -2865,15 +3121,26 @@ "message": "我们无法连接到 $1", "description": "$1 represents the network name" }, + "networkSwitchMessage": { + "message": "网络已切换至 $1", + "description": "$1 represents the network name" + }, "networkURL": { "message": "网络 URL" }, "networkURLDefinition": { "message": "用于访问此网络的 URL。" }, + "networkUrlErrorWarning": { + "message": "攻击者有时会通过对网站地址进行细微更改来模仿网站。在继续操作之前,请确保您是与目标网站进行交互。Punycode 版本:$1", + "description": "$1 replaced by RPC URL for network" + }, "networks": { "message": "网络" }, + "networksSmallCase": { + "message": "网络" + }, "nevermind": { "message": "没关系" }, @@ -2924,6 +3191,9 @@ "newPrivacyPolicyTitle": { "message": "我们已经更新了隐私政策" }, + "newRpcUrl": { + "message": "新的 RPC(远程过程调用) URL" + }, "newTokensImportedMessage": { "message": "您已成功导入$1。", "description": "$1 is the string of symbols of all the tokens imported" @@ -2986,6 +3256,9 @@ "noConnectedAccountTitle": { "message": "MetaMask 未连接到此站点" }, + "noConnectionDescription": { + "message": "要连接到网站,请找到并选择“连接”按钮。记住,MetaMask 仅限连接到 Web3 上的网站" + }, "noConversionRateAvailable": { "message": "无可用汇率" }, @@ -3031,6 +3304,9 @@ "nonceFieldHeading": { "message": "自定义 nonce" }, + "none": { + "message": "无" + }, "notBusy": { "message": "非忙碌中" }, @@ -3512,6 +3788,12 @@ "permissionDetails": { "message": "许可详情" }, + "permissionFor": { + "message": "权限用于" + }, + "permissionFrom": { + "message": "权限来自" + }, "permissionRequest": { "message": "权限请求" }, @@ -3593,6 +3875,14 @@ "message": "允许 $1 访问您的 MetaMask 首选语言设置。这可用于进行本地化,以及使用您的语言显示$1的内容。", "description": "An extended description for the `snap_getLocale` permission. $1 is the snap name." }, + "permission_getPreferences": { + "message": "查看您的首选语言和法币等信息。", + "description": "The description for the `snap_getPreferences` permission" + }, + "permission_getPreferencesDescription": { + "message": "让 $1 访问 MetaMask 设置中的首选语言和法币等信息。这有助于 $1 显示符合您偏好的内容。 ", + "description": "An extended description for the `snap_getPreferences` permission. $1 is the snap name." + }, "permission_homePage": { "message": "显示自定义屏幕", "description": "The description for the `endowment:page-home` permission" @@ -3751,6 +4041,9 @@ "permitSimulationDetailInfo": { "message": "您将授予该消费者许可从您的账户中支出这些代币。" }, + "permittedChainToastUpdate": { + "message": "$1 可以访问 $2。" + }, "personalAddressDetected": { "message": "检测到个人地址。请输入代币合约地址。" }, @@ -3963,6 +4256,12 @@ "receive": { "message": "收款" }, + "receiveCrypto": { + "message": "接收加密货币" + }, + "recipientAddressPlaceholderNew": { + "message": "输入公钥 (0x) 或域名" + }, "recommendedGasLabel": { "message": "建议" }, @@ -4026,6 +4325,12 @@ "rejected": { "message": "已拒绝" }, + "rememberSRPIfYouLooseAccess": { + "message": "请记住,如果私钥助记词遗失,您将无法访问您的钱包。$1 以保护这组助记词的安全,这样您可以随时访问您的资金。" + }, + "reminderSet": { + "message": "提醒设置完毕!" + }, "remove": { "message": "删除" }, @@ -4105,6 +4410,13 @@ "requestNotVerifiedError": { "message": "由于出现错误,安全提供商没有验证此请求。请谨慎操作。" }, + "requestingFor": { + "message": "请求" + }, + "requestingForAccount": { + "message": "请求 $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "待确认的请求" }, @@ -4193,6 +4505,12 @@ "revealTheSeedPhrase": { "message": "显示助记词" }, + "review": { + "message": "查看" + }, + "reviewAlert": { + "message": "查看提醒" + }, "reviewAlerts": { "message": "查看提醒" }, @@ -4218,6 +4536,9 @@ "revokePermission": { "message": "撤销权限" }, + "revokeSimulationDetailsDesc": { + "message": "您正在从您的账户中删除某人使用代币的权限。" + }, "revokeSpendingCap": { "message": "撤销 $1 的支出上限", "description": "$1 is a token symbol" @@ -4225,6 +4546,9 @@ "revokeSpendingCapTooltipText": { "message": "第三方将无法再使用您当前或未来的任何代币。" }, + "rpcNameOptional": { + "message": "RPC(远程过程调用)名称(可选)" + }, "rpcUrl": { "message": "新的 RPC URL" }, @@ -4277,10 +4601,23 @@ "securityAndPrivacy": { "message": "安全和隐私" }, + "securityDescription": { + "message": "减少您加入不安全网络的机会并保护您的账户" + }, + "securityMessageLinkForNetworks": { + "message": "网络欺诈和安全风险" + }, + "securityPrivacyPath": { + "message": "设置 > 安全与隐私。" + }, "securityProviderPoweredBy": { "message": "由 $1 提供支持", "description": "The security provider that is providing data" }, + "seeAllPermissions": { + "message": "查看所有许可", + "description": "Used for revealing more content (e.g. permission list, etc.)" + }, "seeDetails": { "message": "查看详情" }, @@ -4374,6 +4711,9 @@ "selectPathHelp": { "message": "如果您没有看到您期望的账户,请尝试切换 HD 路径。" }, + "selectRpcUrl": { + "message": "选择 RPC(远程过程调用)URL" + }, "selectType": { "message": "选择类型" }, @@ -4436,6 +4776,9 @@ "setApprovalForAll": { "message": "设置批准所有" }, + "setApprovalForAllRedesignedTitle": { + "message": "提取请求" + }, "setApprovalForAllTitle": { "message": "批准$1,且无消费限制", "description": "The token symbol that is being approved" @@ -4446,6 +4789,9 @@ "settings": { "message": "设置" }, + "settingsOptimisedForEaseOfUseAndSecurity": { + "message": "设置经过优化,更具易用性和安全性。可随时对此进行更改。" + }, "settingsSearchMatchingNotFound": { "message": "没有找到匹配的结果." }, @@ -4492,6 +4838,9 @@ "showMore": { "message": "展开" }, + "showNativeTokenAsMainBalance": { + "message": "将原生代币显示为主余额" + }, "showNft": { "message": "显示 NFT" }, @@ -4531,6 +4880,15 @@ "signingInWith": { "message": "使用以下登录方式" }, + "simulationApproveHeading": { + "message": "提取" + }, + "simulationDetailsApproveDesc": { + "message": "您正在授予其他人从您的账户中提取 NFT 的许可。" + }, + "simulationDetailsERC20ApproveDesc": { + "message": "您正在授予其他人从您的账户中花费该金额的权限。" + }, "simulationDetailsFiatNotAvailable": { "message": "不可用" }, @@ -4540,6 +4898,12 @@ "simulationDetailsOutgoingHeading": { "message": "您发送" }, + "simulationDetailsRevokeSetApprovalForAllDesc": { + "message": "您正在删除其他人从您的账户中提取 NFT 的权限。" + }, + "simulationDetailsSetApprovalForAllDesc": { + "message": "您正在授予其他人从您的账户中提取 NFT 的权限。" + }, "simulationDetailsTitle": { "message": "预计变化" }, @@ -4792,6 +5156,16 @@ "somethingWentWrong": { "message": "哎呀!出了点问题。" }, + "sortBy": { + "message": "排序方式" + }, + "sortByAlphabetically": { + "message": "按字母顺序排列(A-Z)" + }, + "sortByDecliningBalance": { + "message": "余额降序($1 高-低)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "来源" }, @@ -4835,6 +5209,12 @@ "spender": { "message": "消费者" }, + "spenderTooltipDesc": { + "message": "这是可以提取您的 NFT 的地址。" + }, + "spenderTooltipERC20ApproveDesc": { + "message": "这是能够代表您花费您的代币的地址。" + }, "spendingCap": { "message": "支出上限" }, @@ -4848,6 +5228,9 @@ "spendingCapRequest": { "message": "$1的支出上限请求" }, + "spendingCapTooltipDesc": { + "message": "他人可代表您访问该代币金额并进行消费。" + }, "srpInputNumberOfWords": { "message": "我有一个包含$1个单词的私钥助记词", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." @@ -5051,6 +5434,9 @@ "message": "由 $1 建议", "description": "$1 is the snap name" }, + "suggestedCurrencySymbol": { + "message": "建议的货币符号:" + }, "suggestedTokenName": { "message": "建议名称:" }, @@ -5060,6 +5446,9 @@ "supportCenter": { "message": "访问我们的支持中心" }, + "supportMultiRpcInformation": { + "message": "我们现在支持单个网络的多个 RPC(远程过程调用)。您最近的 RPC 已被选为默认 RPC,以解决冲突信息。" + }, "surveyConversion": { "message": "参与我们的调查" }, @@ -5176,6 +5565,14 @@ "swapGasFeesDetails": { "message": "燃料费用是估算的,并将根据网络流量和交易复杂性而波动。" }, + "swapGasFeesExplanation": { + "message": "MetaMask 不从燃料费中赚钱。这些费用是估计值,可能会根据网络的繁忙程度和交易的复杂程度而变化。了解更多信息 $1。", + "description": "$1 is a link (text in link can be found at 'swapGasFeesSummaryLinkText')" + }, + "swapGasFeesExplanationLinkText": { + "message": "参见此处", + "description": "Text for link in swapGasFeesExplanation" + }, "swapGasFeesLearnMore": { "message": "了解更多关于燃料费的信息" }, @@ -5186,9 +5583,19 @@ "message": "燃料费用支付给在 $1 网络上处理交易的加密矿工。MetaMask 不会从燃料费用中获利。", "description": "$1 is the selected network, e.g. Ethereum or BSC" }, + "swapGasIncludedTooltipExplanation": { + "message": "此报价通过调整发送或接收的代币金额将燃料费包含在内。您可能会在活动列表上的单独交易中收到 ETH。" + }, + "swapGasIncludedTooltipExplanationLinkText": { + "message": "了解更多有关燃料费的信息" + }, "swapHighSlippage": { "message": "高滑点" }, + "swapIncludesGasAndMetaMaskFee": { + "message": "包括燃料和 $1% 的 MetaMask 费用", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, "swapIncludesMMFee": { "message": "包括 $1% 的 MetaMask 费用。", "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." @@ -5485,6 +5892,9 @@ "termsOfUseTitle": { "message": "我们的使用条款已更新" }, + "testnets": { + "message": "测试网" + }, "theme": { "message": "主题" }, @@ -5666,6 +6076,9 @@ "transactionFee": { "message": "交易费用" }, + "transactionFlowNetwork": { + "message": "网络" + }, "transactionHistoryBaseFee": { "message": "基础费用(GWEI)" }, @@ -5708,9 +6121,15 @@ "transfer": { "message": "转账" }, + "transferCrypto": { + "message": "转移加密货币" + }, "transferFrom": { "message": "转账自" }, + "transferRequest": { + "message": "转账请求" + }, "trillionAbbreviation": { "message": "万亿", "description": "Shortened form of 'trillion'" @@ -5844,12 +6263,22 @@ "update": { "message": "更新" }, + "updateEthereumChainConfirmationDescription": { + "message": "此网站正在请求更新您的默认网络 URL。您可以随时编辑默认值和网络信息。" + }, + "updateNetworkConfirmationTitle": { + "message": "更新 $1", + "description": "$1 represents network name" + }, "updateOrEditNetworkInformations": { "message": "更新您的信息或者" }, "updateRequest": { "message": "更新请求" }, + "updatedRpcForNetworks": { + "message": "网络 RPC(远程过程调用)已更新" + }, "uploadDropFile": { "message": "将您的文件放在此处" }, @@ -5974,6 +6403,10 @@ "walletConnectionGuide": { "message": "我们的硬件钱包连接指南" }, + "walletProtectedAndReadyToUse": { + "message": "您的钱包受到保护,可随时使用。您可在 $1 找到私钥助记词 ", + "description": "$1 is the menu path to be shown with font weight bold" + }, "wantToAddThisNetwork": { "message": "想要添加此网络吗?" }, @@ -5991,6 +6424,17 @@ "message": "$1 第三方可能会支出您的全部代币余额,无需进一步通知或同意。请自定义较低的支出上限以保护自己。", "description": "$1 is a warning icon with text 'Be careful' in 'warning' colour" }, + "watchEthereumAccountsDescription": { + "message": "启用此选项后,您将能够通过公共地址或 ENS(Ethereum 域名服务)名称查看以太坊账户。如需有关此测试功能的反馈,请完成此 $1。", + "description": "$1 is the link to a product feedback form" + }, + "watchEthereumAccountsToggle": { + "message": "查看以太坊账户(测试版)" + }, + "watchOutMessage": { + "message": "小心 $1。", + "description": "$1 is a link with text that is provided by the 'securityMessageLinkForNetworks' key" + }, "weak": { "message": "弱" }, @@ -6037,6 +6481,9 @@ "whatsThis": { "message": "这是什么?" }, + "withdrawing": { + "message": "提取" + }, "wrongNetworkName": { "message": "根据我们的记录,该网络名称可能与此链 ID 不匹配。" }, @@ -6065,6 +6512,9 @@ "yourBalance": { "message": "您的余额" }, + "yourBalanceIsAggregated": { + "message": "您的余额已汇总" + }, "yourNFTmayBeAtRisk": { "message": "您的 NFT 可能面临风险" }, @@ -6077,6 +6527,9 @@ "yourTransactionJustConfirmed": { "message": "在区块链上确认之前,我们无法取消您的交易。" }, + "yourWalletIsReady": { + "message": "您的钱包已准备就绪" + }, "zeroGasPriceOnSpeedUpError": { "message": "加速时零燃料价格" } From 095e2eb3e5655dc5a343907d29b68e60e46e5c9b Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 22 Nov 2024 05:58:31 +0530 Subject: [PATCH 041/148] feat: display native values returned from decoding api (#28374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** display native values returned from decoding api ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3617 ## **Manual testing steps** 1. Enable signature decoding api 2. On test dapp submit permit request 3. Check simulation section ## **Screenshots/Recordings** Screenshot 2024-11-08 at 10 21 09 AM For NFT collection: Screenshot 2024-11-11 at 6 33 54 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. --------- Co-authored-by: MetaMask Bot Co-authored-by: digiwand <20778143+digiwand@users.noreply.github.com> --- .../expandable-row.test.tsx.snap | 2 +- .../info/row/__snapshots__/row.test.tsx.snap | 4 +- .../__snapshots__/alert-row.test.tsx.snap | 2 +- ui/components/app/confirm/info/row/row.tsx | 2 +- .../info/__snapshots__/info.test.tsx.snap | 62 ++++----- .../__snapshots__/approve.test.tsx.snap | 30 ++--- .../approve-details.test.tsx.snap | 4 +- .../approve-static-simulation.test.tsx.snap | 4 +- .../__snapshots__/spending-cap.test.tsx.snap | 4 +- .../base-transaction-info.test.tsx.snap | 10 +- .../native-transfer.test.tsx.snap | 10 +- .../nft-token-transfer.test.tsx.snap | 10 +- .../__snapshots__/personal-sign.test.tsx.snap | 4 +- .../set-approval-for-all-info.test.tsx.snap | 10 +- ...al-for-all-static-simulation.test.tsx.snap | 6 +- ...al-for-all-static-simulation.test.tsx.snap | 4 +- .../advanced-details.test.tsx.snap | 8 +- .../edit-gas-fees-row.test.tsx.snap | 2 +- .../gas-fees-details.test.tsx.snap | 4 +- .../__snapshots__/gas-fees-row.test.tsx.snap | 2 +- .../gas-fees-section.test.tsx.snap | 4 +- .../transaction-data.test.tsx.snap | 96 +++++++------- .../transaction-details.test.tsx.snap | 4 +- .../token-details-section.test.tsx.snap | 4 +- .../token-transfer.test.tsx.snap | 10 +- .../__snapshots__/typed-sign-v1.test.tsx.snap | 6 +- .../__snapshots__/typed-sign.test.tsx.snap | 114 ++++++++--------- .../permit-simulation.test.tsx.snap | 16 +-- .../decoded-simulation.test.tsx.snap | 24 ++-- .../decoded-simulation/decoded-simulation.tsx | 33 +++-- .../default-simulation.test.tsx.snap | 14 ++- .../native-value-display.test.tsx | 20 +++ .../native-value-display.tsx | 118 ++++++++++++++++++ .../__snapshots__/value-display.test.tsx.snap | 3 +- .../value-display/value-display.tsx | 4 +- .../components/confirm/info/utils.ts | 18 +++ .../row/__snapshots__/dataTree.test.tsx.snap | 56 ++++----- .../typedSignDataV1.test.tsx.snap | 4 +- .../__snapshots__/typedSignData.test.tsx.snap | 30 ++--- .../__snapshots__/confirm.test.tsx.snap | 106 ++++++++-------- 40 files changed, 521 insertions(+), 347 deletions(-) create mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.test.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx diff --git a/ui/components/app/confirm/info/row/__snapshots__/expandable-row.test.tsx.snap b/ui/components/app/confirm/info/row/__snapshots__/expandable-row.test.tsx.snap index 3559558bc2cb..6db2cb117876 100644 --- a/ui/components/app/confirm/info/row/__snapshots__/expandable-row.test.tsx.snap +++ b/ui/components/app/confirm/info/row/__snapshots__/expandable-row.test.tsx.snap @@ -3,7 +3,7 @@ exports[`ConfirmInfoExpandableRow should match snapshot 1`] = `
@@ -707,7 +707,7 @@ exports[` renders component for approve request 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -799,7 +799,7 @@ exports[` renders component for approve request 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/approve/approve-details/__snapshots__/approve-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/approve/approve-details/__snapshots__/approve-details.test.tsx.snap index 17d04e237fb2..027fc5c8b074 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/approve-details/__snapshots__/approve-details.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/approve/approve-details/__snapshots__/approve-details.test.tsx.snap @@ -11,7 +11,7 @@ exports[` renders component for approve details 1`] = ` data-testid="advanced-details-data-section" >
renders component for approve details for setApprova data-testid="advanced-details-data-section" >
renders component 1`] = ` data-testid="confirmation__simulation_section" >
renders component 1`] = `
renders component 1`] = ` data-testid="confirmation__approve-spending-cap-section" >
renders component 1`] = ` style="height: 1px; margin-left: -8px; margin-right: -8px;" />
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 47d28a0ba29d..d7054050f710 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,7 +15,7 @@ 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" >
renders component for contract interaction requ data-testid="transaction-details-section" >
@@ -142,7 +142,7 @@ exports[` renders component for contract interaction requ
@@ -239,7 +239,7 @@ exports[` renders component for contract interaction requ data-testid="gas-fee-section" >
@@ -303,7 +303,7 @@ exports[` renders component for contract interaction requ
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 index 7a488072fd42..dcfe4ff35624 100644 --- 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 @@ -27,7 +27,7 @@ exports[`NativeTransferInfo renders correctly 1`] = ` class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
@@ -301,7 +301,7 @@ exports[`NativeTransferInfo renders correctly 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap index 3ff774c6770b..e835130ff73c 100644 --- a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap @@ -55,7 +55,7 @@ exports[`NFTTokenTransferInfo renders correctly 1`] = ` class="mm-box mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between mm-box--align-items-center" >
@@ -329,7 +329,7 @@ exports[`NFTTokenTransferInfo renders correctly 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/__snapshots__/personal-sign.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/personal-sign/__snapshots__/personal-sign.test.tsx.snap index 92ac3628363e..8932bc3bb41d 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/__snapshots__/personal-sign.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/__snapshots__/personal-sign.test.tsx.snap @@ -6,7 +6,7 @@ exports[`PersonalSignInfo handle reverse string properly 1`] = ` class="mm-box mm-box--margin-bottom-4 mm-box--padding-2 mm-box--background-color-background-default mm-box--rounded-md" >
renders component for approve request 1`] = ` data-testid="confirmation__simulation_section" >
renders component for approve request 1`] = `
renders component for approve request 1`] = ` data-testid="confirmation__approve-details" >
@@ -160,7 +160,7 @@ exports[` renders component for approve request 1`] = ` data-testid="gas-fee-section" >
@@ -224,7 +224,7 @@ exports[` renders component for approve request 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/revoke-set-approval-for-all-static-simulation/__snapshots__/revoke-set-approval-for-all-static-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/revoke-set-approval-for-all-static-simulation/__snapshots__/revoke-set-approval-for-all-static-simulation.test.tsx.snap index 8c94061320fe..abe3dca0bbc6 100644 --- a/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/revoke-set-approval-for-all-static-simulation/__snapshots__/revoke-set-approval-for-all-static-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/revoke-set-approval-for-all-static-simulation/__snapshots__/revoke-set-approval-for-all-static-simulation.test.tsx.snap @@ -7,7 +7,7 @@ exports[` renders component for setAp data-testid="confirmation__simulation_section" >
renders component for setAp
renders component for setAp
renders component for approve req data-testid="confirmation__simulation_section" >
renders component for approve req
renders component when the prop override is passed data-testid="advanced-details-nonce-section" >
renders component when the prop override is passed data-testid="advanced-details-data-section" >
renders component when the state property is true 1 data-testid="advanced-details-nonce-section" >
renders component when the state property is true 1 data-testid="advanced-details-data-section" >
renders component 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-details/__snapshots__/gas-fees-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-details/__snapshots__/gas-fees-details.test.tsx.snap index 4941dcd656d9..8cc9a9c3bfb5 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-details/__snapshots__/gas-fees-details.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-details/__snapshots__/gas-fees-details.test.tsx.snap @@ -3,7 +3,7 @@ exports[` renders component for gas fees section 1`] = `
@@ -67,7 +67,7 @@ exports[` renders component for gas fees section 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/__snapshots__/gas-fees-row.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/__snapshots__/gas-fees-row.test.tsx.snap index 5de2d2361b38..94432ffdf196 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/__snapshots__/gas-fees-row.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/__snapshots__/gas-fees-row.test.tsx.snap @@ -3,7 +3,7 @@ exports[` renders component 1`] = `
renders component for gas fees section 1`] = ` data-testid="gas-fee-section" >
@@ -73,7 +73,7 @@ exports[` renders component for gas fees section 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap index cc66da7a1375..99732c7e3410 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap @@ -7,7 +7,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] = data-testid="advanced-details-data-section" >
@@ -146,7 +146,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -197,7 +197,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] = style="height: 1px; margin-left: -8px; margin-right: -8px;" />
@@ -305,7 +305,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -350,7 +350,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -395,7 +395,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -517,7 +517,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -568,7 +568,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] = style="height: 1px; margin-left: -8px; margin-right: -8px;" />
@@ -676,7 +676,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -732,7 +732,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -783,7 +783,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] = style="height: 1px; margin-left: -8px; margin-right: -8px;" />
@@ -891,7 +891,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -947,7 +947,7 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
@@ -1005,7 +1005,7 @@ exports[`TransactionData renders decoded data with no names 1`] = ` data-testid="advanced-details-data-section" >
@@ -1068,7 +1068,7 @@ exports[`TransactionData renders decoded data with no names 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1113,7 +1113,7 @@ exports[`TransactionData renders decoded data with no names 1`] = `
@@ -1169,7 +1169,7 @@ exports[`TransactionData renders decoded data with no names 1`] = `
@@ -1225,7 +1225,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` data-testid="advanced-details-data-section" >
@@ -1304,7 +1304,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1360,7 +1360,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1398,7 +1398,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1436,7 +1436,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1474,7 +1474,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1530,7 +1530,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1575,7 +1575,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1620,7 +1620,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1666,7 +1666,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1704,7 +1704,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1760,7 +1760,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1805,7 +1805,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1850,7 +1850,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1896,7 +1896,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -1934,7 +1934,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = ` class="mm-box mm-box--padding-left-2" >
@@ -1990,7 +1990,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2035,7 +2035,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2080,7 +2080,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2127,7 +2127,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2183,7 +2183,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2229,7 +2229,7 @@ exports[`TransactionData renders decoded data with tuples and arrays 1`] = `
@@ -2287,7 +2287,7 @@ exports[`TransactionData renders raw hexadecimal if no decoded data 1`] = ` data-testid="advanced-details-data-section" >
renders component for transaction details 1`] = data-testid="transaction-details-section" >
@@ -60,7 +60,7 @@ exports[` renders component for transaction details 1`] =
diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap index dcedd06b1557..6ab7ebb270b7 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap @@ -7,7 +7,7 @@ exports[`TokenDetailsSection renders correctly 1`] = ` data-testid="confirmation__transaction-flow" >
@@ -330,7 +330,7 @@ exports[`TokenTransferInfo renders correctly 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap index d4b07b83a269..e80d317f574b 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap @@ -6,7 +6,7 @@ exports[`TypedSignInfo correctly renders typed sign data request 1`] = ` class="mm-box mm-box--margin-bottom-4 mm-box--padding-2 mm-box--background-color-background-default mm-box--rounded-md" >
-

- Spending cap -

+
+

+ Spending cap +

+
+
, @@ -44,17 +38,10 @@ const StateChangeRow = ({ const { assetType, changeType, amount, contractAddress, tokenID } = stateChange; return ( - - - {getStateChangeLabelMap(t, changeType)} - + {(assetType === TokenStandard.ERC20 || assetType === TokenStandard.ERC721) && ( - )} - + {assetType === 'NATIVE' && ( + + )} + ); }; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap index a378d0befcc4..2b6dee2b8c0c 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap @@ -7,7 +7,7 @@ exports[`DefaultSimulation renders component correctly 1`] = ` data-testid="confirmation__simulation_section" >
{ + it('renders component correctly', async () => { + const mockStore = configureMockStore([])(mockState); + + const { findByText } = renderWithProvider( + , + mockStore, + ); + + expect(await findByText('<0.000001')).toBeInTheDocument(); + expect(await findByText('ETH')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx new file mode 100644 index 000000000000..b198680f4e96 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx @@ -0,0 +1,118 @@ +import { BigNumber } from 'bignumber.js'; +import { Hex } from '@metamask/utils'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { TokenStandard } from '../../../../../../../../../shared/constants/transaction'; +import { calcTokenAmount } from '../../../../../../../../../shared/lib/transactions-controller-utils'; +import { + Box, + Text, +} from '../../../../../../../../components/component-library'; +import { + BlockSize, + BorderRadius, + Display, + JustifyContent, + TextAlign, +} from '../../../../../../../../helpers/constants/design-system'; +import Tooltip from '../../../../../../../../components/ui/tooltip'; +import { shortenString } from '../../../../../../../../helpers/utils/util'; +import { selectConversionRateByChainId } from '../../../../../../../../selectors'; +import { AssetPill } from '../../../../../simulation-details/asset-pill'; +import { + formatAmount, + formatAmountMaxPrecision, +} from '../../../../../simulation-details/formatAmount'; +import { IndividualFiatDisplay } from '../../../../../simulation-details/fiat-display'; +import { getAmountColors } from '../../../utils'; + +const NATIVE_DECIMALS = 18; + +type PermitSimulationValueDisplayParams = { + /** ID of the associated chain. */ + chainId: Hex; + + /** The token amount */ + value: number | string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; +}; + +const NativeValueDisplay: React.FC = ({ + chainId, + value, + credit, + debit, +}) => { + const conversionRate = useSelector((state) => + selectConversionRateByChainId(state, chainId), + ); + + const { fiatValue, tokenValue, tokenValueMaxPrecision } = useMemo(() => { + if (!value) { + return { tokenValue: null, tokenValueMaxPrecision: null }; + } + + const tokenAmount = calcTokenAmount(value, NATIVE_DECIMALS); + + return { + fiatValue: conversionRate + ? new BigNumber(tokenAmount).times(String(conversionRate)).toNumber() + : undefined, + tokenValue: formatAmount('en-US', tokenAmount), + tokenValueMaxPrecision: formatAmountMaxPrecision('en-US', tokenAmount), + }; + }, [conversionRate, value]); + + const { color, backgroundColor } = getAmountColors(credit, debit); + + return ( + + + + + + {credit && '+ '} + {debit && '- '} + {tokenValue !== null && + shortenString(tokenValue || '', { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + })} + + + + + + + {fiatValue && } + + + ); +}; + +export default NativeValueDisplay; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap index 26def806c6fa..37e3a25ad5b6 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap @@ -3,7 +3,8 @@ exports[`PermitSimulationValueDisplay renders component correctly 1`] = `
+ + {credit && '+ '} + {debit && '- '} {tokenValue !== null && shortenString(tokenValue || '', { truncatedCharLimit: 15, diff --git a/ui/pages/confirmations/components/confirm/info/utils.ts b/ui/pages/confirmations/components/confirm/info/utils.ts index a80d604fed83..0ad8479ac7b9 100644 --- a/ui/pages/confirmations/components/confirm/info/utils.ts +++ b/ui/pages/confirmations/components/confirm/info/utils.ts @@ -1,4 +1,8 @@ import { DecodedTransactionDataResponse } from '../../../../../../shared/types/transaction-decode'; +import { + BackgroundColor, + TextColor, +} from '../../../../../helpers/constants/design-system'; export function getIsRevokeSetApprovalForAll( value: DecodedTransactionDataResponse | undefined, @@ -9,3 +13,17 @@ export function getIsRevokeSetApprovalForAll( return isRevokeSetApprovalForAll; } + +export const getAmountColors = (credit?: boolean, debit?: boolean) => { + let color = TextColor.textDefault; + let backgroundColor = BackgroundColor.backgroundAlternative; + + if (credit) { + color = TextColor.successDefault; + backgroundColor = BackgroundColor.successMuted; + } else if (debit) { + color = TextColor.errorDefault; + backgroundColor = BackgroundColor.errorMuted; + } + return { color, backgroundColor }; +}; diff --git a/ui/pages/confirmations/components/confirm/row/__snapshots__/dataTree.test.tsx.snap b/ui/pages/confirmations/components/confirm/row/__snapshots__/dataTree.test.tsx.snap index 68d8aab887be..783f16274d19 100644 --- a/ui/pages/confirmations/components/confirm/row/__snapshots__/dataTree.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/row/__snapshots__/dataTree.test.tsx.snap @@ -6,7 +6,7 @@ exports[`DataTree correctly renders reverse strings 1`] = ` class="mm-box mm-box--width-full" >
Date: Fri, 22 Nov 2024 07:32:30 +0100 Subject: [PATCH 042/148] chore: change e2e quality gate reruns for new/changed tests from 5 to 4 (#28611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** To reduce ci usage. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28611?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. --- test/e2e/run-all.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/run-all.js b/test/e2e/run-all.js index a0e5ee9d54e5..b1b75d8864f6 100644 --- a/test/e2e/run-all.js +++ b/test/e2e/run-all.js @@ -32,7 +32,7 @@ const getTestPathsForTestDir = async (testDir) => { }; // Quality Gate Retries -const RETRIES_FOR_NEW_OR_CHANGED_TESTS = 5; +const RETRIES_FOR_NEW_OR_CHANGED_TESTS = 4; /** * Runs the quality gate logic to filter and append changed or new tests if present. From b6613df0681c91dded70fc976ec8073190cc7b1b Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:45:12 +0100 Subject: [PATCH 043/148] chore: rerun workflow from failed (#28143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds a new workflow in our circle ci config, called `rerun-from-failed` which does the following: 1. It gets the last 20 circle ci workflows from develop branch 2. It assesses if any of the workflows needs to be rerun. The conditions for a rerun are: a. The workflow has only been run once (not retried, or run multiple times, no matter its final status), this is to spare some credits and avoid re-runing multiple times the same jobs, but we could change this, and allow 2 runs instead of 1 for example, if we see that it's needed. b. The workflow is completed and has the status of `failed` c. The workflow runs in develop branch c. The workflow was triggered by the merge queue bot. This means that we won't rerun scheduled workflows (like the nightly ones). It didn't seem necessary to re-run those, but we can remove the filter, if we want 3. It reruns `from_failed` the workflows that have the conditions mentioned above. Note: the circle ci API does not support the `rerun_failed_tests` feature This new workflow can be scheduled by circle ci UI panel, and we can choose on which frequency we want it to run. Possibly once every hour (only Mon-Friday), but that's totally customizable from the UI. Our usage falls within the API limits, which are 51 requests per second per endpoint. In our case we will be doing: - 1 GET to get all workflows - 20 GET to get each workflow status - X POST (a max of 20) to rerun the corresponding failed jobs everytime we run the re-run-workflow. ### Implementation A few words around the implementation of this setup: - This setup uses the API token set in `process.env.API_V2_TOKEN` for authenticating the circle ci requests - This new workflow can be scheduled to be run once a day, twice etc.. depending on our needs, also from the circle ci ui, with the name `rerun-from-failed` - This new workflow can be enabled and disabled from the circle ci ui, just by removing the scheduled job The initial idea of adding a rerun logic embedded inside the test_and_release, and re-run right after, poses some challenges and that's why making a decoupled workflow and automate that by scheduling seems to solve those better. One issue is, how to make sure that we are not rerunning from failed forever. That might need additional logic and complexity for tracking the reruns for that specific workflow (possibly creating more artifacts and reading them) into the current workflow. Another issue is how to ensure that the workflow has finished (no matter if failed or successful) to then apply the rerun if needed: - if we used the `required` keyword, for making the rerun job the last one, that wouldn't serve us, as it would only be run if all jobs were successful (which doesn't solve our task) - we could run a job with a timer with ~30mins, so this would make sure that the workflow has finished (no matter, if failed or not) and then could rerun from failed calling the API. That would add additional resources to circle ci though - we could add a trigger if job fails `on_fail` to then trigger the rerun logic, but this would cancel ongoing parallel jobs, and it's not desired as we discussed - we could make that each job writes into an artifact their result, but the challenge again comes on when to trigger the read action to that file I found that decoupling the rerun and relying on their API could benefit in both challenges, as well as doesn't pollute the current ci config, making it a totally independent workflow, that can be customized by the UI. It also allow us to use more customizable rules, by accessing the state and number of runs of each workflow in a straight forward manner. Happy to discuss further though :) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28143?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/25955 ## **Manual testing steps** 1. Check successful ci run for this new job (which in this example, it rerun 1 workflow from failed, successfully): https://app.circleci.com/pipelines/github/MetaMask/metamask-extension/110689/workflows/9ac7aaee-2610-4985-952d-6bd4f747c071/jobs/4141314 2. Create a branch of out this branch, and remove the filters in the config.yml file, so the new workflow is run. You can then check the result in circle ci ## **Screenshots/Recordings** See pipeline here: https://app.circleci.com/pipelines/github/MetaMask/metamask-extension/110689/workflows/9ac7aaee-2610-4985-952d-6bd4f747c071/jobs/4141314 It fetched 20 last workflows from develop, from those, it got it status, and [rerun only on workflow which complied](https://app.circleci.com/pipelines/github/MetaMask/metamask-extension/110625/workflows/af8d5617-a0bb-4d35-8b4f-72ac9812ccbb/jobs/4141316) with all requirements (not being rerun before, and with status failed) ![Screenshot from 2024-11-13 19-19-19](https://github.com/user-attachments/assets/49170b94-f077-4f45-8aff-61dc0c9855df) ## **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: Mark Stacey --- .circleci/config.yml | 24 ++ .../scripts/rerun-ci-workflow-from-failed.ts | 211 ++++++++++++++++++ package.json | 3 +- 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 .circleci/scripts/rerun-ci-workflow-from-failed.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index a8185c00ee2f..1b4242570daf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -356,6 +356,19 @@ workflows: requires: - prep-build-ts-migration-dashboard + rerun-from-failed: + when: + condition: + equal: ["<< pipeline.schedule.name >>", "rerun-from-failed"] + jobs: + - prep-deps + - rerun-workflows-from-failed: + filters: + branches: + only: develop + requires: + - prep-deps + locales_only: when: matches: @@ -930,6 +943,17 @@ jobs: paths: - development/ts-migration-dashboard/build/final + rerun-workflows-from-failed: + executor: node-browsers-small + steps: + - run: *shallow-git-clone-and-enable-vnc + - run: sudo corepack enable + - attach_workspace: + at: . + - run: + name: Rerun workflows from failed + command: yarn ci-rerun-from-failed + test-yarn-dedupe: executor: node-browsers-small steps: diff --git a/.circleci/scripts/rerun-ci-workflow-from-failed.ts b/.circleci/scripts/rerun-ci-workflow-from-failed.ts new file mode 100644 index 000000000000..84827f11fd13 --- /dev/null +++ b/.circleci/scripts/rerun-ci-workflow-from-failed.ts @@ -0,0 +1,211 @@ +const CIRCLE_TOKEN = process.env.API_V2_TOKEN; + +interface Actor { + login: string; + avatar_url: string | null; +} + +interface Trigger { + received_at: string; + type: string; + actor: Actor; +} + +interface VCS { + origin_repository_url: string; + target_repository_url: string; + revision: string; + provider_name: string; + branch: string; +} + +interface WorkflowItem { + id: string; + errors: string[]; + project_slug: string; + updated_at: string; + number: number; + state: string; + created_at: string; + trigger: Trigger; + vcs: VCS; +} + +interface CircleCIResponse { + next_page_token: string | null; + items: WorkflowItem[]; +} + +interface WorkflowStatusItem { + pipeline_id: string; + id: string; + name: string; + project_slug: string; + tag?: string; + status: string; + started_by: string; + pipeline_number: number; + created_at: string; + stopped_at: string; +} + +interface WorkflowStatusResponse { + next_page_token: string | null; + items: WorkflowStatusItem[]; +} + +/** + * Fetches the last 20 CircleCI workflows for the given branch. + * Note: the API returns the first 20 workflows by default. + * If we wanted to get older workflows, we would need to use the 'page-token' we would get in the first response + * and perform a subsequent request with the 'page-token' parameter. + * This seems unnecessary as of today, as the amount of daily PRs merged to develop is not that high. + * + * @returns {Promise} A promise that resolves to an array of workflow items. + * @throws Will throw an error if the CircleCI token is not defined or if the HTTP request fails. + */ +async function getCircleCiWorkflowsByBranch(branch: string): Promise { + if (!CIRCLE_TOKEN) { + throw new Error('CircleCI token is not defined'); + } + + const url = `https://circleci.com/api/v2/project/github/${process.env.CIRCLE_PROJECT_USERNAME}/${process.env.CIRCLE_PROJECT_REPONAME}/pipeline?branch=${branch}`; + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }; + + try { + const response = await fetch(url, options); + if (!response.ok) { + const errorBody = await response.text(); + console.error('HTTP error response:', errorBody); + throw new Error(`HTTP error! status: ${response.status}`); + } + const body = await response.json(); + console.log('Circle Ci workflows fetched successfully!'); + return body.items; + } catch (error) { + console.error('Error:', error); + throw error; + } +} + +/** + * Fetches the status of a specific CircleCI workflow. + * + * @param {string} workflowId - The ID of the workflow to fetch the status for. + * @returns {Promise} A promise that resolves to the workflow status response. + * @throws Will throw an error if the CircleCI token is not defined or if the HTTP request fails. + */ +async function getWorkflowStatusById(workflowId: string): Promise { + if (!CIRCLE_TOKEN) { + throw new Error('CircleCI token is not defined'); + } + + const url = `https://circleci.com/api/v2/pipeline/${workflowId}/workflow`; + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }; + + try { + console.log(`Fetching workflow ${workflowId}...`); + + const response = await fetch(url, options); + if (!response.ok) { + const errorBody = await response.text(); + console.error('HTTP error response:', errorBody); + throw new Error(`HTTP error! status: ${response.status}`); + } + const workflowStatus = await response.json(); + + console.log(`Number of runs: ${workflowStatus.items.length}`); + console.log(`Workflow status from last run: ${workflowStatus.items[0].status}`); + + return workflowStatus; + + } catch (error) { + console.error('Error:', error); + throw error; + } +} + +/** + * Reruns a CircleCI workflow by its ID. + * + * @param {string} workflowId - The ID of the workflow to rerun. + * @throws Will throw an error if the CircleCI token is not defined or if the HTTP request fails. + */ +async function rerunWorkflowById(workflowId: string) { + if (!CIRCLE_TOKEN) { + throw new Error('CircleCI token is not defined'); + } + + const url = `https://circleci.com/api/v2/workflow/${workflowId}/rerun`; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Circle-Token': CIRCLE_TOKEN, + }, + body: JSON.stringify({ + enable_ssh: false, + from_failed: true, + sparse_tree: false, // mutually exclusive with the from_failed parameter + }) + }; + + try { + console.log(`Rerunning workflow ${workflowId}...`); + const response = await fetch(url, options); + if (!response.ok) { + const errorBody = await response.text(); + console.error('HTTP error response:', errorBody); + throw new Error(`HTTP error! status: ${response.status}`); + } + const body = await response.json(); + console.log('Workflow rerun successfully!'); + console.log(body); + } catch (error) { + console.error('Error:', error); + } +} + +/** + * Re-runs failed CircleCI workflows from develop branch. + * The workflow will only be re-runed if: + * 1. It has the status of 'failed' + * 2. It has only been run once + * 3. It is among the most recent 20 workflows + * 4. It was triggered by the 'github-merge-queue[bot]' user + * + * @throws Will throw an error if fetching the workflows or re-running a workflow fails. + */ +async function rerunFailedWorkflowsFromDevelop() { + console.log('Getting Circle Ci workflows from develop branch...'); + const workflows = await getCircleCiWorkflowsByBranch('develop'); + + console.log('Assessing if any of the workflows needs to be rerun...'); + for (const item of workflows) { + if (item.trigger.actor.login === 'github-merge-queue[bot]') { + const workflowStatus = await getWorkflowStatusById(item.id); + + if (workflowStatus.items.length === 1 && workflowStatus.items[0].status === 'failed') { + await rerunWorkflowById(workflowStatus.items[0].id); + console.log(`Rerun workflow with ID: ${workflowStatus.items[0].id}`); + } + } + } + console.log('Task completed successfully!'); +} + +rerunFailedWorkflowsFromDevelop() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); diff --git a/package.json b/package.json index c12b8b40cdfb..b1d69e0e16ea 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,8 @@ "master-sync": "node development/master-sync.js", "update-mock-cdn": "node test/e2e/mock-cdn/update-mock-cdn-files.js", "attributions:check": "./development/attributions-check.sh", - "attributions:generate": "./development/generate-attributions.sh" + "attributions:generate": "./development/generate-attributions.sh", + "ci-rerun-from-failed": "tsx .circleci/scripts/rerun-ci-workflow-from-failed.ts" }, "resolutions": { "chokidar": "^3.6.0", From e3c6b29eaabccfeb0e7219f1df5401d970bcde9f Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 22 Nov 2024 12:15:47 +0000 Subject: [PATCH 044/148] =?UTF-8?q?feat:=20Display=20'<=200.01'=20instead?= =?UTF-8?q?=20of=20'0.00'=20for=20the=20fiat=20value=20of=20networ?= =?UTF-8?q?=E2=80=A6=20(#28543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …k fee ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28543?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3631 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2024-11-19 at 14 31 24 ## **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. --- .../info/hooks/useFeeCalculations.test.ts | 16 +++++- .../confirm/info/hooks/useFeeCalculations.ts | 55 ++++++++++++++----- .../edit-gas-fees-row.test.tsx.snap | 23 ++++++-- .../edit-gas-fees-row.stories.tsx | 1 + .../edit-gas-fees-row.test.tsx | 1 + .../edit-gas-fees-row/edit-gas-fees-row.tsx | 16 +++++- .../gas-fees-details/gas-fees-details.tsx | 8 +++ .../__snapshots__/gas-fees-row.test.tsx.snap | 21 +++++-- .../gas-fees-row/gas-fees-row.stories.tsx | 1 + .../shared/gas-fees-row/gas-fees-row.test.tsx | 1 + .../info/shared/gas-fees-row/gas-fees-row.tsx | 10 +++- 11 files changed, 125 insertions(+), 28 deletions(-) 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 17c8ab8dd8f6..cee8537f787c 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.test.ts @@ -21,13 +21,17 @@ describe('useFeeCalculations', () => { expect(result.current).toMatchInlineSnapshot(` { - "estimatedFeeFiat": "$0.00", + "estimatedFeeFiat": "< $0.01", + "estimatedFeeFiatWith18SignificantDigits": "0", "estimatedFeeNative": "0 ETH", "l1FeeFiat": "", + "l1FeeFiatWith18SignificantDigits": "", "l1FeeNative": "", "l2FeeFiat": "", + "l2FeeFiatWith18SignificantDigits": "", "l2FeeNative": "", - "maxFeeFiat": "$0.00", + "maxFeeFiat": "< $0.01", + "maxFeeFiatWith18SignificantDigits": "0", "maxFeeNative": "0 ETH", } `); @@ -46,12 +50,16 @@ describe('useFeeCalculations', () => { expect(result.current).toMatchInlineSnapshot(` { "estimatedFeeFiat": "$0.04", + "estimatedFeeFiatWith18SignificantDigits": null, "estimatedFeeNative": "0.0001 ETH", "l1FeeFiat": "", + "l1FeeFiatWith18SignificantDigits": "", "l1FeeNative": "", "l2FeeFiat": "", + "l2FeeFiatWith18SignificantDigits": "", "l2FeeNative": "", "maxFeeFiat": "$0.07", + "maxFeeFiatWith18SignificantDigits": null, "maxFeeNative": "0.0001 ETH", } `); @@ -72,12 +80,16 @@ describe('useFeeCalculations', () => { expect(result.current).toMatchInlineSnapshot(` { "estimatedFeeFiat": "$2.54", + "estimatedFeeFiatWith18SignificantDigits": null, "estimatedFeeNative": "0.0046 ETH", "l1FeeFiat": "$2.50", + "l1FeeFiatWith18SignificantDigits": null, "l1FeeNative": "0.0045 ETH", "l2FeeFiat": "$0.04", + "l2FeeFiatWith18SignificantDigits": null, "l2FeeNative": "0.0001 ETH", "maxFeeFiat": "$0.07", + "maxFeeFiatWith18SignificantDigits": null, "maxFeeNative": "0.0001 ETH", } `); diff --git a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts index 3039a33e6d4f..47dda13f9511 100644 --- a/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts +++ b/ui/pages/confirmations/components/confirm/info/hooks/useFeeCalculations.ts @@ -27,6 +27,7 @@ import { useTransactionGasFeeEstimate } from './useTransactionGasFeeEstimate'; const EMPTY_FEE = ''; const EMPTY_FEES = { currentCurrencyFee: EMPTY_FEE, + currentCurrencyFeeWith18SignificantDigits: EMPTY_FEE, nativeCurrencyFee: EMPTY_FEE, }; @@ -52,19 +53,36 @@ export function useFeeCalculations(transactionMeta: TransactionMeta) { }) || 0 } ${ticker}`; - const currentCurrencyFee = fiatFormatter( - Number( - getValueFromWeiHex({ - value: hexFee, - conversionRate, - fromCurrency: EtherDenomination.GWEI, - toCurrency: currentCurrency, - numberOfDecimals: 2, - }), - ), + const decimalCurrentCurrencyFee = Number( + getValueFromWeiHex({ + value: hexFee, + conversionRate, + fromCurrency: EtherDenomination.GWEI, + toCurrency: currentCurrency, + numberOfDecimals: 2, + }), ); - return { currentCurrencyFee, nativeCurrencyFee }; + let currentCurrencyFee, currentCurrencyFeeWith18SignificantDigits; + if (decimalCurrentCurrencyFee === 0) { + currentCurrencyFee = `< ${fiatFormatter(0.01)}`; + currentCurrencyFeeWith18SignificantDigits = getValueFromWeiHex({ + value: hexFee, + conversionRate, + fromCurrency: EtherDenomination.GWEI, + toCurrency: currentCurrency, + numberOfDecimals: 18, + }); + } else { + currentCurrencyFee = fiatFormatter(decimalCurrentCurrencyFee); + currentCurrencyFeeWith18SignificantDigits = null; + } + + return { + currentCurrencyFee, + currentCurrencyFeeWith18SignificantDigits, + nativeCurrencyFee, + }; }, [conversionRate, currentCurrency, fiatFormatter], ); @@ -109,8 +127,12 @@ export function useFeeCalculations(transactionMeta: TransactionMeta) { ); }, [supportsEIP1559, maxFeePerGas, gasLimit, gasPrice]); - const { currentCurrencyFee: maxFeeFiat, nativeCurrencyFee: maxFeeNative } = - getFeesFromHex(maxFee); + const { + currentCurrencyFee: maxFeeFiat, + currentCurrencyFeeWith18SignificantDigits: + maxFeeFiatWith18SignificantDigits, + nativeCurrencyFee: maxFeeNative, + } = getFeesFromHex(maxFee); // Estimated fee const estimatedFees = useMemo(() => { @@ -153,12 +175,19 @@ export function useFeeCalculations(transactionMeta: TransactionMeta) { return { estimatedFeeFiat: estimatedFees.currentCurrencyFee, + estimatedFeeFiatWith18SignificantDigits: + estimatedFees.currentCurrencyFeeWith18SignificantDigits, estimatedFeeNative: estimatedFees.nativeCurrencyFee, l1FeeFiat: feesL1.currentCurrencyFee, + l1FeeFiatWith18SignificantDigits: + feesL1.currentCurrencyFeeWith18SignificantDigits, l1FeeNative: feesL1.nativeCurrencyFee, l2FeeFiat: feesL2.currentCurrencyFee, + l2FeeFiatWith18SignificantDigits: + feesL2.currentCurrencyFeeWith18SignificantDigits, l2FeeNative: feesL2.nativeCurrencyFee, maxFeeFiat, + maxFeeFiatWith18SignificantDigits, maxFeeNative, }; } diff --git a/ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/__snapshots__/edit-gas-fees-row.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/__snapshots__/edit-gas-fees-row.test.tsx.snap index 1cb2f224843b..198f4be8f14d 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/__snapshots__/edit-gas-fees-row.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/__snapshots__/edit-gas-fees-row.test.tsx.snap @@ -45,12 +45,23 @@ exports[` renders component 1`] = ` > 0.001 ETH

-

- $1 -

+
+
+

+ $1 +

+
+
diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.stories.tsx b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.stories.tsx index 89f2a8308bd5..41dde9a36b6d 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.stories.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.stories.tsx @@ -38,6 +38,7 @@ export const DefaultStory = () => ( label="Some kind of fee" tooltipText="Tooltip text" fiatFee="$1" + fiatFeeWith18SignificantDigits="0.001234" nativeFee="0.0001 ETH" /> diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.test.tsx b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.test.tsx index e5433c38fa5d..190e36f1e863 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.test.tsx @@ -17,6 +17,7 @@ describe('', () => { label="Some kind of fee" tooltipText="Tooltip text" fiatFee="$1" + fiatFeeWith18SignificantDigits="0.001234" nativeFee="0.0001 ETH" />, mockStore, diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.tsx b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.tsx index 82c36147ad1d..2bcbe7fb2b35 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fees-row/gas-fees-row.tsx @@ -7,6 +7,7 @@ import { ConfirmInfoRowVariant, } from '../../../../../../../components/app/confirm/info/row'; import { Box, Text } from '../../../../../../../components/component-library'; +import Tooltip from '../../../../../../../components/ui/tooltip'; import { AlignItems, Display, @@ -22,12 +23,14 @@ export const GasFeesRow = ({ label, tooltipText, fiatFee, + fiatFeeWith18SignificantDigits, nativeFee, 'data-testid': dataTestId, }: { label: string; tooltipText: string; fiatFee: string; + fiatFeeWith18SignificantDigits: string | null; nativeFee: string; 'data-testid'?: string; }) => { @@ -58,7 +61,12 @@ export const GasFeesRow = ({ {nativeFee} - {(!isTestnet || showFiatInTestnets) && ( + {(!isTestnet || showFiatInTestnets) && + fiatFeeWith18SignificantDigits ? ( + + {fiatFee} + + ) : ( {fiatFee} )} From 197c2018431d0830fa5c96ec3c2fde113a21056b Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Fri, 22 Nov 2024 05:34:38 -0800 Subject: [PATCH 045/148] chore: Update and use selectors for which chains to poll (#28586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates polling hooks to use selectors indicating which chains to poll. Also update the selectors to only return the current chain when portfolio view is filtered to the current chain. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28586?quickstart=1) ## **Related issues** ## **Manual testing steps** With PORTFOLIO_VIEW=1 1. When filtering all chains, should see requests in network tab hitting all chains 2. When filtering for current chain, should see requests in network tab hitting only the current chain ## **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: David Walsh Co-authored-by: salimtb --- ui/hooks/useAccountTrackerPolling.ts | 29 +++------------------------- ui/hooks/useTokenDetectionPolling.ts | 10 ++-------- ui/hooks/useTokenListPolling.ts | 10 ++-------- ui/hooks/useTokenRatesPolling.ts | 10 ++-------- ui/selectors/selectors.js | 28 +++++++++++++++++++++------ ui/selectors/selectors.test.js | 6 ++++++ 6 files changed, 37 insertions(+), 56 deletions(-) diff --git a/ui/hooks/useAccountTrackerPolling.ts b/ui/hooks/useAccountTrackerPolling.ts index 5dffe5dfc852..cbcb5d569b58 100644 --- a/ui/hooks/useAccountTrackerPolling.ts +++ b/ui/hooks/useAccountTrackerPolling.ts @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { getCurrentChainId } from '../selectors'; +import { getNetworkClientIdsToPoll } from '../selectors'; import { accountTrackerStartPolling, accountTrackerStopPollingByPollingToken, @@ -8,41 +8,18 @@ import { getCompletedOnboarding, getIsUnlocked, } from '../ducks/metamask/metamask'; -import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import useMultiPolling from './useMultiPolling'; const useAccountTrackerPolling = () => { - // Selectors to determine polling input - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); - const currentChainId = useSelector(getCurrentChainId); - const currentNetwork = networkConfigurations[currentChainId]; - const currentRpcEndpoint = - currentNetwork.rpcEndpoints[currentNetwork.defaultRpcEndpointIndex]; - + const networkClientIdsToPoll = useSelector(getNetworkClientIdsToPoll); const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); - const availableNetworkClientIds = Object.values(networkConfigurations).map( - (networkConfiguration) => - networkConfiguration.rpcEndpoints[ - networkConfiguration.defaultRpcEndpointIndex - ].networkClientId, - ); const canStartPolling = completedOnboarding && isUnlocked; - const portfolioViewNetworks = canStartPolling - ? availableNetworkClientIds - : []; - const nonPortfolioViewNetworks = canStartPolling - ? [currentRpcEndpoint.networkClientId] - : []; - - const networkArrayToPollFor = process.env.PORTFOLIO_VIEW - ? portfolioViewNetworks - : nonPortfolioViewNetworks; useMultiPolling({ startPolling: accountTrackerStartPolling, stopPollingByPollingToken: accountTrackerStopPollingByPollingToken, - input: networkArrayToPollFor, + input: canStartPolling ? networkClientIdsToPoll : [], }); }; diff --git a/ui/hooks/useTokenDetectionPolling.ts b/ui/hooks/useTokenDetectionPolling.ts index 66e027b5af6b..cc8fe33d35a4 100644 --- a/ui/hooks/useTokenDetectionPolling.ts +++ b/ui/hooks/useTokenDetectionPolling.ts @@ -1,6 +1,5 @@ import { useSelector } from 'react-redux'; -import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; -import { getCurrentChainId, getUseTokenDetection } from '../selectors'; +import { getChainIdsToPoll, getUseTokenDetection } from '../selectors'; import { tokenDetectionStartPolling, tokenDetectionStopPollingByPollingToken, @@ -15,15 +14,10 @@ const useTokenDetectionPolling = () => { const useTokenDetection = useSelector(getUseTokenDetection); const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); - const currentChainId = useSelector(getCurrentChainId); - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const chainIds = useSelector(getChainIdsToPoll); const enabled = completedOnboarding && isUnlocked && useTokenDetection; - const chainIds = process.env.PORTFOLIO_VIEW - ? Object.keys(networkConfigurations) - : [currentChainId]; - useMultiPolling({ startPolling: tokenDetectionStartPolling, stopPollingByPollingToken: tokenDetectionStopPollingByPollingToken, diff --git a/ui/hooks/useTokenListPolling.ts b/ui/hooks/useTokenListPolling.ts index 98ea0c324da4..dce0e129292d 100644 --- a/ui/hooks/useTokenListPolling.ts +++ b/ui/hooks/useTokenListPolling.ts @@ -1,12 +1,11 @@ import { useSelector } from 'react-redux'; import { - getCurrentChainId, + getChainIdsToPoll, getPetnamesEnabled, getUseExternalServices, getUseTokenDetection, getUseTransactionSimulations, } from '../selectors'; -import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { tokenListStartPolling, tokenListStopPollingByPollingToken, @@ -18,14 +17,13 @@ import { import useMultiPolling from './useMultiPolling'; const useTokenListPolling = () => { - const currentChainId = useSelector(getCurrentChainId); - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const useTokenDetection = useSelector(getUseTokenDetection); const useTransactionSimulations = useSelector(getUseTransactionSimulations); const petnamesEnabled = useSelector(getPetnamesEnabled); const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); const useExternalServices = useSelector(getUseExternalServices); + const chainIds = useSelector(getChainIdsToPoll); const enabled = completedOnboarding && @@ -33,10 +31,6 @@ const useTokenListPolling = () => { useExternalServices && (useTokenDetection || petnamesEnabled || useTransactionSimulations); - const chainIds = process.env.PORTFOLIO_VIEW - ? Object.keys(networkConfigurations) - : [currentChainId]; - useMultiPolling({ startPolling: tokenListStartPolling, stopPollingByPollingToken: tokenListStopPollingByPollingToken, diff --git a/ui/hooks/useTokenRatesPolling.ts b/ui/hooks/useTokenRatesPolling.ts index bd57da835a99..7f9c4dd70b5f 100644 --- a/ui/hooks/useTokenRatesPolling.ts +++ b/ui/hooks/useTokenRatesPolling.ts @@ -1,12 +1,11 @@ import { useSelector } from 'react-redux'; import { - getCurrentChainId, + getChainIdsToPoll, getMarketData, getTokenExchangeRates, getTokensMarketData, getUseCurrencyRateCheck, } from '../selectors'; -import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { tokenRatesStartPolling, tokenRatesStopPollingByPollingToken, @@ -21,9 +20,8 @@ const useTokenRatesPolling = () => { // Selectors to determine polling input const completedOnboarding = useSelector(getCompletedOnboarding); const isUnlocked = useSelector(getIsUnlocked); - const currentChainId = useSelector(getCurrentChainId); const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); - const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const chainIds = useSelector(getChainIdsToPoll); // Selectors returning state updated by the polling const tokenExchangeRates = useSelector(getTokenExchangeRates); @@ -32,10 +30,6 @@ const useTokenRatesPolling = () => { const enabled = completedOnboarding && isUnlocked && useCurrencyRateCheck; - const chainIds = process.env.PORTFOLIO_VIEW - ? Object.keys(networkConfigurations) - : [currentChainId]; - useMultiPolling({ startPolling: tokenRatesStartPolling, stopPollingByPollingToken: tokenRatesStopPollingByPollingToken, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index e4e9c7e27fa7..b6df07bd2bd0 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2367,13 +2367,21 @@ export const getAllEnabledNetworks = createDeepEqualSelector( export const getChainIdsToPoll = createDeepEqualSelector( getNetworkConfigurationsByChainId, getCurrentChainId, - (networkConfigurations, currentChainId) => { - if (!process.env.PORTFOLIO_VIEW) { + getIsTokenNetworkFilterEqualCurrentNetwork, + ( + networkConfigurations, + currentChainId, + isTokenNetworkFilterEqualCurrentNetwork, + ) => { + if ( + !process.env.PORTFOLIO_VIEW || + isTokenNetworkFilterEqualCurrentNetwork + ) { return [currentChainId]; } return Object.keys(networkConfigurations).filter( - (chainId) => !TEST_CHAINS.includes(chainId), + (chainId) => chainId === currentChainId || !TEST_CHAINS.includes(chainId), ); }, ); @@ -2381,8 +2389,16 @@ export const getChainIdsToPoll = createDeepEqualSelector( export const getNetworkClientIdsToPoll = createDeepEqualSelector( getNetworkConfigurationsByChainId, getCurrentChainId, - (networkConfigurations, currentChainId) => { - if (!process.env.PORTFOLIO_VIEW) { + getIsTokenNetworkFilterEqualCurrentNetwork, + ( + networkConfigurations, + currentChainId, + isTokenNetworkFilterEqualCurrentNetwork, + ) => { + if ( + !process.env.PORTFOLIO_VIEW || + isTokenNetworkFilterEqualCurrentNetwork + ) { const networkConfiguration = networkConfigurations[currentChainId]; return [ networkConfiguration.rpcEndpoints[ @@ -2393,7 +2409,7 @@ export const getNetworkClientIdsToPoll = createDeepEqualSelector( return Object.entries(networkConfigurations).reduce( (acc, [chainId, network]) => { - if (!TEST_CHAINS.includes(chainId)) { + if (chainId === currentChainId || !TEST_CHAINS.includes(chainId)) { acc.push( network.rpcEndpoints[network.defaultRpcEndpointIndex] .networkClientId, diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index e70e9cd7ff29..b2c3cd894e44 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -834,6 +834,9 @@ describe('Selectors', () => { it('returns only non-test chain IDs', () => { const chainIds = selectors.getChainIdsToPoll({ metamask: { + preferences: { + tokenNetworkFilter: {}, + }, networkConfigurationsByChainId, selectedNetworkClientId: 'mainnet', }, @@ -881,6 +884,9 @@ describe('Selectors', () => { it('returns only non-test chain IDs', () => { const chainIds = selectors.getNetworkClientIdsToPoll({ metamask: { + preferences: { + tokenNetworkFilter: {}, + }, networkConfigurationsByChainId, selectedNetworkClientId: 'mainnet', }, From fe5f85cdf322002c1f392a9b5c47d361efa58feb Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:04:35 +0100 Subject: [PATCH 046/148] test: rename the `GanacheContractAddressRegistry` class in preparation for ganache migration (#28595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Contract Registry class, is going to be used as it is, in Anvil too. In the efforts of making [the migration PR](https://github.com/MetaMask/metamask-extension/pull/27246/files#diff-06cb4d4aa42b7e7467cb49bd17a4282672cc4a352ee698d122e566f25e906692) smaller, this PR tackles the re-naming of this class `GanacheContractAddressRegistry` to a more generic name that can be used both by Ganache and Anvil `ContractAddressRegistry`. Note: this PR doesn't introduce any functional change in the tests, so we can skip the quality gate for sparing ci credits. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28595?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3662 ## **Manual testing steps** 1. Check all tests continues to work fine ## **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/e2e/helpers.js | 2 +- test/e2e/json-rpc/eth_call.spec.ts | 4 ++-- ...ss-registry.js => contract-address-registry.js} | 6 +++--- test/e2e/seeder/ganache-seeder.js | 6 +++--- .../snap-account-contract-interaction.spec.ts | 6 +++--- .../alerts/queued-confirmations.spec.ts | 6 +++--- .../contract-interaction-redesign.spec.ts | 4 ++-- ...erc1155-revoke-set-approval-for-all-redesign.ts | 6 +++--- .../erc1155-set-approval-for-all-redesign.spec.ts | 6 +++--- .../transactions/erc20-token-send-redesign.spec.ts | 10 +++++----- .../erc721-revoke-set-approval-for-all-redesign.ts | 6 +++--- .../erc721-set-approval-for-all-redesign.spec.ts | 6 +++--- .../increase-token-allowance-redesign.spec.ts | 4 ++-- .../transactions/nft-token-send-redesign.spec.ts | 14 +++++++------- .../e2e/tests/confirmations/transactions/shared.ts | 8 ++++---- 15 files changed, 47 insertions(+), 47 deletions(-) rename test/e2e/seeder/{ganache-contract-address-registry.js => contract-address-registry.js} (80%) diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 091f946a8071..ae86720da46f 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -42,7 +42,7 @@ const convertETHToHexGwei = (eth) => convertToHexValue(eth * 10 ** 18); /** * @typedef {object} Fixtures * @property {import('./webdriver/driver').Driver} driver - The driver number. - * @property {GanacheContractAddressRegistry | undefined} contractRegistry - The contract registry. + * @property {ContractAddressRegistry | undefined} contractRegistry - The contract registry. * @property {Ganache | undefined} ganacheServer - The Ganache server. * @property {Ganache | undefined} secondaryGanacheServer - The secondary Ganache server. * @property {mockttp.MockedEndpoint[]} mockedEndpoint - The mocked endpoint. diff --git a/test/e2e/json-rpc/eth_call.spec.ts b/test/e2e/json-rpc/eth_call.spec.ts index 7ff1dd7489ff..fedc08ea645b 100644 --- a/test/e2e/json-rpc/eth_call.spec.ts +++ b/test/e2e/json-rpc/eth_call.spec.ts @@ -4,7 +4,7 @@ import { defaultGanacheOptions, withFixtures } from '../helpers'; import { Driver } from '../webdriver/driver'; import FixtureBuilder from '../fixture-builder'; import { Ganache } from '../seeder/ganache'; -import GanacheContractAddressRegistry from '../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../seeder/contract-address-registry'; import { SMART_CONTRACTS } from '../seeder/smart-contracts'; import { loginWithBalanceValidation } from '../page-objects/flows/login.flow'; @@ -28,7 +28,7 @@ describe('eth_call', function () { }: { driver: Driver; ganacheServer?: Ganache; - contractRegistry: GanacheContractAddressRegistry; + contractRegistry: ContractAddressRegistry; }) => { const contract = contractRegistry.getContractAddress(smartContract); await loginWithBalanceValidation(driver, ganacheServer); diff --git a/test/e2e/seeder/ganache-contract-address-registry.js b/test/e2e/seeder/contract-address-registry.js similarity index 80% rename from test/e2e/seeder/ganache-contract-address-registry.js rename to test/e2e/seeder/contract-address-registry.js index bfad708d3c90..b437d6b47f86 100644 --- a/test/e2e/seeder/ganache-contract-address-registry.js +++ b/test/e2e/seeder/contract-address-registry.js @@ -1,8 +1,8 @@ /* * Use this class to store pre-deployed smart contract addresses of the contracts deployed to - * a local blockchain instance ran by Ganache. + * a local blockchain instance. */ -class GanacheContractAddressRegistry { +class ContractAddressRegistry { #addresses = {}; /** @@ -25,4 +25,4 @@ class GanacheContractAddressRegistry { } } -module.exports = GanacheContractAddressRegistry; +module.exports = ContractAddressRegistry; diff --git a/test/e2e/seeder/ganache-seeder.js b/test/e2e/seeder/ganache-seeder.js index 821d3b4a62ff..ebec4d5a8dcd 100644 --- a/test/e2e/seeder/ganache-seeder.js +++ b/test/e2e/seeder/ganache-seeder.js @@ -3,14 +3,14 @@ const { ContractFactory, Contract } = require('@ethersproject/contracts'); const { ENTRYPOINT, GANACHE_ACCOUNT } = require('../constants'); const { SMART_CONTRACTS, contractConfiguration } = require('./smart-contracts'); -const GanacheContractAddressRegistry = require('./ganache-contract-address-registry'); +const ContractAddressRegistry = require('./contract-address-registry'); /* * Ganache seeder is used to seed initial smart contract or set initial blockchain state. */ class GanacheSeeder { constructor(ganacheProvider) { - this.smartContractRegistry = new GanacheContractAddressRegistry(); + this.smartContractRegistry = new ContractAddressRegistry(); this.ganacheProvider = ganacheProvider; } @@ -125,7 +125,7 @@ class GanacheSeeder { /** * Return an instance of the currently used smart contract registry. * - * @returns GanacheContractAddressRegistry + * @returns ContractAddressRegistry */ getContractRegistry() { return this.smartContractRegistry; diff --git a/test/e2e/tests/account/snap-account-contract-interaction.spec.ts b/test/e2e/tests/account/snap-account-contract-interaction.spec.ts index e4753f5ff05b..b5cfd4d2707c 100644 --- a/test/e2e/tests/account/snap-account-contract-interaction.spec.ts +++ b/test/e2e/tests/account/snap-account-contract-interaction.spec.ts @@ -2,7 +2,7 @@ import { Suite } from 'mocha'; import { Driver } from '../../webdriver/driver'; import FixtureBuilder from '../../fixture-builder'; import { Ganache } from '../../seeder/ganache'; -import GanacheContractAddressRegistry from '../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../seeder/contract-address-registry'; import { multipleGanacheOptionsForType2Transactions, PRIVATE_KEY_TWO, @@ -42,7 +42,7 @@ describe('Snap Account Contract interaction @no-mmi', function (this: Suite) { ganacheServer, }: { driver: Driver; - contractRegistry: GanacheContractAddressRegistry; + contractRegistry: ContractAddressRegistry; ganacheServer?: Ganache; }) => { await loginWithBalanceValidation(driver, ganacheServer); @@ -62,7 +62,7 @@ describe('Snap Account Contract interaction @no-mmi', function (this: Suite) { // Open Dapp with contract const testDapp = new TestDapp(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(smartContract); await testDapp.openTestDappPage({ contractAddress }); await testDapp.check_pageIsLoaded(); diff --git a/test/e2e/tests/confirmations/alerts/queued-confirmations.spec.ts b/test/e2e/tests/confirmations/alerts/queued-confirmations.spec.ts index 6fd44e6f7af1..8ecd7e908a30 100644 --- a/test/e2e/tests/confirmations/alerts/queued-confirmations.spec.ts +++ b/test/e2e/tests/confirmations/alerts/queued-confirmations.spec.ts @@ -10,7 +10,7 @@ import { openDAppWithContract, TestSuiteArguments, } from '../transactions/shared'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; const FixtureBuilder = require('../../../fixture-builder'); const { @@ -168,7 +168,7 @@ describe('Queued Confirmations', function () { await openDAppWithContract(driver, contractRegistry, smartContract); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(smartContract); await connectToDappTwoAndSwitchBackToOne(driver, contractAddress); @@ -317,7 +317,7 @@ describe('Queued Confirmations', function () { await openDAppWithContract(driver, contractRegistry, smartContract); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(smartContract); await connectToDappTwoAndSwitchBackToOne(driver, contractAddress); diff --git a/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts index a668539d24dc..d3a217f21c01 100644 --- a/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/contract-interaction-redesign.spec.ts @@ -2,7 +2,7 @@ import { Mockttp } from 'mockttp'; import { openDapp, unlockWallet } from '../../../helpers'; import { createDappTransaction } from '../../../page-objects/flows/transaction'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { MockedEndpoint } from '../../../mock-e2e'; import { @@ -165,7 +165,7 @@ describe('Confirmation Redesign Contract Interaction Component', function () { await createLayer2Transaction(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(smartContract); await openDapp(driver, contractAddress); diff --git a/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts b/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts index 1f9d05cd26a8..33a7760a050d 100644 --- a/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts +++ b/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts @@ -5,7 +5,7 @@ import { unlockWallet, WINDOW_TITLES } from '../../../helpers'; import { Mockttp } from '../../../mock-e2e'; import SetApprovalForAllTransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/set-approval-for-all-transaction-confirmation'; import TestDapp from '../../../page-objects/pages/test-dapp'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { mocked4BytesSetApprovalForAll } from './erc721-revoke-set-approval-for-all-redesign'; @@ -47,12 +47,12 @@ async function mocks(server: Mockttp) { async function createTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts index c9c9fdbd5eda..da9da0a59873 100644 --- a/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts @@ -4,7 +4,7 @@ import { DAPP_URL, unlockWallet, WINDOW_TITLES } from '../../../helpers'; import { Mockttp } from '../../../mock-e2e'; import SetApprovalForAllTransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/set-approval-for-all-transaction-confirmation'; import TestDapp from '../../../page-objects/pages/test-dapp'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; @@ -75,12 +75,12 @@ export async function mocked4BytesSetApprovalForAll(mockServer: Mockttp) { async function createTransactionAssertDetailsAndConfirm( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts index 9cd1d3837729..f5293161172f 100644 --- a/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts @@ -12,7 +12,7 @@ import TokenTransferTransactionConfirmation from '../../../page-objects/pages/co 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 GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; @@ -115,12 +115,12 @@ export async function mockedSourcifyTokenSend(mockServer: Mockttp) { async function createWalletInitiatedTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.HST); const testDapp = new TestDapp(driver); @@ -160,12 +160,12 @@ async function createWalletInitiatedTransactionAndAssertDetails( async function createDAppInitiatedTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.HST); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts b/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts index b0f1291a47d9..95768c35de3f 100644 --- a/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts +++ b/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts @@ -5,7 +5,7 @@ import { unlockWallet, WINDOW_TITLES } from '../../../helpers'; import { Mockttp } from '../../../mock-e2e'; import SetApprovalForAllTransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/set-approval-for-all-transaction-confirmation'; import TestDapp from '../../../page-objects/pages/test-dapp'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; @@ -70,12 +70,12 @@ export async function mocked4BytesSetApprovalForAll(mockServer: Mockttp) { async function createTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts index 9e481ee9c75f..ba3c877973e5 100644 --- a/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts @@ -4,7 +4,7 @@ import { DAPP_URL, unlockWallet, WINDOW_TITLES } from '../../../helpers'; import { Mockttp } from '../../../mock-e2e'; import SetApprovalForAllTransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/set-approval-for-all-transaction-confirmation'; import TestDapp from '../../../page-objects/pages/test-dapp'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; @@ -75,12 +75,12 @@ export async function mocked4BytesSetApprovalForAll(mockServer: Mockttp) { async function createTransactionAssertDetailsAndConfirm( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/increase-token-allowance-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/increase-token-allowance-redesign.spec.ts index 4eed23b20f44..30c87080c3fc 100644 --- a/test/e2e/tests/confirmations/transactions/increase-token-allowance-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/increase-token-allowance-redesign.spec.ts @@ -8,7 +8,7 @@ import { withFixtures, } from '../../../helpers'; import { Mockttp } from '../../../mock-e2e'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { SMART_CONTRACTS } from '../../../seeder/smart-contracts'; import { Driver } from '../../../webdriver/driver'; import { scrollAndConfirmAndAssertConfirm } from '../helpers'; @@ -111,7 +111,7 @@ function generateFixtureOptionsForEIP1559Tx(mochaContext: Mocha.Context) { async function createAndAssertIncreaseAllowanceSubmission( driver: Driver, newSpendingCap: string, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await openDAppWithContract(driver, contractRegistry, SMART_CONTRACTS.HST); diff --git a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts index b7902717a787..8d319b62c482 100644 --- a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts @@ -14,7 +14,7 @@ import HomePage from '../../../page-objects/pages/homepage'; import NFTDetailsPage from '../../../page-objects/pages/nft-details-page'; import SendTokenPage from '../../../page-objects/pages/send/send-token-page'; import TestDapp from '../../../page-objects/pages/test-dapp'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; import { withRedesignConfirmationFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; @@ -184,12 +184,12 @@ export async function mockedERC11554BytesNFTTokenSend(mockServer: Mockttp) { async function createERC721WalletInitiatedTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); @@ -231,12 +231,12 @@ async function createERC721WalletInitiatedTransactionAndAssertDetails( async function createERC721DAppInitiatedTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.NFTS); const testDapp = new TestDapp(driver); @@ -266,12 +266,12 @@ async function createERC721DAppInitiatedTransactionAndAssertDetails( async function createERC1155WalletInitiatedTransactionAndAssertDetails( driver: Driver, - contractRegistry?: GanacheContractAddressRegistry, + contractRegistry?: ContractAddressRegistry, ) { await unlockWallet(driver); const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(SMART_CONTRACTS.ERC1155); const testDapp = new TestDapp(driver); diff --git a/test/e2e/tests/confirmations/transactions/shared.ts b/test/e2e/tests/confirmations/transactions/shared.ts index 62ceb318aa48..f984417fae94 100644 --- a/test/e2e/tests/confirmations/transactions/shared.ts +++ b/test/e2e/tests/confirmations/transactions/shared.ts @@ -2,7 +2,7 @@ import { MockedEndpoint } from 'mockttp'; import { veryLargeDelayMs } from '../../../helpers'; import { Ganache } from '../../../seeder/ganache'; -import GanacheContractAddressRegistry from '../../../seeder/ganache-contract-address-registry'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; const { @@ -15,17 +15,17 @@ const { scrollAndConfirmAndAssertConfirm } = require('../helpers'); export type TestSuiteArguments = { driver: Driver; ganacheServer?: Ganache; - contractRegistry?: GanacheContractAddressRegistry; + contractRegistry?: ContractAddressRegistry; mockedEndpoint?: MockedEndpoint | MockedEndpoint[]; }; export async function openDAppWithContract( driver: Driver, - contractRegistry: GanacheContractAddressRegistry | undefined, + contractRegistry: ContractAddressRegistry | undefined, smartContract: string, ) { const contractAddress = await ( - contractRegistry as GanacheContractAddressRegistry + contractRegistry as ContractAddressRegistry ).getContractAddress(smartContract); await logInWithBalanceValidation(driver); From 1124dae8f7ce79b076ca64eff7889021b7e7a913 Mon Sep 17 00:00:00 2001 From: George Marshall Date: Fri, 22 Nov 2024 09:05:23 -0800 Subject: [PATCH 047/148] chore: updating filter icon to align with figma (#28547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This pull request updates the filter icon's stroke thickness to align with the Figma design and other icons in the MetaMask design system. The current filter icon's stroke is thinner than intended, causing inconsistency with the overall visual hierarchy of the design system. Adjusting the stroke ensures better alignment and uniformity across the UI elements. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28547?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28548 ## **Manual testing steps** 1. Navigate to a screen in the MetaMask extension where the filter icon is displayed. 2. Verify the updated filter icon's stroke thickness matches the Figma design specifications. 3. Compare the filter icon with other icons in the design system to confirm visual consistency. ## **Screenshots/Recordings** Screenshot 2024-11-19 at 9 54 57 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/images/icons/filter.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/images/icons/filter.svg b/app/images/icons/filter.svg index c7b73c69a433..98957a007b7c 100644 --- a/app/images/icons/filter.svg +++ b/app/images/icons/filter.svg @@ -1,3 +1,3 @@ - + From 4afb3bc496ca76a5d7aa163cd459e7ae21ba6c52 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 22 Nov 2024 18:30:20 +0100 Subject: [PATCH 048/148] chore: bump `keyring-api` to `^10.1.0` + `eth-snap-keyring` to `^5.0.1` (#28545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updating the `keyring-api` and Snap keyring alongside other controllers that depend on those versions too. Some controllers from [this release](https://github.com/MetaMask/core/pull/4956) were left out from this PR mainly because the current major version being used in the extension is not updated yet. The current included controllers do have a peer dependency to either the `keyring-controller` or the `accounts-controller` which is why they are being included in this PR. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28545?quickstart=1) ## **Related issues** - https://github.com/MetaMask/core/pull/4956 ## **Manual testing steps** N/A ## **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. --- ...ts-controllers-npm-44.0.0-c223d56176.patch | 35 ---- ...s-controllers-npm-45.0.0-31810ece32.patch} | 72 +++---- app/scripts/metamask-controller.test.js | 4 +- lavamoat/browserify/beta/policy.json | 80 ++++---- lavamoat/browserify/flask/policy.json | 80 ++++---- lavamoat/browserify/main/policy.json | 80 ++++---- lavamoat/browserify/mmi/policy.json | 80 ++++---- package.json | 18 +- test/data/mock-accounts.ts | 4 +- test/e2e/flask/create-watch-account.spec.ts | 5 +- test/jest/mocks.ts | 2 +- .../app/wallet-overview/btc-overview.test.tsx | 2 +- .../ui/account-list/account-list.test.js | 4 +- ui/helpers/utils/permissions.test.ts | 2 +- .../useMultichainWalletSnapClient.test.ts | 2 +- yarn.lock | 184 +++++++++++------- 16 files changed, 325 insertions(+), 329 deletions(-) delete mode 100644 .yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch rename .yarn/patches/{@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch => @metamask-assets-controllers-npm-45.0.0-31810ece32.patch} (97%) diff --git a/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch b/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch deleted file mode 100644 index 2a6310c2db69..000000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs -index c2e83cf6caee19152aa164f1333cfef7b681e900..590b6de6e9d20ca402b82ac56b0929ab8c16c932 100644 ---- a/dist/assetsUtil.cjs -+++ b/dist/assetsUtil.cjs -@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; - }; - Object.defineProperty(exports, "__esModule", { value: true }); -+function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } - exports.fetchTokenContractExchangeRates = exports.reduceInBatchesSerially = exports.divideIntoBatches = exports.ethersBigNumberToBN = exports.addUrlProtocolPrefix = exports.getFormattedIpfsUrl = exports.getIpfsCIDv1AndPath = exports.removeIpfsProtocolPrefix = exports.isTokenListSupportedForNetwork = exports.isTokenDetectionSupportedForNetwork = exports.SupportedStakedBalanceNetworks = exports.SupportedTokenDetectionNetworks = exports.formatIconUrlWithProxy = exports.formatAggregatorNames = exports.hasNewCollectionFields = exports.compareNftMetadata = exports.TOKEN_PRICES_BATCH_SIZE = void 0; - const controller_utils_1 = require("@metamask/controller-utils"); - const utils_1 = require("@metamask/utils"); -@@ -233,7 +234,7 @@ async function getIpfsCIDv1AndPath(ipfsUrl) { - const index = url.indexOf('/'); - const cid = index !== -1 ? url.substring(0, index) : url; - const path = index !== -1 ? url.substring(index) : undefined; -- const { CID } = await import("multiformats"); -+ const { CID } = _interopRequireWildcard(require("multiformats")); - // We want to ensure that the CID is v1 (https://docs.ipfs.io/concepts/content-addressing/#identifier-formats) - // because most cid v0s appear to be incompatible with IPFS subdomains - return { -diff --git a/dist/token-prices-service/codefi-v2.mjs b/dist/token-prices-service/codefi-v2.mjs -index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..b89849c0caf7e5db3b53cf03dd5746b6b1433543 100644 ---- a/dist/token-prices-service/codefi-v2.mjs -+++ b/dist/token-prices-service/codefi-v2.mjs -@@ -12,8 +12,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( - var _CodefiTokenPricesServiceV2_tokenPricePolicy; - import { handleFetch } from "@metamask/controller-utils"; - import { hexToNumber } from "@metamask/utils"; --import $cockatiel from "cockatiel"; --const { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } = $cockatiel; -+import { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } from "cockatiel"; - /** - * The list of currencies that can be supplied as the `vsCurrency` parameter to - * the `/spot-prices` endpoint, in lowercase form. diff --git a/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch b/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch similarity index 97% rename from .yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch rename to .yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch index faf388c89741..77a2e7f21cfb 100644 --- a/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch +++ b/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch @@ -1,3 +1,39 @@ +diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs +index 8fd5efde7a3c24080f8a43f79d10300e8c271245..66f656d9a55f1154024a8c18a9fe27b4ed39a21d 100644 +--- a/dist/TokenDetectionController.cjs ++++ b/dist/TokenDetectionController.cjs +@@ -250,17 +250,20 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ + } + }); + this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async (selectedAccount) => { +- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; +- if (isSelectedAccountIdChanged) { +- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- }); +- } +- }); ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async (selectedAccount) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; ++ if (isSelectedAccountIdChanged) { ++ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); + }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { + if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { + clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs index 48571b8c1b78e94d88e1837e986b5f8735ac651b..61246f51500c8cab48f18296a73629fb73454caa 100644 --- a/dist/assetsUtil.cjs @@ -33,39 +69,3 @@ index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..bf8ec7819f678c2f185d6a85d7e3ea81 /** * The list of currencies that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint, in lowercase form. -diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs -index 8fd5efde7a3c24080f8a43f79d10300e8c271245..a3c334ac7dd2e5698e6b54a73491b7145c2a9010 100644 ---- a/dist/TokenDetectionController.cjs -+++ b/dist/TokenDetectionController.cjs -@@ -250,17 +250,20 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ - } - }); - this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', -- // TODO: Either fix this lint violation or explain why it's necessary to ignore. -- // eslint-disable-next-line @typescript-eslint/no-misused-promises -- async (selectedAccount) => { -- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; -- if (isSelectedAccountIdChanged) { -- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); -- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { -- selectedAddress: selectedAccount.address, -- }); -- } -- }); -+ // TODO: Either fix this lint violation or explain why it's necessary to ignore. -+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -+ async (selectedAccount) => { -+ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); -+ const chainIds = Object.keys(networkConfigurationsByChainId); -+ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; -+ if (isSelectedAccountIdChanged) { -+ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); -+ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { -+ selectedAddress: selectedAccount.address, -+ chainIds, -+ }); -+ } -+ }); - }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { - if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { - clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 2ccf864b0edd..0cd4fba34589 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -2307,7 +2307,7 @@ describe('MetaMaskController', () => { ...mockEvmAccount, id: '21690786-6abd-45d8-a9f0-9ff1d8ca76a1', type: BtcAccountType.P2wpkh, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', }; @@ -2415,7 +2415,7 @@ describe('MetaMaskController', () => { ...mockEvmAccount, id: '21690786-6abd-45d8-a9f0-9ff1d8ca76a1', type: BtcAccountType.P2wpkh, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', // We need to have a "Snap account" account here, since the MultichainBalancesController will // filter it out otherwise! diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index a5581cd16a85..30456a0bd61d 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -613,29 +613,14 @@ "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/accounts-controller>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/eth-snap-keyring": true, "@metamask/keyring-api": true, "@metamask/keyring-controller": true, + "@metamask/utils": true, "uuid": true } }, - "@metamask/accounts-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/address-book-controller": { "packages": { "@metamask/base-controller": true, @@ -1121,7 +1106,7 @@ }, "packages": { "@ethereumjs/tx": true, - "@metamask/eth-sig-util": true, + "@metamask/eth-snap-keyring>@metamask/eth-sig-util": true, "@metamask/eth-snap-keyring>@metamask/utils": true, "@metamask/eth-snap-keyring>uuid": true, "@metamask/keyring-api": true, @@ -1129,6 +1114,17 @@ "webpack>events": true } }, + "@metamask/eth-snap-keyring>@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/eth-snap-keyring>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, "@metamask/eth-snap-keyring>@metamask/utils": { "globals": { "TextDecoder": true, @@ -1549,12 +1545,12 @@ "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/base-controller": true, "@metamask/browser-passworder": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-hd-keyring": true, + "@metamask/keyring-controller>@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-simple-keyring": true, - "@metamask/keyring-controller>@metamask/utils": true, "@metamask/keyring-controller>ethereumjs-wallet": true, - "@metamask/name-controller>async-mutex": true + "@metamask/name-controller>async-mutex": true, + "@metamask/utils": true } }, "@metamask/keyring-controller>@metamask/eth-hd-keyring": { @@ -1585,17 +1581,18 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "@metamask/keyring-controller>@metamask/eth-sig-util": { "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/eth-sig-util": true, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, - "browserify>buffer": true, - "crypto-browserify>randombytes": true + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -1610,7 +1607,17 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-sig-util": true, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, + "browserify>buffer": true, + "crypto-browserify>randombytes": true + } + }, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -2007,10 +2014,10 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/notification-services-controller>@contentful/rich-text-html-renderer": true, - "@metamask/notification-services-controller>@metamask/utils": true, + "@metamask/notification-services-controller>bignumber.js": true, "@metamask/notification-services-controller>firebase": true, "@metamask/profile-sync-controller": true, - "bignumber.js": true, + "@metamask/utils": true, "loglevel": true, "uuid": true } @@ -2020,19 +2027,10 @@ "SuppressedError": true } }, - "@metamask/notification-services-controller>@metamask/utils": { + "@metamask/notification-services-controller>bignumber.js": { "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 + "crypto": true, + "define": true } }, "@metamask/notification-services-controller>firebase": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index a5581cd16a85..30456a0bd61d 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -613,29 +613,14 @@ "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/accounts-controller>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/eth-snap-keyring": true, "@metamask/keyring-api": true, "@metamask/keyring-controller": true, + "@metamask/utils": true, "uuid": true } }, - "@metamask/accounts-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/address-book-controller": { "packages": { "@metamask/base-controller": true, @@ -1121,7 +1106,7 @@ }, "packages": { "@ethereumjs/tx": true, - "@metamask/eth-sig-util": true, + "@metamask/eth-snap-keyring>@metamask/eth-sig-util": true, "@metamask/eth-snap-keyring>@metamask/utils": true, "@metamask/eth-snap-keyring>uuid": true, "@metamask/keyring-api": true, @@ -1129,6 +1114,17 @@ "webpack>events": true } }, + "@metamask/eth-snap-keyring>@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/eth-snap-keyring>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, "@metamask/eth-snap-keyring>@metamask/utils": { "globals": { "TextDecoder": true, @@ -1549,12 +1545,12 @@ "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/base-controller": true, "@metamask/browser-passworder": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-hd-keyring": true, + "@metamask/keyring-controller>@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-simple-keyring": true, - "@metamask/keyring-controller>@metamask/utils": true, "@metamask/keyring-controller>ethereumjs-wallet": true, - "@metamask/name-controller>async-mutex": true + "@metamask/name-controller>async-mutex": true, + "@metamask/utils": true } }, "@metamask/keyring-controller>@metamask/eth-hd-keyring": { @@ -1585,17 +1581,18 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "@metamask/keyring-controller>@metamask/eth-sig-util": { "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/eth-sig-util": true, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, - "browserify>buffer": true, - "crypto-browserify>randombytes": true + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -1610,7 +1607,17 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-sig-util": true, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, + "browserify>buffer": true, + "crypto-browserify>randombytes": true + } + }, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -2007,10 +2014,10 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/notification-services-controller>@contentful/rich-text-html-renderer": true, - "@metamask/notification-services-controller>@metamask/utils": true, + "@metamask/notification-services-controller>bignumber.js": true, "@metamask/notification-services-controller>firebase": true, "@metamask/profile-sync-controller": true, - "bignumber.js": true, + "@metamask/utils": true, "loglevel": true, "uuid": true } @@ -2020,19 +2027,10 @@ "SuppressedError": true } }, - "@metamask/notification-services-controller>@metamask/utils": { + "@metamask/notification-services-controller>bignumber.js": { "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 + "crypto": true, + "define": true } }, "@metamask/notification-services-controller>firebase": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index a5581cd16a85..30456a0bd61d 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -613,29 +613,14 @@ "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/accounts-controller>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/eth-snap-keyring": true, "@metamask/keyring-api": true, "@metamask/keyring-controller": true, + "@metamask/utils": true, "uuid": true } }, - "@metamask/accounts-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/address-book-controller": { "packages": { "@metamask/base-controller": true, @@ -1121,7 +1106,7 @@ }, "packages": { "@ethereumjs/tx": true, - "@metamask/eth-sig-util": true, + "@metamask/eth-snap-keyring>@metamask/eth-sig-util": true, "@metamask/eth-snap-keyring>@metamask/utils": true, "@metamask/eth-snap-keyring>uuid": true, "@metamask/keyring-api": true, @@ -1129,6 +1114,17 @@ "webpack>events": true } }, + "@metamask/eth-snap-keyring>@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/eth-snap-keyring>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, "@metamask/eth-snap-keyring>@metamask/utils": { "globals": { "TextDecoder": true, @@ -1549,12 +1545,12 @@ "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/base-controller": true, "@metamask/browser-passworder": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-hd-keyring": true, + "@metamask/keyring-controller>@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-simple-keyring": true, - "@metamask/keyring-controller>@metamask/utils": true, "@metamask/keyring-controller>ethereumjs-wallet": true, - "@metamask/name-controller>async-mutex": true + "@metamask/name-controller>async-mutex": true, + "@metamask/utils": true } }, "@metamask/keyring-controller>@metamask/eth-hd-keyring": { @@ -1585,17 +1581,18 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "@metamask/keyring-controller>@metamask/eth-sig-util": { "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/eth-sig-util": true, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, - "browserify>buffer": true, - "crypto-browserify>randombytes": true + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -1610,7 +1607,17 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-sig-util": true, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, + "browserify>buffer": true, + "crypto-browserify>randombytes": true + } + }, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -2007,10 +2014,10 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/notification-services-controller>@contentful/rich-text-html-renderer": true, - "@metamask/notification-services-controller>@metamask/utils": true, + "@metamask/notification-services-controller>bignumber.js": true, "@metamask/notification-services-controller>firebase": true, "@metamask/profile-sync-controller": true, - "bignumber.js": true, + "@metamask/utils": true, "loglevel": true, "uuid": true } @@ -2020,19 +2027,10 @@ "SuppressedError": true } }, - "@metamask/notification-services-controller>@metamask/utils": { + "@metamask/notification-services-controller>bignumber.js": { "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 + "crypto": true, + "define": true } }, "@metamask/notification-services-controller>firebase": { diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 672c34d8faea..a604526f155d 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -705,29 +705,14 @@ "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/accounts-controller>@metamask/utils": true, "@metamask/base-controller": true, "@metamask/eth-snap-keyring": true, "@metamask/keyring-api": true, "@metamask/keyring-controller": true, + "@metamask/utils": true, "uuid": true } }, - "@metamask/accounts-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/address-book-controller": { "packages": { "@metamask/base-controller": true, @@ -1213,7 +1198,7 @@ }, "packages": { "@ethereumjs/tx": true, - "@metamask/eth-sig-util": true, + "@metamask/eth-snap-keyring>@metamask/eth-sig-util": true, "@metamask/eth-snap-keyring>@metamask/utils": true, "@metamask/eth-snap-keyring>uuid": true, "@metamask/keyring-api": true, @@ -1221,6 +1206,17 @@ "webpack>events": true } }, + "@metamask/eth-snap-keyring>@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/eth-snap-keyring>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true + } + }, "@metamask/eth-snap-keyring>@metamask/utils": { "globals": { "TextDecoder": true, @@ -1641,12 +1637,12 @@ "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/base-controller": true, "@metamask/browser-passworder": true, - "@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-hd-keyring": true, + "@metamask/keyring-controller>@metamask/eth-sig-util": true, "@metamask/keyring-controller>@metamask/eth-simple-keyring": true, - "@metamask/keyring-controller>@metamask/utils": true, "@metamask/keyring-controller>ethereumjs-wallet": true, - "@metamask/name-controller>async-mutex": true + "@metamask/name-controller>async-mutex": true, + "@metamask/utils": true } }, "@metamask/keyring-controller>@metamask/eth-hd-keyring": { @@ -1677,17 +1673,18 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "@metamask/keyring-controller>@metamask/eth-sig-util": { "packages": { "@ethereumjs/tx>@ethereumjs/util": true, "@ethereumjs/tx>ethereum-cryptography": true, - "@metamask/eth-sig-util": true, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, - "browserify>buffer": true, - "crypto-browserify>randombytes": true + "@metamask/abi-utils": true, + "@metamask/eth-sig-util>tweetnacl": true, + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": true, + "@metamask/utils>@scure/base": true, + "browserify>buffer": true } }, - "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-sig-util>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -1702,7 +1699,17 @@ "semver": true } }, - "@metamask/keyring-controller>@metamask/utils": { + "@metamask/keyring-controller>@metamask/eth-simple-keyring": { + "packages": { + "@ethereumjs/tx>@ethereumjs/util": true, + "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-sig-util": true, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": true, + "browserify>buffer": true, + "crypto-browserify>randombytes": true + } + }, + "@metamask/keyring-controller>@metamask/eth-simple-keyring>@metamask/utils": { "globals": { "TextDecoder": true, "TextEncoder": true @@ -2099,10 +2106,10 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/notification-services-controller>@contentful/rich-text-html-renderer": true, - "@metamask/notification-services-controller>@metamask/utils": true, + "@metamask/notification-services-controller>bignumber.js": true, "@metamask/notification-services-controller>firebase": true, "@metamask/profile-sync-controller": true, - "bignumber.js": true, + "@metamask/utils": true, "loglevel": true, "uuid": true } @@ -2112,19 +2119,10 @@ "SuppressedError": true } }, - "@metamask/notification-services-controller>@metamask/utils": { + "@metamask/notification-services-controller>bignumber.js": { "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 + "crypto": true, + "define": true } }, "@metamask/notification-services-controller>firebase": { diff --git a/package.json b/package.json index b1d69e0e16ea..0f963c3aab2b 100644 --- a/package.json +++ b/package.json @@ -290,11 +290,11 @@ "@metamask-institutional/types": "^1.2.0", "@metamask/abi-utils": "^2.0.2", "@metamask/account-watcher": "^4.1.1", - "@metamask/accounts-controller": "^18.2.2", + "@metamask/accounts-controller": "^20.0.0", "@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%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", @@ -308,7 +308,7 @@ "@metamask/eth-ledger-bridge-keyring": "^5.0.1", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^7.0.1", - "@metamask/eth-snap-keyring": "^4.4.0", + "@metamask/eth-snap-keyring": "^5.0.1", "@metamask/eth-token-tracker": "^8.0.0", "@metamask/eth-trezor-keyring": "^3.1.3", "@metamask/etherscan-link": "^3.0.0", @@ -319,8 +319,8 @@ "@metamask/jazzicon": "^2.0.0", "@metamask/json-rpc-engine": "^10.0.0", "@metamask/json-rpc-middleware-stream": "^8.0.4", - "@metamask/keyring-api": "^8.1.3", - "@metamask/keyring-controller": "^17.2.2", + "@metamask/keyring-api": "^10.1.0", + "@metamask/keyring-controller": "^19.0.0", "@metamask/logging-controller": "^6.0.0", "@metamask/logo": "^3.1.2", "@metamask/message-manager": "^10.1.0", @@ -329,7 +329,7 @@ "@metamask/name-controller": "^8.0.0", "@metamask/network-controller": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/notification-controller": "^6.0.0", - "@metamask/notification-services-controller": "^0.11.0", + "@metamask/notification-services-controller": "^0.14.0", "@metamask/object-multiplex": "^2.0.0", "@metamask/obs-store": "^9.0.0", "@metamask/permission-controller": "^10.0.0", @@ -339,7 +339,7 @@ "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.35.1", "@metamask/preinstalled-example-snap": "^0.2.0", - "@metamask/profile-sync-controller": "^1.0.2", + "@metamask/profile-sync-controller": "^2.0.0", "@metamask/providers": "^14.0.2", "@metamask/queued-request-controller": "^7.0.1", "@metamask/rate-limit-controller": "^6.0.0", @@ -347,7 +347,7 @@ "@metamask/safe-event-emitter": "^3.1.1", "@metamask/scure-bip39": "^2.0.3", "@metamask/selected-network-controller": "^18.0.2", - "@metamask/signature-controller": "^21.1.0", + "@metamask/signature-controller": "^23.0.0", "@metamask/smart-transactions-controller": "^13.0.0", "@metamask/snaps-controllers": "^9.13.0", "@metamask/snaps-execution-environments": "^6.10.0", @@ -355,7 +355,7 @@ "@metamask/snaps-sdk": "^6.11.0", "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", - "@metamask/transaction-controller": "^39.1.0", + "@metamask/transaction-controller": "^40.0.0", "@metamask/user-operation-controller": "^13.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.12", diff --git a/test/data/mock-accounts.ts b/test/data/mock-accounts.ts index 2273915f7a5f..b956dbadd6bb 100644 --- a/test/data/mock-accounts.ts +++ b/test/data/mock-accounts.ts @@ -42,7 +42,7 @@ export const MOCK_ACCOUNT_BIP122_P2WPKH: InternalAccount = { id: 'ae247df6-3911-47f7-9e36-28e6a7d96078', address: 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k', options: {}, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, metadata: { name: 'Bitcoin Account', @@ -56,7 +56,7 @@ export const MOCK_ACCOUNT_BIP122_P2WPKH_TESTNET: InternalAccount = { id: 'fcdafe8b-4bdf-4e25-9051-e255b2a0af5f', address: 'tb1q6rmsq3vlfdhjdhtkxlqtuhhlr6pmj09y6w43g8', options: {}, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, metadata: { name: 'Bitcoin Testnet Account', diff --git a/test/e2e/flask/create-watch-account.spec.ts b/test/e2e/flask/create-watch-account.spec.ts index 75fc1c66b0cd..71d47327536c 100644 --- a/test/e2e/flask/create-watch-account.spec.ts +++ b/test/e2e/flask/create-watch-account.spec.ts @@ -164,7 +164,10 @@ describe('Account-watcher snap', function (this: Suite) { }, { input: ACCOUNT_1, - message: `Unknown snap error: Account address '${ACCOUNT_1}' already exists`, + // FIXME: Watchout, the Snap bridge will lower-case EVM addresses, even in some error messages, this is + // a mistake, and we might wanna re-change that later, see: + // - https://github.com/MetaMask/accounts/pull/90/files#r1848713364 + message: `Unknown snap error: Account address '${ACCOUNT_1.toLowerCase()}' already exists`, description: 'existing address', }, ]; diff --git a/test/jest/mocks.ts b/test/jest/mocks.ts index be1120429290..bc7127fb2383 100644 --- a/test/jest/mocks.ts +++ b/test/jest/mocks.ts @@ -214,7 +214,7 @@ export function createMockInternalAccount({ ]; break; case BtcAccountType.P2wpkh: - methods = [BtcMethod.SendMany]; + methods = [BtcMethod.SendBitcoin]; break; default: throw new Error(`Unknown account type: ${type}`); diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index a1c069948074..3c5697cb5853 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -65,7 +65,7 @@ const mockNonEvmAccount = { }, }, options: {}, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, }; diff --git a/ui/components/ui/account-list/account-list.test.js b/ui/components/ui/account-list/account-list.test.js index e815b11069c5..274035710dc1 100644 --- a/ui/components/ui/account-list/account-list.test.js +++ b/ui/components/ui/account-list/account-list.test.js @@ -25,7 +25,7 @@ const mockNonEvmAccount = { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', name: 'BTC Account', type: BtcAccountType.P2wpkh, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], }), addressLabel: 'BTC Account', balance: '1', @@ -109,7 +109,7 @@ describe('AccountList', () => { }, }, options: {}, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', }, diff --git a/ui/helpers/utils/permissions.test.ts b/ui/helpers/utils/permissions.test.ts index 2ebfd42b2874..43857afb1815 100644 --- a/ui/helpers/utils/permissions.test.ts +++ b/ui/helpers/utils/permissions.test.ts @@ -11,7 +11,7 @@ const mockNonEvmAccount = { ...mockAccount, id: '4b94987c-165c-4287-bbc6-bee9c440e82a', type: BtcAccountType.P2wpkh, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', }; diff --git a/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts b/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts index d177986fee44..5efdbfd9b4ea 100644 --- a/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts +++ b/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts @@ -35,7 +35,7 @@ describe('useMultichainWalletSnapClient', () => { address: 'tb1q2hjrlnf8kmtt5dj6e49gqzy6jnpe0sj7ty50cl', id: '11a33c6b-0d46-43f4-a401-01587d575fd0', options: {}, - methods: [BtcMethod.SendMany], + methods: [BtcMethod.SendBitcoin], type: BtcAccountType.P2wpkh, }, }, diff --git a/yarn.lock b/yarn.lock index 65dc70847c4e..bc208e1a8f92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4873,25 +4873,25 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^18.2.2": - version: 18.2.2 - resolution: "@metamask/accounts-controller@npm:18.2.2" +"@metamask/accounts-controller@npm:^20.0.0": + version: 20.0.0 + resolution: "@metamask/accounts-controller@npm:20.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" - "@metamask/base-controller": "npm:^7.0.1" - "@metamask/eth-snap-keyring": "npm:^4.3.6" - "@metamask/keyring-api": "npm:^8.1.3" - "@metamask/snaps-sdk": "npm:^6.5.0" - "@metamask/snaps-utils": "npm:^8.1.1" - "@metamask/utils": "npm:^9.1.0" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/eth-snap-keyring": "npm:^5.0.1" + "@metamask/keyring-api": "npm:^10.1.0" + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/utils": "npm:^10.0.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" immer: "npm:^9.0.6" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/keyring-controller": ^17.0.0 + "@metamask/keyring-controller": ^19.0.0 "@metamask/snaps-controllers": ^9.7.0 - checksum: 10/095be37c94a577304425f80600d4ef847c83c702ccf3d6b1591602d1fe292bdd3273131e336d6108bd713bff38812dfc4d7b21d4075669cde24e12f117f2dd81 + checksum: 10/36f42d5d7db47c15eef4a7b72d8b19bcd08579a26db452974e76b527e47ef71e63bea47a4f1992fd2eadce44be4020f596dc7049f59766d6aa1b857c4518664f languageName: node linkType: hard @@ -4934,9 +4934,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:44.1.0": - version: 44.1.0 - resolution: "@metamask/assets-controllers@npm:44.1.0" +"@metamask/assets-controllers@npm:45.0.0": + version: 45.0.0 + resolution: "@metamask/assets-controllers@npm:45.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -4964,18 +4964,18 @@ __metadata: single-call-balance-checker-abi: "npm:^1.0.0" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^19.0.0 + "@metamask/accounts-controller": ^20.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^18.0.0 + "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/preferences-controller": ^14.0.0 - checksum: 10/924c67fba204711ddde4be6615359318ed0fbdd05ebd8e5d98ae9d9ae288adad5cb6fc901b91d8e84f92a6ab62f0bfb25601b03c676044009f81a7fffa8087e7 + "@metamask/preferences-controller": ^15.0.0 + checksum: 10/0ad51464cf060f1c2cab56c2c8d9daa5f29987e8ead69c0e029fb8357fa5c629434116de5663dc38a57c11b3736b6c7d9b1db9b6892a453fbc3f9c6965d42295 languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch": - version: 44.1.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch::version=44.1.0&hash=423db2" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch": + version: 45.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch::version=45.0.0&hash=8e5354" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5003,12 +5003,12 @@ __metadata: single-call-balance-checker-abi: "npm:^1.0.0" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^19.0.0 + "@metamask/accounts-controller": ^20.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^18.0.0 + "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/preferences-controller": ^14.0.0 - checksum: 10/5e3b0109e6b5c0d65338a18b2c590d15229003e05c55cf0013d8e32687bbe774de05872a7b61038aa90177a6ce01b32814c3c680ee3c10cbad8cba9db2d796aa + "@metamask/preferences-controller": ^15.0.0 + checksum: 10/823627b5bd23829d81a54291f74c4ddf52d0732a840c121c4ae7f1fc468dd98f3fc1e64b7f8a9bbaaa76cd6670082f2976e5e6ecf872e04c212a5c8ec5fe4916 languageName: node linkType: hard @@ -5472,7 +5472,7 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^4.3.1, @metamask/eth-snap-keyring@npm:^4.3.6, @metamask/eth-snap-keyring@npm:^4.4.0": +"@metamask/eth-snap-keyring@npm:^4.3.1": version: 4.4.0 resolution: "@metamask/eth-snap-keyring@npm:4.4.0" dependencies: @@ -5491,6 +5491,25 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-snap-keyring@npm:^5.0.1": + version: 5.0.1 + resolution: "@metamask/eth-snap-keyring@npm:5.0.1" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/eth-sig-util": "npm:^8.0.0" + "@metamask/snaps-controllers": "npm:^9.10.0" + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^9.2.1" + "@types/uuid": "npm:^9.0.8" + uuid: "npm:^9.0.1" + peerDependencies: + "@metamask/keyring-api": ^10.1.0 + checksum: 10/4d9d700b7c2ecc1b17e92f716f7aeb04bbd03836601b5d37f639bed7fba4d5f00bafadf5359d2416c319cdf18eb2f9417c7353654737af87a6e8579d5e5bab79 + languageName: node + linkType: hard + "@metamask/eth-token-tracker@npm:^8.0.0": version: 8.0.0 resolution: "@metamask/eth-token-tracker@npm:8.0.0" @@ -5755,6 +5774,23 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-api@npm:^10.1.0": + version: 10.1.0 + resolution: "@metamask/keyring-api@npm:10.1.0" + dependencies: + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^9.2.1" + "@types/uuid": "npm:^9.0.8" + bech32: "npm:^2.0.0" + uuid: "npm:^9.0.1" + webextension-polyfill: "npm:^0.12.0" + peerDependencies: + "@metamask/providers": ^18.1.0 + checksum: 10/de22b9f5f3aecc290210fa78161e157aa8358f8dad421a093c9f6dbe35c4755067472a732f10d1ddbfba789e871c64edd8ea1c4c7316a392b214a187efd46ebe + languageName: node + linkType: hard + "@metamask/keyring-api@npm:^8.0.0, @metamask/keyring-api@npm:^8.1.3": version: 8.1.3 resolution: "@metamask/keyring-api@npm:8.1.3" @@ -5788,7 +5824,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^17.1.0, @metamask/keyring-controller@npm:^17.2.2": +"@metamask/keyring-controller@npm:^17.1.0": version: 17.2.2 resolution: "@metamask/keyring-controller@npm:17.2.2" dependencies: @@ -5809,9 +5845,9 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^18.0.0": - version: 18.0.0 - resolution: "@metamask/keyring-controller@npm:18.0.0" +"@metamask/keyring-controller@npm:^19.0.0": + version: 19.0.0 + resolution: "@metamask/keyring-controller@npm:19.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@keystonehq/metamask-airgapped-keyring": "npm:^0.14.1" @@ -5820,13 +5856,13 @@ __metadata: "@metamask/eth-hd-keyring": "npm:^7.0.4" "@metamask/eth-sig-util": "npm:^8.0.0" "@metamask/eth-simple-keyring": "npm:^6.0.5" - "@metamask/keyring-api": "npm:^8.1.3" + "@metamask/keyring-api": "npm:^10.1.0" "@metamask/message-manager": "npm:^11.0.1" "@metamask/utils": "npm:^10.0.0" async-mutex: "npm:^0.5.0" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" - checksum: 10/c301e4e8b9ac9da914bfaa371a43342aa37f5bb8ad107bbbd92f1d21a13c22351619f8bd6176493b808f4194aa9934bce5618ff0aed12325933f4330cdfd308e + checksum: 10/4614b53f9dd053edcc470e1d949a57ee5982b544c3ea9c6635b6a2d40fef5f2bd606aa8a45d3a5c519e93dc60c45ba5a5efafefb2c9843c85522477335d208a2 languageName: node linkType: hard @@ -6022,22 +6058,22 @@ __metadata: languageName: node linkType: hard -"@metamask/notification-services-controller@npm:^0.11.0": - version: 0.11.0 - resolution: "@metamask/notification-services-controller@npm:0.11.0" +"@metamask/notification-services-controller@npm:^0.14.0": + version: 0.14.0 + resolution: "@metamask/notification-services-controller@npm:0.14.0" dependencies: "@contentful/rich-text-html-renderer": "npm:^16.5.2" - "@metamask/base-controller": "npm:^7.0.1" - "@metamask/controller-utils": "npm:^11.3.0" - "@metamask/utils": "npm:^9.1.0" - bignumber.js: "npm:^4.1.0" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/utils": "npm:^10.0.0" + bignumber.js: "npm:^9.1.2" firebase: "npm:^10.11.0" loglevel: "npm:^1.8.1" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/keyring-controller": ^17.0.0 - "@metamask/profile-sync-controller": ^0.0.0 - checksum: 10/d6f7498d74794b7bd586700729b003dddcce2c19fdf0befcf4786fd9661e7ee960164112c3a347077a2a346e20fe94ec14afcdb76eaa49c58838de5a6985401c + "@metamask/keyring-controller": ^19.0.0 + "@metamask/profile-sync-controller": ^2.0.0 + checksum: 10/9cc467eb0d5f3ece77c77480d301fe7322545b4826b06c3a15345376ad77923f79155d3276febe044f46d3d91166ca67660f23dc486ce24fce6727e42f35b78a languageName: node linkType: hard @@ -6248,27 +6284,27 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^1.0.2": - version: 1.0.2 - resolution: "@metamask/profile-sync-controller@npm:1.0.2" +"@metamask/profile-sync-controller@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/profile-sync-controller@npm:2.0.0" dependencies: "@metamask/base-controller": "npm:^7.0.2" - "@metamask/keyring-api": "npm:^8.1.3" - "@metamask/keyring-controller": "npm:^18.0.0" + "@metamask/keyring-api": "npm:^10.1.0" + "@metamask/keyring-controller": "npm:^19.0.0" "@metamask/network-controller": "npm:^22.0.2" - "@metamask/snaps-sdk": "npm:^6.5.0" - "@metamask/snaps-utils": "npm:^8.1.1" + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/snaps-utils": "npm:^8.3.0" "@noble/ciphers": "npm:^0.5.2" "@noble/hashes": "npm:^1.4.0" immer: "npm:^9.0.6" loglevel: "npm:^1.8.1" siwe: "npm:^2.3.2" peerDependencies: - "@metamask/accounts-controller": ^19.0.0 - "@metamask/keyring-controller": ^18.0.0 + "@metamask/accounts-controller": ^20.0.0 + "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/snaps-controllers": ^9.7.0 - checksum: 10/e8ce9cc5749746bea3f6fb9207bbd4e8e3956f92447f3a6b790e3ba7203747e38b9a819f7a4f1896022cf6e1a065e6136a3c82ee83a4ec0ee56b23de27e23f03 + "@metamask/snaps-controllers": ^9.10.0 + checksum: 10/72c3cb3ea4148960c7eb4615a10f0f508fda285e6786906f2b0d95cfaca624425973bce47e5478c86de0f9ad3cb44a9637bbe3d9e43e4d75fe6d867d63aa0342 languageName: node linkType: hard @@ -6393,12 +6429,12 @@ __metadata: languageName: node linkType: hard -"@metamask/signature-controller@npm:^21.1.0": - version: 21.1.0 - resolution: "@metamask/signature-controller@npm:21.1.0" +"@metamask/signature-controller@npm:^23.0.0": + version: 23.0.0 + resolution: "@metamask/signature-controller@npm:23.0.0" dependencies: "@metamask/base-controller": "npm:^7.0.2" - "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-sig-util": "npm:^8.0.0" "@metamask/utils": "npm:^10.0.0" jsonschema: "npm:^1.2.4" @@ -6406,10 +6442,10 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^17.0.0 + "@metamask/keyring-controller": ^19.0.0 "@metamask/logging-controller": ^6.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/00d28234d6402632ecf000d7c908a134a0f49cbbdb165a7cfe72895bc91248de82947cd9628dbbe953ffb8b6054f84a0dc1ad824a1aff369d8f2189a78fd56a9 + checksum: 10/5e2fda2d89dd3433f00028da0fa7743a6934b72f33fc0e4803dafa98702b9bdd9d093a326060d5480e6eb065c6b4cc1dc3e39382c00702f28b5a6061e8f105bf languageName: node linkType: hard @@ -6579,7 +6615,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.1.1, @metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": +"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": version: 8.6.0 resolution: "@metamask/snaps-utils@npm:8.6.0" dependencies: @@ -6702,9 +6738,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^39.1.0": - version: 39.1.0 - resolution: "@metamask/transaction-controller@npm:39.1.0" +"@metamask/transaction-controller@npm:^40.0.0": + version: 40.0.0 + resolution: "@metamask/transaction-controller@npm:40.0.0" dependencies: "@ethereumjs/common": "npm:^3.2.0" "@ethereumjs/tx": "npm:^4.2.0" @@ -6727,11 +6763,11 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.23.9 - "@metamask/accounts-controller": ^19.0.0 + "@metamask/accounts-controller": ^20.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/gas-fee-controller": ^22.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/9c18f01167ca70556323190c3b3b8df29d5c1d45846e6d50208b49d27bd3d361ab89f103d5f4a784bbc70cee3e5ef595bab8cf568926c790236d32ace07a1283 + checksum: 10/1325f5d264e4351dfeee664bba601d873b2204eb82ccb840ab7934fa27f48e31c5f47a60f0a4b4baa94b41ac801121c498bb28102a65cbe59a0456630d4e0138 languageName: node linkType: hard @@ -26775,12 +26811,12 @@ __metadata: "@metamask-institutional/types": "npm:^1.2.0" "@metamask/abi-utils": "npm:^2.0.2" "@metamask/account-watcher": "npm:^4.1.1" - "@metamask/accounts-controller": "npm:^18.2.2" + "@metamask/accounts-controller": "npm:^20.0.0" "@metamask/address-book-controller": "npm:^6.0.0" "@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%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" @@ -26803,7 +26839,7 @@ __metadata: "@metamask/eth-ledger-bridge-keyring": "npm:^5.0.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^7.0.1" - "@metamask/eth-snap-keyring": "npm:^4.4.0" + "@metamask/eth-snap-keyring": "npm:^5.0.1" "@metamask/eth-token-tracker": "npm:^8.0.0" "@metamask/eth-trezor-keyring": "npm:^3.1.3" "@metamask/etherscan-link": "npm:^3.0.0" @@ -26815,8 +26851,8 @@ __metadata: "@metamask/jazzicon": "npm:^2.0.0" "@metamask/json-rpc-engine": "npm:^10.0.0" "@metamask/json-rpc-middleware-stream": "npm:^8.0.4" - "@metamask/keyring-api": "npm:^8.1.3" - "@metamask/keyring-controller": "npm:^17.2.2" + "@metamask/keyring-api": "npm:^10.1.0" + "@metamask/keyring-controller": "npm:^19.0.0" "@metamask/logging-controller": "npm:^6.0.0" "@metamask/logo": "npm:^3.1.2" "@metamask/message-manager": "npm:^10.1.0" @@ -26825,7 +26861,7 @@ __metadata: "@metamask/name-controller": "npm:^8.0.0" "@metamask/network-controller": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch" "@metamask/notification-controller": "npm:^6.0.0" - "@metamask/notification-services-controller": "npm:^0.11.0" + "@metamask/notification-services-controller": "npm:^0.14.0" "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/obs-store": "npm:^9.0.0" "@metamask/permission-controller": "npm:^10.0.0" @@ -26837,7 +26873,7 @@ __metadata: "@metamask/ppom-validator": "npm:0.35.1" "@metamask/preferences-controller": "npm:^13.0.2" "@metamask/preinstalled-example-snap": "npm:^0.2.0" - "@metamask/profile-sync-controller": "npm:^1.0.2" + "@metamask/profile-sync-controller": "npm:^2.0.0" "@metamask/providers": "npm:^14.0.2" "@metamask/queued-request-controller": "npm:^7.0.1" "@metamask/rate-limit-controller": "npm:^6.0.0" @@ -26845,7 +26881,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:^21.1.0" + "@metamask/signature-controller": "npm:^23.0.0" "@metamask/smart-transactions-controller": "npm:^13.0.0" "@metamask/snaps-controllers": "npm:^9.13.0" "@metamask/snaps-execution-environments": "npm:^6.10.0" @@ -26855,7 +26891,7 @@ __metadata: "@metamask/solana-wallet-snap": "npm:^0.1.9" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:8.13.0" - "@metamask/transaction-controller": "npm:^39.1.0" + "@metamask/transaction-controller": "npm:^40.0.0" "@metamask/user-operation-controller": "npm:^13.0.0" "@metamask/utils": "npm:^10.0.1" "@ngraveio/bc-ur": "npm:^1.1.12" From c574e23ec5c379e908bd163d0c169b1bc879f75e Mon Sep 17 00:00:00 2001 From: David Walsh Date: Fri, 22 Nov 2024 11:33:29 -0600 Subject: [PATCH 049/148] fix: Provide maximal asset list filter space (#28590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** "All networks" is being ellipsized in the popup view. This maximum length is not useful; removing it makes it still ellipsize well. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28590?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Open the popup 2. See "All networks" text fully 3. Open the console elements panel and set an account name a mile long 4. See it ellipsize properly ## **Screenshots/Recordings** ### **Before** ### **After** SCR-20241120-scod ## **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/assets/asset-list/asset-list-control-bar/index.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss index 1fee45c33a87..b133586371c3 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss @@ -2,10 +2,6 @@ padding-top: 4px; padding-bottom: 4px; - &__button { - max-width: 35%; - } - &__network_control { justify-content: space-between; width: auto; From 89a06d23faac01b7bee3bbce656057f439bc56bf Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 22 Nov 2024 14:13:01 -0330 Subject: [PATCH 050/148] chore: Run MMI tests on long-running branches (#28651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `check_mmi_trigger.sh` script has been updated to run MMI-related tests on long-running branches (`develop`, `master`, and RCs). This is intended to make it easier for us to detect these regressions sooner, so that we don't waste time tracking down the commit that caused any given breakage. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28651?quickstart=1) ## **Related issues** No linked issue. This is intended to make it safer to extend the use of this MMI filter to other jobs, which will help us save CircleCI credits ## **Manual testing steps** I tested this locally by running the script with the `CIRCLE_PULL_REQUEST`, `GITHUB_TOKEN`, and `CIRCLE_BRANCH` environment variables set. e.g. ``` > CIRCLE_PULL_REQUEST=foo GITHUB_TOKEN=foo CIRCLE_BRANCH=Version-v12.0.0 ./.circleci/scripts/check_mmi_trigger.sh Long-running branch detected, running MMI tests. ``` ## **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/scripts/check_mmi_trigger.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.circleci/scripts/check_mmi_trigger.sh b/.circleci/scripts/check_mmi_trigger.sh index 2de2f69044d4..c8d6fc44523b 100755 --- a/.circleci/scripts/check_mmi_trigger.sh +++ b/.circleci/scripts/check_mmi_trigger.sh @@ -9,6 +9,12 @@ if [ -z "$CIRCLE_PULL_REQUEST" ] || [ -z "$GITHUB_TOKEN" ]; then exit 0 fi +if [[ $CIRCLE_BRANCH = 'develop' || $CIRCLE_BRANCH = 'master' || $CIRCLE_BRANCH =~ ^Version-v[0-9.]* ]]; then + echo "Long-running branch detected, running MMI tests." + echo "run_mmi_tests=true" > mmi_trigger.env + exit 0 +fi + # Extract PR number from the pull request URL PR_NUMBER=$(echo "$CIRCLE_PULL_REQUEST" | awk -F'/' '{print $NF}') From b38244b877cea0e1aace85dc45d1dab9c74b0258 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Fri, 22 Nov 2024 19:22:11 +0100 Subject: [PATCH 051/148] fix: add unit test for assets polling loops (#28646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** the goal of this PR is to add unit tests for all multichain assets polling loops [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28646?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. --- ui/hooks/useAccountTrackerPolling.test.ts | 143 +++++++++++++++++++ ui/hooks/useCurrencyRatePolling.test.ts | 128 +++++++++++++++++ ui/hooks/useTokenDetectionPolling.test.ts | 157 +++++++++++++++++++++ ui/hooks/useTokenRatesPolling.test.ts | 160 ++++++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100644 ui/hooks/useAccountTrackerPolling.test.ts create mode 100644 ui/hooks/useCurrencyRatePolling.test.ts create mode 100644 ui/hooks/useTokenDetectionPolling.test.ts create mode 100644 ui/hooks/useTokenRatesPolling.test.ts diff --git a/ui/hooks/useAccountTrackerPolling.test.ts b/ui/hooks/useAccountTrackerPolling.test.ts new file mode 100644 index 000000000000..010085ddce61 --- /dev/null +++ b/ui/hooks/useAccountTrackerPolling.test.ts @@ -0,0 +1,143 @@ +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { + accountTrackerStartPolling, + accountTrackerStopPollingByPollingToken, +} from '../store/actions'; +import useAccountTrackerPolling from './useAccountTrackerPolling'; + +let mockPromises: Promise[]; + +jest.mock('../store/actions', () => ({ + accountTrackerStartPolling: jest.fn().mockImplementation((input) => { + const promise = Promise.resolve(`${input}_tracking`); + mockPromises.push(promise); + return promise; + }), + accountTrackerStopPollingByPollingToken: jest.fn(), +})); + +let originalPortfolioView: string | undefined; + +describe('useAccountTrackerPolling', () => { + beforeEach(() => { + // Mock process.env.PORTFOLIO_VIEW + originalPortfolioView = process.env.PORTFOLIO_VIEW; + process.env.PORTFOLIO_VIEW = 'true'; // Set your desired mock value here + + mockPromises = []; + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore the original value + process.env.PORTFOLIO_VIEW = originalPortfolioView; + }); + + it('should poll account trackers for network client IDs when enabled and stop on dismount', async () => { + process.env.PORTFOLIO_VIEW = 'true'; + + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + chainId: '0x89', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId2', + }, + ], + }, + }, + }, + }; + + const { unmount } = renderHookWithProvider( + () => useAccountTrackerPolling(), + state, + ); + + // Should poll each client ID + await Promise.all(mockPromises); + expect(accountTrackerStartPolling).toHaveBeenCalledTimes(2); + expect(accountTrackerStartPolling).toHaveBeenCalledWith( + 'selectedNetworkClientId', + ); + expect(accountTrackerStartPolling).toHaveBeenCalledWith( + 'selectedNetworkClientId2', + ); + + // Stop polling on dismount + unmount(); + expect(accountTrackerStopPollingByPollingToken).toHaveBeenCalledTimes(2); + expect(accountTrackerStopPollingByPollingToken).toHaveBeenCalledWith( + 'selectedNetworkClientId_tracking', + ); + }); + + it('should not poll if onboarding is not completed', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: false, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useAccountTrackerPolling(), state); + + await Promise.all(mockPromises); + expect(accountTrackerStartPolling).toHaveBeenCalledTimes(0); + expect(accountTrackerStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when locked', async () => { + const state = { + metamask: { + isUnlocked: false, + completedOnboarding: true, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useAccountTrackerPolling(), state); + + await Promise.all(mockPromises); + expect(accountTrackerStartPolling).toHaveBeenCalledTimes(0); + expect(accountTrackerStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when no network client IDs are provided', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useAccountTrackerPolling(), state); + + await Promise.all(mockPromises); + expect(accountTrackerStartPolling).toHaveBeenCalledTimes(0); + expect(accountTrackerStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/hooks/useCurrencyRatePolling.test.ts b/ui/hooks/useCurrencyRatePolling.test.ts new file mode 100644 index 000000000000..9ad405a31c46 --- /dev/null +++ b/ui/hooks/useCurrencyRatePolling.test.ts @@ -0,0 +1,128 @@ +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { + currencyRateStartPolling, + currencyRateStopPollingByPollingToken, +} from '../store/actions'; +import useCurrencyRatePolling from './useCurrencyRatePolling'; + +let mockPromises: Promise[]; + +jest.mock('../store/actions', () => ({ + currencyRateStartPolling: jest.fn().mockImplementation((input) => { + const promise = Promise.resolve(`${input}_rates`); + mockPromises.push(promise); + return promise; + }), + currencyRateStopPollingByPollingToken: jest.fn(), +})); + +describe('useCurrencyRatePolling', () => { + beforeEach(() => { + mockPromises = []; + jest.clearAllMocks(); + }); + + it('should poll currency rates for native currencies when enabled and stop on dismount', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useCurrencyRateCheck: true, + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + nativeCurrency: 'BNB', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId2', + }, + ], + }, + }, + }, + }; + + const { unmount } = renderHookWithProvider( + () => useCurrencyRatePolling(), + state, + ); + + await Promise.all(mockPromises); + expect(currencyRateStartPolling).toHaveBeenCalledTimes(1); + expect(currencyRateStartPolling).toHaveBeenCalledWith(['ETH', 'BNB']); + + // Stop polling on dismount + unmount(); + expect(currencyRateStopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect(currencyRateStopPollingByPollingToken).toHaveBeenCalledWith( + 'ETH,BNB_rates', + ); + }); + + it('should not poll if onboarding is not completed', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: false, + useCurrencyRateCheck: true, + networkConfigurationsByChainId: { + '0x1': { nativeCurrency: 'ETH' }, + }, + }, + }; + + renderHookWithProvider(() => useCurrencyRatePolling(), state); + + await Promise.all(mockPromises); + expect(currencyRateStartPolling).toHaveBeenCalledTimes(0); + expect(currencyRateStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when locked', async () => { + const state = { + metamask: { + isUnlocked: false, + completedOnboarding: true, + useCurrencyRateCheck: true, + networkConfigurationsByChainId: { + '0x1': { nativeCurrency: 'ETH' }, + }, + }, + }; + + renderHookWithProvider(() => useCurrencyRatePolling(), state); + + await Promise.all(mockPromises); + expect(currencyRateStartPolling).toHaveBeenCalledTimes(0); + expect(currencyRateStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when currency rate checking is disabled', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useCurrencyRateCheck: false, + networkConfigurationsByChainId: { + '0x1': { nativeCurrency: 'ETH' }, + }, + }, + }; + + renderHookWithProvider(() => useCurrencyRatePolling(), state); + + await Promise.all(mockPromises); + expect(currencyRateStartPolling).toHaveBeenCalledTimes(0); + expect(currencyRateStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/hooks/useTokenDetectionPolling.test.ts b/ui/hooks/useTokenDetectionPolling.test.ts new file mode 100644 index 000000000000..bae369ffd525 --- /dev/null +++ b/ui/hooks/useTokenDetectionPolling.test.ts @@ -0,0 +1,157 @@ +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { + tokenDetectionStartPolling, + tokenDetectionStopPollingByPollingToken, +} from '../store/actions'; +import useTokenDetectionPolling from './useTokenDetectionPolling'; + +let mockPromises: Promise[]; + +jest.mock('../store/actions', () => ({ + tokenDetectionStartPolling: jest.fn().mockImplementation((input) => { + const promise = Promise.resolve(`${input}_detection`); + mockPromises.push(promise); + return promise; + }), + tokenDetectionStopPollingByPollingToken: jest.fn(), +})); +let originalPortfolioView: string | undefined; + +describe('useTokenDetectionPolling', () => { + beforeEach(() => { + // Mock process.env.PORTFOLIO_VIEW + originalPortfolioView = process.env.PORTFOLIO_VIEW; + process.env.PORTFOLIO_VIEW = 'true'; // Set your desired mock value here + + mockPromises = []; + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore the original value + process.env.PORTFOLIO_VIEW = originalPortfolioView; + }); + + it('should poll token detection for chain IDs when enabled and stop on dismount', async () => { + process.env.PORTFOLIO_VIEW = 'true'; + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useTokenDetection: true, + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId2', + }, + ], + }, + }, + }, + }; + + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + state, + ); + + // Should poll each chain + await Promise.all(mockPromises); + expect(tokenDetectionStartPolling).toHaveBeenCalledTimes(1); + expect(tokenDetectionStartPolling).toHaveBeenCalledWith(['0x1', '0x89']); + + // Stop polling on dismount + unmount(); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledWith( + '0x1,0x89_detection', + ); + }); + + it('should not poll if onboarding is not completed', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: false, + useTokenDetection: true, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenDetectionPolling(), state); + + await Promise.all(mockPromises); + expect(tokenDetectionStartPolling).toHaveBeenCalledTimes(0); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when locked', async () => { + const state = { + metamask: { + isUnlocked: false, + completedOnboarding: true, + useTokenDetection: true, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenDetectionPolling(), state); + + await Promise.all(mockPromises); + expect(tokenDetectionStartPolling).toHaveBeenCalledTimes(0); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when token detection is disabled', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useTokenDetection: false, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenDetectionPolling(), state); + + await Promise.all(mockPromises); + expect(tokenDetectionStartPolling).toHaveBeenCalledTimes(0); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when no chains are provided', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useTokenDetection: true, + networkConfigurationsByChainId: { + '0x1': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenDetectionPolling(), state); + + await Promise.all(mockPromises); + expect(tokenDetectionStartPolling).toHaveBeenCalledTimes(0); + expect(tokenDetectionStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/hooks/useTokenRatesPolling.test.ts b/ui/hooks/useTokenRatesPolling.test.ts new file mode 100644 index 000000000000..9383527e6fbd --- /dev/null +++ b/ui/hooks/useTokenRatesPolling.test.ts @@ -0,0 +1,160 @@ +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { + tokenRatesStartPolling, + tokenRatesStopPollingByPollingToken, +} from '../store/actions'; +import useTokenRatesPolling from './useTokenRatesPolling'; + +let mockPromises: Promise[]; + +jest.mock('../store/actions', () => ({ + tokenRatesStartPolling: jest.fn().mockImplementation((input) => { + const promise = Promise.resolve(`${input}_rates`); + mockPromises.push(promise); + return promise; + }), + tokenRatesStopPollingByPollingToken: jest.fn(), +})); + +let originalPortfolioView: string | undefined; +describe('useTokenRatesPolling', () => { + beforeEach(() => { + // Mock process.env.PORTFOLIO_VIEW + originalPortfolioView = process.env.PORTFOLIO_VIEW; + process.env.PORTFOLIO_VIEW = 'true'; // Set your desired mock value here + + mockPromises = []; + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore the original value + process.env.PORTFOLIO_VIEW = originalPortfolioView; + }); + + it('should poll token rates when enabled and stop on dismount', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useCurrencyRateCheck: true, + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId2', + }, + ], + }, + }, + }, + }; + + const { unmount } = renderHookWithProvider( + () => useTokenRatesPolling(), + state, + ); + + // Should poll each chain + await Promise.all(mockPromises); + expect(tokenRatesStartPolling).toHaveBeenCalledTimes(2); + expect(tokenRatesStartPolling).toHaveBeenCalledWith('0x1'); + expect(tokenRatesStartPolling).toHaveBeenCalledWith('0x89'); + // Stop polling on dismount + unmount(); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledTimes(2); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledWith( + '0x1_rates', + ); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledWith( + '0x89_rates', + ); + }); + + it('should not poll if onboarding is not completed', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: false, + useCurrencyRateCheck: true, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenRatesPolling(), state); + + await Promise.all(mockPromises); + expect(tokenRatesStartPolling).toHaveBeenCalledTimes(0); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when locked', async () => { + const state = { + metamask: { + isUnlocked: false, + completedOnboarding: true, + useCurrencyRateCheck: true, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenRatesPolling(), state); + + await Promise.all(mockPromises); + expect(tokenRatesStartPolling).toHaveBeenCalledTimes(0); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when rate checking is disabled', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useCurrencyRateCheck: false, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenRatesPolling(), state); + + await Promise.all(mockPromises); + expect(tokenRatesStartPolling).toHaveBeenCalledTimes(0); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when no chains are provided', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useCurrencyRateCheck: true, + networkConfigurationsByChainId: {}, + }, + }; + + renderHookWithProvider(() => useTokenRatesPolling(), state); + + await Promise.all(mockPromises); + expect(tokenRatesStartPolling).toHaveBeenCalledTimes(0); + expect(tokenRatesStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); +}); From ad9a74841748e4353c2e1ee749208dc1cf099c28 Mon Sep 17 00:00:00 2001 From: jiexi Date: Fri, 22 Nov 2024 12:10:59 -0800 Subject: [PATCH 052/148] fix: Reset streams on BFCache events (#24950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Previously, Chrome's BFCache (Back Forward) strategy was to evict a page from cache if it received a message over any connected port streams. The port stream would remain open and the background script would NOT receive an onDisconnect. As long as the cached page did not receive a message, the port would still function when the page became active again. This was problematic because MetaMask was likely to send a message to the still connected cached page at some point due to the nature of notifications, which would evict the page from cache, neutralizing the performance benefit of the BFCache for the end user. Now, Chrome's BFCache strategy is to trigger an onDisconnect for the background script, but NOT the cached page. The port stream is invalid despite not being closed on the cached page side. This is problematic because we do not listen for events relevant to when a BFCached page becomes active and thus do not reset the invalid stream. To address both strategies, we now listen for the `pageshow` and `pagehide` events. When a page is entering a BFCached state, we preemptively end the port stream connection (even if the user is on an older version of chrome that would have kept it alive). When a BFCached page is restored to an active state, we establish a port stream connection. We know the port stream must be restored/reset because we were the ones responsible for preemptively ending it earlier in the lifecycle. Combining these two changes allows us to handle both the old and new BFCache strategies without having to target them by versions separately. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/24950?quickstart=1) ## **Related issues** See: https://developer.chrome.com/blog/bfcache-extension-messaging-changes?hl=en See: https://github.com/MetaMask/metamask-extension/issues/13373 See: https://web.dev/articles/bfcache (useful links at bottom) See: https://github.com/w3c/webextensions/issues/474 Fixes: https://github.com/MetaMask/MetaMask-planning/issues/2582 ## **Manual testing steps** Steps are for macOS. Using chrome 123 or newer **Testing with old BFCache strategy** 1. Close the entire chrome app 1. run `open /Applications/Google\ Chrome.app --args --disable-features=DisconnectExtensionMessagePortWhenPageEntersBFCache` 1. Visit `http://www.brainjar.com/java/host/test.html` 1. Open console 1. Enter `await window.ethereum.request({method: 'eth_chainId'})`, which should be responsive 1. Visit `chrome://terms/` 1. Use the back button to go back to the brainjar test page 1. Enter `await window.ethereum.request({method: 'eth_chainId'})`, which should be responsive **Testing with the new BFCache strategy** Repeat the steps above, but use `--enable-features` instead of `disable` MetaMask Behavior should look the same regardless of browser's BFCache strategy ## **Screenshots/Recordings** BFCache Behavior https://github.com/MetaMask/metamask-extension/assets/918701/efeb1591-5fde-44c8-b0a3-3573dfb97806 Prerender Behavior (to show affected chromium browsers still reset streams correctly) https://github.com/MetaMask/metamask-extension/assets/918701/7461bf64-b5b0-4e70-96d5-416cf5bf6b7c ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask 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. --- app/scripts/contentscript.js | 16 +++++++ app/scripts/streams/provider-stream.ts | 32 +++++++++---- test/e2e/provider/bfcache.spec.js | 65 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 test/e2e/provider/bfcache.spec.js diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index 7c427a1821c5..a38ac9fe5833 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -1,8 +1,10 @@ import { getIsBrowserPrerenderBroken } from '../../shared/modules/browser-runtime.utils'; import shouldInjectProvider from '../../shared/modules/provider-injection'; import { + destroyStreams, initStreams, onDisconnectDestroyStreams, + setupExtensionStreams, } from './streams/provider-stream'; import { isDetectedPhishingSite, @@ -33,6 +35,20 @@ const start = () => { ); }); } + + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + console.warn('BFCached page has become active. Restoring the streams.'); + setupExtensionStreams(); + } + }); + + window.addEventListener('pagehide', (event) => { + if (event.persisted) { + console.warn('Page may become BFCached. Destroying the streams.'); + destroyStreams(); + } + }); } }; diff --git a/app/scripts/streams/provider-stream.ts b/app/scripts/streams/provider-stream.ts index 9616253815e8..82b159130242 100644 --- a/app/scripts/streams/provider-stream.ts +++ b/app/scripts/streams/provider-stream.ts @@ -33,7 +33,7 @@ let legacyExtMux: ObjectMultiplex, let extensionMux: ObjectMultiplex, extensionChannel: Substream, - extensionPort: browser.Runtime.Port, + extensionPort: browser.Runtime.Port | null, extensionStream: PortStream | null, pageMux: ObjectMultiplex, pageChannel: Substream; @@ -65,7 +65,7 @@ const setupPageStreams = () => { // The field below is used to ensure that replay is done only once for each restart. let METAMASK_EXTENSION_CONNECT_SENT = false; -const setupExtensionStreams = () => { +export const setupExtensionStreams = () => { METAMASK_EXTENSION_CONNECT_SENT = true; extensionPort = browser.runtime.connect({ name: CONTENT_SCRIPT }); extensionStream = new PortStream(extensionPort); @@ -226,19 +226,35 @@ const onMessageSetUpExtensionStreams = (msg: MessageType) => { return undefined; }; +/** + * Ends two-way communication streams between browser extension and + * the local per-page browser context. + */ +export function destroyStreams() { + if (!extensionPort) { + return; + } + extensionPort.onDisconnect.removeListener(onDisconnectDestroyStreams); + + destroyExtensionStreams(); + destroyLegacyExtensionStreams(); + + extensionPort.disconnect(); + extensionPort = null; + + METAMASK_EXTENSION_CONNECT_SENT = false; +} + /** * This listener destroys the extension streams when the extension port is disconnected, * so that streams may be re-established later when the extension port is reconnected. * * @param [err] - Stream connection error */ -export const onDisconnectDestroyStreams = (err: unknown) => { +export function onDisconnectDestroyStreams(err: unknown) { const lastErr = err || checkForLastError(); - extensionPort.onDisconnect.removeListener(onDisconnectDestroyStreams); - - destroyExtensionStreams(); - destroyLegacyExtensionStreams(); + destroyStreams(); /** * If an error is found, reset the streams. When running two or more dapps, resetting the service @@ -251,7 +267,7 @@ export const onDisconnectDestroyStreams = (err: unknown) => { console.warn(`${lastErr} Resetting the streams.`); setTimeout(setupExtensionStreams, 1000); } -}; +} /** * Initializes two-way communication streams between the browser extension and diff --git a/test/e2e/provider/bfcache.spec.js b/test/e2e/provider/bfcache.spec.js new file mode 100644 index 000000000000..4d2c5d2bb1e7 --- /dev/null +++ b/test/e2e/provider/bfcache.spec.js @@ -0,0 +1,65 @@ +const { strict: assert } = require('assert'); +const { + withFixtures, + defaultGanacheOptions, + DAPP_URL, + openDapp, +} = require('../helpers'); +const FixtureBuilder = require('../fixture-builder'); + +const triggerBFCache = async (driver) => { + await driver.executeScript(` + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + window.restoredFromBFCache = true + } + }); + `); + + await driver.driver.get(`chrome://terms/`); + + await driver.driver.navigate().back(); + + const restoredFromBFCache = await driver.executeScript( + `return window.restoredFromBFCache`, + ); + + if (!restoredFromBFCache) { + assert.fail(new Error('Failed to trigger BFCache')); + } +}; + +describe('BFCache', function () { + it('has a working provider stream when a dapp is restored from BFCache', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await openDapp(driver, undefined, DAPP_URL); + + const request = JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + id: 0, + }); + + const initialResult = await driver.executeScript( + `return window.ethereum.request(${request})`, + ); + assert.equal(initialResult, '0x539'); + + await triggerBFCache(driver); + + const bfcacheResult = await driver.executeScript( + `return window.ethereum.request(${request})`, + ); + assert.equal(bfcacheResult, '0x539'); + }, + ); + }); +}); From 9bdfd03bfafb46f10c11ba104bcd60d37fc370eb Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Fri, 22 Nov 2024 13:27:38 -0800 Subject: [PATCH 053/148] fix: market data for native tokens with non zero addresses (#28584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Draft When querying the price API, the native token is usually represented by the zero address. But this is not the case on some chains like polygon, whose native token has a contract `0x0000000000000000000000000000000000001010`. Depends on: https://github.com/MetaMask/core/pull/4952 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28584?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** (pre-req - you may need to enable the `PORTFOLIO_VIEW=true` flag) 1. Onboard with an SRP that have POL tokens. 2. Connect and switch to Polygon 3. View the POL token on the tokens section on the home page. - Does it contain percentages and market data. 5. View the POL asset page. - Does it contain the market details view; and percentage sections? ## **Screenshots/Recordings** | Before | After | |--------|--------| | ![Screenshot 2024-11-22 at 16 42 25](https://github.com/user-attachments/assets/51e67809-b53f-4a29-a345-ddda516a08b2) | ![Screenshot 2024-11-22 at 16 29 38](https://github.com/user-attachments/assets/87245972-5a03-4acb-85d0-dfe01660b038) | | ![Screenshot 2024-11-22 at 16 42 41](https://github.com/user-attachments/assets/b87a9cc3-dcb5-4479-8406-3a832f9f926f) | ![Screenshot 2024-11-22 at 16 29 46](https://github.com/user-attachments/assets/20ab36e9-0d55-45d6-a57b-0c05e8c533b9) | ### **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. --------- Co-authored-by: Prithpal Sooriya --- ...s-controllers-npm-45.1.0-d914c453f0.patch} | 40 +----------- ...-assets-controllers-patch-9e00573eb4.patch | 62 ------------------- package.json | 2 +- ...gated-percentage-overview-cross-chains.tsx | 9 ++- .../aggregated-percentage-overview.test.tsx | 19 +++--- .../aggregated-percentage-overview.tsx | 8 ++- .../app/wallet-overview/coin-overview.tsx | 7 ++- .../percentage-and-amount-change.test.tsx | 38 +++++++++--- .../percentage-and-amount-change.tsx | 8 ++- .../token-list-item/token-list-item.tsx | 10 +-- ui/pages/asset/components/asset-page.tsx | 4 +- yarn.lock | 18 +++--- 12 files changed, 83 insertions(+), 142 deletions(-) rename .yarn/patches/{@metamask-assets-controllers-npm-45.0.0-31810ece32.patch => @metamask-assets-controllers-npm-45.1.0-d914c453f0.patch} (51%) delete mode 100644 .yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch diff --git a/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch b/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch similarity index 51% rename from .yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch rename to .yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch index 77a2e7f21cfb..ca412ba89489 100644 --- a/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch +++ b/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch @@ -1,39 +1,3 @@ -diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs -index 8fd5efde7a3c24080f8a43f79d10300e8c271245..66f656d9a55f1154024a8c18a9fe27b4ed39a21d 100644 ---- a/dist/TokenDetectionController.cjs -+++ b/dist/TokenDetectionController.cjs -@@ -250,17 +250,20 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ - } - }); - this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', -- // TODO: Either fix this lint violation or explain why it's necessary to ignore. -- // eslint-disable-next-line @typescript-eslint/no-misused-promises -- async (selectedAccount) => { -- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; -- if (isSelectedAccountIdChanged) { -- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); -- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { -- selectedAddress: selectedAccount.address, -- }); -- } -- }); -+ // TODO: Either fix this lint violation or explain why it's necessary to ignore. -+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -+ async (selectedAccount) => { -+ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); -+ const chainIds = Object.keys(networkConfigurationsByChainId); -+ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; -+ if (isSelectedAccountIdChanged) { -+ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); -+ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { -+ selectedAddress: selectedAccount.address, -+ chainIds, -+ }); -+ } -+ }); - }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { - if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { - clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs index 48571b8c1b78e94d88e1837e986b5f8735ac651b..61246f51500c8cab48f18296a73629fb73454caa 100644 --- a/dist/assetsUtil.cjs @@ -56,7 +20,7 @@ index 48571b8c1b78e94d88e1837e986b5f8735ac651b..61246f51500c8cab48f18296a73629fb // because most cid v0s appear to be incompatible with IPFS subdomains return { diff --git a/dist/token-prices-service/codefi-v2.mjs b/dist/token-prices-service/codefi-v2.mjs -index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..bf8ec7819f678c2f185d6a85d7e3ea81f055a309 100644 +index a13403446a2376d4d905a9ef733941798da89c88..3c8229f9ea40f4c1ee760a22884e1066dac82ec7 100644 --- a/dist/token-prices-service/codefi-v2.mjs +++ b/dist/token-prices-service/codefi-v2.mjs @@ -12,8 +12,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( @@ -65,7 +29,7 @@ index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..bf8ec7819f678c2f185d6a85d7e3ea81 import { hexToNumber } from "@metamask/utils"; -import $cockatiel from "cockatiel"; -const { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } = $cockatiel; -+import { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } from "cockatiel" ++import { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } from "cockatiel"; /** * The list of currencies that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint, in lowercase form. diff --git a/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch b/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch deleted file mode 100644 index 1b9e5a4ba848..000000000000 --- a/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch +++ /dev/null @@ -1,62 +0,0 @@ -diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs -index ab23c95d667357db365f925c4c4acce4736797f8..8fd5efde7a3c24080f8a43f79d10300e8c271245 100644 ---- a/dist/TokenDetectionController.cjs -+++ b/dist/TokenDetectionController.cjs -@@ -204,13 +204,10 @@ class TokenDetectionController extends (0, polling_controller_1.StaticIntervalPo - // Try detecting tokens via Account API first if conditions allow - if (supportedNetworks && chainsToDetectUsingAccountAPI.length > 0) { - const apiResult = await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_attemptAccountAPIDetection).call(this, chainsToDetectUsingAccountAPI, addressToDetect, supportedNetworks); -- // If API succeeds and no chains are left for RPC detection, we can return early -- if (apiResult?.result === 'success' && -- chainsToDetectUsingRpc.length === 0) { -- return; -+ // If the account API call failed, have those chains fall back to RPC detection -+ if (apiResult?.result === 'failed') { -+ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); - } -- // If API fails or chainsToDetectUsingRpc still has items, add chains to RPC detection -- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); - } - // Proceed with RPC detection if there are chains remaining in chainsToDetectUsingRpc - if (chainsToDetectUsingRpc.length > 0) { -@@ -446,8 +443,7 @@ async function _TokenDetectionController_addDetectedTokensViaAPI({ selectedAddre - const tokenBalancesByChain = await __classPrivateFieldGet(this, _TokenDetectionController_accountsAPI, "f") - .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) - .catch(() => null); -- if (!tokenBalancesByChain || -- Object.keys(tokenBalancesByChain).length === 0) { -+ if (tokenBalancesByChain === null) { - return { result: 'failed' }; - } - // Process each chain ID individually -diff --git a/dist/TokenDetectionController.mjs b/dist/TokenDetectionController.mjs -index f75eb5c2c74f2a9d15a79760985111171dc938e1..ebc30bb915cc39dabf49f9e0da84a7948ae1ed48 100644 ---- a/dist/TokenDetectionController.mjs -+++ b/dist/TokenDetectionController.mjs -@@ -205,13 +205,10 @@ export class TokenDetectionController extends StaticIntervalPollingController() - // Try detecting tokens via Account API first if conditions allow - if (supportedNetworks && chainsToDetectUsingAccountAPI.length > 0) { - const apiResult = await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_attemptAccountAPIDetection).call(this, chainsToDetectUsingAccountAPI, addressToDetect, supportedNetworks); -- // If API succeeds and no chains are left for RPC detection, we can return early -- if (apiResult?.result === 'success' && -- chainsToDetectUsingRpc.length === 0) { -- return; -+ // If the account API call failed, have those chains fall back to RPC detection -+ if (apiResult?.result === 'failed') { -+ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); - } -- // If API fails or chainsToDetectUsingRpc still has items, add chains to RPC detection -- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); - } - // Proceed with RPC detection if there are chains remaining in chainsToDetectUsingRpc - if (chainsToDetectUsingRpc.length > 0) { -@@ -446,8 +443,7 @@ async function _TokenDetectionController_addDetectedTokensViaAPI({ selectedAddre - const tokenBalancesByChain = await __classPrivateFieldGet(this, _TokenDetectionController_accountsAPI, "f") - .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) - .catch(() => null); -- if (!tokenBalancesByChain || -- Object.keys(tokenBalancesByChain).length === 0) { -+ if (tokenBalancesByChain === null) { - return { result: 'failed' }; - } - // Process each chain ID individually diff --git a/package.json b/package.json index 0f963c3aab2b..45896fdabd1f 100644 --- a/package.json +++ b/package.json @@ -294,7 +294,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%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx index fe3698e2fc2f..6f7de0c95c7c 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx @@ -1,7 +1,9 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { zeroAddress, toChecksumAddress } from 'ethereumjs-util'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; import { getCurrentCurrency, getSelectedAccount, @@ -89,8 +91,9 @@ export const AggregatedPercentageOverviewCrossChains = () => { item.tokensWithBalances, ); const nativePricePercentChange1d = - crossChainMarketData?.[item.chainId]?.[zeroAddress()] - ?.pricePercentChange1d; + crossChainMarketData?.[item.chainId]?.[ + getNativeTokenAddress(item.chainId as Hex) + ]?.pricePercentChange1d; const nativeFiat1dAgo = getCalculatedTokenAmount1dAgo( item.nativeFiatValue, 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 8da096151908..7610890d48da 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx @@ -8,6 +8,7 @@ import { getShouldHideZeroBalanceTokens, getTokensMarketData, getPreferences, + getCurrentChainId, } from '../../../selectors'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; @@ -26,20 +27,22 @@ jest.mock('../../../selectors', () => ({ getPreferences: jest.fn(), getShouldHideZeroBalanceTokens: jest.fn(), getTokensMarketData: jest.fn(), + getCurrentChainId: jest.fn(), })); jest.mock('../../../hooks/useAccountTotalFiatBalance', () => ({ useAccountTotalFiatBalance: jest.fn(), })); -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; - +const mockGetIntlLocale = jest.mocked(getIntlLocale); +const mockGetCurrentCurrency = jest.mocked(getCurrentCurrency); +const mockGetPreferences = jest.mocked(getPreferences); +const mockGetSelectedAccount = jest.mocked(getSelectedAccount); +const mockGetShouldHideZeroBalanceTokens = jest.mocked( + getShouldHideZeroBalanceTokens, +); const mockGetTokensMarketData = getTokensMarketData as jest.Mock; +const mockGetCurrentChainId = jest.mocked(getCurrentChainId); const selectedAccountMock = { id: 'd51c0116-de36-4e77-b35b-408d4ea82d01', @@ -166,7 +169,7 @@ describe('AggregatedPercentageOverview', () => { mockGetSelectedAccount.mockReturnValue(selectedAccountMock); mockGetShouldHideZeroBalanceTokens.mockReturnValue(false); mockGetTokensMarketData.mockReturnValue(marketDataMock); - + mockGetCurrentChainId.mockReturnValue('0x1'); jest.clearAllMocks(); }); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx index 8c609610daa1..89bc94dab774 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx @@ -1,13 +1,15 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { zeroAddress, toChecksumAddress } from 'ethereumjs-util'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { getCurrentCurrency, getSelectedAccount, getShouldHideZeroBalanceTokens, getTokensMarketData, getPreferences, + getCurrentChainId, } from '../../../selectors'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; @@ -37,6 +39,7 @@ export const AggregatedPercentageOverview = () => { const fiatCurrency = useSelector(getCurrentCurrency); const { privacyMode } = useSelector(getPreferences); const selectedAccount = useSelector(getSelectedAccount); + const currentChainId = useSelector(getCurrentChainId); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); @@ -63,7 +66,8 @@ export const AggregatedPercentageOverview = () => { } // native token const nativePricePercentChange1d = - tokensMarketData?.[zeroAddress()]?.pricePercentChange1d; + tokensMarketData?.[getNativeTokenAddress(currentChainId)] + ?.pricePercentChange1d; const nativeFiat1dAgo = getCalculatedTokenAmount1dAgo( item.fiatBalance, nativePricePercentChange1d, diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index bf054d993e74..3be53776581d 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -7,11 +7,11 @@ import React, { } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; -import { zeroAddress } from 'ethereumjs-util'; import { CaipChainId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { InternalAccount } from '@metamask/keyring-api'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { Box, ButtonIcon, @@ -231,7 +231,10 @@ export const CoinOverview = ({ return ( { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) diff --git a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx index abff9f40da8d..439c030a59cd 100644 --- a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx +++ b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx @@ -2,11 +2,13 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { zeroAddress } from 'ethereumjs-util'; +import { MarketDataDetails } from '@metamask/assets-controllers'; import { getIntlLocale } from '../../../../../ducks/locale/locale'; import { getCurrentCurrency, getSelectedAccountCachedBalance, getTokensMarketData, + getCurrentChainId, } from '../../../../../selectors'; import { getConversionRate, @@ -26,6 +28,7 @@ jest.mock('../../../../../selectors', () => ({ getCurrentCurrency: jest.fn(), getSelectedAccountCachedBalance: jest.fn(), getTokensMarketData: jest.fn(), + getCurrentChainId: jest.fn(), })); jest.mock('../../../../../ducks/metamask/metamask', () => ({ @@ -33,13 +36,15 @@ jest.mock('../../../../../ducks/metamask/metamask', () => ({ getNativeCurrency: jest.fn(), })); -const mockGetIntlLocale = getIntlLocale as unknown as jest.Mock; -const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; -const mockGetSelectedAccountCachedBalance = - getSelectedAccountCachedBalance as jest.Mock; -const mockGetConversionRate = getConversionRate as jest.Mock; -const mockGetNativeCurrency = getNativeCurrency as jest.Mock; -const mockGetTokensMarketData = getTokensMarketData as jest.Mock; +const mockGetIntlLocale = jest.mocked(getIntlLocale); +const mockGetCurrentCurrency = jest.mocked(getCurrentCurrency); +const mockGetSelectedAccountCachedBalance = jest.mocked( + getSelectedAccountCachedBalance, +); +const mockGetConversionRate = jest.mocked(getConversionRate); +const mockGetNativeCurrency = jest.mocked(getNativeCurrency); +const mockGetTokensMarketData = jest.mocked(getTokensMarketData); +const mockGetCurrentChainId = jest.mocked(getCurrentChainId); describe('PercentageChange Component', () => { beforeEach(() => { @@ -51,9 +56,9 @@ describe('PercentageChange Component', () => { mockGetTokensMarketData.mockReturnValue({ [zeroAddress()]: { pricePercentChange1d: 2, - }, + } as MarketDataDetails, }); - + mockGetCurrentChainId.mockReturnValue('0x1'); jest.clearAllMocks(); }); @@ -108,4 +113,19 @@ describe('PercentageChange Component', () => { expect(percentageElement).toBeInTheDocument(); expect(numberElement).toBeInTheDocument(); }); + + it('should display percentage for non-zero native tokens (MATIC)', () => { + mockGetTokensMarketData.mockReturnValue({ + '0x0000000000000000000000000000000000001010': { + pricePercentChange1d: 2, + } as MarketDataDetails, + }); + mockGetCurrentCurrency.mockReturnValue('POL'); + mockGetCurrentChainId.mockReturnValue('0x89'); + render(); + const percentageElement = screen.getByText('(+1.00%)'); + const numberElement = screen.getByText('+POL 12.21'); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); }); diff --git a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx index be9921e88793..f1ba436ef47f 100644 --- a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx +++ b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx @@ -1,7 +1,8 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; -import { isHexString, zeroAddress } from 'ethereumjs-util'; +import { isHexString } from 'ethereumjs-util'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { Text, Box } from '../../../../component-library'; import { Display, @@ -9,6 +10,7 @@ import { TextVariant, } from '../../../../../helpers/constants/design-system'; import { + getCurrentChainId, getCurrentCurrency, getSelectedAccountCachedBalance, getTokensMarketData, @@ -66,10 +68,12 @@ export const PercentageAndAmountChange = ({ const conversionRate = useSelector(getConversionRate); const nativeCurrency = useSelector(getNativeCurrency); const marketData = useSelector(getTokensMarketData); + const currentChainId = useSelector(getCurrentChainId); const balanceChange = useMemo(() => { // Extracts the 1-day percentage change in price from marketData using the zero address as a key. - const percentage1d = marketData?.[zeroAddress()]?.pricePercentChange1d; + const percentage1d = + marketData?.[getNativeTokenAddress(currentChainId)]?.pricePercentChange1d; // Checks if the balanceValue is in hex format. This is important for cryptocurrency balances which are often represented in hex. if (isHexString(balanceValue)) { 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 540d2d8be98a..ef49ec3126cb 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -2,7 +2,8 @@ import React, { useContext, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import classnames from 'classnames'; -import { zeroAddress } from 'ethereumjs-util'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; import { AlignItems, BackgroundColor, @@ -336,13 +337,14 @@ export const TokenListItem = ({ diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 19ce592f0071..0f4529861dbc 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -3,8 +3,8 @@ import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { EthMethod } from '@metamask/keyring-api'; import { isEqual } from 'lodash'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { Hex } from '@metamask/utils'; -import { zeroAddress } from 'ethereumjs-util'; import { getCurrentCurrency, getDataCollectionForMarketing, @@ -140,7 +140,7 @@ const AssetPage = ({ const address = type === AssetType.token ? toChecksumHexAddress(asset.address) - : zeroAddress(); + : getNativeTokenAddress(chainId); const balance = calculateTokenBalance({ isNative: type === AssetType.native, diff --git a/yarn.lock b/yarn.lock index bc208e1a8f92..db952a4b1e70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4934,9 +4934,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:45.0.0": - version: 45.0.0 - resolution: "@metamask/assets-controllers@npm:45.0.0" +"@metamask/assets-controllers@npm:45.1.0": + version: 45.1.0 + resolution: "@metamask/assets-controllers@npm:45.1.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -4969,13 +4969,13 @@ __metadata: "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^15.0.0 - checksum: 10/0ad51464cf060f1c2cab56c2c8d9daa5f29987e8ead69c0e029fb8357fa5c629434116de5663dc38a57c11b3736b6c7d9b1db9b6892a453fbc3f9c6965d42295 + checksum: 10/7e366739c2b3fc8000aaa8cd302d3e2c3958e29e7c88f3e7e188c4ec46454cf9e894c1e230a84092bba8e6c5274b301dfdb4e55a0ba4322bdcb9e7325ad5a5e5 languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch": - version: 45.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch::version=45.0.0&hash=8e5354" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch": + version: 45.1.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch::version=45.1.0&hash=86167d" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5008,7 +5008,7 @@ __metadata: "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^15.0.0 - checksum: 10/823627b5bd23829d81a54291f74c4ddf52d0732a840c121c4ae7f1fc468dd98f3fc1e64b7f8a9bbaaa76cd6670082f2976e5e6ecf872e04c212a5c8ec5fe4916 + checksum: 10/985ec7dffb75aaff8eea00f556157e42cd5db063cbfa94dfd4f070c5b9d98b1315f3680fa7370f4c734a1688598bbda9c44a7c33c342e1d123d6ee2edd6120fc languageName: node linkType: hard @@ -26816,7 +26816,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%3A45.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.0.0-31810ece32.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" From 6eb7ccf4f4e4f080cb7d7550b3742202d22a5bea Mon Sep 17 00:00:00 2001 From: micaelae <100321200+micaelae@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:50:35 -0800 Subject: [PATCH 054/148] chore: sort and display all bridge quotes (#27731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Changes - Fetch exchange rates on src/dest token selection - Calculate quote metadata and implement sorting - Create and style modal for displaying all bridge quotes - Autofill src token if navigating from asset page [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27731?quickstart=1) ## **Related issues** Fixes: N/A ## **Manual testing steps** 1. Request quotes 2. View all quotes 3. Toggle sorting and inspect output 4. Verify that modal matches mocks 5. Try selecting alternate quote ## **Screenshots/Recordings** ### **Before** Mocks: https://www.figma.com/design/IuOIRmU3wI0IdJIfol0ESu/Cross-Chain-Swaps?node-id=1374-7239&m=dev ### **After** ![Screenshot 2024-11-12 at 5 08 49 PM](https://github.com/user-attachments/assets/f9db89b7-2753-4259-a28d-b5eb7e908323) ## **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 | 13 +- .../bridge/bridge-controller.test.ts | 183 +++- .../controllers/bridge/bridge-controller.ts | 80 +- app/scripts/controllers/bridge/types.ts | 12 +- app/scripts/metamask-controller.js | 4 + shared/constants/bridge.ts | 4 + .../data/bridge/mock-quotes-erc20-native.json | 894 ++++++++++++++++++ .../bridge/mock-quotes-native-erc20-eth.json | 258 +++++ .../data/bridge/mock-quotes-native-erc20.json | 2 +- test/jest/mock-store.js | 12 + ui/ducks/bridge/actions.ts | 12 +- ui/ducks/bridge/bridge.test.ts | 103 +- ui/ducks/bridge/bridge.ts | 41 +- ui/ducks/bridge/selectors.test.ts | 379 +++++++- ui/ducks/bridge/selectors.ts | 262 ++++- ui/ducks/bridge/utils.ts | 19 + ui/hooks/bridge/useBridging.test.ts | 8 +- ui/hooks/bridge/useBridging.ts | 9 +- ui/hooks/bridge/useCountdownTimer.test.ts | 5 +- ui/hooks/useTokensWithFiltering.ts | 16 + ui/pages/bridge/index.tsx | 32 +- ui/pages/bridge/layout/column.tsx | 23 + ui/pages/bridge/layout/index.tsx | 5 + ui/pages/bridge/layout/row.tsx | 27 + ui/pages/bridge/layout/tooltip.tsx | 83 ++ ui/pages/bridge/prepare/bridge-cta-button.tsx | 12 +- .../bridge/prepare/bridge-input-group.tsx | 12 +- .../prepare/prepare-bridge-page.test.tsx | 7 + .../bridge/prepare/prepare-bridge-page.tsx | 107 ++- .../bridge-quote-card.test.tsx.snap | 16 +- .../bridge-quotes-modal.test.tsx.snap | 135 ++- .../bridge/quotes/bridge-quote-card.test.tsx | 4 +- ui/pages/bridge/quotes/bridge-quote-card.tsx | 71 +- .../quotes/bridge-quotes-modal.stories.tsx | 106 +++ .../bridge/quotes/bridge-quotes-modal.tsx | 201 +++- ui/pages/bridge/quotes/index.scss | 20 +- ui/pages/bridge/types.ts | 24 + ui/pages/bridge/utils/quote.test.ts | 309 ++++++ ui/pages/bridge/utils/quote.ts | 175 +++- 39 files changed, 3412 insertions(+), 273 deletions(-) create mode 100644 test/data/bridge/mock-quotes-erc20-native.json create mode 100644 test/data/bridge/mock-quotes-native-erc20-eth.json create mode 100644 ui/pages/bridge/layout/column.tsx create mode 100644 ui/pages/bridge/layout/index.tsx create mode 100644 ui/pages/bridge/layout/row.tsx create mode 100644 ui/pages/bridge/layout/tooltip.tsx create mode 100644 ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx create mode 100644 ui/pages/bridge/utils/quote.test.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f51c9708fc20..2d603ddc5156 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -863,8 +863,8 @@ "bridgeFrom": { "message": "Bridge from" }, - "bridgeOverallCost": { - "message": "Overall cost" + "bridgeNetCost": { + "message": "Net cost" }, "bridgeSelectNetwork": { "message": "Select network" @@ -873,7 +873,7 @@ "message": "Select token and amount" }, "bridgeTimingMinutes": { - "message": "$1 minutes", + "message": "$1 min", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" }, "bridgeTimingTooltipText": { @@ -4373,6 +4373,13 @@ "quoteRate": { "message": "Quote rate" }, + "quotedNetworkFee": { "message": "$1 network fee" }, + "quotedReceiveAmount": { + "message": "$1 receive amount" + }, + "quotedReceivingAmount": { + "message": "$1 receiving" + }, "rank": { "message": "Rank" }, diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 8369d910f78b..5cadcb1bd375 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -1,4 +1,6 @@ import nock from 'nock'; +import { BigNumber } from 'bignumber.js'; +import { add0x } from '@metamask/utils'; 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'; @@ -7,6 +9,13 @@ import { flushPromises } from '../../../../test/lib/timer-helpers'; // eslint-disable-next-line import/no-restricted-paths import * as bridgeUtil from '../../../../ui/pages/bridge/bridge.util'; import * as balanceUtils from '../../../../shared/modules/bridge-utils/balance'; +import mockBridgeQuotesErc20Native from '../../../../test/data/bridge/mock-quotes-erc20-native.json'; +import mockBridgeQuotesNativeErc20 from '../../../../test/data/bridge/mock-quotes-native-erc20.json'; +import mockBridgeQuotesNativeErc20Eth from '../../../../test/data/bridge/mock-quotes-native-erc20-eth.json'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { QuoteResponse } from '../../../../ui/pages/bridge/types'; +import { decimalToHex } from '../../../../shared/modules/conversion.utils'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; @@ -35,6 +44,7 @@ jest.mock('@ethersproject/providers', () => { Web3Provider: jest.fn(), }; }); +const getLayer1GasFeeMock = jest.fn(); describe('BridgeController', function () { let bridgeController: BridgeController; @@ -42,6 +52,7 @@ describe('BridgeController', function () { beforeAll(function () { bridgeController = new BridgeController({ messenger: messengerMock, + getLayer1GasFee: getLayer1GasFeeMock, }); }); @@ -278,7 +289,7 @@ describe('BridgeController', function () { .mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([1, 2, 3] as never); + resolve(mockBridgeQuotesNativeErc20Eth as never); }, 5000); }); }); @@ -286,7 +297,10 @@ describe('BridgeController', function () { fetchBridgeQuotesSpy.mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([5, 6, 7] as never); + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); }, 10000); }); }); @@ -363,7 +377,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, }), ); @@ -377,7 +391,10 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [5, 6, 7], + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], quotesLoadingStatus: 1, quotesRefreshCount: 2, }), @@ -394,7 +411,10 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: false }, - quotes: [5, 6, 7], + quotes: [ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ], quotesLoadingStatus: 2, quotesRefreshCount: 3, }), @@ -404,6 +424,7 @@ describe('BridgeController', function () { ); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { @@ -426,7 +447,7 @@ describe('BridgeController', function () { .mockImplementationOnce(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([1, 2, 3] as never); + resolve(mockBridgeQuotesNativeErc20Eth as never); }, 5000); }); }); @@ -434,7 +455,10 @@ describe('BridgeController', function () { fetchBridgeQuotesSpy.mockImplementation(async () => { return await new Promise((resolve) => { return setTimeout(() => { - resolve([5, 6, 7] as never); + resolve([ + ...mockBridgeQuotesNativeErc20Eth, + ...mockBridgeQuotesNativeErc20Eth, + ] as never); }, 10000); }); }); @@ -503,7 +527,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: true }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -519,7 +543,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: true }, - quotes: [1, 2, 3], + quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -527,6 +551,7 @@ describe('BridgeController', function () { const secondFetchTime = bridgeController.state.bridgeState.quotesLastFetched; expect(secondFetchTime).toStrictEqual(firstFetchTime); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', function () { @@ -574,6 +599,7 @@ describe('BridgeController', function () { address: '0x123', provider: jest.fn(), } as never); + const allowance = await bridgeController.getBridgeERC20Allowance( '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', '0xa', @@ -581,4 +607,143 @@ describe('BridgeController', function () { expect(allowance).toBe('100000000000000000000'); }); }); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'should append l1GasFees if srcChain is 10 and srcToken is erc20', + mockBridgeQuotesErc20Native, + add0x(decimalToHex(new BigNumber('2608710388388').mul(2).toFixed())), + 12, + ], + [ + 'should append l1GasFees if srcChain is 10 and srcToken is native', + mockBridgeQuotesNativeErc20, + add0x(decimalToHex(new BigNumber('2608710388388').toFixed())), + 2, + ], + [ + 'should not append l1GasFees if srcChain is not 10', + mockBridgeQuotesNativeErc20Eth, + undefined, + 0, + ], + ])( + 'updateBridgeQuoteRequestParams: %s', + async ( + _: string, + quoteResponse: QuoteResponse[], + l1GasFeesInHexWei: string, + getLayer1GasFeeMockCallCount: number, + ) => { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeController, + 'startPollingByNetworkClientId', + ); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + getLayer1GasFeeMock.mockResolvedValue('0x25F63418AA4'); + + const fetchBridgeQuotesSpy = jest + .spyOn(bridgeUtil, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(quoteResponse as never); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: 10, + destChainId: 1, + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith( + expect.anything(), + { + ...quoteRequest, + insufficientBal: true, + }, + ); + + 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(500); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + }, + expect.any(AbortSignal), + ); + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toStrictEqual(undefined); + + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(1500); + await flushPromises(); + const { quotes } = bridgeController.state.bridgeState; + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + quotes.forEach((quote) => { + const expectedQuote = l1GasFeesInHexWei + ? { ...quote, l1GasFeesInHexWei } + : quote; + expect(quote).toStrictEqual(expectedQuote); + }); + + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched ?? 0; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( + getLayer1GasFeeMockCallCount, + ); + }, + ); }); diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 2518e9caa9bd..bbe016ac7aea 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -6,6 +6,8 @@ import { Contract } from '@ethersproject/contracts'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { Web3Provider } from '@ethersproject/providers'; import { BigNumber } from '@ethersproject/bignumber'; +import { TransactionParams } from '@metamask/transaction-controller'; +import type { ChainId } from '@metamask/controller-utils'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -16,14 +18,23 @@ 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'; +import { + decimalToHex, + sumHexes, +} from '../../../../shared/modules/conversion.utils'; +import { + L1GasFees, + QuoteRequest, + QuoteResponse, + TxData, + // TODO: Remove restricted import + // eslint-disable-next-line import/no-restricted-paths +} 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 { hasSufficientBalance } from '../../../../shared/modules/bridge-utils/balance'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { BRIDGE_CONTROLLER_NAME, DEFAULT_BRIDGE_CONTROLLER_STATE, @@ -53,7 +64,21 @@ export default class BridgeController extends StaticIntervalPollingController< > { #abortController: AbortController | undefined; - constructor({ messenger }: { messenger: BridgeControllerMessenger }) { + #getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + + constructor({ + messenger, + getLayer1GasFee, + }: { + messenger: BridgeControllerMessenger; + getLayer1GasFee: (params: { + transactionParams: TransactionParams; + chainId: ChainId; + }) => Promise; + }) { super({ name: BRIDGE_CONTROLLER_NAME, metadata, @@ -91,6 +116,8 @@ export default class BridgeController extends StaticIntervalPollingController< `${BRIDGE_CONTROLLER_NAME}:getBridgeERC20Allowance`, this.getBridgeERC20Allowance.bind(this), ); + + this.#getLayer1GasFee = getLayer1GasFee; } _executePoll = async ( @@ -226,10 +253,12 @@ export default class BridgeController extends StaticIntervalPollingController< this.stopAllPolling(); } + const quotesWithL1GasFees = await this.#appendL1GasFees(quotes); + this.update((_state) => { _state.bridgeState = { ..._state.bridgeState, - quotes, + quotes: quotesWithL1GasFees, quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, quotesRefreshCount: newQuotesRefreshCount, @@ -253,6 +282,45 @@ export default class BridgeController extends StaticIntervalPollingController< } }; + #appendL1GasFees = async ( + quotes: QuoteResponse[], + ): Promise<(QuoteResponse & L1GasFees)[]> => { + return await Promise.all( + quotes.map(async (quoteResponse) => { + const { quote, trade, approval } = quoteResponse; + const chainId = add0x(decimalToHex(quote.srcChainId)) as ChainId; + if ( + [CHAIN_IDS.OPTIMISM.toString(), CHAIN_IDS.BASE.toString()].includes( + chainId, + ) + ) { + const getTxParams = (txData: TxData) => ({ + from: txData.from, + to: txData.to, + value: txData.value, + data: txData.data, + gasLimit: txData.gasLimit?.toString(), + }); + const approvalL1GasFees = approval + ? await this.#getLayer1GasFee({ + transactionParams: getTxParams(approval), + chainId, + }) + : '0'; + const tradeL1GasFees = await this.#getLayer1GasFee({ + transactionParams: getTxParams(trade), + chainId, + }); + return { + ...quoteResponse, + l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), + }; + } + return quoteResponse; + }), + ); + }; + #setTopAssets = async ( chainId: Hex, stateKey: 'srcTopAssets' | 'destTopAssets', diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 577a9fa99836..c9e221b82418 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -9,9 +9,13 @@ import { NetworkControllerGetSelectedNetworkClientAction, } from '@metamask/network-controller'; import { SwapsTokenObject } from '../../../../shared/constants/swaps'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { QuoteRequest, QuoteResponse } from '../../../../ui/pages/bridge/types'; +import { + L1GasFees, + QuoteRequest, + QuoteResponse, + // TODO: Remove restricted import + // eslint-disable-next-line import/no-restricted-paths +} from '../../../../ui/pages/bridge/types'; import BridgeController from './bridge-controller'; import { BRIDGE_CONTROLLER_NAME, RequestStatus } from './constants'; @@ -39,7 +43,7 @@ export type BridgeControllerState = { destTokens: Record; destTopAssets: { address: string }[]; quoteRequest: Partial; - quotes: QuoteResponse[]; + quotes: (QuoteResponse & L1GasFees)[]; quotesLastFetched?: number; quotesLoadingStatus?: RequestStatus; quotesRefreshCount: number; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 548b2f9d940c..29e6f09a3aa9 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2178,6 +2178,10 @@ export default class MetamaskController extends EventEmitter { }); this.bridgeController = new BridgeController({ messenger: bridgeControllerMessenger, + // TODO: Remove once TransactionController exports this action type + getLayer1GasFee: this.txController.getLayer1GasFee.bind( + this.txController, + ), }); const smartTransactionsControllerMessenger = diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index 10f2587d3fbd..8ad27dce4944 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -26,3 +26,7 @@ export const BRIDGE_CLIENT_ID = 'extension'; export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; export const METABRIDGE_ETHEREUM_ADDRESS = '0x0439e60F02a8900a951603950d8D4527f400C3f1'; +export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour +export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote returns in x times less return than the best quote, ignore it + +export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; diff --git a/test/data/bridge/mock-quotes-erc20-native.json b/test/data/bridge/mock-quotes-erc20-native.json new file mode 100644 index 000000000000..cd4a1963c6fc --- /dev/null +++ b/test/data/bridge/mock-quotes-erc20-native.json @@ -0,0 +1,894 @@ +[ + { + "quote": { + "requestId": "a63df72a-75ae-4416-a8ab-aff02596c75c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991225000000000000", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["stargate"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "stargate", + "displayName": "StargateV2 (Fast mode)", + "icon": "https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991225000000000000" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x1c8598b5db2e", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006c00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000564a6010a660000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003804bdedbea3f94faf8c8fac5ec841251d96cf5e64e8706ada4688877885e5249520000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001c8598b5db2e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c83dc7c11df600d7293f778cb365d3dfcc1ffa2221cf5447a8f2ea407a97792135d9f585ecb68916479dfa1f071f169cbe1cfec831b5ad01f4e4caa09204e5181c", + "gasLimit": 641446 + }, + "estimatedProcessingTimeInSeconds": 64 + }, + { + "quote": { + "requestId": "aad73198-a64d-4310-b12d-9dcc81c412e2", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991147696728676903", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer cBridge", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/cbridge.svg" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000050f68486970f93a855b27794b8141d32a89a1e0a5ef360034a2f60a4b917c188380000a4b1420000000000000000000000000000000000000600000000000000000dc1a09f859b20002c03873900002777000000000000000000000000000000002d68122053030bf8df41a8bb8c6f0a9de411c7d94eed376b7d91234e1585fd9f77dcf974dd25160d0c2c16c8382d8aa85b0edd429edff19b4d4cdcf50d0a9d4d1c", + "gasLimit": 203352 + }, + "estimatedProcessingTimeInSeconds": 53 + }, + { + "quote": { + "requestId": "6cfd4952-c9b2-4aec-9349-af39c212f84b", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "991112862890876485", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "991112862890876485" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f6a57ef0c43231b98141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b100007dd39298f9ad673645ebffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000088d06e7971021eee573a0ab6bc3e22039fc1c5ded5d12c4cf2b6311f47f909e06197aa8b2f647ae78ae33a6ea5d23f7c951c0e1686abecd01d7c796990d56f391c", + "gasLimit": 177423 + }, + "estimatedProcessingTimeInSeconds": 15 + }, + { + "quote": { + "requestId": "2c2ba7d8-3922-4081-9f27-63b7d5cc1986", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + }, + "destChainId": 42161, + "destTokenAmount": "990221346602370184", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "symbol": "WETH", + "decimals": 18, + "name": "Wrapped ETH", + "coinKey": "WETH", + "logoURI": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png", + "priceUSD": "3136", + "icon": "https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["hop"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "hop", + "displayName": "Hop", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3136", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3135.46", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "990221346602370184" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000484ca360ae0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000001168a464edd170000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b080000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b0800000000000000000000000086ca30bef97fb651b8d866d45503684b90cb3312000000000000000000000000710bda329b2a6224e4b44833de30f38e7f81d5640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000067997b63db4b9059d22e50750707b46a6d48dfbb32e50d85fc3bff1170ed9ca30000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003686f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000099d00cde1f22e8afd37d7f103ec3c6c1eb835ace46e502ec8c5ab51413e539461b89c0e26892efd1de1cbfe4222b5589e76231080252197507cce4fb72a30b031b", + "gasLimit": 547501 + }, + "estimatedProcessingTimeInSeconds": 24.159 + }, + { + "quote": { + "requestId": "a77bc7b2-e8c8-4463-89db-5dd239d6aacc", + "srcChainId": 10, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "srcTokenAmount": "991250000000000000", + "destChainId": 42161, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "destTokenAmount": "991147696728676903", + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + } + } + }, + "bridgeId": "socket", + "bridges": ["celer"], + "steps": [ + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "celer", + "displayName": "Celer", + "icon": "https://socketicons.s3.amazonaws.com/Celer+Light.png" + }, + "srcAsset": { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/WETH", + "logoURI": "https://media.socket.tech/tokens/all/WETH", + "chainAgnosticId": "ETH" + }, + "destAsset": { + "chainId": 42161, + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "icon": "https://media.socket.tech/tokens/all/ETH", + "logoURI": "https://media.socket.tech/tokens/all/ETH", + "chainAgnosticId": null + }, + "srcAmount": "991250000000000000", + "destAmount": "991147696728676903" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000004c0000001252106ce9141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b1245fa5dd00002777000000000000000000000000000000000000000022be703a074ef6089a301c364c2bbf391d51067ea5cd91515c9ec5421cdaabb23451cd2086f3ebe3e19ff138f3a9be154dcae6033838cc5fabeeb0d260b075cb1c", + "gasLimit": 182048 + }, + "estimatedProcessingTimeInSeconds": 360 + }, + { + "quote": { + "requestId": "4f2154d9b330221b2ad461adf63acc2c", + "srcChainId": 10, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destChainId": 42161, + "destTokenAmount": "989989428114299041", + "destAsset": { + "id": "42161_0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": "ETH", + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "name": "ETH", + "decimals": 18, + "usdPrice": 3133.259355489038, + "coingeckoId": "ethereum", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg", + "volatility": 2, + "axelarNetworkSymbol": "ETH", + "subGraphIds": ["chainflip-bridge"], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "volatility": 2, + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + } + } + }, + "bridgeId": "squid", + "bridges": ["axelar"], + "steps": [ + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "address": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg" + }, + "destAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "991250000000000000", + "destAmount": "3100880215" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "symbol": "USDC", + "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "chainId": 10, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc", "cctp-uusdc-optimism-to-noble"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3100880215", + "destAmount": "3101045779" + }, + { + "action": "swap", + "srcChainId": 10, + "destChainId": 10, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "10_0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "symbol": "USDC.e", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "chainId": 10, + "name": "USDC.e", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC.e", + "subGraphIds": [], + "enabled": true, + "subGraphOnly": false, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101045779", + "destAmount": "3101521947" + }, + { + "action": "bridge", + "srcChainId": 10, + "destChainId": 42161, + "protocol": { + "name": "axelar", + "displayName": "Axelar" + }, + "srcAsset": { + "id": "10_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 10, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3101521947" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Pancakeswap V3", + "displayName": "Pancakeswap V3" + }, + "srcAsset": { + "id": "42161_0xeb466342c4d449bc9f53a865d5cb90586f405215", + "symbol": "USDC.axl", + "address": "0xeb466342c4d449bc9f53a865d5cb90586f405215", + "chainId": 42161, + "name": " USDC (Axelar)", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "interchainTokenId": null, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "axlUSDC", + "subGraphOnly": false, + "subGraphIds": ["uusdc"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "srcAmount": "3101521947", + "destAmount": "3100543869" + }, + { + "action": "swap", + "srcChainId": 42161, + "destChainId": 42161, + "protocol": { + "name": "Uniswap V3", + "displayName": "Uniswap V3" + }, + "srcAsset": { + "id": "42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "chainId": 42161, + "name": "USDC", + "decimals": 6, + "usdPrice": 1.0003003590332982, + "coingeckoId": "usd-coin", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + "axelarNetworkSymbol": "USDC", + "subGraphOnly": false, + "subGraphIds": [ + "uusdc", + "cctp-uusdc-arbitrum-to-noble", + "chainflip-bridge" + ], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg" + }, + "destAsset": { + "id": "42161_0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "symbol": "WETH", + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + "name": "Wrapped ETH", + "decimals": 18, + "usdPrice": 3135.9632118339764, + "interchainTokenId": null, + "coingeckoId": "weth", + "type": "evm", + "logoURI": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg", + "axelarNetworkSymbol": "WETH", + "subGraphOnly": false, + "subGraphIds": ["arbitrum-weth-wei"], + "enabled": true, + "active": true, + "icon": "https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg" + }, + "srcAmount": "3100543869", + "destAmount": "989989428114299041" + } + ] + }, + "approval": { + "chainId": 10, + "to": "0x4200000000000000000000000000000000000006", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x00", + "data": "0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000", + "gasLimit": 29122 + }, + "trade": { + "chainId": 10, + "to": "0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x4653ce53e6b1", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e73717569644164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b60000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000001a14846a1bc600000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000ce00000000000000000000000000000000000000000000000000000000000000d200000000000000000000000000000000000000000000000000000000000000d600000000000000000000000000000000000000000000000000000000000000dc0000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000046000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf00000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000b8833d8e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8d3ad5700000000000000000000000000000000000000000000000000000000b8c346b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000b8d6341300000000000000000000000000000000000000000000000000000000b8ca89fa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a307863653136463639333735353230616230313337376365374238386635424138433438463844363636000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c100000000000000000000000000000000000000000000000000000000000000040000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000009200000000000000000000000000000000000000000000000000000000000000a8000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8dd781b00000000000000000000000000000000000000000000000000000000b8bb9ee30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f40521500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8ce8b7d0000000000000000000000000000000000000000000000000db72b79f837011c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c0000000000000000000000003c17c95cdb5887c334bfae85750ce00e1a720a76eff35e60db6c9f3b8384a6d63db3c56f1ce6545b50ba2f250429055ca77e7e6203ddd65a7a4d89ae1af3d61b1c", + "gasLimit": 710342 + }, + "estimatedProcessingTimeInSeconds": 20 + } +] diff --git a/test/data/bridge/mock-quotes-native-erc20-eth.json b/test/data/bridge/mock-quotes-native-erc20-eth.json new file mode 100644 index 000000000000..0afd77760e75 --- /dev/null +++ b/test/data/bridge/mock-quotes-native-erc20-eth.json @@ -0,0 +1,258 @@ +[ + { + "quote": { + "requestId": "34c4136d-8558-4d87-bdea-eef8d2d30d6d", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104367033", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["across"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "across", + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104367033" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de51520000000000000000000000000000000000000000000000000000000000000a003a3f733200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000094027363a1fac5600d1f7e8a4c50087ff1f32a09359512d2379d46b331c6033cc7b000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066163726f73730000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a094cc69295a8f2a3016ede239627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000620541d325b000000000000000000000000000000000000000000000000000000000673656d70000000000000000000000000000000000000000000000000000000000000080ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b71dcbfe555f9a744b18195d9b52032871d6f3c5a558275c08a71c2b6214801f5161be976f49181b854a3ebcbe1f2b896133b03314a5ff2746e6494c43e59d0c9ee1c", + "gasLimit": 540076 + }, + "estimatedProcessingTimeInSeconds": 45 + }, + { + "quote": { + "requestId": "5bf0f2f0-655c-4e13-a545-1ebad6f9d2bc", + "srcChainId": 1, + "srcTokenAmount": "991250000000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destChainId": 42161, + "destTokenAmount": "3104601473", + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "feeData": { + "metabridge": { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } + } + }, + "bridgeId": "lifi", + "bridges": ["celercircle"], + "steps": [ + { + "action": "swap", + "srcChainId": 1, + "destChainId": 1, + "protocol": { + "name": "0x", + "displayName": "0x", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png" + }, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "symbol": "ETH", + "decimals": 18, + "name": "ETH", + "coinKey": "ETH", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "priceUSD": "3145.41", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + "destAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "991250000000000000", + "destAmount": "3104701473" + }, + { + "action": "bridge", + "srcChainId": 1, + "destChainId": 42161, + "protocol": { + "name": "celercircle", + "displayName": "Circle CCTP", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png" + }, + "srcAsset": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9997000899730081", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "destAsset": { + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 42161, + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coinKey": "USDC", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "priceUSD": "0.9998000399920016", + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png" + }, + "srcAmount": "3104701473", + "destAmount": "3104601473" + } + ] + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x141d32a89a1e0a5ef360034a2f60a4b917c18838", + "value": "0x0de0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a800000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000009248fab066300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200b431adcab44c6fe13ade53dbd3b714f57922ab5b776924a913685ad0fe680f6c000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a0c0452b52ecb7cf70409b16cd627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047896dca097909ba9db4c9631bce0e53090bce14a9b7d203e21fa80cee7a16fa049aa1ef7d663c2ec3148e698e01774b62ddedc9c2dcd21994e549cd6f318f971b", + "gasLimit": 682910 + }, + "estimatedProcessingTimeInSeconds": 1029.717 + } +] diff --git a/test/data/bridge/mock-quotes-native-erc20.json b/test/data/bridge/mock-quotes-native-erc20.json index fb6ecfcc0b73..f7efe7950ba0 100644 --- a/test/data/bridge/mock-quotes-native-erc20.json +++ b/test/data/bridge/mock-quotes-native-erc20.json @@ -289,6 +289,6 @@ "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000c6437c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000bc4123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000974132b87a5cb75e32f034280000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f9e43204a24f476db20f2518722627a122d31a1bc7c63fc15412e6a327295a9460b76bea5bb53b1f73fa6a15811055f6bada592d2e9e6c8cf48a855ce6968951c", "gasLimit": 664389 }, - "estimatedProcessingTimeInSeconds": 1560 + "estimatedProcessingTimeInSeconds": 15 } ] diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 4720bf427372..a3543e485bb7 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -4,6 +4,7 @@ import { KeyringType } from '../../shared/constants/keyring'; import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { mockNetworkState } from '../stub/networks'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../app/scripts/controllers/bridge/constants'; +import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '../../shared/constants/bridge'; export const createGetSmartTransactionFeesApiResponse = () => { return { @@ -711,6 +712,7 @@ export const createBridgeMockStore = ( ...swapsStore, bridge: { toChainId: null, + sortOrder: 0, ...bridgeSliceOverrides, }, metamask: { @@ -719,6 +721,16 @@ export const createBridgeMockStore = ( { chainId: CHAIN_IDS.MAINNET }, { chainId: CHAIN_IDS.LINEA_MAINNET }, ), + gasFeeEstimates: { + estimatedBaseFee: '0.00010456', + [BRIDGE_PREFERRED_GAS_ESTIMATE]: { + suggestedMaxFeePerGas: '0.00018456', + suggestedMaxPriorityFeePerGas: '0.0001', + }, + }, + currencyRates: { + ETH: { conversionRate: 2524.25 }, + }, ...metamaskStateOverrides, bridgeState: { ...(swapsStore.metamask.bridgeState ?? {}), diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index a61d2fdcd8fd..766689cb8cda 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -11,7 +11,11 @@ import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; import { QuoteRequest } from '../../pages/bridge/types'; import { MetaMaskReduxDispatch } from '../../store/store'; -import { bridgeSlice } from './bridge'; +import { + bridgeSlice, + setDestTokenExchangeRates, + setSrcTokenExchangeRates, +} from './bridge'; const { setToChainId, @@ -19,6 +23,8 @@ const { setToToken, setFromTokenInputValue, resetInputFields, + setSortOrder, + setSelectedQuote, } = bridgeSlice.actions; export { @@ -27,6 +33,10 @@ export { setToToken, setFromToken, setFromTokenInputValue, + setDestTokenExchangeRates, + setSrcTokenExchangeRates, + setSortOrder, + setSelectedQuote, }; const callBridgeControllerMethod = ( diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index dc9596fcafba..5a395fa23036 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -10,6 +10,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; +import * as util from '../../helpers/utils/util'; import bridgeReducer from './bridge'; import { setBridgeFeatureFlags, @@ -22,6 +23,7 @@ import { setToChainId, updateQuoteRequestParams, resetBridgeState, + setDestTokenExchangeRates, } from './actions'; const middleware = [thunk]; @@ -143,10 +145,14 @@ describe('Ducks - Bridge', () => { expect(actions[0].type).toStrictEqual('bridge/resetInputFields'); const newState = bridgeReducer(state, actions[0]); expect(newState).toStrictEqual({ + selectedQuote: null, toChainId: null, fromToken: null, toToken: null, fromTokenInputValue: null, + sortOrder: 0, + toTokenExchangeRate: null, + fromTokenExchangeRate: null, }); }); }); @@ -201,10 +207,103 @@ describe('Ducks - Bridge', () => { expect(actions[0].type).toStrictEqual('bridge/resetInputFields'); const newState = bridgeReducer(state, actions[0]); expect(newState).toStrictEqual({ - toChainId: null, fromToken: null, - toToken: null, + fromTokenExchangeRate: null, fromTokenInputValue: null, + selectedQuote: null, + sortOrder: 0, + toChainId: null, + toToken: null, + toTokenExchangeRate: null, + }); + }); + }); + describe('setDestTokenExchangeRates', () => { + it('fetches token prices and updates dest exchange rates in state, native dest token', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockStore = configureMockStore(middleware)( + createBridgeMockStore(), + ); + const state = mockStore.getState().bridge; + const fetchTokenExchangeRatesSpy = jest + .spyOn(util, 'fetchTokenExchangeRates') + .mockResolvedValue({ + '0x0000000000000000000000000000000000000000': 0.356628, + }); + + await mockStore.dispatch( + setDestTokenExchangeRates({ + chainId: CHAIN_IDS.LINEA_MAINNET, + tokenAddress: zeroAddress(), + currency: 'usd', + }) as never, + ); + + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledWith( + 'usd', + ['0x0000000000000000000000000000000000000000'], + CHAIN_IDS.LINEA_MAINNET, + ); + + const actions = mockStore.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/pending', + ); + expect(actions[1].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/fulfilled', + ); + const newState = bridgeReducer(state, actions[1]); + expect(newState).toStrictEqual({ + toChainId: null, + toTokenExchangeRate: 0.356628, + sortOrder: 0, + }); + }); + + it('fetches token prices and updates dest exchange rates in state, erc20 dest token', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockStore = configureMockStore(middleware)( + createBridgeMockStore(), + ); + const state = mockStore.getState().bridge; + const fetchTokenExchangeRatesSpy = jest + .spyOn(util, 'fetchTokenExchangeRates') + .mockResolvedValue({ + '0x0000000000000000000000000000000000000000': 0.356628, + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': 0.999881, + }); + + await mockStore.dispatch( + setDestTokenExchangeRates({ + chainId: CHAIN_IDS.LINEA_MAINNET, + tokenAddress: + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'.toLowerCase(), + currency: 'usd', + }) as never, + ); + + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenExchangeRatesSpy).toHaveBeenCalledWith( + 'usd', + ['0x3c499c542cef5e3811e1192ce70d8cc03d5c3359'], + CHAIN_IDS.LINEA_MAINNET, + ); + + const actions = mockStore.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/pending', + ); + expect(actions[1].type).toStrictEqual( + 'bridge/setDestTokenExchangeRates/fulfilled', + ); + const newState = bridgeReducer(state, actions[1]); + expect(newState).toStrictEqual({ + toChainId: null, + toTokenExchangeRate: 0.999881, + sortOrder: 0, }); }); }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index c75030c7591d..edb0c9ca0d13 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,15 +1,24 @@ -import { createSlice } from '@reduxjs/toolkit'; - +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { Hex } from '@metamask/utils'; import { swapsSlice } from '../swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { SwapsEthToken } from '../../selectors'; +import { + QuoteMetadata, + QuoteResponse, + SortOrder, +} from '../../pages/bridge/types'; +import { getTokenExchangeRate } from './utils'; export type BridgeState = { toChainId: Hex | null; fromToken: SwapsTokenObject | SwapsEthToken | null; toToken: SwapsTokenObject | SwapsEthToken | null; fromTokenInputValue: string | null; + fromTokenExchangeRate: number | null; + toTokenExchangeRate: number | null; + sortOrder: SortOrder; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; // Alternate quote selected by user. When quotes refresh, the best match will be activated. }; const initialState: BridgeState = { @@ -17,8 +26,22 @@ const initialState: BridgeState = { fromToken: null, toToken: null, fromTokenInputValue: null, + fromTokenExchangeRate: null, + toTokenExchangeRate: null, + sortOrder: SortOrder.COST_ASC, + selectedQuote: null, }; +export const setSrcTokenExchangeRates = createAsyncThunk( + 'bridge/setSrcTokenExchangeRates', + getTokenExchangeRate, +); + +export const setDestTokenExchangeRates = createAsyncThunk( + 'bridge/setDestTokenExchangeRates', + getTokenExchangeRate, +); + const bridgeSlice = createSlice({ name: 'bridge', initialState: { ...initialState }, @@ -39,6 +62,20 @@ const bridgeSlice = createSlice({ resetInputFields: () => ({ ...initialState, }), + setSortOrder: (state, action) => { + state.sortOrder = action.payload; + }, + setSelectedQuote: (state, action) => { + state.selectedQuote = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { + state.toTokenExchangeRate = action.payload ?? null; + }); + builder.addCase(setSrcTokenExchangeRates.fulfilled, (state, action) => { + state.fromTokenExchangeRate = action.payload ?? null; + }); }, }); diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index e39f73f2fa15..b92c8e60e4f0 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1,12 +1,18 @@ +import { BigNumber } from 'bignumber.js'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; import { BUILT_IN_NETWORKS, CHAIN_IDS, FEATURED_RPCS, } from '../../../shared/constants/network'; -import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { + ALLOWED_BRIDGE_CHAIN_IDS, + BRIDGE_QUOTE_MAX_ETA_SECONDS, +} from '../../../shared/constants/bridge'; import { mockNetworkState } from '../../../test/stub/networks'; import mockErc20Erc20Quotes from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; +import mockBridgeQuotesNativeErc20 from '../../../test/data/bridge/mock-quotes-native-erc20.json'; +import { SortOrder } from '../../pages/bridge/types'; import { getAllBridgeableNetworks, getBridgeQuotes, @@ -17,7 +23,6 @@ import { getFromTokens, getFromTopAssets, getIsBridgeTx, - getToAmount, getToChain, getToChains, getToToken, @@ -392,15 +397,6 @@ describe('Bridge selectors', () => { }); }); - describe('getToAmount', () => { - it('returns hardcoded 0', () => { - const state = createBridgeMockStore(); - const result = getToAmount(state as never); - - expect(result).toStrictEqual(undefined); - }); - }); - describe('getToTokens', () => { it('returns dest tokens from controller state when toChainId is defined', () => { const state = createBridgeMockStore( @@ -498,7 +494,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=5', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: false }, quotes: mockErc20Erc20Quotes, @@ -508,11 +509,51 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); - const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: expect.any(Object), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + amount: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + amount: new BigNumber('13.98428'), + }, + gasFee: { + amount: new BigNumber('7.141025952e-8'), + fiat: new BigNumber('7.141025952e-8'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + amount: new BigNumber('0.00100007141025952'), + }, + }; + + const result = getBridgeQuotes(state as never); + expect(result.sortedQuotes).toHaveLength(2); expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 5, @@ -523,7 +564,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=2', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: false }, quotes: mockErc20Erc20Quotes, @@ -533,11 +579,57 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: new BigNumber('13.84343712858974048'), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + amount: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + amount: new BigNumber('13.98428'), + }, + gasFee: { + amount: new BigNumber('7.141025952e-8'), + fiat: new BigNumber('7.141025952e-8'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + amount: new BigNumber('0.00100007141025952'), + }, + }; + expect(result.sortedQuotes).toHaveLength(2); + const EXPECTED_SORTED_COSTS = [ + { fiat: new BigNumber('0.15656287141025952') }, + { fiat: new BigNumber('0.33900008283534464') }, + ]; + result.sortedQuotes.forEach((quote, idx) => { + expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); + }); expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 2, @@ -548,7 +640,12 @@ describe('Bridge selectors', () => { it('returns quote list and fetch data, insufficientBal=true', () => { const state = createBridgeMockStore( { extensionConfig: { maxRefreshCount: 5 } }, - { toChainId: '0x1' }, + { + toChainId: '0x1', + fromTokenExchangeRate: 1, + toTokenExchangeRate: 0.99, + toNativeExchangeRate: 0.354073, + }, { quoteRequest: { insufficientBal: true }, quotes: mockErc20Erc20Quotes, @@ -558,11 +655,58 @@ describe('Bridge selectors', () => { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, + { + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, ); const result = getBridgeQuotes(state as never); + const recommendedQuoteMetadata = { + adjustedReturn: { + fiat: new BigNumber('13.84343712858974048'), + }, + cost: { fiat: new BigNumber('0.15656287141025952') }, + sentAmount: { + fiat: new BigNumber('14'), + amount: new BigNumber('14'), + }, + swapRate: new BigNumber('0.998877142857142857142857142857142857'), + toTokenAmount: { + fiat: new BigNumber('13.8444372'), + amount: new BigNumber('13.98428'), + }, + gasFee: { + amount: new BigNumber('7.141025952e-8'), + fiat: new BigNumber('7.141025952e-8'), + }, + totalNetworkFee: { + fiat: new BigNumber('0.00100007141025952'), + amount: new BigNumber('0.00100007141025952'), + }, + }; + expect(result.sortedQuotes).toHaveLength(2); + const EXPECTED_SORTED_COSTS = [ + { fiat: new BigNumber('0.15656287141025952') }, + { fiat: new BigNumber('0.33900008283534464') }, + ]; + result.sortedQuotes.forEach((quote, idx) => { + expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); + }); + expect(result).toStrictEqual({ - quotes: mockErc20Erc20Quotes, + sortedQuotes: expect.any(Array), + recommendedQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, + activeQuote: { + ...mockErc20Erc20Quotes[0], + ...recommendedQuoteMetadata, + }, quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 1, @@ -570,4 +714,203 @@ describe('Bridge selectors', () => { }); }); }); + + describe('getBridgeQuotes', () => { + it('should return empty values when quotes are not present', () => { + const state = createBridgeMockStore(); + + const result = getBridgeQuotes(state as never); + + expect(result).toStrictEqual({ + activeQuote: undefined, + isLoading: false, + isQuoteGoingToRefresh: false, + quotesLastFetchedMs: undefined, + quotesRefreshCount: undefined, + recommendedQuote: undefined, + sortedQuotes: [], + }); + }); + + it('should sort quotes by adjustedReturn', () => { + const state = createBridgeMockStore( + {}, + {}, + { quotes: mockBridgeQuotesNativeErc20 }, + ); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + const quoteMetadataKeys = [ + 'adjustedReturn', + 'toTokenAmount', + 'sentAmount', + 'totalNetworkFee', + 'swapRate', + ]; + expect( + quoteMetadataKeys.every((k) => + Object.keys(activeQuote ?? {}).includes(k), + ), + ).toBe(true); + expect(activeQuote?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + expect(sortedQuotes).toHaveLength(2); + sortedQuotes.forEach((quote, idx) => { + expect( + quoteMetadataKeys.every((k) => Object.keys(quote ?? {}).includes(k)), + ).toBe(true); + expect(quote?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[idx]?.quote.requestId, + ); + }); + }); + + it('should sort quotes by ETA', () => { + const state = createBridgeMockStore( + {}, + { sortOrder: SortOrder.ETA_ASC }, + { + quotes: [ + ...mockBridgeQuotesNativeErc20, + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'fastestQuote', + }, + }, + ], + }, + ); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + expect(activeQuote?.quote.requestId).toStrictEqual('fastestQuote'); + expect(recommendedQuote?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes).toHaveLength(3); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[1]?.quote.requestId, + ); + expect(sortedQuotes[2]?.quote.requestId).toStrictEqual( + mockBridgeQuotesNativeErc20[0]?.quote.requestId, + ); + }); + + it('should recommend 2nd cheapest quote if ETA exceeds 1 hour', () => { + const state = createBridgeMockStore( + {}, + { sortOrder: SortOrder.COST_ASC }, + { + quotes: [ + mockBridgeQuotesNativeErc20[1], + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: + BRIDGE_QUOTE_MAX_ETA_SECONDS + 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'cheapestQuoteWithLongETA', + }, + }, + ], + }, + ); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + + expect(activeQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes).toHaveLength(2); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + 'cheapestQuoteWithLongETA', + ); + }); + + it('should recommend 2nd fastest quote if adjustedReturn is less than 80% of cheapest quote', () => { + const state = createBridgeMockStore( + {}, + { + sortOrder: SortOrder.ETA_ASC, + toTokenExchangeRate: 0.998781, + toNativeExchangeRate: 0.354073, + }, + { + quotes: [ + ...mockBridgeQuotesNativeErc20, + { + ...mockBridgeQuotesNativeErc20[0], + estimatedProcessingTimeInSeconds: 1, + quote: { + ...mockBridgeQuotesNativeErc20[0].quote, + requestId: 'fastestQuote', + destTokenAmount: '1', + }, + }, + ], + }, + { + currencyRates: { + ETH: { + conversionRate: 2524.25, + }, + }, + }, + ); + + const { activeQuote, recommendedQuote, sortedQuotes } = getBridgeQuotes( + state as never, + ); + const { + sentAmount, + totalNetworkFee, + toTokenAmount, + adjustedReturn, + cost, + } = activeQuote ?? {}; + + expect(activeQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(recommendedQuote?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sentAmount?.fiat?.toString()).toStrictEqual('25.2425'); + expect(totalNetworkFee?.fiat?.toString()).toStrictEqual( + '2.52459306428938562', + ); + expect(toTokenAmount?.fiat?.toString()).toStrictEqual('24.226654664163'); + expect(adjustedReturn?.fiat?.toString()).toStrictEqual( + '21.70206159987361438', + ); + expect(cost?.fiat?.toString()).toStrictEqual('3.54043840012638562'); + expect(sortedQuotes).toHaveLength(3); + expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); + expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( + '4277a368-40d7-4e82-aa67-74f29dc5f98a', + ); + expect(sortedQuotes[2]?.quote.requestId).toStrictEqual( + '381c23bc-e3e4-48fe-bc53-257471e388ad', + ); + }); + }); }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 86f4c8155b17..b78a0d09de51 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,12 +1,22 @@ -import { NetworkConfiguration } from '@metamask/network-controller'; -import { uniqBy } from 'lodash'; +import { + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import { orderBy, uniqBy } from 'lodash'; import { createSelector } from 'reselect'; +import { GasFeeEstimates } from '@metamask/gas-fee-controller'; +import { BigNumber } from 'bignumber.js'; import { getIsBridgeEnabled, getSwapsDefaultToken, SwapsEthToken, } from '../../selectors/selectors'; -import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { + ALLOWED_BRIDGE_CHAIN_IDS, + BRIDGE_PREFERRED_GAS_ESTIMATE, + BRIDGE_QUOTE_MAX_ETA_SECONDS, + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, +} from '../../../shared/constants/bridge'; import { BridgeControllerState, BridgeFeatureFlagsKey, @@ -15,21 +25,38 @@ import { } from '../../../app/scripts/controllers/bridge/types'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; import { - NetworkState, getProviderConfig, getNetworkConfigurationsByChainId, } from '../../../shared/modules/selectors/networks'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; -import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; +import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; +import { + L1GasFees, + QuoteMetadata, + QuoteResponse, + SortOrder, +} from '../../pages/bridge/types'; +import { + calcAdjustedReturn, + calcCost, + calcRelayerFee, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalGasFee, + isNativeAddress, +} from '../../pages/bridge/utils/quote'; +import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { BridgeState } from './bridge'; -type BridgeAppState = NetworkState & { - metamask: { bridgeState: BridgeControllerState } & { - useExternalServices: boolean; - }; +type BridgeAppState = { + metamask: { bridgeState: BridgeControllerState } & NetworkState & { + useExternalServices: boolean; + currencyRates: { [currency: string]: { conversionRate: number } }; + }; bridge: BridgeState; }; @@ -140,48 +167,203 @@ export const getBridgeQuotesConfig = (state: BridgeAppState) => BridgeFeatureFlagsKey.EXTENSION_CONFIG ] ?? {}; +const _getBridgeFeesPerGas = createSelector( + getGasFeeEstimates, + (gasFeeEstimates) => ({ + estimatedBaseFeeInDecGwei: (gasFeeEstimates as GasFeeEstimates) + ?.estimatedBaseFee, + maxPriorityFeePerGasInDecGwei: (gasFeeEstimates as GasFeeEstimates)?.[ + BRIDGE_PREFERRED_GAS_ESTIMATE + ]?.suggestedMaxPriorityFeePerGas, + maxFeePerGas: decGWEIToHexWEI( + (gasFeeEstimates as GasFeeEstimates)?.high?.suggestedMaxFeePerGas, + ), + maxPriorityFeePerGas: decGWEIToHexWEI( + (gasFeeEstimates as GasFeeEstimates)?.high?.suggestedMaxPriorityFeePerGas, + ), + }), +); + +export const getBridgeSortOrder = (state: BridgeAppState) => + state.bridge.sortOrder; + +// A dest network can be selected before it's imported +// The cached exchange rate won't be available so the rate from the bridge state is used +const _getToTokenExchangeRate = createSelector( + (state) => state.metamask.currencyRates, + (state: BridgeAppState) => state.bridge.toTokenExchangeRate, + getToChain, + getToToken, + (cachedCurrencyRates, toTokenExchangeRate, toChain, toToken) => { + return ( + toTokenExchangeRate ?? + (isNativeAddress(toToken?.address) && toChain?.nativeCurrency + ? cachedCurrencyRates[toChain.nativeCurrency]?.conversionRate + : null) + ); + }, +); + +const _getQuotesWithMetadata = createDeepEqualSelector( + (state) => state.metamask.bridgeState.quotes, + _getToTokenExchangeRate, + (state: BridgeAppState) => state.bridge.fromTokenExchangeRate, + getConversionRate, + _getBridgeFeesPerGas, + ( + quotes, + toTokenExchangeRate, + fromTokenExchangeRate, + nativeExchangeRate, + { estimatedBaseFeeInDecGwei, maxPriorityFeePerGasInDecGwei }, + ): (QuoteResponse & QuoteMetadata)[] => { + const newQuotes = quotes.map((quote: QuoteResponse) => { + const toTokenAmount = calcToAmount(quote.quote, toTokenExchangeRate); + const gasFee = calcTotalGasFee( + quote, + estimatedBaseFeeInDecGwei, + maxPriorityFeePerGasInDecGwei, + nativeExchangeRate, + ); + const relayerFee = calcRelayerFee(quote, nativeExchangeRate); + const totalNetworkFee = { + amount: gasFee.amount.plus(relayerFee.amount), + fiat: gasFee.fiat?.plus(relayerFee.fiat || '0') ?? null, + }; + const sentAmount = calcSentAmount( + quote.quote, + isNativeAddress(quote.quote.srcAsset.address) + ? nativeExchangeRate + : fromTokenExchangeRate, + ); + const adjustedReturn = calcAdjustedReturn( + toTokenAmount.fiat, + totalNetworkFee.fiat, + ); + + return { + ...quote, + toTokenAmount, + sentAmount, + totalNetworkFee, + adjustedReturn, + gasFee, + swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), + cost: calcCost(adjustedReturn.fiat, sentAmount.fiat), + }; + }); + + return newQuotes; + }, +); + +const _getSortedQuotesWithMetadata = createDeepEqualSelector( + _getQuotesWithMetadata, + getBridgeSortOrder, + (quotesWithMetadata, sortOrder) => { + switch (sortOrder) { + case SortOrder.ETA_ASC: + return orderBy( + quotesWithMetadata, + (quote) => quote.estimatedProcessingTimeInSeconds, + 'asc', + ); + case SortOrder.COST_ASC: + default: + return orderBy(quotesWithMetadata, ({ cost }) => cost.fiat, 'asc'); + } + }, +); + +const _getRecommendedQuote = createDeepEqualSelector( + _getSortedQuotesWithMetadata, + getBridgeSortOrder, + (sortedQuotesWithMetadata, sortOrder) => { + if (!sortedQuotesWithMetadata.length) { + return undefined; + } + + const bestReturnValue = BigNumber.max( + sortedQuotesWithMetadata.map( + ({ adjustedReturn }) => adjustedReturn.fiat ?? 0, + ), + ); + + const isFastestQuoteValueReasonable = ( + adjustedReturnInFiat: BigNumber | null, + ) => + adjustedReturnInFiat + ? adjustedReturnInFiat + .div(bestReturnValue) + .gte(BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE) + : true; + + const isBestPricedQuoteETAReasonable = ( + estimatedProcessingTimeInSeconds: number, + ) => estimatedProcessingTimeInSeconds < BRIDGE_QUOTE_MAX_ETA_SECONDS; + + return ( + sortedQuotesWithMetadata.find((quote) => { + return sortOrder === SortOrder.ETA_ASC + ? isFastestQuoteValueReasonable(quote.adjustedReturn.fiat) + : isBestPricedQuoteETAReasonable( + quote.estimatedProcessingTimeInSeconds, + ); + }) ?? sortedQuotesWithMetadata[0] + ); + }, +); + +// Generates a pseudo-unique string that identifies each quote +// by aggregator, bridge, steps and value +const _getQuoteIdentifier = ({ quote }: QuoteResponse & L1GasFees) => + `${quote.bridgeId}-${quote.bridges[0]}-${quote.steps.length}`; + +const _getSelectedQuote = createSelector( + (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, + (state: BridgeAppState) => state.bridge.selectedQuote, + _getSortedQuotesWithMetadata, + (quotesRefreshCount, selectedQuote, sortedQuotesWithMetadata) => + quotesRefreshCount <= 1 + ? selectedQuote + : // Find match for selectedQuote in new quotes + sortedQuotesWithMetadata.find((quote) => + selectedQuote + ? _getQuoteIdentifier(quote) === _getQuoteIdentifier(selectedQuote) + : false, + ), +); + export const getBridgeQuotes = createSelector( - (state: BridgeAppState) => state.metamask.bridgeState.quotes, - (state: BridgeAppState) => state.metamask.bridgeState.quotesLastFetched, - (state: BridgeAppState) => + _getSortedQuotesWithMetadata, + _getRecommendedQuote, + _getSelectedQuote, + (state) => state.metamask.bridgeState.quotesLastFetched, + (state) => state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, getBridgeQuotesConfig, getQuoteRequest, ( - quotes, + sortedQuotesWithMetadata, + recommendedQuote, + selectedQuote, quotesLastFetchedMs, isLoading, quotesRefreshCount, { maxRefreshCount }, { insufficientBal }, - ) => { - return { - quotes, - quotesLastFetchedMs, - isLoading, - quotesRefreshCount, - isQuoteGoingToRefresh: insufficientBal - ? false - : quotesRefreshCount < maxRefreshCount, - }; - }, -); - -export const getRecommendedQuote = createSelector( - getBridgeQuotes, - ({ quotes }) => { - return quotes[0]; - }, -); - -export const getToAmount = createSelector(getRecommendedQuote, (quote) => - quote - ? calcTokenAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset.decimals, - ) - : undefined, + ) => ({ + sortedQuotes: sortedQuotesWithMetadata, + recommendedQuote, + activeQuote: selectedQuote ?? recommendedQuote, + quotesLastFetchedMs, + isLoading, + quotesRefreshCount, + isQuoteGoingToRefresh: insufficientBal + ? false + : quotesRefreshCount < maxRefreshCount, + }), ); export const getIsBridgeTx = createDeepEqualSelector( diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index 853c344310fe..de45111cc10b 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -1,9 +1,11 @@ import { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { getAddress } from 'ethers/lib/utils'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { Numeric } from '../../../shared/modules/Numeric'; import { TxData } from '../../pages/bridge/types'; import { getTransaction1559GasFeeEstimates } from '../../pages/swaps/swaps.util'; +import { fetchTokenExchangeRates } from '../../helpers/utils/util'; // We don't need to use gas multipliers here because the gasLimit from Bridge API already included it export const getHexMaxGasLimit = (gasLimit: number) => { @@ -45,3 +47,20 @@ export const getTxGasEstimates = async ({ maxPriorityFeePerGas: undefined, }; }; + +export const getTokenExchangeRate = async (request: { + chainId: Hex; + tokenAddress: string; + currency: string; +}) => { + const { chainId, tokenAddress, currency } = request; + const exchangeRates = await fetchTokenExchangeRates( + currency, + [tokenAddress], + chainId, + ); + return ( + exchangeRates?.[tokenAddress.toLowerCase()] ?? + exchangeRates?.[getAddress(tokenAddress)] + ); +}; diff --git a/ui/hooks/bridge/useBridging.test.ts b/ui/hooks/bridge/useBridging.test.ts index 6e3f3b534e35..9fe02c439048 100644 --- a/ui/hooks/bridge/useBridging.test.ts +++ b/ui/hooks/bridge/useBridging.test.ts @@ -123,19 +123,19 @@ describe('useBridging', () => { // @ts-expect-error This is missing from the Mocha type definitions it.each([ [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x0000000000000000000000000000000000000000', ETH_SWAPS_TOKEN_OBJECT, 'Home', undefined, ], [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x0000000000000000000000000000000000000000', ETH_SWAPS_TOKEN_OBJECT, MetaMetricsSwapsEventSource.TokenView, '&token=native', ], [ - '/cross-chain/swaps/prepare-swap-page', + '/cross-chain/swaps/prepare-swap-page?token=0x00232f2jksdauo', { iconUrl: 'https://icon.url', symbol: 'TEST', @@ -174,7 +174,7 @@ describe('useBridging', () => { result.current.openBridgeExperience(location, token, urlSuffix); - expect(mockDispatch.mock.calls).toHaveLength(2); + expect(mockDispatch.mock.calls).toHaveLength(1); expect(mockHistoryPush.mock.calls).toHaveLength(1); expect(mockHistoryPush).toHaveBeenCalledWith(expectedUrl); expect(openTabSpy).not.toHaveBeenCalled(); diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index c4ae1cca57a3..62945e3e7a92 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -28,7 +28,6 @@ import { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { isHardwareKeyring } from '../../helpers/utils/hardware'; import { getPortfolioUrl } from '../../helpers/utils/portfolio'; -import { setSwapsFromToken } from '../../ducks/swaps/swaps'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; ///: END:ONLY_INCLUDE_IF @@ -74,9 +73,6 @@ const useBridging = () => { chain_id: providerConfig.chainId, }, }); - dispatch( - setSwapsFromToken({ ...token, address: token.address.toLowerCase() }), - ); if (usingHardwareWallet && global.platform.openExtensionInBrowser) { global.platform.openExtensionInBrowser( PREPARE_SWAP_ROUTE, @@ -84,7 +80,9 @@ const useBridging = () => { false, ); } else { - history.push(CROSS_CHAIN_SWAP_ROUTE + PREPARE_SWAP_ROUTE); + history.push( + `${CROSS_CHAIN_SWAP_ROUTE}${PREPARE_SWAP_ROUTE}?token=${token.address.toLowerCase()}`, + ); } } else { const portfolioUrl = getPortfolioUrl( @@ -115,7 +113,6 @@ const useBridging = () => { [ isBridgeSupported, isBridgeChain, - setSwapsFromToken, dispatch, usingHardwareWallet, history, diff --git a/ui/hooks/bridge/useCountdownTimer.test.ts b/ui/hooks/bridge/useCountdownTimer.test.ts index f2cd1190b1ba..293fe1ac679b 100644 --- a/ui/hooks/bridge/useCountdownTimer.test.ts +++ b/ui/hooks/bridge/useCountdownTimer.test.ts @@ -17,14 +17,11 @@ describe('useCountdownTimer', () => { const quotesLastFetched = Date.now(); const { result } = renderUseCountdownTimer( createBridgeMockStore( - {}, + { extensionConfig: { maxRefreshCount: 5, refreshRate: 40000 } }, {}, { quotesLastFetched, quotesRefreshCount: 0, - bridgeFeatureFlags: { - extensionConfig: { maxRefreshCount: 5, refreshRate: 40000 }, - }, }, ), ); diff --git a/ui/hooks/useTokensWithFiltering.ts b/ui/hooks/useTokensWithFiltering.ts index a7ff3f2513ac..d729ce3c1fdc 100644 --- a/ui/hooks/useTokensWithFiltering.ts +++ b/ui/hooks/useTokensWithFiltering.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { isEqual } from 'lodash'; import { ChainId, hexToBN } from '@metamask/controller-utils'; import { Hex } from '@metamask/utils'; +import { useParams } from 'react-router-dom'; import { getAllTokens, getCurrentCurrency, @@ -39,6 +40,8 @@ export const useTokensWithFiltering = ( sortOrder: TokenBucketPriority = TokenBucketPriority.owned, chainId?: ChainId | Hex, ) => { + const { token: tokenAddressFromUrl } = useParams(); + // Only includes non-native tokens const allDetectedTokens = useSelector(getAllTokens); const { address: selectedAddress, balance: balanceOnActiveChain } = @@ -123,6 +126,18 @@ export const useTokensWithFiltering = ( yield nativeToken; } + if (tokenAddressFromUrl) { + const tokenListItem = + tokenList?.[tokenAddressFromUrl] ?? + tokenList?.[tokenAddressFromUrl.toLowerCase()]; + if (tokenListItem) { + const tokenWithTokenListData = buildTokenData(tokenListItem); + if (tokenWithTokenListData) { + yield tokenWithTokenListData; + } + } + } + if (sortOrder === TokenBucketPriority.owned) { for (const tokenWithBalance of sortedErc20TokensWithBalances) { const cachedTokenData = @@ -171,6 +186,7 @@ export const useTokensWithFiltering = ( currentCurrency, chainId, tokenList, + tokenAddressFromUrl, ], ); diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 687057094005..6dd54b424d06 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -1,6 +1,7 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Switch, useHistory } from 'react-router-dom'; +import { zeroAddress } from 'ethereumjs-util'; import { I18nContext } from '../../contexts/i18n'; import { clearSwapsState } from '../../ducks/swaps/swaps'; import { @@ -16,16 +17,25 @@ import { ButtonIconSize, IconName, } from '../../components/component-library'; -import { getIsBridgeChain, getIsBridgeEnabled } from '../../selectors'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; +import { + getCurrentCurrency, + getIsBridgeChain, + getIsBridgeEnabled, +} from '../../selectors'; import useBridging from '../../hooks/bridge/useBridging'; import { Content, Footer, Header, } from '../../components/multichain/pages/page'; -import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; +import { + resetBridgeState, + setFromChain, + setSrcTokenExchangeRates, +} from '../../ducks/bridge/actions'; +import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; @@ -42,13 +52,20 @@ const CrossChainSwap = () => { const isBridgeEnabled = useSelector(getIsBridgeEnabled); const providerConfig = useSelector(getProviderConfig); const isBridgeChain = useSelector(getIsBridgeChain); + const currency = useSelector(getCurrentCurrency); useEffect(() => { - isBridgeChain && - isBridgeEnabled && - providerConfig && + if (isBridgeChain && isBridgeEnabled && providerConfig && currency) { dispatch(setFromChain(providerConfig.chainId)); - }, [isBridgeChain, isBridgeEnabled, providerConfig]); + dispatch( + setSrcTokenExchangeRates({ + chainId: providerConfig.chainId, + tokenAddress: zeroAddress(), + currency, + }), + ); + } + }, [isBridgeChain, isBridgeEnabled, providerConfig, currency]); const resetControllerAndInputStates = async () => { await dispatch(resetBridgeState()); @@ -66,6 +83,9 @@ const CrossChainSwap = () => { }; }, []); + // Needed for refreshing gas estimates + useGasFeeEstimates(providerConfig?.id); + const redirectToDefaultRoute = async () => { history.push({ pathname: DEFAULT_ROUTE, diff --git a/ui/pages/bridge/layout/column.tsx b/ui/pages/bridge/layout/column.tsx new file mode 100644 index 000000000000..6f5b2847b5e5 --- /dev/null +++ b/ui/pages/bridge/layout/column.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { + Container, + ContainerProps, +} from '../../../components/component-library'; +import { + BlockSize, + Display, + FlexDirection, +} from '../../../helpers/constants/design-system'; + +const Column = (props: ContainerProps<'div'>) => { + return ( + + ); +}; + +export default Column; diff --git a/ui/pages/bridge/layout/index.tsx b/ui/pages/bridge/layout/index.tsx new file mode 100644 index 000000000000..d519d211f500 --- /dev/null +++ b/ui/pages/bridge/layout/index.tsx @@ -0,0 +1,5 @@ +import Column from './column'; +import Row from './row'; +import Tooltip from './tooltip'; + +export { Column, Row, Tooltip }; diff --git a/ui/pages/bridge/layout/row.tsx b/ui/pages/bridge/layout/row.tsx new file mode 100644 index 000000000000..eeb94a7e06f7 --- /dev/null +++ b/ui/pages/bridge/layout/row.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { + Container, + ContainerProps, +} from '../../../components/component-library'; +import { + AlignItems, + Display, + FlexDirection, + FlexWrap, + JustifyContent, +} from '../../../helpers/constants/design-system'; + +const Row = (props: ContainerProps<'div'>) => { + return ( + + ); +}; + +export default Row; diff --git a/ui/pages/bridge/layout/tooltip.tsx b/ui/pages/bridge/layout/tooltip.tsx new file mode 100644 index 000000000000..b6781c9bf480 --- /dev/null +++ b/ui/pages/bridge/layout/tooltip.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { + Box, + Popover, + PopoverHeader, + PopoverPosition, + PopoverProps, + Text, +} from '../../../components/component-library'; +import { + JustifyContent, + TextAlign, + TextColor, +} from '../../../helpers/constants/design-system'; + +const Tooltip = React.forwardRef( + ({ + children, + title, + triggerElement, + disabled = false, + ...props + }: PopoverProps<'div'> & { + triggerElement: React.ReactElement; + disabled?: boolean; + }) => { + const [isOpen, setIsOpen] = useState(false); + const [referenceElement, setReferenceElement] = + useState(null); + + const handleMouseEnter = () => setIsOpen(true); + const handleMouseLeave = () => setIsOpen(false); + const setBoxRef = (ref: HTMLSpanElement | null) => setReferenceElement(ref); + + return ( + <> + + {triggerElement} + + {!disabled && ( + + + {title} + + + {children} + + + )} + + ); + }, +); + +export default Tooltip; diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 06d784f2e0ea..7355e6579dfa 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -2,14 +2,12 @@ import React, { useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Button } from '../../../components/component-library'; import { - getBridgeQuotes, getFromAmount, getFromChain, getFromToken, - getRecommendedQuote, - getToAmount, getToChain, getToToken, + getBridgeQuotes, } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; @@ -25,15 +23,13 @@ export const BridgeCTAButton = () => { const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); - const toAmount = useSelector(getToAmount); - const { isLoading } = useSelector(getBridgeQuotes); - const quoteResponse = useSelector(getRecommendedQuote); + const { isLoading, activeQuote } = useSelector(getBridgeQuotes); const { submitBridgeTransaction } = useSubmitBridgeTransaction(); const isTxSubmittable = - fromToken && toToken && fromChain && toChain && fromAmount && toAmount; + fromToken && toToken && fromChain && toChain && fromAmount && activeQuote; const label = useMemo(() => { if (isLoading && !isTxSubmittable) { @@ -59,7 +55,7 @@ export const BridgeCTAButton = () => { data-testid="bridge-cta-button" onClick={() => { if (isTxSubmittable) { - dispatch(submitBridgeTransaction(quoteResponse)); + dispatch(submitBridgeTransaction(activeQuote)); } }} disabled={!isTxSubmittable} diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index 266af5b9a3cc..0dbecf6cffdd 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -28,10 +28,7 @@ import { CHAIN_ID_TOKEN_IMAGE_MAP, } from '../../../../shared/constants/network'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; -import { - getBridgeQuotes, - getRecommendedQuote, -} from '../../../ducks/bridge/selectors'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; const generateAssetFromToken = ( chainId: Hex, @@ -82,8 +79,7 @@ export const BridgeInputGroup = ({ >) => { const t = useI18nContext(); - const { isLoading } = useSelector(getBridgeQuotes); - const recommendedQuote = useSelector(getRecommendedQuote); + const { isLoading, activeQuote } = useSelector(getBridgeQuotes); const tokenFiatValue = useTokenFiatAmount( token?.address || undefined, @@ -134,9 +130,7 @@ export const BridgeInputGroup = ({ type={TextFieldType.Number} className="amount-input" placeholder={ - isLoading && !recommendedQuote - ? t('bridgeCalculatingAmount') - : '0' + isLoading && !activeQuote ? t('bridgeCalculatingAmount') : '0' } onChange={(e) => { onAmountChange?.(e.target.value); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index aba35d5b89be..95248bdbb0bc 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from '@testing-library/react'; +import * as reactRouterUtils from 'react-router-dom-v5-compat'; import { fireEvent, renderWithProvider } from '../../../../test/jest'; import configureStore from '../../../store/store'; import { createBridgeMockStore } from '../../../../test/jest/mock-store'; @@ -23,6 +24,9 @@ describe('PrepareBridgePage', () => { }); it('should render the component, with initial state', async () => { + jest + .spyOn(reactRouterUtils, 'useSearchParams') + .mockReturnValue([{ get: () => null }] as never); const mockStore = createBridgeMockStore( { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.OPTIMISM], @@ -54,6 +58,9 @@ describe('PrepareBridgePage', () => { }); it('should render the component, with inputs set', async () => { + jest + .spyOn(reactRouterUtils, 'useSearchParams') + .mockReturnValue([{ get: () => '0x3103910' }, jest.fn()] as never); const mockStore = createBridgeMockStore( { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET], diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index b0553407686d..aea037c71f13 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -2,16 +2,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import classnames from 'classnames'; import { debounce } from 'lodash'; +import { Hex } from '@metamask/utils'; +import { zeroAddress } from 'ethereumjs-util'; +import { useHistory, useLocation } from 'react-router-dom'; import { + setDestTokenExchangeRates, setFromChain, setFromToken, setFromTokenInputValue, + setSrcTokenExchangeRates, + setSelectedQuote, setToChain, setToChainId, setToToken, updateQuoteRequestParams, } from '../../../ducks/bridge/actions'; import { + getBridgeQuotes, getFromAmount, getFromChain, getFromChains, @@ -19,7 +26,6 @@ import { getFromTokens, getFromTopAssets, getQuoteRequest, - getToAmount, getToChain, getToChains, getToToken, @@ -42,6 +48,8 @@ import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { isValidQuoteRequest } from '../utils/quote'; import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; +import { getCurrentCurrency } from '../../../selectors'; +import { SECOND } from '../../../../shared/constants/time'; import { BridgeInputGroup } from './bridge-input-group'; const PrepareBridgePage = () => { @@ -49,6 +57,8 @@ const PrepareBridgePage = () => { const t = useI18nContext(); + const currency = useSelector(getCurrentCurrency); + const fromToken = useSelector(getFromToken); const fromTokens = useSelector(getFromTokens); const fromTopAssets = useSelector(getFromTopAssets); @@ -63,11 +73,11 @@ const PrepareBridgePage = () => { const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); - const toAmount = useSelector(getToAmount); const providerConfig = useSelector(getProviderConfig); const quoteRequest = useSelector(getQuoteRequest); + const { activeQuote } = useSelector(getBridgeQuotes); const fromTokenListGenerator = useTokensWithFiltering( fromTokens, @@ -114,10 +124,10 @@ const PrepareBridgePage = () => { ); const debouncedUpdateQuoteRequestInController = useCallback( - debounce( - (p: Partial) => dispatch(updateQuoteRequestParams(p)), - 300, - ), + debounce((p: Partial) => { + dispatch(updateQuoteRequestParams(p)); + dispatch(setSelectedQuote(null)); + }, 300), [], ); @@ -125,6 +135,62 @@ const PrepareBridgePage = () => { debouncedUpdateQuoteRequestInController(quoteParams); }, Object.values(quoteParams)); + const debouncedFetchFromExchangeRate = debounce( + (chainId: Hex, tokenAddress: string) => { + dispatch(setSrcTokenExchangeRates({ chainId, tokenAddress, currency })); + }, + SECOND, + ); + + const debouncedFetchToExchangeRate = debounce( + (chainId: Hex, tokenAddress: string) => { + dispatch(setDestTokenExchangeRates({ chainId, tokenAddress, currency })); + }, + SECOND, + ); + + const { search } = useLocation(); + const history = useHistory(); + + useEffect(() => { + if (!fromChain?.chainId || Object.keys(fromTokens).length === 0) { + return; + } + + const searchParams = new URLSearchParams(search); + const tokenAddressFromUrl = searchParams.get('token'); + if (!tokenAddressFromUrl) { + return; + } + + const removeTokenFromUrl = () => { + const newParams = new URLSearchParams(searchParams); + newParams.delete('token'); + history.replace({ + search: newParams.toString(), + }); + }; + + switch (tokenAddressFromUrl) { + case fromToken?.address?.toLowerCase(): + // If the token is already set, remove the query param + removeTokenFromUrl(); + break; + case fromTokens[tokenAddressFromUrl]?.address?.toLowerCase(): { + // If there is a matching fromToken, set it as the fromToken + const matchedToken = fromTokens[tokenAddressFromUrl]; + dispatch(setFromToken(matchedToken)); + debouncedFetchFromExchangeRate(fromChain.chainId, matchedToken.address); + removeTokenFromUrl(); + break; + } + default: + // Otherwise remove query param + removeTokenFromUrl(); + break; + } + }, [fromChain, fromToken, fromTokens, search]); + return (
@@ -138,6 +204,9 @@ const PrepareBridgePage = () => { onAssetChange={(token) => { dispatch(setFromToken(token)); dispatch(setFromTokenInputValue(null)); + fromChain?.chainId && + token?.address && + debouncedFetchFromExchangeRate(fromChain.chainId, token.address); }} networkProps={{ network: fromChain, @@ -192,6 +261,19 @@ const PrepareBridgePage = () => { fromChain?.chainId && dispatch(setToChain(fromChain.chainId)); fromChain?.chainId && dispatch(setToChainId(fromChain.chainId)); dispatch(setToToken(fromToken)); + fromChain?.chainId && + fromToken?.address && + debouncedFetchToExchangeRate( + fromChain.chainId, + fromToken.address, + ); + toChain?.chainId && + toToken?.address && + toToken.address !== zeroAddress() && + debouncedFetchFromExchangeRate( + toChain.chainId, + toToken.address, + ); }} /> @@ -200,7 +282,12 @@ const PrepareBridgePage = () => { className="bridge-box" header={t('bridgeTo')} token={toToken} - onAssetChange={(token) => dispatch(setToToken(token))} + onAssetChange={(token) => { + dispatch(setToToken(token)); + toChain?.chainId && + token?.address && + debouncedFetchToExchangeRate(toChain.chainId, token.address); + }} networkProps={{ network: toChain, networks: toChains, @@ -218,8 +305,10 @@ const PrepareBridgePage = () => { testId: 'to-amount', readOnly: true, disabled: true, - value: toAmount?.toString() ?? '0', - className: toAmount ? 'amount-input defined' : 'amount-input', + value: activeQuote?.toTokenAmount?.amount.toFixed() ?? '0', + className: activeQuote?.toTokenAmount.amount + ? 'amount-input defined' + : 'amount-input', }} /> diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index cb7b5afb4c77..6b69b8ec9a6c 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -61,7 +61,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 1 minutes + 1 min

@@ -90,7 +90,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = `

- 1 USDC = 0.9989 USDC + 1 USDC = 1.00 USDC

@@ -131,13 +131,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.01 ETH + 0.001000 ETH

- $0.01 + $2.52

@@ -234,7 +234,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 1 minutes + 1 min

@@ -263,7 +263,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q

- 1 ETH = 2465.4630 USDC + 1 ETH = 2443.89 USDC

@@ -304,13 +304,13 @@ Fees are based on network traffic and transaction complexity. MetaMask does not

- 0.01 ETH + 0.001000 ETH

- $0.01 + $2.52

diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap index 41d8a03d1ac1..137dc246864e 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap @@ -33,31 +33,31 @@ exports[`BridgeQuotesModal should render the modal 1`] = ` class="mm-box mm-header-base mm-modal-header mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--justify-content-space-between" >
-

- Select a quote -

-
-
+
+

+ Select a quote +

+
-

+

- $0.01 -

-

+

+ $3 network fee +

+

+ 14 USDC receive amount +

+
+
- 1 minutes -

+

+ 1 min +

+

+ Across +

+
-

- $0.01 -

-

+

+ $3 network fee +

+

+ 14 USDC receive amount +

+
+
- 26 minutes -

+

+ 26 min +

+

+ Celercircle +

+
diff --git a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx index 274ade65a4d1..7de52fef1d58 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx @@ -20,6 +20,7 @@ describe('BridgeQuoteCard', () => { { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.OPTIMISM], destNetworkAllowlist: [CHAIN_IDS.OPTIMISM], + extensionConfig: { maxRefreshCount: 5, refreshRate: 30000 }, }, { fromTokenInputValue: 1 }, { @@ -28,9 +29,6 @@ describe('BridgeQuoteCard', () => { quotes: mockBridgeQuotesErc20Erc20, getQuotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, - bridgeFeatureFlags: { - extensionConfig: { maxRefreshCount: 5, refreshRate: 30000 }, - }, }, ); const { container } = renderWithProvider( diff --git a/ui/pages/bridge/quotes/bridge-quote-card.tsx b/ui/pages/bridge/quotes/bridge-quote-card.tsx index fc1176c8c3f9..8adf675afbb3 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.tsx @@ -6,30 +6,32 @@ import { ButtonVariant, Text, } from '../../../components/component-library'; -import { - getBridgeQuotes, - getRecommendedQuote, -} from '../../../ducks/bridge/selectors'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { getQuoteDisplayData } from '../utils/quote'; +import { + formatFiatAmount, + formatTokenAmount, + formatEtaInMinutes, +} from '../utils/quote'; import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; import MascotBackgroundAnimation from '../../swaps/mascot-background-animation/mascot-background-animation'; +import { getCurrentCurrency } from '../../../selectors'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import { QuoteInfoRow } from './quote-info-row'; import { BridgeQuotesModal } from './bridge-quotes-modal'; export const BridgeQuoteCard = () => { const t = useI18nContext(); - const recommendedQuote = useSelector(getRecommendedQuote); - const { isLoading, isQuoteGoingToRefresh } = useSelector(getBridgeQuotes); - - const { etaInMinutes, totalFees, quoteRate } = - getQuoteDisplayData(recommendedQuote); + const { isLoading, isQuoteGoingToRefresh, activeQuote } = + useSelector(getBridgeQuotes); + const currency = useSelector(getCurrentCurrency); + const ticker = useSelector(getNativeCurrency); const secondsUntilNextRefresh = useCountdownTimer(); const [showAllQuotes, setShowAllQuotes] = useState(false); - if (isLoading && !recommendedQuote) { + if (isLoading && !activeQuote) { return ( @@ -37,7 +39,7 @@ export const BridgeQuoteCard = () => { ); } - return etaInMinutes && totalFees && quoteRate ? ( + return activeQuote ? ( { - - + {activeQuote.swapRate && ( + + )} + {activeQuote.totalNetworkFee && ( + + )} diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx new file mode 100644 index 000000000000..bbdf9b47fa47 --- /dev/null +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.stories.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import { BridgeQuotesModal } from './bridge-quotes-modal'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes-erc20-erc20.json'; +import { SortOrder } from '../types'; + +const storybook = { + title: 'Pages/Bridge/BridgeQuotesModal', + component: BridgeQuotesModal, +}; + +export const NoTokenPricesAvailableStory = () => { + return {}} isOpen={true} />; +}; +NoTokenPricesAvailableStory.storyName = 'Token Prices Not Available'; +NoTokenPricesAvailableStory.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export const DefaultStory = () => { + return {}} isOpen={true} />; +}; +DefaultStory.storyName = 'Default'; +DefaultStory.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export const PositiveArbitrage = () => { + return {}} isOpen={true} />; +}; +PositiveArbitrage.decorators = [ + (story) => ( + + {story()} + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx index 7e78e515af6e..0f4986aa18fc 100644 --- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx +++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { IconName } from '@metamask/snaps-sdk/jsx'; +import { useDispatch, useSelector } from 'react-redux'; +import { startCase } from 'lodash'; import { - Box, - Button, - ButtonVariant, - Icon, + ButtonLink, IconSize, Modal, ModalContent, @@ -14,51 +12,198 @@ import { Text, } from '../../../components/component-library'; import { + AlignItems, + BackgroundColor, TextAlign, + TextColor, TextVariant, } from '../../../helpers/constants/design-system'; -import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; -import { getQuoteDisplayData } from '../utils/quote'; +import { + formatEtaInMinutes, + formatFiatAmount, + formatTokenAmount, +} from '../utils/quote'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getCurrentCurrency } from '../../../selectors'; +import { setSelectedQuote, setSortOrder } from '../../../ducks/bridge/actions'; +import { SortOrder } from '../types'; +import { + getBridgeQuotes, + getBridgeSortOrder, +} from '../../../ducks/bridge/selectors'; +import { Column, Row } from '../layout'; +import { getNativeCurrency } from '../../../ducks/metamask/metamask'; export const BridgeQuotesModal = ({ onClose, ...modalProps }: Omit, 'children'>) => { - const { quotes } = useSelector(getBridgeQuotes); const t = useI18nContext(); + const dispatch = useDispatch(); + + const { sortedQuotes, activeQuote } = useSelector(getBridgeQuotes); + const sortOrder = useSelector(getBridgeSortOrder); + const currency = useSelector(getCurrentCurrency); + const nativeCurrency = useSelector(getNativeCurrency); return ( - - + + + {t('swapSelectAQuote')} - - {[t('bridgeOverallCost'), t('time')].map((label) => { - return ( - - ); - })} - - - {quotes.map((quote, index) => { - const { totalFees, etaInMinutes } = getQuoteDisplayData(quote); + {/* HEADERS */} + + {[ + [SortOrder.COST_ASC, t('bridgeNetCost'), IconName.Arrow2Up], + [SortOrder.ETA_ASC, t('time'), IconName.Arrow2Down], + ].map(([sortOrderOption, label, icon]) => ( + dispatch(setSortOrder(sortOrderOption))} + startIconName={ + sortOrder === sortOrderOption && sortOrder === SortOrder.ETA_ASC + ? icon + : undefined + } + startIconProps={{ + size: IconSize.Xs, + }} + endIconName={ + sortOrder === sortOrderOption && + sortOrder === SortOrder.COST_ASC + ? icon + : undefined + } + endIconProps={{ + size: IconSize.Xs, + }} + color={ + sortOrder === sortOrderOption + ? TextColor.primaryDefault + : TextColor.textAlternative + } + > + + {label} + + + ))} + + {/* QUOTE LIST */} + + {sortedQuotes.map((quote, index) => { + const { + totalNetworkFee, + estimatedProcessingTimeInSeconds, + toTokenAmount, + cost, + quote: { destAsset, bridges, requestId }, + } = quote; + const isQuoteActive = requestId === activeQuote?.quote.requestId; + return ( - - {totalFees?.fiat} - {t('bridgeTimingMinutes', [etaInMinutes])} - + { + dispatch(setSelectedQuote(quote)); + onClose(); + }} + paddingInline={4} + paddingTop={3} + paddingBottom={3} + style={{ position: 'relative', height: 78 }} + > + {isQuoteActive && ( + + )} + + + {cost.fiat && formatFiatAmount(cost.fiat, currency, 0)} + + {[ + totalNetworkFee?.fiat + ? t('quotedNetworkFee', [ + formatFiatAmount(totalNetworkFee.fiat, currency, 0), + ]) + : t('quotedNetworkFee', [ + formatTokenAmount( + totalNetworkFee.amount, + nativeCurrency, + ), + ]), + t( + sortOrder === SortOrder.ETA_ASC + ? 'quotedReceivingAmount' + : 'quotedReceiveAmount', + [ + formatFiatAmount(toTokenAmount.fiat, currency, 0) ?? + formatTokenAmount( + toTokenAmount.amount, + destAsset.symbol, + 0, + ), + ], + ), + ] + [sortOrder === SortOrder.ETA_ASC ? 'reverse' : 'slice']() + .map((content) => ( + + {content} + + ))} + + + + {t('bridgeTimingMinutes', [ + formatEtaInMinutes(estimatedProcessingTimeInSeconds), + ])} + + + {startCase(bridges[0])} + + + ); })} - + ); diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index 6d52c9e1e753..6407309220c2 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -63,21 +63,7 @@ } } -.quotes-modal { - &__column-header { - display: flex; - flex-direction: row; - justify-content: space-between; - } - - &__quotes { - display: flex; - flex-direction: column; - - &__row { - display: flex; - flex-direction: row; - justify-content: space-between; - } - } +.mm-modal-content__dialog { + display: flex; + height: 100%; } diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index a1ee163eca48..61143bb9de68 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -1,3 +1,27 @@ +import { BigNumber } from 'bignumber.js'; + +export type L1GasFees = { + l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by controller +}; + +// Values derived from the quote response +// fiat values are calculated based on the user's selected currency +export type QuoteMetadata = { + gasFee: { amount: BigNumber; fiat: BigNumber | null }; + totalNetworkFee: { amount: BigNumber; fiat: BigNumber | null }; // gasFees + relayerFees + toTokenAmount: { amount: BigNumber; fiat: BigNumber | null }; + adjustedReturn: { fiat: BigNumber | null }; // destTokenAmount - totalNetworkFee + sentAmount: { amount: BigNumber; fiat: BigNumber | null }; // srcTokenAmount + metabridgeFee + swapRate: BigNumber; // destTokenAmount / sentAmount + cost: { fiat: BigNumber | null }; // sentAmount - adjustedReturn +}; + +// Sort order set by the user +export enum SortOrder { + COST_ASC, + ETA_ASC, +} + // Types copied from Metabridge API export enum BridgeFlag { EXTENSION_CONFIG = 'extension-config', diff --git a/ui/pages/bridge/utils/quote.test.ts b/ui/pages/bridge/utils/quote.test.ts new file mode 100644 index 000000000000..eec342517f83 --- /dev/null +++ b/ui/pages/bridge/utils/quote.test.ts @@ -0,0 +1,309 @@ +import { BigNumber } from 'bignumber.js'; +import { zeroAddress } from 'ethereumjs-util'; +import { + calcAdjustedReturn, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalGasFee, + calcRelayerFee, + formatEtaInMinutes, +} from './quote'; + +const ERC20_TOKEN = { + decimals: 6, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'.toLowerCase(), +}; +const NATIVE_TOKEN = { decimals: 18, address: zeroAddress() }; + +describe('Bridge quote utils', () => { + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1009000000000000000', + 2521.73, + { amount: '1.009', fiat: '2544.42557' }, + ], + [ + 'erc20', + ERC20_TOKEN, + '2543140000', + 0.999781, + { amount: '2543.14', fiat: '2542.58305234' }, + ], + [ + 'erc20 with null exchange rates', + ERC20_TOKEN, + '2543140000', + null, + { amount: '2543.14', fiat: undefined }, + ], + ])( + 'calcToAmount: toToken is %s', + ( + _: string, + destAsset: { decimals: number; address: string }, + destTokenAmount: string, + toTokenExchangeRate: number, + { amount, fiat }: { amount: string; fiat: string }, + ) => { + const result = calcToAmount( + { + destAsset, + destTokenAmount, + } as never, + toTokenExchangeRate, + ); + expect(result.amount?.toString()).toStrictEqual(amount); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1009000000000000000', + 2515.02, + { + amount: '1.143217728', + fiat: '2875.21545027456', + }, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + 0.999781, + { amount: '100.512', fiat: '100.489987872' }, + ], + [ + 'erc20 with null exchange rates', + ERC20_TOKEN, + '2543140000', + null, + { amount: '2543.652', fiat: undefined }, + ], + ])( + 'calcSentAmount: fromToken is %s', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + fromTokenExchangeRate: number, + { amount, fiat }: { amount: string; fiat: string }, + ) => { + const result = calcSentAmount( + { + srcAsset, + srcTokenAmount, + feeData: { + metabridge: { + amount: Math.pow(8 * 10, srcAsset.decimals / 2), + }, + }, + } as never, + fromTokenExchangeRate, + ); + expect(result.amount?.toString()).toStrictEqual(amount); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de0b6b3a7640000', + { amount: '2.2351800712e-7', fiat: '0.0005626887014840304' }, + undefined, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + '0x00', + { amount: '2.2351800712e-7', fiat: '0.0005626887014840304' }, + undefined, + ], + [ + 'erc20 with approval', + ERC20_TOKEN, + '100000000', + '0x00', + { amount: '4.4703601424e-7', fiat: '0.0011253774029680608' }, + 1092677, + ], + [ + 'erc20 with relayer fee', + ERC20_TOKEN, + '100000000', + '0x0de0b6b3a7640000', + { amount: '1.00000022351800712', fiat: '2517.4205626887014840304' }, + undefined, + ], + [ + 'native with relayer fee', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de1b6b3a7640000', + { amount: '0.000281698494717776', fiat: '0.70915342457242365792' }, + undefined, + ], + ])( + 'calcTotalGasFee and calcRelayerFee: fromToken is %s', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + value: string, + { amount, fiat }: { amount: string; fiat: string }, + approvalGasLimit?: number, + ) => { + const feeData = { metabridge: { amount: 0 } }; + const quote = { + trade: { value, gasLimit: 1092677 }, + approval: approvalGasLimit ? { gasLimit: approvalGasLimit } : undefined, + quote: { srcAsset, srcTokenAmount, feeData }, + } as never; + const gasFee = calcTotalGasFee(quote, '0.00010456', '0.0001', 2517.42); + const relayerFee = calcRelayerFee(quote, 2517.42); + const result = { + amount: gasFee.amount.plus(relayerFee.amount), + fiat: gasFee.fiat?.plus(relayerFee.fiat || '0') ?? null, + }; + expect(result.amount?.toString()).toStrictEqual(amount); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'native', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de0b6b3a7640000', + { amount: '0.000002832228395508', fiat: '0.00712990840741974936' }, + undefined, + ], + [ + 'erc20', + ERC20_TOKEN, + '100000000', + '0x00', + { amount: '0.000002832228395508', fiat: '0.00712990840741974936' }, + undefined, + ], + [ + 'erc20 with approval', + ERC20_TOKEN, + '100000000', + '0x00', + { amount: '0.000003055746402628', fiat: '0.00769259710890377976' }, + 1092677, + ], + [ + 'erc20 with relayer fee', + ERC20_TOKEN, + '100000000', + '0x0de0b6b3a7640000', + { amount: '1.000002832228395508', fiat: '2517.42712990840741974936' }, + undefined, + ], + [ + 'native with relayer fee', + NATIVE_TOKEN, + '1000000000000000000', + '0x0de1b6b3a7640000', + { amount: '0.000284307205106164', fiat: '0.71572064427835937688' }, + undefined, + ], + ])( + 'calcTotalGasFee and calcRelayerFee: fromToken is %s with l1GasFee', + ( + _: string, + srcAsset: { decimals: number; address: string }, + srcTokenAmount: string, + value: string, + { amount, fiat }: { amount: string; fiat: string }, + approvalGasLimit?: number, + ) => { + const feeData = { metabridge: { amount: 0 } }; + const quote = { + trade: { value, gasLimit: 1092677 }, + approval: approvalGasLimit ? { gasLimit: approvalGasLimit } : undefined, + quote: { srcAsset, srcTokenAmount, feeData }, + l1GasFeesInHexWei: '0x25F63418AA4', + } as never; + const gasFee = calcTotalGasFee(quote, '0.00010456', '0.0001', 2517.42); + const relayerFee = calcRelayerFee(quote, 2517.42); + const result = { + amount: gasFee.amount.plus(relayerFee.amount), + fiat: gasFee.fiat?.plus(relayerFee.fiat || '0') ?? null, + }; + expect(result.amount?.toString()).toStrictEqual(amount); + expect(result.fiat?.toString()).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + 'available', + new BigNumber('100'), + new BigNumber('5'), + new BigNumber('95'), + ], + ['unavailable', null, null, null], + ])( + 'calcAdjustedReturn: fiat amounts are %s', + ( + _: string, + destTokenAmountInFiat: BigNumber, + totalNetworkFeeInFiat: BigNumber, + fiat: string, + ) => { + const result = calcAdjustedReturn( + destTokenAmountInFiat, + totalNetworkFeeInFiat, + ); + expect(result.fiat).toStrictEqual(fiat); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + ['< 1', new BigNumber('100'), new BigNumber('5'), new BigNumber('0.05')], + ['>= 1', new BigNumber('1'), new BigNumber('2000'), new BigNumber('2000')], + ['0', new BigNumber('1'), new BigNumber('0'), new BigNumber('0')], + ])( + 'calcSwapRate: %s rate', + ( + _: string, + sentAmount: BigNumber, + destTokenAmount: BigNumber, + rate: string, + ) => { + const result = calcSwapRate(sentAmount, destTokenAmount); + expect(result).toStrictEqual(rate); + }, + ); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + ['exact', 120, '2'], + ['rounded down', 2000, '33'], + ])( + 'formatEtaInMinutes: %s conversion', + (_: string, estimatedProcessingTimeInSeconds: number, minutes: string) => { + const result = formatEtaInMinutes(estimatedProcessingTimeInSeconds); + expect(result).toStrictEqual(minutes); + }, + ); +}); diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index b5945f64a9df..2fff7e9c1b18 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -1,5 +1,17 @@ +import { zeroAddress } from 'ethereumjs-util'; +import { BigNumber } from 'bignumber.js'; import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; -import { QuoteResponse, QuoteRequest } from '../types'; +import { QuoteResponse, QuoteRequest, Quote, L1GasFees } from '../types'; +import { + hexToDecimal, + sumDecimals, +} from '../../../../shared/modules/conversion.utils'; +import { formatCurrency } from '../../../helpers/utils/confirm-tx.util'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { EtherDenomination } from '../../../../shared/constants/common'; +import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; + +export const isNativeAddress = (address?: string) => address === zeroAddress(); export const isValidQuoteRequest = ( partialRequest: Partial, @@ -33,27 +45,150 @@ export const isValidQuoteRequest = ( ); }; -export const getQuoteDisplayData = (quoteResponse?: QuoteResponse) => { - const { quote, estimatedProcessingTimeInSeconds } = quoteResponse ?? {}; - if (!quoteResponse || !quote || !estimatedProcessingTimeInSeconds) { - return {}; - } +export const calcToAmount = ( + { destTokenAmount, destAsset }: Quote, + exchangeRate: number | null, +) => { + const normalizedDestAmount = calcTokenAmount( + destTokenAmount, + destAsset.decimals, + ); + return { + amount: normalizedDestAmount, + fiat: exchangeRate + ? normalizedDestAmount.mul(exchangeRate.toString()) + : null, + }; +}; - const etaInMinutes = (estimatedProcessingTimeInSeconds / 60).toFixed(); - const quoteRate = `1 ${quote.srcAsset.symbol} = ${calcTokenAmount( - quote.destTokenAmount, - quote.destAsset.decimals, - ) - .div(calcTokenAmount(quote.srcTokenAmount, quote.srcAsset.decimals)) - .toFixed(4) - .toString()} ${quote.destAsset.symbol}`; +export const calcSentAmount = ( + { srcTokenAmount, srcAsset, feeData }: Quote, + exchangeRate: number | null, +) => { + const normalizedSentAmount = calcTokenAmount( + new BigNumber(srcTokenAmount).plus(feeData.metabridge.amount), + srcAsset.decimals, + ); + return { + amount: normalizedSentAmount, + fiat: exchangeRate + ? normalizedSentAmount.mul(exchangeRate.toString()) + : null, + }; +}; +export const calcRelayerFee = ( + bridgeQuote: QuoteResponse, + nativeExchangeRate?: number, +) => { + const { + quote: { srcAsset, srcTokenAmount, feeData }, + trade, + } = bridgeQuote; + const relayerFeeInNative = calcTokenAmount( + new BigNumber(hexToDecimal(trade.value)).minus( + isNativeAddress(srcAsset.address) + ? new BigNumber(srcTokenAmount).plus(feeData.metabridge.amount) + : 0, + ), + 18, + ); return { - etaInMinutes, - totalFees: { - amount: '0.01 ETH', // TODO implement gas + relayer fee - fiat: '$0.01', - }, - quoteRate, + amount: relayerFeeInNative, + fiat: nativeExchangeRate + ? relayerFeeInNative.mul(nativeExchangeRate.toString()) + : null, }; }; + +export const calcTotalGasFee = ( + bridgeQuote: QuoteResponse & L1GasFees, + estimatedBaseFeeInDecGwei: string, + maxPriorityFeePerGasInDecGwei: string, + nativeExchangeRate?: number, +) => { + const { approval, trade, l1GasFeesInHexWei } = bridgeQuote; + const totalGasLimitInDec = sumDecimals( + trade.gasLimit?.toString() ?? '0', + approval?.gasLimit?.toString() ?? '0', + ); + const feePerGasInDecGwei = sumDecimals( + estimatedBaseFeeInDecGwei, + maxPriorityFeePerGasInDecGwei, + ); + + const l1GasFeesInDecGWei = Numeric.from( + l1GasFeesInHexWei ?? '0', + 16, + EtherDenomination.WEI, + ).toDenomination(EtherDenomination.GWEI); + + const gasFeesInDecGwei = totalGasLimitInDec + .times(feePerGasInDecGwei) + .add(l1GasFeesInDecGWei); + + const gasFeesInDecEth = new BigNumber( + gasFeesInDecGwei.shiftedBy(9).toString(), + ); + const gasFeesInUSD = nativeExchangeRate + ? gasFeesInDecEth.times(nativeExchangeRate.toString()) + : null; + + return { + amount: gasFeesInDecEth, + fiat: gasFeesInUSD, + }; +}; + +export const calcAdjustedReturn = ( + destTokenAmountInFiat: BigNumber | null, + totalNetworkFeeInFiat: BigNumber | null, +) => ({ + fiat: + destTokenAmountInFiat && totalNetworkFeeInFiat + ? destTokenAmountInFiat.minus(totalNetworkFeeInFiat) + : null, +}); + +export const calcSwapRate = ( + sentAmount: BigNumber, + destTokenAmount: BigNumber, +) => destTokenAmount.div(sentAmount); + +export const calcCost = ( + adjustedReturnInFiat: BigNumber | null, + sentAmountInFiat: BigNumber | null, +) => ({ + fiat: + adjustedReturnInFiat && sentAmountInFiat + ? sentAmountInFiat.minus(adjustedReturnInFiat) + : null, +}); + +export const formatEtaInMinutes = (estimatedProcessingTimeInSeconds: number) => + (estimatedProcessingTimeInSeconds / 60).toFixed(); + +export const formatTokenAmount = ( + amount: BigNumber, + symbol: string, + precision: number = 2, +) => `${amount.toFixed(precision)} ${symbol}`; + +export const formatFiatAmount = ( + amount: BigNumber | null, + currency: string, + precision: number = DEFAULT_PRECISION, +) => { + if (!amount) { + return undefined; + } + if (precision === 0) { + if (amount.lt(0.01)) { + return `<${formatCurrency('0', currency, precision)}`; + } + if (amount.lt(1)) { + return formatCurrency(amount.toString(), currency, 2); + } + } + return formatCurrency(amount.toString(), currency, precision); +}; From 23ec9475e46a29710c6969cc4a2c9f8d101d3fbd Mon Sep 17 00:00:00 2001 From: David Walsh Date: Fri, 22 Nov 2024 15:59:51 -0600 Subject: [PATCH 055/148] =?UTF-8?q?chore:=20PortfolioView=E2=84=A2:=20Desi?= =?UTF-8?q?gn=20Review=20Cleanup:=20Networks,=20sort,=20&=20Menu=20(#28663?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Improves various design review aspects pointed out by @amandaye0h [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28663?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. --- .../asset-list-control-bar.tsx | 23 +++++++++++----- .../asset-list-control-bar/index.scss | 7 +---- .../import-control/import-control.tsx | 1 + .../asset-list/network-filter/index.scss | 27 ------------------- .../network-filter/network-filter.tsx | 5 ++-- .../assets/asset-list/sort-control/index.scss | 7 ++++- .../asset-list/sort-control/sort-control.tsx | 16 ++++++++--- 7 files changed, 40 insertions(+), 46 deletions(-) delete mode 100644 ui/components/app/assets/asset-list/network-filter/index.scss 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 dede9004d29e..8e6abb940d1c 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 @@ -6,7 +6,9 @@ import { Box, ButtonBase, ButtonBaseSize, + Icon, IconName, + IconSize, Popover, PopoverPosition, } from '../../../../component-library'; @@ -198,7 +200,8 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { className="asset-list-control-bar__button" onClick={toggleTokenSortPopover} size={ButtonBaseSize.Sm} - endIconName={IconName.SwapVertical} + startIconName={IconName.Filter} + startIconProps={{ marginInlineEnd: 0 }} backgroundColor={ isTokenSortPopoverOpen ? BackgroundColor.backgroundPressed @@ -221,13 +224,13 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { isOpen={isNetworkFilterPopoverOpen} position={PopoverPosition.BottomStart} referenceElement={popoverRef.current} - matchWidth={!isFullScreen} + matchWidth={false} style={{ zIndex: 10, display: 'flex', flexDirection: 'column', padding: 0, - minWidth: isFullScreen ? '325px' : '', + minWidth: isFullScreen ? '250px' : '', }} > @@ -237,13 +240,13 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { isOpen={isTokenSortPopoverOpen} position={PopoverPosition.BottomEnd} referenceElement={popoverRef.current} - matchWidth={!isFullScreen} + matchWidth={false} style={{ zIndex: 10, display: 'flex', flexDirection: 'column', padding: 0, - minWidth: isFullScreen ? '325px' : '', + minWidth: isFullScreen ? '250px' : '', }} > @@ -254,19 +257,25 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { isOpen={isImportTokensPopoverOpen} position={PopoverPosition.BottomEnd} referenceElement={popoverRef.current} - matchWidth={!isFullScreen} + matchWidth={false} style={{ zIndex: 10, display: 'flex', flexDirection: 'column', padding: 0, - minWidth: isFullScreen ? '325px' : '', + minWidth: isFullScreen ? '158px' : '', }} > + {t('importTokensCamelCase')} + {t('refreshList')} diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss index b133586371c3..21cdbe2e83e1 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss @@ -8,12 +8,7 @@ min-width: auto; border-radius: 8px; padding: 0 8px !important; - gap: 5px; - text-transform: lowercase; - - span::first-letter { - text-transform: uppercase; - } + gap: 4px; } &__buttons { diff --git a/ui/components/app/assets/asset-list/import-control/import-control.tsx b/ui/components/app/assets/asset-list/import-control/import-control.tsx index d3a9bfd9ccb7..6ac4a6b1fce1 100644 --- a/ui/components/app/assets/asset-list/import-control/import-control.tsx +++ b/ui/components/app/assets/asset-list/import-control/import-control.tsx @@ -33,6 +33,7 @@ const AssetListControlBar = ({ disabled={!shouldShowTokensLinks} size={ButtonBaseSize.Sm} startIconName={IconName.MoreVertical} + startIconProps={{ marginInlineEnd: 0 }} backgroundColor={BackgroundColor.backgroundDefault} color={TextColor.textDefault} onClick={onClick} diff --git a/ui/components/app/assets/asset-list/network-filter/index.scss b/ui/components/app/assets/asset-list/network-filter/index.scss deleted file mode 100644 index 76e61c1025ae..000000000000 --- a/ui/components/app/assets/asset-list/network-filter/index.scss +++ /dev/null @@ -1,27 +0,0 @@ -.selectable-list-item-wrapper { - position: relative; -} - -.selectable-list-item { - cursor: pointer; - padding: 16px; - - &--selected { - background: var(--color-primary-muted); - } - - &:not(.selectable-list-item--selected) { - &:hover, - &:focus-within { - background: var(--color-background-default-hover); - } - } - - &__selected-indicator { - width: 4px; - height: calc(100% - 8px); - position: absolute; - top: 4px; - left: 4px; - } -} diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index 4e9aa14eea25..d032712be9c1 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -15,6 +15,7 @@ import { SelectableListItem } from '../sort-control/sort-control'; import { Text } from '../../../../component-library/text/text'; import { AlignItems, + BlockSize, Display, JustifyContent, TextColor, @@ -108,6 +109,7 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { { color={TextColor.textAlternative} data-testid="network-filter-all__total" > - {/* TODO: Should query cross chain account balance */} - { display={Display.Flex} justifyContent={JustifyContent.spaceBetween} alignItems={AlignItems.center} + width={BlockSize.Full} > { return ( - {children} - + {isSelected && ( Date: Sun, 24 Nov 2024 11:47:23 -0500 Subject: [PATCH 056/148] test: Adding unit test for setupPhishingCommunication and setUpCookieHandlerCommunication (#27736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding unit test for setupPhishingCommunication and setUpCookieHandlerCommunication. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/27736?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/27119 ## **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. --- app/scripts/metamask-controller.test.js | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 0cd4fba34589..880df69aa00f 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -45,6 +45,7 @@ import { } from './lib/accounts/BalancesController'; import { BalancesTracker as MultichainBalancesTracker } from './lib/accounts/BalancesTracker'; import { deferredPromise } from './lib/util'; +import { METAMASK_COOKIE_HANDLER } from './constants/stream'; import MetaMaskController, { ONE_KEY_VIA_TREZOR_MINOR_VERSION, } from './metamask-controller'; @@ -1273,6 +1274,129 @@ describe('MetaMaskController', () => { expect(mockKeyring.destroy).toHaveBeenCalledTimes(1); }); }); + describe('#setupPhishingCommunication', () => { + beforeEach(() => { + jest.spyOn(metamaskController, 'safelistPhishingDomain'); + jest.spyOn(metamaskController, 'backToSafetyPhishingWarning'); + metamaskController.preferencesController.setUsePhishDetect(true); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('creates a phishing stream with safelistPhishingDomain and backToSafetyPhishingWarning handler', async () => { + const safelistPhishingDomainRequest = { + name: 'metamask-phishing-safelist', + data: { + id: 1, + method: 'safelistPhishingDomain', + params: ['mockHostname'], + }, + }; + const backToSafetyPhishingWarningRequest = { + name: 'metamask-phishing-safelist', + data: { id: 2, method: 'backToSafetyPhishingWarning', params: [] }, + }; + + const { promise, resolve } = deferredPromise(); + const { promise: promiseStream, resolve: resolveStream } = + deferredPromise(); + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.name !== 'metamask-phishing-safelist') { + cb(); + return; + } + resolve(); + cb(null, chunk); + }); + + metamaskController.setupPhishingCommunication({ + connectionStream: streamTest, + }); + + streamTest.write(safelistPhishingDomainRequest, null, () => { + expect( + metamaskController.safelistPhishingDomain, + ).toHaveBeenCalledWith('mockHostname'); + }); + streamTest.write(backToSafetyPhishingWarningRequest, null, () => { + expect( + metamaskController.backToSafetyPhishingWarning, + ).toHaveBeenCalled(); + resolveStream(); + }); + + await promise; + streamTest.end(); + await promiseStream; + }); + }); + + describe('#setUpCookieHandlerCommunication', () => { + let localMetaMaskController; + beforeEach(() => { + localMetaMaskController = new MetaMaskController({ + showUserConfirmation: noop, + encryptor: mockEncryptor, + initState: { + ...cloneDeep(firstTimeState), + MetaMetricsController: { + metaMetricsId: 'MOCK_METRICS_ID', + participateInMetaMetrics: true, + dataCollectionForMarketing: true, + }, + }, + initLangCode: 'en_US', + platform: { + showTransactionNotification: () => undefined, + getVersion: () => 'foo', + }, + browser: browserPolyfillMock, + infuraProjectId: 'foo', + isFirstMetaMaskControllerSetup: true, + }); + jest.spyOn(localMetaMaskController, 'getCookieFromMarketingPage'); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('creates a cookie handler communication stream with getCookieFromMarketingPage handler', async () => { + const attributionRequest = { + name: METAMASK_COOKIE_HANDLER, + data: { + id: 1, + method: 'getCookieFromMarketingPage', + params: [{ ga_client_id: 'XYZ.ABC' }], + }, + }; + + const { promise, resolve } = deferredPromise(); + const { promise: promiseStream, resolve: resolveStream } = + deferredPromise(); + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.name !== METAMASK_COOKIE_HANDLER) { + cb(); + return; + } + resolve(); + cb(null, chunk); + }); + + localMetaMaskController.setUpCookieHandlerCommunication({ + connectionStream: streamTest, + }); + + streamTest.write(attributionRequest, null, () => { + expect( + localMetaMaskController.getCookieFromMarketingPage, + ).toHaveBeenCalledWith({ ga_client_id: 'XYZ.ABC' }); + resolveStream(); + }); + + await promise; + streamTest.end(); + await promiseStream; + }); + }); describe('#setupUntrustedCommunicationEip1193', () => { const mockTxParams = { from: TEST_ADDRESS }; From ebb492665bf4b2d09a102ce20a448952ff601df6 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Mon, 25 Nov 2024 16:43:30 +0530 Subject: [PATCH 057/148] fix: add alert when selected account is different from signing account in confirmation (#28562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Show warning when signing account in confirmation is different from currently selected account in MM. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28015 ## **Manual testing steps** 1. Go to test dapp and connect with an account 2. Switch to a different account 3. Submit a confirmation and check warning next to signing account ## **Screenshots/Recordings** Signature Request: Screenshot 2024-11-20 at 6 12 02 PM Contract Interaction: Screenshot 2024-11-20 at 6 12 16 PM Send token: Screenshot 2024-11-20 at 6 47 29 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. --- app/_locales/en/messages.json | 6 + .../info/__snapshots__/info.test.tsx.snap | 375 +++++++++++++++ .../__snapshots__/approve.test.tsx.snap | 75 +++ .../approve-details/approve-details.tsx | 2 + .../approve/revoke-details/revoke-details.tsx | 2 + .../base-transaction-info.test.tsx.snap | 75 +++ .../__snapshots__/personal-sign.test.tsx.snap | 150 ++++++ .../info/personal-sign/personal-sign.tsx | 14 +- .../set-approval-for-all-info.test.tsx.snap | 75 +++ .../sign-in-with-row.test.tsx | 30 ++ .../sign-in-with-row/sign-in-with-row.tsx | 34 ++ .../transaction-details.test.tsx.snap | 75 +++ .../transaction-details.tsx | 2 + .../transaction-flow-section.test.tsx.snap | 166 ++++++- .../transaction-flow-section.test.tsx | 11 + .../transaction-flow-section.tsx | 48 +- .../__snapshots__/typed-sign-v1.test.tsx.snap | 75 +++ .../info/typed-sign-v1/typed-sign-v1.tsx | 2 + .../__snapshots__/typed-sign.test.tsx.snap | 375 +++++++++++++++ .../confirm/info/typed-sign/typed-sign.tsx | 2 + .../__snapshots__/confirm.test.tsx.snap | 450 ++++++++++++++++++ .../alerts/useSelectedAccountAlerts.test.ts | 79 +++ .../hooks/alerts/useSelectedAccountAlerts.ts | 41 ++ .../hooks/useConfirmationAlerts.ts | 5 + 24 files changed, 2123 insertions(+), 46 deletions(-) create mode 100644 ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.test.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx create mode 100644 ui/pages/confirmations/hooks/alerts/useSelectedAccountAlerts.test.ts create mode 100644 ui/pages/confirmations/hooks/alerts/useSelectedAccountAlerts.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 2d603ddc5156..62c7b4542cf5 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -488,6 +488,9 @@ "alertReasonWrongAccount": { "message": "Wrong account" }, + "alertSelectedAccountWarning": { + "message": "This request is for a different account than the one selected in your wallet. To use another account, connect it to the site." + }, "alerts": { "message": "Alerts" }, @@ -4863,6 +4866,9 @@ "selectType": { "message": "Select Type" }, + "selectedAccountMismatch": { + "message": "Different account selected" + }, "selectingAllWillAllow": { "message": "Selecting all will allow this site to view all of your current accounts. Make sure you trust this site." }, 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 86779c10cad6..6d48461f1c89 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 @@ -50,6 +50,81 @@ exports[`Info renders info section for approve request 1`] = `

+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
renders component for approve request 1`] = `

+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+ {showAdvancedDetails && ( <> diff --git a/ui/pages/confirmations/components/confirm/info/approve/revoke-details/revoke-details.tsx b/ui/pages/confirmations/components/confirm/info/approve/revoke-details/revoke-details.tsx index 49bf5e7724e1..09b2e6a809f3 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/revoke-details/revoke-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/approve/revoke-details/revoke-details.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { ConfirmInfoSection } from '../../../../../../../components/app/confirm/info/row/section'; import { OriginRow } from '../../shared/transaction-details/transaction-details'; +import { SigningInWithRow } from '../../shared/sign-in-with-row/sign-in-with-row'; export const RevokeDetails = () => { return ( + ); }; 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 d7054050f710..10506183561d 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 @@ -233,6 +233,81 @@ exports[` renders component for contract interaction requ
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
{ @@ -51,9 +51,7 @@ const PersonalSignInfo: React.FC = () => { return null; } - const { from } = currentConfirmation.msgParams; const isSIWE = isSIWESignatureRequest(currentConfirmation); - const chainId = currentConfirmation.chainId as string; const messageText = sanitizeString( hexToText(currentConfirmation.msgParams?.data), ); @@ -138,15 +136,7 @@ const PersonalSignInfo: React.FC = () => { > - {isSIWE && ( - - - - )} + {isSIWE ? ( diff --git a/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/__snapshots__/set-approval-for-all-info.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/__snapshots__/set-approval-for-all-info.test.tsx.snap index 42efa15a2a5b..5da63fd56680 100644 --- a/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/__snapshots__/set-approval-for-all-info.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/set-approval-for-all-info/__snapshots__/set-approval-for-all-info.test.tsx.snap @@ -154,6 +154,81 @@ exports[` renders component for approve request 1`] = `

+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
({ + useAlertMetrics: jest.fn(() => ({ + trackAlertMetrics: jest.fn(), + })), + }), +); + +describe('', () => { + const middleware = [thunk]; + + it('renders component for transaction details', () => { + const state = getMockContractInteractionConfirmState(); + const mockStore = configureMockStore(middleware)(state); + const { getByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + expect(getByText('Signing in with')).toBeInTheDocument(); + expect(getByText('0x2e0D7...5d09B')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx b/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx new file mode 100644 index 000000000000..7b20cbc08062 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +import { ConfirmInfoRowAddress } from '../../../../../../../components/app/confirm/info/row'; +import { ConfirmInfoAlertRow } from '../../../../../../../components/app/confirm/info/row/alert-row/alert-row'; +import { RowAlertKey } from '../../../../../../../components/app/confirm/info/row/constants'; +import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; +import { useConfirmContext } from '../../../../../context/confirm'; +import { SignatureRequestType } from '../../../../../types/confirm'; + +export const SigningInWithRow = () => { + const t = useI18nContext(); + + const { currentConfirmation } = useConfirmContext(); + + const chainId = currentConfirmation?.chainId as string; + const from = + (currentConfirmation as TransactionMeta)?.txParams?.from ?? + (currentConfirmation as SignatureRequestType)?.msgParams?.from; + + if (!from) { + return null; + } + + return ( + + + + ); +}; diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap index f5183e21206c..9e9b9b23eca5 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap @@ -151,6 +151,81 @@ exports[` renders component for transaction details 1`] =
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
`; diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx index 89c2da783a4f..79cea5963c45 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx @@ -20,6 +20,7 @@ import { ConfirmInfoRowCurrency } from '../../../../../../../components/app/conf import { PRIMARY } from '../../../../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../../../../hooks/useUserPreferencedCurrency'; import { HEX_ZERO } from '../constants'; +import { SigningInWithRow } from '../sign-in-with-row/sign-in-with-row'; export const OriginRow = () => { const t = useI18nContext(); @@ -156,6 +157,7 @@ export const TransactionDetails = () => { {showAdvancedDetails && } + diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap index 23cddb2b59b2..01614eec26cd 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap @@ -7,23 +7,85 @@ exports[` renders correctly 1`] = ` data-testid="confirmation__transaction-flow" >
- -

- 0x2e0D7...5d09B -

+

+ From +

+
+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
renders correctly 1`] = ` style="mask-image: url('./images/icons/arrow-right.svg');" />
+
+

+ To +

+
+
+
- -

- 0x6B175...71d0F -

+
+ +

+ 0x6B175...71d0F +

+
+
diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.test.tsx b/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.test.tsx index c23d3645abd3..bdc6ed30678a 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.test.tsx @@ -11,6 +11,17 @@ jest.mock('../hooks/useDecodedTransactionData', () => ({ useDecodedTransactionData: jest.fn(), })); +jest.mock( + '../../../../../../components/app/alert-system/contexts/alertMetricsContext.tsx', + () => ({ + useAlertMetrics: jest.fn(() => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertRender: jest.fn(), + trackAlertActionClicked: jest.fn(), + })), + }), +); + describe('', () => { const useDecodedTransactionDataMock = jest.fn().mockImplementation(() => ({ pending: false, diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.tsx b/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.tsx index f5a9a46acbb2..d868611be4cc 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.tsx +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/transaction-flow-section.tsx @@ -1,11 +1,9 @@ -import { NameType } from '@metamask/name-controller'; import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; import React from 'react'; import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; -import Name from '../../../../../../components/app/name'; import { Box, Icon, @@ -19,10 +17,18 @@ import { IconColor, JustifyContent, } from '../../../../../../helpers/constants/design-system'; +import { + ConfirmInfoRow, + ConfirmInfoRowAddress, +} from '../../../../../../components/app/confirm/info/row'; +import { RowAlertKey } from '../../../../../../components/app/confirm/info/row/constants'; +import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; +import { useI18nContext } from '../../../../../../hooks/useI18nContext'; import { useConfirmContext } from '../../../../context/confirm'; import { useDecodedTransactionData } from '../hooks/useDecodedTransactionData'; export const TransactionFlowSection = () => { + const t = useI18nContext(); const { currentConfirmation: transactionMeta } = useConfirmContext(); @@ -50,24 +56,40 @@ export const TransactionFlowSection = () => { flexDirection={FlexDirection.Row} justifyContent={JustifyContent.spaceBetween} alignItems={AlignItems.center} - padding={3} > - + + + + + + {recipientAddress && ( - + + + + + )} diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap index e80d317f574b..8713c5d303ca 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap @@ -47,6 +47,81 @@ exports[`TypedSignInfo correctly renders typed sign data request 1`] = `

+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
{ const t = useI18nContext(); @@ -42,6 +43,7 @@ const TypedSignV1Info: React.FC = () => { url={currentConfirmation.msgParams?.origin ?? ''} /> +
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
{ @@ -81,6 +82,7 @@ const TypedSignInfo: React.FC = () => { /> )} +
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ Test Account +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
+
+
+
+

+ Signing in with +

+
+
+
+
+ +

+ 0x935E7...05477 +

+
+
+
{ + it('returns an empty array when there is no current confirmation', () => { + const { result } = renderHookWithConfirmContextProvider( + () => useSelectedAccountAlerts(), + mockState, + ); + expect(result.current).toEqual([]); + }); + + it('returns an alert for signature if signing account is different from selected account', () => { + const { result } = renderHookWithConfirmContextProvider( + () => useSelectedAccountAlerts(), + getMockPersonalSignConfirmStateForRequest({ + ...unapprovedPersonalSignMsg, + msgParams: { + ...unapprovedPersonalSignMsg.msgParams, + from: '0x0', + }, + } as SignatureRequestType), + ); + expect(result.current).toEqual(expectedAlert); + }); + + it('does not returns an alert for signature if signing account is same as selected account', () => { + const { result } = renderHookWithConfirmContextProvider( + () => useSelectedAccountAlerts(), + getMockPersonalSignConfirmStateForRequest( + unapprovedPersonalSignMsg as SignatureRequestType, + ), + ); + expect(result.current).toEqual([]); + }); + + it('returns an alert for transaction if signing account is different from selected account', () => { + const contractInteraction = genUnapprovedContractInteractionConfirmation({ + address: '0x0', + }); + const { result } = renderHookWithConfirmContextProvider( + () => useSelectedAccountAlerts(), + getMockConfirmStateForTransaction(contractInteraction as TransactionMeta), + ); + expect(result.current).toEqual(expectedAlert); + }); + + it('does not returns an alert for transaction if signing account is same as selected account', () => { + const contractInteraction = genUnapprovedContractInteractionConfirmation({ + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }); + const { result } = renderHookWithConfirmContextProvider( + () => useSelectedAccountAlerts(), + getMockConfirmStateForTransaction(contractInteraction as TransactionMeta), + ); + expect(result.current).toEqual([]); + }); +}); diff --git a/ui/pages/confirmations/hooks/alerts/useSelectedAccountAlerts.ts b/ui/pages/confirmations/hooks/alerts/useSelectedAccountAlerts.ts new file mode 100644 index 000000000000..6e4be13b1ae5 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/useSelectedAccountAlerts.ts @@ -0,0 +1,41 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { Alert } from '../../../../ducks/confirm-alerts/confirm-alerts'; +import { RowAlertKey } from '../../../../components/app/confirm/info/row/constants'; +import { Severity } from '../../../../helpers/constants/design-system'; +import { getSelectedAccount } from '../../../../selectors'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { SignatureRequestType } from '../../types/confirm'; +import { useConfirmContext } from '../../context/confirm'; + +export const useSelectedAccountAlerts = (): Alert[] => { + const t = useI18nContext(); + + const { currentConfirmation } = useConfirmContext(); + const selectedAccount = useSelector(getSelectedAccount); + + const fromAccount = + (currentConfirmation as SignatureRequestType)?.msgParams?.from ?? + (currentConfirmation as TransactionMeta)?.txParams?.from; + const confirmationAccountSameAsSelectedAccount = + !fromAccount || + fromAccount.toLowerCase() === selectedAccount?.address?.toLowerCase(); + + return useMemo((): Alert[] => { + if (confirmationAccountSameAsSelectedAccount) { + return []; + } + + return [ + { + key: 'selectedAccountWarning', + reason: t('selectedAccountMismatch'), + field: RowAlertKey.SigningInWith, + severity: Severity.Warning, + message: t('alertSelectedAccountWarning'), + }, + ]; + }, [confirmationAccountSameAsSelectedAccount, t]); +}; diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts index c5f77f143cb6..efcb0beacf9e 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts @@ -16,6 +16,7 @@ import { useSigningOrSubmittingAlerts } from './alerts/transactions/useSigningOr ///: END:ONLY_INCLUDE_IF import useConfirmationOriginAlerts from './alerts/useConfirmationOriginAlerts'; import useBlockaidAlerts from './alerts/useBlockaidAlerts'; +import { useSelectedAccountAlerts } from './alerts/useSelectedAccountAlerts'; function useSignatureAlerts(): Alert[] { const accountMismatchAlerts = useAccountMismatchAlerts(); @@ -40,6 +41,7 @@ function useTransactionAlerts(): Alert[] { const signingOrSubmittingAlerts = useSigningOrSubmittingAlerts(); ///: END:ONLY_INCLUDE_IF const queuedConfirmationsAlerts = useQueuedConfirmationsAlerts(); + return useMemo( () => [ ...gasEstimateFailedAlerts, @@ -77,6 +79,7 @@ export default function useConfirmationAlerts(): Alert[] { const confirmationOriginAlerts = useConfirmationOriginAlerts(); const signatureAlerts = useSignatureAlerts(); const transactionAlerts = useTransactionAlerts(); + const selectedAccountAlerts = useSelectedAccountAlerts(); return useMemo( () => [ @@ -84,12 +87,14 @@ export default function useConfirmationAlerts(): Alert[] { ...confirmationOriginAlerts, ...signatureAlerts, ...transactionAlerts, + ...selectedAccountAlerts, ], [ blockaidAlerts, confirmationOriginAlerts, signatureAlerts, transactionAlerts, + selectedAccountAlerts, ], ); } From 67b2f5a1f3df8096ed10aa59fc61e3db3122d040 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Mon, 25 Nov 2024 16:45:08 +0530 Subject: [PATCH 058/148] feat: adding tooltip to signature decoding state changes (#28430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add tooltip to state change labels. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3628 ## **Manual testing steps** 1. Enable signature decoding locally. 2. Check NFT bidding or listing permit 3. It should show appropriate tooltip ## **Screenshots/Recordings** Screenshot 2024-11-13 at 10 55 39 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. --------- Co-authored-by: MetaMask Bot --- app/_locales/en/messages.json | 6 ++ .../decoded-simulation.test.tsx | 59 ++++++++++++++++++- .../decoded-simulation/decoded-simulation.tsx | 44 +++++++++++++- .../permit-simulation/permit-simulation.tsx | 2 +- .../value-display/value-display.tsx | 18 ++---- 5 files changed, 110 insertions(+), 19 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 62c7b4542cf5..060ba2a43dca 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5020,6 +5020,12 @@ "signatureRequestGuidance": { "message": "Only sign this message if you fully understand the content and trust the requesting site." }, + "signature_decoding_bid_nft_tooltip": { + "message": "The NFT will be reflected in your wallet, when the bid is accepted." + }, + "signature_decoding_list_nft_tooltip": { + "message": "Expect changes only if someone buys your NFTs." + }, "signed": { "message": "Signed" }, diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx index 690cfb5b5195..86f30472b0e5 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -3,12 +3,13 @@ import configureMockStore from 'redux-mock-store'; import { DecodingData, DecodingDataChangeType, + DecodingDataStateChanges, } from '@metamask/signature-controller'; import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../../test/data/confirmations/helper'; import { renderWithConfirmContextProvider } from '../../../../../../../../../test/lib/confirmations/render-helpers'; import { permitSignatureMsg } from '../../../../../../../../../test/data/confirmations/typed_sign'; -import PermitSimulation from './decoded-simulation'; +import PermitSimulation, { getStateChangeToolip } from './decoded-simulation'; const decodingData: DecodingData = { stateChanges: [ @@ -22,6 +23,42 @@ const decodingData: DecodingData = { ], }; +const decodingDataListing: DecodingDataStateChanges = [ + { + assetType: 'NATIVE', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '2101', + }, +]; + +const decodingDataBidding: DecodingDataStateChanges = [ + { + assetType: 'ERC721', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'Native', + changeType: DecodingDataChangeType.Bidding, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '2101', + }, +]; + describe('DecodedSimulation', () => { it('renders component correctly', async () => { const state = getMockTypedSignConfirmStateForRequest({ @@ -38,4 +75,24 @@ describe('DecodedSimulation', () => { expect(container).toMatchSnapshot(); }); + + describe('getStateChangeToolip', () => { + it('return correct tooltip when permit is for listing NFT', async () => { + const tooltip = getStateChangeToolip( + decodingDataListing, + decodingDataListing?.[0], + (str: string) => str, + ); + expect(tooltip).toBe('signature_decoding_list_nft_tooltip'); + }); + }); + + it('return correct tooltip when permit is for bidding NFT', async () => { + const tooltip = getStateChangeToolip( + decodingDataBidding, + decodingDataBidding?.[0], + (str: string) => str, + ); + expect(tooltip).toBe('signature_decoding_bid_nft_tooltip'); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx index 3798776ca85d..cf774483ee6c 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { DecodingDataChangeType, DecodingDataStateChange, + DecodingDataStateChanges, } from '@metamask/signature-controller'; import { Hex } from '@metamask/utils'; @@ -11,8 +12,35 @@ import { useI18nContext } from '../../../../../../../../hooks/useI18nContext'; import { SignatureRequestType } from '../../../../../../types/confirm'; import { useConfirmContext } from '../../../../../../context/confirm'; import StaticSimulation from '../../../shared/static-simulation/static-simulation'; -import NativeValueDisplay from '../native-value-display/native-value-display'; import TokenValueDisplay from '../value-display/value-display'; +import NativeValueDisplay from '../native-value-display/native-value-display'; + +export const getStateChangeToolip = ( + stateChangeList: DecodingDataStateChanges | null, + stateChange: DecodingDataStateChange, + t: ReturnType, +): string | undefined => { + if (stateChange.changeType === DecodingDataChangeType.Receive) { + if ( + stateChangeList?.some( + (change) => + change.changeType === DecodingDataChangeType.Listing && + change.assetType === TokenStandard.ERC721, + ) + ) { + return t('signature_decoding_list_nft_tooltip'); + } + if ( + stateChange.assetType === TokenStandard.ERC721 && + stateChangeList?.some( + (change) => change.changeType === DecodingDataChangeType.Bidding, + ) + ) { + return t('signature_decoding_bid_nft_tooltip'); + } + } + return undefined; +}; const getStateChangeLabelMap = ( t: ReturnType, @@ -28,17 +56,23 @@ const getStateChangeLabelMap = ( }[changeType]); const StateChangeRow = ({ + stateChangeList, stateChange, chainId, }: { + stateChangeList: DecodingDataStateChanges | null; stateChange: DecodingDataStateChange; chainId: Hex; }) => { const t = useI18nContext(); const { assetType, changeType, amount, contractAddress, tokenID } = stateChange; + const tooltip = getStateChangeToolip(stateChangeList, stateChange, t); return ( - + {(assetType === TokenStandard.ERC20 || assetType === TokenStandard.ERC721) && ( = () => { const stateChangeFragment = (decodingData?.stateChanges ?? []).map( (change: DecodingDataStateChange) => ( - + ), ); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx index 0b4d9eed22d4..86055425fa46 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx @@ -11,7 +11,7 @@ const PermitSimulation: React.FC = () => { if ( decodingData?.error || - (decodingData === undefined && decodingLoading !== true) + (decodingData?.stateChanges === undefined && decodingLoading !== true) ) { return ; } 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 01f97625d404..2867522b42ab 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 @@ -21,16 +21,15 @@ import { } from '../../../../../../../../components/component-library'; import Tooltip from '../../../../../../../../components/ui/tooltip'; import { - BackgroundColor, BlockSize, BorderRadius, Display, JustifyContent, TextAlign, - TextColor, } from '../../../../../../../../helpers/constants/design-system'; import Name from '../../../../../../../../components/app/name/name'; import { TokenDetailsERC20 } from '../../../../../../utils/token'; +import { getAmountColors } from '../../../utils'; type PermitSimulationValueDisplayParams = { /** ID of the associated chain. */ @@ -112,16 +111,7 @@ const PermitSimulationValueDisplay: React.FC< return null; } - let valueColor = TextColor.textDefault; - let valueBackgroundColor = BackgroundColor.backgroundAlternative; - - if (credit) { - valueColor = TextColor.successDefault; - valueBackgroundColor = BackgroundColor.successMuted; - } else if (debit) { - valueColor = TextColor.errorDefault; - valueBackgroundColor = BackgroundColor.errorMuted; - } + const { color, backgroundColor } = getAmountColors(credit, debit); return ( @@ -139,9 +129,9 @@ const PermitSimulationValueDisplay: React.FC< > Date: Mon, 25 Nov 2024 19:16:45 +0800 Subject: [PATCH 059/148] fix: display new network popup only for accounts that are compatible. (#28535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates the new network popup to only show up for accounts that supports it. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28535?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/accounts-planning/issues/660 ## **Manual testing steps** 1. On flask, enable BTC experimental feature and create an account. 2. Switch to an EVM account 3. Add a different RPC such as base 4. See 'You're now using` alert for the added network 5. tap `Got it` 6. Tap the account picker and select BTC account 7. See that the new network popup does not show. 8. tap BTC under tokens 9. See that the new network popup does not show. ## **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. --- shared/lib/multichain.ts | 26 +++++- ui/pages/routes/routes.component.js | 7 +- ui/pages/routes/routes.component.test.js | 109 ++++++++++++++++++++++- ui/pages/routes/routes.container.js | 4 +- 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/shared/lib/multichain.ts b/shared/lib/multichain.ts index 815d9d9e6763..26111a0970a2 100644 --- a/shared/lib/multichain.ts +++ b/shared/lib/multichain.ts @@ -1,6 +1,12 @@ -import { CaipNamespace, KnownCaipNamespace } from '@metamask/utils'; +import { + CaipNamespace, + isCaipChainId, + KnownCaipNamespace, + parseCaipChainId, +} from '@metamask/utils'; import { validate, Network } from 'bitcoin-address-validation'; import { isAddress } from '@solana/addresses'; +import { InternalAccount, isEvmAccountType } from '@metamask/keyring-api'; /** * Returns whether an address is on the Bitcoin mainnet. @@ -59,3 +65,21 @@ export function getCaipNamespaceFromAddress(address: string): CaipNamespace { // Defaults to "Ethereum" for all other cases for now. return KnownCaipNamespace.Eip155; } + +export function isCurrentChainCompatibleWithAccount( + chainId: string, + account: InternalAccount, +): boolean { + if (!chainId) { + return false; + } + + if (isCaipChainId(chainId)) { + const { namespace } = parseCaipChainId(chainId); + return namespace === getCaipNamespaceFromAddress(account.address); + } + + // For EVM accounts, we do not check the chain ID format, but we just expect it + // to be defined. + return isEvmAccountType(account.type); +} diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index edf1ff8bbe22..bce88a9f9236 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -114,6 +114,8 @@ import NetworkConfirmationPopover from '../../components/multichain/network-list import NftFullImage from '../../components/app/assets/nfts/nft-details/nft-full-image'; import CrossChainSwap from '../bridge'; import { ToastMaster } from '../../components/app/toast-master/toast-master'; +import { InternalAccountPropType } from '../../selectors/multichain'; +import { isCurrentChainCompatibleWithAccount } from '../../../shared/lib/multichain'; import { isCorrectDeveloperTransactionType, isCorrectSignatureApprovalType, @@ -130,6 +132,7 @@ export default class Routes extends Component { static propTypes = { currentCurrency: PropTypes.string, activeTabOrigin: PropTypes.string, + account: InternalAccountPropType, setCurrentCurrencyToUSD: PropTypes.func, isLoading: PropTypes.bool, loadingMessage: PropTypes.string, @@ -410,6 +413,7 @@ export default class Routes extends Component { isNetworkUsed, allAccountsOnNetworkAreEmpty, isTestNet, + account, currentChainId, shouldShowSeedPhraseReminder, isCurrentProviderCustom, @@ -455,7 +459,8 @@ export default class Routes extends Component { }); const shouldShowNetworkInfo = isUnlocked && - currentChainId && + account && + isCurrentChainCompatibleWithAccount(currentChainId, account) && !isTestNet && !isSendRoute && !isNetworkUsed && diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 1b1823728a2f..4318310c6ef5 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -1,6 +1,6 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import thunk from 'redux-thunk'; import { BtcAccountType } from '@metamask/keyring-api'; import { SEND_STAGES } from '../../ducks/send'; @@ -15,6 +15,10 @@ import { useIsOriginalNativeTokenSymbol } from '../../hooks/useIsOriginalNativeT import { createMockInternalAccount } from '../../../test/jest/mocks'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { mockNetworkState } from '../../../test/stub/networks'; +import { + MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_EOA, +} from '../../../test/data/mock-accounts'; import useMultiPolling from '../../hooks/useMultiPolling'; import Routes from '.'; @@ -22,6 +26,7 @@ const middlewares = [thunk]; const mockShowNetworkDropdown = jest.fn(); const mockHideNetworkDropdown = jest.fn(); +const mockFetchWithCache = jest.fn(); jest.mock('webextension-polyfill', () => ({ runtime: { @@ -34,6 +39,7 @@ jest.mock('webextension-polyfill', () => ({ })); jest.mock('../../store/actions', () => ({ + ...jest.requireActual('../../store/actions'), getGasFeeTimeEstimate: jest.fn().mockImplementation(() => Promise.resolve()), gasFeeStartPollingByNetworkClientId: jest .fn() @@ -92,6 +98,11 @@ jest.mock( '../../components/app/metamask-template-renderer/safe-component-list', ); +jest.mock( + '../../../shared/lib/fetch-with-cache', + () => () => mockFetchWithCache, +); + jest.mock('../../hooks/useMultiPolling', () => ({ __esModule: true, default: jest.fn(), @@ -180,6 +191,102 @@ describe('Routes Component', () => { expect(getByTestId('account-menu-icon')).not.toBeDisabled(); }); }); + + describe('new network popup', () => { + const mockBtcAccount = MOCK_ACCOUNT_BIP122_P2WPKH; + const mockEvmAccount = MOCK_ACCOUNT_EOA; + + const mockNewlyAddedNetwork = { + chainId: CHAIN_IDS.BASE, + name: 'Base', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: 'custom', + url: 'https://base.com', + networkClientId: CHAIN_IDS.BASE, + }, + ], + }; + + const renderPopup = async (account) => { + // This popup does not show up for tests, so we have to disable this: + process.env.IN_TEST = ''; + const state = { + ...mockSendState, + metamask: { + ...mockState.metamask, + completedOnboarding: true, + selectedNetworkClientId: mockNewlyAddedNetwork.chainId, + internalAccounts: { + accounts: { + [account.id]: account, + }, + selectedAccount: account.id, + }, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + [mockNewlyAddedNetwork.chainId]: false, + }, + networkConfigurationsByChainId: { + ...mockState.metamask.networkConfigurationsByChainId, + [mockNewlyAddedNetwork.chainId]: mockNewlyAddedNetwork, + }, + networksMetadata: { + ...mockState.metamask.networksMetadata, + [mockNewlyAddedNetwork.chainId]: { + EIPS: { + 1559: true, + }, + status: 'available', + }, + }, + tokens: [], + swapsState: { swapsFeatureIsLive: false }, + announcements: {}, + pendingApprovals: {}, + termsOfUseLastAgreed: new Date('2999-03-25'), + shouldShowSeedPhraseReminder: false, + useExternalServices: true, + }, + send: { + ...mockSendState.send, + stage: SEND_STAGES.INACTIVE, + currentTransactionUUID: null, + draftTransactions: {}, + }, + appState: { + ...mockSendState.appState, + showWhatsNewPopup: false, + onboardedInThisUISession: false, + }, + }; + return await render(['/'], state); + }; + + it('displays new EVM network popup for EVM accounts', async () => { + const { getAllByText, getByTestId } = await renderPopup(mockEvmAccount); + + const networkInfo = getByTestId('new-network-info__bullet-paragraph'); + + await waitFor(() => { + expect(getAllByText(mockNewlyAddedNetwork.name).length).toBeGreaterThan( + 0, + ); + expect(networkInfo).toBeInTheDocument(); + }); + }); + + it('does not display new EVM network popup for non-EVM accounts', async () => { + const { queryByTestId } = await renderPopup(mockBtcAccount); + + const networkInfo = queryByTestId('new-network-info__bullet-paragraph'); + expect(networkInfo).not.toBeInTheDocument(); + }); + }); }); describe('toast display', () => { diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index bb02e0ebaaa9..c155be4ba488 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -15,12 +15,12 @@ import { getUnapprovedConfirmations, ///: END:ONLY_INCLUDE_IF getShowExtensionInFullSizeView, - getSelectedAccount, getSwitchedNetworkDetails, getNetworkToAutomaticallySwitchTo, getNumberOfAllUnapprovedTransactionsAndMessages, getUseRequestQueue, getCurrentNetwork, + getSelectedInternalAccount, oldestPendingConfirmationSelector, getUnapprovedTransactions, getPendingApprovals, @@ -64,7 +64,7 @@ function mapStateToProps(state) { // If there is more than one connected account to activeTabOrigin, // *BUT* the current account is not one of them, show the banner - const account = getSelectedAccount(state); + const account = getSelectedInternalAccount(state); const activeTabOrigin = activeTab?.origin; const currentNetwork = getCurrentNetwork(state); From 9ae485349f7a652d60b9e2cae4c92c8906f95f4b Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:18:28 +0100 Subject: [PATCH 060/148] test: rename balance functions to cover both Ganache and Anvil in preparation for ganache migration (#28676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `loginWithBalanceValidation` and `check_ganacheBalance` functions will be updated when the Anvil migration happens, so those will accept both a ganacheServer param or an anvilServer param. In order to make the main migration PR smaller, in this PR those functions/params are renamed to be more generic. In the main PR, those functions will accept the Anvil class too, but this PR is solely to update names and specs using those functions, so those specs won't need to be updated in the main PR, making it smaller. See how these functions will be used in anvil [here](https://github.com/MetaMask/metamask-extension/pull/27246/files#diff-af94879a75170f2f9e28710b48669b2ddfa353d7220fd725df3d44574f0eaff9L32) and [here](https://github.com/MetaMask/metamask-extension/pull/27246/files#diff-907f87ff9f4852f2c65855968d6f7a5028f728eaeb370239cdabc6a57c139342R288). Note: the e2e quality gate can be skipped here, as there is no functional change, just a rename of functions. This will help to spare some ci credits. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28676?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3680 ## **Manual testing steps** 1. Check ci. All tests should continue to pass normally ## **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/e2e/page-objects/flows/login.flow.ts | 10 ++++++---- test/e2e/page-objects/pages/homepage.ts | 10 ++++++---- test/e2e/tests/account/add-account.spec.ts | 6 +++--- test/e2e/tests/network/multi-rpc.spec.ts | 2 +- test/e2e/tests/network/switch-network.spec.ts | 8 ++++---- test/e2e/tests/onboarding/onboarding.spec.ts | 2 +- test/e2e/tests/tokens/nft/import-nft.spec.ts | 2 +- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/test/e2e/page-objects/flows/login.flow.ts b/test/e2e/page-objects/flows/login.flow.ts index fcd0bcb22d8a..f5ed61946ce8 100644 --- a/test/e2e/page-objects/flows/login.flow.ts +++ b/test/e2e/page-objects/flows/login.flow.ts @@ -24,12 +24,12 @@ export const loginWithoutBalanceValidation = async ( * This method unlocks the wallet and verifies that the user lands on the homepage with the expected balance. It is designed to be the initial step in setting up a test environment. * * @param driver - The webdriver instance. - * @param ganacheServer - The ganache server instance + * @param localBlockchainServer - The local blockchain server instance * @param password - The password used to unlock the wallet. */ export const loginWithBalanceValidation = async ( driver: Driver, - ganacheServer?: Ganache, + localBlockchainServer?: Ganache, password?: string, ) => { await loginWithoutBalanceValidation(driver, password); @@ -38,8 +38,10 @@ export const loginWithBalanceValidation = async ( await homePage.check_pageIsLoaded(); // Verify the expected balance on the homepage - if (ganacheServer) { - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + if (localBlockchainServer) { + await homePage.check_localBlockchainBalanceIsDisplayed( + localBlockchainServer, + ); } else { await homePage.check_expectedBalanceIsDisplayed(); } diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index 6a8a916d4349..c5c4d5369d4e 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -283,13 +283,15 @@ class HomePage { ); } - async check_ganacheBalanceIsDisplayed( - ganacheServer?: Ganache, + async check_localBlockchainBalanceIsDisplayed( + localBlockchainServer?: Ganache, address = null, ): Promise { let expectedBalance: string; - if (ganacheServer) { - expectedBalance = (await ganacheServer.getBalance(address)).toString(); + if (localBlockchainServer) { + expectedBalance = ( + await localBlockchainServer.getBalance(address) + ).toString(); } else { expectedBalance = '0'; } diff --git a/test/e2e/tests/account/add-account.spec.ts b/test/e2e/tests/account/add-account.spec.ts index 3fa65b14e87d..76060611198b 100644 --- a/test/e2e/tests/account/add-account.spec.ts +++ b/test/e2e/tests/account/add-account.spec.ts @@ -26,7 +26,7 @@ describe('Add account', function () { await completeImportSRPOnboardingFlow({ driver }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); const headerNavbar = new HeaderNavbar(driver); await headerNavbar.openAccountMenu(); @@ -43,7 +43,7 @@ describe('Add account', function () { await accountListPage.check_accountDisplayedInAccountList('Account 1'); await accountListPage.switchToAccount('Account 1'); await headerNavbar.check_accountLabel('Account 1'); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await sendTransactionToAccount({ driver, recipientAccount: 'Account 2', @@ -64,7 +64,7 @@ describe('Add account', function () { // Check wallet balance for both accounts await homePage.check_pageIsLoaded(); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await headerNavbar.openAccountMenu(); await accountListPage.check_pageIsLoaded(); await accountListPage.check_accountDisplayedInAccountList('Account 2'); diff --git a/test/e2e/tests/network/multi-rpc.spec.ts b/test/e2e/tests/network/multi-rpc.spec.ts index ac693361435d..f5c40259a33b 100644 --- a/test/e2e/tests/network/multi-rpc.spec.ts +++ b/test/e2e/tests/network/multi-rpc.spec.ts @@ -83,7 +83,7 @@ describe('MultiRpc:', function (this: Suite) { await completeImportSRPOnboardingFlow({ driver }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await new HeaderNavbar(driver).clickSwitchNetworkDropDown(); const selectNetworkDialog = new SelectNetwork(driver); diff --git a/test/e2e/tests/network/switch-network.spec.ts b/test/e2e/tests/network/switch-network.spec.ts index a45e634dbbec..b320e095cc69 100644 --- a/test/e2e/tests/network/switch-network.spec.ts +++ b/test/e2e/tests/network/switch-network.spec.ts @@ -36,19 +36,19 @@ describe('Switch network - ', function (this: Suite) { // Validate the switch network functionality to Ethereum Mainnet await switchToNetworkFlow(driver, 'Ethereum Mainnet'); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); // Validate the switch network functionality to test network await switchToNetworkFlow(driver, 'Localhost 8545', true); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); // Add Arbitrum network and perform the switch network functionality await searchAndSwitchToNetworkFlow(driver, 'Arbitrum One'); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); // Validate the switch network functionality back to Ethereum Mainnet await switchToNetworkFlow(driver, 'Ethereum Mainnet'); - await homePage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); }, ); }); diff --git a/test/e2e/tests/onboarding/onboarding.spec.ts b/test/e2e/tests/onboarding/onboarding.spec.ts index 9409ef7e351c..979e416f5090 100644 --- a/test/e2e/tests/onboarding/onboarding.spec.ts +++ b/test/e2e/tests/onboarding/onboarding.spec.ts @@ -198,7 +198,7 @@ describe('MetaMask onboarding @no-mmi', function () { // Check the correct balance for the custom network is displayed if (secondaryGanacheServer && Array.isArray(secondaryGanacheServer)) { - await homePage.check_ganacheBalanceIsDisplayed( + await homePage.check_localBlockchainBalanceIsDisplayed( secondaryGanacheServer[0], ); } else { diff --git a/test/e2e/tests/tokens/nft/import-nft.spec.ts b/test/e2e/tests/tokens/nft/import-nft.spec.ts index 73049a8c9d0d..808bc26ac6e6 100644 --- a/test/e2e/tests/tokens/nft/import-nft.spec.ts +++ b/test/e2e/tests/tokens/nft/import-nft.spec.ts @@ -75,7 +75,7 @@ describe('Import NFT', function () { await accountListPage.check_accountDisplayedInAccountList('Account 1'); await accountListPage.switchToAccount('Account 1'); await headerNavbar.check_accountLabel('Account 1'); - await homepage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homepage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await homepage.check_nftNameIsDisplayed('TestDappNFTs'); await homepage.check_nftImageIsDisplayed(); }, From 2c9f8c2b6c9fccf1557ab684b7301d6bc4c69403 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 25 Nov 2024 13:24:45 +0000 Subject: [PATCH 061/148] fix: Reduce max pet name length (#28660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Following up on https://github.com/MetaMask/metamask-extension/pull/28560, reduce truncation length to 12 characters instead of 15 (see screenshots). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28660?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3630 Related: https://github.com/MetaMask/metamask-extension/pull/28560 ## **Manual testing steps** 1. Trigger a new confirmation 2. Add a long petname, by clicking an address and writing it in the input field 3. The name should be truncated with an ellipsis. ## **Screenshots/Recordings** ### **Before** Max 15 chars limit 15 ### **After** Max 12 chars limit 12 ## **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/components/app/name/__snapshots__/name.test.tsx.snap | 2 +- .../name-details/__snapshots__/name-details.test.tsx.snap | 2 +- ui/components/app/name/name.tsx | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/components/app/name/__snapshots__/name.test.tsx.snap b/ui/components/app/name/__snapshots__/name.test.tsx.snap index 7e3f98c8576e..286429760c1e 100644 --- a/ui/components/app/name/__snapshots__/name.test.tsx.snap +++ b/ui/components/app/name/__snapshots__/name.test.tsx.snap @@ -79,7 +79,7 @@ exports[`Name renders address with long saved name 1`] = `

- Very long and l... + Very long an...

diff --git a/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap b/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap index da6f598dfa5e..3520a1b64a13 100644 --- a/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap +++ b/ui/components/app/name/name-details/__snapshots__/name-details.test.tsx.snap @@ -753,7 +753,7 @@ exports[`NameDetails renders with recognized name 1`] = `

- iZUMi Bond USD + iZUMi Bond U...

diff --git a/ui/components/app/name/name.tsx b/ui/components/app/name/name.tsx index 75f2a2f79c15..4871cc481f0c 100644 --- a/ui/components/app/name/name.tsx +++ b/ui/components/app/name/name.tsx @@ -103,9 +103,10 @@ const Name = memo( }, [setModalOpen]); const formattedValue = formatValue(value, type); + const MAX_PET_NAME_LENGTH = 12; const formattedName = shortenString(name || undefined, { - truncatedCharLimit: 15, - truncatedStartChars: 15, + truncatedCharLimit: MAX_PET_NAME_LENGTH, + truncatedStartChars: MAX_PET_NAME_LENGTH, truncatedEndChars: 0, skipCharacterInEnd: true, }); From b7fa3fa7539c43409a34f209d9347e553748ed16 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 25 Nov 2024 13:25:01 +0000 Subject: [PATCH 062/148] fix: Add default value to custom nonce modal (#28659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** See original bug ticket. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28659?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28033 ## **Manual testing steps** See original bug ticket. ## **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. --- .../__snapshots__/customize-nonce.test.js.snap | 2 +- .../modals/customize-nonce/customize-nonce.component.js | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ui/components/app/modals/customize-nonce/__snapshots__/customize-nonce.test.js.snap b/ui/components/app/modals/customize-nonce/__snapshots__/customize-nonce.test.js.snap index 020adaa0c952..4698963a26ec 100644 --- a/ui/components/app/modals/customize-nonce/__snapshots__/customize-nonce.test.js.snap +++ b/ui/components/app/modals/customize-nonce/__snapshots__/customize-nonce.test.js.snap @@ -86,7 +86,7 @@ exports[`Customize Nonce should match snapshot 1`] = ` min="0" placeholder="1" type="number" - value="" + value="1" /> diff --git a/ui/components/app/modals/customize-nonce/customize-nonce.component.js b/ui/components/app/modals/customize-nonce/customize-nonce.component.js index 1d87cbd549b1..b6b2f82a096e 100644 --- a/ui/components/app/modals/customize-nonce/customize-nonce.component.js +++ b/ui/components/app/modals/customize-nonce/customize-nonce.component.js @@ -27,7 +27,9 @@ const CustomizeNonce = ({ updateCustomNonce, getNextNonce, }) => { - const [customNonce, setCustomNonce] = useState(''); + const defaultNonce = + customNonceValue || (typeof nextNonce === 'number' && nextNonce.toString()); + const [customNonce, setCustomNonce] = useState(defaultNonce); const t = useI18nContext(); return ( @@ -107,10 +109,7 @@ const CustomizeNonce = ({ type="number" data-testid="custom-nonce-input" min="0" - placeholder={ - customNonceValue || - (typeof nextNonce === 'number' && nextNonce.toString()) - } + placeholder={defaultNonce} onChange={(e) => { setCustomNonce(e.target.value); }} From 592a628b2bd99558d6f86b7f4d80af3701deec41 Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:43:53 +0100 Subject: [PATCH 063/148] fix: add missing filter for scheduled job rerun-from-failed (#28644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** After [the rerun from failed PR ](https://github.com/MetaMask/metamask-extension/pull/28143) was merged there was one remaining filter to tweak, related to the scheduled trigger that will be done in the UI: - In the Circle ci UI panel, there is no way to tell to trigger a specific workflow, rather all the config file will be checked, which means both the test_and_release and the rerun_from_failed workflows would run. For that, we need to add a filter in the `test_and_release` flow, so that is not run when we schedule the automatic runs using the name rerun-from-failed. Unfortunately there is no way to do this from the UI, so we need this filter in the config file - I saw, that the rerun-failed workflow was run once the PR was merged, which shouldn't be the case. I've tweaked that following this example in circle [ci docs](https://circleci.com/docs/schedule-pipelines-with-multiple-workflows/#schedule-using-built-in-pipeline-values)) ![Screenshot from 2024-11-22 11-06-07](https://github.com/user-attachments/assets/ef82e9e0-4da9-42e6-bb24-6fb97b55034e) Why this was missed: on the testing for the previous PR, I removed ALL the filters, to make the rerun-from-failed workflow run and check that it works (it worked). However, I didn't test leaving the filter of the `[rerun-from-failed, << pipeline.schedule.name >>]` and checking that it is not run [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28644?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** How to test this: instead of removing ALL the filters (like it was done for testing the previous PR), just remove the filter for develop. This way, we can see if the job rerun-from-failed is run --> it shouldn't be, as it's not a scheduled run with that name. It can be checked [here](https://app.circleci.com/pipelines/github/MetaMask/metamask-extension?branch=rerun-failed-missing-filter): see only the test and release is run ![Screenshot from 2024-11-22 11-12-15](https://github.com/user-attachments/assets/d33761b2-beec-4d01-914a-c559815578d5) For testing the scheduled run, it needs to be done in the UI once this PR is merged, but if the filter has been proven to work here, it should then also work there, for when it needs to be run ## **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. --- .circleci/config.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1b4242570daf..0817f18c36a4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -103,9 +103,11 @@ workflows: test_and_release: when: not: - matches: - pattern: /^l10n_crowdin_action$/ - value: << pipeline.git.branch >> + or: + - matches: + pattern: /^l10n_crowdin_action$/ + value: << pipeline.git.branch >> + - equal: [rerun-from-failed, << pipeline.schedule.name >>] jobs: - create_release_pull_request: <<: *rc_branch_only @@ -358,8 +360,7 @@ workflows: rerun-from-failed: when: - condition: - equal: ["<< pipeline.schedule.name >>", "rerun-from-failed"] + equal: [rerun-from-failed, << pipeline.schedule.name >>] jobs: - prep-deps - rerun-workflows-from-failed: From bf1455bec13d6a910f9528a000c026d375e5b13e Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Mon, 25 Nov 2024 21:03:18 +0700 Subject: [PATCH 064/148] perf: add React.lazy to the Routes (#28172) ## **Description** > **Note:** I would really appreciate quick reviews here mostly based on "did I break anything." It's not really worth our time to nit-pick about the style or the details because every line of this is going to be changed by me again in the near future. Still to do in later PRs that are going to completely re-write what I did here: > - Convert routes.component.js to TypeScript > - Convert routes.component.js to Hooks > - Convert routes.component.js to react-router v6 > - Eliminate routes.container.js > > All this has to be done in sequential PRs based on top of this one, so while I wait for reviews I can only work on a non-Routes topic. This is the first PR to add `React.lazy` to the codebase. The meat of the changes are in `ui/pages/routes/routes.component.js`. Every other file changed is just a reaction to those changes. Much of this is caused by the fact that `React.lazy` only works on default exports ([see the documentation here](https://react.dev/reference/react/lazy)), so I had to change a few named exports to default exports. I don't think this PR has much of an impact on loading times, but it sets up further work that should have an impact. There was one component (`ConfirmTransaction`) that needed some extra attention to work with `React.lazy`. I had to remove the `history.replace(mostRecentOverviewPage);`, which I'm pretty sure was an unnecessary statement, and remove the Unit Test related to it. **UPDATE:** I changed all of the Integration Tests to convert `getBy...` to `await findBy...` to wait for the `React.lazy` component to load. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28172?quickstart=1) ## **Related issues** Progresses: MetaMask/MetaMask-planning#3302 --- app/scripts/lib/manifestFlags.ts | 9 + builds.yml | 3 + jest.integration.config.js | 3 +- shared/lib/trace.ts | 1 + .../confirmations/signatures/permit.test.tsx | 36 +- .../signatures/personalSign.test.tsx | 33 +- .../transactions/alerts.test.tsx | 14 +- .../transactions/contract-deployment.test.tsx | 79 +++-- .../contract-interaction.test.tsx | 112 ++++--- .../transactions/erc20-approve.test.tsx | 50 +-- .../transactions/erc721-approve.test.tsx | 46 ++- .../transactions/increase-allowance.test.tsx | 50 +-- .../set-approval-for-all.test.tsx | 48 ++- .../notifications-activation.test.tsx | 6 +- .../notifications-list.test.tsx | 60 ++-- .../notifications-toggle.test.tsx | 12 +- .../onboarding/wallet-created.test.tsx | 17 +- ui/components/multichain/pages/index.js | 3 - .../pages/review-permissions-page/index.js | 2 - .../review-permissions-page.stories.tsx | 2 +- .../review-permissions-page.test.tsx | 2 +- .../review-permissions-page.tsx | 2 +- ui/helpers/utils/mm-lazy.ts | 86 +++++ .../confirm-transaction.component.js | 4 +- .../confirm-transaction.test.js | 21 -- .../connect-page/connect-page.tsx | 2 +- ui/pages/routes/routes.component.js | 315 ++++++++++-------- .../add-contact/add-contact.test.js | 15 +- 28 files changed, 617 insertions(+), 416 deletions(-) delete mode 100644 ui/components/multichain/pages/index.js delete mode 100644 ui/components/multichain/pages/review-permissions-page/index.js create mode 100644 ui/helpers/utils/mm-lazy.ts diff --git a/app/scripts/lib/manifestFlags.ts b/app/scripts/lib/manifestFlags.ts index 93925bf63a0c..5804c7391973 100644 --- a/app/scripts/lib/manifestFlags.ts +++ b/app/scripts/lib/manifestFlags.ts @@ -11,6 +11,7 @@ export type ManifestFlags = { }; sentry?: { tracesSampleRate?: number; + lazyLoadSubSampleRate?: number; // multiply by tracesSampleRate to get the actual probability forceEnable?: boolean; }; }; @@ -27,6 +28,14 @@ interface WebExtensionManifestWithFlags * @returns flags if they exist, otherwise an empty object */ export function getManifestFlags(): ManifestFlags { + // If this is running in a unit test, there's no manifest, so just return an empty object + if ( + process.env.JEST_WORKER_ID === undefined || + !browser.runtime.getManifest + ) { + return {}; + } + return ( (browser.runtime.getManifest() as WebExtensionManifestWithFlags)._flags || {} diff --git a/builds.yml b/builds.yml index fe33507c1e4a..75f6f4b462c6 100644 --- a/builds.yml +++ b/builds.yml @@ -298,6 +298,9 @@ env: # Enables the notifications feature within the build: - NOTIFICATIONS: '' + # This will be defined if running a unit test + - JEST_WORKER_ID: undefined + - METAMASK_RAMP_API_CONTENT_BASE_URL: https://on-ramp-content.api.cx.metamask.io ### diff --git a/jest.integration.config.js b/jest.integration.config.js index d7236b832aed..685080330fb3 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -25,7 +25,8 @@ module.exports = { setupFilesAfterEnv: ['/test/integration/config/setupAfter.js'], testMatch: ['/test/integration/**/*.test.(js|ts|tsx)'], testPathIgnorePatterns: ['/test/integration/config/*'], - testTimeout: 5500, + // This was increased from 5500 to 10000 to when lazy loading was introduced + testTimeout: 10000, // We have to specify the environment we are running in, which is jsdom. The // default is 'node'. This can be modified *per file* using a comment at the // head of the file. So it may be worthwhile to switch to 'node' in any diff --git a/shared/lib/trace.ts b/shared/lib/trace.ts index 0d58ddcdcfcc..4c05b098f120 100644 --- a/shared/lib/trace.ts +++ b/shared/lib/trace.ts @@ -18,6 +18,7 @@ export enum TraceName { FirstRender = 'First Render', GetState = 'Get State', InitialActions = 'Initial Actions', + LazyLoadComponent = 'Lazy Load Component', LoadScripts = 'Load Scripts', Middleware = 'Middleware', NestedTest1 = 'Nested Test 1', diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index 5ff87bf7c533..7af3be743f5f 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -103,25 +103,29 @@ describe('Permit Confirmation', () => { }); }); - expect(screen.getByTestId('header-account-name')).toHaveTextContent( + expect(await screen.findByTestId('header-account-name')).toHaveTextContent( accountName, ); - expect(screen.getByTestId('header-network-display-name')).toHaveTextContent( - 'Sepolia', - ); + expect( + await screen.findByTestId('header-network-display-name'), + ).toHaveTextContent('Sepolia'); - fireEvent.click(screen.getByTestId('header-info__account-details-button')); + fireEvent.click( + await screen.findByTestId('header-info__account-details-button'), + ); expect( await screen.findByTestId( 'confirmation-account-details-modal__account-name', ), ).toHaveTextContent(accountName); - expect(screen.getByTestId('address-copy-button-text')).toHaveTextContent( - '0x0DCD5...3E7bc', - ); expect( - screen.getByTestId('confirmation-account-details-modal__account-balance'), + await screen.findByTestId('address-copy-button-text'), + ).toHaveTextContent('0x0DCD5...3E7bc'); + expect( + await screen.findByTestId( + 'confirmation-account-details-modal__account-balance', + ), ).toHaveTextContent('1.582717SepoliaETH'); let confirmAccountDetailsModalMetricsEvent; @@ -154,7 +158,9 @@ describe('Permit Confirmation', () => { ); fireEvent.click( - screen.getByTestId('confirmation-account-details-modal__close-button'), + await screen.findByTestId( + 'confirmation-account-details-modal__close-button', + ), ); await waitFor(() => { @@ -252,7 +258,7 @@ describe('Permit Confirmation', () => { }); }); - const simulationSection = screen.getByTestId( + const simulationSection = await screen.findByTestId( 'confirmation__simulation_section', ); expect(simulationSection).toBeInTheDocument(); @@ -262,9 +268,9 @@ describe('Permit Confirmation', () => { ); expect(simulationSection).toHaveTextContent('Spending cap'); expect(simulationSection).toHaveTextContent('0xCcCCc...ccccC'); - expect(screen.getByTestId('simulation-token-value')).toHaveTextContent( - '30', - ); + expect( + await screen.findByTestId('simulation-token-value'), + ).toHaveTextContent('30'); const individualFiatDisplay = await screen.findByTestId( 'individual-fiat-display', @@ -303,6 +309,6 @@ describe('Permit Confirmation', () => { account.address, )})`; - expect(screen.getByText(mismatchAccountText)).toBeInTheDocument(); + expect(await screen.findByText(mismatchAccountText)).toBeInTheDocument(); }); }); diff --git a/test/integration/confirmations/signatures/personalSign.test.tsx b/test/integration/confirmations/signatures/personalSign.test.tsx index 5a9c311c9abd..03685f46ab7b 100644 --- a/test/integration/confirmations/signatures/personalSign.test.tsx +++ b/test/integration/confirmations/signatures/personalSign.test.tsx @@ -82,17 +82,20 @@ describe('PersonalSign Confirmation', () => { account.address, ); - const { getByTestId, queryByTestId } = await integrationTestRender({ - preloadedState: mockedMetaMaskState, - backgroundConnection: backgroundConnectionMocked, - }); + const { findByTestId, getByTestId, queryByTestId } = + await integrationTestRender({ + preloadedState: mockedMetaMaskState, + backgroundConnection: backgroundConnectionMocked, + }); - expect(getByTestId('header-account-name')).toHaveTextContent(accountName); - expect(getByTestId('header-network-display-name')).toHaveTextContent( + expect(await findByTestId('header-account-name')).toHaveTextContent( + accountName, + ); + expect(await findByTestId('header-network-display-name')).toHaveTextContent( 'Sepolia', ); - fireEvent.click(getByTestId('header-info__account-details-button')); + fireEvent.click(await findByTestId('header-info__account-details-button')); await waitFor(() => { expect( @@ -101,13 +104,13 @@ describe('PersonalSign Confirmation', () => { }); expect( - getByTestId('confirmation-account-details-modal__account-name'), + await findByTestId('confirmation-account-details-modal__account-name'), ).toHaveTextContent(accountName); - expect(getByTestId('address-copy-button-text')).toHaveTextContent( + expect(await findByTestId('address-copy-button-text')).toHaveTextContent( '0x0DCD5...3E7bc', ); expect( - getByTestId('confirmation-account-details-modal__account-balance'), + await findByTestId('confirmation-account-details-modal__account-balance'), ).toHaveTextContent('1.582717SepoliaETH'); let confirmAccountDetailsModalMetricsEvent; @@ -137,7 +140,7 @@ describe('PersonalSign Confirmation', () => { ); fireEvent.click( - getByTestId('confirmation-account-details-modal__close-button'), + await findByTestId('confirmation-account-details-modal__close-button'), ); await waitFor(() => { @@ -165,9 +168,9 @@ describe('PersonalSign Confirmation', () => { }); }); - expect(screen.getByText('Signature request')).toBeInTheDocument(); + expect(await screen.findByText('Signature request')).toBeInTheDocument(); expect( - screen.getByText('Review request details before you confirm.'), + await screen.findByText('Review request details before you confirm.'), ).toBeInTheDocument(); }); @@ -186,7 +189,7 @@ describe('PersonalSign Confirmation', () => { account.address, ); - const { getByText } = await integrationTestRender({ + const { findByText } = await integrationTestRender({ preloadedState: mockedMetaMaskState, backgroundConnection: backgroundConnectionMocked, }); @@ -197,6 +200,6 @@ describe('PersonalSign Confirmation', () => { account.address, )})`; - expect(getByText(mismatchAccountText)).toBeInTheDocument(); + expect(await findByText(mismatchAccountText)).toBeInTheDocument(); }); }); diff --git a/test/integration/confirmations/transactions/alerts.test.tsx b/test/integration/confirmations/transactions/alerts.test.tsx index 74d37c858b0f..1bbf9d1fd2c5 100644 --- a/test/integration/confirmations/transactions/alerts.test.tsx +++ b/test/integration/confirmations/transactions/alerts.test.tsx @@ -129,7 +129,7 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); @@ -182,7 +182,7 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); @@ -228,7 +228,7 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); @@ -274,7 +274,7 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); @@ -349,9 +349,9 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - expect(await screen.getByTestId('inline-alert')).toBeInTheDocument(); + expect(await screen.findByTestId('inline-alert')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); @@ -390,7 +390,7 @@ describe('Contract Interaction Confirmation Alerts', () => { }); }); - fireEvent.click(screen.getByTestId('inline-alert')); + fireEvent.click(await screen.findByTestId('inline-alert')); expect(await screen.findByTestId('alert-modal')).toBeInTheDocument(); diff --git a/test/integration/confirmations/transactions/contract-deployment.test.tsx b/test/integration/confirmations/transactions/contract-deployment.test.tsx index 67862e6b9550..7698ce3ef8b2 100644 --- a/test/integration/confirmations/transactions/contract-deployment.test.tsx +++ b/test/integration/confirmations/transactions/contract-deployment.test.tsx @@ -162,25 +162,29 @@ describe('Contract Deployment Confirmation', () => { }); }); - expect(screen.getByTestId('header-account-name')).toHaveTextContent( + expect(await screen.findByTestId('header-account-name')).toHaveTextContent( accountName, ); - expect(screen.getByTestId('header-network-display-name')).toHaveTextContent( - 'Sepolia', - ); + expect( + await screen.findByTestId('header-network-display-name'), + ).toHaveTextContent('Sepolia'); - fireEvent.click(screen.getByTestId('header-info__account-details-button')); + fireEvent.click( + await screen.findByTestId('header-info__account-details-button'), + ); expect( await screen.findByTestId( 'confirmation-account-details-modal__account-name', ), ).toHaveTextContent(accountName); - expect(screen.getByTestId('address-copy-button-text')).toHaveTextContent( - '0x0DCD5...3E7bc', - ); expect( - screen.getByTestId('confirmation-account-details-modal__account-balance'), + await screen.findByTestId('address-copy-button-text'), + ).toHaveTextContent('0x0DCD5...3E7bc'); + expect( + await screen.findByTestId( + 'confirmation-account-details-modal__account-balance', + ), ).toHaveTextContent('1.582717SepoliaETH'); let confirmAccountDetailsModalMetricsEvent; @@ -213,7 +217,9 @@ describe('Contract Deployment Confirmation', () => { ); fireEvent.click( - screen.getByTestId('confirmation-account-details-modal__close-button'), + await screen.findByTestId( + 'confirmation-account-details-modal__close-button', + ), ); await waitFor(() => { @@ -245,10 +251,12 @@ describe('Contract Deployment Confirmation', () => { }); expect( - screen.getByText(tEn('confirmTitleDeployContract') as string), + await screen.findByText(tEn('confirmTitleDeployContract') as string), ).toBeInTheDocument(); - const simulationSection = screen.getByTestId('simulation-details-layout'); + const simulationSection = await screen.findByTestId( + 'simulation-details-layout', + ); expect(simulationSection).toBeInTheDocument(); expect(simulationSection).toHaveTextContent( tEn('simulationDetailsTitle') as string, @@ -261,10 +269,10 @@ describe('Contract Deployment Confirmation', () => { tEn('simulationDetailsIncomingHeading') as string, ); expect(simulationDetailsRow).toContainElement( - screen.getByTestId('simulation-details-amount-pill'), + await screen.findByTestId('simulation-details-amount-pill'), ); - const transactionDetailsSection = screen.getByTestId( + const transactionDetailsSection = await screen.findByTestId( 'transaction-details-section', ); expect(transactionDetailsSection).toBeInTheDocument(); @@ -275,25 +283,30 @@ describe('Contract Deployment Confirmation', () => { tEn('interactingWith') as string, ); - const gasFeesSection = screen.getByTestId('gas-fee-section'); + const gasFeesSection = await screen.findByTestId('gas-fee-section'); expect(gasFeesSection).toBeInTheDocument(); - const editGasFeesRow = - within(gasFeesSection).getByTestId('edit-gas-fees-row'); + const editGasFeesRow = await within(gasFeesSection).findByTestId( + 'edit-gas-fees-row', + ); expect(editGasFeesRow).toHaveTextContent(tEn('networkFee') as string); - const firstGasField = within(editGasFeesRow).getByTestId('first-gas-field'); + const firstGasField = await within(editGasFeesRow).findByTestId( + 'first-gas-field', + ); expect(firstGasField).toHaveTextContent('0.0001 SepoliaETH'); expect(editGasFeesRow).toContainElement( - screen.getByTestId('edit-gas-fee-icon'), + await screen.findByTestId('edit-gas-fee-icon'), ); - const gasFeeSpeed = within(gasFeesSection).getByTestId( + const gasFeeSpeed = await within(gasFeesSection).findByTestId( 'gas-fee-details-speed', ); expect(gasFeeSpeed).toHaveTextContent(tEn('speed') as string); - const gasTimingTime = within(gasFeeSpeed).getByTestId('gas-timing-time'); + const gasTimingTime = await within(gasFeeSpeed).findByTestId( + 'gas-timing-time', + ); expect(gasTimingTime).toHaveTextContent('~0 sec'); }); @@ -321,7 +334,9 @@ describe('Contract Deployment Confirmation', () => { }); }); - fireEvent.click(screen.getByTestId('header-advanced-details-button')); + fireEvent.click( + await screen.findByTestId('header-advanced-details-button'), + ); await waitFor(() => { expect( @@ -364,28 +379,32 @@ describe('Contract Deployment Confirmation', () => { ).toHaveBeenCalledWith('getNextNonce', expect.anything()); }); - const gasFeesSection = screen.getByTestId('gas-fee-section'); - const maxFee = screen.getByTestId('gas-fee-details-max-fee'); + const gasFeesSection = await screen.findByTestId('gas-fee-section'); + const maxFee = await screen.findByTestId('gas-fee-details-max-fee'); expect(gasFeesSection).toContainElement(maxFee); expect(maxFee).toHaveTextContent(tEn('maxFee') as string); expect(maxFee).toHaveTextContent('0.0023 SepoliaETH'); - const nonceSection = screen.getByTestId('advanced-details-nonce-section'); + const nonceSection = await screen.findByTestId( + 'advanced-details-nonce-section', + ); expect(nonceSection).toBeInTheDocument(); expect(nonceSection).toHaveTextContent( tEn('advancedDetailsNonceDesc') as string, ); expect(nonceSection).toContainElement( - screen.getByTestId('advanced-details-displayed-nonce'), + await screen.findByTestId('advanced-details-displayed-nonce'), ); expect( - screen.getByTestId('advanced-details-displayed-nonce'), + await screen.findByTestId('advanced-details-displayed-nonce'), ).toHaveTextContent('9'); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -394,7 +413,7 @@ describe('Contract Deployment Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('Deposit'); - const transactionDataParams = screen.getByTestId( + const transactionDataParams = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(transactionDataParams); diff --git a/test/integration/confirmations/transactions/contract-interaction.test.tsx b/test/integration/confirmations/transactions/contract-interaction.test.tsx index 9a955e1a45fb..5db121bc9fda 100644 --- a/test/integration/confirmations/transactions/contract-interaction.test.tsx +++ b/test/integration/confirmations/transactions/contract-interaction.test.tsx @@ -23,6 +23,8 @@ import { getUnapprovedContractInteractionTransaction, } from './transactionDataHelpers'; +jest.setTimeout(20_000); + jest.mock('../../../../ui/store/background-connection', () => ({ ...jest.requireActual('../../../../ui/store/background-connection'), submitRequestToBackground: jest.fn(), @@ -180,20 +182,25 @@ describe('Contract Interaction Confirmation', () => { }); }); - expect(screen.getByTestId('header-account-name')).toHaveTextContent( + await screen.findByText(accountName); + expect(await screen.findByTestId('header-account-name')).toHaveTextContent( accountName, ); - expect(screen.getByTestId('header-network-display-name')).toHaveTextContent( - 'Sepolia', - ); + expect( + await screen.findByTestId('header-network-display-name'), + ).toHaveTextContent('Sepolia'); - fireEvent.click(screen.getByTestId('header-info__account-details-button')); + await act(async () => { + fireEvent.click( + await screen.findByTestId('header-info__account-details-button'), + ); + }); - expect( - await screen.findByTestId( - 'confirmation-account-details-modal__account-name', - ), - ).toHaveTextContent(accountName); + await waitFor(() => { + expect( + screen.getByTestId('confirmation-account-details-modal__account-name'), + ).toHaveTextContent(accountName); + }); expect(screen.getByTestId('address-copy-button-text')).toHaveTextContent( '0x0DCD5...3E7bc', ); @@ -214,21 +221,21 @@ describe('Contract Interaction Confirmation', () => { expect(confirmAccountDetailsModalMetricsEvent?.[0]).toBe( 'trackMetaMetricsEvent', ); - }); - expect(confirmAccountDetailsModalMetricsEvent?.[1]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - category: MetaMetricsEventCategory.Confirmations, - event: MetaMetricsEventName.AccountDetailsOpened, - properties: { - action: 'Confirm Screen', - location: MetaMetricsEventLocation.Transaction, - transaction_type: TransactionType.contractInteraction, - }, - }), - ]), - ); + expect(confirmAccountDetailsModalMetricsEvent?.[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: MetaMetricsEventCategory.Confirmations, + event: MetaMetricsEventName.AccountDetailsOpened, + properties: { + action: 'Confirm Screen', + location: MetaMetricsEventLocation.Transaction, + transaction_type: TransactionType.contractInteraction, + }, + }), + ]), + ); + }); fireEvent.click( screen.getByTestId('confirmation-account-details-modal__close-button'), @@ -263,10 +270,12 @@ describe('Contract Interaction Confirmation', () => { }); expect( - screen.getByText(tEn('confirmTitleTransaction') as string), + await screen.findByText(tEn('confirmTitleTransaction') as string), ).toBeInTheDocument(); - const simulationSection = screen.getByTestId('simulation-details-layout'); + const simulationSection = await screen.findByTestId( + 'simulation-details-layout', + ); expect(simulationSection).toBeInTheDocument(); expect(simulationSection).toHaveTextContent( tEn('simulationDetailsTitle') as string, @@ -279,10 +288,10 @@ describe('Contract Interaction Confirmation', () => { tEn('simulationDetailsIncomingHeading') as string, ); expect(simulationDetailsRow).toContainElement( - screen.getByTestId('simulation-details-amount-pill'), + await screen.findByTestId('simulation-details-amount-pill'), ); - const transactionDetailsSection = screen.getByTestId( + const transactionDetailsSection = await screen.findByTestId( 'transaction-details-section', ); expect(transactionDetailsSection).toBeInTheDocument(); @@ -293,25 +302,30 @@ describe('Contract Interaction Confirmation', () => { tEn('interactingWith') as string, ); - const gasFeesSection = screen.getByTestId('gas-fee-section'); + const gasFeesSection = await screen.findByTestId('gas-fee-section'); expect(gasFeesSection).toBeInTheDocument(); - const editGasFeesRow = - within(gasFeesSection).getByTestId('edit-gas-fees-row'); + const editGasFeesRow = await within(gasFeesSection).findByTestId( + 'edit-gas-fees-row', + ); expect(editGasFeesRow).toHaveTextContent(tEn('networkFee') as string); - const firstGasField = within(editGasFeesRow).getByTestId('first-gas-field'); + const firstGasField = await within(editGasFeesRow).findByTestId( + 'first-gas-field', + ); expect(firstGasField).toHaveTextContent('0.0001 SepoliaETH'); expect(editGasFeesRow).toContainElement( - screen.getByTestId('edit-gas-fee-icon'), + await screen.findByTestId('edit-gas-fee-icon'), ); - const gasFeeSpeed = within(gasFeesSection).getByTestId( + const gasFeeSpeed = await within(gasFeesSection).findByTestId( 'gas-fee-details-speed', ); expect(gasFeeSpeed).toHaveTextContent(tEn('speed') as string); - const gasTimingTime = within(gasFeeSpeed).getByTestId('gas-timing-time'); + const gasTimingTime = await within(gasFeeSpeed).findByTestId( + 'gas-timing-time', + ); expect(gasTimingTime).toHaveTextContent('~0 sec'); }); @@ -339,7 +353,9 @@ describe('Contract Interaction Confirmation', () => { }); }); - fireEvent.click(screen.getByTestId('header-advanced-details-button')); + fireEvent.click( + await screen.findByTestId('header-advanced-details-button'), + ); await waitFor(() => { expect( @@ -395,28 +411,32 @@ describe('Contract Interaction Confirmation', () => { ]); }); - const gasFeesSection = screen.getByTestId('gas-fee-section'); - const maxFee = screen.getByTestId('gas-fee-details-max-fee'); + const gasFeesSection = await screen.findByTestId('gas-fee-section'); + const maxFee = await screen.findByTestId('gas-fee-details-max-fee'); expect(gasFeesSection).toContainElement(maxFee); expect(maxFee).toHaveTextContent(tEn('maxFee') as string); expect(maxFee).toHaveTextContent('0.0023 SepoliaETH'); - const nonceSection = screen.getByTestId('advanced-details-nonce-section'); + const nonceSection = await screen.findByTestId( + 'advanced-details-nonce-section', + ); expect(nonceSection).toBeInTheDocument(); expect(nonceSection).toHaveTextContent( tEn('advancedDetailsNonceDesc') as string, ); expect(nonceSection).toContainElement( - screen.getByTestId('advanced-details-displayed-nonce'), + await screen.findByTestId('advanced-details-displayed-nonce'), ); expect( - screen.getByTestId('advanced-details-displayed-nonce'), + await screen.findByTestId('advanced-details-displayed-nonce'), ).toHaveTextContent('9'); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -425,7 +445,7 @@ describe('Contract Interaction Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('mintNFTs'); - const transactionDataParams = screen.getByTestId( + const transactionDataParams = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(transactionDataParams); @@ -454,7 +474,7 @@ describe('Contract Interaction Confirmation', () => { const headingText = tEn('blockaidTitleDeceptive') as string; const bodyText = tEn('blockaidDescriptionTransferFarming') as string; - expect(screen.getByText(headingText)).toBeInTheDocument(); - expect(screen.getByText(bodyText)).toBeInTheDocument(); + expect(await screen.findByText(headingText)).toBeInTheDocument(); + expect(await screen.findByText(bodyText)).toBeInTheDocument(); }); }); diff --git a/test/integration/confirmations/transactions/erc20-approve.test.tsx b/test/integration/confirmations/transactions/erc20-approve.test.tsx index a2404ba75b09..c25b2ee3627d 100644 --- a/test/integration/confirmations/transactions/erc20-approve.test.tsx +++ b/test/integration/confirmations/transactions/erc20-approve.test.tsx @@ -163,10 +163,10 @@ describe('ERC20 Approve Confirmation', () => { }); expect( - screen.getByText(tEn('confirmTitlePermitTokens') as string), + await screen.findByText(tEn('confirmTitlePermitTokens') as string), ).toBeInTheDocument(); expect( - screen.getByText( + await screen.findByText( tEn('confirmTitleDescERC20ApproveTransaction') as string, ), ).toBeInTheDocument(); @@ -183,7 +183,7 @@ describe('ERC20 Approve Confirmation', () => { }); }); - const simulationSection = screen.getByTestId( + const simulationSection = await screen.findByTestId( 'confirmation__simulation_section', ); expect(simulationSection).toBeInTheDocument(); @@ -192,7 +192,9 @@ describe('ERC20 Approve Confirmation', () => { tEn('simulationDetailsERC20ApproveDesc') as string, ); expect(simulationSection).toHaveTextContent(tEn('spendingCap') as string); - const spendingCapValue = screen.getByTestId('simulation-token-value'); + const spendingCapValue = await screen.findByTestId( + 'simulation-token-value', + ); expect(simulationSection).toContainElement(spendingCapValue); expect(spendingCapValue).toHaveTextContent('1'); expect(simulationSection).toHaveTextContent('0x07614...3ad68'); @@ -211,16 +213,18 @@ describe('ERC20 Approve Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsSpender = screen.getByTestId( + const approveDetailsSpender = await screen.findByTestId( 'confirmation__approve-spender', ); expect(approveDetails).toContainElement(approveDetailsSpender); expect(approveDetailsSpender).toHaveTextContent(tEn('spender') as string); expect(approveDetailsSpender).toHaveTextContent('0x2e0D7...5d09B'); - const spenderTooltip = screen.getByTestId( + const spenderTooltip = await screen.findByTestId( 'confirmation__approve-spender-tooltip', ); expect(approveDetailsSpender).toContainElement(spenderTooltip); @@ -231,7 +235,7 @@ describe('ERC20 Approve Confirmation', () => { ); expect(spenderTooltipContent).toBeInTheDocument(); - const approveDetailsRequestFrom = screen.getByTestId( + const approveDetailsRequestFrom = await screen.findByTestId( 'transaction-details-origin-row', ); expect(approveDetails).toContainElement(approveDetailsRequestFrom); @@ -240,7 +244,7 @@ describe('ERC20 Approve Confirmation', () => { 'http://localhost:8086/', ); - const approveDetailsRequestFromTooltip = screen.getByTestId( + const approveDetailsRequestFromTooltip = await screen.findByTestId( 'transaction-details-origin-row-tooltip', ); expect(approveDetailsRequestFrom).toContainElement( @@ -266,7 +270,7 @@ describe('ERC20 Approve Confirmation', () => { }); }); - const spendingCapSection = screen.getByTestId( + const spendingCapSection = await screen.findByTestId( 'confirmation__approve-spending-cap-section', ); expect(spendingCapSection).toBeInTheDocument(); @@ -275,14 +279,14 @@ describe('ERC20 Approve Confirmation', () => { tEn('accountBalance') as string, ); expect(spendingCapSection).toHaveTextContent('0'); - const spendingCapGroup = screen.getByTestId( + const spendingCapGroup = await screen.findByTestId( 'confirmation__approve-spending-cap-group', ); expect(spendingCapSection).toContainElement(spendingCapGroup); expect(spendingCapGroup).toHaveTextContent(tEn('spendingCap') as string); expect(spendingCapGroup).toHaveTextContent('1'); - const spendingCapGroupTooltip = screen.getByTestId( + const spendingCapGroupTooltip = await screen.findByTestId( 'confirmation__approve-spending-cap-group-tooltip', ); expect(spendingCapGroup).toContainElement(spendingCapGroupTooltip); @@ -308,10 +312,12 @@ describe('ERC20 Approve Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsRecipient = screen.getByTestId( + const approveDetailsRecipient = await screen.findByTestId( 'transaction-details-recipient-row', ); expect(approveDetails).toContainElement(approveDetailsRecipient); @@ -320,7 +326,7 @@ describe('ERC20 Approve Confirmation', () => { ); expect(approveDetailsRecipient).toHaveTextContent('0x07614...3ad68'); - const approveDetailsRecipientTooltip = screen.getByTestId( + const approveDetailsRecipientTooltip = await screen.findByTestId( 'transaction-details-recipient-row-tooltip', ); expect(approveDetailsRecipient).toContainElement( @@ -338,7 +344,7 @@ describe('ERC20 Approve Confirmation', () => { expect(approveDetails).toContainElement(approveMethodData); expect(approveMethodData).toHaveTextContent(tEn('methodData') as string); expect(approveMethodData).toHaveTextContent('Approve'); - const approveMethodDataTooltip = screen.getByTestId( + const approveMethodDataTooltip = await screen.findByTestId( 'transaction-details-method-data-row-tooltip', ); expect(approveMethodData).toContainElement(approveMethodDataTooltip); @@ -348,15 +354,17 @@ describe('ERC20 Approve Confirmation', () => { ); expect(approveMethodDataTooltipContent).toBeInTheDocument(); - const approveDetailsNonce = screen.getByTestId( + const approveDetailsNonce = await screen.findByTestId( 'advanced-details-nonce-section', ); expect(approveDetailsNonce).toBeInTheDocument(); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -365,14 +373,14 @@ describe('ERC20 Approve Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('Approve'); - const approveDataParams1 = screen.getByTestId( + const approveDataParams1 = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(approveDataParams1); expect(approveDataParams1).toHaveTextContent('Param #1'); expect(approveDataParams1).toHaveTextContent('0x2e0D7...5d09B'); - const approveDataParams2 = screen.getByTestId( + const approveDataParams2 = await screen.findByTestId( 'advanced-details-data-param-1', ); expect(dataSection).toContainElement(approveDataParams2); diff --git a/test/integration/confirmations/transactions/erc721-approve.test.tsx b/test/integration/confirmations/transactions/erc721-approve.test.tsx index c3948d150b1d..c158717cc9c9 100644 --- a/test/integration/confirmations/transactions/erc721-approve.test.tsx +++ b/test/integration/confirmations/transactions/erc721-approve.test.tsx @@ -163,10 +163,12 @@ describe('ERC721 Approve Confirmation', () => { }); expect( - screen.getByText(tEn('confirmTitleApproveTransaction') as string), + await screen.findByText(tEn('confirmTitleApproveTransaction') as string), ).toBeInTheDocument(); expect( - screen.getByText(tEn('confirmTitleDescApproveTransaction') as string), + await screen.findByText( + tEn('confirmTitleDescApproveTransaction') as string, + ), ).toBeInTheDocument(); }); @@ -181,7 +183,7 @@ describe('ERC721 Approve Confirmation', () => { }); }); - const simulationSection = screen.getByTestId( + const simulationSection = await screen.findByTestId( 'confirmation__simulation_section', ); expect(simulationSection).toBeInTheDocument(); @@ -192,7 +194,9 @@ describe('ERC721 Approve Confirmation', () => { expect(simulationSection).toHaveTextContent( tEn('simulationApproveHeading') as string, ); - const spendingCapValue = screen.getByTestId('simulation-token-value'); + const spendingCapValue = await screen.findByTestId( + 'simulation-token-value', + ); expect(simulationSection).toContainElement(spendingCapValue); expect(spendingCapValue).toHaveTextContent('1'); expect(simulationSection).toHaveTextContent('0x07614...3ad68'); @@ -211,16 +215,18 @@ describe('ERC721 Approve Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsSpender = screen.getByTestId( + const approveDetailsSpender = await screen.findByTestId( 'confirmation__approve-spender', ); expect(approveDetails).toContainElement(approveDetailsSpender); expect(approveDetailsSpender).toHaveTextContent(tEn('spender') as string); expect(approveDetailsSpender).toHaveTextContent('0x2e0D7...5d09B'); - const spenderTooltip = screen.getByTestId( + const spenderTooltip = await screen.findByTestId( 'confirmation__approve-spender-tooltip', ); expect(approveDetailsSpender).toContainElement(spenderTooltip); @@ -230,7 +236,7 @@ describe('ERC721 Approve Confirmation', () => { ); expect(spenderTooltipContent).toBeInTheDocument(); - const approveDetailsRequestFrom = screen.getByTestId( + const approveDetailsRequestFrom = await screen.findByTestId( 'transaction-details-origin-row', ); expect(approveDetails).toContainElement(approveDetailsRequestFrom); @@ -241,7 +247,7 @@ describe('ERC721 Approve Confirmation', () => { 'http://localhost:8086/', ); - const approveDetailsRequestFromTooltip = screen.getByTestId( + const approveDetailsRequestFromTooltip = await screen.findByTestId( 'transaction-details-origin-row-tooltip', ); expect(approveDetailsRequestFrom).toContainElement( @@ -269,10 +275,12 @@ describe('ERC721 Approve Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsRecipient = screen.getByTestId( + const approveDetailsRecipient = await screen.findByTestId( 'transaction-details-recipient-row', ); expect(approveDetails).toContainElement(approveDetailsRecipient); @@ -281,7 +289,7 @@ describe('ERC721 Approve Confirmation', () => { ); expect(approveDetailsRecipient).toHaveTextContent('0x07614...3ad68'); - const approveDetailsRecipientTooltip = screen.getByTestId( + const approveDetailsRecipientTooltip = await screen.findByTestId( 'transaction-details-recipient-row-tooltip', ); expect(approveDetailsRecipient).toContainElement( @@ -299,7 +307,7 @@ describe('ERC721 Approve Confirmation', () => { expect(approveDetails).toContainElement(approveMethodData); expect(approveMethodData).toHaveTextContent(tEn('methodData') as string); expect(approveMethodData).toHaveTextContent('Approve'); - const approveMethodDataTooltip = screen.getByTestId( + const approveMethodDataTooltip = await screen.findByTestId( 'transaction-details-method-data-row-tooltip', ); expect(approveMethodData).toContainElement(approveMethodDataTooltip); @@ -309,15 +317,17 @@ describe('ERC721 Approve Confirmation', () => { ); expect(approveMethodDataTooltipContent).toBeInTheDocument(); - const approveDetailsNonce = screen.getByTestId( + const approveDetailsNonce = await screen.findByTestId( 'advanced-details-nonce-section', ); expect(approveDetailsNonce).toBeInTheDocument(); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -326,14 +336,14 @@ describe('ERC721 Approve Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('Approve'); - const approveDataParams1 = screen.getByTestId( + const approveDataParams1 = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(approveDataParams1); expect(approveDataParams1).toHaveTextContent('Param #1'); expect(approveDataParams1).toHaveTextContent('0x2e0D7...5d09B'); - const approveDataParams2 = screen.getByTestId( + const approveDataParams2 = await screen.findByTestId( 'advanced-details-data-param-1', ); expect(dataSection).toContainElement(approveDataParams2); diff --git a/test/integration/confirmations/transactions/increase-allowance.test.tsx b/test/integration/confirmations/transactions/increase-allowance.test.tsx index c288a5cc4e6d..810477d3a3a5 100644 --- a/test/integration/confirmations/transactions/increase-allowance.test.tsx +++ b/test/integration/confirmations/transactions/increase-allowance.test.tsx @@ -167,10 +167,10 @@ describe('ERC20 increaseAllowance Confirmation', () => { }); expect( - screen.getByText(tEn('confirmTitlePermitTokens') as string), + await screen.findByText(tEn('confirmTitlePermitTokens') as string), ).toBeInTheDocument(); expect( - screen.getByText(tEn('confirmTitleDescPermitSignature') as string), + await screen.findByText(tEn('confirmTitleDescPermitSignature') as string), ).toBeInTheDocument(); }); @@ -185,7 +185,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { }); }); - const simulationSection = screen.getByTestId( + const simulationSection = await screen.findByTestId( 'confirmation__simulation_section', ); expect(simulationSection).toBeInTheDocument(); @@ -194,7 +194,9 @@ describe('ERC20 increaseAllowance Confirmation', () => { tEn('simulationDetailsERC20ApproveDesc') as string, ); expect(simulationSection).toHaveTextContent(tEn('spendingCap') as string); - const spendingCapValue = screen.getByTestId('simulation-token-value'); + const spendingCapValue = await screen.findByTestId( + 'simulation-token-value', + ); expect(simulationSection).toContainElement(spendingCapValue); expect(spendingCapValue).toHaveTextContent('1'); expect(simulationSection).toHaveTextContent('0x07614...3ad68'); @@ -213,16 +215,18 @@ describe('ERC20 increaseAllowance Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsSpender = screen.getByTestId( + const approveDetailsSpender = await screen.findByTestId( 'confirmation__approve-spender', ); expect(approveDetails).toContainElement(approveDetailsSpender); expect(approveDetailsSpender).toHaveTextContent(tEn('spender') as string); expect(approveDetailsSpender).toHaveTextContent('0x2e0D7...5d09B'); - const spenderTooltip = screen.getByTestId( + const spenderTooltip = await screen.findByTestId( 'confirmation__approve-spender-tooltip', ); expect(approveDetailsSpender).toContainElement(spenderTooltip); @@ -233,7 +237,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { ); expect(spenderTooltipContent).toBeInTheDocument(); - const approveDetailsRequestFrom = screen.getByTestId( + const approveDetailsRequestFrom = await screen.findByTestId( 'transaction-details-origin-row', ); expect(approveDetails).toContainElement(approveDetailsRequestFrom); @@ -242,7 +246,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { 'http://localhost:8086/', ); - const approveDetailsRequestFromTooltip = screen.getByTestId( + const approveDetailsRequestFromTooltip = await screen.findByTestId( 'transaction-details-origin-row-tooltip', ); expect(approveDetailsRequestFrom).toContainElement( @@ -268,7 +272,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { }); }); - const spendingCapSection = screen.getByTestId( + const spendingCapSection = await screen.findByTestId( 'confirmation__approve-spending-cap-section', ); expect(spendingCapSection).toBeInTheDocument(); @@ -277,14 +281,14 @@ describe('ERC20 increaseAllowance Confirmation', () => { tEn('accountBalance') as string, ); expect(spendingCapSection).toHaveTextContent('0'); - const spendingCapGroup = screen.getByTestId( + const spendingCapGroup = await screen.findByTestId( 'confirmation__approve-spending-cap-group', ); expect(spendingCapSection).toContainElement(spendingCapGroup); expect(spendingCapGroup).toHaveTextContent(tEn('spendingCap') as string); expect(spendingCapGroup).toHaveTextContent('1'); - const spendingCapGroupTooltip = screen.getByTestId( + const spendingCapGroupTooltip = await screen.findByTestId( 'confirmation__approve-spending-cap-group-tooltip', ); expect(spendingCapGroup).toContainElement(spendingCapGroupTooltip); @@ -310,10 +314,12 @@ describe('ERC20 increaseAllowance Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsRecipient = screen.getByTestId( + const approveDetailsRecipient = await screen.findByTestId( 'transaction-details-recipient-row', ); expect(approveDetails).toContainElement(approveDetailsRecipient); @@ -322,7 +328,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { ); expect(approveDetailsRecipient).toHaveTextContent('0x07614...3ad68'); - const approveDetailsRecipientTooltip = screen.getByTestId( + const approveDetailsRecipientTooltip = await screen.findByTestId( 'transaction-details-recipient-row-tooltip', ); expect(approveDetailsRecipient).toContainElement( @@ -340,7 +346,7 @@ describe('ERC20 increaseAllowance Confirmation', () => { expect(approveDetails).toContainElement(approveMethodData); expect(approveMethodData).toHaveTextContent(tEn('methodData') as string); expect(approveMethodData).toHaveTextContent('increaseAllowance'); - const approveMethodDataTooltip = screen.getByTestId( + const approveMethodDataTooltip = await screen.findByTestId( 'transaction-details-method-data-row-tooltip', ); expect(approveMethodData).toContainElement(approveMethodDataTooltip); @@ -350,15 +356,17 @@ describe('ERC20 increaseAllowance Confirmation', () => { ); expect(approveMethodDataTooltipContent).toBeInTheDocument(); - const approveDetailsNonce = screen.getByTestId( + const approveDetailsNonce = await screen.findByTestId( 'advanced-details-nonce-section', ); expect(approveDetailsNonce).toBeInTheDocument(); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -367,14 +375,14 @@ describe('ERC20 increaseAllowance Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('increaseAllowance'); - const approveDataParams1 = screen.getByTestId( + const approveDataParams1 = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(approveDataParams1); expect(approveDataParams1).toHaveTextContent('Param #1'); expect(approveDataParams1).toHaveTextContent('0x2e0D7...5d09B'); - const approveDataParams2 = screen.getByTestId( + const approveDataParams2 = await screen.findByTestId( 'advanced-details-data-param-1', ); expect(dataSection).toContainElement(approveDataParams2); diff --git a/test/integration/confirmations/transactions/set-approval-for-all.test.tsx b/test/integration/confirmations/transactions/set-approval-for-all.test.tsx index a65688030e90..ebe680983a6c 100644 --- a/test/integration/confirmations/transactions/set-approval-for-all.test.tsx +++ b/test/integration/confirmations/transactions/set-approval-for-all.test.tsx @@ -167,10 +167,14 @@ describe('ERC721 setApprovalForAll Confirmation', () => { }); expect( - screen.getByText(tEn('setApprovalForAllRedesignedTitle') as string), + await screen.findByText( + tEn('setApprovalForAllRedesignedTitle') as string, + ), ).toBeInTheDocument(); expect( - screen.getByText(tEn('confirmTitleDescApproveTransaction') as string), + await screen.findByText( + tEn('confirmTitleDescApproveTransaction') as string, + ), ).toBeInTheDocument(); }); @@ -185,7 +189,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { }); }); - const simulationSection = screen.getByTestId( + const simulationSection = await screen.findByTestId( 'confirmation__simulation_section', ); expect(simulationSection).toBeInTheDocument(); @@ -194,7 +198,9 @@ describe('ERC721 setApprovalForAll Confirmation', () => { tEn('simulationDetailsSetApprovalForAllDesc') as string, ); expect(simulationSection).toHaveTextContent(tEn('withdrawing') as string); - const spendingCapValue = screen.getByTestId('simulation-token-value'); + const spendingCapValue = await screen.findByTestId( + 'simulation-token-value', + ); expect(simulationSection).toContainElement(spendingCapValue); expect(spendingCapValue).toHaveTextContent(tEn('all') as string); expect(simulationSection).toHaveTextContent('0x07614...3ad68'); @@ -213,9 +219,11 @@ describe('ERC721 setApprovalForAll Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsSpender = screen.getByTestId( + const approveDetailsSpender = await screen.findByTestId( 'confirmation__approve-spender', ); @@ -224,7 +232,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { tEn('permissionFor') as string, ); expect(approveDetailsSpender).toHaveTextContent('0x2e0D7...5d09B'); - const spenderTooltip = screen.getByTestId( + const spenderTooltip = await screen.findByTestId( 'confirmation__approve-spender-tooltip', ); expect(approveDetailsSpender).toContainElement(spenderTooltip); @@ -235,7 +243,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { ); expect(spenderTooltipContent).toBeInTheDocument(); - const approveDetailsRequestFrom = screen.getByTestId( + const approveDetailsRequestFrom = await screen.findByTestId( 'transaction-details-origin-row', ); expect(approveDetails).toContainElement(approveDetailsRequestFrom); @@ -246,7 +254,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { 'http://localhost:8086/', ); - const approveDetailsRequestFromTooltip = screen.getByTestId( + const approveDetailsRequestFromTooltip = await screen.findByTestId( 'transaction-details-origin-row-tooltip', ); expect(approveDetailsRequestFrom).toContainElement( @@ -274,10 +282,12 @@ describe('ERC721 setApprovalForAll Confirmation', () => { }); }); - const approveDetails = screen.getByTestId('confirmation__approve-details'); + const approveDetails = await screen.findByTestId( + 'confirmation__approve-details', + ); expect(approveDetails).toBeInTheDocument(); - const approveDetailsRecipient = screen.getByTestId( + const approveDetailsRecipient = await screen.findByTestId( 'transaction-details-recipient-row', ); expect(approveDetails).toContainElement(approveDetailsRecipient); @@ -286,7 +296,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { ); expect(approveDetailsRecipient).toHaveTextContent('0x07614...3ad68'); - const approveDetailsRecipientTooltip = screen.getByTestId( + const approveDetailsRecipientTooltip = await screen.findByTestId( 'transaction-details-recipient-row-tooltip', ); expect(approveDetailsRecipient).toContainElement( @@ -304,7 +314,7 @@ describe('ERC721 setApprovalForAll Confirmation', () => { expect(approveDetails).toContainElement(approveMethodData); expect(approveMethodData).toHaveTextContent(tEn('methodData') as string); expect(approveMethodData).toHaveTextContent('setApprovalForAll'); - const approveMethodDataTooltip = screen.getByTestId( + const approveMethodDataTooltip = await screen.findByTestId( 'transaction-details-method-data-row-tooltip', ); expect(approveMethodData).toContainElement(approveMethodDataTooltip); @@ -314,15 +324,17 @@ describe('ERC721 setApprovalForAll Confirmation', () => { ); expect(approveMethodDataTooltipContent).toBeInTheDocument(); - const approveDetailsNonce = screen.getByTestId( + const approveDetailsNonce = await screen.findByTestId( 'advanced-details-nonce-section', ); expect(approveDetailsNonce).toBeInTheDocument(); - const dataSection = screen.getByTestId('advanced-details-data-section'); + const dataSection = await screen.findByTestId( + 'advanced-details-data-section', + ); expect(dataSection).toBeInTheDocument(); - const dataSectionFunction = screen.getByTestId( + const dataSectionFunction = await screen.findByTestId( 'advanced-details-data-function', ); expect(dataSection).toContainElement(dataSectionFunction); @@ -331,14 +343,14 @@ describe('ERC721 setApprovalForAll Confirmation', () => { ); expect(dataSectionFunction).toHaveTextContent('setApprovalForAll'); - const approveDataParams1 = screen.getByTestId( + const approveDataParams1 = await screen.findByTestId( 'advanced-details-data-param-0', ); expect(dataSection).toContainElement(approveDataParams1); expect(approveDataParams1).toHaveTextContent('Param #1'); expect(approveDataParams1).toHaveTextContent('0x2e0D7...5d09B'); - const approveDataParams2 = screen.getByTestId( + const approveDataParams2 = await screen.findByTestId( 'advanced-details-data-param-1', ); expect(dataSection).toContainElement(approveDataParams2); diff --git a/test/integration/notifications&auth/notifications-activation.test.tsx b/test/integration/notifications&auth/notifications-activation.test.tsx index e11e58dad320..d52921c386e8 100644 --- a/test/integration/notifications&auth/notifications-activation.test.tsx +++ b/test/integration/notifications&auth/notifications-activation.test.tsx @@ -70,7 +70,7 @@ describe('Notifications Activation', () => { const clickElement = async (testId: string) => { await act(async () => { - fireEvent.click(screen.getByTestId(testId)); + fireEvent.click(await screen.findByTestId(testId)); }); }; @@ -105,7 +105,7 @@ describe('Notifications Activation', () => { }); await act(async () => { - fireEvent.click(screen.getByText('Turn on')); + fireEvent.click(await screen.findByText('Turn on')); }); await waitFor(() => { @@ -148,7 +148,7 @@ describe('Notifications Activation', () => { await act(async () => { fireEvent.click( - within(screen.getByRole('dialog')).getByRole('button', { + await within(screen.getByRole('dialog')).findByRole('button', { name: 'Close', }), ); diff --git a/test/integration/notifications&auth/notifications-list.test.tsx b/test/integration/notifications&auth/notifications-list.test.tsx index 4e17a53db107..e4c1d6f20107 100644 --- a/test/integration/notifications&auth/notifications-list.test.tsx +++ b/test/integration/notifications&auth/notifications-list.test.tsx @@ -77,8 +77,8 @@ describe('Notifications List', () => { }); }); - await waitFor(() => { - const unreadCount = screen.getByTestId( + await waitFor(async () => { + const unreadCount = await screen.findByTestId( 'notifications-tag-counter__unread-dot', ); expect(unreadCount).toBeInTheDocument(); @@ -96,30 +96,36 @@ describe('Notifications List', () => { }); }); - fireEvent.click(screen.getByTestId('account-options-menu-button')); + fireEvent.click(await screen.findByTestId('account-options-menu-button')); - await waitFor(() => { - expect(screen.getByTestId('notifications-menu-item')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('notifications-menu-item')); + await waitFor(async () => { + expect( + await screen.findByTestId('notifications-menu-item'), + ).toBeInTheDocument(); + fireEvent.click(await screen.findByTestId('notifications-menu-item')); }); - await waitFor(() => { - const notificationsList = screen.getByTestId('notifications-list'); + await waitFor(async () => { + const notificationsList = await screen.findByTestId('notifications-list'); expect(notificationsList).toBeInTheDocument(); expect(notificationsList.childElementCount).toBe(3); // Feature notification details expect( - within(notificationsList).getByText(featureNotification.data.title), + await within(notificationsList).findByText( + featureNotification.data.title, + ), ).toBeInTheDocument(); expect( - within(notificationsList).getByText( + await within(notificationsList).findByText( featureNotification.data.shortDescription, ), ).toBeInTheDocument(); // Eth sent notification details - const sentToElement = within(notificationsList).getByText('Sent to'); + const sentToElement = await within(notificationsList).findByText( + 'Sent to', + ); expect(sentToElement).toBeInTheDocument(); const addressElement = sentToElement.nextElementSibling; @@ -127,12 +133,12 @@ describe('Notifications List', () => { // Read all button expect( - within(notificationsList).getByTestId( + await within(notificationsList).findByTestId( 'notifications-list-read-all-button', ), ).toBeInTheDocument(); - const unreadDot = screen.getAllByTestId('unread-dot'); + const unreadDot = await screen.findAllByTestId('unread-dot'); expect(unreadDot).toHaveLength(2); }); @@ -178,17 +184,19 @@ describe('Notifications List', () => { backgroundConnection: backgroundConnectionMocked, }); - fireEvent.click(screen.getByTestId('account-options-menu-button')); + fireEvent.click(await screen.findByTestId('account-options-menu-button')); - await waitFor(() => { + await waitFor(async () => { expect( - screen.getByTestId('notifications-menu-item'), + await screen.findByTestId('notifications-menu-item'), ).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('notifications-menu-item')); + fireEvent.click(await screen.findByTestId('notifications-menu-item')); }); - await waitFor(() => { - const notificationsList = screen.getByTestId('notifications-list'); + await waitFor(async () => { + const notificationsList = await screen.findByTestId( + 'notifications-list', + ); expect(notificationsList).toBeInTheDocument(); expect(notificationsList.childElementCount).toBe(2); @@ -211,14 +219,18 @@ describe('Notifications List', () => { }); }); - fireEvent.click(screen.getByTestId('account-options-menu-button')); + fireEvent.click(await screen.findByTestId('account-options-menu-button')); - await waitFor(() => { - expect(screen.getByTestId('notifications-menu-item')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('notifications-menu-item')); + await waitFor(async () => { + expect( + await screen.findByTestId('notifications-menu-item'), + ).toBeInTheDocument(); + fireEvent.click(await screen.findByTestId('notifications-menu-item')); }); - fireEvent.click(screen.getByTestId('notifications-list-read-all-button')); + fireEvent.click( + await screen.findByTestId('notifications-list-read-all-button'), + ); await waitFor(() => { const markAllAsReadEvent = diff --git a/test/integration/notifications&auth/notifications-toggle.test.tsx b/test/integration/notifications&auth/notifications-toggle.test.tsx index 8133e4c4bc3d..fd4c11ec4494 100644 --- a/test/integration/notifications&auth/notifications-toggle.test.tsx +++ b/test/integration/notifications&auth/notifications-toggle.test.tsx @@ -48,13 +48,13 @@ describe('Notifications Toggle', () => { const clickElement = async (testId: string) => { await act(async () => { - fireEvent.click(screen.getByTestId(testId)); + fireEvent.click(await screen.findByTestId(testId)); }); }; const waitForElement = async (testId: string) => { - await waitFor(() => { - expect(screen.getByTestId(testId)).toBeInTheDocument(); + await waitFor(async () => { + expect(await screen.findByTestId(testId)).toBeInTheDocument(); }); }; @@ -73,12 +73,12 @@ describe('Notifications Toggle', () => { await clickElement('notifications-settings-button'); await waitForElement('notifications-settings-allow-notifications'); - const toggleSection = screen.getByTestId( + const toggleSection = await screen.findByTestId( 'notifications-settings-allow-notifications', ); await act(async () => { - fireEvent.click(within(toggleSection).getByRole('checkbox')); + fireEvent.click(await within(toggleSection).findByRole('checkbox')); }); await waitFor(() => { @@ -159,7 +159,7 @@ describe('Notifications Toggle', () => { await clickElement('notifications-settings-button'); await waitForElement('notifications-settings-allow-notifications'); - const allToggles = screen.getAllByTestId('test-toggle'); + const allToggles = await screen.findAllByTestId('test-toggle'); await act(async () => { fireEvent.click(allToggles[1]); diff --git a/test/integration/onboarding/wallet-created.test.tsx b/test/integration/onboarding/wallet-created.test.tsx index 55be476839fe..c1ddb1f1886a 100644 --- a/test/integration/onboarding/wallet-created.test.tsx +++ b/test/integration/onboarding/wallet-created.test.tsx @@ -31,14 +31,15 @@ describe('Wallet Created Events', () => { }); it('are sent when onboarding user who chooses to opt in metrics', async () => { - const { getByTestId, getByText } = await integrationTestRender({ - preloadedState: mockMetaMaskState, - backgroundConnection: backgroundConnectionMocked, - }); + const { getByTestId, findByTestId, getByText, findByText } = + await integrationTestRender({ + preloadedState: mockMetaMaskState, + backgroundConnection: backgroundConnectionMocked, + }); - expect(getByText('Congratulations!')).toBeInTheDocument(); + expect(await findByText('Congratulations!')).toBeInTheDocument(); - fireEvent.click(getByTestId('onboarding-complete-done')); + fireEvent.click(await findByTestId('onboarding-complete-done')); await waitFor(() => { expect(getByTestId('onboarding-pin-extension')).toBeInTheDocument(); @@ -69,7 +70,7 @@ describe('Wallet Created Events', () => { ]), ); - fireEvent.click(getByTestId('pin-extension-next')); + fireEvent.click(await findByTestId('pin-extension-next')); let onboardingPinExtensionMetricsEvent; @@ -91,7 +92,7 @@ describe('Wallet Created Events', () => { ).toBeInTheDocument(); }); - fireEvent.click(getByTestId('pin-extension-done')); + fireEvent.click(await findByTestId('pin-extension-done')); await waitFor(() => { const completeOnboardingBackgroundRequest = diff --git a/ui/components/multichain/pages/index.js b/ui/components/multichain/pages/index.js deleted file mode 100644 index a19f23039138..000000000000 --- a/ui/components/multichain/pages/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { Connections } from './connections'; -export { PermissionsPage } from './permissions-page/permissions-page'; -export { ReviewPermissions, SiteCell } from './review-permissions-page'; diff --git a/ui/components/multichain/pages/review-permissions-page/index.js b/ui/components/multichain/pages/review-permissions-page/index.js deleted file mode 100644 index e2da178368f1..000000000000 --- a/ui/components/multichain/pages/review-permissions-page/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { ReviewPermissions } from './review-permissions-page'; -export { SiteCell } from './site-cell/site-cell'; diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx index b2da4553ce50..a886d26e77e6 100644 --- a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ReviewPermissions } from '.'; +import { ReviewPermissions } from './review-permissions-page'; export default { title: 'Components/Multichain/ReviewPermissions', diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx index b644c16b6440..55f7ab9bf332 100644 --- a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderWithProvider } from '../../../../../test/jest/rendering'; import mockState from '../../../../../test/data/mock-state.json'; import configureStore from '../../../../store/store'; -import { ReviewPermissions } from '.'; +import { ReviewPermissions } from './review-permissions-page'; const render = (state = {}) => { const store = configureStore({ diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx index f65dd7a662cf..95a8ea394000 100644 --- a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx @@ -54,7 +54,7 @@ import { PermissionsHeader } from '../../permissions-header/permissions-header'; import { mergeAccounts } from '../../account-list-menu/account-list-menu'; import { MergedInternalAccount } from '../../../../selectors/selectors.types'; import { TEST_CHAINS } from '../../../../../shared/constants/network'; -import { SiteCell } from '.'; +import { SiteCell } from './site-cell/site-cell'; export const ReviewPermissions = () => { const t = useI18nContext(); diff --git a/ui/helpers/utils/mm-lazy.ts b/ui/helpers/utils/mm-lazy.ts new file mode 100644 index 000000000000..e31c22dfc99a --- /dev/null +++ b/ui/helpers/utils/mm-lazy.ts @@ -0,0 +1,86 @@ +import React from 'react'; +// eslint-disable-next-line import/no-restricted-paths +import { getManifestFlags } from '../../../app/scripts/lib/manifestFlags'; +import { endTrace, trace, TraceName } from '../../../shared/lib/trace'; + +type DynamicImportType = () => Promise<{ default: React.ComponentType }>; +type ModuleWithDefaultType = { + default: React.ComponentType; +}; + +// This only has to happen once per app load, so do it outside a function +const lazyLoadSubSampleRate = getManifestFlags().sentry?.lazyLoadSubSampleRate; + +/** + * A wrapper around React.lazy that adds two things: + * 1. Sentry tracing for how long it takes to load the component (not render, just load) + * 2. React.lazy can only deal with default exports, but the wrapper can handle named exports too + * + * @param fn - an import of the form `() => import('AAA')` + */ +export function mmLazy(fn: DynamicImportType) { + return React.lazy(async () => { + // We can't start the trace here because we don't have the componentName yet, so we just hold the startTime + const startTime = Date.now(); + + const importedModule = await fn(); + const { componentName, component } = parseImportedComponent(importedModule); + + // Only trace load time of lazy-loaded components if the manifestFlag is set, and then do it by Math.random probability + if (lazyLoadSubSampleRate && Math.random() < lazyLoadSubSampleRate) { + trace({ + name: TraceName.LazyLoadComponent, + data: { componentName }, + startTime, + }); + + endTrace({ name: TraceName.LazyLoadComponent }); + } + + return component; + }); +} + +// There can be a lot of different types here, and we're basically doing type-checking in the code, +// so I don't think TypeScript safety on `importedModule` is worth it in this function +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseImportedComponent(importedModule: any): { + componentName: string; // TODO: in many circumstances, the componentName gets minified + component: ModuleWithDefaultType; +} { + let componentName: string; + + // If there's no default export + if (!importedModule.default) { + const keys = Object.keys(importedModule); + + // If there's only one named export + if (keys.length === 1) { + componentName = keys[0]; + + return { + componentName, + // Force the component to be the default export + component: { default: importedModule[componentName] }, + }; + } + + // If there are multiple named exports, this isn't good for tree-shaking, so throw an error + throw new Error( + 'mmLazy: You cannot lazy-load a component when there are multiple exported components in one file', + ); + } + + if (importedModule.default.WrappedComponent) { + // If there's a wrapped component, we don't want to see the name reported as `withRouter(Connect(AAA))` we want just `AAA` + componentName = importedModule.default.WrappedComponent.name; + } else { + componentName = + importedModule.default.name || importedModule.default.displayName; + } + + return { + componentName, + component: importedModule, + }; +} diff --git a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js index ef08fb6bbba1..f5858e853dd1 100644 --- a/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js +++ b/ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js @@ -133,9 +133,7 @@ const ConfirmTransaction = () => { }); useEffect(() => { - if (!totalUnapproved && !sendTo) { - history.replace(mostRecentOverviewPage); - } else { + if (totalUnapproved || sendTo) { const { txParams: { data } = {}, origin } = transaction; if (origin !== ORIGIN_METAMASK) { diff --git a/ui/pages/confirmations/confirm-transaction/confirm-transaction.test.js b/ui/pages/confirmations/confirm-transaction/confirm-transaction.test.js index fbd8840de4ec..3c374fd7d0c0 100644 --- a/ui/pages/confirmations/confirm-transaction/confirm-transaction.test.js +++ b/ui/pages/confirmations/confirm-transaction/confirm-transaction.test.js @@ -270,26 +270,5 @@ describe('Confirmation Transaction Page', () => { expect(replaceSpy).not.toHaveBeenCalled(); }); }); - - describe('when no unapproved transactions and no sendTo recipient exist', () => { - it('should call history.replace(mostRecentOverviewPage)', () => { - const mockStore = configureMockStore(middleware)({ - ...mockState, - metamask: { - ...mockState.metamask, - transactions: [], - }, - }); - const replaceSpy = jest.fn(); - jest.spyOn(ReactRouterDOM, 'useHistory').mockImplementation(() => { - return { - replace: replaceSpy, - }; - }); - - renderWithProvider(, mockStore, '/asdfb'); - expect(replaceSpy).toHaveBeenCalled(); - }); - }); }); }); diff --git a/ui/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index ba9bcc6bf674..99791a8d5333 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -22,7 +22,7 @@ import { Header, Page, } from '../../../components/multichain/pages/page'; -import { SiteCell } from '../../../components/multichain/pages/review-permissions-page'; +import { SiteCell } from '../../../components/multichain/pages/review-permissions-page/site-cell/site-cell'; import { BackgroundColor, BlockSize, diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index bce88a9f9236..206998cf82ba 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -1,27 +1,12 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React, { Component, Suspense } from 'react'; import { matchPath, Route, Switch } from 'react-router-dom'; import IdleTimer from 'react-idle-timer'; -import Swaps from '../swaps'; -import ConfirmTransaction from '../confirmations/confirm-transaction'; -import Home from '../home'; -import { - PermissionsPage, - Connections, - ReviewPermissions, -} from '../../components/multichain/pages'; -import Settings from '../settings'; import Authenticated from '../../helpers/higher-order-components/authenticated'; import Initialized from '../../helpers/higher-order-components/initialized'; -import Lock from '../lock'; import PermissionsConnect from '../permissions-connect'; -import RestoreVaultPage from '../keychains/restore-vault'; -import RevealSeedConfirmation from '../keychains/reveal-seed'; -import ConfirmAddSuggestedTokenPage from '../confirm-add-suggested-token'; -import CreateAccountPage from '../create-account/create-account.component'; -import ConfirmAddSuggestedNftPage from '../confirm-add-suggested-nft'; import Loading from '../../components/ui/loading-screen'; import LoadingNetwork from '../../components/app/loading-network-screen'; import { Modal } from '../../components/app/modals'; @@ -34,15 +19,8 @@ import { ImportNftsModal, ImportTokensModal, } from '../../components/multichain'; -import UnlockPage from '../unlock-page'; import Alerts from '../../components/app/alerts'; -import Asset from '../asset'; import OnboardingAppHeader from '../onboarding-flow/onboarding-app-header/onboarding-app-header'; -import Notifications from '../notifications'; -import NotificationsSettings from '../notifications-settings'; -import NotificationDetails from '../notification-details'; -import SnapList from '../snaps/snaps-list'; -import SnapView from '../snaps/snap-view'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import InstitutionalEntityDonePage from '../institutional/institutional-entity-done-page'; import InteractiveReplacementTokenNotification from '../../components/institutional/interactive-replacement-token-notification'; @@ -95,8 +73,6 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../app/scripts/lib/util'; -import ConfirmationPage from '../confirmations/confirmation'; -import OnboardingFlow from '../onboarding-flow/onboarding-flow'; import QRHardwarePopover from '../../components/app/qr-hardware-popover'; import DeprecatedNetworks from '../../components/ui/deprecated-networks/deprecated-networks'; import NewNetworkInfo from '../../components/ui/new-network-info/new-network-info'; @@ -107,13 +83,11 @@ import { BasicConfigurationModal } from '../../components/app/basic-configuratio import KeyringSnapRemovalResult from '../../components/app/modals/keyring-snap-removal-modal'; ///: END:ONLY_INCLUDE_IF -import { SendPage } from '../../components/multichain/pages/send'; import { DeprecatedNetworkModal } from '../settings/deprecated-network-modal/DeprecatedNetworkModal'; import { MultichainMetaFoxLogo } from '../../components/multichain/app-header/multichain-meta-fox-logo'; import NetworkConfirmationPopover from '../../components/multichain/network-list-menu/network-confirmation-popover/network-confirmation-popover'; -import NftFullImage from '../../components/app/assets/nfts/nft-details/nft-full-image'; -import CrossChainSwap from '../bridge'; import { ToastMaster } from '../../components/app/toast-master/toast-master'; +import { mmLazy } from '../../helpers/utils/mm-lazy'; import { InternalAccountPropType } from '../../selectors/multichain'; import { isCurrentChainCompatibleWithAccount } from '../../../shared/lib/multichain'; import { @@ -128,6 +102,54 @@ import { showOnboardingHeader, } from './utils'; +// Begin Lazy Routes +const OnboardingFlow = mmLazy(() => + import('../onboarding-flow/onboarding-flow'), +); +const Lock = mmLazy(() => import('../lock')); +const UnlockPage = mmLazy(() => import('../unlock-page')); +const RestoreVaultPage = mmLazy(() => import('../keychains/restore-vault')); +const RevealSeedConfirmation = mmLazy(() => import('../keychains/reveal-seed')); +const Settings = mmLazy(() => import('../settings')); +const NotificationsSettings = mmLazy(() => import('../notifications-settings')); +const NotificationDetails = mmLazy(() => import('../notification-details')); +const Notifications = mmLazy(() => import('../notifications')); +const SnapList = mmLazy(() => import('../snaps/snaps-list')); +const SnapView = mmLazy(() => import('../snaps/snap-view')); +const ConfirmTransaction = mmLazy(() => + import('../confirmations/confirm-transaction'), +); +const SendPage = mmLazy(() => import('../../components/multichain/pages/send')); +const Swaps = mmLazy(() => import('../swaps')); +const CrossChainSwap = mmLazy(() => import('../bridge')); +const ConfirmAddSuggestedTokenPage = mmLazy(() => + import('../confirm-add-suggested-token'), +); +const ConfirmAddSuggestedNftPage = mmLazy(() => + import('../confirm-add-suggested-nft'), +); +const ConfirmationPage = mmLazy(() => import('../confirmations/confirmation')); +const CreateAccountPage = mmLazy(() => + import('../create-account/create-account.component'), +); +const NftFullImage = mmLazy(() => + import('../../components/app/assets/nfts/nft-details/nft-full-image'), +); +const Asset = mmLazy(() => import('../asset')); +const PermissionsPage = mmLazy(() => + import('../../components/multichain/pages/permissions-page/permissions-page'), +); +const Connections = mmLazy(() => + import('../../components/multichain/pages/connections'), +); +const ReviewPermissions = mmLazy(() => + import( + '../../components/multichain/pages/review-permissions-page/review-permissions-page' + ), +); +const Home = mmLazy(() => import('../home')); +// End Lazy Routes + export default class Routes extends Component { static propTypes = { currentCurrency: PropTypes.string, @@ -271,122 +293,127 @@ export default class Routes extends Component { const RestoreVaultComponent = forgottenPassword ? Route : Initialized; const routes = ( - - - - - - - - - - - - - - - - - - - - { - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - } - - - - - - - { - ///: END:ONLY_INCLUDE_IF - } - - - - - - - - - - - - + + {/* since the loading time is less than 200ms, we decided not to show a spinner fallback or anything */} + + + + + + + + + + + + + + + + + + + + { + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + } + + + + + + + { + ///: END:ONLY_INCLUDE_IF + } + + + + + + + + + + + + ); if (autoLockTimeLimit > 0) { diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js index e162f84ba40f..1983a0143174 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js @@ -178,12 +178,15 @@ describe('AddContact component', () => { expect(saveButton).toBeDisabled(); }); - it('should display error message when name entered is an existing account name', () => { + it('should display error message when name entered is an existing account name', async () => { const duplicateName = 'Account 1'; const store = configureMockStore(middleware)(state); - const { getByText } = renderWithProvider(, store); + const { getByText, findByText } = renderWithProvider( + , + store, + ); const nameInput = document.getElementById('nickname'); @@ -191,7 +194,7 @@ describe('AddContact component', () => { const saveButton = getByText('Save'); - expect(getByText('Name is already in use')).toBeDefined(); + expect(await findByText('Name is already in use')).toBeDefined(); expect(saveButton).toBeDisabled(); }); @@ -212,10 +215,10 @@ describe('AddContact component', () => { expect(saveButton).toBeDisabled(); }); - it('should display error when ENS inserts a name that is already in use', () => { + it('should display error when ENS inserts a name that is already in use', async () => { const store = configureMockStore(middleware)(state); - const { getByTestId, getByText } = renderWithProvider( + const { getByTestId, getByText, findByText } = renderWithProvider( , store, ); @@ -231,7 +234,7 @@ describe('AddContact component', () => { const saveButton = getByText('Save'); - expect(getByText('Name is already in use')).toBeDefined(); + expect(await findByText('Name is already in use')).toBeDefined(); expect(saveButton).toBeDisabled(); }); }); From 652afc3961b7e038b2560af282bda395425208d5 Mon Sep 17 00:00:00 2001 From: Priya Date: Mon, 25 Nov 2024 22:06:52 +0700 Subject: [PATCH 065/148] test: blockaid e2e test for contract interaction (#28156) 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/28156?quickstart=1) ## **Related issues** Fixes: [#2802](https://github.com/MetaMask/MetaMask-planning/issues/2802) ## **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. --- .../mock-cdn/cdn-stale-diff-res-headers.json | 2 +- test/e2e/mock-cdn/cdn-stale-diff.txt | Bin 170935 -> 965977 bytes test/e2e/mock-cdn/ppom-version-headers.json | 2 +- test/e2e/mock-cdn/ppom-version.json | 306 +++++++++--------- .../tests/ppom/mocks/mock-server-json-rpc.ts | 15 +- ...lockaid-alert-contract-interaction.spec.js | 238 ++++++++++++++ 6 files changed, 406 insertions(+), 157 deletions(-) create mode 100644 test/e2e/tests/ppom/ppom-blockaid-alert-contract-interaction.spec.js diff --git a/test/e2e/mock-cdn/cdn-stale-diff-res-headers.json b/test/e2e/mock-cdn/cdn-stale-diff-res-headers.json index 0fb0ec0f7d89..786436052a14 100644 --- a/test/e2e/mock-cdn/cdn-stale-diff-res-headers.json +++ b/test/e2e/mock-cdn/cdn-stale-diff-res-headers.json @@ -1,4 +1,4 @@ { "Content-Type": "text/plain", - "Etag": "W/\"5ae8a43f84ccd89e8ddc79b1dfed0035\"" + "Etag": "W/\"24396be8aa89668e00ba9f795d7753e6\"" } diff --git a/test/e2e/mock-cdn/cdn-stale-diff.txt b/test/e2e/mock-cdn/cdn-stale-diff.txt index 44eb67f85fa4afa1acd35bbdc3457e97dc8c30bc..9d9b4e83e97bc56900c292790947c415c00dd25a 100644 GIT binary patch literal 965977 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

mPa_z6R4`XE(Ztvnn`r|h+mb7TgM z$naJD1Re$O#LwqMXazn)63@R1?##?G=yCsB;g!9zhK;ut&c`phnPbsZHBUS+Bpyvf ze~%Wf6U-o&h-dWY8=H&R{Hs0t;j%m@7@^CK`y|O}kZ3S6Y zSA1@F=bwa9hoF@lS6CZ) z@QT-;q#4LJa{dnbQl}3``P{N{!KW$kqd4QJNgr;7lV0y0$~N9z!B=JnxuZeflTe^o z81Z>!ksPj_542MZ2(&RVO`DhWJxv0{Ju!1HZ0geSA9EZV(;$->b}nCf!)vS-i@W9B zHnY zkG~0;;R16mySC_Z3E1U*y}pIYk>gxskD`irE(L-IPOWeG7u`i2N%NlmNUtj9S^hik zdr;R7AC@##7zrL|p&><`CxC)Q4j(DSXp}|X?%-f+B;BgQngagw8*53v-Xw>B*;gg} z_pk9HszYvSp8ruimQJ+g)$?ADgZwid?gaIJU+_i$7GdlC?Pxd|bv0Zyu_eHlt9AOq zuD4_0XmBd*_vG3x-sTFOAB?HF3y*fw+0;6-2@v}q5|>e=)q&ZC1saF;xGkuX4so#7 zv%JUGA)Ut`zUF9Pgw$Xz>`}`pxMz5N-}qD{flFO?>&53zwxKz)aMhypgaDWnG&c+v z4hdrZg!)Y)UPA@cF-kbm{2rzPh0kWb{XN+mxF|1po3?j{8*fLrj{ehYp~iRqVfm7% z%fWc_sUy~!rhEcJl`M})y$lF(#z{_koyhPFDpU2oeiW^q!h=PV6F%e4p*XvJLveId z?;^w<3J&UhE7wK5Evp)XL75jwUB9~!aP%4CiKo?V8NoJoSe-5W#;?OEh2DmF>sN~! zLs-pzbnLx*^a~KUDYsBRsUqD+tK)r+sXw^zRc8C=ebVy}pf&2JBTnEAx&p``L|ivdWi>@99rALCn-NI2OgOy#CVoVG3z(_LM>P6EB|@J zqmb|V*}wMQm>Bf(-$^Ko4M|~|a@IUY+b|f`|4xg9HFC;j6s+t=OxQZy{C`BG4_`Anzz^C zO{6+^N$EdjXuTaLpJJGN2mO4nY{I3o1?zyb5RKTr$Eu(60pR))bHm@b~t&4uVcruBc*P5kiF z?$r*#g>m1bcV6$3Z6@OHStd$XXIt08CG_baBuJEZehpt5O%H3 z#;AH(e21VK&rbDNPPcS1&0TCDxz8!BbA|#iFC`kO?qR zi97K3FnVrmuK66*Bt$;%oJpF)w?!NzNtwEr_h~;lxj=S@X8Rl!%*)E27innrKrAw7 z>i&FI2E@oaq?E(&9zwH${7I@yA$d4kxX7KegL+U!98>c&a~8$4i_pc(>}2P#o^;1s z(ML@HkBN%bPYCZ^huXWSx=_0Q3$XiRQs|&=ZUqg)+DZK@a=fp-Oy$8RX zB8NdPdpPHqK_?N?cRLG0&ZpSJ4Fk8rr$esdwFzHm^hg~&p6Pw@CRes2L`!$tdXT&3 zE*gf+BVVmene1!h20xnSru|*HC`Udho^cEcwwyO|-K>ML*6O99z`73?^xp9bKd61r zip6UTd-HU3H{c@IbnEA4=obV>*=@|eyPpr4W}ay4euI7aR8?-j%QBt z$oyLPs9V4}gfpx`u=U9uK%>Kd;Ul+Mt-!TGa_HEi;R;lDTZ&$k7}?;4s}ku;A6rc* zm6%qERX(u9`6Asm$?FmpSRg*k?WDfI2^WRBvhVXpUqR37<|Aj3EBnf-vWBHwFF*of z$-^!$O|H@4N@x3vXY4n8z#~2Qj-WqG8t)n@a=inaT@by)5_an+-!QKIoJ(!8`MU^` z;Ugz=>5jcdTe5{vdUMJ@NSl%=T>nX)h#sp&&H}a%)DRVQaf=n{azIx6RSh-#yZ|FYgbZN(HX zm9Uh>PARNkeS29&_VySmwLXb%6^VCYD>SAodF9L&)C-1>zfy^5MxJJ0<^)6M>|jLcHF?u)96td)I-f9A{RcX@%(e8jB>G7_s)D$+OWg{Bv3RQ3 zb*aq00G{`_X76$L97HvVIOE5W&V%^IrfS4}=|LtwUl42gaOf!~IzFe{&i`zeL;DlK z_JTWs+IXxPlIyKZWnmlVB(S- zWkPI6jqUTP!KdGBAA)mKUH-S(&`wCSM6DJTdC($?LfX>m_7`t#KFQjlWGq-h0;$E6 zNVIzjUfvpQP$uoS!RO%HF%LG034mXRe>6PfNJNgx=bB*)MMk z#QCbxxFuYDUGco86KCR{$Nl^@qyazq%szSs_}$q|CeNF=q3^XPq~OOnfIOgXmwiEXTPhYi7~S~ud^9AgH26$ z!*R~#jw7yE2*Y?*OY*9t2p8N}QFlB7CiHtP=4*fix#xJvyGT_OT+~yRYSjK!w-|*?f>1Q!#rz%OT?L-a= z)x5uVE7dJw?jGA4wayq1`L8mW-hrPjU>N+L-~rtqbx0K;WGYn=E64DaH-k0@WbAP( z8PpygbGs0X+((T(Y+PRbcD<(X1e@fC+h`ju09+)m z&ljAPrUv7wx_Szpp)L?6-R$t2{UL+@sy8WuKZ|a``f}B%R=pT2$d4CL{Wrj`hq~J0 zzu!+T*1);C&16-cG7REhJ#AWI>E2>HtcBbZ}Jb&apbiHr(%1$xv3!&r922s@EVesqt^6;UOOdF~jdOUk5 ziRhtqW@UJTCHV&Ux?Vi7op~k!4*%g-k5sA7BI9t+gEa|pb(GWYC_K5AbqNkN&t7wV z3V)6Qm#HbcfDZ2xsRl;M=Fi7O&E~#UVFAW3e~q z){3Ow36E9k53TszCe9tgC}oKVzwvD8*EAFm=pj@3EL@(6QyrvjBWa{su#n>4=45MF zz*GAE1Iq1OY4{-iRAnblQ5w^ZZ;Bk1ULV4^TZwfiYsU=4bz1VhZ($AYmq$z{KM;?@ zbK3UCAL|!+7;jXZIX1XV3(GYT&c@%gCfMi*kJ~&=$_eKFR%Oz~9#R}BwMZ`AyGw_w zgU^}S=g;oJbdRl!%QN2}=lPYQJLJ^j@l(@u@KD30yV$kkp*kzHZ3AJKFyEdG<@b2) zmlkc?x*UzVk7cC-Epjf<+051Iqs!{U_Nt-R>8J;GcvD&$|02y;9^r>m6-j0tv%r(Q zqI!a(C>1xR1p1Bt>e9kq_FB>8<;MaT51`Ip&AUO5@;f}LlKQmvpskhEJKmlDVLwpH zn|9?c(FUI(r~R<*-CBgb(LR(~P?C;36VZu;@Z2>t3vbFAHmkhFTNTP2yLhQ=+`naZ zt$RV#8|6}~Dm$!sR(GM&Oq7};UFDmc5#^e9ykeIh# zy{H~@6m&wZ-1HOCtN3)^=UHGxyFPe7iV1)H+dhM1MD{#N!inmbRU9w2{BnKWg{zz8Q(hyJe}M@)x9R0Xk)37P%;+D}PH{!qkP3)8Dk4qU4_7R_()k zUkiFk@TRWvuN|Mb5>)b6qGNeV&G7JYa82=`btQ&L7GA`C`bP>$6GF$Ik0w{KHL@kg zRej$S216m^2}i%vqVwLKgKS<98_u)%a8SjV>LZFoGd;BQb{tM`5Q@FUnYn#n&BMxW z+I|^gvR#c8AzloqvHP(lx0^x@4KzCqycRl&VfTiT)Ldp(ux<`DyLxgJqKZ36`xF1J z3j~+G1*o+1)qy#Xwpv_^=QIS`Bz}3;Sk{A;+q5c-pg|5YhV=$YetadEk_qKb>xvNq zxnhSnY2v3%%x)PB%+!a}L788R!RY5T0w`BRwb(dPzsKP^zGnZ(uotkNb39%8wB`)Z z(|0s1us{&5a@v2jn}mz7H!AIQL$QXpt4#c}}F10EzjcPB5QaC2g$ z{`GDfj%-J8Q1p|ffSX(UkVNez511R8@uYNY7bCR9drD=v(h7P~KMr=5oo$4YG^b|G zcF68>S@E_v@s|AVQyWHs4 zb!eAK-@4tetqJ~I>avZs+AH|dQ1xc&;pH{dO*jc3yGZPCx+A_h-U73c|GkAtsf(EGEIVD3-(V>Gg z5qce;Oyk+{o`pJrPiw>)Q6Z;(uYIa01vk~(l8+Q)ML2rv!-0x*lF!(aql;HqE)_$d zcw}#=PaheC?u{LI$#RVgo>Z0wCm)i=V?FWYQMP_#KCBnz)Q+c9tl;N}j@ z`|Ecm_JcjHexzn|-kwT9t8&x5(fpsZSYo1`BtB>)1v#--3g-o`#i5NT^<*bdRn^&3rkknN9f-C#*gbCh;=UL%t44L z)b=E2^A^-B)GdQjoR#qSDZRpd{G?Ke+o;Ty@%{tueYrAO9MZj#IgAwfoU<`^@=EdRaY%x8-DD%Eo$T-Y)%G~mKS8oP)n8MpHWHX<_x=iic z(%(B{An@{iTXofs0e7y|Cp2yT`3>DQ3bFGdas2=PZ0FZkub57J3aBADRcvO2r>^H% zrZZkq!`+(u-Z|12w_$Zdl&9{47A zL#GXV&^S*C(r0YUIRCMNw78aT36oU`{@!bIg?O~e#kRwtpNj-tLsIUWC&^(&lGJ~8 zj+YfTG$UsgIB&4;r@N8DE7@yoP!TX&l^Pmx1l_Ou6GAo;=}3LY{qf&oBoi7&g#U$1 z-e$z=;;(yl3GSEhtf-3PZ!NhssyH`V1adkqgbT-XPEJacVMDQqiAwldk zUkClS4ma(IWv}DNrRLI8zr7@|&06xXiiS87U2tYjS(a>un8_=)li}XfxTL2bsKyyo z4W|>Q2Bx01eTN~zkrzJRmd_9#A{8rk&QlKbJmz+M2g?|t8N+-gCbF3r=k*xAUWsL^ z!UxS}r=Zt)E~vPlP=C8@kriKUuJjNTKKY8^_Cwm2-IxAgw%qB>Q}yCmyzOs3u2t54 z07ceUp7$jGhyyWK^?w_ik9QG9N>nSxpPvJmR;d|~?z;!4bAOrz+NK)N)ZJeC_h|DS zs0cMGY!%CS;q|b$SWow>7w90p^pUlnA1Lj2OO={9^BPaHv3Z%Zj}Y%=4xLcGbs`?^ zq0L{}=95x!f3^RNedvg7D4w8)s2!&tmBYu}MfkQhx?>~o~cLtd}|Q6Cexd;eHQ;BnCz?TD=ELCl`Y zsF@?9Yk`rrgc)h}t4ZWc50-L^EbSt}F4Q~goy#63yM(`a#yXNAa)7joP?Izt9l{Q@ zo3q~0C?h#@>S{;POT1!>I&-~q$Qgq+RtIjBh}7V8)m*2!Mu{Ij)>KN;`Id5{CZQo` zZ8|UvbIUpz9c8C#F6pRd8FAf0l<>p6K#DsAIQ8)2L>*mQDbCZ;9u0{UXNHbK7n@gsa|UK#dVl=m zQMZPeTbEu0$b25fzw0edA0?%oAzC*+;5Crmh7KF=ACgneKcTYGY)2MS){Vy>J0wJ( zh&#dBN6W!r>NpXKZ9lGGpi^r1}uO*uGCiEap^I!Kuci>w*J(qdoKrn>}?r*c#?HyP+f(#MObw=vKQh1zwRn%M> z!2-=L<0CoC9J**Ly_EC#l{*_QnGQ&%wf;H>F;Qg_8S;`kkR_7e5#_yJygxIKJ1@{k^QSPhjtRY&Rt+}r+Kmj=bSBxlcxOwh)?yC89Pc% z0)DLs{Y2&XF$}wmc5^)bQHk63YwElFFZI!J??!DCy=)^YqVCnQMYc#lnN;G$>Mc`O zobs93b+3GB13T;9lP45D@T2kb;Tw-TZk0m8^>S00)N~o5D!KgJWl31~6?IsJe?i3n#vV={q`)eeYGl zZ7%V@I~=h-kS09nz?}U#963~Jp0w?b=TNVBltpN{ZWVopVh@h>Bq_m-DZ?W<*XTLA zas+td_|I0s|Ayi14_l;nAt0ALw`F-{9%h;PDK%=Umtk{!M>6UJVI_XpNszw|RS<*n z^?ZjEtK)}o#&bkF?o#A75{W;_Z2cx$MPRul5id;~0m%8ax1_lFPvYp^C%-pH?+C); z$rn|6&eLYNM>KiZp8tm;=FiZ^naRD9K*Ug!#Ah+9*vY*mCkEm36?$?Y- z_7h?!EvsZPCrn1r>M@XlD$~AfZN{6u(6E?be0PFI0Az2Q4qwwaOaUd`g$-)Ph#LsC z&o`9Z=6j692QTy%a`OK|HF+>BBH5e^KcYEazKFbLgA!8<%S2oDMA*E`Dt^VkID)6~ zZ+p6pFB3ykqxbk!kMs^ayx*tXWI4wN;?Ta9pTrd>a4*!v&CzQ41md?xd2(6bHDfRD z@wBWz#NqV2J1q+pp?J)9(vVi1i4C{;pS`R|@jQ>s$HF-x$M{_E!8d2blSM`!wkNK9 z?g`nH!W+*=aT!9qY1rDSRQOxj&y4xoKb7M;8irsJuJV?Pq52%s6>|Lwd+6-;U*~@m zoOd{t{~yLl;g%iw+M`HihoY#Ey(^(YMv_7#5t2>FEM;Y7hREJBvXzjPm61)^>*x3X zIoCPYbDq!pec!Lwb)Fw{W~(rQwnQ#pSz7NbZpQKtG`GywBKhfjg5^rF8saO$iexD) z4uR9;ux8M(+GWV3%2U1=oPUT;J|Q+!nUAfYv{W1ZuZAlQV&~=tI`11MLpEq^sp8sN zDZVn~7js8z2$<=`(BJ~oeuR4K~F zh{$P2p)-a*7+^fIu4CMGgde}3-4LzueIEz|wWh98!K}Y{q^|v^%PU+8k9+!rPdr>B z#3$a@aqq;ga)7^+?6k?4$T-ME43aMF1@OUtqkhjmujnhtG;(5`n>>1veb6ZI_}@eC zabm;iZc~2!033?EV`TIm`=Eq|I4#Al)(p%oo!SX&R*evtyp`k-LK==}`?BICv*Xdo zTlqJ7WzoulMkD7?Vx{f3HRkK7B$G=ip^d9 zQt5;vr?I_u{ei}wt{7_0tDV?nRl1B9;#2FZL>%ex21tEHTRSzhD()$FZpxHY`zXsZ8^jlaD<$?QgL zU$FP6)>_jgN*EmfS~7xT&Pl-ei`P` zYA6_fdeCw=p$L2VnlDs4Z?vI=-fD2@pIZ}BLL;r3%Hna3u z;LH0ON9KE{4`8+N?Czs-?$4B?i@8SfQKLG^C;dFa2WZR9k-m-7z@E}d|`hcB96mQDWt3LN8Gmgo#Uei@vW4B<_}Ubk*XRyC#DU-`W6pTna&#^%jf=R7o(EH# zsNZ7K*=~@m7#lR6J$oGPq+*vdoZ3Xum-=}1YUT77+z#_9N(gsOg~X>d$`jM4wD475 z)8}=z0wap^9;LPJ5S4)JsuhLk^Ts5EUuEoSoG!bJ(@#oC70z6G03WrpVrPC>kihB5 zaPaB-WQ+UqMdS2_8AaItpNr+|Qum#0A7_Omq@*@Dqit+w>eKZsd8pG}wP)SqIR=qi zi@A@5+X>P7N4&YqtBnuqcdE2FGHvJ(vGL;R=xt+vWX(iV-%@Kb20NkZL!w)TiTKd< zbnb=ZQ(vf)J;)JHcDKY6<~g=sJpree#L(0G1g~*j^G|%$@C zQnX0;4G%}l8%K>cT|uEyl^nDXwhEd%^gl0Wel)@%lJMx04rQh=*tn%B`rgnOuX)u1 z#lQcJgm0YlC8>2YQat^t?r1BX^%H@K8Q0`hSn098op5fOGfNgfj(+nw{5UHUiILT^ z5w3|6C}TI7sMzvJ1|4_1tr_Wy99Z?&5_}k~B!$nR~ zTe)NrG?*gf%e$ZO2V02<;!izE1Zm673!Aj-S5V?qoOrc{dKxGHhBNZ0^u*xRDb?>) z1QVxV|9!Rhr@dkaV9ZY7UGK)8B7EllVgh4 zS>8%$tLNIxa^C%o{Jp+nqfbn~;a7f@sXpx37G~pzLX($G8$hAFXhua#l zjz@v$RWI+n%UCwCM=8B{Lb?d6%N)#&L)G>Bq$l z6Du6dEOGwe%Dn=ViBhN(p4Ng$#HB&9yzmLTwiOjm}lKmOp?r|`H{*vAj zwtYjGjDok%gZJ2jSSRh2Bskcqw8?QCs=;I&&t9vW5F^gyn->;6VBF_F-^1nJ**$p; zDGPf8-k2jrpxShC<`XYt#8d!5K)%0`NI>hR(;-lqx<;^ZQ;OhWpxeM7s+=jTd~_~I z;3akf&BqoUs%&;1uz$|DOtCyKm6Q9Uf~&ExJ2%M%SwoJJuOfpe`goNymrRi z*DSige(yx-(%gwmToP>}ukI?j2Tj(a6V!^o6kvEhThc!)B(F-#qp2SVh__TCH7wC(FmjLoSu|{gN9DFd{G8D%1ULV$)n>?XXl)m?0rIR?ma_4hL6%g7Xqx$K`W*N!r zIAHueuv^!U7*=B)rUMhigSgVzwc#6itP(SZE?ldvrsU9I5b7zt_l661_5Nv@^vUI- z<6Rozdyij^Ai9y4e?5-G6x(hAW6>`zd;`x`@koBb$8{!4YGfo^yP9V5wXixer@gcChi{&{TZQHC73dw8~hO-Z& z-&CBnn=5cAei9B@NjmAE3mYQH;8?%jl5DMj|B5$9q%6LsKtH2{ygNs~3S+xN6?B#t zUt=bdMX!T}=@giGwwqJ^T26sLqB}!JW$YnBVud?eO)ids-Gu4JIoaHD&}RB9Ojc6_ zB8|aVx+6CrAKz58SJNu*f5JDKS}r5Sy*V6H>SkDUu^+>tlF*drpMzZ}I92uJ`+RUI z%=C0@OT6Uj5T()OA1rVt0ds;^K6_t3t`C){|KznzCev_+C?GP|;`BTM9%U;$Qm`k* zPrG|R%1@U_V`$3ckU7bMA-)`aE)$x5{W-RO_cZM>{HKlT>UU#Y11GOSQi7ZlSxA)QL&`+G%NIh@{ z4C7tX{l7Sr!RtgzrF=m8CdzFBESpvqKA~Uu(svUvE52?*$PR(P_iY!weWw+Um=r3!#MD zT0#65rRO8y7of|1yu7u9|6+nCROYX|LK;!FMsRw>KKYkfd+d_8^$#&`_Yxg+Kf8kZ zW8*V^4^~!0x~#k%voE@f`sgbiV$YdxqLzKK+N=8J3wXAEAN=rhIt3ZAhUEa5ncC+#ztas zrK8}pTlh^|Ww^Zc%?MA6y*oC>=b}JEwe2?3|G^A@DgRNQv%W!&ly6=XB%xtkcxCgD z^<_UDF&ed}8SJ0Aih7qy1_dqob0$5rl;#rddz3JMFLI9i<3?2Trgo3iWdpj-~`xODd&$yr$Knax#S1Mi)?Vphi2Cw zyycGYlmb6K30?yjJZJQ9?3b9tFS5LjVjY^FC_Fx2-pQ>MjAZ#B&bBY(MhJXJrK`M7 zRgNc1%i~XLH$t)5K<;Ylb)yLv&MI1%S$AAUm339h$Zusec-?yCx${O~72hOFxD@B? zr;*@nH}acw;?F+s^>k~L>iCD|6H<|)0nc3U>RJ4J`#?fF0?*3n^osx3!)1R8&)=u> zE%882wl&g5m=U_~j$4J?6uSe5bEnsYL#Fy!VVh;6eGtC`+$f;P@f-!~g};BN9_>c-g2_Yjw0(^EHG9D;^*>%8Sln3U<)6+F#xM_W?WxyAtox0= zj-r^0qZ_x@J$MAjiu0g-PrGcz@<%!@f9&`xX-K7v=FE>9u`Bs+Fw)3zq41hyC4MpR zov?BnPXnE)=nrwqsQnmror+@TfD!|w8x@#ZZe9t6VS7iFJN1bKtW~aVJr$GBM)A%i zCEsDw8Wj6BqOkn&HnIbhj;PD?Nh4Z#H9|_mBNTNRI!tSIw%X{m`21b#1&=v++Lw%i z58gfrruOIQ4PVba$79jxyG``5ia2+miFf?YE8xOs!Ej~uvO+vKLn^fCo}Y|yTQC2g z4@zjU!E)?gsQ1xDJRy+UJ*`ZffLHU;t-*;0TXE*6h6iJrK`esPB2vRE$tvKkzSCJ> z!~Gp3ktLMnQts#WkNNA~$PWnuSaKDeyKsdz8E4gdw9Yygd_t_9{jgBye?!o`p&V{4 z#rOkJWzMo>9^ZJup}O$Usgqg{L@~sFl~-a4A@cJ{@8{)G286WU@>07$_Z)2^UcxuW zScf6&GOaCZNW+YcgM^U@X&k)xsv7vLpuPJ)gv7gLHfjGRL)nu;n$EPiPdKcbOvlg> zG>@(1tQGOu)na_mdLMHqsD%j*r=pxCURdhFW7*q(Scc&!(yc>Y1^Dh2L(4Na@IzdU z6x^Tw8`C#lYez{Xf3#^>d?AXp?x&d?DrdryKgD1SYtaGjE3Q2tonFCHt82Q z&iDQ~=#;&m^nUB8zbqV|MukQ}<8#N{9PE}KVHVc==M9&W3;hx|zaN60W`@U>^uGM1 zJMH~)^seqc?fF!__p7Q&2a^x@gCixAwD88vyY3Bb?5X=g%D>5}e0m za*R{bxkYIZ+_3wVW_+9$6TXk>^L;PUAh?0WU`^KPEY?NKx*l`ibcE&0s{`g=uI0mF zWAx@w{*DQLko5mKJ7X~eqAbQY)UV2Xv8TKpU6@d=fxeN=mc&!l2g@d{jtV_MxHmM-aT#@Is86`??PS)J|E6r@$M1$3cC}Em-fGuFD!(f zh7s|Yzr-Nn-qnJRtO$IN6wLhjtsY1c){@B)A5ue%qIXHl>$rL-%0z^pIyxE(g67O# zJucfBaE58Uz3pPM38#yj@^0lZBp^Cq|E`h3vJm|VJpIQEmU$tZm!_+uW4w*Y&@84Z z#fVHmu5ETV%2gQG%0CXLoXf4p+V*Q#y|W?9Ah~1LRmdp53WIB6UMGJBkD~6yj=GaQ zApwd;c!=p_o)BW7^ZRvJ4udB5fhXhWWUMrZxtgQxtqVVKlx=9-MR zl!W=y!yo51+8=;~&9yD+Cl@gDIY~CTlt>ol90Z5Rg`@ZFt#e!d?Zqp9agNepRflVe z3-76ljb&abO2afzvz&$I_6~TP8`E1k1QnabcY_B#FnE6p^%ca}Xl zVMA?JUd{j23YpPlzYzKFKg?U1)pJieujBep2T4y2k(W5OHJ@Q@G;U0bq zPSMfE``xz1TKjc1(Ua90AUJP9v6~V&01mEKKJy>GNx)0e__z`WKA@+u=a8!Jr%gzh zcHTV0vG@aLTSZBZYMGayGqXh0spyR&wtGH)&}?C!h5eM3lZE4-hp_s+A-Z`aItg6_ zg`GdCI4;1PKQ&~u;qPzQZj#Iqo>Sz(=QaDQSUN|9Ph+l$&u@OxM#;`Z-=QPY+4#Wi zS~e#vZ-+*Ti&l^BcW@)-PO^1phXEmazJ$LkTaS7S!fzU*T?#e)(C8k?|N7Cl3c)T5 zrS3Ob+3=~~@0bVoEfsvCy{mt9dL{{+uYZ2Lt+hFcG}g0alfS1su{_ExY#0}O7MJQ? zo02CyZGmi?CcApe#~=)SkKO0d9RFagpLQo$-a-`voV54+a=w#8Q~bAZT2mh*m}%u( zzdrbN9Zz0L1SblJ^k7{^>4W2Gk9=%ei0H3XZquML^l4Jm7mq^Rj}l%TndZ?&km4c2 z2Q+;5Kp5{o?Zst!1|&>594yalGC>s}WM?CD<~_7XIH{bqjx^zqKg-pYXQEs1QndNa zk!anE&Ek`sf)wWVIF%&*T1mgc313e}8{Pk7)`PmH`=RiWYD9J26QV06#O-h`E_G=+ zII{|~9jzYS2ZA&(JX0uQyY=4!!bg8MRaT$bMzxO9&PnA{YIsZ5N!4&PxB>sv&Nxxp zxwC?{+^tuzM(&(?H9F@lMpn%!-l&}n0XeyJnu_4HLb!y}=foZJ z{0-@anX>I7j$nM!Hz;4#Zs~`C9&utR?@vNBUY4axIo(E#xcqJoArnV#C~>u&uifkl z#gfBLO7xR|{_tL1d45;-Ffp#5Oyh~qzqWyp#?iD6ruuqB>HYVtJt=MsR*NnQLvc^q zAo%UMa$loZ9cFTEvIge-T>zw2sAAQzu<M#wI08u8DUR72f??mvl-}Dh8RN-trn$#Bi2a^FMh#6*3Uz{IKETia&)4 zk$J1c;3rdXQ2RHzeCgdAd_BW-&Q*@g4i^bNlt`W$J%;kp>pnCoM;xF%d_phLi)9I- zA3ps)9G|I=Po(5GYHnF7VE*f$TydkLyikiIzG@t*mxJoJjx0W2hf?8vZ>^MKWH}95 zPPEej%yx}PRdX?^q7~qQ?_{RYi>s&dG51I9fZf3ra^&?Y+fyBrdjSf4$CiJg1f=*l z;zi%0tvZ4+b%9)#Z`Q34;TjTt`#|kIDA^~c{v$PUKugcf0%PgZOyl{lyh;}NwBbK(Yz4mO>X_ykBAc^VrfO6*ZP4CzM|>8*zkrKt`(Ni zExIm>K;e^1`?-Sr6vSRu>r0KQr~;7%eRA*m$7LvzaFVLjObCO;knrCF&&}HidG_^} zjHdW$Xw&sf*}RM=26a}=je{MgKM_f|BoIBCL5ztw^L~q z&gr97dAt9@;$H;>U#+8@)41{!@3q*S`r`64U`krJ-`b87!Ri&s-?Rt;NBr&3|7LJ@ zQvnp_|5-IR&PX74V#V*euMI00%XO&rQ!RI)bJ>d0qITE_e+SvSl$l*#f|OIj^O2Oz zeMq#6QHs=@Fo%@;rsExv?|V3<{4RA_se05$Im7`{?$({}b-Ms+y_39G#6X_0%6e7!}GQJW^nySC_pS-~UtS;=9G7 z0ypt#Z~u)aO?Y!T|CipicuP>RS*xx}c*Ns;@2{ZUoRu)N=Wlz~=y*N^`#^KXjnIK( zSS-0YRj~bt8oBB}%X0G;^^rw!Nr&fwa6Xvl1!@*4^0FZ>qQOn_AXyLZTAj}i*w8OP z*jpq~di?MiJfD;GA(?q&g6AT7mbtB(r|>E@q0HfoXF6()UO2SIlTl+rtx@K!Ub_m^ zcG%v&oqF&9p&s%QKS*s3BP%zt(|sj&0;e>t+LvEo>x5W9_*^b#_7kHW*P)MITUHoN z9N-Un7+#BPhx;Gn$VKMiDG~c7eP%=v2@~V7K40mJvAY=WvVHns1mbnR6&nYpJw?#z zs^>F@Z+^v}sg=1q*1Tiz8GrjT53K{}yc^u9Q^L^#-*>vZ+|QT4!@=O{p`+FJ8KL&m zxZrRV|Gu_3XG)*yGCd4oC3o3Fq$i$3yOC_E`2WwV%((m`;&EjEDb#0tDv;`Pm4qLA z&)RWv9u;i<`_xl)mTM6+uF>%pt{wr1ZyD86z9xPTo>5V8u0pH7Fmg`V#^8v6D^@>l zov(@Z3W8Ja#z+9|8$Ku;d8Yf3kW&le-)?UvUFq$>HKr!MiFYDwnBmY2Q97!u56hY6 zC-Dzftx+f>kr3X!XpW{}zZF}e&&pWO)x8uPyRL(alw20mCL?U{BLA@z`=#h0M#Su? zv}Ll?5U$f^{>k|)53U^!2@JlgCjvhgI=_QzwnwnxRFNO>rQ#NX`22pI)(MZp__kr> ziMKf2FQ{Q1kx@{ttO%||OMDg4-!BTaBvdXDL|n zShdN+L%+{+!~BiR5a;$|>-;u;5~{~5&XXDDSApx)@)CLMX?av+M9wKGX+6OCW`l^o z9>4i8d;7$y%ClY;Fv|{5pP~H9h5_G4*M{U~*HLESJdFm!B^*fo(I59B%L!d~nprNI zm%hQ$GusG*V{R(A$QC@eC8#qDT^{aJ6y`jSfHiKTb-A+_vHPx1a&qdL37FnRyrybR zTm;$up~4!1yQ@gC^{n(BRi#8!Tjk%v&k|Iiv|*BqJ$&d7q)oNQgu_;!!^*!;-FvKA z9|{~dHU3T%RpC@+$i0VZl|wLNa=+kt^ZP-Ns4R?=n`{mvQZ0kIUhuv>MC=1YTE1`Q z!>HEAm*I=404Q!=PcAlL*MVu6^dK+8nmERb{oFcj9<1WK>f4A3rFC@#%dPjy_&AQ? zW{E)S-<~(Q;CuV$vGp^l4VWps%X#$4;T6t4={|5)D7y!#?gVPG=8qfDrS-$%&jrIf zSd}X~uKA(g1`lfHEzFc-%fXu)E>@Iwvm1Z3lfn{89wZ`OkGpbPuJ;|DW?AD{JiH){ z!FU2b6A!m$h^&?UGHU+FuwNMno^7AhQ~-%6CAHO%nG}@$%&0W41U`Vz^OKeABpMDN z3@c%MHNfnRm+!t;PiFlI1liFH4W>j*RctSvjJ=s(!V0nu(NZN9zw?jYI!os##pTyGzU+`d zxJQr_@ZJbHEE*Qh*!NAlX)5{fL*8KK}d1Jl~{kItyJ{@`1j$<{h&Vl;XchIS| zXOMMY!~$fKd!m!JUxwikX7=^?&kZ4X^Klz@Oc!s1t>CqM*7_nJ__D6I2dVnqK|1XQ zt7JgRN0jrw9nuT3s79gTf8NVS&K*a(k*7r{;WKJ@E8ma|8+;v%3DeiUM{5!ufj{+% zX@)ydJbqVYzq2VAe}<|M`#QggT~RQ5>K@T=w!RKBit<{jg*R+a=nA@_TOj%of2J)D z&i~l)#YBtKyZh?lruZ2CHaF1vf*GP-AFTT4%pis*3q|sdrW17dQOZ0^QTE0TFU?OL znrcqcz!QIhslk^9ZpgTB?O|G@oetK%svhd&Ap8X@dcq8c-JS+e8R?FjjF$#reY>`r z?3U&(m@7z+-*0a@f{NRs0^f8_`oN9%zY?2KSonfRSA7jN(4Nph&^54Iu=aAcV+)r+qULB>65{Oa5lAw-`RbvoJnX`hfEdb-8t zM4^KN>?w((USErG^xEE=|0GLFu_+bk?s3hk90vsac2!R6R^Y#p@-_2h^N(1R4S$gz zszrp!sgW@j{t9dKZIAUuCxy_W|Kt+?9RuU9@R6uf3zO`ThYItnH*56&O(W@6@ZIez zYKh3Rr*daapiakAURTG%lUfB}waqS*>)AdIO5$vLL*rv<@VlG42MSE&n5LNjzIV?$L>Pmm5pAFQD#78bSQ10xUCgW`&*|)Bv0z2uS52ah2Xg>I4Y9T zOvl99gYvuAeipnJdWiI!cPZk1iCu7A=l3;nzpJWP^DRFbcY}riv{Ti`cg#%$VK={Z zz<40`6~yo!5tbN zcG}Lt0;e^(U7$zJYv_^<7MfbnGSPuLyc&bgDwr`*$R)n&aohB-9#3gTr zsL-qfd9y)!hbzm!LxJ#|4e3+$5tKfXaR0s4zK8aqoeY~mp=%(W@DYiWZrFj1+#2IS zVXpV!KJOK~N-~uKf5ULoch=5~pseFMQ^%g6j&qOGSC^n_*NwpTSO9eu(9ntD1t*WGl)1y?Szj7}GkwfL;X8S}~1gHO69Q_q$Bm*UB%h3** zH+s-&oDHzZv-88qka6v&h#U%he;ILBqwZu9RwJ_&ciS&-Lw!)S+RJP44W!DIN}>rT zD&g?9J91RLARGK0;>N{J9qIdgW0)u5(q9R*k2z;oCwFS$#+~%fTZRgD_-`V}O4D59 z0d}oU*&aR^cL~3F->~)aP;1~mv&VBf@x3f8BfigX=$snPKJT-ae~W2EW(X4)W7Az`gBTTA5FREo{keb`wglzr{f>FO^j5bVu~O z{@VSv>YELgZn523t;pC{EfFk=ra=>~`ywoAYVEI+Hq>*bZzOWX9mZ*SnUwOjWjD;~ z-1y+~7NUof+dX*@~@(bW3R8O_N-d^)t-@s7a35rZB7 zzKN*c>%<9S!NF%CkJs_Rm?-(y)BNvnPRNoL71uZl#q;X1pI5f#QAaU-hO}|>C_H9P z?`*ON7=TRTr1MirlNltP|8QYzPnHa~^!yl3YI4iwM~$%R>lw9B^rH@M?c5S$<3!Ry^Y1aaGAezMbvj$${?k`g@F(pO zy7iR_;JJs!(-TTlleqXN+;;I?ct7&a-Ml|K=BNYj|8g`aj%=sl^HW0Kg39mKsFQAz zwot4LN2AOPd0)a?O(>*>8wi(5E8`?*1BIZMbsDltwr}dpIGjQy+a~?AT-0j_YDZsd zV{+)ngsz@Iwa9S~flstFzy$?C++wvS-39 zRac2|%QVr}D@QPdmf1Y}=2c;8{blpOV;5wpFf1`c5552Tfq zNjr{#Dw%(VfiqSJT}xzfVXJOM%4+t#m#UncNY{F}P*6X(3r*^b7F+sW3p`qiile>l z)CZcEvce9V3%7Brw&$bIqs}K-+~$kY(|*>2{w0cm$<9tq^n5zCI28LbA7;DGRu=cU ze&FfTpAl8q<>*|GPQjL=IsC`{Ezmzkifhfk80XAD( z?(m5_Yr$?_>wpt|A+p9)TwlQ&mjA7zqW>W_3$MMNCx2pqwWjVn)p4P*pj(LIk+s?< z3<_y~KL>@~rNNgMnR0G=p$vGHzEn*h;+~1Kj!jMCymO?O&o53=c>l>CQciVWUxvz@ zg6j)*W|ElaB4AfIl@eQjb{HmRQ%u%*5*65SvpCS15K@jbrhgW8L%|a`_CQ8V-}IF% z_MV<#*{P-&!%?Y)!>3KX9Ppca>*8^n3=cRJuj@-Xr6|03lc!7XvO4MlW%P&~Kb^*b zQw>dC&)&R-)R91kjV*z*kQ9CVg~Yru9!qAEZPcQ^sZeq0ki9Lqxrb~ifs;>C%H_bG zmm^&yH#~#gN0u?i2Pz-pznu&Lw}aQOqF?z%RXN)mVQ>dz#9Pz4WP&C+MBu71tun}P zW-~S9MK6fCTfew&5!2yBPPLSR=vQ5IN?%Dcymf&SekJPkOutW1f=73h=QNS_M_Aua zo_vS&UtnHWr}>@oxdbNZ8_^8*cP}CQ+UDcvc}FoU=1San-8x%{4XWYTr9?`9u#km@ ze$Ku_2;L3q9mDyJpZF8v6>zbOdl5KbL&X(!ha94v-rR@Uzb0bKOZ%houcvXyeDVGf z4+kwX?nE2cMhFXAg72M}_@%p}?l{J$?JIbU<~*)l4h>lS{YU{J2ev6dJeLD1kD<4$ zwu2<_Y&`t%5!v5-*f}Nef9;BUgDm5XVktXeTEEa6+}x4~K6}%hq-KFGycfv1c~$rbH@Nu31{&r9oM3;P^}o{@99!se2{~we z`3o=RKAW5-6?k2Qk7E)v9Z&54KrqgHV64oF7QA=g{)uHPxC@WN_X(HwUJhX;=}O4s zK7&~lfA{{())dNu4ue3gvAwxOkP2VYN^72*g!Jm~Upx*goj6u`I@I{|H5&*$2pBRW z;~oJ`*X4q*4!VlidrRp^%)Iy;42O$1Po_)S;>G2s9R&(R*B-}VJb1DU$4SXfj;e8=k7!kex{ML(FJe)WeawaCm8fPlc_)IqBE#QMp=#|=T z1|7_QSCM-|rR@ou0kT$;%qxo+blm)6^zYO;Y~=8b2r`ZKBa68set7r40i;;Q>7Mem zI*K~`2^~U_(Pi9X@n#+?xJZg;ZKj&eetTCzymh>-p_-5%R$PC)ScvI75x94JS!hak z9GzT#ZTFe$gb>Owx)`zU9S2Sxb&jH>&zWc;WWLbNLvRvZnbt>`A`-;#EklPUL6AEf zjxLQ6MkBnxQF8C__FtpoWu*36XYI<7=3?0NYw1WxQarNiyN0i3Z?S`3JS4P?b&3-1 zpOm8KD!DVDue!&Q(^v8gJ?~47mUI6ogHQNnCx!{$3FJ&Hk^AZdZy>2V!OSh$Sqn8^ zW?%CVbd{j3w^gp6(P{@Zuho5ts8jiIWXef#>5FF+c9Or`cWYOd!YemvWq+3ACE&XF zBJfu9Mh)Ja2@Z?dee8+z-bZiOGJ5SJi?KFNvQO~`G5tUi(EDPQ9!3c@43 zmBR{}-A9o{Bh=j4cTEvLug_Vt{+ywPq0=3K#kv!@;8E7Rp8Cyj1Q9BK4U$jkxPYg+ z@uSsEU1gl6ILWS67IqvzmXGJ3Sns=ow{qGyA58qM1P_<$wcvZxp`cOKX4$Q#F~G#r z7V}`u*WNhx?cF~&TjPD0)^Yb{-#JzV=$n1hJ@iDu_V0~_|scoH~p={@#e?>jYbvR#1;2eGU*t)7F6Aq zT$6cpCm&Wf)v~P8GkfExzt2iR9IIT<6HM!ZFUQj;1HL97LFv((w=2FShhQ$e z#q)Q)eIQD|Tp!(Qx_uP%LUttLYho1mmPW_P+9Pj>GR}uX7NH-wv2CQ-|Ge+v4Jbv~ z=9`;bp2t?frS&+4x&W}~3;mm7CuGHPZ+Fpq**a<5Sfq<5(RlO`V%a>2#y=jELT=GR zj+V1h3_Ldjt8No7rsBuW6+b)1bQL_R#<9Fjf8exBRzDFV_j5cR^YYfZ)bt5rqR*Tj zPH0tt$$5{RJ>ZEw=2lCcmHVzbz;8`*Plr{c7Gfk@rab+^2XLG(qOoP0pbu19f>{mO zF~^{EIfH3QE;9wX!oJS$Tn=nQx1i%{$t%uOJXd{OexpL?6byY2U6fH_?!r?}BTZ@* z{z>HKKflyOm@p3G^B1-^68ivNHM8U#sUdN^x>c!O^fEC39PQqhWt-wgQRUK{^u+B* z464VM4%;VEaw5NKs3R&zn+6G=a_y33(vf#9t(!MLpIt41}p#Gi~&5sBg zRAM{B;qwhEjwDY;3ZE`w=cs041Lf~Tt(FL7p#z|k8h|rf3gQ1K8j?E{*u}t*1 z(7&N?$gI`<`gd5z8HXh5sx(gjcMYuHyhR3vK9ghKF7R7J+di^LGY);qFuNa(mPf?~ zBlo7La866&AVsg?H+;&f=b*ST(*_=A=ZJ~W{nxg;!k{h zcUn0R4=;awUox1_ii>Z){Wuw8{}i7ra*N4dlM`IX-{jp z!|CIP@hLoid}ui;1_9Pacm6ik&El>EseON3**cO4uWS6riIec;I&}DPN!2F4Y^#Jv z=9pFCcx{8GS+ndupkTB+#8WXMgIKA@{V_>a`A8))uesduCm7OSg8g6CnHeEwf%^=d zEn_WoLS%TN?+PSE_Ns~i0UVABn!5&y2n2k#OsCjo!;KA2Y5+@yMHd<>6#1Yb3$ z@vcGSwd*^l42$bf>zR9Br$~PaloSLXPu_~QfQ-S`Y?jn}W7O{X|6NIMti#ax{%3Fh z797IEsaKCkF5EH#JvaHMt!I}qkh9bzVVIEl2~8E>V{p(Y|6nQ|Uj zJGUXhBk*T$e`YDJ3OC(1P_OYvMOT^K1)f7BcrxXg8dWBiifA{Vo>$g9!2Oda3B`XU zzJYd!^#cQfL!#JBFOA$n?nlTs6-s`Xc@$pva$s&qMh#e^gKu^ZU!94m);Nm zq*h(26hDbE!<>_@+Om&6O5(kW9<}~^g&?DRn`iIZ1+et^-C^r5a+gp^{$r7LONH(K zk9W(9SQOn)7ai^Nj0w80q3`_ZT$w2mCpuRhczOGK@?hTCLlFMPoC2yvrM#cx`FyaX zpJ?pCM-~Q!NrBL%f@lS>UPyA*qb*OzkWZx0x{UY&*lt-rqN)Gd3E}(F|DO6rFu^@m zqbMkJ=qn=BKASF0JR<@<G#dGfP`Xl$z1`|+<$)Ma8M5$eC|=5Df1sTz&D3IcQW7z8~xzvjneHp8tkhQ`+Ylw5O&Z)=>93scFI<=AjCDyS2kXFiCun;zN71A0MRW z+JCI;XCe8JE&tV?|8}tW)@QxrYwHhCc2Lly)WrECGm%iHs>?76yoa7WWEBr2!f?Fw zjHkWkQ)KU0jR<@+|A ipFvAm(sA>J#oMCXJ-S(7wtI6^2np%_DkZu;xN4gzRC^U z{z*L>4&#+*HAYoS6G+H4a*y;mu96Rdgk;{yIX=`VEy%($DEjq&yJb-$XF3 z)Aa{8mqb4KRomXd_TX{uvu`Q#u=D-&?eCdgEhy$MTr2yUbQ>1GI##b=?(M?$N4f>0 z%5C1@c$)h~zA?}l=ici%x>q$2;!?1nR^dvwIJDi}mZGl6YvP?&;H6xFSIuAyCw)&^ zXnP5q7YkU~8%+XGfADXQb!QkQl6;2j#e$M!;rCZsurjir5^4m#hXfBK{KQ7Q{eMMc zu}?s}mm^f4@s0>m)L|As?o}LwK8^n5zt1*07$!P-aAL#y87i*dd#5DeZHbpw1El|5 z^4>?U?=DZBSWXwg@juJLvTT`~=xntQ2qk?h41yMd*Z$MzqJi_3l8q*T}2B4-!S=X0DcZ5K|*QoMF>8<2yqG+bH6l8Io{q%=i46zD1VZ zfQ;_YhN$SL=WyC+?DhBvfhPo&i0p3v+?Gal?)$?RP8kT{w?4&18FAAXd~iN}QH{Bo z8;9TUP#3YOCU zCDN(xK=hk;3hlp{h_tP9!!YEVHljT;9S07r(|gsbaaQ>8?o9D!XQ&~3E4&A4=WS~s z`eL|u;lMg60$YwGFEMcMfqhWDOD@IE3@if|mKJ0m-9bUzg|3H*N3209vvrPosJR-~ zxxbwKoNs&|3lD#BU77kZ4uYUh1O7YK?U=ISKbu3=?T-xKe}Cp4cAJ6o?yBd&>zH-; zCA^mmD!Jf*iRTGTGDbIBVf$3=lh51HV~{)4e3D3S!5%8QLGnY}MZs_y&-CwaZ*C+(W6=f z3~;?$=v_&}zXdwR%fW@pcobOtLAmp03bN>z^rah5{l?Lb78fJ=N8fP!r6yme{I^aJ zA4CR8LZLqRHUb6)Bz<4t=;cxWXc3#!`1^J$ z`aWVg4${-rlZ{nchS*s?U;N)K$38^<^@?kY|L28GX(nm*BQ%LfCe5WFnojrvt2b}R z9-GYq0q=i2e<1Q}6?~nYJkwn}64>T)I4*S1@-)Ot^;#o8PgCN~+fZ4%OOsvDa0%Y7 zh`i*1*<9kEF{3;oIOcOzIQj+09?}jvGKEO;5u@C`uj&@<(q-rO=!K$^hGcBkl(5gyf zqI@*A-gXWt@)iYMO3-Nq?~*9o?<`=YT+NWgE7eO??|C(OAgxMI{Y9%P80q>#LR8V; zRdKRGvYyZ1+Z^A*qYFd$nG*5xr{8Bv_S9I+h25pTbb^u;BtPpN6xvo+u|qg$aDDqq z6gG^651xD)_z!IUI}E>i{^&sIS62h^kNfVp(EFrj;nzeCdR3Gx%_J5mk=mr=Q18f? zg!2S!Ra4$f4=`eTW6Fm50Vx!B&Iq^cWd8>HrJDATjEzgkJefs9{!M5an)(z@6mn9t zSh)RFNm5rz7hQ)zEyyd|fR$qfQ;D)_W_UsSz3J5Efi3K$OjNtySCYd8F!@fn&3;)-+v~mF=Jr=lh~}_BUDZf6Nb%|@4~Ch)AvH%2U1-3eeFIGc9jRO zC?{kJJR?(ZM|Ju_;Tfg#=$)U+ebv-Bh@-sWF5>PN*%5Q9!(`?65;a6Pj#L@ho)rW2 zyUvt+(KoNaeECpI0b>I(zOCPyxqqMGJGPF$n31C#i~w(wzrfUod($vC6{-0+LFxQz2Z8 zymUci=*3I)5Sb}|xb}Mjqp`!2>olc|h^rwUu|CGmg-dt8QpnRROQ6;MIi-Ys=}(AN z{W;qcxF4~4v@Zu15OCR|AY`qAH2k&V{@Zt+ttjTp<7GMFN#84?zriB$64Ddu?qJp6 zr?li2z}=SidyY^yK_4~l=kt}cd`9zJjXoUkz`ibBvy;WvCz zgIT!ujoszs`>II{s>%8uF66p~0D*5q(`;5**gaqUQrg~q3oKL@PsI8ju7j_g0@?dM z%VTJhEqE~dwni9-)M)3H+An@be-C~9m$zJ(!F^Wk*0u7VZ*i4^W$@cor)W&s&9rMA z`}GXsYnw;XRk+A8Ui9qbqk!O8u#QTH{jfhBg}1z7^u<SUKBwyi2U+D5H7z4hq5a_KR89B+=NFy|qjC zD*_|u7^t1adsJbf@i9(SDvA@op6mY*fn^!=$S3yxd4~b62|3~h1Q!`lAm&|4^5xMm z_-Tar!cW-N;euGwQ~f`JkI|mWIrPUsp8(V|k2H>5H8DfV3T@x~)9>jBY0faMk~hBz zmqs_mRMVd>I3DEoX!i6YCfxW;qaL@Q@CCY0&&qCl6Q|>dV|f7+FIgl4B76$82Xwa~ zwQ%(LkIhk^9T?oCPZIHasExPVM+6wRu0BP6 z?c;kKBg##X7k{c0@%fZ3I(J=-Brh-6Vy~a3RED+gD_px(i9cx)Hbaqh^JSh*Cjo{D zg9<13TuQM0K`N_7)F=Y|?ZkfU|8_2bTeb3QcF$NRR=zj)9JVy;#N`gXP6x;P{3vV} zuZX=FRs_Ky0_O9ES}Jhu3}n>%mmh>5LEHvs1asLzVEA`b>RQ17)b7w<3|ZDL!(yu= zqU?#C5H#?%?WF9}0$lk^<5@AxpauCoQrk1xd~A>06Rd$zwIcyYw3fq zs67Iw_wAJMSw7E6K)drA%Ele|qvz~>O1>MEH`PP{DgJU0JLr2uYR$~y+qT>FAg zFW8QM^)wyQHT_E+Y}o@~A$-ce@Iz1vnmPGL3W~i z(%?lyFNRg!e93O?6Z-_q73%h3G$BLjgO#SplL;KjYnLc3Jah&#fs&K?N+u>yYH4+t zJKFLPExrHBQZs%rV3*)l(1QvWXUJ9R^Zz|9$PFJ45bQ0!J%dfhm2N(o`N2wa>} zIjbSxdC^2Ax8nf1%M*2bOD|309HV%<#B^mK0_u;%=6ru!4;8|aR2z$nf=D6CF8)cg{a~A_tU`E|Qfi3#TyH%Xf`gUyTO6ee9pUH0BlHZS+)SRDnr0*0uA3KJW@$ zL8{RKUE=G;D>!mgLB^4dqX&FO8^@=UtcAgHqy22;?Icg^u{y9lzP7c0`|Yxt-0>T_ z4fcclChYP(4RB?-C;I8JTs|@cv_9Su5wpY#fu!<-_PRH)`Y0oCfUUb9*QxvjDNfZg zq3T&?N&nC1YnYsG|7FenbPRbXzJ0vof8ZsaEv_F*Pq`6`)4ab(*%mnBgPNezvMj(gub z4XQQ`ZsDuPfTPN&#-ee_1FA-N`vKL^d(;L^H(J(cT~b(mwMIw13LN z=)qHiAKuDWAa$l?)vV_9N9@f@(kO@-Sb*7g<=t?8kth!QbL8fj5MhVSx8t|Z{PgaC z>+7B15}ohY;aIcK`LW!@5;0>$cEztknIVU7YmsMCVsWQAn>kplxD^ja4Nht9E(w8e z@%Cbd&{G}A>eD+ImpdjS=8tO4ZfZ;ePNkIlEvu?2Bf~$0u{Y~(08-@)XFn?Rdcn4o z!V72cdorA%@^1SF2s zf3u3*L&%BD+wT*)gYe*6Sn#Q<$}>1`{DL7V?BOBg^L2zV#uyob+^y&K??)802pUrz z*iPbmgoif@8t2yt2_dm<)A8g{nl}n=&$6^d>W0Caw>c^D^F<##W%}pBSoJRs_p0v; zKc4&IhLoeV4>nw=>tK4uK7ujQ_6aP;6J3ivwa3Bj8~y9&q0L(OtC0(S{bgZ}4aO(j z&(}%lP{=Ntv2TWqP+79!UU2zW3Hp8?2>)D9F$Y}|x~aV(0!8R0SX6)HQmw?#*URD( zE9`x+m5LxcW?4Q92c^(}o_O9}=#HOP%kyS<0@iEUy*BIpXR#0guTSNqwP@#a)eJVH zQN;nmf79!K&3+;`^#c7hk`E(DHNQ9fwf^i`c(DjH&2TT$;KzaGO!1=qf-rgROUusz z^*WT@Qy-6oyA1GyAUy`~s2MRhj|M#5PPjHE;w31qZ zWZzrWatWIGoao2j6YZZU@2cnH@!PzBj@8gz3|%D_Z`hGJh5?FC*K0}l_P~5!!u9i) z=qcPfz|nm0p1lo>cnKWLCUEM?%$fQ1$N~<_?$Kc^mih8BEmIIM$*A-?mpp(|-uXz=%#&YQrf*RF#}&8IQXf2UcSHAenHIduZ0O899{q zcR!{|F4p;I%AG^^EN#G%sHGmvpY<8i@%=%Ddnts!B}bNQ8XFfkPkQJ#^KNKE=)ojd~B?Rm*Zc#_)NDMEj@> zBjkQxCXD&{ZW$pQ4`q6Pl`9`^aQ1ytA(66xh~}mhdBtuN7QCg#8WekBoxfd<~&xBNDs z@@~%ijJ&Zdeu@d7{kZhv9V~voj}RKbf$IPJ~Mbu{4kFUFO$UEA9GJ5bLc%G z;cdwskoklJ?pESa^oj3Ck-l{xMKZ@b3*j7kGx=8v>+mXfOHio7xCeyHnk$U&ElMEU z`|ux`*FlB-bSn9}ILo!M zGpZyX0pfp!1Wh}YewfT8dUUohTM%1@v=-gTt*7xh#^X;M=WGPm zG5>L6eD%WXl#>9=wbG0PL!#?&T}3(a;p6?~qV;uVkCCyt3PGWg--(>-_xG3RhZaR! z#f<;UR!#kw7yfYJ6LG-#JW@zi-S^(mPUQea)t;8x`Bfuai|JvcZ4`4xhxqKE^n61b z9tvG*V{$28!R`x#=EoN(WwA+LYmg!KdmIS|y%t@y2bl0Y^5u*5SF}B#e{JjFo)QuQ z(^Z2G>9DxhSdh&X|2A^(2TZv%n};~>z69^O#fh{WgQrMm3L#gI=?%eyG`@63@u)IP z=?yk~q_}SZ+wklkbwf)IC`u1}bE4yJB5c1HhpWBlkj49hoBWsw^0Z|19x-`Qu~uU~#RA;hZY58<76(Kp6)YLG9Ne*f+)Nd$U0CA@+X&nTcw zSe#Vg@cvKGN@PQJ@mkqs)ZXdibr-pO3#P1e&yx9n_9F0>5kah$ek3Nk8ceOzc-!Dr z$Tf;23QZVvCy2XUR3^s}r>~W#WK2jvlQ=^Yo1toq2Gv~KvIl2P;HtL#NAdpSFL==) z(n(K${}RfYK1qoSk^Y2i+U4Rvw`(#OO%}}7w)Yvsc+@N5B1Q9uxGYHIO!?0|3X=5s z3AJ+k9bjm`Tz#T&um<(MIp3Ik|K7ww+PE=atDzDEk*Jy(ZKs8RUYC{revwizygq9( zCZ?R(1hcWRj@Y9s$MDSOX&dJwjb+q)8qIXL-oA!2!2wm@DTQ*;;n}D3F|XJh|NRJH z5erQpK*jF0hb?DGERjYVMv$qQ9R*VogRZh2nR-aB>t)$q6U#usysgyY;j0yh*DSr{ zrx-y1sg69iRi_`aa6hA*e=a9L1Ln%>PgMOMCE<|haQ^C#$^u-!%Gl;kU6>3@>xi7b zLgUW}`CK-|GjhlW%N63zLThZS=vsPuSXJSdK1MC5uRiPf_!>QjFFj{o=+Qx7LG2;q zS|JY9rM;%InaJ4Op9yq<*Qm?vVK2z;`meOh170e0D|M3TK8WE-bE+xt+2@Cs!R^0~)OD(c<9DgTnZdxiR{m^{l>eGnC=q535->%(xy0B#q4*VUZn ztAPB_*)#=n^E|lOpJKk`+OdSFkcS7eE4GFZT|1ec&QYGG8Muc3hcf3tCyvz{oR@Z_?CLFQf`9x3&P1;u%) zVIk;0?L$*tHgKl-Ibm>Qmmfl-n?~2Nju(P^VyOLHrC=DYy6an11uJmlwgY*6e+R=; zyrb{YOD3Jahqa3|FTCYNl%a6U-{eN#${x&@)u#=9)~vwrKTCStI(h>4zwqs`2$kPL zWc#(vQSC#Lh;LRN$zEIt!=X$M;o#Yq*)X|5No17sKnZuc3H9I8Cq2W2=GMbrnR zd&7L(@V~l?I5^0p_1W)bG5SoaCgg=G^zoCv?rgjrM>6V5Sntpkl&WK8jo)JPNZlJe zFKT{4)_Ox8i|^P%-}aD^L%C$|Kg&6XE6`uN`HM{X+aL;+IN~bhGoFIM(~y1i*#3E6 z_5YcDLGxrGa<>X3`wNU5ko-QMZ7$KQ0N+m?>>peR&4d%JkQmQqE)y<%Z6Y=cp12C0 zqA1?$&U!zvM{|mI)Xa?q9g&;P4E$vKFJ^f`_t(=U6Asqoo~J)>cf((3qNL(mOb^(1 zbUz;=7yE&*tj#{jcMn~l=Nglj znmImlXCV=5e0F_(@r}WF|19#c&4;WmNdK2KdsJP00x^ja0gZmwFTf=BnAXNk>ixoE zF7)w=h{YvHX0UYklfO9XK`*6+_67!F}glhoLD$LAk1Q%Gjiq#m<^P0-^T&-mEB96zQetAi3r!2 zJ9>fU{CKIg{_h5__!s2AcVsp1TPwx&Ph;fH(uuV&OPjhs+H7hSO6)zNzkKgMjyLk=U)X#H z#S&FiUVn?ufuB-J>qOwGWH>LgE1FJ7T!Dm$#)OW@{VZ5spQcgaVE>GwK~K6uz%kPu8AcT%a`m37ZAPQ>UlPydy4?^Jr`w5P|_(yn9UOdkb`<)UT zR3BLm+4^5V(yMqE@oGIv=&Ux58&T;0fy7aFKaKwI29Wvrk2dG!{=tET(t-iqY(AgITPO79@SddvMtHfL(#`;=tK$>q48!igEQCZHC=HrEF z?%a-7vpi%@hTC^}x>%rXjbkJ1FV!>H8j&*V_)q#{(8si>R^rkA1uy?t`RK$h4Q{d< zEz6djd4cDwZTj&U{Vf=H7TY`Ne^3_p=)@Mh#&$*UuhduZo_q-{~)Qy@6AfR=geSr$IEo_R%?OTjPu5=%C*&!A{?h_Tp93^{Tpm`@?+d zgzWIn84&i}raQFy;VC>mz1wq+QCopr#E}qM(~UV$nw-0AE}7$lq>%EdZSB=V7+T*K|e28&{Ce&z0Xpm`Dt7Rw5yeU~hu`Pk&-t z?oB9(JPu1rYbB+dD0a#*_-L)?i)!+)w<|kogYY*pNR&y^q(^Q3yK#Lgx)5}QeZF-< z;`k!YosgrUyl`V5S=cK6HH}Ca#_x69Yf91++82&7BhM^(GZ1{L=eYSt{yN+aBiMVG zYjg&mhDWNsX}oyhrPay#^2((F6wpIF&WyDcve<|d77K7GGrQV9$TXp6n8D1iH@{>3PnI zzQ7=_t5R6S*dGYKtjnh;_=H)*C^ z-57r|B=&O2`wob9|5CS#&OCxJ_dI)u4+FV@+z4n#D@jhhEkU^ z;&2u8z5Y_j#vbm50>u-JdEMZ-a-sBANZ1BUoqrjg3MP_A6FD1ucx6@^;$;d4<4&|v zVfFaYQ7H?@b;Lg#krGUxdxxv^-->^ADOSTYa_h$nh3Zmd1jk4hSe8BmRV}H@{UeKu z`-LZlVzTeQR_Gnsn4P-L(*fEgAK4rILswuPcq;A#$zJ+C!d>Ui!a0mx1)i&O|X640N#eXeRYSCt}P-gSL-ut{02kh=jbb77F zbD03mT`T?+Ec0Sj>Vg%HI3+WvE?*7gE^-2EByog~8$c?$Y3T6ETw z;iXnhTfjbg5U1~NR?snA*e?hHPA?r_ET^E;(ZcAj@NyJ7#N-aHZ!j%kgGuXP;>h)K zy!?F4gF@B*KE!=>#IoKq8>1%Hj7v_@fed}4fgCoaF-C9`go-$c$~caAN2M*A&NiYh z@}apuTg_?~Bl$v|wu3gd1stmh@HGR{et;oY$?NOLIFHFI?Cs zI;zMe;eUHYwLJHz6@EG-s|*pFUV)Hx&-6go+fq<1FufdUkW_>5Ers{_Zg2jA>so4y zO1xw+nA=?6ZiiOOVkr5~Kz5o45k7rb;;AU(eAj%5ijMTO$OuR8WX4 zDT~8!WNWV1&4Ukc?j*hIe?_WE=vH!ZV zR7;yM3?oze++`I%@kg*P(C6|I3%`Y@eTr)-?nJ>bI6M1_%DT)CeIYi7Ql8kjqOF(h zpf3M?JGj;oUwV2w#}7xI2xw||h*n~;Hms*Iz!9kZ@Afv?jWA7k5TEUIDG_~(h4aq$ z1UM^F@oed7^X-JGCA>YeTcf!7krpI3rB7cx8!!dwrQ-v^A{vXt#Zb-N_3tD33?TI8>yoRi7{SLqkF3}^{~E09j5m$7EA2^@m9+JZj2z+Sv<|t`smH-0H{|`ZP%oKc?>_*t#&nA=VUbJyq_5s zoxTc-D=&H+>;%5zg0bA46E2Uv;I+sY#vMbGf@`ycgc_Gm=HlYc$d%i)7Vq);(@U*3 z9g*{3?juXE|L>SP9zE+EBDQ!efL|w*?kF7%$b?DY1UsivvH|46OCpA+&)9oKY_rO14BG46$nyKI3 z?`X%QhSg(@6;lvG;2##hr5}#_^z=DLTwckd?f#(WeB^Uw#4aQUdAjXy^Yyuos}uQ- zD`06Scls`Od=jP>K5<*E-5T&5+{>X4J0gcgL0JQSCVqNE+*EsdIH^S!fef4qTuMg| zp-G2=)YXjkGqe^H|8pp+l*0DYh}7y~iXsTt6xfzA1>1mQ*zI$UY6%}y%LF4>w2HH# zZB$zy_wZB_yyrTW2x)Z$z{T_a6$|U8D6UQ$br1?1SV7yZi*$a0N1oy&<$(;LGS{1U zM;TAlRsQ}59?$gi5d2Fcgl===6Ys0*IVcQJYqtL*n^{iqJ{;6MA^0YTR?vmB z^hbrpw-K{g9J#A_@yUaE_!319QOI>^L*8Y_`tsNFMlcB1xILnDlo!6TMD^20>`ua4 zh36(a<-tv8+*NeZdTCdPjv#iI$`R&tXf6Ls&6sE>fNYrTqu<6!tXP!)2R2QY_!#YeKA3@*l(d|;4COd{aT{R2#V z*$s+k-mT$FK8x?22-+{04g3@t9?;5(_^8A^u^I(CkjKf@W()n=A4EeeZ8_E)`Jiof z`;S~E{yV1qCg}oaZI&WC;YN^gGnu90~KiHMj9m4g_f?`Iu z`^}E)E6j{BPu-I%)5Jw}k=s4D!+0RF{*Fi{$;t(P?C!1E((YbIb1Hil^(Mb2G7z1QZ|w&Wi_Bh{ zf5s

G7{y22>NG;2}NJkz=Z+Nb1}ciqd>m4k>a)<<3)Lrbzrfdbo-(lp1MVKgLXm zvN`Zv=Jjb2Ubbn-Q>t@zcb?b}z~YR5m5pz%Lgz|d-#w%hl=m{0NHweSaLqYG~n5bH7hA93v#D^l;C{Sxk;Oa#F;|E>@r zEeG^IF3>-*;AH}_+xoezdM(|MF>WZz_Awa;QQQ@o!KWI$IDGaDd3B_h1J1E%iHU1- zh=J^u?cx8{x*y=(Yevz{5{5urm2hqexxw)nue6O>H(IsAz^8SdPpnnf5LXhbuVt2g zk_F3MohNk)MRu^DZTRW;$Bz?LPp(Ctz05t1HmbGoYpy%Bu(!PD=pOJi14o@lde%;k zDMRr?y6;@%%~$)UX?EKq@XSk?j|;zDmnhZ4N?NFS5}CXaw43HL;!P&c!YWu;P-BoV z5nQoXN3JsPID_rotKvMKp;XWondqAz*=J2P=5Y-J&$`HwZMI!ZN+KQ#Ujv&GVv$WR z@H1Z;W%zI_5G7uAF**LCl=ul+AeoUX^c_T^})zP&2Ew6K%Dpz+!j z&A+ymT@RlPM8_w>vw|IcTFBHQKfv|=;xPK?_pVD!9?JnGPto3~%)z6GoE~?mGPp92 z2ezLMoW7W^42E$sulMUhg3z4F;n3(_NP&UT@>dEXY8Egi;CV9G639$giPi1=|>M_xC;!D8aaAyKR$_$qdw zT8b*l2iEZ=550%??xL2cm(5`>@*sjPT&(KJi0Q@cDI8rCl)H^Kro$dLZzj3n!d`1e zMd_)?2J~Ih->2KF5OWpg!vp_3ni@X+gA8 zN4VS$wf7m>G-0JBXqcOs+=xx3#*I7A`{s^qCc-D{>Gy|+yW1cvnN+Ut7k?4d^6d1A zqAZ@Ed(!KiHEHz|14KPkyP^MVAiyMYEP=%LD5%cF9G^P0au_a8LlU+8LP;>oD||!O zEQS%HnN~;7a1#H)=lc48N1D76p+KK(@%@t~BL-v$J~YzZs>0n>*2lII?-?QRHvF#E z;QM1>IDhxd&cxnjWJysxa$ogK#ibm>V;jUf(m>OE=Hk2LCfp&Go7TLyczIvC34S7* z4p_vBg?V>a)a?aPsYTI6Rw;uk*7ehQa*n#WB8j zc19TNm}0ATyPXd4f+m;#H1u_^&hI~^qO-Q5>qL+7Y$odlb% z;evKPy8)qiK6qu8v-ws2&VkG@^7jtqWHPF*{2D$rQ4o#CdB5VDsYz0BAzFtcE9Chg z{t$5bIgRQ)M%?$s(>(P0q4-Z->vVyL9W}IXO{&IQUHgahzKhN2S6DkCMkAy=(|zDD zCTw%Angkg&;1h8-tBTgW4x~>QFMR1ueS$lk_Mg<_N%X;gu%>`lP@EIGGKs^^?Spza z*>3x0;i_~6{+R!D4)}B+7lF5r-VUzO8bOSVuo|EH>>x5|mAdRdeK0|#%z$fT8py-B>~Y)4}rV55{iLMe#qP87DA@Z>&>@@@d&pXq`9o zxsm?pJ3?l&KZ%?z8HN-s|9z3y0dH|EJ271@uIn{cRlBbwuZb{Xsk66p;@4^r_-%TG z<}GFg5y~U!9IL;#huy@>(wCLObK!CRc+f|(YYb>h`ILHtW-u3rSKl44d+o;vi88l#lcZs|?vOS%_f=*?`~{inBIAj6bgIpyLnBG59Pn3ITB9e@{0`Za~! z*hct}hf=-lzc`OR0*&X*M+aGO*eCxMbu7;pTou)L>BDbjf^LbpZ>>i))8JUe$Ee@Y zYKlFNwJ|#~4G;W1NUNlOllL1mL!Al_wDmMHF1Rw6F-)0|M~s&#f4uldpY%p zlW^(v{40LCg+)9H7GHQ%ebNbY3cXiTZ4zfT_HDJDxI+vapEU+fm$F^t$NBCEfb?qkw;!?eM~kOoE`pJIz#j$ zu!%EtI>4{?9lxJklbLs)nOnhu3njTXs3peT8lT|5n5Z&n~(Q zfz?k+*xs&T5;N>~Ilpv&&c%uH3~rYu9VdKN=#=udn7j!mJ|ZPjkKZC7vYEerzRq$4 zroB%nxyzD2LAZ5)Af>F&<8Me!&C-*|Rs1E1e-algk%R{qOb*=d`<@4aqDK+A`FE31 zXK}phs76*POjc6@u0)d%qec6o{0X&V447qS{`k1#o)GMaRR8%)9#zHzQQPL5mu;f) zhV7d8-Q9ly__`Uj((f#9fg=Hr=T{Xw9^)5F`{CJhgLWW@J@zoZJ#Pj7J=_IeRT_9+lt zOr-+e4EN9C>&?4Fj!c&M@P7A1Y-f1s3(N!(8>r;iZzG-4l;GHt`v;)Rc!h0~K>jVV z?k#F8H$*w=M>x1E5ah=CKfj7`M zOyjzxHhBtW=EUJQ-gh;F_+z^CgY2p^I7C^sbu5uH7oHyn%190OuUG&N(VE=&`U!|L z?pfL{bR34O#mlK5J(;QqYa3RkWRDJorsFAru44svKyKC{b5!Hv6IA zOiYQ`#3OLgO>pM~YRiZrJ({d~mH70E9 zMJhak5PD3~`9F%zGoA{!593M3WrvKEm64RKgh)jpNmgdIY$BP--mf4$O<9r@w`6gyg29F_wRRI-|y#hj_26ZFUYc7e{d<`voC0gR;y>cb{SFlNo7v; zoxd!OYl%thBy<#jrzKa)esrK1QUx=NsaD#;c$D0=?oZ)F0j56rWwzQ63=rUL{})3f zKZ;MmS*{iQvq|_|l~3f#m{N$@jc$$k;d~ybxtgwB@p{|`>bc^78zN-os0;KQ{MNR_ zfzO^N%eN0ww!&Q1k~U&&es`S&b zP`P@XK-4u`9Y6AoB!rKrZ{qtaBW20CWFGWN7>Erz@0;Ry^TYnSi*9FdF(pE?t?Vre z!i)Tt0{k71A^7t9*t@;=qhTz{qfU4+cos&I7r)FTygC5hb$*tk1KSlyJ-kx?qS|~5 znY496Dy|NcU_JlRs(r%94h7#juB>vs41((3^smn>J1S7}SrHT1pk#;n_Tr7z{u|G* z9abT&sP*74LaX<#Uw81@_vVei{A1joh2obe*}$M%I1OC7OK#XWkY!+3ul%%C>u4=x zj=6S6TzX}O=j$I;Oglssz|KsQmTyP$7)7#uXxMVk$ChvSxcf_16O<^Q8ox8Fl#B}> z>6XaVZEL~h*>=hO@>8OH?G}ah=BH;Woyr*gvQ|uOCIo)WH)i z4M)dOaR2(m@q^qvc(-Z#CT!0w6!+!KUiOcj7Qu9aSMESw!Fw!u-cqM2cIQJ)Bx|Jh zaEJjcW5z9yW@`PzwY}`GihZZ7v1(Z(-n0F^46(KrvV@PmX~9eHAfx%CGyZ7g?s;g) zpJpCNx4!^#R>k4CnRls8=MfqyPje3N+Srdl;>Lp7sC!geB;QE&4q|l z7$vQ8Xp6l}0@;tH$u=59IZ$4Ixbat6aRg5fo_$Ygm>!Pz1kVKnD%H-f66^{hwc`x<7hJox6OqTq-u?y5^pk#c`Ae9^erE9G(y zg!2p6E=l|Co7sZ9DiQD3M>g|v{Vq-}Vj?cgUIQ25Q?fAeQIHh8@6nne~? zA>s5{9oB8r2-uAgD2TM(t%cEaR;zVUKO2~WF0vL)UuVSBbat2sb;T-rNn*(M1d9G5 z@V_AKjHaI!Sm~%KdtPl&i@qlWTi2-!F2ONnrl|VD8wse=3-dMJ?;`~D7>BXxSgbUf zs;>8>Rg^sgVc;hgwOfLzAUpS_n=||Bebn|!)V}|BtW)0f0qPLcPqiT@Kl&0j zyNp*MHDZDdmG-X`?JtS;!iq4ily_}T2uvZ>7qZin_E4+J zL~w8`J{<2lNXAz^CYErS@@ZM&?r$rk{`0Ymi)@XC)x1YQnH~3eNGA|HT$|n=fwrK2 zz4W2A2z>Z4DoHTM%8m0Yf%gSP3`cN+b*AIx_VZn^w;$@nq{r6UD%tA1j}ISO$ccV4bLZ-1>8O~k8L ztzO^f15@Bv2Spa61-$K^c}nUP=8PYVeX&1Ch!r86!C2q>aw!z%;z$0xSn;VuTY%8J zcN$$RxD_t;N<&Cq0v;}RyHe8*9>Ht~LBZQ&Jr8iTtRQqTJtqv@LTA3VuZajk>yK%{ zIYYK0#JPR%jyw9Z5Xo+Zw^)VO%s}}(zQ6v!(_&C-u?{|XweLy`*pRF?E@w-~- zQPP9v(A!}4J4Hdj4^NU6S+AA{&A1alad&c*{WItVg@5;j6$_!kQ;O`E{#OyG&jiI0 z_;f5`bMA>Uuf)O#q+Zn1nJr0~LHT=y+Gfl|3aVaBT&*m)`UfL~Tq;Zyl7`s1;2?68 z{;xHHJv$pL9@p*SMt4o>@TMC9_U?bUb3Luc9lD!dvJXCbegIwMG{qeunn^Uv$u;WP z(JG=hGurE1IL{dBna~x|cm5+@TRzUu^J=^WX2~LhqDUzj(4?{u5w5W};8`-oWQ{=M zH|PpV`DA`+_Q%(awtInUKRxicn%zU&MxGr(ayeE{91nZI?)=%pGi|9M;Ev;ye$XYB z3T`^{(x{t*GH8pSrC+}HcLWkl=2jd%))Elxq*KTxwClycqh^x6l7Xd=&57rI@q5o5 zn|$qo@yFUL5Yktfv@}{q3Z>)OL%%t`tYCa$$@4gkry;7P6qt3WzSSd)bx+1AooW&J z**AN2r!PK-a8{A8sH^o|T)vzg;jiQ0harI@w_=9ICh#lZ&D^sN&y%p@Wo223A~u9+ z-0wlY=Ba9UP0Lz&7OmWv}>w1&`uC}{_0zM8H&C~+0;$vzk*m- zlhZrD^d0>8_l#pmJMRE22o4s`=1Bg9bzTKavl;Vm{AjO~r9F84C^T9=co2~{dcf^- zUbI`<1~VLQOWyIjdM_IST*3{nC%DWIDVlQMp~7qq|9RBfzmeT&#G)nBvF)I|ELet} z`IA1@ss{Dns*dXYdDr00caw9A+3-A!nJ-RdXbu(Q{Bt(CbA(<7Fe4YKxpl&y4<|#p zhZywQ8!+4%y9%==c%3 z;?o%w2OGaP^q-<+guFjv6YFI$2^NbC4jLzexqGZ%$nBWnnpriv#|% zLP_6MP%Oyd^WU4AHi*3arSVJtm>*^qxL#1(|LBB$o5d;3vg}Q)N`~<5T8Pcz{&-z` znPvScxW`;tDAf|oz)7zk6PzZto>;MyuKt$x>>G^UgmbMCFSbHp%#J>jcc1&wrjMRp zkK#+ka%=bJG>PxG!0?=FciCi)8blIIi-Ww`pRydHzLTilDYQoOlRdaN&TQ3FCm$B#)%>{u`;FL{I~&zp z7<{&)uJyNa9NG0`JNy!l*6`|U=43!{%6-H-{~+@#x-W%4xv#$aUoOo@W6uD3-IeD- z*jDR(gkZoAWwrk}G=2m>gR550sL-jbPpFw9@ulZ|^#MQc5s>WiaAbqopRzfdt?w3U zH_L^7d^~yu33IQwNUbRkqxZ>?jq+zN+aY?^nn{O%a}VmCmInGpq#xjC+|hLM)|LU5 z6oW6u_;?QBpOiJ{UFM^r_?RnDW7qTO2BMz~*GJMO{eo{+hedJ(X@4IGss_-dT-xQZFA)R2PYO_DJtTem!W)fNH&7v3O%*Aol?;af!h?dWw#VQD1K|T(1lt%>2|E1u_3IuCopyL9 z=wc&!1Zq^qYgTWH!8+)Hq}n&rGf1K*b$%A<%?M@5(=8d- zz8`|C#{B)jw$czdbw=ekFP&Qf(Li_TUx@%qoZ?EI*NySF#T`ofn{vX5%y2Pu9QN-| zO2@aGwYB8)FF#?gJbU!XdDB0T%o3F?*-@p$4vF{EU=f{UB&~?WuoB;F!s3tv({_H1 zEOO0s+p2Bm0&AkgVlyNa{<`y3Cp*A+ z!+uC)v2YIOW?2s}t%&&`cGK#$UrWtx_|M(+c=(Q89(vV!FJpA|IB_v-=Dt6J*$;4t zUobAQv8dfY{FiyP;$yBtQ(9{9&|vW_c4oHJ$F&!R@IldZO|hcyAqt;quRXcWxde?c z0(ytkFNWw|xf7^X)qNG8@BWez_;-Q=JzMK>-8ZVPqPtI&fnCmF2XTfpT!wZnU9cJZ z`f4ojk1?L9RT=mBpR9+A(6zPcoGcIYrBR*ltZK2sX=&Euhn52XPJ0h-0!!X|Xjx=C zb(*0l3326gfB(D6MvAPbyT>c)->4v9{-gY(W1sIq;n}r!cC^RaF?A-yyOP%BJ4%%} z5~c3OI76bEe{#9$xhqH(OKelO>NoM@3ah7|SrjpT+nnXip*fw8Wg+qDt{A00Fpgg8 zjgrs30Jcxx9R3DGL}R3jXxH(R%^!%dQ@)_Ml2VLn!3*W@x2d;q(PjHmLimL!Fxwax zTE6?@22Ta+0k#WOzIfu(_ttt!K?9vfG_B|Mih|)K{%2_79{&^g@5=5B>z{dp63$Vo z(?o8q*lP9uy6AcMCqC80j*A?4O^U=^kCQzE!7s4u_KN%Lk&sxN8-KlHQLWGo`(q92 zIE?nUF1IH%#URYJ_xdpMKMfw19qU&c#-5# zU-1&Ga)k}!MfFCJxDrG>{xS0a?&`dqvF>A6ft>@}fUrmSB+R&ekvh$JD?qHsO{~X@ ze;O<+WSlytPYa=&_M)ULlqwK$WS{B&1FIOw+b8?T-=6J(-&vC-zQ>c!;NLDWr|wQ9 zgWGh~>q7G3KGe3BYc#hxoX78J%a}I*lAE|z;89W)mPUf4(=W=H<9R<~XO(Iu{@Z*c z){ij%=1o?LN9fUrkLM~5)q#}3vyYfPvKTT9WR3M=C1psjv7%F&_mP8sy(Z}Xs!^al z_F87`JF0C+^X2K-Yjf9u>~(5T-=0eVo|n{6DQD?a;GN$St^Ql)HDKDj9?1Ld_!Rz= zwGe7$T>FD(?Il^G3+?q-yse>sf{wxgR4)#PANqGG5VEon?%SE^!EnxgV6{nLb`?Jx z)1CyG_8-Uo1v>DtW6c)@0v$3+zoXgF!mjY0^l^C)(*2SJ)pi&c07F2$zuw=}*L01zHgbueGH0U8U3lvR=*ar3I|N^KAi6&IBTb}HDtu0q`@fG^ z%YcEr+hSwPewW!HD7>)CvZsW3>y9ZMCZ$7|U0F8HmYdN<#r&TI;^x>voMV(g+QH+h=7$E;A?Ssr!`BK&^`^=%`KiUQYr5+>f zZS8SP>iXTqr0dLP%m?2W`2M@n-p}CrD5N&hO}{>;{15E~Hb*raC)^RwqjNKR+bA1e zP42nfJlc0rO7c-%OT4@Z7xXM&NuQy)ijuvlsbduV_3#n%qlvbQmxPgyzWb#}fnj92 z&0ys)0|&k;Rd@HM96OGm6-$p#59Cu}Q~%xuE9=W{+@nx(vKBGT$DU!2-Vv70o?Cqs(l+?-W5O=zr#$UH4 z?Z(F7X^e)O?In{qWMCvkBE8AHe*k-5v>ny#7*$ZER8*@Zd{+>MTW9%}>5cRu&iB&h z^sSKnTi`A08T9zaO_b~0W0XkdxdZ)Hz8xott8-w_Xu0O>pHGHUCzek|-g`iSnDZH( zh1Cwnv7d+9*+nNE!6j-OoPG9900Ok#efYW<4k2_eM4#rl)O#FL8tsgrNTWcMT4N%e zU_}cA4ckN|RpvP1v>sY=^fr+vsyXZ9D$g!mLP=G4@=lvPBT^IG60;RgUqxuu=Ep7T zyE0Ju^?K@hT&yQb&;ET;BD9|hDl6`PPD;CBxqnYPE>gtu^Mj`$Dc@XoWEm03KSTbg zu~_5ecC&I_p!$Bc`Eo($Z0yla*xh)$A+`D?3TK}Q_NMwA= znY?x$PAHM_PC+Fi`zte+N?U4%=SzAmyYqmwkg;MQuuI> zo4LZA_nsB$kWvYvXH@tvHmPJmF40@Z%mK8Z( zPr$Bga)UU_aSt~MRVw-VZ|315f8Uii!bhW!dyuD>(^)!B)smKD@MHTKYvZnhdCAL;SNXU>hsG)ai&4lhP?vgg>{v|z%gxOOMa@WvC8F=1fTb{Eem4)f}gg^kzm6MoS**v3}8cB~^N~hm;pEv7; z0#0zwFsvTLbK@T)rkAc;p+l6EI51cx4kyRRKY29oe?Br3otMp%wQDi)#AdvcI*ACg zR)gugONPU+zi(n6C&D>{rNIMVU2FUbP;o_F^x}1sYP^v=7WR5w=03tEYkl`dt}}oy zd)B00#J~{QH(cu}bw$Y#R4r_!6rH_+z=^w>+C;Y%5bnn6c4GKT3(BJ7{=}V3xQJ%Q z1r4fy2^SD2GuCauCKZN2@4hvk<5q_eK2oWtMMtOwv!uTB$m#CHJEh`pRFT5OXg-oy zm}*WufXZ&?!)1jwQ=nnkWKG1qh4Vfnk<+Bt((V7bFd^Z_R94o_-fWn6I9)1qs5L=NQ5X$R zEUg!q*5^;2zwtE%dtv2c`K@K6up`Z3dHb@x7QKghRvrzXw825)&!-xtAFd#H#=lVb zSZ^irf6o#Ne8~NRpc&s;$JyD>FshGwQo_YK0)}(Nv7v9${@`c+g>PMPKlaJwgOA0_ z$`Z9Wc4hvshm3Fn4i4QsfAVaF6m~GDR%Ddcf;D~Jk5ALDctXTYKv&3~DF-|<@q_Pv zXIkShWwLd1wo0vZBQ^FU&$Ar5>@tfBG_DnF|7t%% z>Q^Voe?2HQNO@2@rbL9( zvRi#xuX56Hgy#=cTHnkwP%`uVlnpg+$J*Bk0m(cz0`S=;vo;^}D?(ryN4}5MvqlVi zkZ`Lu41_>-_eu_-PI#b`g;OUM3YKZM5GH-UG*Hxa{uVeQhnA>-Vh+cpH1fwzv z-r{lnU$~M#+~1dW{}7rp>~1K%nPfu7%lU9dr+8D8Pg)Cv_U4O2#)js=yC5D1EOy&P z`0Lr8L%QISmd&Yon#zqw>6r+QPt=-csrHEaC(H8snhC zp|4D6$ebl!(YIbeqzT*KYD3p~sOGj=PK1;sVD6Cv*V_ZzgkZdxvJrqIeptYutzAdw z0qCY!URfw3JploMo{5lKYArZYO}qOp+Sdb>q+HwXd#^&ES>oAUcIrzzj>i_U*IjT) zMc0#*FXpGZQejDX$D*jH(+^(HjoNnjza0d*5#RSk)yi*(eSPcsy5{3%c*(7*Y<9#` zq9V@r@A&J%P^>op7nhuPas^Z(lQm}(yv`u+kSP<-m5dvhJIXZ~Sk3kan-pUDUeho3 z=f`PjyCCK$0$4t!DC*t`h;9_tHvML`BqJNRcYgFEZ04vv)yve@%-oTGD!?JB>P2_Qi zlSReZ`jRQAW4U|$ncdhxwdu0{B#3((7s=a1tqB~Naq#d+Nc(vzNzhkP9e=${9S6ZJ z+B@{qp{H*JCOk=ROSKSz9`njvl|JGuuwVV_8REl&)&*LwtIk1J0slZJh9@1^Nusg6Nz4960AA&{Q(waFHLcMH%lMz3{dOmS^{#9(g)OfhF2f2gjn_u7H4R@-d=4@#3jQmPui`8dUcr7+1PsPb`LRWRI z)1F!T7v3MWyd-M!nGv?{^6xE2Me`t3^F>r(DBrpLFmUwdHLW9hI4pI?bYbvK7?{j7 zoD$N0-9hk#_H9CI!b-S`4Gl~RGistp!egny#ljg|!Hv381TmNJb3O9dfvW_|xbr8Y zx^Vo~FJzoM+Bl%)u!tUk8{>{zRY5Qp=#ZY0U$_Y0jg9mP(TZ1~2#&u+L?d?(SzHNZ z)h(^77`t{)Ozzmc6YfZz9sP7%AP{my9?D%>Jr7Y3J!2@I$nS%24Tbcg7w-bGH#1Yh zPW^Lue>+&*PzXNr7uUB)0?8lea6swLm88^k3V+x(ZQ)G*buFOP;lcY-kq%h>6uoZ7 zZ7hXihM>kbH%Z)~W3eUo<<5x~RQl~mg>9=p0iliK%hU_S_rO9w;ne^8bTKqdy6YG? z1#0lSGLGAx;+iUyUOrTFI#bO7<=EKY443GyK>ihs*==5n}M|?8@5|jpn>dr-;sB!kl??%@Z)JYM%2ySq$ zMP}hLRgbeaAr5`KeUneST@IUOp~n6^CqlvgsQDhzm&qhBH^-RDmF+uLtrKcGO8VEF zVYi0V#fOA7n05PpyDhqX6g-h4f%=UGDY!qfN3>ulnhGLDZQ1Gkd@D%yDQO9cHg@B& zUa$(?W&Pj#U1n{ZenaaVSeYoU1r?vu1lx^5&ZyU7-)@&<>5UEc!^Pj4pnp9{JGhj9+O5 z6HTPzzoEya%s{zq69EqL@h59ml8yNMukfbDR7oL1>wH!Vwn&Z8`yxJl)>CyE8?8ce zApx5Ec-G+P+`S#*I=sBgFYs*Ea1Iwa9*6Tkk?4jwA$=G7bNw+$E?107-JP+7lS>hM z4z+z6ngR^99(}(I^ludB-@i|D1bNrGzuji>Qpbb6^{+0~da`H}$~`ISyVZ-v|7LDC zRCF@oRaRQV!ma4LxL{z=pK8C=2~X{rC`D?UIk46e8To(EQiQpJSp3Vv^eCt~n5x+9 z%rn7&xr%xA+=q^sKI)#TC;IBtD!C zI=7HsdfYEwd$S#uY5{VGSc#wGYxh4JB9ZlfIC3%bsF6?!9SZ!$JUm}41Yn@z80nSo zFPyMo^s?e`kPZ>FeuhRZE{W(uJ2PEVm_bem+lS~fbi zVR)BwpjGJm$Uk_Nu^hQsAh*w-XHV^golYOa@7BBXWN$1r(9Ep*{+>?UOEf)eK43O; z(jC#lN&nIaEP_ySLneyV=-X|?>{nI+b8|toNH@#rN6YzRLxibs^v0|^E{wSP(x==^ z#Qxhl0PVkoka48$tQYBAf*tv{;l%BeKR__VM4=mCdjS05cdA0K11$eLw{P)H>Ij!ZuOyCzTF^w8Px{vRdPDHha(#i@mBKc_>+ zw8Zw!Y~#0JOH)jZ;jnhWq~x3Bn}j+Hpvd#3F?A1}!ho578s_O&LH$Qb+|PtJ4P5<2 zR8~cYV{nFXW#ShH#Q=J&Z&9uXd?3R>5t)B;@Bso;o;`oDnnhCu&bQlRL)+-~@ksCV z%gNWeKOrb$68vvTe+jQX#O^gKbYH*;+ety;suNvMUGU;@W*KNgk3tTGNffCCq+IkT zI`r)O@ak=HV!(8ZAZX%=Bka~|&tjc6-<0oe_ApAiwhU+*6n6Im6^FWE$*d2K_WbOS ztJki;{J7GcfWcRanD7zduA@1YiXv&I1Vg=23G4*weNPE~a39<6%OVAC>Kw$6nC+E* z3eQ{kS{bt`s;N(nE-q2hz}&4mXv<`Em{ez3LZSYqTJ+}OGg#Zx32!L1dW+Ze54|Q7 zKMx?{#kLto>VuacZ#Z_yFe9@X@6>W8k9-|4h7tp}jnF|mPE>^N&foZcITkYv9UuP{ zx)(rNr|DWJk9RG4?nFFaO(C1Y)VsPA(o-8lXq;R0iZKo($JE2PUzQr#J-8G0Yn+gP zvjVzx7Y+w}7QYGdvs_#+l~U^vR;*d{de2oDzy3uP=RGBm!+lbcp4KeCbP#iM(arxj z9s>Q#N%Ym8KO6B+Ty#fsOxg~{BPo7{rQVV#8NaELdee>%1SXvKJ|o?D6pPz^h#BFw!$4=6F^A*jWH@_TyEMHFnL}E}cf*m;b0NZHyp(s&SSk&X zIhc&AsXPbY7nH`wuMt_|{>fX-)_Vf7RhC(pZ$5E%IXrR(O6xrIm9vxD@mlPTfC zp30AFiVUYg&chMx{I>Zu*u1mq7(_|}_F2JSOH#+aJ>;?dyeE=K!U7ZG!#u9Sn|Am! zbD^EvOoSG%+P6iyUE56dOIFf?n&=M=sID`Iy_ag4fkA3#$n2D1GTwxRH>j;|=z(Ro z{K`@T|1I#ghkWWY}8Sis$F+4LJ3#|Ciq`#T#Gv!A3Owe)6N(l5}*CAyEh zyA}pkNRhbNK}9syj87*&m8?eRD&XpH_|2CjQk__wvc1v#p>zQ?wjI~fn4Tm;JeO-c znYv~G-w7Vkzhw1Mg1(!>#Ek9OGH$-fl@h;G9E{YKm+g}+L>;KEekEB@G`Utc-Qwg99yPZl^AtInc9xM&|)&cW%kY^8>K zA1gM=;%-dX?oC5w)udm5M>H45ugzJ!yp!sUIco*w2=^DKG3k8HDT{UBGDiG&zqN^d zti{#7LapdUM}B0`lck>uk_yGO{O9adkImW9v1;3KRYWTiD&rfqPHcBM(8G6Um#RUX z5rhvoS!rK?b%X9R)}O6|p=~%evVD+%F`o!}cJJ%yWLC4m>an_Vy~ep3&r5fCFSmRu zK|>qKqC(0{4x;4@IaQZdyHO)a-|*l#MF5P=cin`GN3O%=Y{|#`-La3*;d?BeZ##Jg zOwZ4Y7)h;ozFqb$!?7>BJh8o$&3+QI=b?XsWmD<<*LQ6(^GF0ux_yZM9MVS*2E z$AZON#Eabq1D{g)r+9xKg!Qx4*pV_y1(aoXwtb2IOoQqyvBRRG+o9-bd~i}NK~Dy$ zD+=z7ABUvDZWb=6`>ss_tSj+9Z~u4x0JO|ZYZ`m{6);3W@R2@f(H87Fb_snPsh;R1 z{^rtZ;+Fx=JmRkFC$yEI%KC!4uUX_ezMK*g6yah%?dhX6jsn_)&EcLqGFJpa{$J9p5UW#4&xVUwr1vgV+_Z zY;ccn&g64zCU@P2_2{1$6*8~mvP*> zW8nDO$2(BGrTj4x}EHgTj+i`7YaaGKESYn%@LiwU=z z#|$ORM)33(Py`8oUTWP!`D4FW10^9PQ7b6DL+CB@soG7I5;NfFk_;? z@z+#13i)@v7Zp_Nn80K;FJ~$-ei$!I;_)x2 zsfcgvCt-uw3+JrAoPr%~MJTVWd<@8&bQ|Z|ct}8V^XnaV{l8QoIa*^NAiWlY#+v#@ zS^nEeSRgTCnDeNmL7Ma*|MPD*^}udoEM=$Z){CJp*5jn(lv40+qTX=Jo20qL&hIe^pvDYz&m>|(&}VNH)OkA$H;OU!(i+AM3$h6ZWu4EM}tfc zT&jaEt)^VX*zYwg9k*4tXU{W59RI_e$_9Z6;Lt{R7_Kg2g7L`@sS7u%;QezyL~n2w z;dIy8H=iP*$+TPDXN z5kQ(t`##Xa2n*DzA5;l{{=&$H(aiFZc4Iv6cHa0Le>fgwhj=_ALeJ}=c=!F9YxwjA z+9(H?)0 z_y_7U+L@ojMo**A^V=?ANHHY8dY2!Wm=mY_yywn249@r)H?Ia1tz(cl{!y7G*F3CSEB$nT%@%;5 zyI0)a>&+gn%Cdh`ySO=k&&%zD28XWp!<$v(cE827VQ@^1Fo_;=TSD(x+w>gU??7OcB*H$@;qpc4wuU2HA|yqx3|0q3{dd5x93)9BZSGr~%)0 z&d=wXO-4uqzAZm_Cjbr#}#XcK0I_>-JeevVmh*~nCP?1(0jJ5!*Bex3x=-* zJ*m5H9*BG4^v}hsFKF+(NY_oKimHpqQDG2X`7pS@{wh^E)w)=eaj+^H;%2(oJrF9IOd*z&g1fa8tf1H zx%%oHJ0&)q>MCp|#awZ@AcQNx-uWb=cLXAjZzcYR)=Gtvkd~DF!lE=GbSZ4I8}s3B zEQs5_SHryTn@|b)2}@XctkT}okVxO>>Z4mrUdJTB|7CqPhq;LqzSo13)+{Qd(f{u> z|MMO1a?pkA>Xln{o`zLxo^bUd86PYaAAN|qw9hU!vnPb6?cCL2aJKz;O4D!EePeip z$13a(BU&GRsU!3yt^zF`|3P98$z}W|N~WJM?0o>$j+%}Os-cuP{l=RtVe<CThAv(>mGVM$#>Y;0xswxuKRYHE! zX>r9t#QD6Km!qZ_I&>?~7@p7HUv~2gDw^NqJTcin)NSGWegu32FCE3qPO#x%&veuAqmAr!^WybxoZL_KPS&v+mM%yR-sEeK~^2f zIHDUVV9~RkgW&Fd+N&2Gj^Hw-(RmRvkyU*08%vU{A_?F#tXJ}7+_gvA6>Kh0UM*F=Lyq!1xTI1L@%k6xWgEwb=U zj#)Mg?+L(%OYYqz0!Ih&h0$S4Y&Sa(Gt5rML*#!2;?HTJM;Z}NZsSr*v*2V_$|Gd_ z8=6mhk{gfLrYZLhmM*GeYC@9dYYJ~N4B}}22?vf;;%SkXGV3$1*O<9S(=HHyuL?g8 z5wp5k{@}t@w?@CC?A%lMb#_Khq0Ih1Mr@OZv>SG;ks$Q$m1wr#E-v#v{WyJV`z*xP zn*3QT#`#dlX|?g^irq(iyHC7f+j^Y^?lDR9!!DjC$kJ@cbMQYKjQg?l{ogK`d;)p( z;1BvYY!>)!@aij9h;lBZJX3s23SFMVA(({VZA0}*@Nf{kG$ntZ4iUO%Z{#VxEy2)w z(z`Y6-ElO^Qa3*pO8AFZvcT*EVWg?>p*g@?^jk(2Z`fXnedO8J!J~#}mH#-K=0S;J zBQBN*a(vsnFlKSM>=;6eX4FYY+xX#hc0%zC$;k+iv6?2te5v+$* z-fb7eHMM-D+NjyTAr((1HoNp(jvJNHQ)?-uW23fHVLT*9k{MWxUZteUFyRe zpZ+PB%X{~o9Msas*?=F7MLWaJU=4qqeoMwd7?Lk1OM7G~xnNgQct@-`PZHM)RTobe zQ=JFdd!hJTHTfW1d7SZIWT#RiJew{%y^E~x!}8rnBx7_p+|hIJ^;4}UO4pG|T-YS$ zsdpC0`Xqn%U5hCW-U=Ig6H)(tg1y`DYGu*IElk&ozIE-kal;v!rqth4 zA^RHF=b_s>d6Rk=JH8dj)mJG5dd$kiS^Ydf*|cS;mh_ky=Y+iac&bS@Q8c6*D#mrJ z1y)8rqz}C4rEt_(`C4E@8ZFwXPVWBatYwJwKmUD~E87p-?#sMU=AoqDfR>?RA9?$9 z_@5mxesUm61}mM{avD?_+)?n|O)ag{mj$a=I*G0>i%7zx_~)DZ$un!9QB`7kdSW#d z*Fvg!3x1Pa#4mf)JhY%-)BBSPwTjemM!s8DGQ;9un?A9r6H3Uy#`pV;qdc z`*!Dad;W?5SdJ!W@a6xVg`C6Ba6Y!JLO351diLW_)g)Ydl=9;gbeOPudR3-~d^7`m ziT|YQ?v(T*v!zm#Wkp35i}g(BNiDlo(K7sm|8;)v2((L+gKvvoh{D-uZVk;h&gu{_ znxR%6`5O4o-tA`YzOYM!hhnAJnVaV=9p24PObpDm^ikofl<74uOqPvqT;$3GEj z6O*S-E;vb+^77V37Sp~>K6gT`MBy)1o>5M7y|4*DVg60c)|*%CL7vVEw`%y9Y&I<}|TK>HNv8I}l z$8qz8FnIx{%J_;nlz!(Yr{BJH5mC-tBGuCd!9>lVP7UdAe~&)+ z#$$=w_R{$WV=quaDC~^s=Jg;8crqtHu;la~#RJEiajCDS594@{aMh$HOC?CRB$tc% zTXrD#FY52juZkY{eB@y9tJI!i2(>*?;pp*G#uX#N9S?tb8a(+BA|9Zi<`1iA4xSS` z`Rz~~)BSa$h0hhOO$|bRQTbuG5)?e}bE)VMx@`#jl^TBWAve#ODPkm04h{TpV`Oct z#~i^ti+=0hFSvfgrIy>C+7z0)lh4X+ts3Ber-l2=csnOn>CXM*N!g`=ZvNsv4rFye z>Z};?yg73pnC^PooLRmnkB6-OU#$Kv3Be`((F-NE6VvD|8X^)JA^VQ-_QCS%|7xz` zXrf=@lgsH@unhnB(85gOGFD$~_MQ(W{{f1rdz!NLIYT&hv%UZW6Vp&?9C~=Qmwd+{t@-aD%}2)x$Ytb})2PXtfT5 zS!Jo3<&Xx#I+7JnRLE0cSdpo7toPYHFldhPPKxi8Vm(*6k+M)K7^~mY4mD*l>7uLY z@MoffWAbq25MnRs-3rF}FS!qTd3X1XVbq_dH$@C-aPMAnl@mHmiQ*vgN|oC^^x$C~ zU_D_~Df9o|B3RRyBh&;X`$^4O?iCLRXY9^!5xqD7tQ0v+zpbLe*ChF}rk6+7VfxT9 z^6~1M4)m=@oEqY|Tmu=m+!MA{KR9svpccJ~PC*Nl?(^iB3=5RO!d&RrJB|PD!u|JL zLDut#!}x7u$C38nk{8TXiWgmLU!8!L-HG>d{s(D5dHJ&ca9&?CdQWamNA!OSuhtz)yQWf{uDb`GsPImxyq5jE0Bu!?}8BWRMwmSn)&*EczYefK+af?`jdOR z{zeCGAl$V+H*b;eGbTi;di`3>;xX|szw&CZ_7B81yemJEG~kUxk2em@v_E5je!;@< zi`}8$cusQK%WZL=hRr^>8#_}>YY4ZOX->)f^P`B;V(9p}ap5#L*%aL_ekl@1>N|z& za)KMqp!16;7n(hn3{?Y1hx-bnlVCrv9T%)-2jmtSt!<6n?!eEHD%OeoPy@)ev*jx6 z#nSG(04p7y|LQCebaj@K<~gGcMlXsy*Sy=94xxC>8_D`^*CDOmGT|(HR}joae@rDg z#5+*dacuYd^yfbySbxI#Lz0>Y_KG7V3OD}$tL%v*E1v1s_%T$x!J>Ei&UYA&>N>a< zyKSSw)cc+($(3Dj&vIOHI6OFv^F`LyN9l?iP{nM(xHYV#1Y**9uHNFCuTk!N)qAq$ zpb?JxWQcf+|6+iwo^j{#o8grE7iqM5DuOK?eMkKriF&+k5h&u3Re$=gG+1BVW~p*s zoWhcxt)1&ri%=91@lx`}{7}N%uE*!3%^yzUyy&lKw*%Kg@XeRG<-b?E#o(dt5*8o$ zt%NHxY`jh_@%ngME~fQH%jh*~`G|^Y%Z5Ghi}9&-vQ8K$%2io1e%HOf2Tgv8$_*af zBRFLvvlCUw^8m*S45V@`!c;&L)-`zO18YA%s4jgFm|z$Kd7|VuG2-S@@M?xhbT4%7 zm#lRjMY{vfpJ8Z&rZWp6wV4%j$4pp~#uBeUW;4@;~L5 zM?mGf#k6qj&1u{jCnJ2p^0Wmy{JEn|H5gD0r6*|FrdGH z)H5!`iC2zJVS3$uHl}mf1xC-jRcUg@UZ81Co3$b21r2oc72Oo;)QHgX)1+ehTD&?w zEGjvZX}f>N$esT5ucn<6U@%NL5=K(AweKr~y1i$0b>T}FIQ#7G#ZE*Ao!gVT{d0fT zeU2>^Ydxce7v)km#ddyKID6~&M`P2r4EQh*vOM~mcLzF5WveenZgJx!w}93%r*|kU zkLDb9WxM$h&$w07@639{LF!k5dr;-D2}m?plR1b7)q!`)6yY{kM{(avVygH1=3U>*V?P z!4EyZEjTgvQjT^xFu4xb{9MUmkCt47x}kwH&8XWoICws6V7xB;5&kT-uU}uZyb7oP zJgBIAS<>+3G2`8S<&S8NhxdhQAQ|8yAR(PesF1@+&(b&A5#CWi>15UCD{i*8~|1K)q zSwwr!``RHGQI`0y2~xS>4$-5pu~SsdFp4mW%-b6KXM&;|(c0MFO z_v>s>OHl@MXJl~pqpln%pIi+5ksM5iyL&`rBceu;n4n58xY(Jz4Xu}h(l>}%nNWBC zsdbm>i>Dy2zr|5f9M_CCm7z`Rt^!WbhxIS`G9G<_$jE>kI++dzY$uF1-#qeB5DBtr zA)5Br?%=w0o*7B(umuk5#6KtQU%7-u8wN7*&w*y}Z2XmfhRKZq^GrDa@tle~Z;#`KKMbDz6FSoV@DqfK{YI-uTFjuzPRH-q$`Fs?gA6Ky4!oc6imb${ zeq{b9ym+2>TAJ>M?a30)hO)4KN5Qm3{!jady*R{?bi9o_j91-gg0iqZ1u!PV(g9 zXy?j5wt${dkk?oz5_i(L!AvJ8_RYaVhapE``^yxcKH|`V3*msC{Wt6_pIK$J(0hm` z&Ys;}(Odi4>)5+pBc|cgpjacW&)eD?f$Tq;-V}n$Sm;y@JtXeiAGsZ7_nvDiE#E`? z!d&=~bZ!;cGOxI-j3?4TT`84#Z(Teco_Rw0_C%fR*mw~By1Dh2HniXUojsEiS_t=( zXI0DB4@^U06avTG_VVyhv|Wu)!T2rio@e4{9GoNu4Vm?os2HDE^abd6KIX96LGQ}u zWX^x*+_Akop{`Jh1iopP zV=mDw_`N72r6L#j4mlj!(nrjl6mfAYrD2cE9yV9~>-w+(+N2&FCml2zs($k4vkv&InYq>e zU84jAJ42i~0mC$`#eM&Jr)wl4s`cofM`yIg5kW4p6;8>yh4W`0Mu?wDlLFO|lGksL zlZL@x(y{Mf zo{PaIr_%I~`#nw~lN^V!8En;T=>E|?kM>7mpt*S61}U9SIwiBO>!ORQBgp?b(#5& z08(GCiwo5K_y|8|LJF7gR!dZp&}wjRUN%E>r{>g^@GgDG%>~N7SN~Ry?Q&`3hj-qD zf?Z-!r|!hmGu)tT;$@pQxQew6zc&U%je#(9eG^e*TtWnm?8gNMOC8z(p1~#80Fy2F zl@Q*mKS=f$Vk*3hcq)}80Lno3ncj_v4G3=~;yHR7mt=_^#dfOnkK;Z+rDg${I}zR<9ja@%)8UC2WtKyV zOXOK7tl*8LPLKMIrkJQ3HJg2%c$9?y!t=Aw!+8Ehea3c(160(gqSqhyJ;5>F`s%J& z!C7#X_9xhp+g*TEq5@~F{+Da;+eo4++rDChq*;Rh+6JAq;QQZyb0iOi5|9mU`|%`? zZ;%g7Jt7)o=#A%>lSuO_& zPU1*s183I<9!@lmSGX(%7O=p%DrxaZD1jbE5B&Yoe&Dh$YAyPn38;r9qp;-Ot4;gQ zXRt^&#h|QJAA*~;;l(HX9Iau~7Z&iNWseT;k0Y12|Gxwz8#9!i8Dtp3PR|=g{%R6W zyg0>8ZT9oYZ(J-nZf#2Vqz1GkBv156(oA4~i1jSX*hvj|zqFGWiF&<)*}sIpx<0i2 z!LZtVoZo?uX$ZO6w9L-tr;lGEVWVYB593kQk*uHdt563y{JF8MjLUmyJ$&lbg|qVw zxJ>J&WB4d<0Bvc@&mH~^q`*Ml#o%F)&V7vixk0ZmC-V+(qHAQS%U_}|^beA*@Y zu;6;C8DZ#1gPg)H_qD>d{b41xDHgi>=MM}%mT&DP)vF5QGS9hRWT2mu%wXETUK!1SJ;Tfnojuql7Rz}Y`_cz7 z?Lj6oTlZq{I59EpBo(hM9y{dRsE|Fkhp@@8r<#1GA}~K8e>7TCm;@Gye(iNyw@#z3 zxaZS{L%zxQWoMbV7@&76`BGRj*{mrlP*&hUI@P56mEQ zCzVMdKl2PGyQ=EhlJoa#PLXnkTAuv~hE%v2wqN-6hFa7d_9hI`qaEqEM&_# z3c5QNA|q>#s5r=D70z#26|(-g97m|j`GQY%$rC4KteJO7aQJl)CpvZuYOx zm=+%JqEkuwqU$o=_g^*|pXR>}ipK_AS}xlcLFO?Zva;=Iirx#hPNrM~J+L*<*buX| zPyyYM(SyFVPHw21-+TVYHJKT6L;bI43Y~^grn39nulLA z1v{o6!Z%cz)?xPW!B|19uO+^Gvz6)$nPx|H>*SM$sE>+xnwfp+Wx}gbTE`L><_Q_ zg89S8@#xh9_c5G&zO4SoA3Nw7`CiiyIr0(8ma^jRG`7xoVyw@pr~)JSa^K;nHOo8< z?-T--*OUy_5L(w%AyL1)i8HHb|1wQgbQ+STKiJTCo4l}^7B(Ek@8cR z3nP^Y)3q>#wk2o(=Z!O$kw(sR$;|kw3bHg5+H=Ie8bSX=^?-mC?I6C7_5bC1bkq~% z3`6ezkFts3I?`Zh7-eu2`Yk;hJZl#e(Q%dKgvcY?286t`c;pD%FfbLaExFpVoq%xX zBz4>UG5dW$y2c)QZQTjNX34K)+5+z4y0dpQ&S#Y%uF61dEUm)`(rJ0>#HXxP$Vt#$gzYF~)U4_x(x?IpHkNLPGzTcAKjb=)Pd*2Y^$Fz^W zE1P&QOcqkLUwKH-fu6YXuI>jp9n4Lb{*6gEmumX-%%pF@LkG!!(IX2KI&X( zFTOklCaznNw;sumgY=VVRpH~EJTE&&kU_&@;G^2=4z@UY0-?tJ2-+Rged&gY z1_vJ9JZlhhNqhm98Dx|!)76EbeB{;`AA!R%WRB1tp-l6Bx~~A1^AZ)P#c(Rk+`fgY zfe9Q!jKZ2Ci%3F|4!J_08v zyg+?ol^`kpNh8wk1`NA!di%nWEa~N*_Ruae7=vzS_ebvUl#wfX1&i(bIV!WuFy+&g z0?f^A&zK7jPa`C2eakD*o&bAg4t1C23mwt6MWMRAaoiUn+>X>!?_ZtS-*F-%Z>V}Z zQRFuGI_j8F9nMWU$n)A#XyHbPXO$KXKgD;iJ%5JA3xt@S)0J_TV%b8-*6qykyIvcR zC;7Ie8&TPcnm*5tA4W&S(B04OkU^O|hL_kpHhf`GkUato zVRJ;jC?_uxeyuoT@lD2_ZNh>H~OwiFZtonTpgl8cPNvI)dv zm(o_K5)D9ZuX%L3Xwn$+`gTWjR_kW)I#b4BfjVm#GUe)I9^aH_5cBKP8jBRmQ{47J zqw?$`8ES&FkK1;$&|*B$K{k;8!BNaUs&|~rsy4>9(<_VLkGX@;k$g+9uY&V)t4m_TXExIyxKV{wh*L;e&dt8G~9Fa^%yA1xnJO5X zvyZ(+!ZGh>jZ_@{xHA%Ab|W&y8K0J_XS5zl3gan}EM0kY#BZdXZSFiXmB$U|)b|Fz zAGIsOC}pBi!BgrL-rJ`i9HXfC5B$q=L3x6QPN226MaApiS7}s`E;G{KA~b>Lim1Vp zqVXL}hW6;cnC_f}Le;Y!0iT5r;I1l4ebB(fhh_)m#pRi1R}?w%W_kPbTjP06#C`w4wX1$uAIn!w(|-M8ytCgBJ-rhb zs$Sz@0AE^Xpu}O&a7mve`4qtl&AP+NAJaM%kajrK9%c{U_P?mRfK83d*y>4#ELX^JI*1XJY~l$c8<;${#hGQjJJoc zf&2|^uJmucm-w*#jJ`Sc89gNTR77YJY|o>Eg2$RZlV}q6rLQ~9o+dj8JH6&d$q_o9 z&`A(2RrIZY0_k1OX`;dA%ojY-y;3GbMP0yThR5Zb= zG?%AqXo)|Ljeow=*qM3>oJLghLm}shp?zt%Zo$9D6y=%j#f2Aze}ejlIE8nV5Hp05 zu3GbEa*E;yr$^dkl!YB+)d=H5e0>LCX}76+08c=$zx-Bw2N?gJ<<;=5xh5If}r(*aNeV2FHmyR>5$UJ zUxjcK*5W?P-5Z0x$;uGbO0WB{)AoK}*g|?1q9+eM!_je|7!^_%@j+$j7xAy85V zr{mpS-^jVQ!1ihwk6)pdJi08;@jT7ELI}DCjO+Y=p9Erf(*H*Kk@u$i3wn&MCO7B{ zE^!R#edyt9gXm^UFpg>u!dqB!dd#9(4s&mhOLlxXq=f!b#^1ZmgIy4vi;vK~`^+7k z%|XHWFC>VOqE%9S?>_sKMa8{sW(0^le`9VO5qtl;v!l= zbx~eec`XB~KJu@!Ek?Fs?|8w3qU3A~SoGBt&%P0M1ou`L1xMxmHav4In&s)Ve~4d~ zUEIimM|h#*YaL1dGS3D=%kQoVM)>{4jd`=>@A(|hP$pcVdw*228D!J?l;JPyo`T46 z^77T0c~dk`4>AY5=(>a6-4<(4u|9509%v4|wj(2gfc9gWD!+sDQOYy0<+)KH56|+j z<690t6tn&ro8~!vYnQ>#FHl2%%W<(LgW(&2S)ZOels^8War+9 zH!)0S!eg^{W$Nz^TBH0|+VuRV@DRlEGe!jk$ht$KO8C`_51=b%D-D=e=Y*}`jSf5t`wkKDVkLpW8YV1tJ|-yj zS=azcUjoS=m!=S0mp3_)dxgCY4nLT56I}UdvFqRNwX>XYAI`I^+O1wPMnL7Yc`-Ryc02+{zv@;Of%sb#pAl&_?+kr}3u8(BnNKQbz|^Vq=*asQ2^b2Hlz20) zA%g$547O^$yxwA7CYf}Jx$z;)wDppLcU^D6%7S9#EU!AYvvDT9Z%T(ox2*Ko`3h7Iw_{-W+#M)0f1aaQ#O;epeZxGX~ zD*I=}*AA1n<~(=LXg8z(QuZ3V?m`4Ib|VKOX1|$2iT9qT%AJrQw7<^3;LuL)Xs z2JZDeTZZ{~y5{Gii=Pl;YSepFNM#=XX{ah3Xj0y9CI3oe=~;ZIz?#-v*&O^l5B%94 zV_~`vFW{3x+}qnO2?^*8yCB&d`6(0>*ZUHl>pg40ZEXs_sbijNIL}Sg6f7A22}Vji zd*vbv{~+Vx?4C*aSpuAij*g1Tdi+q!@JJw6Ys$cXrs0edWa0HVBzdO!&+0xL_HaqD zt`T_640DpYhBB1^a+qD!>q{+h{X5Oj@v9!g@wQRpuKS9Aux? z8?}ukc#f<}=bjl6HcBu%w5&3%k2gf4BMmR^P$#oncr@HQN=T(#p z%04AaPyda*iuV?SLh4p-_Mm#J=Br*)^$~f`NenI<?3gevC|C>ImxXWKY^nbTH!Ke^HXIGKK+g z?~54WU6riI;MyCx5Xp^p$TBB%biaEl1%A18;hmk!!l010Cv~lfpN9SjO-?_@-*))% z*%CPQ&;zv6PYpTvCKzB5+-@BeRL6#E2Y6T9Cw;FXIps9>aeCc7sGDaBvPXD!gD5D@ zr_`(II~I)wp5&_W?jXo(`gvv4ASpt%FBw|3ie*Et@BZGRq|Y*(-TH2vs+P!x)QY)= zX%MRxf@F(|gqdq^;hyV#%kCp5E6@_F@^AARX%|lMP#85!U82B^8drCHyO%SVO;=zE ztQs8y{T(l-!(S5jchhRAs}I@230RZ`pOFHo?`CMd_?Smke4n$9>M6dy5esmuKyg(n|0PWT^GcYu=mtGD8H-f?KL+urzm zdAlAtpUJDgwmGJtd*;N+NUq3hkm4!7`lx!g8)t7nU;U&ZRRVkJlsm@fnHg|i{gj?Q zZ+H*fKW>LV@w@sL(%rftpke30&ubKku`SwD0Z&L>h$*VorQ@m_0;CIz}qiFEVIb036nl^*)IrwuHd6x^Xr@L$3=yca!jqI2+5 z4KhY{WqX8wJ;kg19`CM*u@x*wj0R=(zpsSZ(QcAob87zRZxs*jZunk~pCYuWx=f?< zD2|nVEqbq|8KcR3ZfPfWf}w5odirH_eib4O`AQBd-QzF#Z=#Mt zNBl*Z0QJTm)}{oguYG=fAG39OLn_ZM6M$nm7z)$rXuI1N>-x%I2+mE|R$LiyY9H8?j@!ko_yDXTaJy5*ldWaYk!56=&*V*%7 zjrFOD-dI8z68SDPj2xgT!D@V32=Cli7x0n3rI+`gxP-ih&-2}n;*y|nCU(|Ii^UT{ z?y2qES7f=-(^t-$85xs<;HM35Z8K)@*70AiG-~b(EQZ0IpT2q2U~8>N6CqF91k>|0 zpF556ccF16m#=&=nSR*v!iT+JWGi`5j15U=v?}e zq-Evbj_JV*YO{^zgCKZ6B>I*5<_tcyoIDtEPs0Pu236-hm9G**pW&==$7@vzl)MP! zvdi1|p0E7h2&>yg)FG&k;gXj~dkXTV2oA_it3JVK*K^9Uqqf~(G0<=KTRwde;p06T z$DG*+V3YUOFhO~G1v5|7m-b}x($Mi?y0CtXF9r45_l`HIf6fH8<$E&{qcS$Q6{VUj zc3;`SQU6QpuKV4{;gh-CWXLlbz5p0&}wr!U)i=eHAtnL|ALO*O}e6LNr zPkzQS-=PC{^8_?-a7*s$T@UYM(D(X?PCGZ&Lc%@HPwejglM{K)Qa_>`Ho(yUj+NMB z1>fLJx#oGe?(;5sD=$;K4QCTUO+mqao#D6=nth*(F&j?(!E_ZTZ4Zf+DENXz#-36W zY=f?n(q_)fMiJ^8S{n}i+er|2V!uWX*A_e%au2*L!uuP?f687lm-$PLL9&4ZUVRxy zp!W6O3IS)P5^e@|9(w&>$Y+!t8El`{p`b>ybX^g}dA&z?<7xlUw%3|fvNI%RPsu=_wX zPMh%jB&AlBK#~(z-pXACS9ArIGF@NwOv9M^6xr~N(vH zv8d7h>z&MZ)XkkW2>;ea1EsP2%Nt*TK&^!z(>UbXkH@nB%kq;23RDXPEe9?PE06$Y>I?=(MWA$M3& z)!xn`93(nX=zMi?a}Je-`fEk7>@hSyQ_Y?7@Sfvzv;i~|HGD^ zrq=C-j#rp)d_nZShW7=EDxQmoQ8C>|{Hb9)?2^mH{GQ@RvBDx}JbzN`O%PCH3xS{} zxyBKFCsb;;7TSAhc0zdeaNY@}{L}j=rbOe*ZXOFhh!<3Kf4a>B-FAJ7uqY7+EL}Xv zWJwT1h;xjR%nJ6BF5oax97vcj_=F^r((BZ7(T-4-J7f7u@NFzo(?h8yPO<6WM94aE zxI$+)259X$)6N`sM%%1^ZS4t)bSw({Zl#8P7D4I5I;PblISJrhrM)ddp6CYks?f_v z7v_tw_LS4+u^=He?k+yCZgXJ$0FguEW-Wg`IgofOr^JOzW(5BE9&ra~-W|ti3|ar) zw@7Lf%qO-G{~{+s<;88oOHy14c*TJthFx#Zeo{2bnD}OJyd!X73#WOMXv*F)Oim z5SY#<5ct^|iUgD8irQn6+#t9{HJtU4c?QGzISR)}ePwnc{pu3B2A0YOdZ zSKay>FfjZdIw*e>yc4hxM|7nDFH~qu@t`Bk<|N74Ca8QjkL>gv=fcyb@cN$P_5M&^ zELRfLU!OvWaKOESZxo`)T2K$XefI(_3MLA(()mMR!gcyBX-12ka>}&X3 zdf*lHRdyZfeTUKr3KV>C*?#)V=0Bqoke2b+zD@Xz90jv{Qc2gP6+vk}-n{r+J|1^M zo)~9WohZhPEEPixYegP(c~l+Co9b(zbMvpDsgi#`h`k@qIz=#dB33bSKCMiI2b2@? z{GVzj)!}$}E=_)g{SoYA*kYybTvkM2or5yPe25|h+k_v4-Ia5K2&wjkbKW!u;9Ph5 zNMWA{9Y}K&okIyvmZE>>wXrUHLLg`e$8RXrhP;Mjy2>(Z6!9(wL)sOEuGilH={dH? zy(ONz;LdXY@8!y>56UDMDIy#kQW43}G%}P|_80$67xRUO=w88^R>sE{wa-hCC9L#_ zpQM7F(r+ysXtYk1GUYEn0&0Ekmy-EUx8Y`a zzpwx5L;uY$Q{E}lxXeGZ!w{eNnOmQ%3tsXkYU+rS!b2mUCUrBfTgZLw6MW=q-ta|%-Z zH9V5(IHxZZlI!%*2mafSfD>A!$4nZ{Y+ijQ(oe_*2{ zb;Y0_t_erpvBvw9@8eK<|9tr|2fQJdA4~|%7)QvsPOn?t=n|?Fo9Q<7vy5Ouv-ePL zy5Ix;JSFrmlbaR7Cn9%x=8>^rR11^kdS%YO1iP~0vI(WuPw+g(InJeHFEKh!zkRBC zUsyNMKD@^=#{3k@noEYI?cHR!+edxG*5*Mj*4FcE6wD2nK``OodawaTP^OLk!Bcte zAPQVLhAkYzzG2)iq`x?EyB}22?d<*+`|99&HhJh*1P?g^QUs=+jfQDrcj8P?sr$); z_&zqfd}s0UJKU^U3=z-pKL+dLLEb{|DAe)jrlI}hKb>g&o*K8dYHp5&@qacKW;#!^ z;Sxb?{h@^G3{ZcU)&HV@fCq(_)%^7z3KC#^^!4{h>hv%$%!f!1bh~n)Tdh#h?AV#N zm~41-f8g`rO?)_Q)qVV5j32nN)u&4G7arjJc>OSEu8<#mJNu_4w2FShyGJjuppLi| z2fFM_g1BpRA-(-R8L}de_z#D;+(3`=MtGfu7dHr#9E;|vU2}1qCBSv1dGZ)a zM{rP>7|UEZZy155jRyk6=*bX-X}~ZZwbZ#hf2RwS>0z71v)R_-yv0 zk-bAM3NNG1#biEm`Uh(|$D?wEEnlGMa@t3F#QX!wWC9O=1S~2B068}c1LiJIDufH+`_r&!ZtPtwaAPkNQJOB8zQ`kR2GZ%Ui`B0sbsG4+?c@VM}PED7;X>Gts-R~sF zrz+;~rrqOy{T(wSh&)}B4Z1tU1jzw?r;j4)zpzQ(@z8LE`7OR(<4C1x{q_x-_700T z%SW3*oO>){L$&e_sErFA>5pCNfYf4r>CHFlg3$gOy0g#}!3_;J$|FLb=#Hbu{J%{R z!SDz${-?E{HO{%4Zc~QriF#ipD|*fBl)6vy##)w*Z=js%Nl~|&)kIa zu~#>+p~;zWXQ?)T9-VW+x6(y!HG=%mfy2}^uF8n1ephJm zE|?otiKGNk^va$fTc6uWZ6t3;X#?&3)XZEgFi{|h5I1I|T>GlzK#_T86 z$L27&U?^IYF-3*P+`oF)2DY!^wwF-jFXHM$kYT!W%H?&|XJoZ0Jo&qHEg9!`Hg3PY6)+AZR{_=VMpcMKNY6L&}D6Lx@0xN54#A*&guw$SKNxQYKxpYHv>m!kqt?rXYsAN%Uz(eJzQE^hZYMjTU9h6b$V zk^Hk>>HPD<10dkKEY(Ps$_;^AOk9)a*-l`$yCgifuxA>(2dK<8B)Wpp{@+)#?GQRv z3{WT8rW>EW0~vDC4QFl+UdX+v^d+NANrqMA`)^l{x_9G8kISfby*nLvcuaR2ZjvR# z0wiLFWg3h z5pMy3IKL!Ths@=ZEE2|XdA+U@pKa2m7uB?$@tgNE;y1GJy}JzyTUcHRBMBORzR z_o*GEm!rbr0k^3VkNOfQiOIIj{C#GLRno&H#D){h=n}-G$3%1#s3m`pQSJIl7pfK+ z$$Qf4&+%t0<@#n#v=r#QVht@~3>je)zHv5`uUsG6ceNM9z1%)y>WNX7XN}@Z+z=@X zl()Jg0T0bIYY&IaeU$gn$k;W3k$Nn7i`ai*hA7W_@90E z^SaVR=mpNKDo3t&qeemkJL+TD72NVXH-1f4k6jej32-K z9zc#=o`%WjvKDMU+zIkkE#d+EqX?f7L$Yx3WJNNQ8|g4U;Y+LenYOGpptdjxprb?c{V_ z!^1C=FQuV82t9QSlKU6`TGQtfvHzp! zJj1d4+c>UBeeEq|@0ILP_6TJp{VB6VB0D2QX12)4EM#O08Chi{g_2cNMv)mMTRooF z_lx^DuHX4P&+qy99LII-*5b)bY;TPKp*OrdRP3~46hpzvdyQwI(`N`X#@Ua*`HEhJ z>$bK#|VVQ}3wndSQMCKaP=>Xd*THKckJpCT>(|Zaxl4aqmT2a$kOoz^px* z`OlsHa-riDI%Pbw2j`+$d;hMD2UBs2i=g%zlGevQR z`@pfG)!3SSn^PP#Z%#7|wk}i6#x?17(5(4hyL5Ul3IQCucVB5KdZA8LRonF8o(#tE zq_T5eybL<>h9PG!{A7b&ozcMM)ZJ2?4D?GYA51$3Eswtcv`+MMA@F5o zXW|a1KtaGS-B?ZbF@lM1khw0mdm+%!Fo8Zxp$|jPsW%QxZM_FU*Q)t^_fJ>s4qOnA zlwnYLY9MAuR!V>)q_x zdYLAmK6>n<$V8n9v<-`uBf_F~G3sELyO>+`0Na;i8hNUtU2yGajs3_g{t8@4%@F1O zw^xt3&lAVY#pE>6og#iBrxz2iRRrtMFAqkv+kplJDRK_k+3tl{7=8h&ml(LHGnB(L!0YeYK z>HbDBNqmk3Y%bMuRn4?Icw1sNSML=R09VcLgnjCA8hG}JFNlVZv<@%T3aP73mp;Ui zy(dpYFQjDQNW;}USB7(sA>NpLH0G6F7eae<7RUbE5WuT;tNwrajhuL4sPjDLxC1SI z?`*i&TAi*1`yV^q`t;Kjm_M%|<6lQ^0`Jtp%uAAz6ws#-yD!PZ*M-w>DlR{{>C%Cx zuQd6;q)!UqM0?B5wDI+esHyAL6eYaw3>ocv<>iz#RZ!63F8;PocmvsMCRNPS2UYOj zjm~)n=3D`sG@X0hy2BQSuFRzIiY?I&#E(Sq8F=i2jw!<9^roNmFQKEnUb=PC;UwTd zr%@#}c@tdGm#v<7Uw(vVwcZl06T;Jop_KFglVo!omvt=W!i{!MqJGn~Af4pnS8Q1; zPx@?GQbX6!m@8xD>IM>GpK_0U9Erl3WZIS_OYRA9ly9^|XfxTNRkBd&#hW!%oJ~?z zsoQlrh@zFEgAP(}7_n37pb|N7j21Q-P7SSI9fsh_>U?~;_}VWB>rncPR><@s_CXL; zh_$HzDh~v(ModzMf+hKnW!IgpP9$t77#%FwxPdK~Xg<cn+z5PC>D+{SsQmX!9 zS22Hv(AmNZM?TFgj-H^=gDe4p_wIkmgz09$wZ$JIHkhO0GSaNl)y}e-~FCfru>i-hq|^4&;>TByFh7{DMCh-QmY+eHZr; zT%Qc}%}`p9NuIWvOKr}E0He;)2ZhOdkR$1pR5p($M!pEik}Ol{zFaU2c&zJe&s2k^LEJvx)0XP;EK;-OgGm6Fy-s zNl#2Fg0|6Z3EXOQ zXL1V!y+E8&cc_QnUjfb{v2{Q4)VFbwftUA2Rm?4@91#t=&t*il|I?W&z3k{eh%LC; zXV#HbfGXwUdY8O43Ya`|q>DehO**TsOQw=?9@%!C=izRhk|~$GO*7A0#j1gwsSZ z$p%$DI=ua-ixv1o;2HmS-Djaa4g8Pygm|A+ZNRR}OKa6ASPue`zER&VJZMH6L%r8;vQAzRV((-vvPSjVczxgi6WQO`XmwWUw<@``_t;WL4 z)mRb=e=Bx+{sT*eN9J3#iLai(RtGcSojFe9`LpcRHw4{Ln}5 zpjQjp3vZjR&dm-XQU9p6nr1u&@;8*3jITOf!lJOw!NK7h%FuS)%2qi1?-hPNzB6pu zz)gb+**7=3yYr@S;5X57#c1FlGXA^$K9R704#T|v>G)eXb>O4(yU{0axl}Ner){Lp zEk1#3sCwc6NK*aO{G1TmH?mF3BK#89Dsy*$M9-xO%z!=5M|S z)}ZJVjoa)pKMQlk>`f~=!Hi>G(ruKzs5w%$)B0xn_{m z1cNR!d0}nh54iGHmXn~!?I381s3{+}YuDq%bdi#F#RMhpXR~gub;m_Q}{6-)rTsn_%s{lDZCP{ zi=1N~fl*Dx`;o?tm+8CyMj9Tvs%d3*Dp?|#Er!Z@b#N7$ig_er(NwWmwA6h(!oWob zQ;&33tv`g>a9C(|SQS#*50eh0@lFa^Ch*{`d6WJz?;bwW`XcnK#5p|nWRuVN=JtOK z#bgoLAZh^4Yppid4NFc!gIJdRfv-aYx`lVImJRV;fI1b~V=+&PPhg|ebMc~f;K9qy zuviBYtpTV`1jc9Bq;=!5bRN%}ss%UXbham)q5e*X#mP0rL9^PgXe(Vgzk0847Hwj4 z2QK5^I;f7Z1?|M*CU#z5&6XKaB?0B4&xiVT;x;r83*I|uk|YNL`7-Jlp`(GAiJE;Z zOsnFIh@RzyD(b9TaHimRNyp218f_j#ri#L{2w6k()1FWa%HmX( zdr*O?58)9ZZPL+r7W4Kd_r1m2C~}M7R3u_BL-v-oR9xc8qZl$|)G+)oAdAc^WZlu0 z59#1~it?>j*Ti|G=|t~5OdyoQ&q{muS%Euhh*34%?H5gd3C_We4WWU&R`iWDJjuVl zKQyu2`XbP9q8FM}!TCgtNe;Msn}kgIbq_IAO{%m*=3JN2Gsf)D`P=0+l9tv)EiANa z;q>~_kwf>1($T47w7=DN5MZt8xRtqqfgj%9b4riYIb{i>&A#}bSr4c%Cn6MD^0DPE zN{?JTAsu#15fxNRTt5v>!eP%|S2XpPRTG;e56#alTBKm2phEAo$ms?=SiT%+E6LxE zovV@bo0KusG5g&fM z$WYTXHg|9WU7d<~Mnm*Ass%orEzel1fbwl!nKC8rUR-#tJ8{oDr5s+w7q>lA-dur< z`{q>Qq=q%h_86^87keY|=bWj%r%bLDmW`AyozaZkMEVNV$k>;Q|M0Vm^n{p;a?bwWI#5O zyu7p^U=D(IFXIPfhxuW0{69L*O)3L$+qG$y^e6j+JKu-Es__>$*d0|_b6=!NLGLXW z?by%HtmtOaj(ngzypAxJ>w10T;-BOz~mhF-Sv=2sV z8Pva##+mqM*T{MWG_kQEUw(*kQW{_DuRrqVBy&M$9sTdJbALGC9jOtzRi)s9bH1zP zH>|`Yu@Lt0mq_%VHu%}>91cvk*}#F8Pg_4{?P_r@U}EPj$FaZr1drhJ4f1FEF^q}4 zA&8t@3_VIi1-IDZ&ZA#e`t2~!0e-ZVL<>2)UfRH|hYAe&Z--)`ETeOhn^h(q7Eg#} zgmLm7n23b4~3z3P({yN0hlAu8JFlY;lsx zk+Hu0Lvaut{yUcY5?M^wDIOiYx(&N6-IEzalWeP_;YHmri5R@% z{3Rg^@lqs9!w25qz>@KS6{c~!WDwTI{>iitoJPWX2l`=-uwsx+>fIvr_oaqXF~jV~ z!{KJ2Kbj=;pAN$#{Jr|JjHpG#44IRHan2WauHm@?HIot1j31~96vGzIZ|UPE5l!?v z`k%M)_(HS7RZ4q9JYh6ClG0K^hSVRqV!I@kgrJxo4BXKmJcOE04+sw&9eD|ZF`pY$ zZVqH9V;$(Ht0$Mj?ZsPXA_R|GLt5Wbi8_})0d@uVl1=B&2Z7g|;g!n4H&jqH=PG)g z{{06mh}#ZFM?S3qm-ks?vXJq=5D$wuPpv| zHTDUxl0silpGp(>o`;xx`s5V=i{(Vi-;{oS=qkq2QUBeV~X3mMPT~o-F@QQ z=_q(zu3loNm+yiN!wuWlf@jTf^2mtXG~L?*#FhH~_psyD_x%JilCG3+HvvCq);7GK z<#ogM_gMjfFBYay|6F;>C~0B=59PB-LhT~%fq;8dz@M=oA5ugAsFu9BB0|{l1tXg!xt0?h~ZFrTEYF?e8>m z{&<*OwUaS$)C)k6c!!n_724HJIz$ds& zFNcXBC$a2Cc3lwL#CESH+&zd(S4@NB-svyl`3ljLf(io*OeP~^_zzVD?K=g{u6*Ks z?e+KcdE#uwa&sgNr)iSDJaQCjug1&TiN(~=c+K{_I_=m+n5VE!Oy#JBftG_Q^n0HQ z6+T{|B54wIra{v9#+fNrmU>98oG~oe44#H-_H$v4@F{Od&Z?DACEeP@sOcw(f&ctV z!4S4t{8iRv03Ykqjt1qY*&xRKi*r_u#La#7b=A+VyDAvemW&5qd?%oT%YpOO%nNbV zaC>F2`k5wb5R3&b=aR~&_L)q)1o!U~ELSkCF#NLUgyLM$oVpe$nYP$`AWjX zuo`tdXgP5I9Fo}o85GO!ujD6QZFoC3@k(Z6U9)ySXZXGe~u>%{4cBZel=tJ!9M`6h+oLrF1{}FI_MT{M1 z)oh!hUJ=*~WJ*t|EAvf00L7)Q9@kge&+$KW5?#H)ut-&;cLp~k8$7i}2c zS*ndZC-=F83W>!3z6JjF#oXY(@8pR10y4!Ujk)*j447ky{<7fCb{N6QLD4pHL5I+0 z(|o{^RE!A?nL)c_UxG#;Gvq9-Ep~(jkN0*7eIhOIz)<+nN4?~aB=|J)b4N8(>MhE@ zB-(6zwOYcS@*Sc6?Tr@vPWDju@D37nfOP)x!B0GQaY~I$jcDcEUp%mu zE;<=ZS^~${2ZWzn-ZlcY5%Jz7ZWVq!zTTu+_ncn?{8=BVjv2WY;GJ*cGm$kHb8P*k zbYBovivYjBus7vWYaK2uI}9o>T;W37J+F!PhiLiX|BH&pFH`y&G^i?el8cPKKzVmh zKAr6t8+H_V3aIvaSJ8d;u)lyrrEGB`fA=!brpjJa(vc5uzMp3)y z(doOz@O1JgS7Xm^!gVU^yQ4}6GO%DO9>f?9#BT|%m zafL@4!F8P)?`cA{_e<314Ue0E0jPwzruEu~oe*SM$MIw&#uco`ZdNDXEs28r-Md52 zS1&cA>u^-ZdfJH%INTIJDV>lqiq0dA_iCv{xS*)=L_4+g_GJWl-92^uMeQGOtX{MK z9$3>2p8pQKpSyoh4MeOTSC#Gl?VnDI)AV~8UL~BYW!<%uNL>cQQ>*#8FPVF=nYc3M zGtY4Ou(r> z7hhk5$YC?rw}knw2>oq8AtFJ$jL6o|uacq@79i^6)~6i5wt}pf6Rtx4#SWsEjheZ+ zU1}Dt*8RNh4F~MtB{cJ*YlVdp!3#&Ovya)vqx|v6p}7m`8CZXQPPE>~!5B7`0d;eH z;$6^bawnm^80QO z-}mMk?A-y(;#-(rh@ku-agY!7{G`{zX09fJrF_iHl3DKzzCU_BOh{RjiMrC*qJvLc z=|Iz<^{Ge6;XdwHnyp-8PLjs)udn&XKk%}^p!$lw@-P7(f)`H(_6+_B0^PeA?`?|v zZMaBKjKp3gL42yoyeaoW#1S2|kqeosIhlC#nKxZpvr`;wN%_}1RW?`PW_DjEuSw|4 zeqPPITWdn~0k?F_kDC5ArA0@2Cegj4)1FxPNhA3xav#{{H$N!U?VKbAUy8tJ7v*0^ zJlZK8zTlzU34;CuTj?K2K7;02q8=ry?mlRx`H{e$clHK8zHu4#*JULbYn5$?C1dq1`;<$>niJQ(!AZpH9g()V%EA*k~CJ@u zH7h#4U0o3Vx4BYI%0LB|NJt8SGv+GpYNZCTFQWc~#P+A^0S?$zEzzA%wxZNhtJsH+pSWi(r5;`%4) z!`XHI!MJ3iZqq|rSPJzE*|w|C((2%xc3bnzW{f04lJd_G?37c3VU#MLK7;*~n(@`#}CaX4CdvD)9$mxHP(y?lD7@g-CrO;b<*%Xt}6N#U0SW6e+Ca>Iu1 zR$lFWSX!Nne^MDjhunuxyq;W5{Du2L?q7-d_BT7#A?^E*=J(E^yQ!L?(&k4QP7Ne_ zKl4egh3-&Yjs7=!69lf49A&;{uZ}6RTW7U5likp|M8OPt9|!1pxN5L@Q7GfGTf}uc z!5=$VD@lp2nyGR^%Cd%)iVzP6dMZEUii%cd!nQ!{k|JU69xTouBF{N;;uV6gbh#{3 z2R(zBq?XDeTO1|W$Hdr$E)EAkO}OQv{2{#<&~8dNA3ME<|WnHvp1PStW9_#>poauCBu%qID&tX87#su_`o~nbXe^H?hA+c^`70;2lwE&v-3Pj zxe%-QXqNm|i4og>8{deC9OTEK)^$3qqa5c@_A=URmQGL|jbmLge;*dfLqgX46Z+c| zaWZu1sZ0iW6;A9n{VG0xBL)he{Qk=}ZFGlAgzZVK90Lm|c39=a{c;nB{g=g6%gKpr z_`=pNb4&i_BpmGOZT~Aw$N7gTU)z0n-+X|J7)P4gIe%Q zau?J$-SvW9w0IBm@QD(P&=GR{m&bbsu+=BX$qQ8h-ByKYA&+l0mVc|>xp5~j6ln%$ znh$IDOX8<3pYd7e?{EI$1B^I^%w6ktCGnS}%V<4okXA{fn=`c*n16toZ)#Lr9g~xz5NTArA+u1>KgN zx=QHZu^lQm81X_UOIhR$ujmwl8{@C1pN;5;{auD?f8qc+yySB62`PSVji)y*8Js^q zw~M2Kiw3<%3@d?tn*UyEb+}?M{`Oge@w*rC+Ruya(!Kq;T9V+k(N)?pid{bF2utQJnM^vM^nc&)31B(;@2&BUv{f6e8;N zfoq2mVUb%D1B?Dg@Ug9Rl>dvrJRXf-?z65ZItO9%d6k~1oh%&fPRSQ20ZibacNKrm|c>Cay2@rp$ENc2@kc6`*9C{|yCJ*ATcauTb z-!t|wkofj+QodjSdODf!R!x2OnDO;)SsA&-t(&YCUfE zp~V2&cUfcOpC2kg?$xMYZ^SB2p;FYQ{QT%2J{+I=CXjIR-7VN8d`#hTF8YqY9{-ul zzkjI&wqHcU1u|EhK;q3&cBNLJ02Z|ov^ms-0!X9MP%&^)tiWeyeWlG$ef}tQmlbcx zTOkB7&r_1p>i@c6!2Tv^rT85azV?Z>@jgv;gK)R5vqnhcCult~TGgy+-8Zw8ztYb& z`E5Y0*ZBQY8}&U5EX+_!o^0WPc2Jdf>_--I1RPFPK7XCR7=@)-yi@%PufdVL_sjV3 z$Y<;=CWSj1E%RU~s?C%5MP(7lTwWx0b$yb-T1RiaKCJ;Ea>7se90;VV!mGPRY0G@N z!g%_F?e?^O+#FhnKt$JZ^Jlm5_u@t>qIPcLm1n38ux9f_tNvuFXjTea6doaw|Ajb z0rV3s1b^4b*pRnvSsUP>Ne4bvv6P2K)cGK-HF(|hh~yU5uRJ{LwY>fs+{xvWLrqj) zkfaxWQnBP0F<5tw)hY&@Sb~nSDt}-_#sU20PMl#Qe$@$C9|hJMmet%)WvlgZa&*;! zxvKkPRMwP$*;_Z=Yn)OGRueUG^~v_47?Ln0bQ(}K!J72rTTe2a1Td>4+i57MWC%CP z=iiTCpQwPL`4n=R!yn^_N73n4#aF=?Ux|_LxOT}I^rh$LI<(|}VfxCUP3hy2pJBiH z-L~f42YQ&Df45FW%)$r5$#B(KhdZvtyj`{UlTt2vV|ELln4@&z)=G7`Sej$>~ z=H~tP9+OB7r>|tY`Y;ENxS0~4Bs@3+e^!=%v!3=V_>=M8)9U8p8v3fgkzaM-(?+pl zlV7RrnL30z+-x}d;4MJ#^#|CSN)6%B5ia z@8Hh>eEA-okZn{kVuVju!k1i?1uo<7%|AIOmY#3oid@nx z2hWigc-&=o!i0t@1%_H*=c^P3uH(g-)(gq{HkPQ?3iV(i*rf(h&vW0MUrC3N1n)mB z5eg%?_2GyAz2!S+K_46V?so{o3A~%X*_Hc?oCsqfB2{VrT7kIep|Dt9^ZWsb{#)1^ zE68?-oLyoF#n%@dcyL?j+>b^xanum2d>ffz_<(SUZ^?>_q#kZQ@{RxO2VCthr`z0*C*Lne*?tWsSnPlqsH5xm6g>Uc5{%v%?S8^p0Lq z(Wu)<>Pjdzi@v9i%Y!ez1ZwY#xm}UYB9C10w;(ki>vx`2YQ)aL&ewnYYDV#z$lyTt zKDa>Ro2_pV5vgX7yRY#6$qU`jsMzz>IepVP5m#1)=B1T?{l%w{6$+~97Jt|uY~y{vO~bxJ=kA%gt1rtZqoDOhS;6X$LkFUE$IsMu~# zjUi6(3jKLFT*8lEDcuFv=lm~Xv%0(Q`OeE#lu+Bcv8P12;<=7o^%3^IU5p)nan}A( zOfd*0S#@6zD%W9P^!3e9!E_&}h0QL9+>fM1tDOs3%~8F1n=~u*5-pvAzVYx@n`^|?Y=e5MkiasW{jp{S@(t$v@*nBT89*S zz^#M>QHiYfNPqkwLET3n8~QaoZ$EMr$6{Vmy0+l(n+-?=8wBQeCF&u+lH^phs1q%k z7q1@DQvJY)Lo(YYjf6!+IK^L2)Hs*Q17W|HbX=#HP2qTPj{j|Ff(#)-Mdc+tXlLk7mq6Rz}cDGqGP(02J-~1N0J@)^7jp|^u$4$nroPn zygTm4d;2qD=~ou0L^b#KO2&*>nXKCkV62oKvR(LPgLXe7Y4-Uy#7GW$#~|`rB^O0( zo!+IMWV`s@@a9IN`~fGV<#1G6^Vq+^iKmIv)H*vmcz@_J_rQ~qLpb2}BGHW1h#%s< z$uF4Si1NYl@kqs~+*@AAo4x4f5l`KTYmaQ+5m~BB!Z^3`_*L0=51?N2*w)egbsAoN z7|~c`|1p91*Vi~3Wy>tEkl-av|E;eUC8jG=v!lP}5Y%li&lvd6aGyt&238%?b=c>b z4xQYJHl!H;%Y1Y`ROc$X=I?JD8SPF-d!x(xmR#8qW*_$)FJpM^1L1@bzIx3}N9+~o z+^-)h&4x~><)H}|rCXq@S6-E!tQbYyb>dQ*e~;$yFNbI&wM4!S5+ept`QO8?BawKU zoN_ZK4(73zQBxOKmSI>m`mpL^unBfsqD>P5jtqljOZ*|bp7S!KY&mJQML!(Fq52Cx zKc(es@I|t5%l3;ABdkP!HYDnts=<;cZF;b>j20|ftW8ZC`u?Hf-^>n`mQyBr4slQZ z<#}t5Dk`O4B!=Tp5f#)XbxBD-2ao9#8?qz(B@tWPsd9>*Yyr2}rII~77CJ#iw$-3- znDhbxu@A5P{rxop?EUmfzf-IIVY(?lpzoR+iHSIJr@?QbyihmZSuUfv^9Cex{*2vU z&NM>LwnOQG1z9*A2VHGkmg6Epz>I0~S(UwNkbh$OHt;&y3>4Fa=H?{&Q%HDYWvOeL zy@IpWU)fUsCYhikdHT7^Z@wy+N#w9pEEC8ejMvHTS(eWW{5G~%3?}_8g~AweomRi9 z3`n~u{b9K{a}G{Ub3M{m{?$U{^(jyBp0IFumzxRJBol`sv^y_2Qo`8`K@K@T58wY0 zk4pz+p14s|tHA1s@rn4aGz~bUL;3#poVz(%KI?Tn=TuZcbkFu^OZF2tte-l#60rGc z0ETsk*3MPEsl)xHE5VfmlSQE3RNQpTkT3%M)Lx5_8QT~>b11oZ(=$`RveDNy%&hnV ze$DH~dBqi6Lpr-#F3lm&8>qPdtp337{9n+KHnL5-_qzZzA=f9)xK^9s#29&Xv%%;g zEO`72`R1A-0qyKwdXtAobl?#GxoIeivKPxCb?vHtdfv#aX-`ZyAiIM~hd*(83SNg{ zDS7)CbCgajX#AFp%6FyhaYwA+0G+t-AZ8jKbN#-u`2*rkcgP~gE(n7|+f~fX#O5Mi zTwxk|bkfHMDP8YtPq6wPfyPAUR1mArUqrZGanZw`76NjZ;tz1|us}gq`rd_*>m^wI zdW3?GO63xKUOtx39zFO6qQh2CMD={GfZ^5oU(vT3giuYW#gJ$~kc@?T@sq2^8aW~Q za=7SXp;8T+9_$fz551hnWlNr?e)KlCQ20gnaVy21B(~R%k{;0{8AEEhEJ&LofV}h| z>AfkQPJGYnFVkFlCJCRdmUB(s$}y1nc{qmiT|zwwC(R}o_?jOfcj3&JU-~|S=(*mW zIUZBjg9$nLBkoY%UGj5pT9B99vydg79CjsgviGz~zy*e10Aktf^R4~N%b`fb&nksHwZRHvm zNxE+jj=wja3$iC&2alJ5Km+9lA6UN86)o*_6@xs0F%dtDo8fJn^eum3&E1!y(I*Va+kI$>ZU_5#dnb2zxf&R zlGNuLUq|>Mbs$px(i-6btSoD_gvUzIf%0nvQ-$yrHCX3PO<{=J3WQR3**pTh4uY3( zi}0HC-CBIl(&l=2^~(?jtgnq5GFa1qVV6mBC;rM6d=tx6Wf-hUM&307BAtYqLl~<~ za9p+;F-G{JScSzIl0(R_eYn<0$8sEoOX<=+2NLdKHS+gKmYHAWP?VA|E*f)*gpImK7x8_}@G;v7eonlM@ffxX?{tQfgnY&NmtAoT^bp9&8a+yaTx0&^ z>(yg3*cdTz6ma~=h_>Xiq}+^RX53Y-rV5kjVE`_iqj_|UlO0EyK1CYO32s7hp?fv1 z>dj+N8^`bu+>(o|V?{XGFUm7LL;oSJE91^L zYI=QShW+QEHq2Onf6PBrNu!_oV)4qTb(tgU5IW-I?b#AX=`oXi*E-?Qlqv3t-1m52 zJ2(%wRn~P|O+cy5=7AcrvYqhgy? zG}52lFZaALeiGPE zBGdM8`K-Q9HIhwjgDyOkp93cwuSegeg)St_$HnReb(leymu4^N$v=n#gOe+#JEEo0 z7*U!f8Merc_jbL}$zP8~p`vWz2jj%a`}nl=Hmk;mYZ{-cH-%U^Cn#Ym6d>bvz2F-z zMHf_>-(t$dZmWxbLu%4*RJsg~cCU*uz~Naj|B35r&ZukUvwo+UU5HDLd@iqAwo4(k z-cRw?kz%rsoQ7up-A8=i zIuh(csc(m{PEEG#PQhE7*|?pB!I^jhj^B^j2T*GT4=I3f8^k#&tiXK zk0PM?`=+^cxb8gesSpIV=$@BDmbLU+*t+6BjEoMcZO?{@gXVkf>%1TP|4tLgJh^@U z(_>s^?>Jvjbo4hwy+cMkZZ)(+yZW5o`-bKXv}AI9rO>cXg@)Bk-*Nx!JLv2@dN(oR zjSGs&&l_w?=qTb}oH9#$g>=niI#M?Mca<1bb!*8NU%yZ>OAgM zJc^V^t%I*W*L*^bIOAhldOiZ^ToyX{jllm9uCCpR(m2ejfyk4jI>LvlKVyb8M2789 zg&Y38rRuU5pHxA#^mgHo^LC#5vG7rflV|f8jEx&67kLNYr%BDWIlB;#)z$-yFnfi11 z6OQM|J`QGtmwwvC&Q>Mpn*0 ziox-Lsj*{CB(Odw`R(te2nB?uFyw3x@hCv`yuP?->5t6=&e@?Ab!BFD2N-Nubx~MGh?a}rqcn2!|4K`BdkurE`TvO+lDB?yz zX{Pv#D)C2X39D{+n88K{S$^}p<8}9+V(Bu`XaGr)3R=x_99w;!#US-x;6KY%hFc)1 zP#3b3<1~Vca$k-2C(aLuPWTz>daJktLkCD+kLFs9AW-6T;~JD@VKfqvUD=jHi~lqu zpA|+fHQ{}4AAfz`##baL^${m>AJRm5li)KYUn(Ztah&#Y_GBA?+gMdcZ_@RC$bar{ zAbBXR3nBiyk9t#&6+^z&&rIA)&jLTwXblBt#wj4__=#mSbl-q`_L??^#anzrOO$x- z!46Y7oVql2VoEAb3abC)sl5wKxsXQa!nSZp@*=2@-cbJ~V6~0|HGBRmNB=$tMVf{h z5wrR??D<}*(2>0{joOFTqpMUJs*pv|(70ekbs1p~PHWm_a{Pn9Uxp(@A4HSz>hWQ= z8@kg8m~T6p{h;UMSJ3%Zn~Jh`RH2X}Bz5E9RbL4B#wvVLr?bE<(ckwU;hVR@oVuVK3S0;u(}M}5`Hxlk~^W1^sLUke%Hi`pd( zE$46`sq~JoV)!O3+qSPV#HVk-lxJ*0Dq5`zh<|zD1QYukX!8V8zonKmMO_rVX4d1P zR5(;L(@$i=ld~~`i0$lHh9hqoi^J)$GjU` zz0^plrS) z^jXgdYh=+fdAi(9Fvn|EQ<6#5#kGR$FJMLOw{YtCw8V)Co8b_;0O+e^3Tl&9j*np#jF@>}FE(OBQ8LE_V z+^T^ndwCIU-SY^9mvvB99*D%lGQP6c2n^DHa0N_`*S^xy{ZENs61;T{bwoY9;SkxYs?2`o<_{;v;Es5 zK})ci=;hqKcsvWFv=%A7-n1^LY~(Gy#vLvSAGg`S$al8`A(vyI^Ivh%Z#3tNT89X0 zKf*WHtd3fyqXwYjOneLCFP{`d+)cG~m!z9h_@hp*Z?~T7g3}Cpl-;?F z;?UMD5`1u=y&1<>o*eMImm&oFG}0@P9QmaeKj_+oO7+!EdT_s(!IxQWe(u}i%^yq*J5XIpl(2*(o?<-wl5iEO4e4b zaD?HL{dRyRIfS`JhFttyu0bz6FE2%$R2d1DQ*`lnALQbLWf3!J?e7%Wu>77qra)Ya z)yk_c&V+7f;oMVqhJS~?YUApN&ETDTwx=MjYNT*PBlsu=Xj4KhIy8siIMQ;6+tu$G zTsqng+jdRfh5B~qE3Ng)m-x%!pwWKGrvpW8yI&fn&TN2;z+ZVLLY@n^k9U4CS#bV> z+nYPZ%67a4&{usXJfri)7A&e=Ke*Fg<>N=`pP;zN7(d*ny!b&y?VABCILHVD%^N*n zqj75W&|1emxF`-li6PknkIqWkBpm&83?BAgPh11Hcks3$bg-p1#v6l@pFi=@{hC1M zXD(IN@xgRV?Gh8~-pn4xsJBC{qS~=a-0os}YIOC`Npzj&JYp^_K8tG95Tw zpduYs8(YTs#5|`wZ(I{Xc^emozZ|}c!s|^pjCRuQqg#=#aa-|V1WMfFDy2=I`N0)G zgAAo3NpNk%{(fEiX&GqCrSO{aU1UR_lc`wBKcxvIj&qJTD;)Fzh0v!ct+)O35MRsM zn%d5g!ST3)YT6@vx6#oQMcK_Z4EVSb-!yljeg-4o-ZOX3UVI5cyU*oa3qC>+a5cV8 zyC%MeG~#eMvAcymXsJ;frD{oigwi+iq-~L0EXWJGqWI+?**&<{(+YK~$Q{Om_nkAp z6z5OiqmXrd-l?bVC@ZK)))taz#82qRuFMxDK-I#g4$I|?kTBk;ILlO-iVH;Nv+8d6 z*I;cBY(ts~zwybc&_iYD=XcP2-m29Oe3Ax^D=$3b`FeWxl^@CHxMO_(q1%uDwUw>x z3$)K1IKDx4HxYFHB%ffB>amkf#cP+d+$M=@;i?$v*gPM-{XOMbe zO+CiJY$e(a-S^&9^ck$YfmEf%i-9eZ4>;P;oe_E1Xc8P-qgswVojQ2$x%6MxS(2My@eCmWtxT#8!G}wrn>;bq)tmSbzb~MLX^xoB zAKY}f1ZiEtV|ux+;hAH@7tD4n6k(g(@-*j;<^r>`=M>j#Tr1U`}Iv zWBl{NJP1sMwTBz4m|;V`a6BjF5*<>fzy4e=jS>bgx6;pho{R@jYI*JNO#>=l$U2Fc zPj=Q@Kr;V}-fv$$b2L8c@cK3qSOIrxmJid9UPj@A!J(H^gp-eue`dkoT_dX#t&SJR zDIDa!BXuM&t0A=GHl(7@c5YNjyn|kLppSFE$1r>%6jynakpFfVCNO?XJvkJ0E*9ULMAuTg z7;fXELGvzHg!0hFp7s8_CFdtpdz8LSyCE6~a&Cdmy?8=pn6o@Ld64qz2M#SLIhUP@ zkU&aP>gTn!1JCf-@B}eB2R%{-ByMFBs zeg(3fymIOoJD&PXYP?pbRY9EJlQI4WR>pA4ohvg|h*w1%|AeNCjcgQ35|1fATdNU6 z=%C_BIP|Wsq$goX5cG~DLLooMFCo0jVOxy;=y&{k*DkK{XM6-F>>NLr!Qwg= zPJc@8)Qqfwl;hw(vx)vK+;2~l{i@0pj+FIyhD&?#C-Em3x3%XDFX7-?O;W!K#Bq@I zUq?#Yzb$avicSAgP2xsWK#|e`Wx8Y>ySHO@hPh7~1?QU45kVdRlgpbehjlXe@vdtt zBtfgz4svXsXQ=;%TEXG$1C75FGj%w3uJF3^Vd^aKjFGr5ecZ?Ctb&Tq2$){dVD~RW zq-56)C)UM9!ip?^_=EY-qhF_cKIS1qZ$37^gw_(7;=QGxkI$FFBjnc7XHVxzY#xt@ zh-&{#3*Ca;hz`n@ea09erqe}5$pGW=|1t&-95;ry$j`)`%A~J2M}KZ6IL4L{thX04 zvI1)^0#9v8RI#5foIgFy zB$33Ls@h_~1#U-NYr2@{KXc&`R(l>SiyI{6qNki`hWrrg5)21D4_vw1%!`93PZLE4 zy}u6~?UfL^)9kGHaQzOyZU=D>rsWG~@Eg}A{&W8dq zx)$V(zPg-E{$L2_b%+zQesKd9Z9m?R6bn4V3)7dLNwb7Rpm9C)(@NoP0*aPb{(k(S zM-R=NCf1!~Vg{5O^m6oW?tX{MN^WKeqqZeHJp9+rEf~H-r*w!~^}X%4xR&?)8pDIw zayf- z;3h!agTTGNr$=3%D?X5_AW^S`! zF?^8hlhbZJRHp)KgXENaG1b;S@E|{z{QqChnyeI-SOL4>ON(V;x#M7}>Xg@tshS4i zX6U@Kzh zjmqn#?ebBVb5JICBvuo{7Voyt=ShFXy*Md}&9*`_+-y+gFCfypi^~GNehVd`E%@_{ z-z=|?@+l}ZsU>J@;?CihIg9JFfb{opa6Q8OhuCvJ;g3y}x<8IQi{V3S2L|hPIIzau z5cse)^cqMEFh!9#+j{Cx z8zORZve!TD6i}WzA~{VO{}1ES#Yb#3+k7BglfvFKbT$PG@0+{2;@I86W*HZMyw*wx zuiU=2^tDc0Mmn|kFSm#05Ac_mMCeaF(LM>h=8|yw&N*Pz!^hG1R~s#=jIS9zU})+A zMGqqnc~zu8oT}+WDR+J;!P&(mj3SoB98`yX$Pr3tbb!TQWrx$(eg{!sX8+WTN>@Np z@PW*vf2+O?|0cLq3_ z;GDanoS}?p88YU6^5PN*R9t7$lKa4hx5iZCul^FIKyTD;^mn*eF_=yImRR5Q$Rg&$ z_A!;H;BRO-SE~B0g!D3wXTEw#N91xIZl}FR?>}%phKN@@*T3GfJq+^KgeU(+=xL!y zF!(l$USI?6KX0!*(c}6GF>@|gyQeLc&{F*0;?)H{38WIS)M(fd5Q31r{0iY~!w*P! zKVxdENwSC~-0P-Ndu$4o9`dJWv$AjC%86i(Ch{j;c=gUIrRtG`DB9(OZ?7A@Q^Hh) zXdcnF3oAr5V{gYC)@gy`i%;x6vzO08pjp%MLW)5s)G5w=b&Hv8MbXcK)n=s>YZSd8 zGg+kaeFN!qlRr1U2AkrO+c8Pax&>}zZq|ric9HW(xxQen-tcQnwAGVUp5`gc#Nt5t z>4%R`uHiwf&)1BZ_^!(FFQ^l_rQ1*F^I+m>1+mIUS9U~3GO--^ck?a zPo-yGz#W1c6-9|L)u_1G^xxy&hlvnaHWAHyCB+0GT}qQo@9>WpBJOy_a6|4Dh&kyv z{VX4C@Q^(BdekpWb-#Eh1YEm6h)vGD4wY=|Poa@6wtM2S!W$F zXTY5Ud0j)q-5z*sQ0QGp_+$Y3{v#sR_xC5-&u>xIY^POD0#9P!IW1h&!1V;_E_S11 zMOaLTuXsx+9f3yejIujob2cDbFg^LF(A5E5l6sZ-#=rKb@Mc;)o4?)`g1o0Wmm*rL z(5cgx^7iP*Eu@pH=2|tr(?o@EBH@wY2Z~s@MNhD7;>3@?= z!_I8oleVg#rpg>|=QPHzQ_OFgyKa=<@4PA>1-ZDt)o)@C-G@x2_n=J*EJo z-?w9a!iM|&x$jxliKLzw*sap=o<1iX1`&1_%8OKovruRwraopnCkl}-j19q+=c~ZG z;c-j+pT7}$qo)^Z1ycTkwDO5~=UX{1+~AUbaB1wv2b6bN_E%i;(Tj)KI2HxXRPk%q5JdM>y7dM0g^B*9O z)8*N0sQo71w7Tf)&GOFRI=8|zCWb#v2qI0JDze;FLdt994&PFx&V831RFp$%F#~4Z z$rZIFayoEnbHsGzN>dW_^BbMD8(o_*?j=Ab^US#yNK&c15g9kI2$97A zgPQRJY-lk#*TMa7YZw#ib1b|MmtSCdJDGN9)1wST$`dEtsd68|PJrCP+LP5B0a>X_ zkKFBzp)R}qPO16e1x!?UwWYbGJ;X>*P>Zlv&p(teuCkuo*cR0NJ=H~p7) zF2~XcqGV@sf1f9t#}R`rStbvWW*DtqO)R*worx@c|_H}1-1FOKcE!MRDDyKH8GlKAHjh?~%n>KLMBR`+>_F+3@mVcEu z$&^0DgIo3t)72k7fmQpLgl3k1KIVHiWsa6z@Ii>2ZbF@wXbPsK8p2=JbbJEwnKJ77 zQiUUMQ$0D`d_Rp9(+|Jw%rlOif!gQUlaDe)i*P2Unj$oOCmL1-_0PZQq$Z)f>iwiR zS3(#TGWBBO&Rm;;rLSFQat4DkX|N6XyiTj z1!8A*sjd!s>OKS|$ z1i=@3Rm2zfMe`%~%ubt8hDMlkXBZKF*J=P`-KE;aQ-p4)zM)|!X?k95KTP_E+$p8= zfp;ybTV;&y5_Tuv1u~qxb_7=SAGNE0i}>M)v#D*f1`f#OMgiFp%^@}cIwj->G_LM zH=UBVvqMSfvu3U35-vLmkKZrz{*15NLrX-6qE)t534Uc_>AibP)bPy`^A>bF5d;yE z?w|5T<<~K{7;5%$=SCCe;=fUIzE^O8f4I5w$I0X=cu!0koj5RWje)mvJ;6Fozi~mh z=weO_2Q^sr!n_rKYI^TC*a05r;Zvh{T(j4pY4?^D7nQUw;|z<@eh@M(nK4$3Mi~2< z%_$SsbwtgRx}@YM4ue)SI3u&#lnB*Stj6x0-h{~DjF@r~S1-baFlHCl)~Mq+LP2~z zm|9Q(`7rKb4J)(56?{m)F* zEFbxx`#w6r%HM|v>sR>3%SbLi!n8g8YFo&MAK;bkc=2Pi^$$cWMXx&@QFTVyi_N}| zf=+wjpZ~bcJ0b3k;HBTZwO{xG&@Ae`Q$sb7fW!YCr|UQ_-i(69D&-5z`)+IIE`=I> zdC${*H$izV=g6U1SbUr$>H9|M3x(D|&c(UNH}DxSP`sHSDvqnocRE>fUa}&cBv|t( zbyhlB4xpAX_Bj`-^D?f5vvbjdVNk#Q?SaWMv_6;muf+7g7+SYod~ej)bAff}P2UY! z_7rF(a4ZeVE(t;Fys=>U6}i`V=;v{ehOAT-q+e!KwmDAIftx&$+e9mp7(=Q>Z%+^0 z{sTEV{&kzkyPHsodF3v+=o5vQ@_&P4{DB6jP99;(Yu6S)+1{JU-9{3A6o#9oRIJ`R^Yz?IcgVE4_4Jw zR775!lC6ABj+ly0v7;Psc2TK&WixZl&JH2>$D=X|9i?ETb7`A|>)|$zu3lmtn9F&N z2FsWg;l-UfTt2FvMEq240AhDz`Ckl^DB>uaQ!@i+_5_ZrI#KU3n;k@l_;I@jsjscT zp6N)+I$G+>O_Bg)b^)MA~q)>oO_1-KHbr zE9)t?PbS>NIL+l#bh=uHAop7Gm`|;FJ{ay*C*HdE?=d*~c%!((dJUmBdtrKEuhABA zcRL0;oApGH65SpAa&YD*rf&GRs5`c1VDJISb3;q(Jdn_Yx;{0%ejP71^>xk@_RfHi z&N?P&w~!E5-9@4(MT@KuQ2a}a+2QGNT=EmYfA(|}@X(x}AWCs=9F57+(WF0)PQl_} zi=5%1xB~dUj(B61Wcd_wm)B^0bwBgsB?H*%XxJ;j;ShP1*5;cD4j!bqe@=5r1fgk> z*LxZyPQmbIK52DVoEq8*Z(a+PBR&f<(LX-Jt_+s=J#Rt!>wQE8R+{#bOf^p|;eafm zqI~~hB8bcrG`*c3^M$4H=8oSH)*9qoe%Ci2&ytBODzhx-$#r*BOo>!4IV)%3zol32 zx7Ol0!MS<#qM@gU2i&fE-4@z?4~(=VeoctT9D#R*Fb#W?2m!i2c-{Z%z{H1T#~Hbe z{vdihVLdgpBJ*|%6*F3A*5)YRBbtlwQ1A`ui+INSxbM-8gO^}v#V@yCRtG>#&+z6d z)3h6Q{ZrngjPe^J>r)b8ve(6LSe@uPC(&fs1^-ie%SXMg zq;P?>EsjC)rz8&=rrK$3bSY0Fy>&@(YVG4Fnwcb8hItF$A%DQ~T>0qcA$U7$8o3-& zI0)ZKf2OSWo0hoIahJ9BTu(OMCGi~zJtXlMm&zg}royiC;QQWI?hOIvVhEo4%%41Q zqZHSu=t-VuY)#{JbEpi*w6FqB)jcULUgK6pviDUDDAAupw&0ycwU;x7pkMql*kC|s zfD89kTpEJPg`qte=9W5g&N)3Cm@d??o@BUy&T$J}rl}s3}^03`-2tR)j zA0wD9?-jrKhN17pKMJC&55p;mdho`;T^?kd^^l@1|4fDd z+A<|tiF=8_-p-U#ti<{d+uNRX7E3qxiEC1<>z90{IgFaw#2yGDHO1j~U)RgkR0!}@ zI>*<_#f=`(RjCx0-!ar3%y!ID@Lc(1umC$4+?0gi}BzD1IdRpZYw%C3dKFw>bxclrL4z(ri4a+@PS23pG=XB0GG&l zn)}u+zemUhE4fPBxvR+hvf^TYWmy(9S)`gKdbFo;XV-q}+X-WTh;=P~bjW-fi&+6b z=?Tdn<|x+w64&xqMs4ptMHe1>%UFB3qQ%QJJ;C_9n<`|KMVr75P0By z$MF4B9_kn>FAa#SZbQtWwS(JZ9?4tEs>4A!IDN+WpW2h|qdY)(r2>xXTY0_`Z)ym1gz0jWLJGn@=vC@5j&QYgQd|FGcXoj`Ljh8p$-i{?Mi! z^{JqO$*qRFcbEPN;8#JI5u-At940r0T5e>%y^qG9yJm*2?g2QSbWg?fiWMWmVbR!d zNGTX&a)099+9Y)2xs=hVK)LY`2)^+0740+XU+B!aK75VJoDkbbyw&1r+xSsW+W5dR zZi@rCl)l7zJLNXGDHvb=A~W(7s^(YyC5Som@OqNy`0;=IL^!qivDtaO!Us&38-@~y zm%d}tUP`r^L8l32N#N zL+_B%@AY>!@6AK(E_@;VK5^g})M~d+41Ruh3L-nI_NQ;x{eepmwS#!gQF#+&s zElLB)K#>o3tiSVsG|7hR)Ay-7OjG?B)b&^d-=diuX_&k9t+?CZI-70Ls~peY$h{rSXr7b_RJObNyd za*@E-@I#$LgdInpK6^y`$e0q77fZ8981E0@(REr%vZ)?Te4`vM+dbL#3XY|38>fs4 zvvGd#<8tQL4|#AAN^Iil4UI(J8H(@u#-@%q66)cF?eeh-s- zFyq~MtF1Ru1)Wd1x31{?i^pZ%og;yN1}~xa-;b+Z8%doop!#bu)1|Wn#tOdL5IrZ! zedIozA!oSPkIP9Rnbw~fIsv~S{-+*`8c?0B2^9KBxi9Nws^yq9>5Y&e(Wi9EL*yFP z%i~i7#6}V!o*(o|=n^qAZb)q;ykIongFw!5FLhv^Bkp969dWbPCc{%vqq zYq};$S>-_Lh;*C$Zc-6G66AZHiRuajH_?xNfr_qLc)!)n+L1}V1#@jTPs>Nl23Ya5 z`Q%H=wT21NiiOAv4%s*((6qbwks=u6Vuug&buF4eG%SN&%N3ag4beyz9myZGIFiRyRm zJeb#6#dkrTS8mreLhXQ1BX(O9)u0LjxxT(NIgwS!5dW4c zN=!F}@xgPiV;U}y?As->D=TF232?XJc7yP+BeX>CF4#Seti>ILXNA)KR5VEZx+<|F zaWEV0X9McPckJ^Z(Vy(99gs4Jr^SMHH=HTj;pC?r=%vXP2c<-ELsffO9qfEWT+pO% zIv(9V{K#YLKpl=uvs-@X6`up^24S~c+_BRzXQRLP?b&|QE08jqxyvffgD$?*<6E@X zL~yY^ikC#j>=d|ZEFDrbWbZ;%v8NQpAVKq z?@CX&qfmDbwg@4fS;eF?Sx7FG&lw=bZv*3*Wv%ZnXa{7%olUFpX;-hT;jt3pOh*W%}y=~?YPb2 zeXZVt;>cXxj&$C4jOga?M0bBSL({c#>V=sJPrLvxK+wM*wn|MkcOeA3kjvIzPG)yR zhw*R)Z1St)KaTMWtLyI15pGrN=4XC30NH$Li5`B>nbF_xKQQp$a3Oqkxt^^mb69|n zVQ1^=ZJqxhEh^k_hd0>(L5Y>2Z8ylT!o5l?)isfa1$WzotvybfVb54O$k+L!}!x^nO5beK(t?D ztnrURuh!z_1G%VEoY(X4+{ksWS?Z}gtoe*))RfNd;>R6YVyTH&%UB4Jjjg$DR{+B= z){kBmE0rVd()*ogsiF}$-fmUWF7r%6$-id0(xK>id>Y+IZEQ(AjuQr2dy6vr$elXa z{%|D6Gb(8RxP6(~C6W`T`+to3yIGrKh{H2v;8Dp4t!I!5V zmT_#PvS|69>elAEZ2>v5Ffsm*(Oua6>+`bl&Y#-|A84y&yCD7(KSKXINhTib1xlgI zo4d2Z(`Yb!@i%_;r#grdiYh}U{|(~oh9}z%IZah4{oP?n&i9)`{ps8f9v-(}fJ>@0 z!2Nr~MTk^5zhSEy+{W6*iRl{a!zwu8<=w_+N6P>$vAGV$8&!1>b1c^&7o8bEO&9w~ z;rEw4k<`~0HPLWK7f}>ySLLM7>_Y=b9+R~9;w3m)*Y}jA%RVn_&okhD+Pv}hspc|NQGet0 z;mu$~?4SQkN&MXbUS_FFg`(o&77NIJ`fW`>XBO)J_{IJ+h0AZZF}ebL_t74V|J*S{~wC}D>d#i zzE_Rk4oALEncEA&B|S)~bGm*31V%{$JJAhKa5t6p7GYrEQ>4yL(FF|dpVv7_`*4e> zcP7xk_SMv0=M4??;<0p{t-~HduZ{f1>b_Ef-0i=VYgO4(cuWy+R^0HID~{dajlDT4 zw@+Njcz6Z2M(*NNk?&(u?LA$L%Cq@~8?=6e;*Q|yJNI8l?DsmeRTGYx3pg&xMqdA^ z?j!!qJU$vN*9(NoE>^$oaw)?fb?0_XG3#~2{GoMi>Z7scfeBhxNX!NsZ)`VRK;bC=V1(?0Xso#JlAk(d(*p@L&Dtc+&vBR- zU<_5HAL>U#tyRWP+a5`TieFZ(Chu4ULC{;lB!>DxJPM=~yy}v<@2v*rlI^I@q@g6k z<(U1r8XInqogr)p52C~+lm4Curm1TfqMo6PeY(g90g}&je@$-B;Tq+O{5G0&d*lg9 z77bq1Pr==o@a8Akp%o|%iC)d--YvDeq`zW7+YAqC*AD(=lmI$+m!#i>^ztZo?D%v7wP-BL6FR* z%C~Yx3UoDG=Y+HTeUa!|(z6|Aa}RA%2RHsZQdS9m+tWX3X=PWzCNG!%F=o5~^zFxQ z(Cgj#f){edT2`9m$DlNmNkmb8Wevvp$KL(*+2;UGY!m_GTm2`&ewIJd)t}-$99dq4 z?5O=BhNWuXwN#A^8c>@}AIsGx48%rbj_rvmhZF?LRongSy~zM$^ACa-<@;;V+srj1 z`j_P=7|AR@$x9_zLH|gdTAOXy7nGOxVKm}&BlJVpJa0#Eo8qDKa-^1M!zlJ7ABYny zc+4XzRBF{Xje-wz)=6GJMhF7oG%VeFQPM>hswQs7J+dAOVRqJBvZ}{F+ zzC?j@jJ)g+$vwzldC_~_XJ4(xwaD(0Hp?}je2Hsrx9a16u=h{Sts@bZgXW9&RjK<~ zE*SluMUxn&)`IIE*FI$4DmsBrdA6Lb)ulJ!QC1&x$Lm}MZd$#%U1Ah}8L89Pgx8Gr z=_T>j2z9(|Cj|)K_&w_6y8jQ+cDIlF{CF_BZ<9ZGY`h|Ri>f*wkxZL14;Y(vM)dB` zC8I!NrRMJIoI{Wux?yG#?7;JC?3K6>bV zCcdmC(&X%L{DNv>=B!h@w+L)4HWe~F_2yx9%!W#?<1!~coZ*x(&Gf7T%ehaZ7FSI7 zZH}VNz)+*H6->k~_qX9 zYw70u0h5jFed(|0|KH!oy4WeN(Kuf!^9zZ;hhxg>G+Ytl8M`L2c%lQ`ady54EoSqC~wz7}WWmHx`MJdJvrkn0#isAv`XzO_|1y61{2nQT_ zm6djj4@vGuzpvVM*~32bvtU8DOE})`9=m5LLP3kBAc^|r8>_9*)T^6RNtbIzZqA!a zA1W30Ae?9UK1wdX0i?FJx38N^JwR8Z#qD7a*Ow6R9?#EH{-uZJYEv># z44W0y*jrx}bjtpR^@6Kx^P9%fyN#8$~ zfnR*6e;Zq`(>p&5=YkK1-{cVxV#bJ3NWp>86}1bZ<&~%Ut?=;X*0SueKW->wbdwBY zfBXgyudSDUcxPt-_WDN>tqZ;%knmvIARzW(6Pk@}vyV>r96~>V+PR?CHb+?5BxT%@ zZ?M7TYv&kgkC1;udr9X?2^aS+ER~Nw)$`OZ!6j;%A+Pa(pO|O3B&|^O-5JJp%iP`- z-kmtPoqXwANcG-v};J@_NxIpkb0|wMX(0i+XX->}nA!@a(e(=r+ z@1wlGL6wlabpR~vv*-JV$#ZZ{qvo*Zp|`zY_3^0UU%$HyQ>Kj%lj*7&sM7F!<-x0* zh;}!YGaN@XE+EHNg3*(#(*kUZd~HCDSK55{&Wmw6s-aR6(`I&}nG?+cO-M`BWPDVA>vkq89xBexkR7 z$>c>18W+N+z*%&s+;vUE^u`CqHcBQc7!sZH7k2;1gyC-@HDX`t8ZmA>yck;--~sDr z4~qu1vgbfV$n<82gk_(}xK9c{iJ59a8(sfiFiE-|mbaQHh%Y&IV3+69^>~#BF%S>A zR@Ria8iB^+CkiSYxVaJ2c#%LMRXqv{YKA{7r*Qv!RGu55N+s_Auwq{lQ;l9d#WJbDM7=-E~ z=?S9m(}6pxO6rTGhrwz5=}Pgga~Dd_uSAe$<~+gu{!{H4xp5qzaAhHrs9?X3s_}r{ zjLsQl+{<}7Sye?Kj%O2I`XR4=J%v`HG+)B5D>2GVd+JuN$Fw1{$OB?L2e07Ti+j4H zLt|k;&)oIzXYyh&saA5 z9<@?m8SQMVa#8#u<0xCNI1LP%W&c?`jB16$){3vxy^s$m?}?tA7?bV+^OITXQ%C2i zVfom7@KKd934TPYx@><>oJCt$qvDsncTPB>rcyz@96$=2Yvo%nh3fUN_>!;5dq?OD znka03xDjxVLn?*n_IKU6H?Uv&Zd;h_&WK5QYrUMpuWPu{A`*I$ZkY;}7u^=A&n~fm zJL7QMi6_j@Q7-iHrK-v!UQk-<@GlXQUPS3j{l38@zGZ|l*VfssDmLM!#5>oR`Jcq- zBl&aeCbQmmNWT6k{&MQUEX>1VxfhHh#qia~%)!iHSsFGx3fWh!xqER)ib-u%d3pp1 zS{-lhwD-P;w6&|mwR*`h6qIJCAA8K%1zR4X<#R)IofvAi$>6*D$P$MWW{XM$YF$9P zaV5&>@na@rKcRk;?&QM+k=M!j>{L9JIP>T93&GEZr{SmeGvs_r?h#zQOWN>^BfA8u zuA2V&5#c8yXSRh`UuSWKarU0C= zf0*AliC;i{QOG0T`r4n+R*DGz`)Z0GvrnU}qL*$Q1oMt*?rpD*!{{dQxa}3fnvUb3 zlL;`eJ%MmeTD|%tnP(_e@qOhvTzVP9cc=O5zIPezTepK$+VC$WJ?UsLqd45Gy6Uye?j?dy@T&uP=9@U2^$9xJSj$hubaBjb0waXJ-L+ z@)B`tTs!zdY44x^Hq>ZO307c}0#^mZZB5$=X7^*yPSz_*E<(&Pv_G}~esmLVsSQ1E zFX;uru+ns0a5nNj1a6Y%J5sKUVQTv0hG;FjFf#J)JI6+U>c)J<^F1z!%ozB5aSsb} zYU4p_de)gFr_PUPCbDg){5@n0&4A=z8wNS?s4}%F5hp1A3SGY5x7W_T?ZSvh=FL5U z>MQs*?{zAoDJB;DtzYuuS;SVb_rdyK!>gf92&l53OXyU7gCjQIe{Y+rJpk?01%BGg z!e=pTr@Zpw{IEF`{`7urqhG(U52jp}CDbBPAi_{6qjfRj5V)M8R?W*VOd_r*Gv2MV zkQs*~gYS%fm(GHy>C?1hA9-02{y9xl+;*cMYC-ZMoFyI%xW0CBQhs>T3b`q}vR6~8 zn_-_9b4l+Y`#4m|sSD~qx{Km~5Si?%2eA-BEXWw`2)H>w?>?mU&}sjoNqpi6u2(uy z4U!o7rrnShB^>+d|5f+by+tr*gk<;MmfUCE-P}QB8#75b;T*-OLW?QptdF?H`f zD$;!E$WQ*;XP#HnHjkflWrytmYk|ZGwoqKBR{z>+7XBVr{s>oinf@n*yf0T5)Pmo? zguU62;L;lrA{b2Gj`RDI-U_pav;TIIZdt+Z(Ozm)0KsXvyC)_H{5I}I@RL7#^^d#O z(2@6Gi!5@b0gHE&oJ;EKve5Bcqv^$UB5^oSDn;qMcy$oXQ)*IXDTf8I*XR9>sWsUb zNwPn+80UGEP_$88b+Imn8a&}Gfi+~$4rBUUc{{OGRT#!o-kctb<H)<;iGxsG#Qi5wj3Dt%zFMyR; zMqZcq14H2S^mrisWUmdQll%k;jOLQS4x3=|;4vmNWu*NH_N>qa;lS_{CK>-Cn5`IP zk}0HbfJ*)~Woc94OZc?KEy^B$)`-}ps|h*Lne6D(pbE_r)eymd{Ey6kpI1r;_ba{A5a)1S)!~Xy0!@KJsqKvKF?dV=%{gTuorE@~ULVHLG+Gq5-UucMbX~;Mloa7& zyHE-m9e*z>J4xALgx~4oHP*up810OBQkHG9j!&{44penw7$bn2v-Ps}|e z^n*tDZf3y=%RbEI*ZM15t!TiV3(Th^d}~jmWbDRqWx>VgU>(_tr@5FtjKwaI6YGSc z-q`SJm3XZ3$QX+3d>?2Zh#Ej8*OR5vxJCfO*9a-U9~wxL%bgtK%dgx#gav<~Cgm4 zUb<&!y{QLMhID9lf7C1D(ai^9h39iG;VrM-n(Aelb#NS2`OPAHLlkQ3#^l!m|8`($ zF#P$z!xPaszSP3d?!dkBxCpArPBE`E!=i5_>fgm2uk#Il=l$ zW1D5Y;5wvy7ie1=SD5jx&hx_c*!PT}T(%-vOO+W!o!3IfDIS#^q#u0s>+q?{R-AaD zNO4onLm4v)1914-a1H)Li?z=;_U#E>>3|l;qoF>Wx|b7vMOsu6-}U*rqWu(;KuqpY z^S*NZ5CT74W5^W?4TEEF@7xjjFTBWoN3_JzFKdZ|J151wswM}ptUgX{&@UE-Gsi71 zuFAyiz~N?NSxVE%pP-%m9xPapl7kqHkT2J1Z$?4z?ML;pL}h9Od1pv@Uhm%5Lo3vB zLz&5)5ZtDzA^8;S0jH7+cGjKsy!)b%T!h*05Ul*nDtzZJoVE z%@Yf`N6LI3j=%!19=m)Tk_=nM;p-gVRb5$4VAt=-!14b3QLLrj*KbUBs|Cf!Jco|J zn-4)2HAK4_?GAk=&mSi+!%a+K>j+Nm>us*LCAWWh7a@2hPJtLB*ZJdFdYn!oS$-8Qs`M`R}KHZ7Q4&!|k`;rjH(} zInJk`OtP8{J+Y^WmWVp z;edUT>}>IkLU517zN^QQP=W zC}|*h&Pg_9icZ~UpZ<_HpFo|BB9V@gjsm{wRDSj*c$R|O1kWl!$8p6F-5YsV*0e;BDNE8NO~ zIL8;(@+#{Fj~uG{wM;Xz$ItF*$4|8V?lXWG(nfGV@$WlT#EW z#qL6Bc_cCjxqeaWs^y#yGCH1pbBhwy0Rx?#?9iB#B$z4RRu{HZ4njimH*GD~F<(qN z7}8kn8kK|c8pRp^uXieO&vopL=<`hvuzF_jS6_a279P6CE!Ry=$g%h!hMxVN?=-&1 zlTsz#wqrz^{iBe!mbZaWVu{VqhtOAy`mx+JPGU{RRsVRWD4#>ah|M!mx9ZJo#uK9c zcPU-%!sr{eESGq(DGRbuMVj>1Mo$P;^&B`eS$Psa`nqoZ2^uBEfEl2Zk4Wb|kfaT=x5 z-?Ra}5pCjybk#>#Jpb%cRf=a5Ol^Z>2P^#AkSr(M?Nv#W2#$s$;m$|1){t|Ht#nCX zR|p@9D#UNnkfg)VZB2KRqL~}Hv@y-KFCt?>X3yTdegBgmz9%WjzbEYvhM$iZIX3TX zf#%npFmm4r}_5(5I%gGDE@$+a3XI0-YZ%%5mwV!j+B5=Os9OyEXG~8=V3& z(hYu>r{_ID&3mHk!)L{XAu=>XGDT&kf}BheX7Br_|G~OBm7gG`|2p0>(7kaS{Z#>8 zwxUHva|1a-od}-xX4nHP4`;02KQJ0&D{TT=MvWM7`F~7sgI#{ zp4ZGukAVlr!W4D9ipXp6`$pg@4a@$fCgh=#aX8%(0D@q%`Uf5=Uf>Src;eD0=ZV13 zC5g`;+iyT!Z0Z3Cn{zB|zVYPF7=+0pPeiBg1QAUxOqU2hMS0rxBPYK5@epY~9|CBL zTMLer5aRdS$5JDiRj<*NLSUQ3OKgjifwFhRC(hcUxI%aIo6{yIR&9;wnM+nV;3)l0 zDv6io5Xe2A94dYKM-HC;cXFs1h-aZk{PcyYd$B(x8Y`7)R(~a-YRY8e%LBs#g!7Gy zMukkZz)QX1g3Tyt3MS7PND{au=Azs9p0ZV>E`OL;sfP36=nqMraiW zeBBpw>Al&@-hNlL@b&NYhN9ryiWM+JXRR6K#zV6R! zA|9kC5S^59?iopxB>d^e>J82)BxBDv_?kAN_7dae3G=%}%^s4cK>QmS_IG^b$ zU^0)CKmSebF{BjWlGg*@C12@wc-@fsZF0V}7dHogT7E-PC5G0yTTah~7yx1Na=~M7 zr9o`CX2X*dy@r-`CgPkePF)cHwS7!QpH+b;Dm9_opWD`uJVmyg-D$gr<-(nV6_<~A)p2cd_P->`ueW&+Tx-1oHLT?}F^XqS;&2GKrW1ojM zBDu$#{Z9lGAhfvVAV-7NFHjWGYiwzgKE~eUpEsj|dbEh>D(Q5;m>z+;>fQ9FSL+%3 zi|P4Netpges?uF_lc(AJ5hzR?U=y2s4*tTCpILO998uXsn56dXz%y)2ZL!@_&gTWK z)1P}qd=kM(iPK4+dSIdtVdY6m)%K*H*fvR{(WcF~3dY4#!p8)vrlIM?*-BvNvW~S_ z$zO>l-ra?2qF??OE%{66@^O-Rqe*uL2kjbPw3N(`z`G=uFpYuP55#`W&ph3z7-1(! zMBB|&{T=QH^&Wp3sCbD>@q76X@2bvXi9~kldsAyaZV4pO{aK(&K$vawOy#|^Uic)+ zN&0RoK>?LVPKfFZp3p$;^+QBc_OExr+SaLfK2fa)L;rqKcZpZmA~$S<-{Zf1iR19O zKxARG;v+0Me6Fj?T>k=#mv_`MxVa1P zye5OJ`H_9PZ&-^a@GJ@|gARX_ywnOBFOET)nxhG5i{MhX7pl>RKwZP9*0$ckXERx$M z_rwHH;oJl52Z|z8Pat08Sz)GqHwRuF`G=qSWbvU^?6I_rSDg&nKF{Va-n(@f)Iw6; zCz#dyAU-#FRY8u^11g)L!slwgjliDZD`Bcf3LAb%tHl2qdzAn?W$E7y-23$DHWZm$ z_{w$gSn|dO|L?$NjA-T!HXawDL)FR6Jp#u`Z8(svZZUIQ?1FxFzTCX*ha;H((mFHv zLv9CGHp-`HYu}!QV%ziTyb|SAY*m?T{SdgCfq#WVa_8G$z6OWIpI7PeK~GVC-KUhK z_$@uYDGmybXs(3s)A(k-HtJgokpIMfqH^#B69_U#XKEWB_<_*uMDU4}2~*G*M!gbQ zJMs>$BHgRYvnpSZUi~Bv#jLeBz09oQzY=^D6a~kheA{F%g{K|wZNeL`TA_Y@$LccM z84BF;AW(>&{2>OR9j8*U2G1eH{~w+cQ5_FV$gS1Wx<;9<{Qya-Ea3>pmRW$5yV z?nBG8T2EyH3UsnqmFDdA_`^%j!Fq0!;Vi7*h);Wdf5L<3rSk#mqYCL@Hcg1*HhLTa zlR3V31#ga0gV{`kZujeFLTIM`ZDP-MT7-qn>I6k&`6TAHL}wez9PgvFVY4`xDux7} z#Rc7^1^LHdD{+HW%24PRrYS?s)IRVegRRZ&s}{x0RM^R1W$flJ+F!AwG2j2YNOcOk z4b7j*N1_PvaZB&s)YA{oVXCd9X_?#o1UbUj-=>L&UIzKg56`*v&&y)IuP%{%`T1+i zJeZO4QjzRHl=085lcS}ikeq1=3HHm>!}OQs)Df3vew=nbaaE1#&M2nKtCAzDBw0+DU|I~?O)fcuV83xN}NIqHC z)4F;FJ}lHVPElEoXlASKwlSo#hR0ii%?QhBN7$`=Vq5OYNEX~U%Me)ri4IhYfUgOCH?}DPu`4X7B$>&f$ z^lgXXvl#U(V&`jM)=HM#7SjBTcGt|T+~(YD1halj-uTpS2-;-b=Y1W2equvTd~J?@ z_#zg6ZyjhNWV;Kqtn{>s;{U@mLlvKjz%oDBZm-F_*C(9A!S<^;;+)QI7!H~~TU!3; zJQ6Z(*EXNH+JVUWJ4I*M)+7l3xe>E0_SS+@b6|XUELaR9j1^q&QhfZ-9f@O74a?re z*Kv-FPfr3h(5Q4E@IV6PBLw?3ym^0yZ58wc*VvXuoHk)0+AKjAs=tUv19OS!sMDBO1ZcKu?`FeG(?mzh&GF z-LnoL30A?z3F>C5Lrv0nYQFLN@QR~1Vp&w^dC19lafVZ=yMxx;1y3JLCo?*m^5Q$$ zcAv^x$08iYL$)fm5(Mz!jIivX7;;M#TxYz0{`aeWs&rp>4%$g!Nd=YQQl92$iVKd$Y_?xzA; zKGF^XB}Vv&#}&RcIa!a`7iLBa6g;{Ji3^Ys)nIXe@5`^Tk@FkT(B)IyE;RNnL{duA zwX=(t^g(JatobzSo(4vCv-RW5O7)TCZdl( z$T*mIaDb&w*Ke^+73Ml!sv6s0cQH>fyTL&B^EE7kmAEPrrB^Wb$N9^vQaTrSS%;H} z*G?y6$jsorB4s)Y%;=^o{lI05=O0WbUsn@PpeNySqIM%u2N=rRzV~F-2ZQ6ny_^uJ zses(#-O%v@bunC#)w&i(eC!M&Hg7iTQ|a#W!v{YmhIC&tV~3z+`J~^0Y}B0~80}9K zUO?i|(?3^l>R2GgN#3)^Pp$!xV`Q(JdF#Hy@B2|tPxVJHz)9EODnYSKjvFrbj6+P0 zwc^T~xLRHH%jz(y<>LCVM)3g*>Fo#dB;}5P#Efk`?d7Q!lzm<1AKJ9Uca#~dWtKj&C zBcq&s6Ax(&&`Z(YaFbrq2JKPji*?gRi1FKX&?V^0tEYH;hBij5YH931zbU#E(M>1`w$5#ubsxmB5VX`*5t9T!>d>+1_`o-v8Zp;z>tY zneUs(W~O-fhnyFBAYpzDu^#oQ?SC)?=?NjxgwGazIL+!$Me6;AZr^hHMrq&LE&{>B zOy-DewS6R*@7WRgYD)|bZ9m?yz9rt5L8hf;ISLpCA%lU_)_W3i$eWg`)O1ek!LSk* z7Q&P^SYj%d>|0qrj?L^lY^pYKpCEp5w{xq%WgqG;HGJ}ub^nDdx%hJZ6?5(VBOZ(A z!Tb3pi2kW3pVr1GhQ}!@4&`OHc@gzA|5a*bs5erdoV=n}%vOQ(r*B7@Wc@q}H490H z1eaqsP%YoiduiiPCJx1Zq=@y&wg=l^+psU|(O+=xMR3e^6P-Qc_z3dGb(FWjpI`g+ zzX-;Apl&U&s7zxb0t5F?iT*v)msl{hKkrva_z2;3!z4+h%N@|&aT;H(Agu)z(ed6< zTW=Yt_T0TipZ44g$|rjK`HxlY!8Oj=wQ~5%GC(GjVR1>#b+Qcm(gi=Mc=sEgwAn!dNJ=b#R zRU96ji}$U+coHIW$A%j%jzl8IsjTY2=N(Hd_HDfk%O(E~yEWp=!}bvZ z7c;t7#XhqeeZcvh_17E4#Wv8q$>U(o@#`nJ=L=b4CM|t%AW6u#uKy4#TDApwX_jh5 z(K6w3L}-XU0;m34ycsrsC=1jBaX)Su+y247MyKKYiq8TdiX;>aG&*nzD)rK1ioH3P zkS*A?a8IXq67ifyqmGJ_PHXBXBls4{2m6RGB{7+x zvhZhaMu&YZNKd+XR8Q+)fceBVpYTHKay&^-I!kPFW*P1xvh>FGuA;E-*;?T_dO!o* zi61D;GVF@*Z9LL%yJ8KR~==wO-EIREP79=d1s)V_b04o3C)L>XIEc`op? z7v|=)k32?h**njLO0n-)`MYCAvOB-u%*FRUQ5>c=gXNR8;SbL(8}R-ZVceJTfLrM5 z)!fV+c^!!N&!}r|(~|n3h4kGP%dVFiZp8OBc*_R3)@V4N@YBbOXP9?vRsakTa!oM|_5 zs;uu790n>J1s{7}!Ux-|k>%~s64c)=<@?xfCy#LN_HjzJ&lI@#{XnDWk-Zp%qa_DE zW3-Ui=B}AP`{)_wPmbPxMUdbQZq0XTUhMM;a266eSH4Js)5)d4LdS9P^6b4lGvSq|v9tVC0%dhWKVkEA>zs9vc%fohc_J z0+r!?gYS0riO{oV_!=BRW(8aM%?>*CQy;LwpZAgUT7o}hceq^F(o`*Bd9!+E&qad~ z!SN&x2QQqof(@J8dRFd*Z-}}#pBAbpdl2XUyeQ%0$x1|2mF>kZanTWAO}EmxA7`9~ z`tEg_@WTz(SaiA+Njl0}4ZUBy$(aD5#2;!=oLNGFoo(ct3Gl^XPDTB-j^!v( z2??1~PA!fR(GlKRE=$5cqWF8!p)}9oqP>1}o6|Q8<*cNtEEFQi5Vj(S=qVPfM(kIc zrRQ(;zN6-%HrHHVI~jhQ{Ht*5vbQ--(3A>CMUI|==R}*Z;#mo0SX2MKHo2e9arZ*@ z^LJkJH^Cr6wMedXS`hl4;ocVsJD2fE;y(^QVmocr&0ll*@Ac7ebPgVP{6R9%6sglL zHh1lAv_Pk8HX!|_#19OyyY+QPd^5zRBs^Y{oN^bTDoe`-AgO0Z)Gx6L*)*c0MOD_fsDJ#HD5Yy72nko1IP;DjI*kGwy-%g8F_*z^%PVc< zdMXclBKdMPhg0+*)Z$Fvs3LI|YqQN89v)XX!MPQl;ToQk0fyK+OK)6@nNby5Wl?pn z_dJ?~{{4BF+V#lMwdcwRX1M@?3I8Q*#p3A{Ih_Gjh!#vxX> z@lNQIR@}c^zGMyK*Yek6xD+qqXlkwPB?(4t5FI|`&+)J`2LYwo_m2()eZ}!}FNYP$ zT2Es0`jytsgl;JqX>{_1>A&g)eXV)(a`?l3n5gocv3$399v|aV*A~s2v_Kv~{h3eHeiSW4kl+cv4g!hao~!6fa%``TxAqJ!f|Lv08;FaXnVD+ta0$Pr^glI z7y*!7Qs~Y7b36bSjHh(w)50rY`-HRd&VNQyXj)iyj@>$43C1Y;x+fw6vEU(hh^JH` zD1>0^ZY>l4k1q%^Z?q6KyKju_BJ+hf&C(CZ%?tFsI}`Q?E@NVckM+e9V=+$p0W~ps zA8f3zU8k7+JPz(A(-+AtTPFR_6npW7R4m6{ga3HPjI{-Mz`Y^NL<`= zKuA1Ob@NCC2@1|;NSLY%O(LoD*G0NJ`g$0NvADr+IPw;b(WI0`mtQ(!S@%W0^`G-M zKqh>E{=?agxaL#gJaJib-1e~D^OW3dg@_7ISEi$PUO8~!g=W@Z=ikUB zd}gwGC>G8C35=#|_rj}cSHM31t?l3V3vmP=&1aUru`q#~FDrhXiXx9f*kF)S*zCJ8 z&|NB8{;y%K6_W)Ljk6kR*D)tgBIh*ARs(7!$wX(ucm1=TiOW3WdvSyK_>N-{Eq({O~1O&5QmP<(<`G-bc<_~PX3TEI| z(UAW;JaGaoWs#``M;$UzeZ+KFzxP)IN>q6*lr9z@-KX(Yi`nw%qAeQ%b<4@I5Csi&N*d?fSL7^x!jP3`|gh89YJ&0l+`%i&Z>V?e+BCk8GD z4?fHsW@Q9>P<*y&ridKY1cQutKAma7U;D$dddJ&lpgfT3`%}ns6Z*kl)ejXbF+iU1 zo2_@GUpKtZsmeDTE=|Yi6|vVfB2hC44776BcRqC;3uphGnxDU!2t& z8J(A9;em(EhLXn5i?&eBEvm??XfZ^FrLKOKMZ<4ADNlEg^!1O!k+O^z*4`9f~;v}26F@{GIZdwR&N_l-;bdcBJ3qffu8iCekii>F>z1ksyYh!Xqg z!;2qUs6UxIsME|?fxz~5UHy9kN=PF5o_OfH%?j3cO;^cO{aDe%(xY7>np6r=i|aXUIM= z{_;Gs{A>LJK4<^LKjZ$#l(q)x5EvSp;J&8LiGe6O)6?fIzTm2eHI0Yc1@ff4%%0U3iKBh&{3|@V-m-34HkzM7uT6LQJRn5OzYam$&KA%5ldiW{g@Iv=&w^UTq zHM}<>^9#FK?*@Z&U((%v-s{Dqy%?%owg*wr8gI7DzCz23l!R@&o-UDCME8`l87$m9 z4vAYecWbvAx**6eaBfA+q6YPiv2R!7jvU8_yVI1n2m~Wx7*jViJ{=&7{}gVB>W&^B z#M(#B55he??;&^Ek6@+q_C0Xrje9BgrlmlkP;`QpI`JS>1l?Q=W7^;0wy~;wf!XOA zd}|L6UU@8Q26_#?U&-eJIQM^*7VNOYd>sBW#uR5Y%EOTP$A|lMPvL1iPpmwon|$Il zOkbvsw7d-8Pr|Zp@gJy)eqxgEhI`1DgTr91^OF&`=4F7<&3qDG9L(Q&J?bA`kFn|RgzDXg z)+>{_i8s`ZC|7+l`iP>fAK|Wc;Wzbt+%bP_<+X&D9fB^=~ zhaZ*@%*Dc|QLj4VW`O{Ts|IzCB<=ercKIT$I7bP2a62A)B`leK4BW^18cu$+;KzZX z5+l-a%1kgE&ucN)&S}7F0E6h`6xpTNE|9sc(u8Ia4{%e)mCi(FQ3!#N^ z&!4frM7R5imSMv32E;{}M?8Ghc+vBkKk!k-#_8u>ae7?z*4qbA?!2=3Y z)b+=X;?-ZjJz-ex7LC(LZRPl0B^lwtf#4F0}Z^l#Fcyy4!t}6O~FB~5x{@Ak* z9Yqeo#~kmKkI|r1=}k~LaOF45G_TfPd$T5q3nvd>yf%9MIwT8Ea?-mSzk`cuf~MWc z+xKzo6=BH0?Uo(He$aXO;@H!S~7Dr=md!0p$hTh&u3GOd2QvAqunTO5_LiLpgD&)v~<&lm9ogpBSlV968y7*G3ACS$4V7 z&|VU>?s^<^2lB!RQ;h!Mr|?!SjqYuFy%6ej`i`DaOQ^-AHNC&{zL%!Z68t+V+3cV& zMD_R5sw0zr;j~tT^oNV&r}37wU-FeE3lYA|Qzh6}47@{ZQIP%wfz}R|q>^g>{q_9< zZ^Pp&10FWiStI+e%5k|%uuigZt6N%32Yct>(0q?4i#Js)NFz)kA71YG7)*=TjpVU-PWRING~f5kpDq3?Th<7LwYPya)ASwYL%Lu#GMCC4S6gj|qlP$(yXX zcE~2MVz}V1%n$oFMfwt4xgR79Qw2*x8RLYN2XXH4%SQD6lhCPuws?z$}|E1^1u`E}a-e+=EA7JxQ*uuZQVw)=~44#j!Z+yHIDdy{d92OQx}|y85S{n z3GNpr7x3qeK6p!Ce~T>wKX9-2L$#d(d&5DOgKs+;~xo zw2!@+pSUYia4J1zNI8d&7j0$F3?`cdsL}N|mp|(2`$F)h>K`&xy=RB#J5o|ggYicZ z@bnw4dEB(dK1givj<~4T4+ZxGpHRs$7wnR>m3(DwjK(qZpU)K@+&YCEnTGjfmef*A zCAt*6^HL&!tmE>>=$NYlxa#re+IexVTL@7n`8crL`yV7IFIDJU1*u^x&z|v7u%|Wd zOv_(>F0`+iHbQOaM8q^#@I>UK>8eHbAeuhEC%3CSvx9+b7sig1OZOmrD?mDgYETCM zm0pf0W#XE_yOj!!{U$#cLDElaH?#;@Sw?+$RA+d^-8*j27|3)KPX^yzj#LY^MZvLx*`?TsEp(a>mOI`hFT#nQ zZH{-8xsK@n`nH-9Q)>`vH?F7Ov3L!F`nAcgtaiU)W2e$aw769ZCXV(=>TD`LuwM(E zqzkbY#gxM9#na}}zA$Ed7&SO>yAz7XKQXs@B*!DUL|5Wgh^sJIpUu!1J=Sc)C5CO? zQyQ1=z{$OgIFPvg7s{hHn*)uSZ1A{lgxz%7fe#`l6K9#c(kb9`U+n$AEs=DP_okLv zhFpFNQ6ClYb41;u=({WSDrY<6BA)e@3NP_~)4{>!nmT!=AK?h!Yi7BXu;c}%+P?fG zuXQJsKRJ}$DAoE3|0P}M_taf*FP)wG_I)!RO3l1X4@j*zv1O$~8q{@93vUQ_Sy`(&H(|9Cob}?S?NijwWdF?G zd&hut*WT82^Ov4N@tMj5=ldu2-MW?gu_LnC+jx?o*7|-V5s*slI;J)K?;oVwSB0O3 zoXbM10FfU*L*7#`YkJI|nI|fOTb1j?7-Rl88n3#{QQ9mwKsx*BK^fPpvN#rF^v*{v zcnHf%>#e8m3pycrqsm~$wwV$vgtQw=JGLfxE4@G&ZWq{xs|@Yjffoi$pvX79^S!h5 zEpE%&$y*n(EunL!?SOS{R}nlu^c%Q2cRmNl!a7+r7^EbngR-rw#yh3`)zkCnbOIe{-cD+Wx471a1F z$lNw;oth7Bg=lI+jhcs;RE|sGO+HZr!MUe?t*ljNP`vQyH9du$BTVi&4SE=+UxBuc zRIc^9VLWV)#jyKE)_zCck#F55AtzOl?lYpo$<8o?N24hr+)A^5Ah>mVRGDJ*1x8?g zOQOcu587FR?aB48#Zk|BK|*;#&;WHmoBr_j{*J@oPW7PoGx{FHN6-kYrVhAb`5oO!V%}r2T)Ytu4 zax(<#rh)H7g`4$p$~FAE$2f~8Doz)W^onk#!Ev{U%;?s;4v<9IS*A9>y#x5D)=c$sn3uvjO!8icBbZX;aF zOBg@R%}GuDQ2~%mKYPS`iHj+qnyjonvGjf# z->K3gzc*fgg>OfMjk!2e7GY9EIR8^k&k6@q1uX-UuaBZYm?qji>8J$Wi9~eG9(DeP zK@p;sFsaB$On(Ch-ek=ql zhMRwCs)v_h6EbqtY~1_>bUVej&-Ew1#_KqR_husJzN3$=L9xC~FABWvS`xaZUHfjm zyva(~{%$6!!hXuIOTJ-4#Sb={mCHBOVg7Z%&C>0fAri~lwcBWlhcT<4Jb z2OlT9A9e~<SXA_KHQmJzhl7g%^X99NFH!P@x#uu-Y{GUAn`rR`DF(D10O4-+sD%|!KP>yZQv(}jvl*o ziH<+?h>?1?!fRGCf;rk=r5xs)U7%v1dmq$OcOJxNE_xZ}3^l;=kep(G+G-@!PQRu; z)>1PD0?FIAi~dqYV&;YJd`-!hIT#$&t^Jm@A1+R++DC-YR5zk$^UC*&kD{58B4A)= z$st`2ss@p08(QjJT=ELYc~r3#jSazXw-4#mC&T6Mkw(%d#mB&W=ylDJjGb*HKYq(` zoPvK6-Cw_)7mhgH4CyD9XZcPqn&Q<3@d4kL^XedYcvkY)6)78(9>V#KFUS2c?|t60 zIm1d4LsdMRrNqq)@S%7A^r-9iU3eX)ATsxkxB#jY@8j%=mrOyS_OR|QgP|eJ2ds$B zzfA~$=~cRg)PzhA5EAai8kzE!fXDiz(Syq}V@MuTJ7;QY(2XGi-BHa5*&#Sm1{qr& zVrRnX0O8tYFZ+JzXugj2Sh4EC$=M*m#s%Sa_%2`cnW!w@LDKzj>ck$qJp{aED84M{ zbpavqN946rFGxX9!?8^^B(?`qyT1b~uT(f7y+4RGOC+)mX2zd5978>IA^)hOHIg-T z2nrY3=&NKzS-?l`l3yG+IE9{1JLG{(I(PTEM7W$=w4g7%y(4GbxqiP#7^c6T|9tfl zM!r?P*LVN39~7^P@EAvSEW?V*FXy)RuR{o{BJ3ZMUMGf6nfRciOKcK&7F8I6Iw$Aw zbc2F>u1+})e%8H(8Xsm(A%f-(|L(<+dXUuL9QS1o2BkK9aJ*UF_c7zASGH$!=@2~WW z20Cr*w_Bmpwl4H>=PWOnT)#g$PNBxXkC;~Prbg!c1ebKuJRcA8 zzWFIW8kj-im+D11HE2<>IbeN7~Y7zaBlEK8T`DHSsFFPV$d=h zr<+PGE({iAi5a2nZJAWPP}^J)wS%^#AnNVqoQYcnOal$h>cl-5lfrVsbvx-<341s?hitI?YcfKQz=Ywu zQtB-vd(oZbnfB*GUU21Ej{?7busVOA+VV}c!0`h`XT*q9_wlt->)KuJ(~}rI{Y;VC z?P@>jlNSKk+gi=&6}?)KxJqLwiOAYfAMz9jL{=b$hqp-~aOM_XUlX z4DsM1vA#=t?g|l9a+97?IC|a!{cMfS1m{CDP#k|Sxtf#C2FIHW_j@X67ciV$>!yEv zRvs+a+!c)D5W}lhYdYqmF}9deB5S?c`9ca)mluDm*C(8Vh3n-@bqR9skoA4QD9#~D z4@Z8SU!d=P`ycF?2t-Lzif`kt=YO-`YFh7tUnG`|Qh3M{!fUAo8{_q#kf~xI9BVXb z4~K>`#ODM8{qS9re(Rg;?I!T(d{}e6{6`#f)C-1N8{~sH_wPXACUaXA40lQtIw@l` zQS)u1Ez!if5{)D7W=E;(v~eKXVO6tzWd)Vx&%O_3-qu6MRXO$XC(P$SQ5?DztRYX0 zr@A^Pij_ZXfc&Ha)yc#i8mtw@Ejp>|*+SsY)w|(hLZ;wa5GWaj&q+}KVyOD?_}_c{ znlzJgaEXwE%#+Q?%tR?(++iSR$aRPxz8FM zu57=AE&Jp^vl`VkIh(qS?)>(&MZWj%G50L0hf}UR0OUPXndML~K z#Cg)wG9zfKP@zDB*Fbpu@Xx}%09tr(|Mt40o=t-)_M?&qg=;EcadxH1 zH+$<2_7s0EWc5+bLrcahDBIUF9>4R`&wbDgdxB-ZQ~WBthxwrqq_bMne*8T2?RS5C zJrcZ#98uZQwfcK$*fZbhVd-j*hU@KAk;f!E&Y&`>>`b<(+ z23(M}Hu-dA&TIv|I%Dq~ZwFjOW8cY+`O)`cu#WkpGr%NOiZ4U+m*OU?ozQb3P;lWm z^9iWz9B25?cGLoL=48Ptm!jSFiy>pm@gThdvMr27G9Mp3gx`W6tBAJW-NA#7xQvaV zJ5IQ+{_^jOcozeBQBf0zRVNW)d|9uv`JLrSq($PNaM?;EsLv9;wY120 zUO>{<&zDe>n-w!#cj)g95oV_WC*E^oYZo$ zh&J1lXr3zKjh{PR7?K^pt;x^a&lAoWqsAeeW|-qtJ_^e1jhTmioRP~^QWTj*pM^TB zvCc}9i^F*6@zT2J$<7rBl0~%MBbiD92PK`bbk!pcoC|Nxy!=NldtVAjEiI+02Fn%C6#V5_AkJh}I!7@EH*PkQkZ03W|3FX6~_vUZl@p=CzFU>OvL3e-prQ+B9q-TDU^wntz6FO9L^|}13 zIpPeHg!D_1nM)5q-E==L^9-RDKt3Q%$i2VyPEx=9ZJ))<2cK}FskookT-YP;qD-Zo zcY3-+mIfMf68ZPX=c@UKw_BC_?N-_ zFkXaL7bqF;1w)vtVu>n7lpEE)(+!=3qdot~?UJkH0xE29Xw&w)GM*ZZSxX)!@8-{d{_Esoy^R*t{hcYF3I zE|G`7`b%={Fe*d-T|M&X{ylttJ^t(t`(zaItSysA^GR}0{V_Cu;m~;-jF*PgjCk&G zA%yRa9LH&E7**BZNSt$7Lc2&`lb#Yo48jVJ{eFIIF9(5Dx*V=lb{F6{+x&nW6m3Y7 z)A(84UFL?85~{ufSVHMq`71ta1zwze(&&9V4_NB$C zXY*oeVD%;7*~rJ%duSh7$(B}T9s^6+gS~CVsSOmGMm^o7E2GD##Fdna!(t~e@}FzY zWK<9Ee#?LSUYBDL*gNHVpPufbfwgo0;gTn)!5{7*Vsb0E80IVuk@r|l;=-HI%yaC$2y}8gx-7Def!|iMtn2yn+c_Ae}mdp$vO|7 zjuZrY{Zzf5D0>y7mrAIe6QkQ9)x+{FKe$95m*z>%)*aO3h3mNl7t#ubVZ>dZEYWlE zi$vhsvo=dvwtRTs5BJ7$=hU$`=G`?x&PPhWd}<04r(p0P7=2#h?g!jm)KYs*Bqry%&OQ_!xt zlo&bs8U(Z*t#de%%05XwUWZ;#^CxHMejB z8LTo4rsMwvG~p_dw&rUA(H{uln;>S@53&Nm30A(F&4r5icQQubW7ts)dbu=#W3!}T z_;J_mKXJ=!88A;gQXS2_QG`MQ(VDd~uS5)5yt#Qem@paLVHZ#Ln#Uwydc?Cic070l z4gz(pgky)!p+ac>QR9c4bC#P81g8~m*$~@y4a?E90`78XO?#IyX;lznA;#uIjySmBZ?)MdJ z7TgwezqJHNb+V;lu(L$qe ze*CvCAubpZeER%u$q2c~yprXa*pB8+n-6T{c7`YLVvKSrdk+Cv>R-D{MsyhCmHCt>vA5ImeuE7Xl$R9k#uEW9n*L)V z*N`d1OTC1)F4N1gRgnVv>8` z71k4>KYv}kv`-zU;2*1--40_hCLuyy(JvTMeQ+tNM^74FzWtLl+YfzFpKs+TGW&f4 zcPKUHct!R;fX(&Hi4%cjYWQSQCvHEKd;;+@jB8hRB+S6ZdXqtMMy-bJtI2-e3;0p+_oQv!1~Zz>?yo8vu1X># z@rHo^p^MCrF@0!2P`UgF(+_Sn5fdN32XQ{R@3Xb{?O^j~Rxwnz7P41EhTe82|CYFtNaY_4PJISf^pGdN5 zlrd4ibQY1iS7dTD>-PnEJBR4@ai%6zJbT{d)4}#1_*EJI%V~O31Wq5OlTJ&+GYH7j zcIUW!a|y%hck8C;+%v$KQG2bG(rXZ3l!@|gN1We9G0CuM;rg+72pN$N{qBxqC?CJ1sh_@Uc)Q?#w=#0SvP6c z3sp2RHnqPGIBsrThWD&`w#sK0M%>k_b1mAmqlT>eftWzT)vtKK^5ENk(n45 z$nR#rw||bZ|2qXEPAO^MrfJs{gLv-RyR~AUDfrPRE4UPj1w)`{dfAZFvKddmjAoPf z@vGor!6)JKSNq1WdhPh}K0k^%Nb+2wd7Ha<11=g_Ej{V9Ik-l1GpN2}+Swle}pm zJPD%g^IK_-!(OQB$WlH+A7l!%$C(Zt+G-KVviYSo9GI;G_NQ-RDvzIv1O5-MF`d`QjEikvmd1RPrx8@nSgGUYXoI=2i*HMrm{&3J&yB+FU}6*u zb#?6jDJe>UMAqx>ksW&}{1-bISzemN36Yt{ta2!52V;Exj&s`ZI{4Cm^Ex_qNJG_m z#Y5Y_k_UEC`(_nI1WnJ@KiQKs3L;!oZkQ6oRvI&)NX2i2M3(P z3)R`=&Lf&cp~#bNBOIdjhxb|==;k2f)@3dBL-Qui(1*QL^CDBn&ZL*2&Sd=<;${(- z_%g2$a?@SP%^yV1ApSbJ5iiS6UM$Fd;89?D$&Za@hXXoyn?i7qaPZ*P`qNU(`)%L+ z@6N0QPECkZ+g%MF#G}Q~2a7hOK=Bf1>8PB{78L%`QpjG(l0w8g52cGSi3_lNv?IyC zp~wLIzIWB5GwL6_WK<3-1Q1YQsYyh0n!`R6&x@V5zhwm~p&{B?V1c{$3jT%1rw~3wH7h&4_Bp*z zIQ!M@+Ur8697M0`=Vz)b|G}dla|vc*HKkZenMo0Hc%%%ohuoqG?;l8lpCikRPyE0t z7S@IiH9F4U1&Kd9QGw7-F5a0JhGg9)+lBDQrd21mr=nnR$c%O;ob*Ay62~#C|L9*L zLTRVqGP%M9I%<9-71ZrYgKHE>^I%!&&Ka&$|ep znd|X#hFspb$@cDf6Q`%SqCV#B?8h-KeHa8#1vMo}d848Bvj2>2b`#1D zWVLyvURZ{_h8gkgwT#!G-`f0ZA1?0%HSVk1@ z%Z-Gq)ex%eCZGQeont`Lr$|SekLB?zG zZLNKc8unDT333+B%z@ka!UYHp;A)X} z_ckVp$p78_LS+GNs!4t)ZfXV0P@USkyG*A6KbE1Tq8}^tSbhKXPvAgkHjMJptbTDF z%Le`K*NyX}GdzefzPqYO#?Oc@i|p4Nn~NH7bLxAs5&16%94859JEivbOY{o4+NM;H(~fd`i_^1a@2SC}+}OO-ozsVHE34$}!}r#3H*E1rl=-_*(BJ<(J{@bz zkMY{nQfi0XdQ?l5&@^~9Sz_()jMfuvjz18hk^U$6_E`d)7?ScB;(H%JnS85yfF#%x zM(6S}wsW~W;4~2uR+jzV6~{9Qc38R(#=!0Q8pmgs3K6J|cdm0?3ZBMaOM9W0Hka(t zH`l$r-oEx9Sl(SB%gqchf&ta}JSQjEq&j0-keo3| zTWKlU3D1XzR%A!1)FCr#zv|?=5{8<26~B-DLo4|3`phryn*la(%Y5g@-h90QZp!9#@J{ZiA%QLx>mD@d;wt%m&B##c>Rhk5n|U-S6F)Ax~(pnlN(QElfF-aX0a z;~?JmJAqTR7ZoU;K7`RTs?WB|nITBjy&RRemL!E2!bV{R>p!OdZI0B25I{_Gt>5 zREfrEc_;1j{6};^Y<&DdR=-p)WQpd>%mQY=!8oxf@;c#Y4D83#`!Amo*h7&D!UQ*s zY#?U+L5BRhX%)_rn=~cz1a0HZz`zUqn9qUxd7&R6pQfp?bTsD5;3q{JP@6XvebIU% zg8QljWm;W-I1xHem^rG&e*uhn3lEn5)6qx4U4!H;#V!GCklZn~t$t_;(Q4Psz`Od~ zct$+VRx!eL7QEb>fp1I4OknAA`qtN~t{kjr)JMLebuC8aA4O?x_8u#+sXr0X^}igB z=`SgqMZNdPaMv76%3C=Pp`UFkoA|-r9WITpBjnE5>zCyRUI(xI0m)j|HU8jQ_V*Bw$6_D zqKrE_4!U;yHV}G*@mVw4VpWAEB?iog*~%5!pTAfi=n4$QTuMgTQ&)j0jY>)A6M0u}_dWw&g_(V1%I>6vaRciEo_8G=u$OaH?`^TX z9)fM9le|A&TL#Ox?e=4iN3Woy>D@(BTs4J&?3pAHzo;j058ZGixE{|0C-ah4DZAfk zxUyC5A{6o`5p9spBKepzgfm|EqhCI9`h*FiC--WPUaiJKfem$ttR(t!XC|_ZvY~Z~5+({C! zQSum9u9HMyUDz~%OWm6mbbpTU=C{7LfOQF5i5`i$7S!c>cocLz9T6&0xc)uzAN}5W8BqPw z6-w3$34xB<8*x6ZJ#*;DyyXgXx}<^H?*wVOA(s@fs42ny)n@Dh?gvNBnuwpWg(NHG zBm3=9X+->bI6ztQiX6uL?emHEzD>X}Ld1`Pk>oO7x9bbpyP7qF-_P;tyTD_j$O_9k zs!(DXfh#WxjzoRArvP5J`@R2_S$&6KMC->1i#i_U@>ZFY+9uC~`-Z2M+0~oB@cn4B z!GA(8%8}1A?IZe@{T-zD#Bb9C--w35Hw#zlExj^4s$zW4sanzx71~Re&foTJ!Bf9p z(ZMC(FL=@0`S%P*#Z}l!Wn>M!ZCHRi@8`yfyw8uY<6)ujVByy{IIxpSIGpR>K(_X1 zR6-6z0?6BsD7>sCqJq)`ed|-ITMlS7XngvlR8<)i)8>wR#Lv9ImMFV55V$A-rq?-z zG;-_`I3(o1oKXtDj_{BrYKEJ)qw^T^{rr{LKrPiRDpm@G16H^27E zq-G#1>CRb`gWEs%34_C`P+M{~rfI|1IdXf+(dM#yGUkQZdq~u=@~8)%%)!|&)Irw{ z92AGg?*!RE{V^j1r)-(^>j;tK=r^ky#Yt_HAR9J6>ZddrhHu+f8eP@H&toHZhptXx zbq1$ek8diLk`v$|8<8!cZY(Kyf*GYZx3acC?EcZju8JNQ%IcWcp)Cjl-$ItyXvA(e zk}BQ(M!24@Lzd#_&e9W-OGq|R>8R6}e1%UnuPz)=|qP&X;v`$~^9!z!05q8^`y z{sj-wcTE+FxE5Py>dz-^i!;}Zx`UdjobV&-+!2CL4v33T+d;(>DpnNyZdTKmw83Qi*(aNOLwD1mPx#d~!T!8UO z&&QKHVM@46%I3zK;t_(RhYzic+JsxM)wOLYbKWd!$pu7xnh#+bk+f5##dCwubG_2kzOA5voD(-!MR?fzR!+V0nbPq z{<0YTa)nj4oySLEr?b%Ew%&c{{AUwq7N#z!1i6Qu1@e_GJdXe zl)e$wF91ij+{EqgZ*8#^m^c5>>BVux9w-zOEtfoo(Uquxkj*{@yi>@_%*qq%$BkQ+ z^DRtSD`+87O+PL8P#+n-$wcXP-99*XV=MLHq7xJLUS2%IVrjvH?(G#e=`UfTa3z=T zYCRAW1==c`sL`anP^8G+W>?NTun(`eQ*Mq`CX7OR{jS%euO_mf$qJP;T=kMd{dphz zYTj-K#9z`f%O;J;gz*HIQk%d(PH0=L8;h=?T4}tVht0yj5$# z5I%tDPl_St&4u3J|E)*$viDv&>S$gK5vYGIgLix-TgBLP8fGMJu#TVjssawiyjzEB zV!Cm2FVVXE+oSt1aL?DRIJfZuZISmfHK?ohk^AT!9}9aKF?=Z>9;9F_A;ammZ_aU+ z6Dz2_z-=bO!Ttt*h2*XWgH?2J@NWJUmxue&TKs$ZqXZtIp9r0jCG6Hz_zfo->%YI) zC+$%|aQ;9xkKs6uRx?>Ggsq%|_yd{8)}3c&aK;V zia{dh2A`F%V*!el2{`Y>TAx6Whp}Von*=(DHs^UAWwyu#mFr{6YlIi0aA0rf9MAYE zd5}Jo`X2b->Sd6u@sr;nrO%>@1RFe(pJI};!H<-D*4Dv^sv>#Ro>!KHWn!QrSx zJ9J%DQ}0c7X2ubh)0t5oF7uH4oo-@Llbe9<>z<+^mm}Vya_I~AXJwK+1SPYC#4K+>12US5hD}8+#8$L?hUO4@!l@#`_d7DDrRm5Pw^IuF2<9$vj?^uTY+Tm@$ zZrnN3=^LpJaPLluo}nZ~DBiyb-s9XYe2a+B3jEVQ0?M$vIz`WTqrC~i_8xew&^-$>e8UNF6o}5R5e{SPUYxfeg!b3I=5HD^cgDkPgQMkPuDJ@Z-^9BC9j3@Oq zkE|Rtd?C2+e0P`JcMvq@E>#me@&S&KwOS@w6y3O|MG#efW248&?3E;iE_oLQN!U8f846$;#(c$)W$+Lxxq~H%Qf(lRXygv z>$e9l!|S%kUtLWC|4DgEFVpW2@j0g1Ku71=AgJfBvwBvLH|EESd3 zPVR=y;o}8T0o*U~_w=&uWLyz9loDp{Zzj3R`?b$n{F{@g8FijXm?yl8o z|M*E^{cZe7$Mv78$Y@~*-_D}(#%a@-^bg#YH!+cY%b9ItR}X?t{&cK!cNie=e(g@B z7lR}SMvd%kWNaRQtH4~dXo|27hju*bh2*dN2Gz0Rt8MeI%HT>TcS*G2jqm;mdQU|{ zX*3yA_fAG1ubumd+QgkQmsO&87-ft2&KGyxg&O}|y=HYwdK_vEcDjA|03+s;#u`6} zZQny1xj9ww_fzq3Er=?qHAz;)fE0Iyn8=1d+~3pw`n|PZki}=2?@!e6J0aG9EUl)o z^$}c?Q#jVz_a$^QQ?1e8N@D>G?w(vIdFoXOP2;qb>aKUo7#?%>bm0nn1I`0<*Rwa7 zBQf=#*K8bz^A}|QNotc8_`8ZTYARlCxBjbxYZOI7WBu+!RI;cHy_vREM7L7-=cF#5 zL~MRiZ@W-n{~nLHB46d(x^%+aAv?!6>$nfrt@#Fb_5E%k?9}r`+uW5?(Cj-)InqGr zjN-7z@f2)#_6MSDZ|_uHt=8QfVPa_~$G7AKDc$ymJhOmeI{Vv*z28 zusPRhIe$9V2fy9!6d#)x55=wE+O#Nv!CVk*D{xBiW)DMd>Gxym%bG*@GchKam#`KN zq0xdJno#OT=%Ol?R9{FCMVGQ=muY_N9t5@OyM3cp%~0AN<$nJ|ofqV%me!lO7Co`x zMp)l^T$l&#D#EW{KDP74XW{p!JNjL`K$G&_gHm_>0|b~F6PKQG+(4h5z>UXW8?qq6 zQ8pj<-vT*2XU`6V*kv7r&3Ikw<+J&&I4e*5ulf70TF4J_CLN*mIgMzs#nVLR_Qqk? zDskO*_JRZYaxd>BbW2acGQ3ymHuI1IL?}HPX=xR|VA3FG$$ae~FFJXpqxmjw8=?H= zS9OoWOxeh|=k5Gjn2QM~?pE6wT?(Q>eC@xb!^`$VNW8wGelgbZF|3_sbW@)SwZN6Q ziRf_aAO|Lzc*->GKVE}k>1Q{;d*Oe<{EgpCv4Khk^s9eU-HOjD!@yNHjZFMvFvz;p z%>Mq{lZMQI4smXn`)eG_6r9Qky|d5NACYp{IGLnE_R_R-y+0ufif?cPFu3rj!}nx> zT4~7B8ianPy87IDhz_i;QyFrvat9%%?}W2(+UirVF#X|l^ruRO*8$6%XJ-#N!%t{< zckL&kENUbi_&nU6590T~SQn|TXN92geY2aBU}=P(LBi?_mFd0kZuw#|eC4qY{V0ekFTJ>Ywpta zSDiD^B6%jq;&Qecr_`QpzT*tt7nfV9XJr4A7C~bfnat>kCP6GZ{rCBW3BM2u(iMJC zgcR1}b~zPSke6lx%4ugViDl0Rqvw57ndVAX5Y%iYh$BAUG{OocLEn+`v@}>LQ;G9d zIz5Dx#<9(X@{Be-{ybGcd!8T{eP_g*{5jWraY*}?(ba&LXYqoV@8TQPYkc7P!6*7x zWXch}S4|_R{tJ@;8PD$vokX!Mm=4*Ro%rqBiWReQQb7~X3S?faT-vV8*2LZqS(a6L zgVQi8dDBcHrb!Fc%g>i$?=3#SSppNWCnH&-=)Ye1^Vqvoda0*P-67l^q*Z$9d+Z0+jS55=WmT-ucI3uic@g<5&`6L`nwshf zfp#DNN+kQ8t4n(;xNw|rW3K7( zG7g?I^-~F&7{u)4e?v_6Kl#9vM|bFRS#T!kU+g`u`=GXhjKz#18pCKAq#U8DB3WFc zhjy}Z)E&8A2BcA4+%Zf%bQOQShL=JJEel~>Ek@iyP;U%R=i%@5yl18H^Tp++Wn;6C zknZPddogxK01kwCt$+3`f1}y*!o!TIQAQNIx(6#ss-;2rSHfZS#OpI)^jpXswK_Tr z4%&C&Ys4>FU~#zj)&=LrKKQEs^*=W#YJlq#lLst#cZblf<10$bZ88C=78lYtGi$aW zR*o2nkC&N7_a$N(@1jr47*ct4+3{XvG~9N^hRkjcn8C+NO=kVZyEcfEF?A`*NgjmY zx%gXrMT`w#J2w^Z?sg^*iV+0flKI8uB{RBrStPXSZ7$3*Wup;@L@G2>=gb=TO|I>B}T=(O@ zU#DpiK?bJ|Rdk~5b3~S&Hrj>H3;Y`iJmq*}-T{7l7Qci}aq)vg^)gL$$Xxq1omRKx?p5AJw z5@i6}uV?j_a?MNdDE93=u>?0ijNHo^I+jZi4q@SOu47*l2f$7F?T<_Hggky&pQ&0u z^=}oi7th>2Vo?@=q{!(`ovUhvkZ`_Mejy>l9zxvLv+ql_ilNs@(QVsxKb-VO{poSC z^xtpq3jBW`*j(SiR~^fu?a`6*h)*q;?KW8!!vl#wyl$tiUPnQOowZU^`bV?~%=j?+ zdVj#tt)%R(-5pLCxyg33ziqq#Qv$}z8WNKgFvvf2H8z-V7%u52ba=>~_#((+!;KiL<1j&aZ$sr=(t4RWSuB##^s(eOGFNNX3C}3UX3^w8_TSG5I*D z;_JGnQgp;sP4GC}@J?iOQ%B~{zxkcVkL07kzH3vMl7(eI_f873DFuk2&rcx7rj45h z(>%ieqANq<5Wo_0cxk%(0{qD)#9zje?JMl^j*ZVzxBW1k7_IVQYEBh`2Svkv4YLm* zOd~zFcT!*iR3%h=H?N1C0lmlFp8*j+>}T)v}XA@bi* zH~om@1CsXzC>^hcX6AKPh)e!BeQC~&3e7ozml~1;1Tb~-*K!txX#zf%cV&3a4+tR7 z)q$jQ)A%;1$DCbU+%JzI!zhViGtkQbv`vvUY+ixYSiOIH%10o}5qZj%)-|^eox+An zJu$hdNf#mu>SEhfGoA29yzz405j;SLK3(#sthyP*D)#ZTooDt(K-~M&9~W+jfsuBq zO=7zAALI}8r!%yF=7#$1xZFo$W_%#v&ey*cJkW|`9skzuN45~b%rE2`|IER!C|C+F zd#ZOi=F@R_sFOR_-i-L<{r&SL*!9fi!bhM?6{!ARj`^Uqm31vh(I;l!WT#j^xPJD zbFu&t+64jhx2ueyZ}n-nq^mIiPdGcA9tszwg7GQS!J-5E{%ew1qOF=NF$boP9yaD4 zt>#Csg`1RKSLYqns2g~D*K{=_l7(38K$hkd-sPq~__Hb}0J0Y!Up2*@Yem=jqXe^0wV6w5k2(dhq5?C$wlwpl&7c2>l=behA9_rUfCPfZS*=s$p>M zbrGC->G~1pf;2i$#O<$qhQSV>?ba(TtWwF|Du~MEK_b=XpTdXz6X2n?R=)hhvIqq% zb6j_lBTqndn_nS?_o@hNH;2sawLehfh`C$5o|2{}qMh3go+wWl1#{Be#hh@QLG<~W zUuwJr+z8ujJo%5jQwJ||%2Wetyu{J^)r(v4*1Pkd>V0re_3iPe2z@f=5O~|z4++&b z9N0hY65v!nJ;|tw@FB1Wy{_o{5E6#l^hc`$N*(>N`}d!tWv?wUSV#hP!;Zc>ip36n z1BcYmRGfUjtHsq*v<}Cwz4Zr7Tdu-a^M(3yrm_w?b^c3bCl7RkVeO|kOoseKSk-^~ zb36a~Nfhb`S*iV3QiB=ZzxEYmb&i@nDwI)ygb7o}4x9*k2 z(QB)|;7m}YIs7=H4i^SK=hrfuZ6f~l-TTwtdpS7YdD!93tUD9NN*a4C>G#*CyngROtyj9$Nqw|t!5MHt;h4wto5rA%J#qt?poedJqC<~3Q4&H_qcg{`6!*}IzqVs9Y zw&a@%Bp8npR4Q>ffym`s-`zv#Z6?6c?-4b?oKKE1-4a4=0jSS7mX-Hs7kv&WXM zm!4sPf{oy7O4ShPOkSkTo~*cu{1aO7&$|yd!Y?%bxmNXx7AgzIbdRO(+pYuenGBJ7 zYX{_UFQ(jvy*t3&=5~1fUaKt1W?5LK|NP^|#on3>8h&|QXs(TijA&JGL8vDY=WBMH zQ5yG$YPw*d4g6`BvY)C~@M3RRVe}b=+kFh49I19MIW3A}{{KD{xmC}Cf5b${=WlK{ z0v}#si29kj|ClOf*vmM|6)|G7EcfCGEjb20Zd{OY;-kh8n?%##q);>H6$BiYgWRPs zeBs+@3gdAe+>2auA~f>24q5#$qnD4xYN63EGAK$`mWib*r+b8M{a*W|bnT?lR(J$1 zp1IR-G~)I{u)Hy*0Ecvw`5^r$NoM7i(c1I5BFXXWIj& zrtNQD)M@K*QZ6H+(>FMY-5+X)v|xAYF8*@Wl)*N-O&u+{&aPMG+42Iyn0l{&YW`FK^?& z^QC?a`A>E5T5*1!_1N!&@ODZQ@?88Ef~ajFFKIKw&%6r29!^$=kPAslJ5Z(_t_M8Ux3{9uhU^R12+;_;%6_32U zI6n5Yry^W7c!($8-Ux>rGs^7WX0hUDWWyfGfPD=puC#wHY&N?I9nP1@acw2X;iBGg zk|Z$fCcGIOC%YAU%;0r+P@*;=x@W&<3T;>!Q^+9v9+g!olhHmg>*Bs$DO2eR(mk4W zf0MJPA(Ct5Cw}I_H;hHH8vVORA~VP_>6k8Q@(Kfg>V1*o|dslb3%z>@Ar4feq+2MC{wezvVYN2p$ATuR0tG7a`+gz z^VXsjB1>Wk2h2>o;IDCZ=Stkt70C8T5PTG=x(&;#B>h5{^u{nVKE2eQOurv#>>60w z*N;(w=3yG~aS0(Z*zHV;TQ;8%#n#x&!jVifQarey()`@iQyD2?OW#dI_KHASF1)~| z%M^r}tg&%2L)lLla^nzJZqix8{mS!3rv{ExqK)DApROuaW7HBliO8}JjANU@Os$eq zKo$wyhnUVhRLO*&d*8-ZH-um|d&Q|fg^CvQH=fWC`mZKAqpzId@~|`-T}e8n|1FduAhatyST?4#+r91doJ61^T44< zyqpg?%P;(T6kI>^dMhj@AMCd;zM>c26O*WY-;q&o@Gc2zL-z|c1Ft3y_YbIu{+NL7Z_XI1QL zWqqFo%iI<4X3|}<9vt$(oZ}E>ZiEaq{66Nrw|lPs4$Jgz@{SN95D~M$p%J_fWzA)H$@0-#B;R?VJ@p zP1~f$&Y$eV%ReNBN0$EepgU%Bhs4?Q3qG@OGrt_`vBuoRif=3*%$)I)W8%J%j)Ejg zultvA?p_6>cF zkA4gsFlEBp>AVYp-0KpEpZ7xz(jM1n%{pB9Z-GGFILKI*^ZYp zErkmI9p5KZZykm@dkocZqmzHA#`{zWYz1FmZMCTi#@sb4OZKyt15nf2elOFj&5m*h zNtSXA-xO@LQGBJC+gt)INy6+a)UTn<`BoscmD71dE0$-r>X~`tq>N{=DBqJ+m|tT{ z;VU1k!kHh&??XqF)Iq+^S~+j;{TiHOXa777KIDkQ-PdN9?lR_JmgOeJ#uW~8FdYgD z^zAu!7xI_3o&J4S*hBy0-GIwy?lQy9H#=kE;B#pl7@cahJF(;hzeP`q-oDp2U~B8n z@u8c%7J`~Do9mNCB9XoYU!7vd92}R?xF^X}cN~eW?$0zzJ$W${u%oo9!9s(l1uPA@ zCs$jMgJV{`X%g^{G$p$WwVdN0v9T z3fo*tZY}k758QN;W;wI+egFou9L=(d4Zk5CxX!nD&E5hr24ympZxqSVUaD$#i)2Oz zhk||oR*gxW9qqd&%Sy5^K#sH$Q$w0U)tVNv6RAfEoGkk)Rs3isvUmL2gq;1 zgpG7^o4fleDAX3x9_cv~;Y!P_*pbQDVm!N2U**r-MFrPJ>++PJ;RF~l4I0QKEjfg7 zqpPEG8*Gx0yLLTw^N{@(8o%`6Zw%$+tWO%*fGB?4ityX9hq!-%?6fj3*FA)2X*j)JCL(~v z6V~^eQ)xWVWS{5i=c{DEwQ~2lh|-#ym`zx*%$wnD!M{MZf(N~tEx5Wqe4~nUUr#4{ zW_xBy*)D)hkT%;@E?*737avT^X%*7o!7uG{O}5VkIK=YC9cCZPuuA(a{`@tGeTdpC z*xB#7PK3_}276K!Db}El5|}E;lFC5d(3ke!xMSUT5qj~~Zh{avDPfD?j)oq7$!0u@%-X5I@w;D4 zxm^-Dz+itdc1&o03hUAxiMY(0yx%S)Wc?-&TIWDdms&@KcV`j~s=W6jidP-ru+@Cd zb%iVgCC-$ec@EqyhEn-ftks2_W_T=rpVR1d_z!qXRmXWJUkkrh2I!l-`5r)eqU4|z z^Z7*#y!8%~XKI;4pSR({%!JAWlIvM$V^!`vLT-Sy<^eI+d2ri(s`}mI`yV2O#Ygj4 zj>KVmFTc4;guoJn$_}*e`Hgx&_8`PPkpHz1jE+V>UpUkD14dTmts4Q}1gID!Q+}b< zO#;XKPZv*zwlLy+8HM`B2YDK>C*7a>ar|!uV*9jG>l02}!-nXyHKQZ@GVn2q&WDJgF;UI9nW>0?@ZViMdt3TgPfw>3G&4)VfFW@<_8a{A%k>BZo1$?X_^nLHe4Rh^E6)EsYV6jm8i1MqQE(paSiTJywvwyNlJVR7voBVav&jx?6#kzVoMf_MOyj;8>ZIAdLD< zkJ>cL|HPuQVs7KpXNGvyX23`pxpEF-ZJ)Ltxl~ACaXvTr zPt8I?D9Zf5B>kW}dItffk42Pc+Oy&M{S*5`A=d@e2nK*IhH+6cVa+bWUuInRrsKK+X^oPK^@YU<>K#?QYncXLmS^GW`e7$rF3C z)1mz!SG)YhxzrwX@&-VG$p^K=BbhgKLu85>90r{oa@~oVxSyRO?NXd5fyjI6N-;HDa`ZKVA9=N69YcczXGIH?ji-0%qYEoQl4DdY-#i2h}md>Bg zMal91_S`3}JCX2v-uF;(_Fa5xRM&{V_B{{RE(lGZILBRsnu&Z3E%dcQA@}rae-|oZ zG#iYCD2h(*GvSrH>ScabgJAp@l>77As6L{EOrHt&S9&3J>HBlNJ^mE1S6+NoYM5n; zy6S0aN}1)Cc%#Fp-L-eG9^0Y8@W13Y11Uv8x`a1D=@|ALb7El@`HI?|l!&gQtUKuZ zFl4kwsq2Ihv0pVAUus^1UpzHY3ucDcFzOeczVPvVly62LT#KX6 zH~n}cUVVkfkLAUH2JE5m^6)~)((*%WimxrK))#reKTdB(!QxV9U@lInZmD-6TP zJAXF{*khA6@Yo%jr`!cxzGY96g|^hR@`h zzkd#NyaB7{V*-hl-p>eab63N!)+8Ji7h7xMwkZUMEn#->i2W1rS~sP&ybE{>f;~Ux zrw$&E@bKsQ_8ucYH5M&PL#X9r1#yUDbR;zSaRlth3(suCTO0?`HJ+R+9Y(<*x%jxH zpTl7cU6qQv-0q~wVDr8$*I-6<7DpMs2NvZzt>Hkpk{!c1OE<;{VimbQ$mHM)>qi?U zTG}K8zRy@m z(kEemHDTt*1AqUPW9-jn!Jo9ct^B&U){Mob$-=vPdivwNfn2heRxgj&3XhqQA z0Uuu4##H63zGlUc-u<>V|Hu1$LyGx|A>E^Cl`02QHo? z!rLl>x}SGBd%*e*X>ZzP?Atc?uc5WW5Z zd^`Fur9ETb2{uU{zvmg8KBAzTFQHR?MjnfQv}Q+Ca^J$%I8^5H)fP#-uk|{Rr*JS4 z(IZ#Su*6XNg6-hjg6ScKOh^;1s@F$7CqRuy=mqz0sVR8hHetMF;=lkKEj)So^QAWY z&R30ZTBVZXDYxg=DZY{}5X!kc;Hzq+#^3(1s_t$jJ{XOZ-xof>bQdJ_9+pZp)-2E| z2qZD1NY?<}jDE|{hU0cPOj`M6GL%mim)I$6A6H65pf5&4>``yJ4)o6H#Jhjk%)~1@ zwTu6B#o~Q46|Jd}wf8^z!4;KmCkhuD7;AHx>Fl=(w*!3T2KZf}L zWJWT3JwsA!^YUX_20J*sR$i3MYj*{CyrTVkLyOlaoM3Vl z)z>?YT3zok&QmOLQ2g=v=fsh7<Kw&?`?ONED( zFU$C0I_Ck&ubyMgn7SuW;-=Jf7j3_bG!l7U2;uU4SF>V)d2qRD=OQ4Q^C zVYRhd6pt{-5m0g$lgQXtqz#27jN;mPSnDorXeuP{0uV`sxBzM{+J~LW9r4oTu&W(i1(Fz zQ95)`d6a z!dpH#+G{zqkaul#{O3q4)b5a)V!WFqT<_Qwa7ucNZ{AI$~AkkV{mHwrun(v zI35o)&zl-V8y|wv(WQV(mp_Ss#ri;%k$Sos26CIWscvXUV(xKSbz|UhCG<+=E&j5d z^T69QS=pT*`^Gt!>@6t?rRNYvo<$AXyIwnvF8+~)-$CoLX#AdhQL&PS1GKUh`D)!w z`!uHZ40GS_=RFv456wDH-tLXONu2kO>>9(J%ZGG+R~_eszlbX`e zhZp%TCS*3#`f>jKis}3TwGu>>lz#ollstrf+MJ(GNB^q8`&dpD%fyy6oIaaOoxEY1 z2R3VB1}pQQs$jPs63*Hb`iY|Jf4@vV-WSc03K6dL?`Ho)_qT>XH8TT#+#AT#A)oqi z9C8%rHctkd&|^H&>6p!(r4iU1U%Hj$`D9U1s3D;3&($=m)yifnNH)h0{s$MPE16oQUten(s3R+sF{W`^1K zfbOmSC;hKaVkK|$u6c}9G*-VGQ4(mV{=-bm<`tot{dYL4$v>?*#S%Xh2v?KeQ}tH? z-L&Js?{EH|$J@4xn$CjfG_cUFa>x1v0`QqJ?K<0mP*IFlzjM)_tSLlyXhhS;*Bwk) zWS?+Zt!hohXPVp$svk9{v2Se|8EdYdLiv4@o6n7ZWdnaY-ii&D4dAUtqnWR#Ck>k4 zoU_qReaQqJ-mX(NrL#xiHo6fJoN5~f&O2Mr+g8rlV&o0(9O)xhg6Wf%yN~bgB|!E? z_XmpCiU(nrLMWb^WD^IDf=!Kx!SWkm@cdhLgoU~sZsyPLTyR=#z{8FwT(@{@SaE1@ zvZ>30lMRQnwVGtltzC>}cC-hDPe1 zVb9Df7F^cikPEV+>_dMyZF|t>K26Qz2|r14>}UeW{*`>TK1n5xALix?w8xBi5w=uv zhdpUa7FU*qe{yNZ8H29R?AjCE0Bewn#Xr=`kjVwl-RCQ>+v9>U-*9E**S4`4;yAAv z9Gc5_$DmvFd0XT5a?D?QFLCIB>Id{Nw*ApHDe6PwN{izGy8a!k^LexFmJb==xUT0y z#xGWPxGXVx%m)!4Kw~Cf;NjKQDwM1C4h>6uO+)+T=xaiB%&TzC%!zT5U?75UdU=r~ zVSqE9hx*JX5D^l<$fhLnP!5kDywc|w9W(fb;kW6pdB{$52zb^!L`h@#9Z9V7LTXNV z>{v>k{P{{N%?XY<@=bTk?@)qoi{?l1<=RDj?c_)iVBIr78NFOv#dj59B>+L&o zSbkZoMetR-5i^_x%DL!5{wmb_okWsb$F@9*mK_b=lGwha1&mu~*ZTPhY_o~fTlB_C;Az3;6X zXd={J_igg58>ObLOGhEQz!9|I`Vf2f!8E5 zzW&^hxvPH_e#0%Y#4M+4aP&Ym_0xvnC};(0d#R|px#M**Uxn3G5m8)atMmWIN_-s4 z4;BY9;~hP*F!}g{&LxsH^sL`{DR}N68-`}oG#RIJB2h%(vav+GREy*MsxPIr_t{JWP7ZA1dF;jHt801t}vJAE%ES#-2+D34T zESYOrxIk`saC(q7*g6EaZj}}jr6wy$X zz58`(5%75!pm4ZtHxy$nn_Kypw5CwOCSkWGzq*CN`h?Q>^s5cvJd{FqV@AUrah}Z& z?#}5+Abv1)uxGng5JE0PH%^mEQK3)4!F4XjT^8>LWlnC_CWpX}{tyGzMsh1Co)~_Q z`Y%We(Ykjoe^@mN!J#7`J}tlVNkuK$2i_wy1Oc$O3TQn<{QCl0c`rO;I^}i|IzfYL zhn8cWp)qkNaC}_t4DL*H=C7Y6$%LEW*39ge{6W0+tiJifY^4oht+yy%9kFkK?9s-* z4`ctW3!-%u2V!!piqzbS1%WQufkuyeea)j+-L(}0y!>V#VlHfclA53w- zzj0d+6MAGSGhgPl@Nx2k9p5RMB`8d6YR{4~mf`PU;y3U(gDthoyE0n(DDY93tfrj!JRMgtn&~If>oxDdgQV(#kB_(N}5| zyIz9hS!PI<8Amq$KAIqZMY`pP@#EH?M`h(kp-3@X{j^z~9pAsG8Iyz_bim`2cmH!f z_1p}p{v5jts)bz0^5{Nb`I=fEu9LF~hlj;a<34R+eE6w7cJRnZaX$CA{EbPSBL{!$ zuRp_*I)TQZiEKG0#_#+PGGfrf{f*<@0l)61V*dKamhi74emL;6$3*;9rX6C#A(u&R&_6wWpx*Ol7UYk73Jc^gvqpqNjLQ~P?+IA{tB+W} zK4*%HC)$cruf+&KMar&GCoOyzR06+!x?LuDv0h;H);%;17?t7@HefpW2h6Tn2Js)y zJjZzLgx2Y|Dr>O%@P+U5$1piu=?*1zBM6j5LA{X6Lg|0KAmBMt@yFTu1dQ*J-&hQk zJA?mpZb)1Y@g_&%Zb(zU>Gm;XEKF{sYp7Vj;D{xuSQ1eXOiEY;h_bj0pziOX*wlhV zH13s}bW<9e!sb^{XL;pB9XeBTUak|Iia@|Uj|noKaeBmMIh?ADw<$n@`;c5j?G;|E zChp1R-7Yjiv3HTl_^SgNh)55wHFii)%uJ0AaloYr{$;>^lV+BcZ3*%F&KcB9V=o|$zs*#+B8I@=U4u~wXOJI4MdFSZ|#T&JHG7wzl&cM^=F z(#&To5Eo;_=gyG$3kT`eBJ@_T2BJShvv?`V<^{T!&(1M@wc)_Wp2~AE;in&=&|mN( zSucSMXvtp$W}Wr|C7sD{j#p=d- z!P*B-+XO@U$u$<>G5`0ah~tGkl$3pws*YHeVfD{V3ds2x}lj(j;}!_rqh0xKeaVLZz@y6pXZjGxr6hZs5WF$!xQZ z!WOhT#mPH~%&3AyQa{Xnf&Boaej9J<(vK#9r^D6Xuzj=`ZJc@rXWwh4qx_taAHVyb zP<&3zO#k}S^DJaZ>>8Mfw~nJc>yCo`z*R;Zp!juCKdOap-*K$h9X{htxStoT17q83 zA}~BI`sF!~fGZMg?87UHi0Lr0nBZpDPCyUtr%@)4hksMxpV^}@ZE+Pz{D}Cb^ke;I z17aOU9v8->Ho`enY5A(+B^_vReU;p7+$)XJ5fmw z`YQUHN8C#%&;hZz-`@-tKt!IyZX_NfjVSdyBNZyiEEq0YcMOqRAxAB@ek=9gBdU;* z>`{r$<5~sRg1CNSsI@4>gKk~)Yk!rCA3xkpiDtD#v1&qUabPER3Cgt|SDpwQ5r+R5 zlO1ary*Y{)4j7g~N#a9T$xfo>)N{r)LOGCp{-@%Xg$*B5J z_?z{9=@R)*P&hXBDSix_3W&OWt;A&MV0@5QLcHMOGJCtdms z(03h(h;3HPq+%n&!`AJf3rPNia!RfoqgxVM_!l7V@@Dk48>9|9Yh^EqUPi#XsP~ERkz5XmG1cU?no(#ET)7B-rGH^p&{y z#v_$ztxfP@$s@daN*Z-^o$?hHZ9Zv`@|vGPRD$)RI1*7woIV(>B+gEfjJbNF$h6Cp zlNb@~Eg2?R6@&apU8>vS!E(&Lcs3h<=imw^nq*#+RBbEa)Td5~^xU8%jD~LK3jN)t zL?g=}?S;(guMqtfBoJn_7Z0_spB|rxemakbWx;$}7Jm++k1DG$KXu9sd%F2wr=N1t z;%yDT;)!jZvv4<}4oYNNq(Rm@5&I*;qs?eyi{+~uU-CwG0&y?DW8&yOR^=nHW=^s| zpw!h2iq?)=sFKPuFMC^WgJt7+bR+fkRHWZAXpb3OT!R10ufnPYY@zUI&iC42RJwrZ zP;$8`=AW$i*3a|vZ`@B7tY0TNVb(|61eKAxi=JNJixBU4+`?|R|2f3(GLavA_{$QB z$8BkPLxrD#!=)pVEa=D;9MTf__Ml4R9yX2l=KFGcClR7*<0k#ta}gnlVrtCx`OY8- zspFU*+Kt3hUw8G39M{`Goc66$$*Ow^2TqQXt_4Q=;dIk)#g`LevM^MZ(0O_?X$VmV z&zDCj54^@^DAi6f#{w602ZC3sBimheTR z>#ykjFZ;h&Lz|8SnTsj*e!V3d-;4}_P!565izKB%7`ntTULiSu1qVZVO=EMHq+!5c z6t44KfCWOMHh-Gf2FY>nRJd`n)Gc--ZnCrH@Q+o)oZBkOBhtSfFLMGon$|y4!9-Je zVQe@m9i{n+PQ8t6;RrmQYFPP?^b+K!jKsMZ#d2|b$m*eP@q1z9$A+97H)|$Aq0VHq zx8jW_h+|HOyL^>(8c8h1baxknsUVvCNP<6I@;LPSK6$K`?|aW;9&$4ZFa8h^HVG6e z1&oHG+~RB1>~+`uTflp8TuP?c9L1lRSc8|88sR@ZS{m`L)fkVz1ttnbS1pL2gjGQ|82A+{lG}ly_bWwUw^>j$-mvP zqW5n=;ladsHmtx5hZ%2~TrVNgKv7q*hMb|tRS1&LYPvDg2;jjei#T;ee-q+wDhn$( z=qq3~=SGA=1xG))G!skPSAMo(C8emBhWD+HDCja!8s2tLq4QlW598&h14Ha2B6E6(!ew2* zqnRN63OAKr60DLGt#FUF4C`xN{fR%WkU+1aE9H0czD*MB=xw`xl!6076rllohed*L zkZ)sdZ7{U}g+D92{WZyo@Xt5?YogU81@6k9C+2i`7Jv|vI3h*$*&L9!NO9h}r1BEa zswIcrUL@thewO;O)|#C*wi!>1q}}~|1$8QtZZZ^SA7Q*jX5-~$nSDkbRX1kT`z0Le zlgUMof8P0u+Xhv1X9fsNKs2;CbVR(U1ccM9zPyZgmBAC$KmC?SXa!g4O8KLnwYI>@ z{B+mbZA&-&R6f8OT_lIh0p_?eRD9xHC31H%Pbik-D zV$<(OH{?N1xmO%@%jYj<(++hu-Qmat|HJx*TRaU;*r*{pFhE+=f_$k7?n=sy7F==Z zqWh0#w;Ei_=X15@3T`4=QP9sZmrN6cb2}dir+KA8{9w^zuV^<5LoT0msM;@tgUp%O zX7gsf3*xT7Bkt^al8c`w3>Irf&v?N3_*Y?H2MLz_&3IMuW88=(dfuPhwc~sF2RB-E zr5bi7_3@wd`>Fp9RUU&33E!2IG`Uu!glM=M;=4~P?fVaV|G|yT zb;*O^1V3UzT<+haGaZ8~9pwpUXVbU)^|-><#^t|BP!UU??@eiN07I{;y6fu>Zd8;W zJny;l(G7z4P9M$NKFR{I5k6v({k4OgQ4iX+!D|RDEQyOlHrFu&EHA6W#k#DI;P$ z{kU)JaS-2b_dQ*`-a{C#f}4*zADH}!2kxobrU&@|k`cmX z13C*`@DtKijH#XDKmZ%@fj2$#O1Nx9VwmK*I*e!Tzq7hhV*i7T^vcN|@4RS?a%Ah8 zo{QkZKomu&CB=d)EM9fg?nu~FfbH%rZe6C=Jt+FFH=@l+K?K=Pmp@J}m2p71i2PA? ztDY_dBkvo#zs@*-bHs)TLH+gIV2o5Q9SCD@N6Wg{;Gx5H=6D?wo5ykfs{qWNZ5+B~ z+BgLU(?i#qxy_6a{cKHC>_NW@W=W6a>Y`>H);w<`PcgL}K*~6ubJqOoB9sW@Y?ys& zZo*Kh?vyM6Nr4$j<83!B<9g=?hvjV-6* zv*=+kH+i7`s2MM)SFN`TDLzAO;yTA|ffg;yIOTNCzYS%9U$VW1Rkqq4Obzll9KQYd zEGpWf9$vdC>4&>qRPUEg{L93ljmr0m7Wuqri^|rVp|_#K64M6za{+(AW;v8UA}j79 z9?0AZux}_T1ZhSN$GOiB=wVeK{-K1GFbw0f>MnZ;yOW?OF7l0tHQf)gpaz=l;z&uz z9sd|uQcj)?x}=;=QL$t9;Nnbs?U*B@I6Tgr%{hGTPd;{QzNmOMUVnleO`VIoSBFmF zmsgW%5#P53eAnKI{~Wv-fvv9PZ@-(OPJymcV!8gT_cwT&k;{mAtC|9}7aGmlx`OcC zz=fKRd4Uv^DtVb9hIEH<`caDhbtng!Z6b2Yf5@nio#s<> zKD5;a3vtiSOZ6U@fXMIT?ssPzMnQEeyCFL`U>cQwMNPlEFtdZ2!Q|n)9Cuad9eQ;6 z6PfHj_Z#n}k*V(2#|c{hmnRnKU68y=Xe=GW<&L+(R@%TeRRkr!Rt&B}T^*qmnB&?px1N65SOu5{{qLuj{KSN)ksb_XX*NdD_m zuY3Z==u2(XpW31^p^^ zJ19|7a`nZlJLi=!+mc&)^73DE7+?PUp1_603#Bbp0$x!@rLYbCuk#Ms3tQZrq|6l+ zZSO+xMQ^tgDU!6%q4e+iU?}zkinG~^?7cL8$l=SJJTZBe2#4(0#Zv-AXHYTKCO&(s z;{o{Z5YRfmx*vsvYn)*|Uk8)0E9&~>IKSgrBx-y<(xprgfuFO7B%L$&6v0=QGo(qS zUj_-+yCI(|wp1av)V^$Oux*3|p10~@Mw9!U&5AXI+qrrHOW8bGjinCUsHPW`yTozC z7gW(bnr_nGk0AM-Rpm^Su?1qy3tm|p3aetLbih5JoL&~j8gJM1k6gQig;=`UXYa2R zL(8*%PLhbs0*xEKW>+h8-Enk)pS2+O^AkLnw6_|mleL0LkSx8YqTwA#xy`gTy}lX> zW<~8~Cnl4ZKb@;u)9k2N7!`CYTe?6tMo-YzF;X`+%Wy{yjMB zN_4>Z;$kOeyJP)4Ng_62cvvj>PXOft6eC$4)ecl7V|dl0`(Wa`Zx|w*)9TLDh{1TO zYvBjx8CuLgv-)+_rt||`E+m>bPe)$Gvr&Z;5f7v^pvEwnRB)Cf3=Km^WiK2)ZH7%g zCw`BiI}~t!a;J&G{nHSfTSp^==7WRqQUA|PWA6nUlyFm_efa=C$ikxYr@2?Xux9+A zp^V=526WW>-kheT3&Q*4sP*HU67h&KGiF^oPfLk8FY8EhN&efgoX!x?rb@8I@ZK8{ zLABbyNN(2aVNiPD0h_}`1v~}TVeqQ$X8687n+7j}Zryt`iz~3AIZiX!z5NP7Dx=cd zI-eh6d9JuP;=|BubTx?25Kk#|;7EQLAJgOzJA{k|2{f))`(fZ}T8-j-mNV!=A39qJ z9j`=hW1F61+HZNZu^u4gcj+jADSw=FZ|TKp{JBnB$Q9$cjz5FqEjlF`XOaCc_2ksU zX)4f|sKk-?kAKHoEz`YECU(`3I!e@WY+3pYjM#)|IDX#+a%1{Sm+pMAhe;ay#^OD@ zGTijvx+3(mEgNHV)1s9YbzM-MxpQIYT$2nUZ`c(cz8@}vr#&_UUnv!fFs^#I{QZHz z2+Ur2UL^ma!4au%CrgD588zWcfA-%KTB$J{5`6JDDs#vKTI99zGdwanh*h?`Pm}=$ zTBf-AHg~kd)o3AgDEH@b|bH;_d4S9vGzIZ-eP?e)_Bz}4UheV zPin5JEm^WM#&vn9`3tfH5a)R0g+DphNAUTVOS2zdTf}Jm;VUB+TUlka4}N`aqK>gY4?yQ%7;B0rSttYb!tgs)UrmV#BdlN{kri zzdEyVU*r!=D>b&BEE*bt_s^T_9>ET8VdYixeJ^=C4?VVL&W@3C`T=mN`sf=OEFQ zt`I$SYYxe={O<~__H+5wO;UPYJ?9N5cCJ2(s+)X_kKS`mn~`H$81-E`-n}+jh%?<3 zqMhoK^5FB<;8{5`O^lfi*<*h`MBYPgSaPmXfYA{wxIW4qHk9(jE5)Gz$3@>rWH@A5 z%~-$bL4?%jjeJdt8Ibinj;*mc;sV~_ud4~Ei4Sqwdf`?g+4-AjQaL^Ho4#8Pk3Uki z?|D5i1G!|;ADhn2YsjX5*&9cbE{NZ}BGH$wd{uy6<>k_I4lb#Ps?VY>e;ucSp4!C% zp8k*xtm?#Ni1|L0g_A3F!BkC`8_FKq->I*PmBrj#VsggJPc?8_sf%_V7R|tR_xtUT zoXJ4sUmqTrT^lh&7tfd&WA4uwtTA^DBpo0;1D3||@AtZVqR=L{nrCWBt%?yAzcMc_ z>Ztv6@Z#Xs`fdn{=*pT)RvS+tb|UvT|0o>|xI&}VENs_fainIWK-o>{9iAPfnI0RG zio$II)%esG-Me7AZlr1O;&U^;?!Fr3lQ~U*bD0T2+Yc;h5mV^%#O3Y!4;;&HC-@Q0 zSB{Wbv-#BqItPprA0U(dr8xnLc)6Xf;Zx0cd`ZRr5W)LtIPcon>)rgLfwL0I3f+f^ z3!v+!=N4J{d<~x6x32P1j>*BIk4^W~C!buzOYd=Bv$WSj`3Z*&uM{>D*j};TBH?iT zjEy@1TqIOwlUOX12>kX&+Ywxq-v&Y*+gDLG>Q|e7fa@GC_h+}0pLl8qk*k~%5ZQ{jGz$6 zWt}U2A=dc=$KPED{V=qA921WW`_3+q-NlEYuHGDKjW}%mdOEWpHYJ1un;~OIm>itI zuWdp}Qz8(6d$nPm`AZ6XU~@U@U9CNer~u95k!P++1KSWpgV-X}`*HKF|DldvNqA#>7?R&lX5L!NF7~!>JN)CH&<4 za>Hh;{XUMjE;lEt-Mk8CU7>2H0xLGCB=bF#d&_qcCwt$9N%^UzBROrm*fdoy6D@`} ze`u5tY~f~3H*>SVcWZ?8_@$qGaQ6`S1cPSUVshi~;rb=j(y38Ov>p0+pw9a2I)clH zy+nxWGO#Lly7Z-wSsR?ZOi^hXRcB2G=&S_FJ*3iN!FQ*mDeqz`g0%57`Xv~U!0eE)X# z+rOVkCgi?;+}(N*+``n^*DriH1)Y*T^{ami1CjVlM5#K$JKBJTXQcx>WRu+ClC z2B$Tem*!0+8L+u~W3W>05E1HMZg}1dHuA&y+@2rgtpe;Y;=cHxs_tkwmPg3P)^0@} zgZ{^9h$^qv(mVf<(V3y_QUI=p#`?l&yOS z)KpYorLQj%qlyDV0-ZA(cyIMzj7yNt2IkH?byOTbDT|ImQ3Zh`MM0Q;^4Z~~jpY)G zyyv^CGM@g0;mg5|!@3Ern46*cP8#7Rj&;FVWkJ&T2WYYUnJ5qy(22E9xtl*?3l_1Q zFQxq9Rfh@|SVJPpG^(D#x1hKYSCR!$%TgD3Aw??;YN4K5f=&}Jpb~hfpe0I$4X<_@ z)@n?$M-VDZc|&de)qT{S{im;+_U{XpiCncsLoB*6!DZ{ke6NKBR2A>suN?d{iOj!m zjumqLYJk>8a*V2q3_V&eek*xgdwKze>93tD84VtST+yy#g>NqnpG0@HDdH1CVP@nW zO!Xm~5R`1?WyES1-vtu1d!;(wL#)}{r~Ol74vxEqgliKY{EWB!lZL6B>CHHFP&f03^6jsHel+!D?9=~CzM{=GWA{UPw`RL?8i^i&rSKK?ymtb-GoF)6sLOHBZycH*- zyUx7t<7Q{A*7uKuh;U{55AE43xSSg%{t%)39sOOhoS`?_U!uN{TU$(mPZ9~(rIP~O?;ipE zg*QrCS^-0lcpuze+Rv2=y>aJcqmt_iFfsko-6oZ;gu~}duM}K3lYsFBWslRdMM;Px zWgGpTZgmds)Z4@spYHP`^%TW;z~viqI8(@ZlelI~7F!Lp?{cNoamQK%sTEsON&IM zhm!Rt#SOqyT-(lYY=RVzY@JSo&`#gSfu9FiiUY?2@s`O`?w4D^7pQ}~SoB`hd;BM5 zt`}DHn!+Ai&AH?cu@dWb+<}Yi;Q#lT+vM_5MlNW4Gje23-t>zxbgVN zyn*JNFc=;RJw90-SdZtkM%H=L@dl_VO8K=%b=(Rz4NNCFb$&|Y?WUnh!nX`(mjCiG=*9TJItl#a6mBq#ed zmnP96aHMxTow*Bt{^dkQI{nRA+V1A}J!<|h{dM3Bd|~jFl1LW5oKH5Dr^O|( zB(tJqB;r&K<(xj|%Ow7^V4!TtyD@Gn1gXuHk(HM;oM@ywU&27zn}wTg-4)JTPdgCG zLEc-zApa4nCoksz-s(Pslovkbc1|z0@pSG$gG@v8ADp?u8MpUeRT)ZzzN>0PFpVIX z;7`pHr5h_4mQ|=*rCTXL$^81Iy0wV?Ir)-0RkG5S2t`htQ!#al@rWmUc5v{b#RFKR z$iMl_8+Zhno_!(4fyd5+Ue)5DI~VB&=takj>+IEy@bisnjNDBxA@n~Quz0|@VFTme zF1c1h{QLa5M%|&ji1z`6$|L5vf5t_F^!;nsH(?`{*kPVnAXp+!L00jP6jt=v~ck;6$RW@VsSHX{Yrsa@12w{fBqI>=)~H)QeS~A*lMT5-Tl?)g1oQN z&PJQ|op2Tia0p3>wMGA}&)L~ulI;+>tQt9XXn&`)aIeZs_3^%kgp`0tmhh=W^s8;O zoqy-3j^0PjZ`vaFSyL?kV{!GXr^66UK2hBC_%;>Tgenx-|MPr}1^(|+dNn0+7~NnJ zQ;2hE#G$H0Ydc~UJ)|uhJk7o}Mh8-D*>kg!1E=tWDY5X%+CK*9@RvsP{E6j&d1wLg zRKk=4dbyW)?hM)4Uf)U3BhiwHI4DQ5djJ~Z#xHib0=Z)gr&XG zSBnIsvR=K}F{Q2n&tl!4yTeaQ(Uix)ENgIc3yvc#rg0n&r`k;Af;N?pP|6(iqzpyuTvA}TOhO@kn3&S zt&9fxbmrQ5F@D&_ktDBqBn2bw3hTzz=mY|!1O`=3-r+h zNRe58{V;^#?f$^*`yQ(Ja1qzS1Kr>7; z#UIM(%XRvES$HR&_3VC&Jr`OJHJm+1vr2}XGaW)LLjR>=K;G>i!}#id&@3h0ZA?%v zhWzPXS8Z9J=lhC3XRCQMXb`q8)d#+C+RSJPZiW013E2TZ-XY)y>r3N9b_y_dkv&PQ8{-Z$ z(GiIojK+uYz50h+qu&!R%tBs~%d1(pFSct86PXdtAfEy!#s*#Ve;kW!E6exT?Ypcgd574=0t0qw!*ij9#DR2;A$a zFBsqd{tZr}jDZbT27-{^`+;XkCiMhrS>+PTN!8{+7jTP`kcB!7nF_KJ-3hBCNE?{= zyPc3o4g(_T^$T=X#@O&*cskc|XHakO~w}K}bFHH~j`lXNm#g$KPzOJ18Z!uSU#BgoBxd#Jg!eQk^lMV=uSEoJZ zKInZGPs)^HUe$r2aaZ0muq0MOYF6MoYCptb{IF6H>9HbEw zZ#*#FbX`ho*dzq?HdkuZBRX2p5t0!9V`#V@X=gpk2vd*HLLs?~^T(msB@DL2%hU)l zI>5PwWbnWOmkz3vb7%fDX&=J5uNyCC5=aQKMqc8rBJ^epF)xKyk_@lzYj?6B56YpHxlg?gZ=c&VP;V?3Y1XU3YG@aH0fN8_bb2D<&fQN@864Ak#|$#0v-rx3DFj z!yW#T)~3bl7C2Y1hRNIdy?B4Lcy=e4D->FCi`m6o6sc&Nj8yn%#iInlOm&{joIW#r zRf+%k#5w&rx?*EFjoInK!B(A`Yw$|&ET%hTOupL=IDk`9?T4a!PbhYYR(raGo}GZv zwNwRKnKchkzNdeEGCq3;cXvawGXhs7A@y2yAhyF=4m|lx6HXom*84W_qGKj2r5FSv zuGx6(*j&K3x#IJdojsm-zdWjb+9x^@V-u_HNnIDYVZ(Swa}_!F2q3apbo?M`&mmoQ!%H^b9|@ z3_H&W(CjNwi2=`Dm(Uleeef=}iKw>{7q;%mST(zap!6=Q_Nv8l30s{!A;KNfjO@W~QV>kp4fM)#cio8Na7>T%>| zeICc<6dpVx_O^KJzi=4CtyI2+J+Jm5vqE4EK3$qW`S#hvP9DJN~Bnntd|~Qe}_rm zSiTEGxea++`w$TatY%W?nLTp)u$Xy)(0gR*8h*6e=N=D}%R%#R*U&+mobxEw+2da^ z5mH9!7{x8dH6sJCL>w=bm5cj=+7ZL~r-2KNa4sV$)of2C1BEWMe1s+YYAdq%~;L+|7%QMI<77|@kA z4f`d}+}Y7CKWs^*J!Tl(ehELiUnIn%p8TMS@MMvajM!fs2H#ZFR7CS}W%{(!)!%0X zk!3;dbn$Yc0=$>bBwkD{634wu9d54I7OfHU%}h*rxcw4Vdx|fm9a-gt*mZxOHr>4@ zd};2I3BDguj2hv4{-0IijbY{R?F1X?jsyz&PuyQRN(xXtis|6DQ{KegYt&1YEJd2& zbT~*^3N=3bqO;}TGdVSdnTWni)JKG}L7j9^@{hNc94u|$R-{M~5#oWj>lD3HR4eK{ ze>Br}(%r)t*-aig_8>d--2HlurFiY|{_6Lc8Yr{fXVmAYG|Jnm3P8sD1v z&Eivs?_wRD18-mag2ab=j}Moy7T>t}4Mr*4Kq>89f7 zRz&^>M9WX_J@>Gd!Hc^DDbFj|N>H7t?YYr3`4D1MpB9|APl@5`_T$@)6t;#SoGDUy zE|a-G^=|bX9&5c34N9kh|1=hAeNn_c|GYm_AP!Nqa>OmU(n;VkdwBD}*+dfX3bP6l zNkkjMqtKUPc!>5So;hp|zx7K`$L|?eh1ep)|L`xf?E6q+aXxAiM5%X{O>RIn{9PR@ z4JhzL68!uJSFb>RCgM`u$JaqPdNxSN&*kVmtX4+iSQ9^TA%3@Ou@nf505q6CXWv-FL2XFz|JuphGza5)KU|LeR~#j9=|ugtn&Vu zA%zA{#6iXNPlj+O){B61+9SMu<(YBSkv<(2wunC^S{`HR?9 zFa^!hF)h&(!*`Zd`1I9%{pxEL*WXncPXbm0nI8XShbWNWPq^<{K(YS?DJr8IvogwH z$wkOiWW81c1!ZqJ+u{A}zQA~VVjoeY;?yMN&`52l`aU|FY`Z9}lZ_%bQ$mwY?J|6J zFt{0g=>Qe7(>>1V@CFXyrzXWNSBj7p=1mkEZl4qig?R<#zZv<+N-P=-D{SWK&O+vn zE-SUpLsh60>h!+S&B#Ivn?TEZcQqam(zFcw+UO>s`cMEa@1gFwW{v(%{vNJgF#5bJeCsoEGU#xL}u?cx; zN*f1SsrJpVK=@Z$cTcY>;Al4cb=TE+G`~t%+DU-Xit^*#5?Bj0GytIzT4+5Rno z?C2F*X}$wP$kc0jz1HWvio&bK$zSM3MR4oJTir8iO5`BYHE!vlASFgMS@fyicdtfq zBDgAvVC@kP$g`+Z+N_LLp#7oCz|+%Z2_G(65pteBy9;(}^FDKnVoC%`Ua9FgRQnma zYCctWnpxW*{b8MBh^SB=nh)$|9bDbt!bLFgV$p!WVO%@=-!`MsKna59ZrsV=_;edL z^EEy_unSefjTTx{RwthCs3+|^b|a=)1Yxi0Zpg)>waQreZ zg(nwwdJ1+Rk0EI9sEe2&=%$LL#2z*6;NRr!nExIzO2Dw6>k;$%ja4|99Dd(UP4OSz zO4z>kINI>-Xv$x(-o+N30gdY({Zp+3N-Yt@a_@ z)zVHs5i3`VBh2Y>qPqnT(OVj8bAnAp1KRFq2O=K0ABW*DlJBxFWwOD#wVOi`c*Yzt zDhb7YWBL{lJ2)_;OdU>&OTOnRHLV6Pi-VU#reCBZz-H%4)99rGahX zq<&|$sxt(KQ-Wgk&QIY`7XOP#8ug<{7&!G>JG@m8UH4A}bjuvi!r?KdcVsV;KH)e~ zrgXyOKmio{M$FsnPF=)U`|*jd;U7t1LR4~LIU>>n2X$T2?+RrEqddUa!mY&VC#Y6) zB6-NGzkz$xP;yzna1&434-zuHx2Hiec~K^=rM<&ynf{&4cXf|YUv&F@mWo6iJWNV6 zdtZL8hi=G(aLgkbNn{Xu7baa>ZN%i|H=BJz_O3{m`26{oO;bFat&Ua*w}^)5sPMpIX$DoL_F4g|>nNAK)Jn=;WF|AzI)-X#|e2>(X4j)T1MK3RX82y>}q*qB@CtmFQ(~+-SpAdbEc>9 z^Q8EJF)`~=VVh#B zj}i9r(m?I-!yaIvR3yG?mkxdoA@W{V#br>btkygrTj+%I;#8e=Vkc}7dVNZSWcG+T znEU>uc==Zw!O(?jAXuz<8b`BP+!HqF{6VZjA!(~UV}KoTI?vT6$}X_=a=Wu1qWXdA zu$(2jKc&2AOMXQv)SJ} z1?JpUxx)<0!UKa^v=nBrSGz#eHF`@O(QQ$oqaXhZKp-JI$*IO?DiE-hKF4n~5r9)n zvzPAOa@Pi}XkyXDiWPIb{P~pCe)Y69PR9t4V6l=A;Rg+g4&dqLIzrx~uS zuT>BQzm|o=UAv1XyIu)Ep>->gYDQ+hK4rI0L=A@hy9YgCWUVv zt*-N1s~h0Be9})QOa2bz%kl#5cHRhsy=fuQfl&iNkZ$~ZV)3Yy9U&^_+)Ij--b!wnCep#773~x(0+GzG(L%CA2F#5 zSa@)oPcJ+4z4PB)z6h%k8-%i-7!>rLH z-iBE-8YR8Y#2=K`FhEkjIA~~3M*&~7_|CaKRD6zb!8B_cv0qVvbK9A~;RSm<-*-B37)|Cj7bmH-;Tr2R=8fR_QX~{*7OxS>Z)5D7F>hzy zQ%krTn|_%#o>fBGMAJL(qiWUYRK<#vCQ+Wnz5xIg-{6uEWh6v~9WgcBx3 zTu|Lb#dV7|JquzTzZCE4UX{SdI7#VI_6$p~XMGK+j|(pcW8hOfc}`XXBVWn*TGllg zl(@N_d+sF1h>x81VgWY@tI$sUc|^SVy&|mi2#2iauAV?!JcDLB%|;srNqqi#xaTv$ zoLXB35-Pe#t$j45CqX)bohs%WskLLTKyO$!sdROJ7)qa-$3^-Zq&qoAx3jZnE@hKbgo#f=0DB)lkPi612U- z8aDiJi3%@7rI^pnNw8x2ft`-5L%jj|%)O$f5?!o7mn(0tFzM-xQtNW6DHaJ+e9Cz$ z&qI@{fX6h~r>zq>)-Wi~&+8N<=87L*PW0p`JSf9njIx-Jq`oQ))&DxUrEA6D)wPaQ z=g#R~e4G^Snmi_Q311!;P|k)fx~@FmDtsi=KwzQO_9Pc7%$2`+eJWNgB( zwVzEM{aTED%KvfPfK1JovYkCgPlUD~9EtfYbQ=>oz3QBUw4)$q@LBF7Q5lCznCzbu zHe2i9i6$=@w-dI*P+fglS#;}l)E@XtDec=3jKc693th_!CV0$zv>Z0n<${y5R#-=q zygS0v3sTiA%br0)OYh-=`l~{m2x7E1Rem`KACK2k&l^IeaQJOhw?JuBEV_xB$v-G{ zE8@R_r|%J0I)&!8Q2pPxNOKNy+x89O^NTnMY$6B|*gfU&cWl5+AUQFDI{^zIPL{zoYohFflrU&zMl#GSfa5 zcJ5E8^hL)XLCzww%A>ng7s`LdhPw(0}d%ao@O zcWEK%)9&M4R6eTjP5k3d3eMhKS%*eqL!9X1tdyGkISk7ATI%J<?j<#MdyiQi(_23qkdk;F)r^e(Z093@Vs|Jx>+y12UXUmp4|~4T}2h`XU>MTtA)T1 zf}Wbc#RBLn^WhcZ*^z;LoYm`yGatB-tay*##z8m**KZOY(w;GUibfy(-Nm*4KBJ3w zS(UNrr4tq^1#k8BW?4duq>4s>^Ym@l?({0sCB?|2JEVi1VQ8WbF-IQW*Evwf0kzfU z(%q$Zvirz%);?fhW*!3;XFA4ej~oHxv+*n61SQ@;%J=Juck9Uv2&nEv z=tn*wZ3oMZ!mtyOjh+}u_|BJlQ_cygPwlVMk)3%5{!O8;db4r9Q2sYq@s#%u8>D5Q zNM{+PzeM&o5z$GlC!Y9CRVQd7vQMqyGtqc>*?9vjNvxqHzvvwCQ7FyYci_%rd^m4Z z?d{w$juOkOzr6_P8L>lc&K3FmQVCd3#h4vf_}7H<_w|!RcpJaMUxBCjTyWWR#B+qEQg@3;mf0~bxFO0~gXNpI4{(^HQVcB5t)oW0Rb}@(7-&4gUkB3L2 z`gQog&`s{7>mGL+_SGu)&r6kc;yEX;(L5o~1RRJ>ym_7L>l*5CIVjJ6Vouv*rhAT%Le$A0Z?dnruMF3?;X_sG?f>@J z&Vso#%GlN}8F-s>;L4RJA36|ywqrG=$texistPo@-T^e&*~q=^`rB3Jv7 zMbNiRl_T196Nnb~I}q0`G!BUhiHr9hd3(bxTk}0-mum+Ek0y54$)yM&C-C^Y#*+#A zZ1dgs&Cd}}NAb*`rH{)twGZRT(-rB>w6`d83V9N|WQ5z{Y0!GoJ$KkPObxGz%ywDthvi}K z@3??t3% zl@8y|^fR!nk>!Zde`Ucn!rWWI<4di$ktO=!+IqSYs=qTjztsG}iuX*Fiq{oJilJ;Y zd)D7`G80rU%I7)7eQ0q}YV}_M)58nkKYwh*{mDizO1xg4jtbW!hKhfjx|V6>9KI1` z9X)x&SQNS59?8W2_GRK=!M``nR?kE5D|W{}lY`_4XdW94y&|&PMwDR&uWH$v4rm0Q z_ZV`Pq+)Ey)P~8r^#O=zWnF?26^Yx+#u<27TsGbu*!+=SvdKJe(tX}`m zjy+kY5T9jSc?GU0?qT;6L&QX-btEiG%NGRj{k{A43AXCqi~K&LB!x8lNxiBTT`_3Qtesm?sp&_i<%e_= z?gV6oca%HiEE+4AT|AhSK z{Kgyy2+nH=?mk!r+oK}g$Faw#aU{p`^<}1od-$DoHum^MSq1!O5JOU`lT?DSz*FlY zy)~5REq_41$n*6v$a8`UUe}Z*qV!Y>O_Wq<8mgE$r&{<#=n$8bNPPB=+9@=Cx)bd7 zymA#knmb-69=sC?PUcYW9$q9U2&s9){$jOE0Q! zSCv6olz680w?sXN)RQFtWxf55ut#Kh{gVU6IQ_i+OyDyH6F48dLVZoDIu*3U;pb+v zKFy)suZo;F^+*-A&T`8?I>&JpA@$n?USq`i(66)EWF?g4Ma4G_0xG%2%W#hG|56*Y zzp`&s2YLm`pQ8rTkmtQ|8gUEM9-A=!_50aJ)Nl_zG!y>x0_L)_^v?tTIHGyzPj@ok zl{?VCpsAhna_J-LEZ+^OEVtZ3+a;eH%cN^IxUQ7iGw|OzMM!83fB7O(@)KqRQVpVe zk(Us~AbV{2Kk}~-JITglqssUR9G#bBPukZQp+`Y&`$wo^Gs=l)7s@sRsB!ZpiJ=Zz zdJf)sN#<`ge0mMK)}o8YzfQG4Scxi|Eu7gI5pk|5G&w=D@KNL}43fQAgM}BqoCNAy z_dsBB|6hIN(Nc_E=xpKp$Z`Y1A#X`o2x|s#c#%K;rb7GyxMWGR3QYw&fSW}1SvAz4S4E>~WffZ6|VhCm6fjs|eZQB*pa(ZgR^xwL3U-_upEQ&1D;OOWkG+njlPs z6Y+{~`skcKG=AR}e@geH50^DqejIAkB!prn^15?d>F{aSSZ0BN`~Dt{Dh>w$ZSU;ix9tJ z&vWYACs89pq0(bAQwXy&wlxG_5`IFad-UPL*se70ir=Zr)}ZBr;e)P5p<-t{yoiel zdS=eo0+BwKS0=oC^pLltZMOAT{EGwhWf$aF{++~N2xs9*hT+q&9-+DZbgoGP;{2^i z)c>5k5Pt3TAVn|3Poxj!yWH30IEQN`e)D|Cne%XAEN3NdxicFcDJ87_VG+u>S-$dL zV3LIv-dj?8sb1%Mh48T|0TM;G6$p2-9lO!@I|_`X2XYQ1S2u(Fwc~nvFNZ$97e9Yj zmu^UkFY(3ZN@0>u(M-us!rzr21t0eIS8h8-wz!kD7Ea>sR*uw@;%TI>B*}5#V*6~8 zaO50LS(^{E7XNvJMdH{@U#cBaaGM^!CS~_p1R-}QtFsO>97L?<*^VHa*5ioUBD`yq z`?Lu;D`xZ8=ZubEjUbcOP4O!`gl|WV+n!SRf{Ap>M%zd42l3{qFtlh}wBh)3hFB@0 zH4C@T(GG)W;DH^8DKw>C-0e zP#FG7LnJG54ZrTX6a77ELxAb`2T&=V*o?yu4!_bjH|v8%Yu_JYTb?oSbu>{}eBT>K zA^Ycwh{MumFgUBcb^Y?W9o&9ex!jvBw1A3hSpf93TvGPx>`l-lLdLhQm!yedVYN$G{%i$yJa|UI?#Sv@T}3J!Z%aKXHpab?FHn zT5P;y+TOF{qJ0(p8Jw?pf>Dir#uZ} z-<<16u;=r`(NAyaZ&|FnWADLW^Fb9lSxAdC$EPfadLr1?VBp_NUrKCVA{#ij&_{$q z=PL|a3Vo$GWmoS^W0kJcANKT>UN`m000!}8MUYInmq?2-SozaF@f4U(=z!q@$3PjNkb z{;;ty)m`Ki5;wX8?}vhquxt1qsJa{ARu%kCnh2W#5 zA$Ph~jWI&-{;e6|Y9XAi#{7C=MY4q1(=}BuZ(TeFvpj|uM=oui#*?=jA70g*=!98l zDq~CSD_z{4n~E!D0FKtzKR`(Q zx$8e#qClKY4Pv}h7O)O=#i_7sYEtHK74BkHF<25pyP*7&;ZIcAaOnISO3ju^jMl#S z{0&dFMT{Hg%PN-d#bE5!t-ROw-AmvQ^0%j=K_?G38Unq3H-By;T9KT8V)K<0%*ytz zg?xSJ0fDHc22X`vQ{0;JGbLkMS4Nth&WdQXgE4m16EyPsFEij2f1lGHq1b(JEWWC~ zxPEvH=abV-+-P(4LG3W9{Pukv7s_cUG8>wDszAqXeBzqBQXu|)QyDKJD7pykBme0# z>MMM}4f`E!B9f39P_MebJT6C6h94gftJl%|t;EOVpPZ5WQw&&WQO=0z%)XDJk5+ND zUtVY<+}wca<8@8~bO_M*v61nRA#qbx`qzA2KBO~aGQa#+FNj)Rtzk>CO*3$d^PTx) za^4k9=e;)!qN!e@_RUwG@r61aP~RYv8tQdPLKiWo`mfH10if%RRoETM+z*QHug4pn z!kXS`C_|KlzX-uC-_+2E2g!qJKJ;pvk_c`)l-Wy@}* z-~%F+)&H<6%b>Sm@6BASdL?YH)|2b}_AJ0}aXpV<8I^}fsr%y-PN{tnPfxSHE3tVr z4t_i5Q34_pYXsE?43$nW-h$RBkFMVhI!WvbHv9PAYqY_{^H*-a4z_pWf)dlsUTWD% zeDtPORK8q33`U~Oon=QRPo#z|m9^}p1)xys#pr@scO+<;C7<6Pw%h@WwG5LxMOGX* zNw_aPtxK0iXX>8|M!i3j(EMR+@YOf%k9Z>fO0P!vqB_3&ISwiDwRNB{q*2IOdiN5- zj?)UxTwXE;Q^!V4fA=$fRD2@syejpB7nSjY>Q1CO*5H0XcTz><1yJ-tE_hDRMhQ0Q zmhu;B6~!>{c4AeJMN$*p((m2nx_ccUe5xdjV$0_?2%lW|QF>5FWxun%3_SIp-Uk$3 z*FK#6%8CxYhnRiedf6u;^5bq}ddaT~5WFkyNI-MF7u+7~jAmDubYNY*#c_N$ViFRH z7f(c-muy1;(=qCtd}1C5P&*rYQv4N1?&&|Rzf=X=v3%HG%;flME70C1u~W7V)WXl~ z!3&q7uEc_8^QfLG4|^XJk1|d>bkpQQ+D)}Gbc{a;t}XAFo!0oCp{|C9riAZRGaPv0 z`-M%JG@$cPmPjJ}f;29;_q~jfvz7$&BC*)B^$Rb+p*D71pIE;KgnF(ljQtjiczn>P ziaSk<6Qbpcy|?lw??UAOjmT{1tAEIgZ(=j7Wc>w-e-a6tZa>;^r7Eye(Bf=uP#48)akG)?l;_>UZ;6>%Sws;uZyYX`O8(4!$WQ<%)STq`eU!CL$S)J%HbWBa< zKSP!YxIFgDE+ce4iohPuTh|P}Tt!!60P7Az=285TP7V3$lT?g}XK|WOzj@YxJp885 zyO<9SP?#`1E%!3yC!CTle9F9(6^7RSZmzAJ&#Sm_EjRO&)Da=189OcX^^@%z=S8ss z(KhE)SjQf)fePGD7rnno6Wx(+Ta=rB-x<*D`*Lt$gv^BYgv z8^QWF<;D`-6&5(G=bMY9u#@i>s`qbO2=2~eqmJhj?Y)y8(Clz*R~p(#gz&kgF2V2n zW9>}FM>d!GW=Z7VdngrgK)Mf~7S&^|iyUpxL@#|uN=!uy-p&-)M8uyLB1+%k5b@v8 z1hD;=Fz`w)Ukz8u3~omcP~3+Tf3|r8)#XQUE8<|8zmj_tSAX54Z&W*dA73^fAKME1 z&l9t2NsAGpKHbnSNWT1-goOpFI-^F8og(89zN4$a5V^$&Cu0_ZTSujC;Y)$5aOsre zNAS}e$YAU77>D}RAHIXZg)PwB3$&*0yV!wy-Ikx$EjLWClCED!GWPNT-hY{AU!tMD z3-V#TUk=0NE68!@y5uxzI**#A@mClAL@y(inLI4v-JSFJx4X#OwlgP;*k9jTPBy&@ zz$?n>CoD25{*jRAdKln#B{IaCO5Y$oylo%t0s@dqU5lH;|&-4_`0*ItWl z{~S`LiEL}rgO4MyT-3vD15JnT>i>EDSsRHd)ej>1ALrpa-_uXK-#b=tG9y@V*E%T{ z=Px+#rT)~GMQA|H&y$f0G5Fy~*ZGI{;T8%-NeMEdTN?0;BxT6^n1Kz9vM<@`*xeYy zGV0q7t%vP_WBkd35pF(vOxBB`tz*6qY1a=He%?682=ZK;k`TFHA8Pjwt9=5wCw^lnGPGiheRKNT|+Z9MW zh}=4zVD%2I_of`ac-^SPa*xeawlBK1N*RiI$8zzqp37m?Uq}Frx$l2pu8eZT%{mW5N!1iqTsbzc z7<8q83cske2^jC%mBXu&proSk+ZFs|DYWj+C>TPZ(6@w~i+?MSPOh<@Ts=sEC5uMi zhhvvV!Ap@dDE*C16q)L~$#QT1nP8CH=%^$edoY-mLp=R5o>73F{%~xTn065anqm(H zC_Nd3PlkA}v5UiVgt&(I33CZZ!%pzWbIIVR+c+Fd&HX%s`xf35n@2QhuaZIDJxVVw zdA$mH6PM~=xG&ElOp20;>`a0j(unUbf4EdGj+*s{7dUSzTtI(n{vdrqa(zoK5@mh^G@w27fEgU|cW_$I@?_M|^dP-R9 zM_hv&R(^ErfBm^o;q~X{!Zn3YV4X_;u5#hAHO^nuCXkhsD~9P|>G^ar!>5R#{~KYp z{73`D6Ir*DuNN%ip8WHjqkajR7$2wkOmRx22<>`FR~psq<>9ESreVA0A`Z=KwDeZ& zK1)zlOkt|=x%dP`42~Zd3gnV;qIj&v=S?pi^wz@n#qu|;bt(<2 zU>uBu0YNZUOBDB2!Wq(2pKNlF{%nr>dNc}DFD&GKtwHd=032qBaHwxa%M z0hv?l7uUKt29ePywKn#Nq8kuF-QCve{W`p4Uy`}3`#i=|hlrw)E^PktnLF?Z zSiU5ys%r^+f#(cKn!`-+0W{d%_2eC2jlnsx6)CmD$0p&Q5*Xa|`Fk5=+~39Ly&!xI z1x-Uvo6GD&U{?wrrn#kg6_4^C&s_IqAI6S&N8>Z$PnMXAor^U1VC#k(!NN~#Prvs> z(U@pox)^m50@dO}T3C-WTHbkd$4d+W#Ye+yoQ4h$j5TK-?k&z-7~u8**Gl< zR@bKt__QBGovP`8?9P2_M0nL2CoSy~p<7X9?`=taG>Y28?Rk>YtvniC`ykbA@t9~!5XFGXL-l?KHhFJ2pgqH909|M zl~;?asxBJhbkus^(FkCNo%96@XRi-BHl^m5|8i9$C-}ew(H(zgELuyrcneLLBA58W zvpc`y$S@j3$?(#=*#~{!PgWj)VtV-C;qusK(E6&? zH4pjk3(%vV)+@TC5P&&><`DiBM|y2ioaI$3T9T%r?DdE^CMf-_6oYcS{~p|o=Ae)_>(gs zH<<*`QGA-SNZgwfk<6plUKeQJL-};!!_a#rdw5^HoFF(__7V)i7a3|2N_(*P^gPL{ zgS&!Q2{pOh7{XUf7=C^3BZHQ*$%E$k z2hoV!Fv`95#qkE-X*!q^w0C!dljdfk{4ifI#AfG2xCO6w!Qb4cU8VJF8Vtk_U+4e8qTPoV$AuPzgRQg>I98^X?VEQn=1&ud7!=2)HfJ;+|5v=NqG+D+D+Q93QYw?a%@M*Zt|I0F9lGybb3;PL0r7_?uBsu~ zsx;*@bKMzq%p_4%)L7@B>iRXo06FDSxGwRJPPnrPLFC`_(eGRWWZ5szcRZ|;GN4tWEm1jZ8)#(4nF}`r%=sc^qCE)5ngBi zVDJ7AtfMDhJ=FFa1BVq$13%Brd0f3=_V*3VuQWuMuY?_o%%engu5x7DJbwrJR3CQb zCW^gB!5Jr>FS)FY@Sqi&qn5Mjf`b%^REFWiNzk8?+Bm(^BZ_woHyu7l`!HegC4Y>) z;$SMQIMVxNf_ksuP1HHFx&#>9y8xlb6(skbbtCG~IBi6feE!J#TpZXn>Y8bCsIT zXF^E3$du||{dQGP+`6@Ud1G-n4O|kJNTkm(SRgj#5yi!O zm)@bgfc|^+A+jyZ)l?Q+-0&NQ*6A-TGatkvaC$McVo9uF3BPPAytB=_0BOp^zV#4V zJ;>eQiTNimvcH#`y&tmEj)W~F$he~Qh--~t$wMPtc|kJi{r5NpGPB=Yx>8g77}rmw937mV zJdE7qP7Rk#)3hP>yHFrzS<)WUzc_f>ZqcfNwdU?6D$1i9_$slL6M@yg8Lxgx3lCIvilVhFjKrSIX^$f4p2QTA(aY)dq%r|y`>#%Y(ImVWzsae=S-;}Ff{ z2~cSkw?1y)y(Vr{|;76XaL0;_m z7pChuflQ^fOxVf@BX9jTF#{sbv-*pa2j!7v#@GA7+uy9V+VHImwei3+q&QvXDnaPC2qlYmny$Up`t!; zyZ4yq{jBGAh{@nY2Jg||I8A?Hqe424Ygf$y*TdeNL@IzYko+W*%XJ0Sg!;NVFaO(`aM2Ji24?c2X zUq`29wyHc1(W0xI2Cu$^YGZjnHFF}T20-rhY~n)G0Rwo4CPy(ot|P~AgTXQVTe5;U zK}4hBvC(}KQcEM*>?Sz{ur>bsL;SEYCBAG8w6 z!To2aPw94GFwrh#driM@KRqLQANtNu;M3?xR;u*<7zoYkquni zcD&##qJILTl5Y-a4A6|D(L>PS8Ha5;7=HAhj(v_tc+$dD9;Hlcg`RaE*}v*RRj6AU zv0ssq-oSYNw~qtc7Vn|(G&hIB*?SyrPvvaBP_8^iG4;;jC!RoT^!?FChO?$46m{w$7!|Ue- zkJd#d#Bpin_Eeg~bu;)*_Bieej{2j0bjCCJp*^5h@>Be-?amt%ANm;)Rg$&}Sp3i)x@P*J}>gY(F~lCZ2x94@QJLwhE0 z52iUm(rdr-DpodNOyV8)ZsEy1bjwQTO%Q%zL%meS?pl-?8y0+Yb@GE5m!Tti!Co(= z<0<$_Ph4WRp;Uo7U94(<@RTavDicIzGF-j}_WnCgmkmBtpz5mX=&q`9CWP%y-ALBE zw+WX@!!VHno~JOU=$V#%Y3PH%iSOrC$qHJ)$oO1vi^#?TB<|M~zOg+yjf($@1Z&;* zHL00McF1Su>K`~E&?&2TkSG%W=tzHNOf{0hm-FfA?JgsAQ2rPw5xkWn1lQ|r-vV$k z3NC8?ck|zc4&#qP!#u`v4d1Z!S71Tz(ySlJe(U zUG$ac_p})9j^Ll(!n}|s(%szkbBwQ40B_ZVtuCtTfDQhp635ymdHTWAQ=1V9;@>4pcD5w*j z4BA?ugd|n8PGV7z6d2>ocMYm@?GTsA`LBW{LK+@VEC*hC^m*ddWKd>1*Y6Xc&GRJ| zF4{j5s5Lp(L3E)2z18vWbYxH2!M-a|d0WCf3Uh@?y-C5ds_6OQec_E$ULuTLvdhjm zrS@Shar#r&?w|^8E*_cC%KdQw8R-`Pqu@N;v3}byp3KuKdxf$`cG($)$lfwCLds5v zL=+iCR+N>!SA-P(Y#NkRLRLsdWt0f7_YZi+@qF*=y3h0TIgZDN=c_<24ob=YI`_$6 z35FAlIs7`~sgOE(IA7;Bah`0(g@yGcihhHSSUL53%lV{U zK2}4kn~%&ie}oN#z;zYLt}&?5x~=~kK5U6kZ|werOA`viQR&LdBO~L1D1TCZzjz^R zza?E?eEN-(MhP0}yyyOe6y+l_#92sbQza0VFTJ$~_~l}d??y7cF=+V&iusPJdWU5- zaq8E?eZ>j~K5W&76WwNs@5A8*=NS9d?;?1V-Fr$UJ6smzW+myO{YGKvY3zS_cr{@e zM;;4`EFXLn2>FGB)v-T@C(v~5+}(qjpZ=nHq1;54+&72*`@4y-@*Dqbe@LGpBE( z-Jj%K>QnB6IMaQCp!!R2GTw-qbv#zrrN%L>{Jp5x1%-Hg@YdV5&xus48Izx-3r|4W zO#Yv}e?T^fE#9jc=U=pixI@2^meJ|&cuB405N*r<1EWEktewtr_c6RHlRhZQ(t~RX zgE=m51p`nnt8(;sJMF%a-Nt{O4yPz*E-JOJCBSi2DVVmOB$%W*88= zB7Q1cRt;mUXMLt3Yf{19R9=45Rca82M}FKVRvlDE;SsnWW=pb1**WE^Jl}>rR6K}W zH~q(S7~dYOe_H(TWd#G*Y?OZLJuw5>{IYs}&dF2wZ}af-+=8|heBxqX4n#SHVp!zI zA&;|@|49PVJe3Y`nsQCZu+00xIx7|#P|&yb*(Gh0?}MB z*|TP~I&dHQ6)+H_^Az#ytuI+qmF5u;B~clWc8U&>Q~ryZrep3<-P61y`kikY?<1_a zbRTPOg0q}k*fy+C2yY+mOy6B;r^N2OP9fa|mlpKeGSZ)=(Kir8 zS_xGlVXHTPC2|e+0ko z)+5ZN`hH*>oV<_ET5TqZJpx7W8@&0?^6Ld2+!^?=+J2Kd1A?F4DcT3!p+V2rod=8j zGQ^OQzW(8xb)Y`xz0_qC9!r_vhZ75z{S?O-E?4LGw!F@~hcx{sX{_J09Z_%_6Y*G`QmCp?VA4UN>*GDav5LMYqTinQy<+P|lZsTgFIq z4IgD*rmjlz8Nq8Mo$T0AyT>@0-gedPbzV8%GOb?!HNF`D$@Fag-2QJ%c+VU!Ec7WS z1TVguiyVAS^9F^kbMLS7m>$6qSaE+9SK!0zEVWSgc6&nb?aF0dIJDLV*P+V!a*!|kBE^^uGgw~=JAt(aWhco^(b1^&ABybP##tt=$J5ibhEpOtyXWCyMA zLSuTZ%JuX%ZmB>0ci8R95u{vDy7Kmd(&YYB&StiXd{0MfQ^{HN3*vDv?*F+#LAz%kykXVE;hX80uBQ2>kB7)h+rjh|NBG#zs2y8wI9g7O3#CZ zEdBRA1RIoiz#-Q*blZUlvY96B-^HTcQ9Bp=^t+}mKXfU-eZR)r&V>vGCyFQyp>`-W zyIo4z8qz>tvf`22%2FzLDUmCdUG1O)w=YR3$&spmFcxt4b!z!lk8A8dd3K1u4C3CK zjhk#CCCfyAA%Z{PrUaQ}knQL%PORCB4DYH2)3Kc%N#`NSM{DO-FkKUz|hiKV?!`^kyU|79m zWH~cCeR01sd^$JV=y3%xakDbZC&&1(c|h!Ow!q7eIRDFelkZ8{OT@chtr7dtY_TPoiuM5FR70}ak<8>o?^|@WeC!0r&8l_)zpmc z(go!|`?s**CcS;t0pxRazM|uPxPYJzHsT}psb4@=@V?zk@S_SQhVoP&1vst1Ntead zRZ8{~UQmQP9n#mALMQi~+Tth?E*y4@puS*!cOGAAV;CJQHB*5#D>8*1P8vM!8Y*KE zyT1%k(hiPfd(KMOeBL&!YclkOfoSh=%5Z^0v@=v038;W5jRdW;+IWx6p) zS>ih7x~6>7Vk35Jjw&54j`xFQM&W0+!{OTyOC1a2eSIMwLqcovFmtnjp41P)zs^5j zf!XyU^>MBQ8^|m0-7~f!J&3N-CNb-fk282gQPXr;?{qG1likX26(H3C<@XT5NRBHT zpx(%LATlauM$rW&nfXsCU(mlc8?Y^W?iQ^3BcDCiBn^gf{mY;+Nu6>eX-N^-%Ln)) zdhrH>^dXg-IQ(Z-=t}ui3T~11l$$+wB7p{7q96rjg%E;#~n8o6`N69 zSle-+;O|pNe0?<)sXFe3Ooix;8g^kLFx`H&bt$p=1-f6H^UaP4Cqmw(j<6Ii)l6^| zCGIXQy_$hgiZLgjMg#CE>2rTl)lC5^SDx zBwy&HV{lI;KJewFt|W{(J%KRJ0>lPHx6eIXP}B2OGM;FD2s%*@@h0jm8T()47;YO@rNX$~%U%F@s#MBe*?Np~+nc9sPOL{nXV@Q8hX zh;|YR?ByvvkHb6jA<2k=jyYsTe(!zw{z?jdtz**tZRGpY^uXg+*bh!q5G>X4NnQS6 z2Kuxs7xE-ryfHE9Drlb5-Urr@3H5C?qAu`BXDr%1l*SOzw>Cw4swPrWH zLHB3p-tz6kd1xp*&1h3XK!^DTVzvBF3C*Yo>g+fjS6YGEwM=&28&@T9%3>j?Vdb|i zR>%eAhP4IFAjHjOZs;<>Bux?YlO;#F$&TZ>^D~=@&B<{f!ap)R$hBE9VeF z5#iP%#g|DT5?%7)Vp3QwKAE_fS#cLRqS`E2%&PIk0k}=@s%0wa4dc!)iu(MU|H5!L zq&C!}O5X_Of=RV5%1)F=uJktJq1qph_3 zt<}}56d+OT`emkIL<^N7Th?>ylcVUky80jD4>N=HG(pcn%D`Mu({dcHRBBJf^|U** zHyV#^VA^HbE{)~dHTaLVO2mA8)P(M1M=j!;4IJ>{TAe);kV8`PJ| zj~=I51!Ln7Ga07{J;Z7^2Gb2+(n0AhgM>+)*dh3a{e0y|NIQ@D+#3_LF|i_$)<}q? z&rzwzOFf=fjO9(b@MegTlD(Y#3Drk06y85+Jqe0*^Cu+_4bMYcY~l0Bj#dlKN*XoY zrG7>P?Ypg;z1}@Tu*$PJz_YY3tg9p|gj2~Px9}?^ha(_GTM%v{m$uH|v?&0SbD%-% z*XbCk+h=^Y(qFdrPt}?VMd(?>PPxLi z@<`b0-F>(6>GE^5>UgzUl)dRi`}gjO+szN1A?{oLZBBgI3!4&ARV!rCr{IhA?PxHT`4Ov zVunv@3RTNqnH9*koyoatcS-?=38!~9-m*%frvH?ug@nf>$Q4rI*sU6cPurzh%rr|+ zk@n*H8O;wb_upBZCw3=Z(+>X>O1aLJRiuGA@J($uBf(YlV}6n8oQeZ79i2ii(!6ZP z+;rsekKZ1JqHg!X*;&)1XzayBZVSqiF~d%d>Hd%3@2XJH%k=xH4T}Sq-OiGm^96*U z_DUUz;6iKyv?{~DDsJ<2;GuZlJmdXmyciMnWt5GW+r$q2cOk|-i9*PXNwiTOASHr_ zk=n_)h1;gOv}*|JsV#DBbSi3>&H0;7@4BUE&^91v=@$7xIb`^=Oc4 z%+=}erUJpuOD+<;pIdOeclsB5lE{55uJmdS@GVE6xs`!*HvG9W*2SWAsp1&pQEimB zaj(4e6=bZk4}KnHiNzrcdcvz`mUB^2A#~j7k+CauiI}WPhZ1L@Rd;zT=RPS360U6= z59SwLgr7~69luR;D7=zBGlpAsJ7VK{ptt!B9SMH>|Ms)G)h7$rtrSfO$DcEJU8cG& z`P%pZ^xg>tG0*=t#h&(5&kI;mp-njcKgqW>BFLxoxgck|Y=Ge9f}Q$=up** zQV(HXtkscs=rkqNp56b(BU&&6(qXdMi-e^^m^flb)kY^)562L@Y;U9OW3bkC=h&vc zQUur918EW&#k6=HpAu`>XLST!k_Snq{*ol(iC-9Nrx&jf>X@UaH1D1F!r`0>-sk1- z)NolypTkI<;|MO#+&d7S@E{v;CDty6rj`4V#&#`Y>~n24ls+E(u^VFc7GWa3l#W2H5IbZk{>%C&U>FE!b`({t{M+GV2lB!SAA6Juv)DesNwolLW@MdhYk+2Um1_jLR*39zc&s8 z^N_sE8(=MqF{Gi$Gv- z56JJR&t^4#VuXoZn#TSA{Qd*ORzQ!UXOba)_PmMA;!!fhgA)nd8#*~vpyzR6@ZAfI zgwyE)j22O>lmOcBch7hjcz`MAB^Y zd+v3f2hS6dhaQxiZa6zIEBB~{ZVJC#m^K^lk-x&GlYG0&rl&4g?{_^+`uF%K7*jsw zp0~8^!P32*^PUF`TA^CqUsYT#wuF)!ZvuwJS@Tf;M302<-lQw^7I)eQp6_3g9v=ro z2t$ack!^ZUBa!iaI^KR_z43ZS*8}G`l@8xm^iIUxjVV{C6e=5QHP~p_d6~&n`mj)u=_ujX&YI7~VLT zK*0YNwbvR%Po8unfFFO%w`LhpVvv}7ytwtYHVuwcks+mi3f{P7;*=_3*_V#Z92Guk zcK0U`y~3EH=3o8<3=_S7IYL~raD8mPQ!2jM9D!wDBTEAaqVXtosaJevvK4}GLwc-i9v-=I17n|PVP#}|O$&1}wJVyAz=P9_}Uk*0d`{fO%_Acs<2R-&+U$tiqDi=M0 zmM#rLMN4)MXk0sE8Af7z0{k&6kGn^CJmF$~uso#R%@A*I!>8(Qiy_W9dUf4PW9C51 zW#_2!FGitIrM`UhP*w^P6m3~87`U1!pe)*v7}NXsC!Vme2eDgh(m>CZSl5ZUV-Tsk z!M3Yf-xiQIGSOD1pS}qQLwE> zTX(!<5J9aT0k7|wGNR^ZkIGI^!qfeu9(sJo_532f*BL(W*QX~z;D5(P`+8Ljke?tQ z;Co&}AN@+pW0Lmz+vq*Q?H~DA_dVJ)_`b`pU!6qLc1dpD*2N=eOu8hQ+#M&2Yh^@4 z`QI#!v7A{ilu1gWi95Wq@x$TIE`qqI{TFWt%Oj+2(k?LEvaQDN4}OiI9FbjUtGmki z?ZfOv^f|p3%)ND>2lKBImT1-#ZE))v0Y^#8rUH)q(p}YNV4TMTvf-_WoGbaD_~JjT zFn7BFlVVw)4<6npLK&m1StJC+CAhzIENYS^fELHN4cgMnQo~_uU>^BsUStUZ{gX;l zFI?kbQc){yAl>1;pX)YM*HtBx@m{=r^VpuPDN17JMGCVBjjN!QT3{zNKNPAU`Way9Uya)+`in)UVymY9C7RC@C=LQ67(uW_xW6~T1vr&2%g zL0e;uYU--8-kKy4P^|h>5xs&KEyfvizD{&XHi?68u{)qQNp!964)Nj&A z+#w@=C-8111$EI6dla5sID>7fYhu2$KeW)N={qVsf5HoCpT*@#ONmHO!fSS5^$uYd zhIC0CgiNGL!18iihT-0ggQz5XPJS`z0bnk8Ji|2JQ4N8dQmf$|kLv{lAq>W3F_ zHn_I#`nxh*dSD;?FGxiVi79ixZ#d;}VP#+TWt|UiL--F*g*_J>L;`=>8M5^+fAL~L zaIpJvXBiS5V=_ypwi-}tC9WB#X6XbToxn=MlU05DC-b`m7oR^7o(R}IQMj~G1P*7( zjmLg|1rQL3q}P0aL*OBM`8i})yaECtK^BF%R`d`Id2%}8sq7E<-r&7@ag3l1E^J(@ zmg%?F;SrK}MZGp84Vh*fR0B_WrP11FQRMsSMFNU-SWDjhs2>0gSME29+j9vhAT~ z^DGp6PAW?MRT(R2;QO`K>v;YNT=?zDZQ4>!;Q$Fwu}!QQJuXTII?#M{e2BrZ-|VAx zO1}8*%_A)EMTrgW|3+T~A39uu`8Tijfzh)yOw~=#+RL8qLxb<>R3n4calCqWq1aBq+4Js@WqDZ_BT&8wshtOBZyBstV7dLe%M*iR)8J%qAy7HE z{urFyf|}vW;&Vu@9qB7B8o2=nqn3{E(TpVUWKZhPIM;m&Ygd;_)faY5p{qhLB^F^H zfSa%0P@8`FejbbZ3I|;|=l&pq`o(MUxn55251f6Xr@7XFjgXRv#LEA+;Qu?S>iX=3 zZq%-_ML5UbNP@|%02eo{Bk(cCKiq&Se;WkfxhmpH$v%K@ZDoYGf;tKw@nl-eNA|x) zXEW24mt$x3;Ji9J>iKiU8=8DKr5MR47hz60`S;8X7Xm~)&~YEUT)vAO3Q>lO2Ko#5 z-XozY#>p^_CW0>po5ym#%fV+C8RdaDrTdCs zc=|fGo~RZWIM(}~CsgmNACY+r(<@z{AmV%C@>|Ok7ChZEXS=ZZ+Y9l%9_fK$o&8Aj zijPw9)>1=7N=uAEdgxc@v)xv$Ln}i4&%B!ci_(M-E&x8)5q3t>vv%g}t?&tCO;r+I$WNBS*jJg3 z$_}GqNS%F3(;PP4j#Yi@l4KRVG`!xqt$)5MqymB;{zeAA?aBw|XI`#+_8?E(VG`)F zEZ#p8pRPV=sJLJsw~vB-*%u6oOw+hE6!vZexOfq9y*hJ~3A8z4x zri3ai`48yF+MJriBIlq)U*BKJxhf~+KxKqqB&iB5R;%j?mtY3?l~>$tbd*3AYNctUFCN+#;M|?(Soa# zVh9`TeZjTNJcTe_!I_PsM=59*pjCT(xqSnp>ELxrBxJ!SZ2^CR)840{_^_vfo=db2 zw`7GbHD2_nMPaL3YSz+`Ke%#qm5r*6-Ux+Zi7GCae{z8{sId7k5nTrUe$0AY_>@)k=!!KDD{_2$fK2xRQHfazc?^DoZ zC^?iJ$lixf^+^u5h{qX`U9Q4R9U!lWk^p>A@ZFfh(Mt#32Hz^M0`Vkw{cyR99Q-)7 z&vg{*EkhtisC__Dcmt=XRDKp7#p5FB0~50_ygHVpe3x4QO?i?Nmxp{Vz_a!3 zTd!3!XLMd2y}5hU!wl>>=UW+1+0^4_t=L)F>i8Rokt43@d3Zz>mj$)StKTujfXT4k z;m2RoN;H?GmAf*KwSqeG$nT}d#uWr6a?k!XR8WCgS=}+)*Rm5(@CmHi>5wo3Tgf;> zysv{alrffu+@<&UNJv2KP^EnpQYThzA9?aCV0Z7t#^3|>ax|@koQ=5{s)$HJQzMdQ zV+{~JKV%isAwB{#E5Qqe?1i&9qx^@cS9>}I(OxIo2QIf0BS>}L;Ah0;CYS~rpQlXkhguX; zRdD%eO>^kUkSh3C9ge4zRsV&SjWPSF*gYl4xXZurp~|%ak1PLMp(xik5aNo`lTmuE z35x=~q)iX^hv0VFnLhrD_!tTm`kp$UUT((sRerU{qbD>W-tU)GWzZ~-;oM&q9{An{ zHS>Ql;;xkK7@4zn;3qm#i~Q}G+Xc)I`cd%os}mSHhiHhDb%bgVuuQgYcVjJ;aZo`w5r^O&z<`&3WOc zdRJKYCBX?S@(0;CO>;`%-{FzulV1hM;raENzUEFaJ)CaN`JO&T*ai~?**B4I?sj1& zv*w?lir+H6$95lIQ6KsTEpNG|y9IL{sCdV>d?GZ&1BxxpE;TYH<(Q*?xYf5>=73i` zY$M|%8qbknWVLk1(s2^L%HKi+9cqIxYY`w${^9Z#PS8B*w{~>f1g}HrLz6Feo$=V_ zDUEAznFef#S&dFJpUuYLsKE;zMVUTy-SJttOiK6{e%$WngQB@F5JKow+4>@%3YJ4+ zJe-;`cDS&Up~W)$>^R(th1p3iuiJxIt-t;Hi_~;f(cWuscvbryLu@lCI&*saOXeBX z&ThF*FNCYoYwHNzS@Frvvt;7_FK3*W%8x%X#NUQ?qnpoXj5+K;z}9)-gEF@tj9Azn z9jdXc$Cm%-{bQWsiy)M*+n_=I9D1fs$=iGUAb|`)2-#WE{n#8i`o`A6CV~OiI?HrM ztI7G%o+T1G6ZGppDC@QTI$b`gfbg0fYN%`+#~SNU$Sr}A72M(IQ@OuIAcO3hy6WO^ z=^~g!8yr5$}q!3MC>6ACWSk8^~*dz*UA2Wk@R5%~LB9zWpcHcs1>7(_)<$tKiP9ks=d3LALwuuJ0b;H(>c=D z5tIMz{NC$mS43p&wU`aP-d|tOo>q@EzO#X@biDCJ#qC*~H@_8fVng&9V)$G{n}aj{ z;eCZCWh1RlEKbsFUt6nv5r{r#ar->_1vexzSDpwx9^`<>lPXGqI!^YOnmGIL!!M%q zm=pCE|06um1%rG=gT%3blL$M0rsL!0oE(^RrcS6AiBh2P_d?E(3mSWPAa%~uEaQ3> z)V4!^MQB@@ph^4?<2mE9Yv8EZIOzVP)fHNYU4C0kzE_3N>cZ>pfZI&)eBYbv#bp@+ zTKl3B^EdfJ;L-oYN#>Ikg*4*WTrTZq7Ic_~{>Mx7nhPfrb3*tooXUbLi9GY~toW6&3 zt)eKOr&{62*V^oUJ1D+@KrKEA9^aub2q#OA=gKFUAUf|u>v7*HIdr@}ApDeJs{^A2 z`2$lg{r`YRD`EH)W6e`ch#g)SneaG=VS+oGeeJ8dP&Ygk=~!&$g?sjA^R?=l&x2t3 zlFFBE(qrg6A;0}KBytIw!TmLT-90-%@QlrBkl+Y6JQIW#V(XbDB^$?ulMtIwiA;&=Spq#Hj#9`jj! zEY#pNxFXNHyyA8ahwgViZ^dtAv+zs3{&@3R@;S)u8+#{**j0!bD1X!8f13;*UFkEN zTdg)o`&25xQ5s7Ml{q5M^@c5F@aYx{NpyWofcm$>S^e=8I)t6|EnBeL{)$DPiLv`h zO*U|rl{ns^d8-tjMI3rs8$Pw@WxCcoM5hyqld?oDG{<<~VKce0%=XgBC-D8}Fz`tC zB`Z1!{@tFsGF1j6|JIusZe~Q-WpgXo>0_3FY7&cqpv8qHoUhgKTD?WbW~yFkL>T(gK`8cu@O(wlj(HH+v?qF?sKWZea)@RhO= zr2Q9TeA7N65dltFr*~*Bio)8GQh3aftpm|-L^yp*|41S4mR9j$^Sdmlx+m-TS~&6^ z`c9j6a7ih?20_jFU*#P)t?_H)`MTd5?+3Ud;%NVz!ap3O-7@D{N={QRsAsX|2X}f)TmzbD}3>r*d)Y5(_oJG!)TWMFi9fMJw ztzuQ9^tBDg4%k}OzoCgyYT~tTcun!LT;a*?Er^qeOZZJ)^u|5! z*nzXhlsge%)9!WkT=AKT@KOWJ0o5_6JE7(jXjGW86-4((aFM)Z{0&K z`HCU(c2Y~((>ondOHN^A<4~|4Gv1zb%IewEhg$D7x-n@LUDT!QT;G|xcNTKOanbX$ zgafGiF<26`(I$em8X>a#&CDJ!&{^Kz?ht7K(Rk>O;)8M1c)_3S6=x&oa)7QD4O%jaOdv7Tw}X>&#r(n8iaG!VhNvzCQuczI$pr=C>cTF zWu+U!#UEgunN2BeY)S*~Ndm{g2}f7-_~c1ESF|ujdIB$JFsEJy4BxA{d}-5@#ED~F zB#TAHRCqYretk!tydSrgCvQ{*>sz2*U*TDdZmSlG(j5}Co>YE-b3mawdv#|8;$%Am zxt_@6g0P48zjju2JNUnNQ}NmODLMKI*MffE@OqAz#La^vG$d*8zP&^sA{icl@$T7A zkx>)ei1!rByWw$F9Gu3i^mpt3y93_Qr(QcRw-aHJefkBpsk<@y-tCRctXquX#m_JP z5NMC{!?!(97j9d+HtPhHty5 zU9eh4siW4pf1X|P2pzoDbx820}aV`&A(eWPl1%L}gRIxdL;#bF<&fO$?gUwiGgY&5a;`nmvu20Wb`f8Bp|FayN zVy;59Qju?^;*2@U4iMje{)vCvXKh(eY zau2Le{pH=I4qL<3_LbwZ-SGeaN%i(Z=ibs~G>E&$y%X)|1r3E6OYGzI!+5j7a?(~` zLkK69WK)lypRLEI$z?SLp{i+Uupf9%TOk&Y7TpR85{-uj_}QMz*l81!h`C16>?CDI zb@&e$Z5IgMki&_hu@v&x_jBWxP9r^P~{{|5;(03)ASmNNRf5% zw19e|n-r!L`2-z5y%YVH82xu;ecVeQ>eelpGkb+Bgto7aEih@cdd?Ex>F!ZeM1ZB9lVlplUHB=T&d1Yw71%R zb^b({gQBrZLBi*UWssli6zo4mm4r75HX6GfjGQRiB(!hQffRJbMLsHBM){ydU1 zjR#X9J6rK(l;_hvwCwpb3WbeEaL=FrC!?v~1@dbTC1$CbZ7}3oFVS%3ueF zDR~W6%Z)f@$Lpui-xv@(UOw~{<8P^XNG|RYg3P#n=0aj?EfAn?z$Yr=hZl2MXP6V2 zo`aKgzuctM6vx{sufKm z8R&L0Gh33hI)ih)Z~VqmeE0ssSDY@f6wZNVyjYRnBwIQ{RN3A?l8S3X(mUGGv=`eK z_pjs2kE}SV^^h^JYksaUy&j2;f>m|my#=84GODAId3p}x{(WxiFN93dzrmXK`iZ#? zCQeZ4?aEu8fx%Srf9jNfqEU6QCi6>Wzd5KZeJ(xOY!w1c+?8E!L;Yr4P^OoNmhrSj zi>vMpT>1JQQ=g2><>%d|;Z#Y;kjq5y2XR?iC4+yNqEK?Q*)#KkCk1NR&dYFI6jVg@ z3`=BC+;c7v=Tf+{C4|bNO?5+W{Tx_vNV}_{TJVQ1c<#%qL>hGwKz*u)%;5Y!4=1>* zdRS9hu@2&Cwg+RsR>>jB({hMC)Y2b(a+9x%KFp-y=MU-Z*^BAvXeS5q3)>+!HJnQr|w58pm~c;RVoc0d6yN93 zz737fk0Czf>Y9cIp)3~Oto~jipLl_<>1P<5u8Mz!uA6v{`mrhzSoB>B%zZu(i6dvy z^Pa9J4M8%{ziaZrULiJ*%?GmlJirF+DXPxBOKfqtU{_snJoT#_G9G?fmO6Lq2Gpq} z=gOzV0zn+sBTRmYvI8Fz-7?BOM_pkXrDK=Ot-S`vr!|gI+u?Q~DX{%@R%_%AdWkRG zpt(%G?*jTc?mJ{Xe~EgF1JSP}7P=7RoG2}5&E1J(3o{|D#vg0I`IxlcoP{L=^tPOS zIZsQP&}jPc9C3ll0yqZvtZif;%VWa1r&{o#+99YjpW5;~pLquqWgo>QP6fUO;Th5c zKOU`|#oYqq@HlS$7a(p5H1!Lju0Y#f^UJmYlS?@M-XQ%Ye+voxZaatYyAm)!lqw1#eKknCAe!CZF9aFDDyB(;m;>i^-UPea3*0zT&BC zU^w<6Mqf4h5-gfS{q&7>tY{GxdZ#Z8@Ii5*`0{@83SRiL986(L1sS z@wt0CFB7{TBGQaRwseK%4iwHbzRj@xSOA^dqJAX4jYcT@?bvWsqs9f?FJyWYIt;7u zG>oijldj?iCaPzzKQAJ&fLXFEb>AtDFPJeNrzw4T;vzh*6aRam!I%IF)-#j-SI(`2 ziN?-tV$<{^92~k@BGWye;)%6V^=+T2_Yh?uuG|RlqJX3@<5p^zB{!HINNOW_NjD%o zc%yzwo>&09Yr6}_6py_{f$=y~&kRKql5~ndN8q?K zdE0taBo|xl@kY1E%OtS`oWbF~bQcgTWz?R5eIM+rR zO6@(n0*km{S+2txE%;u`wPEs5RUV2rYxBnie+J++W%9y>4{psctGg|JtBRN#L7T6_ zM4XAOkSo8voaAZYhf$x~r@Dkq)NwsNgi0p3?iuU^EO%=vOt^6MIQ^yJKN;*OAzqf2 zR(V~77fqYOU+l1D(F7IUy=D1wowr~=FeiSK zg-09xGgMbD8#o%{STB!Er<6(_0+yXE$<9&*;qZA=fq}?|HJF{csu|g@5rVqYB0;yN zr*C8Jm!f*i!FT`A80RJ|o^m}8+RVips@6%)SSGdFZteZ7hMYN}Glxg~60yNg%SBqB z*ona;BSMnURymlbwUP#iy4Rw%!?@w;k(gr;XZy1|{>1GhJX(I=zLBM*0%lK6f>h?Q zT`12864i>^b7H$O-Rea)`2e_bzq9d>9P2{BIMrSDZ^W0dYn5+yH=3CYq^vg+4nDcN z2($a$(hpugBSL26ze}GwX6BF?yR;cuuT+ix?2eRa>*!!)I~C?e+~Ti9TZ(}9tUPrf z^1m0~eY75`g`uwsQGVCnY$CDl>Mm6~%>g{3{U~1dyyXU#*GQhpPrUwxlM3~6s>}C@9zBDeQuK8902bku+ zR8T0>Av2`vE}M=p83g#mP8BfOlw)~IG%dL`Dgqulv%U@9e+Xc5Q0WrQ&^J$zgpDs$ z#vb8C^S!K26054;xT`v=_QoSj0z-CN62^r!=P<`d{66o_8zQv4nr!d14i&CLs%aKEi z+EFEz%$=P=J%u!HR>8L|B+@>g+^yl;hW{sjmUt4qO4JDjUQFMPAHjr@;qNym zGUedP@o4zv&zVGw<(f~flsoS$zw0TQ`QMv6@XIP+gu(M$0RD44zhJ!P91CyTU%PTY z1W)fXd{1jV{f0Q44L@D7qwc-{ubSkh%@Qd7j*HgU8BbO?A@Dl#c+&AUT z=DP-sSW@Xr!`oWu&vSb7CFslm!amZRXnnBy7|mDCo3|7x|AI^C-~Xb5MoUomGNJFa z!=oOYR}pmU{b0EPC)aR4nQwJ&(0!*uU9j5q2Eng64xiu)Y=iDOsXMxR!=o56?W>p_ z+!})LBg=0snaf;|kZK*2%da^M%cvc>!85j!kUc&YqCcxvjYoEd(utpbMk93gxWpFK z9~r2`jZxM6WSql+lNCH=KltgPxHm!aX{dlhouEi023SLs1I2-F+%&|7v0AALW1GNEm zY1p_lf7&ihZ3g7ZV~oE8hspPUI-Of__Y|$*G^#Fo$Mk^Gz6BOFW_Vlb1|g#0cy?aD zOOUT>E-l^T_=&+v`KZ7sUrHRy`sl4hsbK&nHAm+N%d-7~P`>id&yv_3zt7efcXqxB z!)xcif9IacR^Y7Y-iON^zQedcMx`iWyW|BTQ=Vg&JB8cv+@c(ps}Dyan@iT;Qu6&X zEI-`fUvIj`h2)rfx(2NlXK_j6e0la%-aHb9JnyjYT+BwCTG$l@nWyO><&g>=ZT>Zk zaq@Yc5*fe8=nIOp<9g9fkB2$FOL2)GuR+PTl?0r#R>C!;!a4G%u!ybz8 z-IwNnH~)J8|0@aXYA5}tvGb8`z~}D1OX5#`xhFDCWD6=sjuUoy;@e1HJ4~FV8K4Rx zx9|(c@7~aWqlEuhzLLNcS`>q?^_w|0LE)p=-^s35)-da3%x_vX@quI`M_E)+=mNSN zIi`0)eV#)nG-C5VOWJ)-aIh2^t)i4ueJ9w>1m zz49@;>L@;3B2;Dy6Fv(k2_?xpFANS~z5LPFPm~m42xcfzdPPBLh^@IkqrD67zhja6 zkVx%P${i$Kk0-BreQp;H9ZtX3;>V=HMwYM`oD@3^VjDq&4zfSnIQ5mu>SG379Gou( zH&O@vU_hGx$)ztV%vSK*6mZydqL+j%&NBj zs(Vtu-9Ky-P0HUm#qsiNr^jmV(F17sIqdtdk#E1J%)V3(wYBiTC*J4HznOyiYfFJ802s1mS1Y^+DwNS3oo5ZQAxS>pZ$wr$R`dpVmV|bzMXF8UI%B&=<}> zDY+Vs>jbe~-pxkQnCv-ZUU@Qk4)^(l&j`^w1>o7i--0_E9a`A7scxhos40MlAZZ16 zY(N|G5C3^&7&@GRz+mRe25!L%IAnNGyifSX3Xy>T4!nhCxd&o$`9Os ze-noIR@X7xyM}h4Oz+z1<|NvHB#4F`nu}Z$;p?uWq|1?3$}j#~w_(j2P2bZ8s)E5|B`qK9Kp< z{u|C7Cp&m&`1(E*c2TBHbVff2rn(ekg10`P^3@rwyN7D+V4)KCKRZ%X@Bq9i-X$cr>3pG(M%QgxQR#?Js>C8uYqse~XglDhMbxd2 zBYNB?GTSz2pi^bFt3KD=gmIgb>gP!`=s@N-7_9s&ED8;eAK0f$yk5j-fAZQ(kmN-i zho*pY{NEGElE1+j=&w$TJH8j!x^o!BAem?&mA{^E4JSe+4f2*B5wK{9(9eF@n}}~R z17`(37&4=8DW+RVzWg^NcS&-41fz!V;D)!<6}i(_K_R-M5aMASfLrc$d9(>d$8kOM zfLh)h@gxphZC;^O>$673n0AWqIEgOo&i0UM`6Q?y&12poYfCu{|BbO9JCfc*ftR7P z?KFoXt+7NX^YYuff?zPmlG(WFc@VW=if-m^>0F*}1ZB?XDrC{K#9vo1`nqjjTDk zYnNmVD)M-T5QeTZa32o5xYT$w8FC|pPA|5JjgUWR)UYALnt{AdR%KV^2g7jbJ`-1? z?(2=@*b(Bff{~*z+Kl^gVP~iTWCw2WpU2`WXmY%}&B>UN55HI4O&T)nyck@lqn09i zH-H8dm&~tU=z>}OaAlj@z2Ep2A>KjCbEgoh1{N*Lu|^Jvdgp#}_0NMjB+3#-xFmOz z1IHufPHpVGMdU&L!8hecdQq_2%^locbPL6@{H11kx(3Mo9Xof2$hH&OUr(ER6pr^G z`act=(e{x72z&c-DiiRl<3ngh#-kjga(wZ+lSLjXwT`peNm;qkx7|UL)>v+L<-iei z-FRmn6ZSj}cMs@YD`qqDhrPk;@U~~t6QGzPZ+K_;=mFOFX%BfPKPE>@z+@J$VPGJ7 z@AOB3mTVABPtGzpRV(?R>o`sBD;q^Vs7^A+DvvtNp-lZ|v_0#%B=~!_xwnXs!aP-Usb#M>8AsBXgXxy^moefGi+N=tkC_fYJwBT z#QoZJ6&8?NKHH|2O!x`$uOYt1DNa`q&&K0-b$||_g^!=n*P^$GT1h_>Mj2%S(Vfvq8+K1ApX9#Fq7eAbpQixw37oYTgJD3a7C&O$l&%SuUrekFC z-@?g#<`Cgjeln8$J_;1HyqD)~Ux8P9dFPbxF$k0WAgAu8?=TUsR#4QG+eJRPw<7u#*@)@rlcVjjReCrOVOj z?h%vd%WI1^J3+C7|5$H7w!8fLA54Ne?(je0{R*Fa>q?@r0okHA$7)_oBD zGFB^sQA)=^2O51!G`u3vST`QNfoA!7^IJzvTF`!{DE2fx%`_a8BtDwjFomJtlq!Yd z+)gWw`Rr+U@68p!q9c2X>Cwq8P+HBMimW-d4Fz-iXu*s8eHg!skm87{LwL1)?YNv> zn=;-T_w}u4rIA7Nw#ZO^-e@o6^8brIuY9u{&(3}KNo1nlClx~5X=`m-geYz7HzEJC zYz!TVzVkw^pIvaJ#z27B&-V&AueOtSdwsP4>5u8eL+y^g(J|~1`TB`k7uYOsdxmTm zK7|rtxna@WDt#>dV`1zF-%G}9lJ1Ar$-i%L`P+@>$<)_Q;a!5FJKKG_Tx{%G(Y76uKy9`8XJXn;$tu9x%T1VRp?<^}-iLC-l^iQ3(QT*`0 z4U*T6N=vgD{f33WH5)$eOXBFSSN!GrUgZuL8}AiZ`_2Sn`FF>4`9h%wFky@R8MnSD zfkByhA9WeFedHBMNWpb%RSYr%i)(%dNV`!pY{XefVwwx1>`?NzIX`Le$6m#H42RF+ zl@P7h<${JnIEJsiB8d3!3uu;3b9VV$8pZeLwRcWx9y*M)lFuLQezwWrDb?)a;%{Xt z*z!>x4W1flhj7X5+&}EyCP=x_!CjGSy@bylRd*Z>4l#l!v{oX1pp*lWM??Re{>FP2 zk1iGF(P|S*LbNHfM1Lhr5vei9$_CofCPDGq&emJvoBw{Ud-`ZX_0u>qWyQ|gJIW`* z=-gQ8Zdf2YW~$aaWH;tdfc^_>!^*08IE33fD99gT3MtXA8O^1Cn&G3W=-g;s5i6t* zb60A)2nB&}_GC%Wmwk9uBkA+JXC-?Q=4T%pMMqfYLafQ=))PyH7~~OM9O!>!y@od4 z-R{tDD1$Z_FL`RxkFh)y ztb4r$nT4+xWU41OKuj&vwCj0q4ie5ENt!#-u7c$GuRSC8ArqXZ84j}H{JDtGqTftf zY6k<6{TPO4&aP+RnaSzEU7eFZ&`@)>{!Z+zHY9tz9oqaf6@#wYr1Z6MN&T4N{&3s!MZ_2^L)*>`Xd{W45aCuwGGd-$X z2%i@U3R<@mwa`^??Y~vC$1b>edMfk0!VP6as#P>>wOe+#yf=XEPd z!3mw?8gLpZ8`2V~GKW&2 zaQ}_td*`+X&^SZtEnP8U4~L6#a!^^3LSKMbI{7EB8%P>3;yQWl`V6=jn{LX*{4&M6 zj}5DOm#<&P?Q2OyhNg?t$Wbl3HqO4_4R?kC$qs*MNl>^v9`)SHWd-wPX#x{x-5`Y9 z`&(p*1}{OSG!E3818?C$ete6{Uxx;=^G`{noPUi%*6`fBk@>q*u$oh^wIr@+ssm3~byHZ|0o zam*LQI@1#~LN=wRVEk$NEjGj94M_fckhAE&)PcwDVqH2%*%%=}lP53Say=d7f384> zwxb!2-Z@FPL!Y^0Fd+HQ&sD#V(DD+nyuuq|gANNhy(1?tnBw8X@y;v_=hrB>PANw) zL{E*4$Lg>9yE$etsoHR*X7J&Eh_-w!Ot91b!F|IoB5w!zO!!%UQs zbgw}1<$0Ck8#{x2)>PxS{$wp2SN2{^z9U)hL#$p-m;F6OQrP8_&?of}yTRtgHFo`l z4@Tg-e%hBr$Aud?ffN&yt)zEB8#N>+aQ!==`n5rR1PACaVxB@pYDQKFLU$j_@n;j? z5aa2$?rw1u`yTFr3`xEId$2IPYs#ldXtkOy@~s`XTvpx;@u!i zh-e;X4DaF7!@JLaJ_sc@OGAHE(Jfx{&U?_Fb-0Xk;?>yMl_R|S{8$ds7xfQlQtu0( z)X6rAZw+>9aE)$o@GLANglK~!O@r7QLnNrt`j`KwGYKA3sbs?Af-T@AQsXT@FDiy3 z7h+RSg?#t`!RbwPU1Gv5%-5OE8Kvr^pd?wM%lOohCa~SU$UE}t*D_372ZkF7*<0Xd zzdd-6EXQr1XI|rYOWiF8FAh8A)2r@`(B@gt9?acz2d&9T2jfQ!gs8eoF!1-^Ay&Li z;7x9wT}#IH+m}z`{Rb{WGdI%zC`;~fe3t*YHyLel7>ULO?P@J@w-GA+)IUqBuocJU9st*u+vL%Oco( z{;$2fdjhV0%;4ZH8}h<@ToZq3mxUlo$hJ=%t#Al|@6P4rY4Lg+vU1IpRz%F}^@ZjL7eW71=@*3D5oUB}ngv|KI6k7Uer7knk5% z9-@%W#7Mx=_?wY+FF>^ybnOJGiz56)L~kbVW^F-wjOpvKC+%%`Z(Aj<#*_RC+3(wp zmbCS(L9hKKc1(3Z4Iw5)6^9tEkU?5(Fw#8Wls4S7iL9QL9NL4>Vu90VHMY~}gpUwC zH`RH}b`S?qpXxt}OIO+LN_o{CL5CtQ4k?RXtfi<~@7|((3I81P56y8bB=DB<_558Z z^B#mxs-grBIR-#|%C%-g(yJbR_hgAKu{_a%2NA)M=iO?*5q?7Yp>sRy0G2!TBpC=K z3NT%~k#BzB6D?H2!;!b3ehOiRnYXCw@{161&Hbm3&RshU^}03MTOA06l+v%g&|WJ; zd_Bl#cDYrpAGIk0gx(M6&O!ZVEW>y&JVs=LsTez7;(d0)= z+gxv#Iq?{wbleDQ(CShYY=eMxOF8*O7AedKB_A59^1GmT z%Kyz<0To%C&$v>>=*=LAFvSh4e^pgo*l1`!On4{sDPFoVz4&$Jw=66j96u)4Jtzdp zfw7FNxJ4sa3St@(GirW*8n z7Y3p&O?NEG+P@cY74N8VotDBbH~c{%N&iT>)_$Il2bH=#X)LbLFcZxC81 zUYl)OU2jI1bq1>-S7HsKQyy_gdI(-eWWw)U@-ky}j1LXo{>_27OD&hsgaOe;C5wMy{CBole zq;mbiXdd+6tA2L;al8}aDOGJZ8s^U9!IwUlm|$xzoL&-a`CTBIip5;bc2at(Y`jrb zHIDoFa35>*SQr<-_tt=7azDl00fk^By4`)8_ESp+7nz&go%BT>K*0Y}FW-~@rl8Z_ z<72Z@a0Kl=kDqHa6H0`)l&kgd)xUNP9z#!S6d zW8ZwkI3C<}U6JP_pTgS4Lr9jGWP*{_NBq(yFLT`fAZh3u=E#H3k0c)uo*PbpeC}75 zY`1DkbVz)9$>XU=1>yA#qI#v;XcR4&$!%RI4+g_u0jVEY;KLdS9n^XIP|jre6$_uDPd zVGy0d{JS5-R_ifL);qH!&_9KH!iL=VN&DmfP(N593%qEO0Uu^bQh)EzX>dB41l$rs5@EKB|W zAx2Ymg~E&@4t#?Y%#TQgKjL-_PsY}p!4Mq(Xz+DgIKmygZG9cK+~3P^z^`097s8El1YJWW&aH?jvqdqvzGu(;+c$gujiXs++Py07T1lC zdbwe$79B%oMI^6EO>i#3RzOcK292MbI~Gcx#{y?K zge|ic8`-ab@FU08CC|YRaNUf08AzMnfj}MJx(71^N1$)0bN245z!`)HACSwLr8$C* zvW18F?z2DeIA7A7--P=M=%+kwlG#j}p_9vSJN8WF2-KAs`>*ziya(l?;Ly3yjY}Bg z&NZq_P_6{G{kwaz)iYhNl}RO?@nk%Mt5nq8jpP+`SW~smEuX1x!_d;?$73F6p24iN z(U6xfZWVra>WfmSAIablZCu`A{!?~{o-{6*N;BFQ-(s1?3>`ecl>uGBgDWXDjP> zsniv4B|P&c-A$?&Plt)+mESo9;>7Z;C=Hhbu?R9h`?u2d)f)KTau*+2%vHm>7^mp7 zAJ;f>CBN$PxEsY!JjMfQ*9dP_95CQt$r$-}4GQV8<%~2*eOL}|u4GbPD#8xNDS^358dPktrReFD)z{5OxO^fUnl3tABk;(#DUjBTpy@| zu~2EP6j%(m!fVOa4kBMtG;#U|%`!Inc9L9@*sbth~%c+{Guj21pZZr(T|9L zI9M`7iEQ&U6!Qjl{d^@Hp?__a#V@8y2wFs^D(6>ZOOZcC_R8pB&Mo+wS5kfTJi~zp ziMu=8PBI)AjW4&%dhVEq>{hpz1nmB*I3Yl4l$`SJ4jd>aKOG4h&4ru(RL8YKn|Rop zCc1p5o2bFzaqbDubClX}<=6lIMY35NhdMr5CwO-Sz_MlSBAbBB3XYnw$DYZIHb+6u z;O{Y^J!2GoIo&DNHk^JF{~HSCg>yFY*tQnMKlLgRHvy~N(^K7-4+|&n}QGp{3p8>;6!vV9MbN3 zKOJdbRlLw zroM1|4c=E&U1~QevvAsSp3O;Eq6%aOr{ihMPAs8ysV z#L}yIaPNR;Q5k`gFoc=zeCd}O{)u^Fo5}$W>K)MCnZ9f8dV62CG9LF>9yfgm>8t7r z?sEU0LipR3)rLK-8zz*K8B!>()}!Z=2yH{g{7)!t3>l={=?MV~`ygFvnO#06zfsoo zHTY+aB#@lsH!&dD%!38##DG=r z5e_(BSMRRKJ4VLe)(enE7wkKCZzW6k$Y zr(18Me@`&&JB3RG4~g_9RZ)G#JEG|B}ahL*SDdAcU4J#i|-S-T(QfDH; zX8px`Tkyj)bPFG+8CeG117VkR=~^`BAT+-Ht@LXxdWhHgnVW8+w@fe=!t#1&CZ!bB ztb2PAqELcX2XFqacq9qj`j7MX81yBBf*>u%$5zq~_de-fIDcJP1Cg%n*}0|1*C8%U z_3IIbY&JZKn#WId-{Xe$xe;zl%L92x(q_FZKXxD&&ordI?%p&!gsbGDK?G+q&cG$i zh$`~&sWTuo)@%Z1N>HyG&G4~GbPo537JttCGFO5bv601#Ih|W@dG8}27&)s4wzO_O zd$x{4P+;G3k|2Kc<5cDIV}(SB}`xJ&&{w&@%We4AXUS9wni>rc)9 ziLen}!LDWd+0Ufak8qj3d%FGvwXoqI~mk#jj-hJ^-ufpClK0i{yIvZ`mv6e9MrG&`_VH*#v6B~ z(1`otw1gfxmBtfm9I*E;)D{V{!cn7*rxjk93IA z^1L+h_d%$7rgAG0I2jx*C4_$LBGxcAo#Ns(0Yuc#+XWreE`a2@fO6Ni%U>}5UqUFl zOzB}Au#o=wWTrLPi_$C97<9xCLfZ84&A8nL{Nwq;3FjrKAtrS7jc{rBaTo-r2Sp^8 z4C5?g<<%4eM?WygzqEHeGc$urH~mK{x_ReOweV`%pNS+08m68#i|2fwBQ{rs;GRvs zBxWm*KiIS-6GW?C@>Vv#pg-hv+R9vP3k&-o=A zr=tb8@9h?(;t&@}6!-D^JD{mg@N(~bF^5+_c+Q1(?~cJ`)Z9L2p7RHMmyZZsTReOM zm)d>S*M|I`<4U*LUAp~1hK!fVcaQER51{fv=zEF-;t3G>UNB4N?-qpzO;N92yD_Yw z;r@76L(CBa1S-p)YOLChgrGp*YrlihAqc%wlJ77zSp~IdwvQRvPkzGTsBoET^-?|z zsRrfPW5r1!aGtr{Oy>I&gvZCx$+Av5g5tZHr14E|P87xqEGtSiGQn!chs=v`G8>h#qb>-!3_3vJYGBZ zy0c-_$e8cjNIdQ@)(~1<5?rzo_cezDl@)UjuSwH@#Raz?tGM zu=D)mL!37~VnI*)WC$W>g<}MN*)%~XiBtYnpmsX6Y7VHp>eSm$AoAvO&m+gqeo=!&C+L@P>)4vVFy*gUDqmR{O;3Agu zx3$FbJ;ddB?kOrJOM{VHUd%q`iWlDW-4RF@Yb!(nvD#zHr4CAnQjff0An^@ADiuY^ zJ*BxMlmuBd3)-(9g=)NH_w!n29VDryt0l+3Z~~o=YHd#3yiYB{*Opr?#3Z{?qV zOeOY_*Svb!D#wFHX!4!C>E*zA1ig}z5fPkCf=JlX{OaW^WC7|sZuIxmtRLdT%jt)5 zRY_(r7~;>?q2b9w#I-Sh?Oz1Pob7x=#1Mv5dr92M z{yl?vCU41xv9lPc*R>+oIv&-)@Hfl#>IkY@c++h+y!ZGjgnyOxKbf4@h@fzmGXIt@ z-yO_}mn!j+tCpeEO;?La)YD)eeeia4%hfZ%`2*MFuVTAw)T%4Bb0nqZA!Gi#wTVvn z1j3FvMs8|$wIhxGkil2aBQN3P^=kIyajHk)vU)t)di1#*W?8&$ZpmEm#m#U@LxKAX z!8n@HE=RZGeh2dsozYK!T&PD%1IHn4qJ2a6y03OZXC>wtQT-bD zFF&%5{_94wKUT`Y+($MBuoU-BSx9;%6Jy_+r1{Kxsi5C8{V=`2v>ExL-u*NY1V7=E zS*gs@j^y;?2Lr7OL@AbG8$$f7$jgC+jLWwQVP=-R-7Cb^)hhj~-mC+#Q zeCVlk3k#;CX2Z+tTNj|oxu*G3%6SKZU2dDMS86w*>?vE%xih+lkGV3m;`6uJv2^*2 z_vg+-g%IY3#<_Q8*-%d1syC;inE++`7p)RiseNO3J3V3{d_fhgrA;{t9p6OoetGKG z_wMRp%oHDYmNWK^1Tp)As~!rDBl~+}=~`GEDHSB5?=U1BkS2#{kk8ju(xS((T+w5* zl^g2@hoZ&b6DLmXLO4V9sl|OW5C z$Y{(3L$~@7Npm&{xE}Bt&P<9|MAL)jf}Ki-66hvOhjOOv$EQEfcvNgv&XwVeFuQO> z=uHj0cpP$~=TeImoWt#HA63zHfj{oQ(98WPJP_4l>{n>GbN~-u8ePdhWbzk3!=qF4 z8dAREoBJ;N&o^e5Q5Kq%_w||8UqnoK@M_V=*Wk6E<%3uEFI7S|@#fbx&7x!YbNE`@ z{FJgWV&XQ1=90cKL;OPeE0Z>TQSg0h5ZE55c1O{Pl4mVd?j6Y0eXw2=AUBV?5k388 z?YB2@itKobPxF^$j07r0Ib7>o1w>6Udv@;?!2hCYZSMCqGfd?9sbAR5=R}v0nC-uX zt7Hf`+5Deq_Mz{PEuWLjIb~7})^1|E&&*3iV6MwsF1Ky{hGjLT+nt-_bnxf!yKls? z4=p<$W?d_PKz9(r#K8$OMFAy9{}R2OrZjJbrAD#fvX;SNkSRKR8<{a5g>Cl1+PR6Y z67V={Uh-$TBgIBg`}HR(ur6a58D9iU()bJy zz-oi9W>Gaq64^U{D}zsGVfQg+taRmKH14&?#ozMiWro)2`BuSBo7<2tql+T;c=r}r%{7c~>fQ)I zT)gj&I&1Yue4jWeA7q_0j;`YTQ?dDJDu~x_uT7w#S%HNuL++|KAMRijRa9;TPSUiuoKM(v&SZ*-zhzuW`o{&+X>PflBGZoePs+-l6iBM#bjquv*~5 zjrYn0S~U1dZP>Wb{qrk20_G>ySlC)Yl|M%GesoY0%$`bwYPQnKIB6f=#_IW}6gFn6 zB#tv-`j{eB2^hDlpGWvGZ~Bkt56z+Jn=stFc+3U0lzes%?nXFbT}!2bR&I=G~1vA_Z{6o z2gB`%&`pfHEYiWBapSZqtFQ@kE%X$kir?A=PQq2TtF$89!yQkSsCOREm|B5y$Rnkr z-omDtDVP&nv$w$?z=o(=sFV{%oFhq!9eR6Cb7n=&^N_+_2L41+@*v*c^BnbL(!uY>dg&r^(L~fiE zpX~x?o7m4v(-2-5@1)(ov|IEIVv;+BwdR@ysO5Z z@E*Hga&oV|2Txx7cYbL%wF5^ksZNNUqv6Mhy9;ssLQg$xerlaPG0HOzun7~qHaO3U zvLCgKWDgRpQKBhcR$)R;iSL)9h@1wcUt?AydRKLY@&Y!V*Lb-$XUD+(e3QogM6O{( z6mP|RrPp}{i=!;}H$tvuf+RoVlm1ELrx2?P+`NCMLLC{;OV`I3?Y&SUvG<2RAbSI2 zh4(TgSN;aVc!5n>c_=_1num^8&bW*Og5R?|j%K3uKF+6G$seovK8}vl!hghgtd+p7 zzVaWJKvfE~r*7m6$?g$C{?MDhS|6Cju=|B*i9v@l5p?Yy_iw#DS_UyIpF`z4Zyw^# z&x9GpQX4KjKU(9O)|-|9bsJ}birh7MRChfr)>Vq2$D~Kp8Pkj<0bCm~DXb+7bjRz- zh_sCZ599WIld&kJ`oBgTbH8@xhIpquwBx(>$fmtbU@FJLmD-*q0e>obvdeXMFX3|# zrE1|PE>;wsCmYZtZ*syxxuv6kqKa!77t=#>2&%_NXIupl&bDQUr6vKLy#mzn>r_{=IZ&zk6pzigzq+4_)4~1kc3-1{e_NxF%7?-* zORxklJ(sq(2B!Eic`)U0;jrO7=oE-+*BRKc!~M0trEBveJ4jbpd9a?CwE|tOBSB6k z7Z2o`7YnKW@$j)vcJxT&^0N&N&pv=*zAq|oOFQE6HJ+UO(x{F(WXYa}l=o;_!SQF~K^`^=nT&(Dx!ZKDz$ozzvX)98MYyQd zBf2;$$+%5b7C)F=FOhDGUd5S8C&m?{?B9^v{rs8Q=vWLcb#yfF_XmaHH}$T2R%uE! z?vItz2Z-+t!IAC7%88Go@;LGF5zF)k>#MN$|-{6tBD^!fp?MP#wjQC0&%G)CaHvxw+5HQjZ1${cy;5hy(|BVM+t4H)zF}$`>$FK zyhY4B_JNo3(VenKa5{aTYn2ZXc|=EP_2AUmbW4}Q*^8*XGr7`As&EAYWq-1EuAC@< zdeZjf#Sx=3pdg>py%a)|hCDwlp_Uupj^bC>8#%cRky$*r`gP`U=$qGY&I|T;{quPp zYpfo!tB1b6fX5rtsW;oEn^-DOt<)e?`+~z;>GiLpNV^dc*5v}7!5V!1-IPmlLzD|! zH(xUCc1+!Z#z=aFl7xa0eqBkAqvlpj#XG;0$Z+QhCKMyX$55GJb= zx^aP&`VgF*9;Q?O^9Fvhjl$2)5E_9-f!XHSoVXp1nI2wil$$<_gBe3wwU>0&u=4ex z*2L*iUdY;|caq-o?#GvbgJR^3c3IH6;PNwUsc9D@lA#2Qrj7S-P^f3cZiQMJe|i5@ zOX>v1plINbRP5UYHn7*|uIK+ZlL;n^N0Rd=Tl}Hhy4Giww>J+tsfzGjp$~u2w?pN= zd27xVkLwgwq5`FsAuzmlkK>if0L-7xA5?Xy)`G^D`}KVb)TH?SHmvhPbbStVUyra) z3$5LTo>-sa>qoEp@la*bAo5)QNnEFU{fOyLxi3Ttyc*-?Qml|5!~4juY|#LI+9a*# zGapeQmh#+fYe$=B_;WkKH*)19F&LP7ZkN$534&gMWJREwEeL%-m}3c}!zEz+dGOH$ znXxRc{>Qt~MYcA9w?Vg9!hPmMu`v~E{kYet0n9cpJbmbbWEX zNYVi_GcqEbgyXcRm5pJla$YOOv8$bIxhtNg5M{l(shA*4h(_z~TeVJ219-Ulq0zd! zsT@fZ?=QXfq21>jMc(N*c89|uVJYG)kgH3EYnlx7ZDQJYz;pQMXy+Bqlklj$cGW#; zG#e(e7YjnB#|AL7GkBZ6DCa07C4P5yJdEbY-MDBEuHMpXIQ66VN6mjH8iDwq6t0J0 z2|C-j?-It@QDESxNDr{Q*pGPnzVq^ucNh1iNlxJB=Of`b+j#Ay;=z>{$f=etbLiVj zg(zna_3RIC27G&${UNT0o(h$eP90+&ZwN4_`u6lhQP>|)nJ+Uv4>0isxz>#nqq}1Y zAZp=$=u1pM1Tl+c?v;TaOI$X|%bpjMoq+Z6kOx{mUZ%+Er|9^=p?DkgZ5HYuxAISd z@L!meTK>~u7@wveHJVv!M{Dy=+J925ttdP^@#Ms4+XifW&T@aZJMIIHl^LgJB&W4e z!|$T&aBKWCGOo`KRl6NK4Px^i)8R$-8PM$LGS$ugvIVz-zQYlYnn!R6x;@(8O2&uk zmoY5#mX89Eeu$qpZ^j`LGY>Q#@b0RI@>%4y%lU#reWYP$L{RF6~j z*)Fo&Mt@?7{n!6daGvp4{%shStV6akGP079m6I@+Zm4 zC?TV0B6~(g66*21zV8>;eO>4EJCEc0{e13CepRp!yvjK6q;>?Gs=G>`k537K@Wpr~ z>4a4YUfNX%tJ|~AVeo-}-Kj}wYP{SMTQi7iYzM__Wv8o|-Nl$}dTmVO_F5Fg7Xp0G z2LD$9Wis2^B_H+xY937DdlNztUbbPE-Oz7k(oMs_)&})~7N1^(^qk7^hV>rdH_VPw6 zPcn|nn9Tlg6pKc*R?mZhVJ=q)hmU+atiL>q&G%|o!GORVqx*vXbkZB<_P}g|cT!-~2gL#IscE6Y^YuN?GOw$FwJV5IphZ>GM z?oW#apCemrA?9lo1UgOyZGkIaz0&(WwJ%t%-#KF&i8$of>(b9P3&rD?!&JwIo!4W~ zy>-8Qm1L_LOLpPhMAwb85cWeV&OhKp*#7Qbws9sX3y14*t#Dx;IxQHxkd%M8BwdBW z58RZQiFdtWwntEDVpMhyz3QwNI$J0Dz<%Vvg+FZ46zJEM^?&@hULDigTr%z4k$(_X z(e+YI`xY@gKem-Ph&h;GQ^D+a*^o>$PVP0+t93h4VN(3l-a)DpE=bD}s^d61NCX1w zr`!MBZCXKotU>2gm6{1=wY8l{&R-gLN*Z379FFKrH9=YK-kb3nEK7~4Tae7OEBTc*EazX7FLT{*q`0Fr0fe*C_{ABghI zQwd5_F{kiuX6o;QgQxWW`}gT zU|}ZHbS?z^cTOUUKSdr_Q|^Tb_pgY-u{hRld9O(Ve|cqZj#fV&MAJzV_5eBU8rbu7 z^pcjg_F~rJ@yCaXdxtPEHX%G3Txo;wp$E@3)x_m-#?@|O`bc0H8sn>;mtDWKhNTV% zPP>>-e25+Xy*c2pkC-Ss4tjVwCe*|5=)6egGyNklt3OC>HB3d1rEMlNA%-+5_+&XB z^)eg&fYG|Zftm9zn^3MBd%N?k#slmfd}I%9W$A<5a`92 zJo&%6(RJ2g?KH~`4~Sf4(sdMnqK2a8Q{i$MTbt1RXvaKT^YjoHKe=5=`suEMF3W>) zAsG)-~sbNdQ{*zl%dF3JvOG}EE_sn0kW8b>GJIBuW5$t}Wx<=7n?T`RZK(N1T>8bVE%a6cs zBm1Z9Q<_h4i~5Dt6=NbJ3|Bf9$cs+i#k`twLAAv*anw8Y*XtMCuEPI>`P_1hHWB>( z^51ouNa;pm(1?3l`$;M^?1{wuO3As6(mK)FMFQ_u{4JU7OFToy2+zNF-{cjWb0a59 z{wyVj&>p1w`8~Lr+%uu0Mu~`zb#d65R;aXnD0Cgee4Wv>6x&^R#pENN@&1(~4AaJR z$w`m>hGRdyL_^8!QIKb!C>oFb&J8WbwZR1cFCNG~$^1N|tNSu4a*A}j%EbjCKdWff z8h`5wBwD{IJg_Fi3u+Z&SXbw z1lZu`p&tg^uVn?nrL!5G6j9!ai_h*o=Xp2u2i+Y(Bi+`=vXM|d*%3*_REEqCaZAm! zW%2m8(6nhL&hiy+IZsCSYe@>j`iCJ!#%zW$LVd2&wgp(~qokL6W%yH=Ak@B!xIbon zCkT6j8~&3el_#+Npy69~e2fup3Jh?0KeyFG?eO^P2^FhVoS0eOGQYcU6rKn*@_ZlE z3L~0{utOI}b+ILKJdK^Vb{J<;G+ju(e2hbHF4x({g3@6`_MWybipmIr9zo>uNfFUU zaJrT9LD}KYqM#t1184y5O646T`ttKSIPb#F|9dg_UBrUgWXt_V*y% zJ-(=UBjn)@KJ3KPUU>3LEXg0t zj<|_SOFg~n|e-Kh`3krW$WeRS=Dz2y7QPj9C(C$I|>mdy&osalvQifz8Qt)Pn>BZCbppnYW z7p_of#cFCoJh4$78S1lkbA87JzkyHlP*cmR)04283DovEk&yxVk#mXOtmRaw;_xgs zp)nW0(@BS{R{k?UNNmQa3QgTl$Q+LJ&^Bn~LC4h-Ka`EFeW3sH&qu))-v9^;-`p))Hwa;OuTsR#_-JgEFVVOx4(t(^rXRn^%m3hfHA>WkzYxSv$|TLjM2TLOmu4S6 z7sjArS$*kG_m58~%}_85(se$7lql!~XleZH59?A@~0CiOgqm~=9IG}gr- z19?G*#@TR-ePTvt{P8Sd+B59+P@7JceENpA&np?b3NyfJ?N4&XY`ehw+s<{+^EHNqeLXIEsJFswluy_e0)~V%#`j_i1MK^q5o* z26iof#c-KQ!6M&y>Dam%AxIle&Q2~jIYX%Zd6K27wl{y{h-VO_6JSO<@#1Y=4mB+0OS#arHcG%pdG z2Xv<6#;oVy8J2w1rJBAE+xA!6)@H*UVNh~ruZDO29(K!1G->-ZU%~fXSUef?%5fCk z%B&V+StdaS+3rw4TwpTrnA#c&nvdk#=%DRn%aa1k2h81m5Rgnw+vkZrC4)s2AWP} zSber1&ck)<+5T^g_g%7S`j_EPqFgfSa|7)2AxIstQ%7=5mUN)<2G+<$i6I# zNRP1dG{r`B=;!y}g{`oxjH4F=Mm{5K=VjZ)<6E?#du91mu$)gE?5pqXq=^*WAizDv zek;e-05fmSe9DX2yb95gTAPP?;!kksT2gzr?Ab2xpM2k}V{}vq(^3kp$uC|oVA7@E zWapbKKYXL3557+(9p4u;!-~AiF{DWD3$=;w6H|b;+K%M-)W1F)RyJ=wHm#I21LHVs5OR2aXTRo@oi$R6uUY89e@!j_t*OoOfQGJcV0QMO3EU~k*()#R z+Mqk|k~Lw2^9G8t^b|`HmuHZj=HGngnQ0*+P8}F_{v0BN82(mveMud5$Py(Dh|pQT zz!cs12QvGp8f3LBw2rBTrX@&!)h6?V1C(q)#FExB00AzZLWP%UcOe`$e6c00AQbm9pO}}GuLmQt$G%N=jBx@oD#Q_qB75`r z@ARCrFn1gq)NfR0r;rKtB8Js8{yI(n5vT{Yn-kd3&BBbl$lWE=W*@80+=*7VH#rMx zD_td-XsfR{t#ZbUsqRe`MsE9L8eqB;i;U5KU)8@IgstaSy3Ut-FVV&pp6s*GG6Tow zBBj!y%=WnBRWIQ8D4-H7YnPr(UuLxd<1vHilcy{LVJ0vAwf5Yzc1*NV-4@y0Jb)lC z-=a_d^cvx`((;`o_$e<^wt&Ha_&zfyYgKFD*$2N* z+`rQ?yI?ZZ)zep`Z41xZ#9!pd@d-e43iJsd-jYLB@J6TObHX_YmKYlMGw84 zzbNU^aAIUdaoVI11&oJJZJFt|U`volN9tjm4ct{-_s*)X_Ck(C?&g1zg~cchb|!zE zAj<}F#m{QGe{NFXXbfpe@bD`;oL{VR($dSahrx?JD(!b%(~x0zkKhS8q6~qTVs(9D zb?GP;ed>}=$#NK1tPf574HLQrryK5{#$UeQgsZ~m8@&TYck$dy|9EC(emrhf#j>1M z{ZNb6<^VxVP7urwSEJM(b41VJShk_3sR zkHOr@5~sJ)s))?#YPzCxkLyt+<;Hm7%|~Krs;FG}$yDM3Z=tU3vINJwkS4QBa=#jz z4MqP2+s|>9Q;>d5|M=Ct*Ysf9NPjJ;*EkEwYo2LDyym3PQ1I)yaL{}Kf0_;$`PHTv zg4;!!uGx3H5&fk$t*-;Oy}_ezpjf2aLyH(E>!39?PDd0O3KE~{7z{+7zF$MJ$+j!h z2ZT?4lc!pN?#?|kx@o6iw1fnA6;&s8g5XnZ5y#8+Of=OFoV!r+UK5oT3b&uhyq1TY zOq2$nNu?s5eNET!_p00ntz2)lDEchlLs#Ne-S@_WdwVl+8f-R^a3e!!SxI~?{gs3G?1!3a9$Bk??yYxyK_`vVNBfj zV?uJj97$LkJyMb!#p&nY-=@vT5#wMxjh;&!n-WT6+~t`xzTZdU@dhH%`Nx)c;eO>V z`$e0Z;B7lv&B>Wz0X>vagX1?`GWlicT$#%cBBrPdHrYq34KdMf%4Zz z^YRs0bU&uAtq8Fa0>iuX6Tkd^aNs=PJ%5U#xo*_^*Vn+T%XUjz<)kNI&#t?1~_ zxJP0yu<_-94%NU=FOHs;f6|0jYM8ygtmLlca1I1-cthJw9g-nyM^ozcR)7RN)$)-| zd7|_f|M&j6gn@Ypl8!T3Z0HF*$LDhOTR)RHhL9dw#4z5QWdu5bxc6E&l?ZTFj_f*R z_4sobY`$-3kT#>muxZ389XlooeC4uxdH;+{6+TsSTxa?j6^|wX&o*JYd2)1?Q=0~_ zq$(r4$+1jlNa!evg==_87@WVtOyw@I0llO;K7IMd9W6`Og#74f{lv;gp}60x%UU@t`5<~;D4+3pH=qW&86vhIic+WYS)hDVKARxfoALZ;7clF`Dc z4|-QyPP`s<@J0dgG=swFSP2+$%UDoJm*+xXLjF-H$uK#xDQ6zF{dq!xsGObh-EcfY z9BmNB39QNuwopN=aTXZy0!IW_Zsl{uO!# z4_|B6Iz|b_e~(U#q`$I7okVVU)GKmJT-K?5eoU>S6?aa>78Nfo_O&iWLpN2t=-Jq_g7N?Y^6tul26s{^P?KjeXbO)abQTKevI?4+2-)o(;6(vnI2H%Xa4{yrnujZnT%j4|>OtGU8Wi?jVjQ zP!2G+eu^mOLq9I^|6B#bb7wYR!;dd<+wa4j^`oRDkj`6DlAnFAhqv_M60{%PH1Nfa zGt550nE|vn_{qEavu=S|tnfb*$Ei0UqUVuwCJeiW_9P}QKe~HP%+em;zVx2>MR(5z_nrKy zDXS!u(S5u7eOwfuIvh=3iZY9RUjyCA>d*yh85^AP*L}6t@~sI*ZkzRw)ji~~IInn@ zQ;O6U9-nggE|ie^Vc^{r_9Iv8hW8i8)hGE6&GK+QzG&jSf9w;C?s4UGDxaZ7=nVz+ zjm`2=d}@E@MwTZi4+f{QN)KM~6}T~3tSGq-i6HexYvS?v_H=Z(KK3Ij)yshr`=5m= z7tVdQEWg%N-m%Dqi<4}*R}YGDqGRnOSJZ~z17rt(xN~}A`vJ~bt)De<*?a`AhT!mf z_wU4`Qu_FVttm-YeDdb~O>2EC8y;6OHEXjzNI_k)|D9RTY6a{h<+B7i+ke21Oqu21 zyHlw!E_Sd&8rK6f<*N&@Gnd9gp}{6Im^Z%)M_i{e7kZSLP!i(vWcqi&5{yY|YD~MU zL~zX64)?$1R$*9GUX<54zY7u}zDwp5z1P97Ykluc>{cl#XgEvFi8X2AbJ!$WW`wF9 zf3!pCwu29ELvP!SsJd6s4s;LMRsNg%DvGEe*N^wkecOZNx0uksR|aq4r;cK%^7`ow zeCHyfV;qzf!NsFQwWl+E?Vu`o=TcMI&u|F2{{5-xdh`KoReEyXngy(5>AtrRF@vrq zmc<_#y<&KC5T9nQ$oU_?ISG=#HP!YN&EeQ^%ATq9`~DHt9+uC@0uF8>Ex{m$=Yd@i zN@5-aFIM=;;m1wwcrHN$MRdp2kI4%w@1INo1Tw686qEZwsPF3?(Sz5JVG*>m{>_OL z>{H%pAFPs&fl5;3$w^8xIb13Kua)FW(;QB0UlUhUBqjub%I{ad3itYOs>;xQDsRdj z))`)Z?~-@Of`rk;!)1Wp3n#DrOEx*Or+_~L)pikIvP)nnUSoIZnaNw^eY)}P?CFXe zJUMdcs`b+rQcT?QQjg_IRK#-PO7wuFvnh7b+ox`^PU&6 zY57tA$=VZ3q<^`P@X7Y+4mfn_zg&NFO9^UIXS*!)_N7SxUv{Nn?8+(d=I)HR)4Sb- zgJQy}-`o2OT_*AD z;>qLAxBuvX`3n_?^mgwdyemjv82*&8j#MLB1E$-4q@dP+D?UM&{|Q>Zf3c<;#1O!~ z?m*!a20~2~IxHIf(ahS#?dn3gr5_Yx=&7S5tAG7%9(Rk_o=$uy*Z|Rys}HBn9*l(r zjlJv9Pw(!)@1ympLWy8CWM&^?ha>Y~f2zMQ;|PPl6-! zDk3*VZ$%<4%EbHOSDmAXu#1s)nti5?5*mK$kR;xI(4FWCkJw)I$FNM^vGioKLRiN- zs_Ehr)A2`uW}Bd2=%Yl zy|iZgAC&iZjlK;3O>DYnc*rO@XCqK}_P&wzyA6!}{!aG%_7NY1gk76ll*u9n741LE zo9h9k*nV0(Rn0`034Yh{#=dTHeH_a=X3f5PUIv#RMVtO{cy%1}X^(gg)>DsTCoA-C z^HIVAq-M>dxjn`1?@g#Bb z1L*`DbiX!j^pbr;-)rOFK6~W%;m?0iSv&CCVXSus+sK6(Tj11)_g-tNR|Nh}_++_g z2}yyFO(#xNR5S%Vwp;G>ZxiV;lj+O*K=`OLK3rxf$gxRK#2_V2391VZL_rs6{$m_x~Q;&<2Z ziDslc+|!v*=g&s^=mB~ovrnn8TRp%MGB$Vt8pQ3*8k@T6kR+tkNaQ<{isQ}(!s%AC zw=wckmPAH`HW(%=dM#F=oaSh_o64CUYG)0P3ndEo-T(dAR~rJtZy#U1hbPY*iR8P@ z=y27W%qphxu@RWEq_&quoJF8R^<3#f-orppl#)1P%@N$d#Q8sw47Z9_AY$nD!qF?C z5MIPp>xt4%lly9;%Hm(S!#$Y1{$p^T^{xO;d4_Nl{YvPBm4l^;#|x#;aB;KBZJnZT zK;LJ*$>ph9JsdDQx?IHbcm!TAtIeVuZ%u*Qlfo&IuJi#8rv0!%fZfZ@|S`_ zu`+b~y_b!uHE5cZcp?OR`cQHE1$V^ySy9Yw#th1RTa^Jv>#FcaLCHqkH@$t9Z_l0& z^tY;&IZUobKtNu=f_8);3(fXE)ryy5Zh+`~tv8tzxg3b@y*tnO^uS9f^-=S5uY27< z@4V?q*_Vb4^dGV>th{zo3jrJxyZ;RTlLmu-zxce!>qry~(kmG``shN|bI$ywY9l#L zD4ZMJERbV`wtl1{t)A*4GD7F0hV^HRaO2Pn0UOt|`*?nCbTCxzZ7g2xRmO?@%`F1$ z@2MQE<4xM&(0QXvzhRySio*xk2*z|2VeD!8?rkeCFRVwN#SQB|Oo!4l?Ujdylg3C{ zcV-}Jd!q%Bdv9ksKS(x!ye;m^Ny7*xe3UBFTeaaV#AwFZ>!}NZ<2d{7@`WRf#mbOa z6B;nxkf8!`-^#({(+|CnTgjnl{oh-E423_LqD)-9!319h4opfs~GeD~vx<=w%bot_P?8QY)2T|GI zJDh(JL6S_gj*>B@7<_eKbz9hY7D;v5$3rewsNrk`E7P}2x%WXn6_DYi+NTF?(GX>6 zx(FG}ix38ze^uzmy`xnuXSTk}!AC`H`pxzo88jAAU47zQeFT}uzF9PKS4ksDs9_1iqt!JnG)}*T>i^3 zccg}(*E}O9=jOkW#}`%(y3>dKy<| z1;KzBpIX|cWkkxUrbm-g_P~n7lls_W3n~y2q}kqiBM^Wj?YaoLL)V2+_FREGZ+W{G z0xVR-tK$;E*fM`zzCu>qjjY`tcShVDK7guFlHw6L&jvD2yDg6BoG(F5aZ5tHm7XBl zQfcq^2OMp~T&B8A@7)uxko4juc8e-hDZN&pSG07EaSB*}mD|BJc?rLmmOOLQ_@F!YivA4xc&0b|__cL~!4UYz)?v&vs9 z_XbxDU#)I*9nnJce^0FcG37X52BiHwy>Y`YW7Br2X{r9#{B+kS1b;WB*o#ThWbg*}qLB-%M zG~#+#zMEDJAg07yTt4(>1$y61(+#e*itNjJFW+iA1Ap`+lZX&LXyC%U)uEV{+a3Op zxl1y9w!v2&M~!c~UAMe^6&jkK#XNUEm*GTu_&}PfG#^BtiiOQ-3Ri$(;T6DZGLcV}fG6Rf-^dW>H-YOA^^Y0lrUEb=)@^Y3wZwo* zPO~tX_N<5aZD=>W^)gr$*N>F)Y?jUk;y72%N~la3FMJP$<#(iwG2l+h(Kz2zpHpC} zG^9_P)HI4>idVAZg2fH+(Cp1Bj!CqJM%37eTVhYtv5}K|>w04Y8$_SHIQLAr;0QEJ zwhBwbN_?UIDwi|y#GY=}iJ+QnYLZ#LSuS})1Bpg5M3R~^*ef?h0&}>sLwaM(}E6>hbrtfVc((7tH1Z*@5yD{W$) zn)vfP6kWDbLjDT00pJX=OYWh)tPATKMH?S)-HisW*3>alRnAukZD;$zu(xs{P6JOqooWaXQ4E z^=8-)oeKZtJS+wtBmC=y+M9i|4{&(q7$5c6eJ`A#Z7nCf`PUwf+}dP9&+@(@$)){u z@7fi1)byPE&(uV86>?e;6VCZa1FdiSP^jAVT@1fx3;A>R>Hz#iD5Kt*>KVZ;y`d+- z;@%X*ISX#}#H{bvPKmy=6tLwNnto1xivlNPg5$E5xAWs6+=$Ihx4lzUIBE5 zdboeqrs#%X)z{pUYYn(bnU-H}nUIAr37OXsABJbAQKaX6-6A2MhS;+kU}(X2|Q4M_7(=&;qfKE-A( z;dPGQ{U4Zm<2!M6L!CJGj+`ev(>`_w@>vz#H-tZQ?PoUk{`l?c1mwEVH&Ea0ScAC7 z4ZTU%f7(z@YT0u;UuS{JV^2f>hAM}F-l1*X=adW=t`P0{5q#U-h0aXq{C$V3t%wTD zj1GGK&XYs*kQ|=oa9!xL3L6CMB{5(v$ZV{I!vAxO7=l$y7916j; z5@dKR580o_{0~I#mci(78FBvWlx^r#B_s!H8EzoQ_Rmp;lO40Tlx|6BW|WGP}G zkA;}xzi&$G@rE#w{xKw-+)D$xswRJ3@g)J|&e*ZuYLl49Tq5`cgyWQ%saqzvqTfM~_cp7a^A1ib$6ivOj$L=+6n zV}(?tLJ9Gkf0vy#Wd10EGX}^W2McW>^u5GI-n*AIkfrzYBgJu>RroPgcL>-j9fz?R z-K6HTQ5$4Y7OGVfKa4}5yUf3Ts$wCyHy9ap+JmSHsZ!$njBguj!SlNB5|5dlDo(B~ zT4}qS*8zv8*%aHhDidre<_?eieaZ)!QhXS&7qNz^XHVz36p2p6tCKU|CU$!R>&zqR zVI%?X5%#h=#I{vM0pGHnN1rOcAqTClVOQei><|cZ8J~zrUaFQFE#^2XT{%%%rm(cNQ=f4ZYy$5mYqE_Y6 zf8YN??!w2#v7+P4Ag=rQYE|&fX{bLx6!xL;d*1%tc=6?fb_O9@eL0m}&qW1*vO4@7 zivsNq{s!7U+AJ0tM7)RN;UbyTWPC7E|7j??_61q##Tv3Xr`a%BTfD;AKpq8;clu3V z1TvJt!IN;U^o#5&xU!80?uOA`L_oonAsu5wN94ZH_B%zy!U41JQ9yF$Bv&9JN#DZ-j9tAFdq5OVlZYyV}Q0mLgtoGub8 z4MV1Iv?|dBqW}aqliR)EdwCIoPwR$6ukh|*gJF8yz`8gSHcBdtWwK`dSkch>b)h-C z2n#gBc|Q*cY2u}7>egX5n^VZwy>UM@y_g@=)1SYNJ-aG|uKp|LuqiG>b<*~6$M4U& z5JVi$BcxF^g0#tvulaw9oRo;d1VJ`k0uhh@)J2_a%tXXGkzj~=>NbpSn8Ve21dFWd+7 z6d%2K4F@?q#{zWE9oJYu;u+o*gX)DLyrO&EVEpD&Fr>9IK4&?+gg-0m|}& zXLD)(*`oi=U3KSUai?+lXPtLx-Rd1s*7UD)X>9$2MRIzGl!fUS5~m3@1AG}@fuKM7 zrMix=H4>x8j9LBS>M^R+`}AQ`_@(t}cGQ>Am4opo=Vv0tJ_JsIn zRnWX9xh>u$^$Cx?@&h}{2_wL1|4BikoeCaO6k+}*?<_U4U!OXv8zlS( z2QKP3C>yyM;ku9JSc@iy9{yH5A(xb4bc9>MSFe%;Y@=kkCMB`P*Z{(XoM%ESSTgqe zsp|nbJ2r1X8|fx z8K1t4$t>e+@o<;qWhQNWFzMfMG*mOkHP*noV4=x4EHUVR$c!4VM$VT)8;`661$=02 z;C9upI12VoRz~Yl)dT=ade&(6LHzkV3K-y~HJf<#Sb z{lWtI8Z<24z1(;&w1KT&ckQe~vG16A-s_!nasNIqwfFR9>Zg#SsOpk8@8=>OxT>fy z$z93lM#`xbZn>k~XF#I!?Q%;%wIjyN77ZrKpYeg^ty|$P-`ow{?u!q(dACj;OA!=} z3H8E7=xUkf6_OkzK<1mQFrkQwpD3X`7Cl9!po#dJXB?-@r8vQM`)R;Ur80Al+VnXQ|z7rmc$sa<;0Q@A%LIB6$5o6>`u@%<+&;~koAZDd~#%WSzl zz`VcKe=ms*ZX4mTG{=g4mG^BF+)*HvwFun=TfgApbArLE$dT-nEnvGl0&@vI+poOK zyLfn+BBK6gK@*<3)`!`WcDNvduPyI(eaHS8UaY=bJI&dRGFN|x@QXU%VEy`LO4`M! zcW5SNCA7#Vsl&{_x~6|^^fTb}zO1pgG~A5D&yz0scWSSoW^L!lz^C;x%$A;>ooA8R z4<~akbdpa!8%OBkk&q1Hb6-L6j*ZayQJO9a#&a$PTwlp30(3dZ25Hq!-dBJE!Y z2)C@5*{xIqL*XO8MV7O?;CpytU`3dJ2=;y{u45u)=RkSk)!Kd-T!*`DTnBwrKL@sb zi`2>ul~3XN@60-ynW^t+>9}oH%voEA)K87SNUy(Vg|f-og~8&@ISjwLRG6>iGL6HL z>u#s6^1DO$F#XT5^fwLgGrC53b?|m3UK>wSriT$V;5kE{OtAhEBmVP0Xp^E~(unA; zOwlq@^+H(K(B$2|=%%yJ&^|7XYNl3!v)wQ`sqUx)_h$xlz$H{pp~rT64SHa}R4?){svULMAqiw_Nta}Kv4H?&p! zF-KNEoF|VGEj**T0fASQrgWx})1V}JAZhn{zn1zonmDvjL~G*2lXnS+1L&DR70w>x z`{ zGJb9c{?_q{U$VN7BkeonJmyJLk=*$h_5LZHR6C>SNq2~(VAv3f_UMo$MHL8?=D{u+uUTs5Nk#47+JRtE~JPawtV7J zi>ntHZ)&vqX5mNG!n7cR&wZTt?<#ap9z2Z6k(AtXTr!l{Z2M|E^E5*UqrVc;>DLr; zaDDA%_Tq^AG338mYRUV{6^0<)E#V8_?^2_gdsASx=ywsC{+oE&`h1^Mq)Sl;Fg|V2 z2RG4i>4EDVt|ypccm8|KgsS`tt>&X7HTiHq(w0ev>XRCMj-3 z;$mL$lYw8N8sM?-KKc1_r4we(XXMS>cq}1s_7_1(R3jY%j+0$1y2eQiR>}6MhqM&V z@L%}%c#8163&I;&IBAH~UgGOT_D<@YLz@*j$M;r+)Q zO(v38%*p(vaJ>I@y`|%QNbxdQ&i=QhvIji58uX4n5{Q9r`tj}eo;%c_ zO=z8@)J}0jqMTad6(e0$JSSu!5>%dlh`8?bzSm=IX5eL=x+Z(2R33G`pBR^xo=D-x z(papW)bslgQ7ewn>gV7=k+#V?CnZ=jN`w7wq*^ zf3Q&X?V?(D%Cm0#s0frortj#oXzOD9&`Mw2&>#HFj?xKg?sShd)yK`|s`cTFIu3-#*tJcjWR959e+9y&c&dK=N{=Y_24IJU7k!@`n4 zw%HAySY94|cZs9$`|*6m=EnL1OlTgk)C!B%#tk8Lt3cZ8l=wO1LStf=Y6-)n*P?g7 z-ZICPXRY=5(-L&Br@#B(*Rp;oXlXqO{&D0u3qrdah^ptli{gA`{@h$WX(Or$PZ)du zlKB7=spnjBem1oj`0n(G#GP3e`n2s);v^;V^1gd?%tV)BA~ER*qfq9)pqUE_POgpmhxFGIwm0}y3vih^_p{7K z(F2^0C$t?qv_CESY?o^HDN zs)(bU;wGhE7_Wdda=vpjeLWX3LkFWS{+N=(oBTvc(}f%^9KLkKuyP=f1(I4MpXMg% zBhc~m^?*M`U?tiwtzDgZ%W@c;Pc9f{+A&W+iIsI{_{$+`MANmkiH5U3!+){4mu$ni z#P+HFC2JK@!#YSlAbD3R%F2z_!%M*fa=(MY!z_A()+X&OOp+H=791MA@#uxLQ*7$> zG$c>V7~NvaEkW||Sl(#Zw@FO?UHriA-wB$9rT_PG zr#DtuV*ig5mklyo+YBPc!Q#x}Ju=S~2(})kxcoOEdN^~Y+{uePj2U`CKT2CZ7QXtUGmy3uhMUb7wE;ii)OsfAwY9;%sAYX+GqhjFV?MwsgOtx^aP zMHlhy>b!&9C-#$r5uY7#!O`LR2#d*C6iEv95Rc4SfyenvgX1rGXH0R&Syl1#mI4VI zSi8anOuqIODBxFmjr3H0XWW~i}(X4Cr z@p=uSv=Y;)k92RqBu`@6@9i!n;(6puKduZ%fuXq1w%D~kZNEx)XUQM?br<&3Hsvms zav4Zi^HOCaWFvI|Br20uWRS#j_t)__ z`yy;jRQo2+)h6Oil=?A|tIw(SJ5gHkr2bkMOr9!vN#s!^<3f_Q;o-(CGcej5YTDbn zVu=2??0$9czNn**AYYE1J6j%yJSj(JR{8BPC8)7mI9=opmxr+&!`5rz_^oamd+GLE z9;SCg86JPh*2h4i8=dP@=QZ5)YWs8}X~hU~q{ZDdC*?TMWEYndD@F4B#t;qP5Zv|;(EN4+d6eN+V~@K9S;>=*H$9kXAV~^jL22z zaLMjV``p`>Y#a_so9j6>rGi5uy5o7DekbBkz+9p0bmKIF*fK5+4c(-L{Qt1WQRcJ0$ zIpvp34}8NQ&+YO{Z!~_QMyKM8uUqL?tXRD$H?rT}!eZr$Hio{qp)qDecY30#0F@t^ zhAFo8t=WCrwDh-1dsY}ZE-%v1IkwNN!mjcd)kt2&^k2Fmp;Cc=SQIjd3n%tD2b~Uk zt}uzw=SY^|+TA74cEtP0-nyJon~&HdpAsF+{BZ;VYL8-7Pput-aC}L&N{IM%6c|hw zm|YRE!-EAC+m5Eo19-TdLg(k$OpAW;b7|}xYE5W;KV3*uyPqNJS$6_*5*JA@KFN7` zU?g`ILg}~Veo|aK3^YFAYF^|uz@b3fj*%R{v&do0Pmu6@a1l{XL{|p8A!Tbd~xY7G>>V?M3)~uOnT(C>gzVh}w_b5a%o&pBA~U z!vyUSshHV}07eMS?JRy`Wi?0nyJ20)b*4+uc5*Qsr6lQq5_7Nlj89+zjz;I6)~^)f zM|>_-To~`g9dPP&g*5FA7^ChqZ#ZL?%X`e}ZxQ@F=1_n$6YX?_FN!{)`%@otxwlh1 z9xUC630qA_g?DP3sOF|56V^U@5{VG+xBko3ldV%58QwTH9qLnW{G1J)^SOW4&m}VA zV*&SLk&(X#;cLpNIzOMqjCt96t1`lDr6{A!TX`JCZHsfQ;@`hieQkl6Sy$+gtGyEj z3a{?D)ZKWCrk5v%X5`Aa@G-5M=fxg5CpPLmj)jcZwt(W-e|480_%(x~Xz8Da!O|A) z%bxtz`f9~8qOa88 zKhk{Vjm%RCZ0;HBj;l-Nz@YM#Cf~`lP~1=A{^ynH$UHDUm!#IHQX#i+ z>Jd@m(|6AsVJrW;C$Ov49@WxHgY>f{>-b>IVVq?cF^jtu{r7!)vK(;h61P?Dlgok7 zVm(;D&=ItQWF7TfQg+J=`;OJ{6dM=q3aU5~e>Ap!_=7$roz|Y2_q(8#JGXBC{`?+d zFL`H)s69M^GoNR@eo1r0qb{`3Q@;5x0SG@zUuM+b;=@z=s-NnzlU6v(5@&MH;ITW- z-@X|3L-t8OYP+{0>r9QR@Opo5=@NAi;oZcef_feDTTtv1e%Uf*N{Qge@M0>brk^OC zh>SZ_eQ6IJ7ns^y4lnVcrg>TNp=|EAeH;;O=}8oO6K=P1o*Xx-xPf)X4c_m1q^A(i zS@3Yx&$DWZJ*B<@X4ki+6GJefJ9k2jklhk?#^ikau`j zl3njK$VfXq-E!}&0A~k^pH0j(Sk#y#k+%6a3-Q(eG{baPwJ~#Qppe)w zod6bIH6POLGN|2s1i_iu>k0O8 z^Ej7hcz%oEJUJXS?-^2vX71oirq2L5K*qmZz}p59Fr=kKQNA3v1ly8A8;fJLHfRm6 zm^@Z{7=||=#eOEL-d06IJUjDK?eZv`di;t&GGpXCtadz$ezLZBBZugRtDl)IF+6gL z>J5q0oFP`6wsD6b;xZB+ULsdZW#Gp1W7`>t`Da?NX8d2ih>mkH@)hni&Axc(hd-a0 z>l71H&tRlDAfDo18YM=Kafhn0ctt@{HzxVK>(T?{Ryq6i@75=xLG*2??K=T>5cFws zvA2@);xO~~yGsYY?4M&h_sGjXq-H?YG@>rtF=yWoK8i0o__x&v??p7J)^3&*;;K^p zuMOSCtJn?rT;SUJZ3FzWnkoz5d*7n+mr<{|%iIX2=3*Jvzq>f#Gm%>zzv^@cQa+sP zw40>(R8=YIxX#yD#+@8t>3O|-~PyLcR=>%eYV5$G;9nL{g)+wNkR`5^Q>pP%6@ zQR2qCHuh3qKVJ<*5ug5Ov32_cBHq`JxVqbJz~T0<+>Rinh8U;H$<$G64m{Z=eGy9%;g9o~;wnns6P_4X%#Hp-aaI)3kEhiW&OZ{sp9kcJ+X{na zG1z7Cm`dHg70YCg3FLTER4|_#??KVB{0OS-ZPX)SmRC?7T`rzykerGzC5|0Qnffyb z(&p(bAZu&HyPp+;KEb#C;R-L=dRpdxmr(o9_a=vBqdWf4H)`jS?Xd3KWMQQaZng|$ zWGWQAiviIb_A;Lo$tkcT zYSZ1uTbKuXAS2r7{wykj1p17>D?hE0rJ-W; zh4tGRCL7#ZrM>ww@%nMl)_BN0_(LQL0n@Puif^OoL7n^E)rwvH5h#d758G6f_h4Pz zuVYo)tR8`vchdj;PB?=*Z#uRYf8N`|iJt-wFEg4_>30k)jT^^egAeebq;$%LOsmlz`sdO~G;Rf2V|@Q7nO4fEBkQ85 z>%m|;4pfg6y)(M4=!!Y2Qvys2#zYX;q_4T!ZWx4%8tH`&-DjTTgx0Sb>-XEcNPbkU zRG6i`1_R-94Zf=+v&fUK{L*MM91YnYMCy&JUlL(kD|><{Te22%%)c6LUS8Be{(n-; zRqqRIv1hIucVWEtrn@t{e|2G2P5-1k(`$}dQ~N|6*OZ6_3|FhCTNi9XCAG5`2)p-hkZsu?p_7s-CaA^ ze~BEh?I4rU~iFe*76ZCWb1sIj54P_V>nwhu(g8U8)X*e>_s6* zYM>__yyfz9-`+kwdrkP75=lSAs>J+Hh+mdM zs?ACDGXxuqNbxR(i9cR3Eb6Gpp>#cmT6I zYC>c?sZx+V%28v~|FayD?kB2G4q1=FXX-`N7v7vm4E&~)c^bEW6N1tDXj>Ef{TZ~a*nSUFNmoDS4bG9`e;e6jWww}eZ&zRoM zn!jCM`5NXS3VtgBVwYhvuG(`#g8Tsz=iU9ppFTMOW(hy>0v3kj2;4K9I`w2M7S~o5 zDmk9lW`K_`pg*AIIT;Rwma@5$ycLCcrz#)OF+&%m=w@B9{849yI-&m4ds}Uh=ncR9 zTlLDH0o2tlxI|JMxrl{{B!dg2HNr3`(wrWNCE!Jj(slkor_CSueV|u1@n?_<&fXb6 zw7g`Rht>Z&4Y)O5H{*`6RIo?I5nkNy3~C=bw@3;Jg1Yon(GUY9Q4e-;QC8ByEj!Ua zK>7L-ENd%z^=v`{K$LKD-E}5m9VPz>@GC3!#6jM0I{MsC={B?-I66|Re(y96|CJ*= zxglK+9mbX&@&m`dVRZS9aYfH^U>jlyv^pgNO0+o$ntNBM2#(nQCh>~nc;y=5e!UY?L!NRBNnz(Vmi)K4-Lj&uE)S=QH zQyDNd3T-_@C*KZM^0s4VpQuHHpi5`{>hGTxpx{=_J9WVOGzKR&AKx(i{Rga06s`%+ z*4Z(t_2UiCfe-5tW*|N~U#jj4PfI)K15xIS;j36Kc+4hSTJq5AxW-xD{9$=9GXYQ!V~@6 z?&4|U*^`$aO7P^7Q^u7HCqPC8Fc|?18V;eZ1|jq=#`q$^A`6R-eYs)ho8daLTowaqbtJjq?tcBN>m=zQ zZrj`UbQ2PX;P`N|+OM7aGMI3#%~>&4KMC=l!QPyc0~D~L9Gh>{4!e8U=ft4!nqRuSmB{Z7g1}FB6ueHb`zQ-L-=~S7`jko z$*uGG7~KJor@Zr3sQg%uI07m9_+RH`VCU}o`af#pM`*aVHe2fOKmszUsU;(3MDLK% zb71s@snZrF2!#S?eXaiD_17=$HG-`*cp@SAj)vCvHR_g)1?(?$(j(r8!Ao~H`aR~a zD!iLjmpY7))jVS5#DQj&83<{BJ#g4A(Z|Jw@{D>C4#Ig_-`2fWW%q) zIt8t?;ZL}pt0c1M#?^HMzdw@jqsS*VjF-b#D6%iV^ZFw1$PEXb@j!i`ovq4*pYr z_+=ujzC+D~c0{^oXaknicmJ`abGN{B&$Gu{kT43WV^zma_SY`sRmuuItr58#p7=RZ zcciC%g|lYo1EHiTH~2G;EfSq~dWcNIj(M`FRvnbbo{`A3x^WI=dnpY1FYkSUr$HLc z*1=pB1V_|9Rw{n`9XCZ&Zbl!T55n_}l>^`Ap6A2+=XXz&&si-PHS)Q^b-}k5jIRGN zEJcRCMDG)VTTGRgzaagV@3Z$m&g&uM*hu&-DQ!RaI2xCC3>?Y8^<$q4CJiJl5uDCs z%-&qX2X!&hFXZ=Sl5vpT>KbugWjuO^Lbc}ywW}ay;5ALQ{GSlic==~6-+1xhg{x@) zgV@VLSU=yh(dJCHKO5dR1Q;(~iAC<+tG?PD6|_iaaMBUG`|tzm-ADcn(&&|eF3|8o z+Qr^Ou++2@w_Lb-9fP}nWxZHU^s(u|7iC{Pz3;zx7+)6sCnksorT@+ft3QuMYirG! zsKn`X(D^)cD*bz`7$sz@9t^-9p=F8W0W#^i*aobeABEJGc9k3l!ctX zmhPO_(W88YR)C_m0X~!8{>E8xKgR8R$(zJ1M!Yyt8|7cD_+1@3@6_7;0<`jQKgM1w zr8ltycLwws{fCwIg{*hu2xpd*3%qhupMA)_;emJ5iJJ)>SIeQ8enieLNAMN;tI2F9 z18qJds|@GUlZyip?NLDH>RR|6>6cv!d#w{bLs@eDQLImy8hSSLj%zRSNI~%h!QKVu ztLM=;@_INToAwG8{{*fcf4NUwHH-gvmns?1VL~PD+Sw}t+#vJozNR$JaS$qcF|j1Z z4sDP+_Nw@~C1*EstDp26RG+;JJkk~T@O}6ND9toO3N9bl0D-gH>}h4?=TI*nX{Rr; zIDscuG!!UKq$gwe7uQ=-i>w7CDc92wxe-t8TgBexr26l5kTa?~n^$&{0vyeZt3mVy z2XS`(ukggfuTD_T_gd-mg1Wox!BYJ^RV6P=B@ z6$!rTc#n=I?^%#uSD0`&ShmCSI_Xx`Ck_4?*Xf1Z9tCKfso%wMpJd z;tOP5XP+Layi19ayomgpgo>>QQ~Z_{)JwvO*DvOD#ZLD;L;)$K7rAeSJe(&g^z?tY zzl1P(8olyCgFncOy|>I$ImeG1-A~yJyE%dEQNe^<&v(;s&7$@cJZh+pq4b0cn`!AL zIGgX1d#&H{G<+Kc10oZqf1+T4Nz^<4fi%+J8a(3q{4N}M(L?+@Z@$;S&o!E&xGClv z7A`oy?qR^i?q6%9}r1K5z`){1N@tcoME2&=) z_%DWp_09Y;Mgxn(NoeBppu*4KWN5q}DtI%;`vOFR6R`eA`UyF!p&vGXtDdhG{*VIt zl)5POtG|^Xs0AZ4vriO??7jVigO9^>gi_yiqS$>on3-VdF^-E|qVt-f6o>eS zj#f8o4?S#tXO-oh(-1%$Ig_Ecj|(&MLd}9%jXbSLKdiSD0K^6&>v91@td+Z`!=S0KFFGIZ;)BCe~;N`^w@UVN{ zel=yt2yNf44DP;P$VK=G_VP0dxg&_v51d<;~EzyTEQ@H&US8MH;HA!Dw-7Rgt%MMN!)jtZZ}y6HxF z|6R*#c$WQz?Vt}=8+Pdro_2VXJ%g15k^6JrxPgNAI4$Y9l#27;{&O0UV32_X@9JX_ z)2beXD?J}=PCq4qBkx4+*BbtEKC$odCJR98kO1u1^>`11X8Wi3bID&~ zB?W`+`IkG@_RG;^dQ{I(UjG1yF`NFd&+@HAZJm<1!JUoM2o_j~B&%BE#|f{kMY~pQ zYV>qaNP4cnSAxZ(9l~FSo}7bT`^j@MUEgx?#afH4&tmF79F+WfuwM0sKCF6Gh-0NS z|ARlzip|4P1!v@RzJ`!J!B3dHEebDcF8cvaQrqRc`u*Cz&Tl?k@>;Y5@0_j)g)|xZ z;_Z7E@=fiVXL0{|<1K>agA+)XFd#D+xyENF4Y`Bn=hF}gyMe9V?^5nJ!d#`}%Fz z%pNCrb)Aa|Cfy#Bj>H<8`!X?4Sx4 zxzoQ^v?*gUABj z>z`sNwILnXTm{taTKi90(hR=40vqe^=+?yNfuH>%TwZp&tXw}UeGQ>l6hq=H3@ z?|f@h#9$u(-n;tt7Wh{OOt11W_96e|4T@}1F)jT2cX^7l=(#+qQqO6+k>=!}vn%d; z%&{Ok#L)YAmb-rJf*rGCRoAU&-RQ4lJz{W3whk8N1ZoG8%6Jgd_)xJd{J;$?G-)Sj z%-{0I_1_#@$2yaoP!K+PK*xao1ELG7YBvO0ZSd_{*-N1pe(Lb{`4Db*ZoC99sfBoG zLSmEfb3?E@hfMJ*TCZ!-KJuM>iLVW%XI#@2mta-)kj?q%N(izDiAT;cxk%#0`PSXg z;szHyeJpguc4V;~i~p8|x9uf{A+!6rNZ%)H0}0P=f1(d^b4GoqeaTN&rt@$WSvSpF zSs}$#HpT3rXYww%a-=LTwf;W}(6MRJzr1lV7@F<}_WCp&4ZssqmE^0H%77m?js8(c z2i%A13=)*Mj~xe9;Ure=%^i1-bl&&k2@atwPvmNR^+gGdBb&+oHk7TOTP=t`GvwHq z&_$C@#-%r^)hhVFekVIF+yMy6XwX+KB5?!z$?1*G#G-MW5dEm~>=>I1BpPgW-b`Am z;}Ua}w(-XP_?@54(EQ5XDF@oX@ncn9-}n(wnOBxlH2DOC9~{}zPSd{w>9xq`f*;Et zVp?sh{t5922LkV1_gUZlKn9znX8)QOJtW{#Db5f|E?CCf*`2Wbc!E->yp>FLYg!#a zP?bA_Us4J?RxCN+>#$N8WBNJGIf@Q)6D(h_DPCCJZb9OQlDzkm=@NMO;$##{Yp^Gp z>a0Vq**p`&CjwQqpvIvin0wYfap3hi1;{j&Wk0c_UPDYJOW5v}RXMc!5Wg;_ji|vL ztGi62{#XCv#MuU%-&C1H-Zi-z&E>5vTwHhkP(dz`hU4VC(H=nt9r$iX{$%mYk~Y#w zBDM~&{*;03rEp`T24Pnu7EyLqzPWCWGh4Bx$~l{4=o#c#PTXxwMk|AkmDG{Q21MKz zwrdPJtOsp$`!rb8I_%v=bgNg)XD)ZF1zBYF<|`R)!SSC4VfCkl;8{r|5%->&b6 zd#@H6Xm}E&o*)}v!?_n}ZkrrLhw*_wqpNr!F$n5LbQUU*G>ac zg)5&>{>9_eCg{qW2Pb-Fuw3`1duKtt(5!r-_giN_Bd7gU}b^Mn4FU(6%l z%rU62<>WL)MaSb&5y!#|A^$9fgEJ&qUY`GlV$RAt-mdY~kYf}bh*8XLM`*N)xE5zr z8%q9OdtdMADu&{25hur%WLnTp`O}>;Gh4&Am16;H#|Q7hc1m&dbg;-Nh{@V9om8MM zgRCI|V_yASCBAr+rZOzcgdrs?nY6#w#}%qi$FBvR;#tM3o8K?cXU9E+V%PmwdPiaw zaD}e$el2<62+~hlSAB0@dW7a5t0BD=Cj8j(yU-Kz>P{mr9c8@y?A{S?Nc_q^V^u9x z1_RY-u3NFOr*JLyze3%HV>u|bziY~SFZ~KQIB$Hv_2Qixp5RRO?M$g;(CKe1y%IN2 z$L|i$Gk?ZOIPrj&QO9MmYI1)Pv>25HkL$tk9KpIpb(h_ z7-E@q<(qv}gHPW}3q8$`XG1{Kwo%8TARfwBWtE&Wdu*VZY3lQ~!pIdzwcC3S=A}Br z$>~Pirzrl1Q2)AbVJ-naBlT?JRKG}e{PJRt2yc3t(ep}uPrjU?h zSHAUD;lfc7=30w=O&L)#uc?;3nTyXQjz9qUpK}hS(5J-U&GRhU z1nJLBxF(>_KHz)94#LO9+OOYwwgh8_+^KJV=LjIYr>pwP^qmCi-Y9Od{0KXVTW8D) z3+!&s<40Cd@T>BLx1d<#dl)r*oEUMBFP!$FJ`{q7-<-bkvPFuZWv%(ZW5&}QSdz}( z?mXQ-4(}Ru6E->7KR7)dBRj1=c>*@C1{z+xn5Dw>vCAw)>SIrFw?mGZQ*fsbHqH7a zekOw)I8dPFuQnF)4QE-|R36udpTmKUg|A)}QtcS1{jwGIWBn_XO5f>@hGf!Wu)0pj z?tYgzwuMEnri(?UV_P)os{vs~Id;C4R}QLERl!`ycVIt)22PaBvd zZpmW4{!QfOEoB`nc0@n-Px?oKBW~})ckJTJ5kR)|RpET>CmhgyNc-tZn-t!7FA83Y zq>2MU?2uqlJdOM0=@?A3{U|CYD669t|?X+Zjs#uKFw^eN{nFSjP-;v;WbugN-- zHB8LEK0B~&M~%0wMyl1>1hF{q_PK{r;~R3EOfz_Wn&A2{7(O0X{BVc*}3z)AIZ7Cg{ zISW1|Kl_>!0$tdSxZ&?$O=AlB8ND=toZ31#4Ob4-v<00nk9|KjM?iHOwT#xYcbmUg{X2Klg4?HTf8JqTm(a#6w?RWglQN7DEnzQ@d z3>PMmJKc3G6*Mo(Wj9s#azOo#YgotQQx-CGA|Ejv{u>Imz@iupqpa_!l<2rEbxtk+K1CEuGXYbrHiNGPoFzvG*6Ca?$`Z=z%yg3_3d#;ePhOnAK z!lhd%@PtMpzWx~HTHe+;fxR<+oLu6PtT=Kq?G|IOek1sL<8S%VS6&C}a?L-W1dt&f%KgMm<36v6^HjvjC`? z?|ms6I~;>VD}E1Q>1k!O{U^UYa;=USH`LsRY||Gi@mF;#%qlu!BX-Ch4+OeJiG_&e@x`2q z<%MTS59mk#ko_UkiG&L&!IoZVP8J-3yx&@0J2r;a|BT$67B#QqL@MXKrrrX*z9;t z9XR~!48%Ev27Mhh;?ZikAnLxmaTt6Ys!#8@tZJG?e$|oS z=5PCxdHthiA%e!O@!~M|+j=STA_(@@3+<9$ro?QBXU!?=E8&RKN%lQ2@Uj5Bcddnf zoob*%6#;#>kfnO`{+f6nClLK;KUKQLtIEX*XX3i5GM#TK0|n+XC9>qUv)^G(LLe=P zypjOlK86l8q>oS|ZYln_VUfpI&`Y#Tza%8$fzjLZ<|pE=7vWBd>E&xOb_sa-=Xd`7 zo?C~IQmyp8u2(Vylg;%bSu--EIPET_UvXZ@9=?(?omB&GzT(Ck<8j_rxd2EDQ-!(J zCOY8=v+?@LA_EQFTpL+0_nA2k0m2hjx&E6pppg~wQ<6(*h3@aPyR!;+)NxhNjHg&d zEERStN7v#yh_b;a!rbIVo1p+bb!z`SvLEt@S&-I#)v!PdHD>K_zQ|kC=$~XRt_pnsqeDcoT$gu9O66%iP1mkdyOI$~y0Y=|J)OY~lqYoG^Yupei5B zf%;~G+##Z;wb)QR7~_7o`8?879sPNjnJ>d);@1hGX@#G-JL$ypZN?%McR7d}2ewAE za5GnTuO{NM7A{Et8BRUfV2;f%5!EIt)3Qhp<1*@P?|6yN?>V2oJ63!F$I7`vdX?9t zaQo|`&W?;b5sKzBH8u)-jd1DfYfE8{Y(m_iU)X74DIi5z;K}RUDFkm}_cmEN^Fr(> zXxM}t(r3+{ql}4FhqsTE4p*N?^F~eke1qxsj*&=jLIJ3Yy?5)g%5_meb5Zq?S(+D$ z2mMj=Bd~XO9Q}Pm_dkp~=+IuI<@kt@ zlrL&UUBwhQ^6S*|#Ow6m@Q3G1lM~P9Ylw7AdBG@d$d3GW{onuc_Pv$mDLukpC%Q^- z$UET%5$&uySQ1~QQb%SoqSV#$qKWT-1l-7fd`w?^S%)tld7Z|*iSY-hZt7nYv>SzNbXb)YF_OM!A;=QS?W`1zh9Qrtsa2<;Fsvq*G-A^EL+f zqim0h{9)YJ<)cgH7Y8^H`^bSvTW{4LgyjR~fyRR)aH4Gxw=6pD1tq@pC)0i_%ZOj+ z`a@}>7LIl0)}txz{iS%=T%GtW``#jY3Qy3Ote>{S`Jxxi_xmRJG0r&n@9~QlZz0E9 z`1edWc+mJ~^U1Hj6RcSEl5`yZk0l@3LW%AsywsCeAOE?&EaI#U|HA8k-BbVTK~(AN zBe9sn-H4R$t!M2Bo5t=V3Po#*xKcPX9J=gPa%Tz;I~Z54-cb_5FmFF!u0U%(($AeL zdGD{43}1?)F2b%{HjtCKZg#hZo)nACEf11I2t~o#OKo*TQL76iFV3ymh@9bvVc{Gz z;cea#ByH9YlT5}gVdd-FixGCI-PomkSN82!|7*-Du4*yVobteY$OfY_ecTH?%^sb< z_}57sYp+y^4LjHTErK+KC0y~oE`R~9{X8#yGW5iJOfrO{N+U@g zf8hcDXvaQuGtvhSMjRaS+>M|^*dKRiw>5+Lv=*Tqcn5^dmhm(ukJb1;Y1V73HP-W zYUo7COzIYC=|SMMbNq3I|9s%&BQ(Hs<`DyK$th8`$Xm;Rg@Qt2<~H>`$h!Zd6#BG5 zjKX`h7ZSJ@wV-Kz@QALrmrLld!9B?9aqMaz74{s+er5l{MoDb4MWYrYVdfOgG+=f22jsbi!me)`od??;!gIM27SE6% zZJM_vbFK%;=c3w%2p)XKv63I3ABw%!1*Ll4p`=$6RxeSA_*21W+H|FLg0K84(9 zzl7H(mp`~6RD0&=#urL-ro3vGz@#pl-^r2o_LTM1@A1x~WagSNAG?;u_zAtY7kfC|J+-m&t*!r&Hp z_3fZVlqw%x(O~h9gvq6vbFM)Ndx#3`-Hbog(hY?YEeF;~v#a1*jt&ex!XA&70JkVV z(?%X7>HVPkc|ar=VO{17PjeUkVd!}8S*Jrg=P|8A!D7O(qXO?6n!lg6om1}P%02fQC)xt2@4x7-Kw`@up=ysP52WAa`g-=kPbbuWD>(dL zY>@$K^Uuu?xDDwXC?`aOO}Q}pCXn995yFTJdjgev33Bm5D8NqFHRx$w^Po;#FUp1V;H zrP`s>;<2{$n;(x5C8oSvRh~hI>babxAd8_+kdhs(l%=?M1o!;@__}=+Jb>Ho^gBMk z(_5jaMW4>s{&)nsdJh%}`1^(MOZbR^{xebsTu?i9g?)18BRE${X)84xeq;H`iehD! zNFf^jT6smu98JgTdur#1DU3D1)OM2S=+LeWe$3a#DvF zghNn=I{iY$n}b-tA}D6HQ_6^34ofF{HIU=1pO5F>utXHhE-Q17=Xl-5S{8S{9m{G0 z#AOqX_^OAqV(xtFwx;u&2YA8T6PMpg_6w_oY2Qu}-t5Duw)s4xY>y+39(HeiF**AY zU*~UTk-xF?g39YG?@+x*^w@ZErH)o=`voGNGe%6iR~g_ySV#KCDZ_T8_AidS)E7B~ z%XnIwE^;6gEz0@4MEUiUSZe)s&pS!#@xDVGapbzen}~US?>9dJ-3f7`6gLYL^2?D? zcJj7J1s5qEkv|f3?~1JeQ$R$=_rA~PP(~o^G)hKP36Wu$%9JY!y6~u1*C0CQ^A&#T z2Yv1dE~`W7u=o47g7bOs@S68h(5O{M-)`X9dy}su@bTUT5!J>+ZOq1>ks?byrVkI< z8(QOU`8Y7yHM+LUXY>!(tOE-;^gZ(sqs_N?-Y(P-`g~Nv4m7s*SUnIcRCfRKVYsNB z_;L1N`6E~w4<|H#eE0zAhUY8pnhJBnT1C37=M-l#Mjd(A{`5c%qz|8@tEtITA(ri6 zfv4D310KEXF;>dkVFUM{4}4LBlpK)g_ZcBmVywc|Y6atw)-F9zw!-(PuD~T^{3aoP zk>hX((Gxl=A#{kl_~|&IHrEr#q+W6P z$wb44_|xahg$XU`a9iCq?oh!YZKxe2xM}U4Uk`tShVLYso1f9hbjyaJn(jF`r+&$O zv*gf%Pf7fZk=cXosAzQbskbHx7!W7Nx;oZd7KhVURahc;DeeGEEz$p7 z1}pyj%uJu)q!)mT$qo9CgUvkf|MxvKO~}3yV|CscE=1&Ypxa3`2@AEE!#S3%_S@D3 zCn3uHrgHR$-Y5*-h3r(u@QXv0@<91wk;xYbYf#;-NIoco^BzCwDe1KO;VYXdWOZZC zAB8^oofEZcOQ;WF+|ru+`U|@(UHbN~yi@V8SMHFAr;iejQk&07-)j#=pv3#jJ}%vH zh-!5i`%Do(2_=?B(|Kq8J(Qfye%eHTI0!fX5OERtYmGpPKJ(8&`OZNcr#pVynBo>I zPLiF;bIq#SCsa@8O=lTY;<3Y~OOWduYKh`?kr(360*EjumzzAebT9`x{*fB8F*~#1 zdO1p7W*eu2liKTl-uNZuBX~oN_{5*8GITk;%N{+cIfqM1xxCkE(S@_g%bYRql1fmV zymy4Crw#IwU@Xlwior}591f@h0$IQK(6VV*UJyyYeoD@3? zV;Wjb?P73#q^p$)L{n zx&B=t_X9}!w-l~G(i66WrwrV#r^Q0k;lEh@x2)CpHhHahLE;)43RK+w%at&`1O+{- zqEGT?d0`ywm)`UA*Z(Ls?{F;NK8zcQQ$nb$%*YHOD@tYjtU^Rd$t+}LZy|d%BxGb~ zMn+P$?3qz!c15yf^m_k(j{7<8KE+9y>UG*ft;K@ zmJdz58c8Y9-&FUPofGlQ`)7|Zt+f3~vesG>TmhEJ=?7J`;N8RdxAa>=JNVtEZ$wl2 zhQdKlEcK;#V++jBImr%wIq@GTNyOmvTlUf`sxo!d`~SRD8X3 z0Z|cW%&z(WD1^rY`MCB2WuEvGpUeHVm3$I}x$Sq!bnT}gUGw7VQGVun2*_(}b){Sh z#`WcxDit>LkCsh8!fdEP9?RrMp7xCX*QhAFn@Z-X5^qWj}6?F0--PvGm~6 zIGRpIS$i(!3<_e7urSzcnIT(D@bbX7u7eQT;E_?^&Ef^m8ruiLH~btRaC4tzQqsAF zO7%73$Tmww#s4_vJv|1h zjFV~4tNViRzu3F{(l$Dn&lcOGjMcn?9W{EkL)@MF7N*EI^t4Fz8C*YigH5?`eGh7n z)5L>3Q)w|zUneB77xfjiZWGZ&{B<|s!*=e?_`P{sz?WiiCdO6{jTz6MhA4=pqvCMZ z2swAOIrgt_+x?DbEdFD@8_8po*@E0Y%Hdh%gb`?$v8CK@Pr3{PBGX}y=P8eo5dqR` zM@96J8raLHlYU|v4{Nnnqoch3p!AT5#pN1;4|v>Yw8_Y0%Y+x*oxXOjty@8~PMGv3 zBFG${Kkc+fJ&z<$Hy?~c$#`m;jNt7BbU3GZNZ%PZL!EaM%ka{VCG?h52T$y! z1moR;dOOF6l0Jf|;*7?_#b)rqrBQ%u8sV}=AB$(d=2fJ4W)>^Ml zqcPJ}o4?h%78+$6cPjHca?p3ORQ}wLx2~8{Ubdz7S7Jm2rKZaj0o_js9K2_Jg6!@; z6!i*b{7K%;2lHF`QsX!DztH=lds<^cRRpIJ&qW{pvULPJvU)7j+-mm7i>#4w?)3JB z3qa}J zK8iQd)s|3m5x#UN-`Hdy__GoRD%>Ums`ekInYN+hW$>Q9>nv*4;lEX7XM<8N zJDA`26sG)&Qe{3qR>$K%PEA_DX5Uf#`d9CO@BjhQgQU{_Bl}a1&$8k)!a}dh@wNK> zPrV`=dhj;c-BcbW3B;RO3uEPzuP)+pkj6F9{KwkRkWr$$Jh?CDNJn(9Y9#BAL0Gi# zq}&spN$7MMxhtFXw}2&j>5sqo(mTX{7zuIsTw(x{AWSc@t}wJJ!L$u3EW;myI5^9y*;<9GRwlLG@Z1(k`^ zC?m%~#jNo78cX?ayz<``ram6>3U?1(KS$v%l!qI4v!k3>H3TubOm0McbWH(QRrr0s z`VF{(MSl{L1+)ias)L%ACBRhGVpYFV!pN2cdh9dvWn!^(RQ!o+6xl zyzvq>-^_$5wGZjzQS~q_|8Xh}>=ZEylyKzjVLr8dTyiln1WmSge%GIm`2fcTF@vg6 zRX$v-QCV2GO>PA}TXT7i7}HCz{Ybf~mgHT7TD&ZgdiDGg_zE!h%xKCJ-;dneV!E~; z7=4I+M7s2bZW~#haw6Lglp^qhN0otor&AJ-MWfCVaD=_Y;~Syh773$N@$ZDp32Ltp zKiJ&y2yVrS^OIPd) z&i5IfzI{f@5Wz#b(igo}UgKKH-81|%dPX=ykQ02@L8TGC@j+koMApM#UeZIkdY?Fx=nOldJcK!OCA$B?bw%2Lu_96{2cY z@qj|J4JQitDm^aB(~hH=nA3HYK2;S|2^M}Lx4vJ;9g^Ms_EZvf=`zZk9kLq7PC>-;KzTsvFNl7>h!h|^&$Eox6kwNug z<@bg3e#pGc$;iK&t%aiaoNp7K+rA>BkmBaO?fwfGe-o~;5Z1Acpb({B<)KkpoeH0!>VFt8j2Szr5&mnxgS zQ2bg%n)}T}051#~k57>9Y~gNENItSTE6zA1Jp* ztjw~)qSbIE@~rCkYKu+Y!&8?1lb68ZE-+sibEY$lh(qeYqEFf(lbeVq9SL(B;CDgi z7b~?3qI63*_w;l$ozSr|*f-Lips#RU#fMw&BFCQYqmRUxn^l2&$Fo4^Uz|bz!|g3} zm0g4lCCUilaQ@ENaCa3Cf|xvtK1S|~uD2N{`zXG8#pBnpSGSbUhTFqllW32Sk7x>? zgw0=YtBR|`NcEKX!ae^q*cH4iKNRyK1^Jy0W|ORXM?jnFmAnzOc^HLr$9$~2^UcxS z8B?!zbstq}W`E{wuDfcFx6E6TE9Fan;e1Un)8NfEA^y6YWShQ4V*o!zmlaxJ9WF$> zQ$7B!AkKnIq7AARqW!!0>VAIFGk!@GpKGx+KYX4GmICf-b|u9!$bV2G|5y6X3*;ZA zC?{nzOhZ70!t08!wOgS!Gp5w=qgDVB&P!=z7~g_1J}%= zaMo(-8j8s{_`k2k+`~5S=&{PoyTeko1NAB17o( zXBa1SWNj*~xS>mP>p0>purUYIwgk1|HdR)Tm!1zubiZDRPDlD%C9h8tfjQ+~dxOqL zPpGP-2>9*^b-;8cn6h0cgBe9R_dHmF!iK>3Dw;s|sd^Ja07*c$zZ0|#8}7&v;b{bi z?^WGfMhKX?`AF75N*#86P1Sc_GFD*5|NAX>5>9r!FRZz!H@C-ycV1LuBGJa~m^AAg zzxVcOHZHx?TzdW9`Y$%LmX%rTBxGR8eg6TaJjF=_6nBOOib>7j^tXpU&fV&!1l{!Y zUx}he*iiW(6!FfY6g;XsBxNz5$q*>7-YUT9? zTPedd<}=s{MX5CFYpiJwBc$D?4lM#o+j6X%aNh3)!% z)Jyz`l;>t>6W*tgJRq&Kg!`h2f8!j9HJs{i#%Ns#t@Gfivu5p?)9wf}K@C5Q@+{#_TKcEXy~^^gW( z(j63}Jb6uE?(d4SN#^xSyIbS<{5-$mT~~SvguM@Np0o4*jTD6>*7z7LQT)eQrE6up zID`HW3+BDGQC6H7DjzvbIHrUjrjaiX9HT17xW8&ln$=n!x($LAx)_rff zEZnsq=o~y9`xsTtTQ#}lKQ+PUR7}`z8q@|h?NSCci&yq25qmgqme9)#ehdDjyzPsW zFzjsIvCnRQhROhrtPh6YD?t)bQ|Yg`atwRqz0LGZ*%XLh6iGbp$@u^aNyE>?baZ>r zL%MA;;4jn!zFb0TgHT&hFbb)4+}~KZ3&OGI?_)Tx5y76Qn4;$0Z??lkwqV_I=rDQi6FHPKPXw9p^9XGZ%HmkfpKYrn`*fQ+%?;w$2b2lz7c zK%5G+& zk#o<^Ak|(VMmRbz1P=rwGoo&?27_ey{L!*f_HvwwtMyLry>5)`rr|3(w#U<@F;asTdF$k(xqnkvm!gZP;OkKhR| zax9ogzc}@8Z4u8O578a;@vy@LhpUnCfj_<&b|E*SW*~b2!$nqZ(cF11oO==csIvU2 z7W|^_o+jP?JqX$AxaW`g`VWJ^qw`%S#rscq8~$g|$il)3YoXqh+m7P?`0w=$lVNm_ z9%@$$PrP<$B!F_ytdEam!7^+rx$69G9Qp)v!-T`n51#2oe8T(4Fal;(Sf!FrHc)NV z;z9eCkV)!=6kH|QVPwgpumf2t%d0aSPm<8)qmuA(;P6pw(lZvauk>((Rp0WcWnJSIST9KY_Bw_c8-4+pj)Q^dG@6cc+i9E7KbFvcO z%SCOdFUIuZuJrZxG3hlfjNU%bg`CPlT=t^7x+bVt1=IY%VTnIKCE!2F#pz&rAOIsW zv(4=pF?LXN7GjG%tgVZ|qL+mvaTofbYGL8_TC0!(G(Mr9%PRJ{D3ubYWl1ocK$2T! zdnxIS3GOmfgtIBJbfVh#I>9hyXe=x%h_-sVdmmtSL7vK$Xy0gzSmqB*i8@`t!oXXS zJ7kUq;5%c}GLUQ|1c3>rj}>PK8o&^`@W6nzZ3)}o*$DXTn*1=ol1R`{Pxl=hoEjzr z>E9_KJlu_J&KH6h|7rD%*Xt@N9yL5vepY0227N@QjRSvDJ;TwrYnpmQK^f3*R5^6E zr!NAklm_)YvSfF0|5cf9-5aHU5dQdDEB5(uYvkmAJ@I6YI|BNL?A4EWJ$?<(ez`Xm zZ=W1MMB139cl)y@K>Sv!hfm5=TztxF{OM)7B4nN_w4Kf1Y(dGkhwDs-#t}$MODeqh z@Sqe1zcxdkP{wy+O^%{LL|e!dQa_1kKQ5#V;fdynV-7p6e5fExe$w_|R4LjdSUwx9 z%KU@%?x}1YukE8~{k&>#`x+`Z=cU^xpSJxSx3@}je;&MJ1dj7uvQ50+`NXp%*bj%whu_f zSZtH|zr4wUhP<#oZxZa`xnv|o?z7mArt-wx?>@%WC_8DSoHS*@1~cD=uCgDFK6vn) z#i~c1{xZak-Utac)pa9xT_){f(6%;;udmCvJKWZW)M!=V*M8MWILrN-V2~P3aDNM(8Wnt%AN_{lSe4A5)Jg6p)#zzV)Fy28I7D1NEhDJ%-n8hwcKA zR}#*iOQkS&+}wff_~PZuRtq6mm3w@rDLs{(WTFop+8o*wp? z=t7|73H@(Hj#4nN$sLxQXgP{+Q?xv$5iJh^s>v^L6Dg{Yi>*}sych5eN>-w3zEv&9 zF+$K&NfXuWi@M_EbWOoa59?zl>3%Hx|(dEfPqAyg2(#xj~Y`|AYq95n^k7^T%f zt3G~9#bbmD*8Tq$&5sdiK>E=0h z?tNN+rCNO#il2HSmh$rEVPtW^$=if335*8EHx3-%&mw8$!Q*coQG?`m19Bo`QF8Ry ziMT{2*oeaam#k2Hh}L!dDYLbxtQEhZ zmUre1E~U`lcrJXv86S2&eJITmEJJ)t!ubs4*@IB>|8Voro>~&9wRza>NT23n(de~L zd)z=VK8By45i#rA0=w_Y6Q?Cp*gOTDg^7Y>k$iekz+bfRWIWD-(=aj}r z_4zjlV~F~H2W?T|(0^Uxv7S1ikN;YR7?Rw%jH+LKTMU%>GtE8emMNeDQCwCos zRjnV>h~l#0HC%231&N`FH|`G_;c6f}@2bpHp&0EGu=b2W7xWytMn8Y6B!VHw`!3h|(o--$ z-L&DC6Ai=KW(9L%MD#8$UT2K>c>nh(E+oy+9q^hb!nw$Ju`p@T7VxPXEXSx^JqERg zs-y-RaxQEP<`*<_F3W+hV!7$V>dY_H=MQ{w_AO3=qgcV(eaV(1IJY!*FsV^9055J7 zF0Jk>6x0ZYSEO8ttAml?G4^`WgC*ee{8-ylzLf^nqmwr-|B8Hr-;MsiqepAI4yy^c-6WQq4d4LcHhs$QD0q0rsJKdxNv;p zISsj?7s$;U4kfF>0g`^gjU&57GRVEmef;->o^9NCrM&(?s+S#Mtt=Gkj-O70$MTAI zLwi{e_>7N?t4chxhJnb-YF}gVR4BV99e2ohqlfHgp(O9d&RfBd{?Ea~j0f!S)!I&E zGAr8?VeGeqL+<-)!~1W52=Ge~gppNPaMbZZ-+8M86Z)?np(20ixNOg_SP+j@z93L3 zCPL(!O(|*iX=2P@`0s!hgI)~s9HjWwE`Q;~Rg>S2j*^)<2&^CaNwD(N48j)&T^{_G zqk+S$v-EKtb&lvvkR@$T&S%9=+qrkkTCbSVw8tW1cU!^;t?Kgge$SYU5ot!in(1`m zIM!+&H#^Wh^ua3e(y3Sz0zP~Zt0;3CdRGL?TkJ=ua|TYKIi;fq0ji$~97Y_BOnVZ-lYWXM93WSYlg4z#qWDi2X3%O8zXI4UE->ERA*G z!uqgf)FqP&Zq^@e311CrMrIT3WaRkcKd|U)rlq&*j)14j%MSOh@lL#8y)SKfR{uV> zOuyRRDN1|xJSz(m2W2cybMuTIIM|BDY;O@WO{hjkiB{~+rxNh$HiR0v5 z+NRH@PW#cJUfZkeP>nY@@_6fPh^)03j%yuHVpt}lfmT|NF3H8ykuWQhIOi?=?;)a= zw9SMvWFz6Sms%mRM;VGMu40_p7pV?FeL;!sNC?di(p(5Od@rB545JU3R6(^aHu(K- zVMll?c?rS%Cjx~`o=W1RZ;cqqcCZ#$8X@H}Vf_-^w`4}5)Z?}Bc4D^p2H~4FSe+nZ zJ2Y6v4s~i;$wEmW=|cfl$u%CO_0PP6#j6gIrGs|5Sb0er#h;j>jOT0|ibVgFDWat?!#&i` z+z9`a5}L;yN$+I>`O;2x zI&w|a#FELD)w6(Wm?*Oljk%e# zh0xB6o$qVvo}kM4{(Pn1eQSg=zM~n*J3ofW|1KGMzf)X?OoB&c#A?MF+z!T`)aJ>v zhT$*W?Ya){%Q$nyurT(xSv$mgU#3v<+oJJ@&(fMl^~JHHHnlwlDT= zdoc4G$@A3R<@oqks3R+(rv)itUl>0R5Nklha_UQ&P{>V8qzk+XaT9)rgj;^h|EoUT}^#&eK28D7}070Cpab=8FGK?uSr#$Hyb-+3owX zxnPBOCa@ei&zHYb6i?*B$z4)IyINrr%y}2P!SY2FF&gy|Avf-LLGoVPsjpQA&v4~x zc~-1x*H<_ti8-f3(LEl!kJ$y zl6+4zD`2F>bImK$sTb;d)b7+q{)#C1RMJ|s%BPNCzPMM1eyY;ogc|GVD&bj0Tuc@H z)H*w=2&a6*&NE>G-mpvbB$G0WXF}Bdcjh0%_59$>?jGfPPI?Emg2rJTLT6kMqU~{o zjltk2;G0;^CK?e8Ke2HmFS}QfIBh4Npj+:q#0hSCdf8By%E$wNZ-^ZLHc86Jy1 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%0A-H!JjBkf%8zI1#RKe8s6hG>F8I9=D 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

4Y`>UySxPQCL#*}Oh(+SV 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 z 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

MYqhi>6JI%K(dHpVg+4+|OtGDe@c>H$` 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 N4!<*V{{x#!+jvz8Z0G<0 literal 170935 zcmV(pK=8kK+7y{*Jl1a;#!<#0BO`ldkCrkc*?U%IMkM~(BpGF|vW2v)L`L=|6j4Yv z8BvK4$x22Z&%68cxnJDlcU|Xs9N#Z&HhImd-!pXMThUFb@73-cSl_**HKih+hL`@V z_jZogEhFz_6xsCL%xlzSoIk%ipVo;J=gu9{eXo5RB{j;daUugJp=vxQ)MgsEh5N2X z@=g)aH<9_0SUJL&si)ulp80vWd{FA}UKsd^*WBIz{++pS5m}<1 zt_SRkG{Jq6a$?u-W-dyYm;Up9Aj64%FU@Y>m6%)b8Ev;azQ=tRQE`X6ZdPswnuz<;?n}7lKK-Vj-BlmYok+f}513`(A5o*z zgfQVF2t+<``z6gdf`#}c_TIEdP579?&`$q-m;$N0TXrf$>Df4*S3g!3&FTqtgBrS^ z6v}c)R_q1n_3WDADT~p+<8l`-K%XO!Evq#o5Ep{RoBUO%Um-zwx0u^%Mi*Dw(>~@M z*MER6&O4+}0yY~UScv4ulATVN-p-s#94~6ZqrlLbq94`T2vg^($|osk!6&^}J75)l z1|5mso)7AFIas5O_^Mm*Z4SPA3ALHgYP`s?{8@8!{+}-*RL&mjy=hShX$k*m@#ASS zpy#}DvWbR959&a`l4ot4^1eT`!hc4z92UKovT$cRV+1^6*?9EsD3qc|yPb0=F~s^&VhA+V{rEL17;9&CRnz2ch?fy5>@@1{}@J{;=i#^e4A zQrPl&D*HoI=qZF0y@UpD}^sM2%gdTsElxd;gxZy({)mLh%U9g5z0zX z!MELk5c(HcT#(5XyAtu+As?ak)cNY(?m@VIK6d{6vEM2%jTD-gZ6bSzc+u3YG(x*@ zyx)x>EQ@X5g4cgGhEo16BJdJ8r2!RpvU=j;%yX3w)BptyzeY0fOt?E?y+ zG+tz^^|^Etw;in&DyQ9Ta2em!FI@@8?KU5jTE%DmiZ49l7ti6j zLhmSt7_Q>lVABb^{zg6cn3CM`>P#!c!`&WAP2o{tSX#MF^<916iKm^cE6=tc5kg;7 zZIt@R=Qt>SxYS#ARcRL!7x&Vfj+`+=-D_lMrL}J1Wa@Y@Ux#G^`o6m8uu3fGpk$rr zUi{H7$?!f?;CA&^_9F~l`eawRC|pC3nP8m zhl|}m!LDK?Mq5Me6x!!Q8X9;g($N%SNq$b%@iczQU*q;qJ#z{i&&DJZ_QbT2#4ztl zdnTS9@*R|A+E3G*z`w@bw;eyL1^P#CbGI05{@_%r(A0m2PUhoyvXil3FG&R6-WVf! z>3`-MN?Dct{ZH4^L4tYD(c@RoA&^j(P(*8;$^#$6_ccC#5eYc*xYJ)4-#-9ueFP`} z41IhAulQVDN3lE|6x^wLR(R+>5u)vWPFx&Wbi$eRNEWTi@IR2~OR}S;&8tM)-BxO~ z5%s4Cb?~fZO6>xM-f8>msA^w96JgrplFuhkfvI?X*~^KO2PZBt@qU%wfAJt^(ds%u zK{&n(i0;N!Mu3p%?ut6rz2xN$lEu1b%+wH+R^`d|1=^q~nqN_(2#&$nK{tI%9)@(FJf zDDc=^td-Hr#me|2?Z5T$G9ZvFF8j4|OaxR1jJ3PB&ZeR|psiWu`b7`Sd?}uK9alht z{&UYM!$RNngD5mx7_qcv-6?jA#t3GLRd4SV*TjI)+VA}iVeoU< zQyO?pDlwPidEi^$nLF)`kWfvGq|cJC#zu9rvEy|4S8Qje%A7lrIEw4}?)ii8JckD~ zy+1^WgHzDe!C>wX6;h5;F3a2)aq}r`jGq7e)N{51MVAZ@Juh@*0PE3p^S^JF|AJlh zh=eKA*$#-@5>i&Mk*Qaci^izx(HIAj#EikUhxk791o7T{VCA0IJf3yOYFK-JrPAyZ+{I zcNQeaMO!EcNDo3LjC!O0Wvd9ZE_b~9y!rSAR*jB#=>C0J2LINb-e83{C-M2|jck(Q z?5j|?Z<$6je^e9oH=f4|g{2q4bxXqEQ+(%1|F0sNbgUigcz-zMV9iAbDZGlv@~kT@~*#{HvyG`*gvQee2qVs)z!7n&6L)vWlvOWu32E*yVgf@NoQi|7<7Y zA0%_vTGxv1Orv7&t&8LAryH>B@4LNTrNah=EatdF+tJ`$qzn+ zLeIu8Yq$PE1|+y&^?s_V&%qA&se(|e)7&sK7)vw#K^a)^qly8(}cm;B~t;y zU(eJ!X+(^mjq-X)oW;orT+RRcgz=`GJW_74eCdzZO?QXB{jM(W2)TVF2_p9S~lFqz|l|CNBKBx?PEeb#g2 z5OwCfV_R{x`qs5U@zO7x2!Y34{7!S}G?~Pk#<>$DwmHFW8Jf96<49!4G#w-_XO5@Z6v!AEPOF z-s>>6{}dR(;q#)0B(56lK$vHm!!OOn0JA0(H?7m|bwTm9=HiB1Mkh{vJ}7>i`28~C zNf*23_Xby?%*sX^!bd5OO9c0%_F&|!T#T-F!8dF@EO_Jcwdr{tOz~faJh-ziU-a-oa%b8@w|2I=GiJ8-010}jp*lj3{j`Bq4}ms3xs&q2z%$=F2>=d zhpEq&JhjlkLDlrdvAhz$Y3@%^)KVlt>Po0lRi|7s7ML%!aHcPY!(Coti!f+m3N4e| zAEvWp`tbh3JmXD2e{mFd_=qZhvfjbmPAhfNXao;f-ida+-#C>FGe>^c_QtJ3JaG9> zdxD(650`rEE{{sFtKqT$qf-zpm|*TR_heK&%^W+0-YGkGr#f)(?D3YBwO|trdla(g zWL(_=f2P!;!sja+&^YKSPCrk@2d@Ex`YJj0XiN~dWe3eAq=BsG*U{<=0x57A=Dj6< zx~T*H^fQh5N!QoWplm|iZCUyma<2w+=+{)u&|3NCQy9gNAUwZ?g&W9sl;Zb)fu!Sf zx<1JLnd@&=ATx~nw6#YT7PqbP%Qf8{*j62 zbTG(!C-lBW-vi0Z7GE~?5}T3uH`%`1=~Eh#4K?Y`Bya>nw2x4zMmM1sbgS1QYG)$* zVU^J2TVCHq0`rmuu(yq8AYwq@ZiD}(GzN-%t+?&`KB5Buu0E4krpIvmhr$i2y#Fv5 z@$5OvAx|r~{e0VS*3c~n@q;fi2EIKM1N+*;cOu#iv-?@}!5o`k8Z}z}Ox*wPJV68& zb~&l&%nHc6TIJ2W!dNrhYV`k`CGh#@H3SCZFR1sA0AJC zDSVW(ki`(;wQIhP{scJa@Xy5)%eyFV-|a|3^%QO-N^~0)3=d)VT~TMmjdDSpc|vnn zL-MKxo;p^f4YDc=6s-3H-@*4HNz@14wH$F?e#mc?NgWx9H~V zC%^;SqPut5_Tu!-X~lL}R|%do)T1Vs2L&&{>bQ^5VH{iMdS+!v@*=n`%iHYq}-jp2^KqY6g& zg?HKyri%8Vy~HQzxA^DJcv_MzL-u(;&mU*))txmsK96Fb-smfYk2LX{=h(SXnkY%k zTfLkV9unS0$Jm}xnFDP=g;HN=ME zw}j4Z+SjNNR*0;SbFC%6@gwQtNc0JLBW#f#YEAmf{vTfS zJdS%Nb>tzuuI!9ubN36vmZghyZowb~smqB!&mRix!HS-FezLsoV-zdS1f+xzJwk4# z<#fC9kQ79Zoj7>5#IFp4&o(~OQLy)-yev0}lDgfa@Yo8{5kLgdk@yPADQng|m5 z^-A!2b2V5m+2~un7;N3o8%_246Z~u7B6UkivMW4{UX~Cy;_f?6NMq2H?f;ujjr=_s819_nxGOInH%p2-z4k|)0K^K)Z-2!#LytCtG}(EbR6@~nRU zi+qzYyipGo=$WK@2;*<;=?(`(kK)R+92Vb)WL>De)7<-b)sF<{o#g~anJkGx==mr8 zhHpSONJQ%2amqgDKpov6_v;OVB6#@>c9T86ISI~siyG#%&wufc%R#xt%)1qjs4@=> zzRDOtsG+l5`savauD)#DXnQn z>lDZx8FBdht>X*6u7?l}1zfDb&6NN4#z)RXB9cwJ)28S?56aR%{X6=S`3hK>JT!Ql z)rFyot}vDFK6QANp?2h2soV|xDLcZiJNp^nEq9&Qm<$t#RF0vLy{KzG&Qg0n-CdA2%Da0Sp7fr$5>3-S8S8C{aiyfUD>me; z_{T~0M|Nnm1BO2v<8CW0PGV&;ig2m=+9R+jFVgtRiKZY|eAWF)OwJEv^3++6+I)YC zM|qz`y`t|o!pYfGlkr_41Gd>KA2b{$&W7K!u&%BNqt7Tk*`FS{QT_^gVPsy8&-Zr$ zgU&5BZtv@cxM9Bhx%)`l?tV94sz2XhFpBkKFS1ORdDS6FqR*siszr__Ue-?&pHyq1 z!`eOMWrzTf+i*=iEfj9U@O=B7kxSk;z;;{vpGJ=15~9X_Olsyt<)VCSS%5V2#b>m7 zg@)XqC$|JmUed9+ui8;y3|fBCo^oyfJpW-4{3t%6i1`D4&q1NHw{3rqL0nTaVnu8(O{6KxjV}A1)CS?iF2Cr>fz@#aWGQZR192O0v z{?JFuS3%wG;MK;?Y)OFNG9%@?0%>H|+x<1Dcpv}`?$!TdETkB4B8R~_m-A&B($1F@ zGDvyxps!9M@6oB2AvmqRo;=km?T5LemZ$v~w6;L)RQTV~vzvb)Ag;LN@J-zn(;+VF z1j2V-;Z^*AzSf(9d}O*<1fH*1dIwz{-@>mVvgG))lo}f!dUix$Shuc%ZJM(f> z-1ABm6nSr*Zc8;VL+VYH#Vzse1w7_7&lHcjb`I_wsXuMbQQXJbBKEq+1Lh`R$m>sH znoR76OZ1u9OUBW$NPVi$)ljWi4f%i>f{V|!7SP@-!FGsvq82qyWPgS9Ylks#JD#74 zY=QvYkqMT+$eGV#iebxC=7SJ9oMSyE-jpy_B6uY@GJsQS9mf?6lfKW7DB*XKMGcAV z?-E3F_eHd`lM$nQkn$iLZam{>q0xBf{nzCxUIEFh$*Kb>rG^*wXn^uM=v% z!UQY%$K{AIWl+dV%d9OE<-_Lg5qGVkebo7OTS{MgPU#+OWi75NF~omI+a)_bXBiTW>X45R19x+I~#-% z{qI5HDxK>9z6*s6ZT(6K!1dXgTH<$>D%icNv2&%uRuhMBRpc~3J8%^p$NrP6IbJu5 zr;<;1g6}x0;+p-1ZGEK;e>{18HCq0HF+X;E?$|h0(pEwz$6bl9^Xg$lHYGvGzL%RuIO>v2^n+h*14EY8>FdgzT%fyFa@x4;#wh%16331)^5)>8ZzwY*|G`Zx zJC>0iY7D-D+rLj+8U)hLz+ik}q<+ps077YSZJS1e|I4EH3b@v!PzveE!}%O%E>5 zP2^WSQCh``h@t5j?u=9jRL|b->5IPy_u4wDIYRF+$Y^y1Tu-v~fS;F(-ezE<6M79Y zYkPO^F~Kw>bncY%6l5HpYxzDS$Zf8$J+SO5t+*#P=D&` z^UX(r0*uLJe7`S>kAZs3m0hLonlXfv?y4WNa;Ha4pGdBRu-6sb`m;;9MDu_Vfj8)U zCe8X(9X)d^^PbPK^jueEp#W zntLOtS$x{HN}JY!rFV^|iWUT-Fxi$z61*8PgD19UO!pp@(ZaGz@IkD}axoYr2eRp@ zKc&FejF3i@OXMi>)_>54k23F1KchV(h40&AxM#!BUn;l{QEQnwJjipqZ=o$w<{zcP zx1;EN|4uhOkU$y@^!Go6dKJ^+mi_H>JJ&sWKyAj{Bxe$l3e)f}A~KKmjQ6p(;0rcG z9|a^Hy_8n|`==bl87kY=rb|y@guV9oY4#JJ@TX9`=_JEeGPWsZOw$x}fdX?SB0pc@ zWDFQ(UMuRFNr#cN%OCy^kHau~BMd z3e)oFTkiaqZa_$4zOjU*;u2=w?pU%H-)up+kZmoqRM#VfBQtkcc~cp?m*0kecRrmA zW*PZsmn>ohG2P(I^ZXli6=EcEmi+?-+HgoKn2zDdBphqn$qd)aD+^o(awT-{+p_-nuc@K8CZ^4xT|{R)OVyrO*fgAaJ$0z^=4dWE4n$lCve zQJFTop@LT5H}%S;^Qdm-SCAY3IR())(@_%5cZqP(UKTnQIlc;#SSuJ9>}P-&<6R^4 ztQeypMqNzCrCJJSB4k%58)r){Og z4iVg)Z;t%=&oKzUxspwE?kblc-y-+;&-GR@5LGiY?gU)!gd_8au%Yig3X~AEe!l!n zauc6z^`6vot+?R*ZG*g5vxR%uT4W!)CFW9!@~7dyQ|jafFu~BdF=>ez$uyJaj4hh=ra=-WRjv=N{Jt0&KbSq?gn!=P>RM9a$- zUle3?pZsHh>tH=cU;MYG)(2h$>YEoX4SdIsyz@3MWB$;hzn5oEu#ese8^O&YJ1+!t zV44jP5qKQGn(ltckb%x!tA6dug}=hbT3=(vTMZH{!7%SVPF z6#nJJ-eN&B{uFT%};v0Hr#0<)^d^X(U2GhEkvEM_Nc>ERiOKkBskVTd6>@9lUJQw&r z3{BT6f2odGN@qGP#+$te$Vn=QiFhRo?U_DZ-&?un=sRV8ufvG<4Ia@3sHYZAr(jn& zEm_Vgxf$Q6UiEs;A9KS(fL*=?0fQdi@n_AsMpHP!Q8Rav+VE2Pgn(nhz>u$iH46p<;aQ9q*snlsd$_s&@v;GAIVo)| zstb84+yu~+=ByWtkysoYBVdvYNmpp|E`8zq*VWnk*;U? z9-{x5;$ejEk@rzwM8GU$nykW>7zSHsofB49vPR58<|f+ zR{BPcg0c1)QS1Y6ZWyc8FOsS2ora}A+SurkWmkB-XpFkLS#Jf$xefo&&iH?L*Kt0e zmFt)N2LU;uhb9AwfgOw?5-X9W0ORSgL;JA;H1S&~9$VgLm4{tC z3L;?>F~nIk{@WgcT-;}uwSo+wBqgM>l4#hEf*(fN>I(UWTp1!SCsQ{ zKiQYeCg2bYkKLV&wdmvlJZAj;FM2}r99CYr5cbOj--3HqwWfD+TQC~SetENc-1!Hx zf=Y84%TG7q6k|nmHKt+-jJ7OP zYtQ4UCAVOuc)>x5iQ5r0o(7GFhAulIO|<6E@3?Ym*k>iCJTIsJf@fVej0SlYCHpT{ zuKH@2nc>NV31@)Q$X~p>=b1fkcUc~#wFyyDALlZ`z;@saLrzycwIKdY6zBv+MX}(B!5ec$9;0bE+YnJD9J=xp{87FG zUuWc6V6~Z1$3CYo40>W~?Mfjn4SeMrxJ=N~2uK?7-DMDQy#vC(m~GG2vIUEW-ISc| zkP>`YgUd~9_i^9z9TW2NHlm+kXU5rM*Q!a3L5{PBm0HevB5%fgi$hXM8I;7v2jwj6 zAHX?P_sHS7K0Dl(rlG$!FIxk>+B{E5QmbSfQ}?lyww$L!uVP2f)!Uo-xDi6-+uJGH z1Su_zY=*YaPDo%ixXc`vSc6l;nY{(}e9I7btPA_gpg{=3V-q`0xBfAs@%B?=i&ve= zXm4=XyizUBfwS5YKC2gB-bNO~LUwl_kvK-C;zpd9IjNu%wO2zvuagGf-cES$-^7OPUUwX zsTO~skv{k}tf};!_B6EUF&T07ea$7Um-rRN_nuZOg#h~NMbbGkf>{VzCA0b1#qSR` zFSoCK{pE)tF3b7wueQWTydNc2Q`)?0gm0lvgpaK%x)7dqTrI+{Xd9ciZngw@3>Tuh zq9Y=uz4R;2Tz^Zx8~SVqv^MuRjR|#w5y7GPj{bRUAufe`^4M!mY9T%R6Cv`;wsBD} z`Z8@|LOMptuF@&qSd9i@wsNxmp7C?2pB;AzQ^kx=xL_w*x;?*p_M)AH7Ew-JJ@a11o{(nq)ue%2l-l`*p5Xy`xnjkX%^k zi?!|lgnyk_v52%^!;w36C06(K_wCZg1!k%7M&^{_p{(4R`)NP&5s0afYZXL87mBKtb1_xBJ&oG(vM zt%3ldn?eoo-CpRlRZw<=IPvS@X7 zEXqbiS$>wwYo8vRPfQe22;kP~`r5nYgO8#;mqZx%qsSR?3ZmLz zB>^pev8^>+%Tu2WWISe%PlvmXJ@=-Y#+Td&G@nJApX0>^)n_z+ zkMe=_x}E2R(=JpXXQgM3PE{2K10OY)u-U7}kUU&dy29kAhIg(}BPZUkZXtE8uK%L{ zLw!uXB1G!@!K=8oOWe>qEo}N7LY~20;la;~ ze~}k>?Oz%zK1^ZI=nCYM< zJCe>mO1H0Uw8o`xWAxsda(+&r4Ji+8tWj;ps<-5IfKK&O$N#{L5-zUDUaWZrPoW(A~U) zwZ`bbx-T|fgGcQ7;Kz^$3W#Q~5??yg$^%Y0xsx5w&kRR7 zyfk;1+alZ^eyjJ^|1;nHfRyT;#{vSyUl8ePr1?iY;~1K6TKI!1z9rwf5Wy*&hQAUoQ$~6exltDF{xV?Pco_5#QWa>p_sNpTykS6Qn^7R1 zzFFTWoh*rg_N`|YAL&!?!(@v5vb*)*O^Au`v$|yrcp~Vn_yN6hQVIBe@$;1vQ0;-E zJM%T8asEcMtj44e{TCFCV|i8T&pjRz;)LCI(~?}_UM%&wKRUfE{RW4geHHyAMWqXw zjA)|dw|s)A4=%{q?6Fou^=pRV7wI`xTKTUw^f7w9=~I&bw;+4yFaX zd@jePba3uP;pVmH3iYu1AaI}hw)Gl#?Lrmo-U{#{(zN)NSe8~f`cIhNDOA394ScR$ z_wsfb-JoVF&KJsYSrtpC16X3B{KRpKx-(7x_TD&}WZ6Uw2;!CZF$&Cr~BEa@o=trknCj{^dv-$ z5k`9aLM%Xy$RE>?V?_b3<~;!l$XA7dUqc+V1AJBQyh)kVca?Q*tyc~NdX4zg#`~1 zXb@qf_F~?yNlHD8csfr%xu7TwJ`(q{pWNuh@Z}lQ8;;&!!S?hO3t1x;Z4?T;xWxLx zumb@F+w<2syN!`BW#AyS+0YCMT8>5&{_!)=ennw6k$Tbz%NZqc1zFiA;3D+hXQO-7 z2k{2NHc7%Sf-v!$cWOsVCIo*RX(H3F^$KA)<=ggyx%dDi4BJW&PxBIjp}MdwqOYkL zovlr5n^pCmcy=+t*o1B52n4jUh6@eWzkob_QR9T5sS>_g=un4?9{qu@l|lIv8w-C? z@jh>oOJZsT(R7Yib-MD-BZ)_vaX2bG0>27oNLY%QZec+=U6|qa;bV9?+Bd5@bMhKe z66j`BbB?m((Qa9FM#wM|q)N{etcWVjLDDz>CcBC86Z|@uXK;(!oES`YCZF^-Xx2a| zThi6S8h!_^BMxSMNtO383F_i6et`&OmpQ=Wxy!hF@6qLZb`3f#e|LGg16{9<*)l{{)4P>&E6inAimx* z)=l=l$cvgL-JRwW3>rwLV(p+`i3@`D&R@HMR`LIkUmSe**W8Pz7!9=fMp%AH07i7@ zRq}7`>%21EXQ^go&CNI}y+OzIe{_Ww^crc~Px|SU@O|!s zqid}i0hl)9^(Y=s{ezaupHl+~@7Iv~%T_%9zlAZlW-h*R&G{7y4wC@F&z(zo2zc=D75h%9%sveh@emHUfJUnI$31T4Sd~v66{&xkmGFUq%y*55-a? zL=!hbfZTJ}ZaUEi?*a(}&57C1z?=g1+DiJoB|r zltmHO#S&+1wbDN0{WWY1AC4%7z$bIsmw(gu>;K6l|HA(?Op&O#72rGnnGFYojV=!X^hU2FY6xz4wQmYYg@~hPGxmeL2PHdV3BqfQwC;QZ6Oq^MhlaJ2Q2p0OG1p+F zhWkH)Muj&%QR2iv+jMzzZXyEDXUGXxBpikO zJRRnwDrrw0K*>5+u!f|pBH+VVaqaI0cL>Buwa>>evEjL+u(iB(V+>yKu>{sH9Wq9j z;6t4_vFcG6J8Z9Xs)%u5E!pz)`F@#AbkRBpcWXCm!*9i9VeYj24E`Reia7e8>n&uH zM>mw+46lQ|lEA&r#LYz<9=Tq_*mM0A21uk?SB@TcgSoGx6er1EK3q5buABK7{f7^H z{a>FeDx62>A(3b5A9tD1-A?n+RZnLCXNC2LUdtE9U`;czd+t>37i8EQTpXgSJA!t? zv0jVk6=Y~{Pp0I5H!lRSucV4hDv7}u=8VyLKjftnf@o%HT#?T@F+RcP}Nxshcah2)0Pxq;y<39;1#JQMEOP3}HSP53M4wiu9OK9pxaJ6a3*YioE@}vbScp?Y zq488TCTt5eZOm(_K-91{5kRN7ib)-E@~*X8?I0B5a%L>RBrb?u;6G0=6OZ`!8m6_; z0r&Q`&a>>u-#Zdu+K6`03yyHY>?dwx4v%eL`D!(`wSk+1)3%HaO6O6W67 zVI2AbR(FzVBLc9pz`vcWtx%4K)w4TUAJf#4S20!n>%8&{SW9H5x_|Pjh2+p*iCr~S~da+s@>Df{0y=xqRaj6yF_u@Q?U@LH#{m)tcAM7sVkRAKR;{Y|!if_)>)V#55 z{=U7dyjcWy^d55%#Zu+t2i?>tZ3d?aTDUIgU%MJt3QFHoce`@JcyKc7CQrLj_Xljf zR~0Mq+dZ&9$JV{CcC&|o=kI(LXKA`L(pyFTc2>j|<6T7?6 z&zCkj4zHOGh&Q}dnL}Lwh)Hz`c2OEBw8tg#jujl{!wUDmO1^^uPdB|sY0nX4>3r!g zE_O)98P1M?pC@F65LB)Ioayw$7x>*{OLDz$9F4Z9i|n(#-j!IfT9@Cjy<7^eYqU8% zO((q3&31#1wQ}JOG*4O6%vejR;%$gBd*ijF1-#OoS5PeO|A&W6e_9PoD$c{#a_k51 zt7;wycBvkiE2+MNjO_Edd4xvx(D7FN8Fst#GSoGl{<<&RtVKv7!_z;JkKywU};M=%)$A-f3G?AtgY}MkaKf$dpJlGMIqkF^fhO-aZiP0_L186aqt(+eeUy~cn1siWX7A> zWCb|%=tQE9&FV`W_)q$o-NQwCP+K}gc+l*W?3=awkGURPP6y%Hk^{HDKe0h{wT=Gx zR@DGVkDi-vyfa^k0)j7YPdcg(K<-;Ko!^7TBEaU4$^n-1KaoO|NVf8dabLfV_MAL9 zRG11aDc{4-V{7(}yeSX+s~ARI+?nTf8$bC@0}Ds)I|gvm{e?z3D_!SQXCSV42X?8% z@_a$#<;?T?s$Q-L=O_HPx3kBF*vFK`BS#;yLc{55*q4-Q0n9r@Fo*d{5bpyalAkZL z!Q`EpANk^ zd-mkr^zf4B2%cj-p;Wp5aNlZwNxbUv{RQYggl(Q@S5X4HTaC5W{Od|wFS5TL!f;s@ zQMMEh;wB|kAo`0vaqmY{_)bs)iYCdtH;0L@pub3wh^RlL3ImEH?=u2O4?&;!)R(TQmR~rv6HO21mLg<} zyTw(No+JSWqfp6D$^m1j(sg*BbEa|vk5X;VPQC&?vbo;GI9r4%!!K0%kl%J#60G0+ zavv)w>D%qtTi`4z`VWa2RA=|4y@brRc1BTMg z)XsF;1&-gB$w&}V)As30!r37Fw=~sG zc7N?NC|)ko*MHV;f<*sCjmiwUOiV}&^6<;;$5eyn^NtH`jVVy-GbGFTWaJ6rvv(E* z&Y!#ow`a6%(>6NJC~6EhVxRaY1#OG#dtI55chSN!Wy&PXON;!BjDizew>{ADJgDk| zRR0;6#T|O;vH0&T)Tem#sst#cK|6lw08Bn5VwW{mM7k+L1%<4+f_%xv6o?r$Wo#2D zF9Lri?E(Fa)=pfT$$l_t;kAK<=l#Zx?)e-bymIJ5gKJ_Qo*iS!SI|C@hC^w=Pedk~ zU!XDGi}qB{ou`;de#0x%bi4!q&VA?*j1RTN%Of#&q{?G|U_ttACHKh-B51uTyLp%| zNE!1ZPfdii`wekMvDa-lWq(R6Wk0%4BpJ4Y$Jw&PU(;jUkS9?WMPYBP3}5}blq&bx zD{-$jP?!5cRxCJKraGCX_yllsVRens%V!Sn1bf7iZ)mh)@0&{QN^|@!{$+)D6@Ss^ zhEzp?x`v&94jy%6N@qv}9Kx+@;nP8NKVD!L2MM~jI~O3e#dI^d=CdA*9*bQ&%KPdh zYDfOo7Te9`AF#OhjZ6Hv?7mU8sn}~`Et192ft-j1(NY`Wr!(Vkc9CQ6dv2xURh>3rfxuHaI0aClnjb{|d??TsP@6oT8S*`uXdW@m3TzVWX zX73Ivn@4eDyX9ZZKm_Acj7Y`2lUsfN7N>HG`HrzCxT4?c`tm$WB|Th}9#`@FGc3ar zOL*74ipSj$+K}WYt=6YSS;En!;1dT=!EjgN3&W%M3K%+RmYrw}_=Hzu-P$YMmCG>r z;Z|m+UPuNj+wmTPy_g$FSaio7M}1o~54WmHJ!8tn2jlfA#!*jF=zfSzNJ}kx3O74~ zjbmFxr(k3-bK}-feG?jZq=(A>epdzAi>Fsx9v|Ss;JqWEZRrY`D4jjs;pijPj;%`H zBhhr?+^CiaFTA{ToeEkx$GrxtZ&<>6tEMP%^RNa^)16?tIUIQmGLO4P>${xz(4BLn z)j(Qf1;Tqjd%loe{|WVEmRkA466a8KegBL z)~#CJ&3*h)*y<`gu5;Cd4ssWK=(qEjl&}-)U-h5D!w6Vzy1UQqw{lGLcnY3K;!}l* z^JT}|S-RBVKCmm)W1}#GHLod2F(dx~_@_L*_~*l%8$K1#9#Cu5Nye$0*P0c|=_eqm z*-dcF@a-GOm-EaY=rR><(n}f!@I5Y~zgHHi&!zP22yQCWg89 z*Z-LGT@QfIuqWN_KiOMw^SKtV@RMr~!etVxH^0Aqh|>hCf(y3u5x7cpt;Ii3z7?D= zMa)=)i&GGje4voh*r5X%alao0H$6{4DKBHrwSd--$P-y2rx%KkL3o1XtZ8*YI*#P( z-Evx7jDGf3WS zIud^Mi-xR1X0}`Ew|Veg4~ll(MiM|s3j~m z(z)Q&1$zR{s`yXMGC2FY)`^St?i%{;er2-s_WOZ{E&`S;$?U#}N{RL`DYblri?5%% z#@Se;BJPd8=Jv}L;I{AB?6-qIUgBFq+lP#Xw$o_1Nmi;;xe|?GF{ZQZL4rz{?re#s zFl^{RcAt73ugoPUjGnoEg88AxQLK+Y>VE(xK-s^`uy_ih+zyr-bUYEzDj)QJ^Iv*8 zy3-d$3hq#ZquopTy8VosDSG1zedu2MTtfE?3ey)a|N8*zj3YjTX=(p2SzEaiLm6^V zwe9|D#I^bsYIi3yw&@Rsg6qxdsT>83Dzy1*9pG#F5(fM1IRzQT#U*TfV2*E88T^0? zN2MK~ojr5}x`FymmsPhn5K_Ox{+CPg6-e*P+x*^H>V(+ix1%FBcq^die2`Pz)m{Zl zH@H)B%5|GzL+(pAnEIv~dIt-rEd8cLF-ke&!!>`92|mhiFMZFt@euyLe2N+gGf&Z_ zRz%M9nP&kvCOc*L-hS~yb*lN$-=?KKkkCr7Pw+f5duH>dAX&m;d3) zVO`RDzY?VJfl!Ot~4oVMZ??s3nyMd zB;n)PgHQSqEj65ew2`0}JmtK+V_RMa!O|eL**LMEE+ikaA21Ztc?PDshl7PI#{Xci z-0-#hHTM>x9t2r-vrva&$NRcC&D)|{eB6q2v`X8F#O}BcjnRPT3PPUzRH`b}AH((k zI5=HPpE&oMNF zA2n=$>GT_92_meNUSk(A9WR(A{HERq=d5K$tQX9m!&NHtGWQ9_Ev$C^NK5?M$&R9* zf1)@dWK@u-&DZQQYRQ3}*>H8s#q@IU@+n;Tyqt6da--F(6P)uBcz*eiI7z?G8pPK> zbx_r(`{89{?4#q7gJw8NR$^cBDd83#8lDm-?eQW7NlGD|&=k`iRJ_c{3KI>;CKOnA^0nCmxt;`F=TcM)hks(HYjj+O*@dj_yG0SAFqUo^2dQKfLKTVencOhj9nCFeMS|A?c_&C z33ttkaCzLc-EB-%6;EyoxBYaMe~r5Cdd2Cu*=_Wk8d2u>c=aVnlA4z9XAbd#L(zwS zl4WWa=04ifC)&x$K&DI9C%(xSi&Ny(L#2F&wqP)-Xt5uHAK|`2EibzpB@c2D8`pb; z)fxm3+1|>p1~{XAWm@#PPA&nieczK7m+nl&CoXt_RCs(pM}GyE=n{e-)g#*Dj~=3A^#C(srz`+LY%sC)4UvY z4*nknFX&RhXmid>^_Bk!<}`y(ZftaOBdV}l{!^Px3?`41Q8N8GLuRuLk5iLorQYZ>PB z2jW$d?Ihw(KwZEgM(`4i1(_;!cmcFf3~^j?k;Qv z*0)O>zpkP{k;B8#Va-H{SM@@e#{EOeer{+8f`-)q4e#u$F1+Q@ z(ya>R|2PD6r-q7|HAKQJ8KMOlM^8p+9bu7jvc_LNMV9$2m2TVij9NL`5CvNYBI}eH}Sm~K10_( zm|_1P%g=L_Z?oaHgy$yTmmH#@bc_qI9XsM|SBpRY&ZeCS>->Y_NkR&STKY8ziill$ z^IrNibU!^T21UCxdc1Ak9yh*R4emYfgQZEoI8d!ecv4Ng><>ar@2p4O3vR(5A(IiM zriC)ttnCPp{G2%n9|v+Ro9N76m|i+5V=rp!hve$4+X3I)MsdrHE8FCc!32i3XGo;& zCC72~$cxZ3ua8XPvz7Vg<19*B_?(|#8ocjTkMCdW1^>zz_d}hc#Iwm*{3<+GeYs9( zhM&My+Ys$EgFEcFZ$tec>38!#cK%mo(pXtTT9rzp{Wu%__1%DNKxvNT*{#CMaSw>;1 zA@<+e7?sxCHH;l_einEpssnVTmY1uZ{=9}9p`o1z-+9RpEKBg$l-uDI(iqQG|Giqn zf=BGfzCODn<-Q+f#R|?ZT=j;Nuf7{ z)he2Gy?UNNIAxwG+irX@geH5(;Lom!Ca{Q9G*?aTJ&qCU%W~Tcd{GE{t;R?6m#`IM zU)5_e{8fz5@pE8MlG-Q$o+E@x3$*cEaMh_~brLF52f0pI5@~$YF}(lN==s6-pglPK z2|P$no;!nJb)M&@<@EIk_)_IWud+#qkOorDN2fNUpssB4^+xT%Au!16#8KT+X~9+5 z=4eOfFQj-AzE^*v>hXSg6nPfz&n*2I-yG<(=i;qJu`6`=MH1};ZA>I`cF5U03B$jw zI~tWO^;~dc;21bQzsin@51ilg z`CTftSr7|94+XP5*%^gN;$G?eBiGX)u@2~L*G(9~U#HJT;b)!L;n1D=_DML=HmI%k zKIeuT$)hd4n4x~{wK;AvZsj{UxFy51oW;G}Z)_YdPd^v=M*heh1UNNw+>ZbJ_!)A)2At#AnQexgP(px1wBi%A^UZ2Utxep-;9@#0O}fD-%1=Mf zS$lo^Hp0C=RUQ8Ja{^(J?G$ZoL&tDAe8wlppJ*J*MVe#wnKz~IAe&3{Yg`>Ae(JF} z9oll}20Pu|_wt9{d0=N`Z2UJ_aRB^G$9tMu#%{ylL*qfRiZ)7^yA%XBSJ&)d+Sh_h zav{VSsU-ek_tkB4@MBZy-6!UPTlnO5N-W7kc>)Rlr6J3?Y zHc~TCoF?`Y;`&B}9R)e>D;IQVLEoLs(H>L$0^GE*&ok4`)x$4LFXd3B>oM%@2`aAT zo1aHbeE*|9Q+Zif9Lg3f%Wb-h>wY)3nJR8-;vltP&v6qcHgHtWSCFm$bi_vsPR@jd z$o)0(BkYe{w4MZ3^wfhK3ZLoYX2*mN&uyi2%s>2isUk-}2PVwIc~=7|4#S!L=NtMD z)se9L`H60(BA^XralyJZDyrV_K(n2snQ;~F_Ay*gYN zg};+klSsB&IZih|;ZX2=nF5~;1^L-ud|9x$sm!i&mP!hbt;{`MiFBRCzq#V?!ShE= zG4eetrCR@W2^7h9JuLIGc+jSLsHGz7*Gr7woM%0BYTxG`sc3ysTTiZx*N*Ys&sv`U zhTW`24fli2bKoCvpf?V9ore*(fvlM16MXm(z;Z{{Ls%C_Dl-Y_9{_g=? zzw*FXKjK9?lJw)Y-v<(3!{|RXJ;98Bk+5f8r=IAmJC9ZJu&f%_P6cE{_p%myERkYe z%QIw0K+I8O()1fvPf9|UJ?Yr_GUd@D1p6`mCgtn}#YXombHj1-& zFYnd*yW;`ze_ktNLg6ssI%7TYeie8qVR44b#yT7azx>i*f8NWE?44_KG|qtmsGTlK zUD^C`1OviB1BAPqg5VqecI|sZo-$fIZz#@0Df{5Gb=fXK`S(LGjb=E(UB6I>8MW(Q zVhldFVg87R#+&HeZa7H?$yVpvL@m@uo0}_uB~hSmtdI`bdA3 zFzuNFQ)uoxfgS@(R1WKUKdmGA4mvBR_qDflw(yMAD~Udbg$A>Ep$4~y&*1 z*^mZgl&|*P_`ng33@K{AJH=*=DD?{>ihMiO3Fp@L#HMFRL!e6RbLiXN&10A}Uy@3` zGPwk<5DEGFdH*`FNt|%x355YA?0Xp^#oi=e!=bt%Ir&Y~9oQUq54*Cf;07MYXn%1z znKC>$xAoX9QGNv1TzPH32~wD0Q?Tkk%-Hzi%1u>5L*iX+kQ-|seBHi3!-tA4P<$M` za|PBOa<2*lADQCe*c4ZSX2T2!O+RG3zSppVfg75B_iK#HuuH13uzW)39p2p28y-Gz z_&k<-JHr}O4^3fsPWran4anZhp5P6+(MaaQk3|eUD6`O?k zA>PK-Ox|yQe@*=?rc^!ipAoh;Puv@&c&m-hucmE#=dL6`u2e_TCo8o9k3HQ^Ek4_B z#NbAPkjUYpGqC-7>L1tbQF4U8npyVoh<^&M_08;`95sHh4Ocw;J8ee_pLd!_2k03F zV0pmwiN*DUBe<*bUlUWkR~^hUYnHW|b~KP@%bwK!?f=u}xN}zcVM>SnlwwG?7h67q zb5;o-mGyMe@q#``{sUWzC$za1pJXLIy^rs;I$t?#D0U%XtxS})#z=?=?#0D-0uAao z|J3Jl&u9r5uH{Gv_*w0jhy!cK@@)i&nK5=~g;r4H0tdQ<6qro`f}x02$UR_$w+_l1CKKPajhkl>wr&GZ`0oWP(IkN_Jw{WZI#7s zOWi^X_Hl2d{`cX(s2M>@4A@n;D|>v*#zF}J+8TaZ;Y;O8?6^)#BaBNGUuk$hF~ohA zch;bBX92PPrb~;Gnksz)DnLzP&^#C+#qrb;$&m_89dAp$jP_k zUeOv<1J{L+{=28Cf8eWfM4GA$$tKJK6-9ktwHjkdaK$UK;o$*HMUwdx(EX*te-gBb z8{_-wE4`B}?ylY!ComlPymumm?;qj@&GX8u#hGD$_r`yj#MF~;*6bn-8nF;U%!hjd z-rx5(e)`hATZ_DgtXMnbrdL zTYOh}5p#TzPMl!kJHEFRt{R0|Fp><62Y)-Q66p{-4B~F;-l3-D5as1zC zDId(ge+kQ6)$YdB`~=g6`;%|D+dLeb+@W+Hf$bev$Fm)*kdYW5L(08)7GWowwJAnv zG9i$9Ds;BelLtzLAs;!-d#f;?@u-YwvQ!-A6jeK{pxgbswsk&t>6lydWkq3)YTsM z{PyOO!$C7n!^wFCEU;di?9dEf(8;E15vk5}7UXYeapDC4R4 zrW@jh@)ZAjzC*h|0ajaI2MRfXWrpv)!CgjH{5YY_hK80j5I!q;&S<9}4!{0iW|wlv zcJNzw=tpJP_YpV*5!qa2Jm!cRw{xbQv`lTFKCJsNX5NIB5@Uk+s3I|$=>D^Ss_fTq0@-31;R}c!L=bS`UK37BG zznZJdONyV+?|0=WQEC|lHWS=iJ{&TL!e%FkJ`l-nTPC+(J-ly*v8qyjERvCEuB;?O%S|EXlQTA;ffE$ zjd{T*MEtPccRHg^uyqL`FUs6hgKz(VC;i=bPl|8&&ZX{yvKG z{l8ywLrUYFHc?XZP4oo$rDhqm@gUmXfFFl6USVB`Ds)?>?-X#zVKv*X`z!KwzmrS4 zmJNWXPTrjD>qr2&=Y7@xIPZny^YP~=)8zcg;H`3#NMXuM2QG|7_UF#!X@6R!{cw=9T6p!-#cs& zPlfNd%~t5dw20h4RKE;!!{e}J-JU~uKbsY=!i{Yj$o!Zvn36X3WbQ&R+>d-ZysNB{ z0-@fUN9*b1xZqOh!BR2G{thfIjLuh|T=%n#c0uAA z$Y+lerH5;~!|h^O%kww|EwI(x=3u$)q6-r_2I`6(AuTLl8?KUI zQOlWM|6cJ7fc6Wk*=CABEOyprrR5Io#{;JTv1|7Pb#`%!>8RDo;4=|OZ~Ml+tZ2N? zpHB;KyM)>uL5BauzzRivesDfM^L}()F9^QzS0C#9eBzDKvpuetKL&3?Y0~hsOW=JD zY}cOju#cV_1kZ=xuW8)GC-6XaO`Y!{Qy`Lhk4@ai9mc=n z@8FSOrgL|>A^-dWvs;I=2Yh0Ldya3=b|NBPdRV~Y)hVdxO!$&>?|*0I295)M_t!n} z{U29q-rbUIeD4sGHnC+X2A8zLvqCAqD14MNkT~&!-4JH#bul5L#d~OxU&s)#k;=s< zYa7%5!ZRO3Wc6T>=;I1Uh?1}Ec*snQLZ(0NgnK0cJ$julx%+%S)s0_^Z@qk{i}LZ} zzfR5L{?_bhn&;y*H~P(nksxK^@gVL+OfU1lr&sMUL)CW6%E9gvL9iGLI^nr7CJwGT zbDm`t{rg@?j&kYWa!=}1s$z7 zL2fF230Q3&gjHEz@4zx$GNoGfeqFLS_Qr}M@&*YcMmhg|SK;^xhdZsaIyNpLaHe>5 zfhaWB1Ahv9+bv#P+rdi7?*rvz|CwPxBvh1!iMrzGB1&m7fKf-;y zqMNZBLJoebw>%i%<3)>~UEZ;QP0-8Dz59Llniq~Wl2%DEQ}M$#)XDbh6zvmaM?L&y z`MBW_zV}V|AoaQ{E{S;FufC*}3t!SV+#zcd142* z9&Y25h{O~w}y5M|TU3Cv_yB5z(Ql}UzX10OrNgImvY z(m;suK(wx{>@K3zBq`Q>IJm)Jo)}up9`y$z{Ii~8oj32Gr6R&2T4|~t#U3A_6%wfg z9?o4FYPHBKXeblT5jLiN1)bW)Jm0m>Z`hJ7aTa)?)QsSt5K?YJ)dy^&8ByX=$e#-n~bp*~x+f(*1k*a^SjUZ`7|YbiKYx zJhN`U4AH(WLx$c*Uf|$7`J3y--E0({Fg2@q>uiTfLn3mL#Q*zUXBZqjaTW}ITS&qXuRu$Uj z$LaB!rkqJGvh)Dll}EezY$I*qWw%S_8cJ1y9|QH%97l~S;k=hypB5x2i*KYnazZDx zH_@F~V6>Y3)g4M3e<#>`rkX)&X%-W`)uqG9OSZK0-8P^bq*Xq?i|XWqv# z$r3s{I>#G_2|3Q00_Cbts3u8h|MRVEYWe_=)? zcTl%+V}c@XAJ5NG`*=AAC&>ePFVrvw!0~B=j%f?wG(_VBzMb`YND0*^*AhMHo804M;od@M0)BjU)&K5fnglW z^yjTjE8I2~KTVazErS9k`P;iGK|PT6k9;4H)cO{D-g8%>UKQ&c&WpqXrch=YoC7c?j}t`<$B}5mgxh11jvPmMrfSS z!uB!x;fp$wOR%QmY7O~VvV#i8#wTCf_IG6hnXT*c@IVfFk8(MTs$Z4FLauORxbRpv zTfc#CG@Ry_eoyflU%2OqlixZeWB{qKH=(~>ydUf(X|+moYaazYSTBL%QhAs& z_?|Fmywp@z2K}B2hmC)cAM|P!WV7nCLeZLe`62;tToCTn^75=4RaQaw1vZTvLa|HO zy{j4VDBa>D!bvS@j7wcYapl|%=875-4ZM+fdF|1e84c*$PR4nwI~XJ7P@v}Ti({Iw zNGx$6m%P-6?>9z2Q8cBd;!ka_cKMQ!k z{<*{UkX~so?k~(FTM~Xa4G!kr9+ovh1qg9y(FuLOeHz?z?F`y72VLP*MnL(I_B8=s z2Y;ANer276=^6jq6epvKusHA`gp&Rn0ap0R$X5$r$|LK?2@ayzQEN~MR9`rJjEWG< z4Lg;^|2eFHN{afMs(;`w6bC+(jR?sfg==fAhU9zwMVN+~j>RoYZ{fhV$u`M{Dw{a< zREwTW^g}5UD>7-l1wK?kTjLLfgU#g)v+&!`~;zL zvUg`_UJceV&1AO+kE`QOd{KJcCpKji(}}5EUpW09HeWv%{U`cJ3tNeMzC!nI{6mt& zoof0z#t)b;p>fW+RUL(s_P1#h`+k;VXL8PaJ><6xY|~v!TVk0RQ4+WP^Ty`W%UG%C zGc)QrB#uXyBfc{|$aoLG7+~sq?VQ57Pqsz> zb0<2LcXpqCM?NCrY;Xf%`6v2NO%F;mCoR#On4&ua@vme$mM5Cs@mJqYv}Na{7fgnQ z8pb4Ll99^H$sNbAM}w(N%FXlDLTWI~Qhj{PVZjtKVySaE#?V3Z2g@B&&uMG4Glf?# z-g3Ty$Ll30KNnTzBVjpvLYMTOGu8_4tY-2z8R4F({gbJM!M~_{G4%LjPggxk`1l3W zUxn2}*-b(G;b!zaoePuL2>wu*&W$5GZcLN^ZpnC-wY;&$$dk3UF31?_C=Z> zUdKI9esXB&@Iu}uiWk4|Zd@(f!=#!2WA;5;7F;T6c~w_4{Qzw;^)qJ7(fg!u{i?V0@71LJnF^F5G6i*y@0uVa5)X z=ng%+6Of@1kb541+PKDEeK zHjW{3^?Cyx&Q+n=O@w^Zt92PWT|!d6ioa5^SX?vf{hd(@*NC}3Ih1JE;l#lSx}lr~ zN4WNoG+&$9NkU|Cdo1A(KUIVY?}{EN5Su_Ji)`QnQtf&SL@`U<6h0J)!$;HflzR%_ zVz0rb*6-gpN!%LX`fW@$&5hTEu0F2>NZKG3?>}|R*0Txx#Im7ITce%$li%{|v_z~8 zT=i@f9(xg;+kf2B*XsrLvQXpa{5p+snGhaYSH9~$-Dj{4NvDU%Ke!!&>kwNMjiVnq zWbQ;Q`zquU;EPjHPa9s6V^D0#S2NTj28Z>!J6ejqJizr0i(=_&+WlZp`ON3)#2scd z+_kF}`&qL8lTGeu_#7KLf?p@1f?4C)#K7H2Ak~QyI;2QzZaFcJ04Y8Vs~JxZ^yBsw z!VllP+8kh;pqf0%{Gt`sj+u=~f1>;Xqt!_rOrj*Q@n$o0~E-zgNFRT=J!ubM(da>%)kmSeion3=Go*tgS+|@N)ungakJ%2Sybb zDKviXHgtCz>4lM6!|`dh_lMBeZvOV;&HXBLTKA%LOV0ftpeeLu_WdmK3^i43ulMMR z`S9c4e4Uz3^IKFO{KyfU_HhENZw=lyZ8KV;=f-yYIY~5A;L3ddl0S+S zsv5-FcFf(P=rgE#_-M#>7(o%iu}z=7m(X~{vFo|7p%b2su9$8qS}5$tZ~1E>nOiAf zG{`0&B_X(o&F#PoC)q+yU^#E_%g!-*CQ#=kKljb^3c}Sqn#T{-ACIHadd%_8-7$Z- zRe)c)j={M( z6$@0RHG#|89cLH!1TxHxaqkGEDzv~%F2(EQ1-?6I?GMtXB}^s3dl7C5skmdCXo^?J zKCAQN2uRr~ei$Emp@vf?eLvK43ag;0!6`~a_sI~>qH(&{NzdLxfqQ;2Q`3WgD4@H0 zuQzAkwVfp?Ah{{IZ34QU7qVaTFZMvMSuLcF{n-w><}}qyc;kKSATD*b&Im_L-G(D&e^|`8t{XaIt94EuVDo{Ou%+T)TH$3Jce#8* z+V7DN8eh6;Mr{QpAuVpvS*+&36)4v_Q7p8)mq(>XkY~P|$1_xso};omrE>*$bZ);^ zqYe_pOG`Sl*_uZ`aetopjD;CtAtJ={H4}fl)IyV1R?QhI!E*FfPi%CP-5=S%J2TU6 zSDs$LR#)|!-P6JX%w->eXQMf%d})i0FLIQQ$xNRzM)G{_rfFEIypBD=DbmhmH@KVFmlJjUs7p9cokFP7iq z$BPk}T4}%jTF4j#63gCM)!yD{8P@DS^i@9}XWs-+NYhihqeQc&_Q_G@P`t5Y>}T)S zW5N&Cc@LFE9cpNr4M({<`@TWO=M|N?@7A)2QJj<`A+fK7{f0UTr^9F?Be~{=AfzzyDBVd6!s4$vS>vfLB)_u}Pg4 zu?lq(1K%>jkVP@7?qpOfk5^M4?uRMl6TL@S*e3OK-k zo7RM<5nRE)cpS1&ndRsO?Pm7~tGOWZ0> zk+i0H3gRYisFpfgw+YGN*J^6u!OxLju(@ONF)KDV0zaoYDwWR`?E;fLiBH@Z;AaGVovR zB}~vE+9zeLv|p#J_C{e#aOIfvQR2({>Syq#jM82X2y5@Y>9W6Qgq^1?XmZ;8IIN!a|cg#_&6{jyebUxrRcnZPm z38$0K%NgSK0ar^}sv>h#GHw3%xcV6)VaMN=u%1XoVR z?3@3@E_7Hi^*km$u>sPqCDsd@k>h9>Yhpf`{rw+kdWDFz=Be4hOu)d~qw@VS4qnVt zq2ezlL6T(RpBe>w0mL&;cn!Fgf55g0U=#_w-7#Fw}nqH@AM+s{jLhLNON2) z?K_D9mvFY473vY>Ga23KZqRSS6H1RCxtmT=h^mli-qZO{6-%U9OAq2wZ(#ekn0efR zN7|rEr0DU#vdoO3)~M*DYMGWZkjC7`u4xx$}hIk#%J5?Cq*0 zJ*XR>T2eA{_XWhHY;rf%tKrAfmUm{Aup3S4-EuOluF2T_A4TWkj^*2jaf&#}CS;VE zWG0bWM##r~m_sWQjBqf=z_dj@!`~F_v>pVZ77K$|nEHzC!@29WC87!p#drh#j5+>S?F@du8nW=a#4%~oJ@<>Qq* z3Xk7mqU|*&OQx$i=r)3R6{~(UgMiC+bJci+5vdjl=^I}S;-F`KKyv-pyfehFHwkm_ zj(^AYYq8Lmyw7H#ToIbDx6MKW>h-7|`3QR&bf3Bu%%pRW8*c<>xT(ZS7U4bbc=1oB z?JVrH_6TSlCyGGrRN#NoG`t@2cc~pnpI&UjC_EyU|;QXqA$Dq6D1vNH68RHhH|nsTuxm%iqD?bWhazd z>*3S$jCJ)1#baz#=9`O?t_WfCj%B@;${9vz(QbYp7k$bGb_T!kBkxPR&>cknC->Rg zWdwN_()gbGO^SZ+04(+$&xGtGpRcoals}vTUs%@)M-t(eus6wnt^SX&&g;n-{P6uP zvv0ijrj4&K!F}O>Da^<+^lMpGHLO3B#?EcKtXi;@USxQhx<|^llnsdar{( z+a>C&LBvmhQ(49KgAy;O;6>i6cVX&q87?xHd3FaLl*9NKue(Qs<3Zdn{akB#a`6Se zyiLeUl#N-!m4{wEONw5i(7(;uGGoI|g(Keg4J7Q%A1 zPuwf$Ai^Z;nA0U|aIW!V!SFTR~e8;I^g}SGsk{}VAQ{+ z;LF6EJ;sFPI73UaEAg8?foHb#^E=G*dN4AOGHC4g9h%z|5p3FZs3_7^((;aUdiY1Xa&4$WD@dS%MZerN?#*gp35P4CYpZu%O~0rT&qqBsaQ=L zMj+eZKOrCglgJm%4o|Cd@`OgOniNM)=09XLFa*D`WS7PKg?Gpr6{p9Nsa1Xt=Tt3d z{is%`-ZQgfwn>mWE%K%|a%z%h{aKH9LQjor{EX|#6L_DNWB6NEOcFgqe-{)F(^f(0 zi%F{I&a4&u%ub$|y>-nL3irPnx<>2@;ix1ZMF`2|AY@(E_gha8iN@zWT30QzT@^j<0PUu zV&$Hkp4x`<*TTZK`M9?r3a5OvcDYJ%U$y)WWjx=xgp8FU<##vo_t_5Bc2~?|&p-6F z7W^p;bSp(Ff$|N#=-4N4arYm+n;DhAPh7YEqun z9C?(6?H(XCCos6ob-563t}m8WFK;j*23B5k39Z#IxiS<~@Hku+0wWu`5ljZ}AaRe& z#aE^53J$rSd;T+>%O9E#8`^9_9-l-iou_-@jKdZbJk`ivyGfo!?l<<`kmWrG+>U4r zAg@oaMgE3CKY8ezCS(Y+-C2rz84s29fXeLiVvIPP#nf`)#m`r;)-l|$Q3(46Mf

jsq#ScB%#;BB<%?ZH8RRS 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

4(7;=6wYdA=-d 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

- This site wants permission to spend your tokens. + Review request details before you confirm.

- Spending cap request + Signature request

- This site wants permission to spend your tokens. + Review request details before you confirm.

Date: Tue, 26 Nov 2024 16:26:36 +0530 Subject: [PATCH 076/148] fix: transaction flow section layout on re-designed confirmation pages (#28720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** fix: transaction flow section layout on re-designed confirmation pages ## **Related issues** Ref: https://github.com/MetaMask/metamask-extension/issues/28015 ## **Manual testing steps** 1. Open test dapp 2. Submit token transfer confirmation 3. Check layout of the page ## **Screenshots/Recordings** ### **Before** Screenshot 2024-11-26 at 4 06 28 PM ### **After** Screenshot 2024-11-26 at 4 06 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)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../__snapshots__/transaction-flow-section.test.tsx.snap | 2 +- .../confirm/info/token-transfer/transaction-flow-section.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap index 01614eec26cd..66ca6afdee69 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap @@ -11,7 +11,7 @@ exports[` renders correctly 1`] = ` >
{ alertKey={RowAlertKey.SigningInWith} label={t('from')} ownerId={transactionMeta.id} + style={{ flexDirection: FlexDirection.Column }} > { From a6e1a746afb376e7a651ff8a3ed1328e93341406 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 26 Nov 2024 12:34:45 +0100 Subject: [PATCH 077/148] chore: Bump Snaps packages (#28678) ## **Description** Bump Snaps packages and handle any required changes. Summary of Snaps changes: - Add `snap_getInterfaceContext` JSON-RPC method - Emit `snapInstalled` and `snapUpdated` events for preinstalled Snaps - This indirectly makes preinstalled Snaps trigger cronjobs and lifecycle hooks more reliably. Closes https://github.com/MetaMask/snaps/issues/2899 Closes https://github.com/MetaMask/snaps/issues/2901 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28678?quickstart=1) --- .../controllers/permissions/specifications.js | 1 + app/scripts/metamask-controller.js | 18 +++++++- package.json | 8 ++-- yarn.lock | 46 +++++++++---------- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/scripts/controllers/permissions/specifications.js b/app/scripts/controllers/permissions/specifications.js index fffc9ae44f49..b0b2051b10f5 100644 --- a/app/scripts/controllers/permissions/specifications.js +++ b/app/scripts/controllers/permissions/specifications.js @@ -412,6 +412,7 @@ export const unrestrictedMethods = Object.freeze([ 'snap_createInterface', 'snap_updateInterface', 'snap_getInterfaceState', + 'snap_getInterfaceContext', 'snap_resolveInterface', 'snap_getCurrencyRate', ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 29e6f09a3aa9..3ca916acbf41 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3024,7 +3024,11 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( `${this.snapController.name}:snapInstalled`, - (truncatedSnap, origin) => { + (truncatedSnap, origin, preinstalled) => { + if (preinstalled) { + return; + } + const snapId = truncatedSnap.id; const snapCategory = this._getSnapMetadata(snapId)?.category; this.metaMetricsController.trackEvent({ @@ -3042,7 +3046,11 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( `${this.snapController.name}:snapUpdated`, - (newSnap, oldVersion, origin) => { + (newSnap, oldVersion, origin, preinstalled) => { + if (preinstalled) { + return; + } + const snapId = newSnap.id; const snapCategory = this._getSnapMetadata(snapId)?.category; this.metaMetricsController.trackEvent({ @@ -6089,6 +6097,12 @@ export default class MetamaskController extends EventEmitter { origin, ...args, ).state, + getInterfaceContext: (...args) => + this.controllerMessenger.call( + 'SnapInterfaceController:getInterface', + origin, + ...args, + ).context, createInterface: this.controllerMessenger.call.bind( this.controllerMessenger, 'SnapInterfaceController:createInterface', diff --git a/package.json b/package.json index 45896fdabd1f..56b7c8f876bc 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "semver@7.3.8": "^7.5.4", "@trezor/schema-utils@npm:1.0.2": "patch:@trezor/schema-utils@npm%3A1.0.2#~/.yarn/patches/@trezor-schema-utils-npm-1.0.2-7dd48689b2.patch", "lavamoat-core@npm:^15.1.1": "patch:lavamoat-core@npm%3A15.1.1#~/.yarn/patches/lavamoat-core-npm-15.1.1-51fbe39988.patch", - "@metamask/snaps-sdk": "^6.11.0", + "@metamask/snaps-sdk": "^6.12.0", "@swc/types@0.1.5": "^0.1.6", "@babel/core": "patch:@babel/core@npm%3A7.25.9#~/.yarn/patches/@babel-core-npm-7.25.9-4ae3bff7f3.patch", "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", @@ -349,10 +349,10 @@ "@metamask/selected-network-controller": "^18.0.2", "@metamask/signature-controller": "^23.0.0", "@metamask/smart-transactions-controller": "^13.0.0", - "@metamask/snaps-controllers": "^9.13.0", + "@metamask/snaps-controllers": "^9.14.0", "@metamask/snaps-execution-environments": "^6.10.0", - "@metamask/snaps-rpc-methods": "^11.5.1", - "@metamask/snaps-sdk": "^6.11.0", + "@metamask/snaps-rpc-methods": "^11.6.0", + "@metamask/snaps-sdk": "^6.12.0", "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", "@metamask/transaction-controller": "^40.0.0", diff --git a/yarn.lock b/yarn.lock index 9f8daf6ce2d6..2bea9ec5fc46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6457,9 +6457,9 @@ __metadata: linkType: hard "@metamask/slip44@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/slip44@npm:4.0.0" - checksum: 10/3e47e8834b0fbdabe1f126fd78665767847ddc1f9ccc8defb23007dd71fcd2e4899c8ca04857491be3630668a3765bad1e40fdfca9a61ef33236d8d08e51535e + version: 4.1.0 + resolution: "@metamask/slip44@npm:4.1.0" + checksum: 10/4265254a1800a24915bd1de15f86f196737132f9af2a084c2efc885decfc5dd87ad8f0687269d90b35e2ec64d3ea4fbff0caa793bcea6e585b1f3a290952b750 languageName: node linkType: hard @@ -6486,9 +6486,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.13.0": - version: 9.13.0 - resolution: "@metamask/snaps-controllers@npm:9.13.0" +"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.14.0": + version: 9.14.0 + resolution: "@metamask/snaps-controllers@npm:9.14.0" dependencies: "@metamask/approval-controller": "npm:^7.1.1" "@metamask/base-controller": "npm:^7.0.2" @@ -6500,8 +6500,8 @@ __metadata: "@metamask/post-message-stream": "npm:^8.1.1" "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-rpc-methods": "npm:^11.6.0" + "@metamask/snaps-sdk": "npm:^6.12.0" "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/utils": "npm:^10.0.0" "@xstate/fsm": "npm:^2.0.0" @@ -6520,7 +6520,7 @@ __metadata: peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/bcf60b61de067f89439cb15acbdf6f808b4bcda8e1cbc9debd693ca2c545c9d38c4e6f380191c4703bd9d28d7dd41e4ce5111664d7b474d5e86e460bcefc3637 + checksum: 10/cce5a4d7af65d70a2a2902f9a89b15145590edccf8171b3994e2ddde74c6700abf49d7b800275d7ab3b216ef3dfca3be82c4145b5986d20ec1df8b4c50b95314 languageName: node linkType: hard @@ -6555,32 +6555,32 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^11.5.1": - version: 11.5.1 - resolution: "@metamask/snaps-rpc-methods@npm:11.5.1" +"@metamask/snaps-rpc-methods@npm:^11.6.0": + version: 11.6.0 + resolution: "@metamask/snaps-rpc-methods@npm:11.6.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/permission-controller": "npm:^11.0.3" "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.12.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" "@noble/hashes": "npm:^1.3.1" - checksum: 10/0f999a5dd64f1b1123366f448ae833f0e95a415791600bb535959ba67d2269fbe3c4504d47f04db71bafa79a9a87d6b832fb2e2b5ef29567078c95bce2638f35 + checksum: 10/6788717e1ccab8eb40876fce15a4b66b44f267b33fe953efa56c0fee94f651391704556eaf9f3f9e6787e0bbbe6b0fb470a9da45d68d78b587ad96b6a3f246ac languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.11.0": - version: 6.11.0 - resolution: "@metamask/snaps-sdk@npm:6.11.0" +"@metamask/snaps-sdk@npm:^6.12.0": + version: 6.12.0 + resolution: "@metamask/snaps-sdk@npm:6.12.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" - checksum: 10/0f9b507139d1544b1b3d85ff8de81b800d543012d3ee9414c607c23abe9562e0dca48de089ed94be69f5ad981730a0f443371edfe6bc2d5ffb140b28e437bfd2 + checksum: 10/b0e24fee2c90ac2f456aeb5babc180c74f56b8cf94f38abdd9b022c65a9cac4a10015e3d4053784292c184238f97704cb2b6a41f6e7a04f23ffaa8d519d2a39e languageName: node linkType: hard @@ -6615,7 +6615,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": +"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.6.0": version: 8.6.0 resolution: "@metamask/snaps-utils@npm:8.6.0" dependencies: @@ -26883,10 +26883,10 @@ __metadata: "@metamask/selected-network-controller": "npm:^18.0.2" "@metamask/signature-controller": "npm:^23.0.0" "@metamask/smart-transactions-controller": "npm:^13.0.0" - "@metamask/snaps-controllers": "npm:^9.13.0" + "@metamask/snaps-controllers": "npm:^9.14.0" "@metamask/snaps-execution-environments": "npm:^6.10.0" - "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-rpc-methods": "npm:^11.6.0" + "@metamask/snaps-sdk": "npm:^6.12.0" "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/solana-wallet-snap": "npm:^0.1.9" "@metamask/test-bundler": "npm:^1.0.0" From ad20b719a1834ffe4a189d6733001248fd3057a6 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Tue, 26 Nov 2024 13:54:28 +0000 Subject: [PATCH 078/148] test: add accounts sync test with balance detection (#28715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** E2E Test for Accounts Syncing when a user has more accounts with balances. * Adds new method for changing label from account modal context to be used with other methods * Adds new mocks for mocking `eth_getBalance` in test * Updates `unlockWallet` to be used with custom password ## **Related issues** Fixes: ## **Manual testing steps** 1. All E2E tests should pass ## **Screenshots/Recordings** Screenshot 2024-11-26 at 08 50 30 ## **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). - [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: sahar-fehri Co-authored-by: Jyoti Puri Co-authored-by: MetaMask Bot Co-authored-by: Frederik Bolding --- test/e2e/helpers.js | 21 +- .../page-objects/pages/account-list-page.ts | 27 +++ .../sync-with-account-balances.spec.ts | 225 ++++++++++++++++++ test/e2e/tests/notifications/mocks.ts | 61 +++++ 4 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 test/e2e/tests/notifications/account-syncing/sync-with-account-balances.spec.ts diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 19c9aeecd6c7..b5962c0c079d 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -615,30 +615,27 @@ const locateAccountBalanceDOM = async ( const WALLET_PASSWORD = 'correct horse battery staple'; /** - * Unlock the wallet with the default password. + * Unlocks the wallet using the provided password. * This method is intended to replace driver.navigate and should not be called after driver.navigate. * * @param {WebDriver} driver - The webdriver instance - * @param {object} options - Options for unlocking the wallet - * @param {boolean} options.navigate - Whether to navigate to the root page prior to unlocking. Defaults to true. - * @param {boolean} options.waitLoginSuccess - Whether to wait for the login to succeed. Defaults to true. + * @param {object} [options] - Options for unlocking the wallet + * @param {boolean} [options.navigate] - Whether to navigate to the root page prior to unlocking - defaults to true + * @param {boolean} [options.waitLoginSuccess] - Whether to wait for the login to succeed - defaults to true + * @param {string} [options.password] - Password to unlock wallet - defaults to shared WALLET_PASSWORD */ async function unlockWallet( driver, - options = { - navigate: true, - waitLoginSuccess: true, - }, + { navigate = true, waitLoginSuccess = true, password = WALLET_PASSWORD } = {}, ) { - if (options.navigate !== false) { + if (navigate) { await driver.navigate(); } - await driver.fill('#password', WALLET_PASSWORD); + await driver.fill('#password', password); await driver.press('#password', driver.Key.ENTER); - if (options.waitLoginSuccess !== false) { - // No guard is necessary here, because it goes from present to absent + if (waitLoginSuccess) { await driver.assertElementNotPresent('[data-testid="unlock-page"]'); } } diff --git a/test/e2e/page-objects/pages/account-list-page.ts b/test/e2e/page-objects/pages/account-list-page.ts index f68cdaa333a0..9f96d70f4972 100644 --- a/test/e2e/page-objects/pages/account-list-page.ts +++ b/test/e2e/page-objects/pages/account-list-page.ts @@ -230,6 +230,33 @@ class AccountListPage { async changeAccountLabel(newLabel: string): Promise { console.log(`Changing account label to: ${newLabel}`); await this.driver.clickElement(this.accountMenuButton); + await this.changeLabelFromAccountDetailsModal(newLabel); + } + + /** + * Changes the account label from within an already opened account details modal. + * Note: This method assumes the account details modal is already open. + * + * Recommended usage: + * ```typescript + * await accountListPage.openAccountDetailsModal('Current Account Name'); + * await accountListPage.changeLabelFromAccountDetailsModal('New Account Name'); + * ``` + * + * @param newLabel - The new label to set for the account + * @throws Will throw an error if the modal is not open when method is called + * @example + * // To rename a specific account, first open its details modal: + * await accountListPage.openAccountDetailsModal('Current Account Name'); + * await accountListPage.changeLabelFromAccountDetailsModal('New Account Name'); + * + * // Note: Using changeAccountLabel() alone will only work for the first account + */ + async changeLabelFromAccountDetailsModal(newLabel: string): Promise { + await this.driver.waitForSelector(this.editableLabelButton); + console.log( + `Account details modal opened, changing account label to: ${newLabel}`, + ); await this.driver.clickElement(this.editableLabelButton); await this.driver.fill(this.editableLabelInput, newLabel); await this.driver.clickElement(this.saveAccountLabelButton); diff --git a/test/e2e/tests/notifications/account-syncing/sync-with-account-balances.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-with-account-balances.spec.ts new file mode 100644 index 000000000000..8ecaff943721 --- /dev/null +++ b/test/e2e/tests/notifications/account-syncing/sync-with-account-balances.spec.ts @@ -0,0 +1,225 @@ +import { Mockttp } from 'mockttp'; +import { unlockWallet, withFixtures } from '../../../helpers'; +import FixtureBuilder from '../../../fixture-builder'; +import { mockInfuraAndAccountSync } from '../mocks'; +import { + NOTIFICATIONS_TEAM_PASSWORD, + NOTIFICATIONS_TEAM_SEED_PHRASE, +} from '../constants'; +import { UserStorageMockttpController } from '../../../helpers/user-storage/userStorageMockttpController'; +import HeaderNavbar from '../../../page-objects/pages/header-navbar'; +import AccountListPage from '../../../page-objects/pages/account-list-page'; +import HomePage from '../../../page-objects/pages/homepage'; +import { completeImportSRPOnboardingFlow } from '../../../page-objects/flows/onboarding.flow'; +import { accountsSyncMockResponse } from './mockData'; +import { IS_ACCOUNT_SYNCING_ENABLED } from './helpers'; + +const INITIAL_ACCOUNTS = [ + '0xaa4179e7f103701e904d27df223a39aa9c27405a', + '0xd2a4afe5c2ff0a16bf81f77ba4201a8107aa874b', + '0xd54ba25a07eb3da821face8478c3d965ded63018', + '0x2c30c098e2a560988d486c7f25798e790802f953', +]; + +const ADDITIONAL_ACCOUNTS = [ + '0x6b65DA6735119E72B72fF842Bd92e9DE0C1e4Ae0', + '0x0f205850eaC507473AA0e47cc8eB528D875E7498', +]; + +const EXPECTED_ACCOUNT_NAMES = { + INITIAL: [ + 'My First Synced Account', + 'My Second Synced Account', + 'Account 3', + 'Account 4', + ], + WITH_NEW_ACCOUNTS: [ + 'My First Synced Account', + 'My Second Synced Account', + 'Account 3', + 'Account 4', + 'Account 5', + 'Account 6', + ], +}; + +describe('Account syncing - User already has balances on multple accounts @no-mmi', function () { + if (!IS_ACCOUNT_SYNCING_ENABLED) { + return; + } + + describe('from inside MetaMask', function () { + /** + * This test verifies the complete account syncing flow in three phases: + * Phase 1: Initial setup, where we check that 4 accounts are shown due to balance detection even though the user storage only has 2 accounts. + * Phase 2: Discovery of 2 more accounts after adding balances. We still expect to only see 6 even though we had 5 accounts synced in the previous test + * Phase 3: Verification that any final changes to user storage are persisted and that we don't see any extra accounts created + */ + it('when a user has balances on more accounts than previously synced, it should be handled gracefully', async function () { + const userStorageMockttpController = new UserStorageMockttpController(); + let accountsToMock = [...INITIAL_ACCOUNTS]; + + // PHASE 1: Initial setup and account creation + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: async (server: Mockttp) => { + await mockInfuraAndAccountSync( + server, + userStorageMockttpController, + { + accountsSyncResponse: accountsSyncMockResponse, + accountsToMock, + }, + ); + }, + }, + async ({ driver }) => { + // Complete initial setup with provided seed phrase + await completeImportSRPOnboardingFlow({ + driver, + seedPhrase: NOTIFICATIONS_TEAM_SEED_PHRASE, + password: NOTIFICATIONS_TEAM_PASSWORD, + }); + + // Verify initial state and balance + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed('1'); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); + + // Open account menu and verify initial accounts + const header = new HeaderNavbar(driver); + await header.check_pageIsLoaded(); + await header.openAccountMenu(); + + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_numberOfAvailableAccounts(4); + + // Verify each initial account name + for (const accountName of EXPECTED_ACCOUNT_NAMES.INITIAL) { + await accountListPage.check_accountDisplayedInAccountList( + accountName, + ); + } + + // Create new account and prepare for additional accounts + await accountListPage.addNewAccount(); + accountsToMock = [...INITIAL_ACCOUNTS, ...ADDITIONAL_ACCOUNTS]; + }, + ); + + // PHASE 2: Verify discovery of new accounts with balances + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: async (server: Mockttp) => { + await mockInfuraAndAccountSync( + server, + userStorageMockttpController, + { accountsToMock }, + ); + }, + }, + async ({ driver }) => { + // Complete setup again for new session + await completeImportSRPOnboardingFlow({ + driver, + seedPhrase: NOTIFICATIONS_TEAM_SEED_PHRASE, + password: NOTIFICATIONS_TEAM_PASSWORD, + }); + + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed('1'); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); + + // Verify all accounts including newly discovered ones (which would have been synced / have balances) + const header = new HeaderNavbar(driver); + await header.check_pageIsLoaded(); + await header.openAccountMenu(); + + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_numberOfAvailableAccounts(6); + + for (const accountName of EXPECTED_ACCOUNT_NAMES.WITH_NEW_ACCOUNTS) { + await accountListPage.check_accountDisplayedInAccountList( + accountName, + ); + } + + // Rename Account 6 to verify update to user storage + await accountListPage.switchToAccount('Account 6'); + await header.openAccountMenu(); + await accountListPage.openAccountDetailsModal('Account 6'); + await accountListPage.changeLabelFromAccountDetailsModal( + 'My Renamed Account 6', + ); + }, + ); + + // PHASE 3: Verify name persistence across sessions + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: async (server: Mockttp) => { + await mockInfuraAndAccountSync( + server, + userStorageMockttpController, + { accountsToMock }, + ); + }, + }, + async ({ driver }) => { + // Complete setup for final verification + await completeImportSRPOnboardingFlow({ + driver, + seedPhrase: NOTIFICATIONS_TEAM_SEED_PHRASE, + password: NOTIFICATIONS_TEAM_PASSWORD, + }); + + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed('1'); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); + + // Verify renamed account persists + const header = new HeaderNavbar(driver); + await header.check_pageIsLoaded(); + await header.openAccountMenu(); + + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_numberOfAvailableAccounts(6); + await accountListPage.check_accountDisplayedInAccountList( + 'My Renamed Account 6', + ); + await accountListPage.closeAccountModal(); + + // Lock and unlock wallet to ensure that number of preloaded accounts have not gone up + await homePage.headerNavbar.lockMetaMask(); + await unlockWallet(driver, { + password: NOTIFICATIONS_TEAM_PASSWORD, + waitLoginSuccess: true, + navigate: true, + }); + + await header.check_pageIsLoaded(); + await header.openAccountMenu(); + await accountListPage.check_numberOfAvailableAccounts(6); + }, + ); + }); + }); +}); diff --git a/test/e2e/tests/notifications/mocks.ts b/test/e2e/tests/notifications/mocks.ts index 748084918272..b7069447fd45 100644 --- a/test/e2e/tests/notifications/mocks.ts +++ b/test/e2e/tests/notifications/mocks.ts @@ -6,6 +6,7 @@ import { } from '@metamask/notification-services-controller'; import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { UserStorageMockttpController } from '../../helpers/user-storage/userStorageMockttpController'; +import { accountsSyncMockResponse } from './account-syncing/mockData'; const AuthMocks = AuthenticationController.Mocks; const NotificationMocks = NotificationServicesController.Mocks; @@ -105,3 +106,63 @@ function mockAPICall(server: Mockttp, response: MockResponse) { json: response.response, })); } + +type MockInfuraAndAccountSyncOptions = { + accountsToMock?: string[]; + accountsSyncResponse?: typeof accountsSyncMockResponse; +}; + +const MOCK_ETH_BALANCE = '0xde0b6b3a7640000'; +const INFURA_URL = + 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; + +/** + * Sets up mock responses for Infura balance checks and account syncing + * + * @param mockServer - The Mockttp server instance + * @param userStorageMockttpController - Controller for user storage mocks + * @param options - Configuration options for mocking + */ +export async function mockInfuraAndAccountSync( + mockServer: Mockttp, + userStorageMockttpController: UserStorageMockttpController, + options: MockInfuraAndAccountSyncOptions = {}, +): Promise { + const accounts = options.accountsToMock ?? []; + + // Set up User Storage / Account Sync mock + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + ); + + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: options.accountsSyncResponse ?? undefined, + }, + ); + + // Account Balances + if (accounts.length > 0) { + accounts.forEach((account) => { + mockServer + .forPost(INFURA_URL) + .withJsonBodyIncluding({ + method: 'eth_getBalance', + params: [account.toLowerCase()], + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: MOCK_ETH_BALANCE, + }, + })); + }); + } + + mockNotificationServices(mockServer, userStorageMockttpController); +} From fd3ac164fd81a7f4451180cf58b0e9ee433bc880 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 26 Nov 2024 14:59:24 +0100 Subject: [PATCH 079/148] feat: enable account syncing in production (#28596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR enables account syncing in production [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28596?quickstart=1) ## **Related issues** Fixes: #28438 ## **Manual testing steps** 1. Onboard with your SRP 2. Add accounts, rename accounts 3. Uninstall the extension 4. Reinstall and onboard with the previous SRP 5. Verify that your added & renamed accounts are there ## **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. --- app/scripts/metamask-controller.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 3ca916acbf41..94ebb67f3617 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -157,7 +157,6 @@ import { NotificationServicesPushController, NotificationServicesController, } from '@metamask/notification-services-controller'; -import { isProduction } from '../../shared/modules/environment'; import { methodsRequiringNetworkSwitch, methodsThatCanSwitchNetworkWithoutApproval, @@ -1609,7 +1608,7 @@ export default class MetamaskController extends EventEmitter { }, }, env: { - isAccountSyncingEnabled: !isProduction() && isManifestV3, + isAccountSyncingEnabled: isManifestV3, }, messenger: this.controllerMessenger.getRestricted({ name: 'UserStorageController', From af9ebca332af423c2f4719f614bc76f18fd5b5f9 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 26 Nov 2024 15:18:52 +0100 Subject: [PATCH 080/148] feat: Add first time interaction warning (#28435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR implements first time interaction feature where it shows an alert if you interact with the address for the first time. Information of the first time interaction is fetched in the transaction controller when the transaction is added to the state. Core PR: https://github.com/MetaMask/core/pull/4895 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28435?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3040 ## **Manual testing steps** 1. Go to test dapp 2. Use send legacy transaction - make sure you interact here with your account for the first time 3. See warning ## **Screenshots/Recordings** ### **Before** ### **After** ![Screenshot 2024-11-13 at 11 51 14](https://github.com/user-attachments/assets/6cc1f481-788c-4945-b190-1448c5a03141) ![Screenshot 2024-11-13 at 11 51 19](https://github.com/user-attachments/assets/98413caa-ef43-4877-a37d-ea0a6da1397f) ## **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 | 8 +- app/scripts/metamask-controller.js | 2 + package.json | 2 +- .../app/confirm/info/row/constants.ts | 1 + ui/components/app/confirm/info/row/row.tsx | 3 +- .../transaction-flow-section.test.tsx.snap | 4 +- .../transaction-flow-section.tsx | 16 +-- .../simulation-details/simulation-details.tsx | 5 +- .../useFirstTimeInteractionAlert.test.ts | 129 ++++++++++++++++++ .../useFirstTimeInteractionAlert.ts | 35 +++++ .../hooks/useConfirmationAlerts.ts | 4 + .../__snapshots__/security-tab.test.js.snap | 2 +- yarn.lock | 10 +- 13 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts create mode 100644 ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 39c1b20d1a52..54c0a782a592 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -419,6 +419,9 @@ "alertMessageChangeInSimulationResults": { "message": "Estimated changes for this transaction have been updated. Review them closely before proceeding." }, + "alertMessageFirstTimeInteraction": { + "message": "You're interacting with this address for the first time. Make sure that it's correct before you continue." + }, "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." }, @@ -461,6 +464,9 @@ "alertReasonChangeInSimulationResults": { "message": "Results have changed" }, + "alertReasonFirstTimeInteraction": { + "message": "1st interaction" + }, "alertReasonGasEstimateFailed": { "message": "Inaccurate fee" }, @@ -4744,7 +4750,7 @@ "message": "Security alerts" }, "securityAlertsDescription": { - "message": "This feature alerts you to malicious activity by actively reviewing transaction and signature requests. $1", + "message": "This feature alerts you to malicious or unusual activity by actively reviewing transaction and signature requests. $1", "description": "Link to learn more about security alerts" }, "securityAndPrivacy": { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 94ebb67f3617..ff3ad58f26d4 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1940,6 +1940,8 @@ export default class MetamaskController extends EventEmitter { queryEntireHistory: false, updateTransactions: false, }, + isFirstTimeInteractionEnabled: () => + this.preferencesController.state.securityAlertsEnabled, isMultichainEnabled: process.env.TRANSACTION_MULTICHAIN, isSimulationEnabled: () => this.preferencesController.state.useTransactionSimulations, diff --git a/package.json b/package.json index 56b7c8f876bc..5a3a7531a45b 100644 --- a/package.json +++ b/package.json @@ -355,7 +355,7 @@ "@metamask/snaps-sdk": "^6.12.0", "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", - "@metamask/transaction-controller": "^40.0.0", + "@metamask/transaction-controller": "^40.1.0", "@metamask/user-operation-controller": "^13.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.12", diff --git a/ui/components/app/confirm/info/row/constants.ts b/ui/components/app/confirm/info/row/constants.ts index 415358aa5252..5c6c1ff980f7 100644 --- a/ui/components/app/confirm/info/row/constants.ts +++ b/ui/components/app/confirm/info/row/constants.ts @@ -2,6 +2,7 @@ export const TEST_ADDRESS = '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1'; export enum RowAlertKey { EstimatedFee = 'estimatedFee', + FirstTimeInteraction = 'firstTimeInteraction', SigningInWith = 'signingInWith', RequestFrom = 'requestFrom', Resimulation = 'resimulation', diff --git a/ui/components/app/confirm/info/row/row.tsx b/ui/components/app/confirm/info/row/row.tsx index b628e2256575..193e62a5f2f3 100644 --- a/ui/components/app/confirm/info/row/row.tsx +++ b/ui/components/app/confirm/info/row/row.tsx @@ -33,7 +33,7 @@ export enum ConfirmInfoRowVariant { export type ConfirmInfoRowProps = { label: string; - children: React.ReactNode | string; + children?: React.ReactNode | string; tooltip?: string; variant?: ConfirmInfoRowVariant; style?: React.CSSProperties; @@ -169,6 +169,7 @@ export const ConfirmInfoRow: React.FC = ({ {expanded && + children && (typeof children === 'string' ? ( {children} diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap index 66ca6afdee69..10955f0992bc 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/transaction-flow-section.test.tsx.snap @@ -94,10 +94,10 @@ exports[` renders correctly 1`] = ` />
{ alertKey={RowAlertKey.SigningInWith} label={t('from')} ownerId={transactionMeta.id} - style={{ flexDirection: FlexDirection.Column }} + style={{ + flexDirection: FlexDirection.Column, + }} > { color={IconColor.iconMuted} /> {recipientAddress && ( - @@ -90,7 +90,7 @@ export const TransactionFlowSection = () => { chainId={chainId} /> - + )} diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx index 357f71230a0c..412467b4ff8a 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx @@ -110,10 +110,7 @@ const HeaderWithAlert = ({ transactionId }: { transactionId: string }) => { paddingLeft: 0, paddingRight: 0, }} - > - {/* Intentional fragment */} - <> - + /> ); }; diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts new file mode 100644 index 000000000000..964b218e8501 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts @@ -0,0 +1,129 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { getMockConfirmState } from '../../../../../../test/data/confirmations/helper'; +import { renderHookWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; +import { Severity } from '../../../../../helpers/constants/design-system'; +import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; +import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction'; +import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; + +const ACCOUNT_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; +const TRANSACTION_ID_MOCK = '123-456'; + +const CONFIRMATION_MOCK = genUnapprovedContractInteractionConfirmation({ + chainId: '0x5', +}) as TransactionMeta; + +const TRANSACTION_META_MOCK = { + id: TRANSACTION_ID_MOCK, + chainId: '0x5', + status: TransactionStatus.submitted, + type: TransactionType.contractInteraction, + txParams: { + from: ACCOUNT_ADDRESS, + }, + time: new Date().getTime() - 10000, + firstTimeInteraction: true, +} as TransactionMeta; + +function runHook({ + currentConfirmation, + transactions = [], +}: { + currentConfirmation?: TransactionMeta; + transactions?: TransactionMeta[]; +} = {}) { + let pendingApprovals = {}; + if (currentConfirmation) { + pendingApprovals = { + [currentConfirmation.id as string]: { + id: currentConfirmation.id, + type: ApprovalType.Transaction, + }, + }; + transactions.push(currentConfirmation); + } + const state = getMockConfirmState({ + metamask: { + pendingApprovals, + transactions, + }, + }); + const response = renderHookWithConfirmContextProvider( + useFirstTimeInteractionAlert, + state, + ); + + return response.result.current; +} + +describe('useFirstTimeInteractionAlert', () => { + 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 firstTimeInteraction is false', () => { + const notFirstTimeConfirmation = { + ...TRANSACTION_META_MOCK, + firstTimeInteraction: false, + }; + expect( + runHook({ + currentConfirmation: notFirstTimeConfirmation, + }), + ).toEqual([]); + }); + + it('returns no alerts if firstTimeInteraction is undefined', () => { + const notFirstTimeConfirmation = { + ...TRANSACTION_META_MOCK, + firstTimeInteraction: undefined, + }; + expect( + runHook({ + currentConfirmation: notFirstTimeConfirmation, + }), + ).toEqual([]); + }); + + it('returns alert if isFirstTimeInteraction is true', () => { + const firstTimeConfirmation = { + ...CONFIRMATION_MOCK, + isFirstTimeInteraction: true, + }; + const alerts = runHook({ + currentConfirmation: firstTimeConfirmation, + }); + + expect(alerts).toEqual([ + { + actions: [], + field: RowAlertKey.FirstTimeInteraction, + isBlocking: false, + key: 'firstTimeInteractionTitle', + message: + "You're interacting with this address for the first time. Make sure that it's correct before you continue.", + reason: '1st interaction', + severity: Severity.Warning, + }, + ]); + }); +}); diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts new file mode 100644 index 000000000000..7e4a86c3802f --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts @@ -0,0 +1,35 @@ +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 useFirstTimeInteractionAlert(): Alert[] { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + + const { isFirstTimeInteraction } = currentConfirmation ?? {}; + + return useMemo(() => { + // If isFirstTimeInteraction is undefined that means it's either disabled or error in accounts API + // If it's false that means account relationship found + if (!isFirstTimeInteraction) { + return []; + } + + return [ + { + actions: [], + field: RowAlertKey.FirstTimeInteraction, + isBlocking: false, + key: 'firstTimeInteractionTitle', + message: t('alertMessageFirstTimeInteraction'), + reason: t('alertReasonFirstTimeInteraction'), + severity: Severity.Warning, + }, + ]; + }, [isFirstTimeInteraction, t]); +} diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts index efcb0beacf9e..382c7cdea511 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts @@ -11,6 +11,7 @@ import { useNoGasPriceAlerts } from './alerts/transactions/useNoGasPriceAlerts'; import { usePendingTransactionAlerts } from './alerts/transactions/usePendingTransactionAlerts'; import { useQueuedConfirmationsAlerts } from './alerts/transactions/useQueuedConfirmationsAlerts'; import { useResimulationAlert } from './alerts/transactions/useResimulationAlert'; +import { useFirstTimeInteractionAlert } from './alerts/transactions/useFirstTimeInteractionAlert'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { useSigningOrSubmittingAlerts } from './alerts/transactions/useSigningOrSubmittingAlerts'; ///: END:ONLY_INCLUDE_IF @@ -37,6 +38,7 @@ function useTransactionAlerts(): Alert[] { const noGasPriceAlerts = useNoGasPriceAlerts(); const pendingTransactionAlerts = usePendingTransactionAlerts(); const resimulationAlert = useResimulationAlert(); + const firstTimeInteractionAlert = useFirstTimeInteractionAlert(); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const signingOrSubmittingAlerts = useSigningOrSubmittingAlerts(); ///: END:ONLY_INCLUDE_IF @@ -52,6 +54,7 @@ function useTransactionAlerts(): Alert[] { ...noGasPriceAlerts, ...pendingTransactionAlerts, ...resimulationAlert, + ...firstTimeInteractionAlert, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) ...signingOrSubmittingAlerts, ///: END:ONLY_INCLUDE_IF @@ -66,6 +69,7 @@ function useTransactionAlerts(): Alert[] { noGasPriceAlerts, pendingTransactionAlerts, resimulationAlert, + firstTimeInteractionAlert, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) signingOrSubmittingAlerts, ///: END:ONLY_INCLUDE_IF 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 e7306f64bba7..23d2b80519a3 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 @@ -126,7 +126,7 @@ exports[`Security Tab should match snapshot 1`] = ` > - This feature alerts you to malicious activity by actively reviewing transaction and signature requests. + This feature alerts you to malicious or unusual activity by actively reviewing transaction and signature requests. Date: Tue, 26 Nov 2024 23:20:26 +0900 Subject: [PATCH 081/148] fix: use BN from bn.js instead of ethereumjs-util (#28146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - remove redundant resolutions entries - `ethereumjs-util` v5 is no longer present - fix: use `BN` from bn.js (v5) instead of `ethereumjs-util` (deprecated version) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28146?quickstart=1) ## **Related issues** #### Blocking - #28169 - #28171 - #28170 ## **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. --- app/scripts/migrations/088.ts | 4 ++-- package.json | 5 ----- shared/modules/conversion.utils.ts | 3 ++- ui/helpers/utils/util.js | 6 +++--- ui/helpers/utils/util.test.js | 3 ++- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/scripts/migrations/088.ts b/app/scripts/migrations/088.ts index 274b93624a85..27f324228790 100644 --- a/app/scripts/migrations/088.ts +++ b/app/scripts/migrations/088.ts @@ -1,5 +1,5 @@ import { hasProperty, Hex, isObject, isStrictHexString } from '@metamask/utils'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import { cloneDeep, mapKeys } from 'lodash'; import log from 'loglevel'; @@ -302,6 +302,6 @@ function toHex(value: number | string | BN): Hex { } const hexString = BN.isBN(value) ? value.toString(16) - : new BN(value.toString(), 10).toString(16); + : new BN(value.toString(10), 10).toString(16); return `0x${hexString}`; } diff --git a/package.json b/package.json index 5a3a7531a45b..9ed956d639cd 100644 --- a/package.json +++ b/package.json @@ -180,11 +180,6 @@ "eslint@npm:^8.7.0": "patch:eslint@npm%3A8.57.0#~/.yarn/patches/eslint-npm-8.57.0-4286e12a3a.patch", "eth-query@^2.1.2": "patch:eth-query@npm%3A2.1.2#./.yarn/patches/eth-query-npm-2.1.2-7c6adc825f.patch", "eth-query@^2.1.0": "patch:eth-query@npm%3A2.1.2#./.yarn/patches/eth-query-npm-2.1.2-7c6adc825f.patch", - "ethereumjs-util@^5.1.1": "patch:ethereumjs-util@npm%3A5.2.1#./.yarn/patches/ethereumjs-util-npm-5.2.1-72b39f4e7e.patch", - "ethereumjs-util@^5.1.2": "patch:ethereumjs-util@npm%3A5.2.1#./.yarn/patches/ethereumjs-util-npm-5.2.1-72b39f4e7e.patch", - "ethereumjs-util@^5.1.5": "patch:ethereumjs-util@npm%3A5.2.1#./.yarn/patches/ethereumjs-util-npm-5.2.1-72b39f4e7e.patch", - "ethereumjs-util@^5.0.0": "patch:ethereumjs-util@npm%3A5.2.1#./.yarn/patches/ethereumjs-util-npm-5.2.1-72b39f4e7e.patch", - "ethereumjs-util@^5.2.0": "patch:ethereumjs-util@npm%3A5.2.1#./.yarn/patches/ethereumjs-util-npm-5.2.1-72b39f4e7e.patch", "ethereumjs-util@^7.0.10": "patch:ethereumjs-util@npm%3A7.1.5#./.yarn/patches/ethereumjs-util-npm-7.1.5-5bb4d00000.patch", "ethereumjs-util@^7.1.5": "patch:ethereumjs-util@npm%3A7.1.5#./.yarn/patches/ethereumjs-util-npm-7.1.5-5bb4d00000.patch", "ethereumjs-util@^7.1.4": "patch:ethereumjs-util@npm%3A7.1.5#./.yarn/patches/ethereumjs-util-npm-7.1.5-5bb4d00000.patch", diff --git a/shared/modules/conversion.utils.ts b/shared/modules/conversion.utils.ts index 75da336eb8e6..5c70e5ecd683 100644 --- a/shared/modules/conversion.utils.ts +++ b/shared/modules/conversion.utils.ts @@ -1,6 +1,7 @@ import { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { addHexPrefix, BN } from 'ethereumjs-util'; +import BN from 'bn.js'; +import { addHexPrefix } from 'ethereumjs-util'; import { EtherDenomination } from '../constants/common'; import { Numeric, NumericValue } from './Numeric'; diff --git a/ui/helpers/utils/util.js b/ui/helpers/utils/util.js index eafc8e31bfe5..d687d9b82338 100644 --- a/ui/helpers/utils/util.js +++ b/ui/helpers/utils/util.js @@ -1,7 +1,7 @@ import punycode from 'punycode/punycode'; import abi from 'human-standard-token-abi'; import BigNumber from 'bignumber.js'; -import * as ethUtil from 'ethereumjs-util'; +import BN from 'bn.js'; import { DateTime } from 'luxon'; import { getFormattedIpfsUrl, @@ -168,10 +168,10 @@ export function isOriginContractAddress(to, sendTokenAddress) { // Takes wei Hex, returns wei BN, even if input is null export function numericBalance(balance) { if (!balance) { - return new ethUtil.BN(0, 16); + return new BN(0, 16); } const stripped = stripHexPrefix(balance); - return new ethUtil.BN(stripped, 16); + return new BN(stripped, 16); } // Takes hex, returns [beforeDecimal, afterDecimal] diff --git a/ui/helpers/utils/util.test.js b/ui/helpers/utils/util.test.js index d12a57675343..bdf5c9dd9b98 100644 --- a/ui/helpers/utils/util.test.js +++ b/ui/helpers/utils/util.test.js @@ -1,5 +1,6 @@ import Bowser from 'bowser'; -import { BN, toChecksumAddress } from 'ethereumjs-util'; +import BN from 'bn.js'; +import { toChecksumAddress } from 'ethereumjs-util'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { addHexPrefixToObjectValues } from '../../../shared/lib/swaps-utils'; import { toPrecisionWithoutTrailingZeros } from '../../../shared/lib/transactions-controller-utils'; From 877332833d3ae4b4f1ae7339b92e0e802ab326e2 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:21:21 -0500 Subject: [PATCH 082/148] feat: cross chain swaps - tx status - BridgeStatusController (#28636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR is a collection of all the background related code from #27740 (no UI changes). It has been split up in order to make it easier to review. A follow up PR containing all the UI changes from #27740 is here: https://github.com/MetaMask/metamask-extension/pull/28657 The main addition is the `BridgeStatusController` and its supporting code. If you would like to test the functionality of this PR through the UI, please do so through #27740. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28636?quickstart=1) ## **Related issues** Branched off from #27740 ## **Manual testing steps** Refer to #27740 ## **Screenshots/Recordings** Refer to #27740 ## **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/constants/sentry-state.ts | 5 + .../bridge-status-controller.test.ts.snap | 213 +++++ .../bridge-status-controller.test.ts | 739 ++++++++++++++++++ .../bridge-status/bridge-status-controller.ts | 310 ++++++++ .../controllers/bridge-status/constants.ts | 10 + .../controllers/bridge-status/types.ts | 56 ++ .../controllers/bridge-status/utils.ts | 49 ++ .../bridge-status/validators.test.ts | 238 ++++++ .../controllers/bridge-status/validators.ts | 179 +++++ app/scripts/metamask-controller.js | 31 + shared/constants/transaction.ts | 5 + shared/modules/conversion.utils.ts | 6 + shared/types/bridge-status.ts | 146 ++++ test/data/mock-state.json | 3 + ...rs-after-init-opt-in-background-state.json | 19 +- .../errors-after-init-opt-in-ui-state.json | 21 +- 16 files changed, 2011 insertions(+), 19 deletions(-) create mode 100644 app/scripts/controllers/bridge-status/__snapshots__/bridge-status-controller.test.ts.snap create mode 100644 app/scripts/controllers/bridge-status/bridge-status-controller.test.ts create mode 100644 app/scripts/controllers/bridge-status/bridge-status-controller.ts create mode 100644 app/scripts/controllers/bridge-status/constants.ts create mode 100644 app/scripts/controllers/bridge-status/types.ts create mode 100644 app/scripts/controllers/bridge-status/utils.ts create mode 100644 app/scripts/controllers/bridge-status/validators.test.ts create mode 100644 app/scripts/controllers/bridge-status/validators.ts create mode 100644 shared/types/bridge-status.ts diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index d0fbe7bcb085..289bc0a0d29c 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -122,6 +122,11 @@ export const SENTRY_BACKGROUND_STATE = { quotesRefreshCount: true, }, }, + BridgeStatusController: { + bridgeStatusState: { + txHistory: false, + }, + }, CronjobController: { jobs: false, }, diff --git a/app/scripts/controllers/bridge-status/__snapshots__/bridge-status-controller.test.ts.snap b/app/scripts/controllers/bridge-status/__snapshots__/bridge-status-controller.test.ts.snap new file mode 100644 index 000000000000..ebd3a938822e --- /dev/null +++ b/app/scripts/controllers/bridge-status/__snapshots__/bridge-status-controller.test.ts.snap @@ -0,0 +1,213 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` +{ + "0xsrcTxHash1": { + "account": "0xaccount1", + "estimatedProcessingTimeInSeconds": 15, + "initialDestAssetBalance": undefined, + "pricingData": undefined, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + }, +} +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx history state 1`] = ` +{ + "0xsrcTxHash1": { + "account": "0xaccount1", + "estimatedProcessingTimeInSeconds": 15, + "initialDestAssetBalance": undefined, + "pricingData": undefined, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + }, +} +`; diff --git a/app/scripts/controllers/bridge-status/bridge-status-controller.test.ts b/app/scripts/controllers/bridge-status/bridge-status-controller.test.ts new file mode 100644 index 000000000000..3890f27f7f65 --- /dev/null +++ b/app/scripts/controllers/bridge-status/bridge-status-controller.test.ts @@ -0,0 +1,739 @@ +import { flushPromises } from '../../../../test/lib/timer-helpers'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { + StatusTypes, + ActionTypes, + BridgeId, +} from '../../../../shared/types/bridge-status'; +import BridgeStatusController from './bridge-status-controller'; +import { BridgeStatusControllerMessenger } from './types'; +import { DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE } from './constants'; +import * as bridgeStatusUtils from './utils'; + +const EMPTY_INIT_STATE = { + bridgeStatusState: DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, +}; + +const getMockQuote = ({ srcChainId = 42161, destChainId = 10 } = {}) => ({ + requestId: '197c402f-cb96-4096-9f8c-54aed84ca776', + srcChainId, + srcTokenAmount: '991250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId, + destTokenAmount: '990654755978612', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge' as ActionTypes, + srcChainId, + destChainId, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000', + destAmount: '990654755978612', + }, + ], +}); + +const getMockStartPollingForBridgeTxStatusArgs = ({ + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, +} = {}) => ({ + statusRequest: { + bridgeId: 'lifi', + srcTxHash, + bridge: 'across', + srcChainId, + destChainId, + quote: getMockQuote({ srcChainId, destChainId }), + refuel: false, + }, + quoteResponse: { + quote: getMockQuote({ srcChainId, destChainId }), + trade: { + chainId: srcChainId, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: account, + value: '0x038d7ea4c68000', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', + gasLimit: 282915, + }, + approval: null, + estimatedProcessingTimeInSeconds: 15, + }, + startTime: 1729964825189, + slippagePercentage: 0, + pricingData: undefined, + initialDestAssetBalance: undefined, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', +}); + +const MockStatusResponse = { + getPending: ({ + srcTxHash = '0xsrcTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'PENDING' as StatusTypes, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + token: {}, + }, + }), + getComplete: ({ + srcTxHash = '0xsrcTxHash1', + destTxHash = '0xdestTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'COMPLETE' as StatusTypes, + isExpectedToken: true, + bridge: 'across' as BridgeId, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + txHash: destTxHash, + amount: '990654755978611', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }), +}; + +const MockTxHistory = { + getInit: ({ + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + [srcTxHash]: { + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + }, + }), + getPending: ({ + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + [srcTxHash]: { + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + }, + }), + getComplete: ({ + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + [srcTxHash]: { + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getComplete({ srcTxHash }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + }, + }), +}; + +const getMessengerMock = ({ + account = '0xaccount1', + srcChainId = 42161, +} = {}) => + ({ + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: account }; + } else if (method === 'NetworkController:findNetworkClientIdByChainId') { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: new Numeric(srcChainId, 10).toPrefixedHexString(), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked); + +const executePollingWithPendingStatus = async () => { + // Setup + jest.useFakeTimers(); + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + }); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeStatusController, + 'startPollingByNetworkClientId', + ); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Execution + await bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return MockStatusResponse.getPending(); + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + return { + bridgeStatusController, + startPollingByNetworkClientIdSpy, + fetchBridgeTxStatusSpy, + }; +}; + +describe('BridgeStatusController', () => { + describe('constructor', () => { + it('should setup correctly', () => { + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + }); + expect(bridgeStatusController.state).toEqual(EMPTY_INIT_STATE); + }); + it('rehydrates the tx history state', async () => { + // Setup + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + state: { + bridgeStatusState: { + txHistory: MockTxHistory.getPending(), + }, + }, + }); + + // Execution + await bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Assertion + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toMatchSnapshot(); + }); + it('restarts polling for history items that are not complete', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Execution + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + state: { + bridgeStatusState: { + txHistory: MockTxHistory.getPending(), + }, + }, + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('startPollingForBridgeTxStatus', () => { + it('sets the inital tx history state', async () => { + // Setup + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + }); + + // Execution + await bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Assertion + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toMatchSnapshot(); + }); + it('starts polling and updates the tx history when the status response is received', async () => { + const { + bridgeStatusController, + startPollingByNetworkClientIdSpy, + fetchBridgeTxStatusSpy, + } = await executePollingWithPendingStatus(); + + // Assertions + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalled(); + expect(bridgeStatusController.state.bridgeStatusState.txHistory).toEqual( + MockTxHistory.getPending(), + ); + }); + it('stops polling when the status response is complete', async () => { + // Setup + jest.useFakeTimers(); + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + }); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const stopPollingByNetworkClientIdSpy = jest.spyOn( + bridgeStatusController, + 'stopPollingByPollingToken', + ); + + // Execution + await bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(bridgeStatusController.state.bridgeStatusState.txHistory).toEqual( + MockTxHistory.getComplete(), + ); + }); + }); + describe('resetState', () => { + it('resets the state', async () => { + const { bridgeStatusController } = + await executePollingWithPendingStatus(); + + expect(bridgeStatusController.state.bridgeStatusState.txHistory).toEqual( + MockTxHistory.getPending(), + ); + bridgeStatusController.resetState(); + expect(bridgeStatusController.state.bridgeStatusState.txHistory).toEqual( + EMPTY_INIT_STATE.bridgeStatusState.txHistory, + ); + }); + }); + describe('wipeBridgeStatus', () => { + it('wipes the bridge status for the given address', async () => { + // Setup + jest.useFakeTimers(); + + let getSelectedAccountCalledTimes = 0; + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + let account; + if (getSelectedAccountCalledTimes === 0) { + account = '0xaccount1'; + } else { + account = '0xaccount2'; + } + getSelectedAccountCalledTimes += 1; + return { address: account }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: new Numeric(42161, 10).toPrefixedHexString(), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + destTxHash: '0xdestTxHash2', + }); + }); + + // Start polling for 0xaccount1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for 0xaccount2 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: '0xsrcTxHash2', + account: '0xaccount2', + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check that both accounts have a tx history entry + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toHaveProperty('0xsrcTxHash1'); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toHaveProperty('0xsrcTxHash2'); + + // Wipe the status for 1 account only + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].account).toEqual('0xaccount2'); + }); + it('wipes the bridge status for all networks if ignoreNetwork is true', () => { + // Setup + jest.useFakeTimers(); + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: new Numeric(42161, 10).toPrefixedHexString(), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }); + }); + + // Start polling for chainId 42161 to chainId 1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash1'] + .quote.srcChainId, + ).toEqual(42161); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash1'] + .quote.destChainId, + ).toEqual(1); + + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash2'] + .quote.srcChainId, + ).toEqual(10); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash2'] + .quote.destChainId, + ).toEqual(123); + + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: true, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(0); + }); + it('wipes the bridge status only for the current network if ignoreNetwork is false', () => { + // Setup + jest.useFakeTimers(); + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + // This is what controls the selectedNetwork and what gets wiped in this test + chainId: new Numeric(42161, 10).toPrefixedHexString(), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }); + }); + + // Start polling for chainId 42161 to chainId 1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash1'] + .quote.srcChainId, + ).toEqual(42161); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash1'] + .quote.destChainId, + ).toEqual(1); + + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash2'] + .quote.srcChainId, + ).toEqual(10); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory['0xsrcTxHash2'] + .quote.destChainId, + ).toEqual(123); + + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].quote.srcChainId).toEqual(10); + expect(txHistoryItems[0].quote.destChainId).toEqual(123); + }); + }); +}); diff --git a/app/scripts/controllers/bridge-status/bridge-status-controller.ts b/app/scripts/controllers/bridge-status/bridge-status-controller.ts new file mode 100644 index 000000000000..18010ae0de3d --- /dev/null +++ b/app/scripts/controllers/bridge-status/bridge-status-controller.ts @@ -0,0 +1,310 @@ +import { StateMetadata } from '@metamask/base-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { Hex } from '@metamask/utils'; +// eslint-disable-next-line import/no-restricted-paths +import { + StartPollingForBridgeTxStatusArgs, + StatusRequest, + StatusTypes, + BridgeStatusControllerState, +} from '../../../../shared/types/bridge-status'; +import { decimalToPrefixedHex } from '../../../../shared/modules/conversion.utils'; +import { + BRIDGE_STATUS_CONTROLLER_NAME, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + REFRESH_INTERVAL_MS, +} from './constants'; +import { BridgeStatusControllerMessenger } from './types'; +import { fetchBridgeTxStatus } from './utils'; + +const metadata: StateMetadata<{ + bridgeStatusState: BridgeStatusControllerState; +}> = { + // We want to persist the bridge status state so that we can show the proper data for the Activity list + // basically match the behavior of TransactionController + bridgeStatusState: { + persist: true, + anonymous: false, + }, +}; + +type SrcTxHash = string; +export type FetchBridgeTxStatusArgs = { + statusRequest: StatusRequest; +}; +export default class BridgeStatusController extends StaticIntervalPollingController< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + { bridgeStatusState: BridgeStatusControllerState }, + BridgeStatusControllerMessenger +> { + #pollingTokensBySrcTxHash: Record = {}; + + constructor({ + messenger, + state, + }: { + messenger: BridgeStatusControllerMessenger; + state?: Partial<{ + bridgeStatusState: BridgeStatusControllerState; + }>; + }) { + super({ + name: BRIDGE_STATUS_CONTROLLER_NAME, + metadata, + messenger, + // Restore the persisted state + state: { + ...state, + bridgeStatusState: { + ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + ...state?.bridgeStatusState, + }, + }, + }); + + // Register action handlers + this.messagingSystem.registerActionHandler( + `${BRIDGE_STATUS_CONTROLLER_NAME}:startPollingForBridgeTxStatus`, + this.startPollingForBridgeTxStatus.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_STATUS_CONTROLLER_NAME}:wipeBridgeStatus`, + this.wipeBridgeStatus.bind(this), + ); + + // Set interval + this.setIntervalLength(REFRESH_INTERVAL_MS); + + // If you close the extension, but keep the browser open, the polling continues + // If you close the browser, the polling stops + // Check for historyItems that do not have a status of complete and restart polling + this.#restartPollingForIncompleteHistoryItems(); + } + + resetState = () => { + this.update((_state) => { + _state.bridgeStatusState = { + ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + }; + }); + }; + + wipeBridgeStatus = ({ + address, + ignoreNetwork, + }: { + address: string; + ignoreNetwork: boolean; + }) => { + // Wipe all networks for this address + if (ignoreNetwork) { + this.update((_state) => { + _state.bridgeStatusState = { + ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + }; + }); + } else { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + const selectedNetworkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + const selectedChainId = selectedNetworkClient.configuration.chainId; + + this.#wipeBridgeStatusByChainId(address, selectedChainId); + } + }; + + #restartPollingForIncompleteHistoryItems = () => { + // Check for historyItems that do not have a status of complete and restart polling + const { bridgeStatusState } = this.state; + const historyItems = Object.values(bridgeStatusState.txHistory); + const incompleteHistoryItems = historyItems + .filter( + (historyItem) => historyItem.status.status !== StatusTypes.COMPLETE, + ) + .filter((historyItem) => { + // Check if we are already polling this tx, if so, skip restarting polling for that + const srcTxHash = historyItem.status.srcChain.txHash; + const pollingToken = this.#pollingTokensBySrcTxHash[srcTxHash]; + return !pollingToken; + }); + + incompleteHistoryItems.forEach((historyItem) => { + const statusRequest = { + bridgeId: historyItem.quote.bridgeId, + srcTxHash: historyItem.status.srcChain.txHash, + bridge: historyItem.quote.bridges[0], + srcChainId: historyItem.quote.srcChainId, + destChainId: historyItem.quote.destChainId, + quote: historyItem.quote, + refuel: Boolean(historyItem.quote.refuel), + }; + + const hexSourceChainId = decimalToPrefixedHex(statusRequest.srcChainId); + const networkClientId = this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + hexSourceChainId, + ); + + // We manually call startPollingByNetworkClientId() here rather than go through startPollingForBridgeTxStatus() + // because we don't want to overwrite the existing historyItem in state + const options: FetchBridgeTxStatusArgs = { statusRequest }; + this.#pollingTokensBySrcTxHash[statusRequest.srcTxHash] = + this.startPollingByNetworkClientId(networkClientId, options); + }); + }; + + startPollingForBridgeTxStatus = ( + startPollingForBridgeTxStatusArgs: StartPollingForBridgeTxStatusArgs, + ) => { + const { + statusRequest, + quoteResponse, + startTime, + slippagePercentage, + pricingData, + initialDestAssetBalance, + targetContractAddress, + } = startPollingForBridgeTxStatusArgs; + const hexSourceChainId = decimalToPrefixedHex(statusRequest.srcChainId); + + const { bridgeStatusState } = this.state; + const { address: account } = this.#getSelectedAccount(); + + // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API + // We know it's in progress but not the exact status yet + this.update((_state) => { + _state.bridgeStatusState = { + ...bridgeStatusState, + txHistory: { + ...bridgeStatusState.txHistory, + [statusRequest.srcTxHash]: { + quote: quoteResponse.quote, + startTime, + estimatedProcessingTimeInSeconds: + quoteResponse.estimatedProcessingTimeInSeconds, + slippagePercentage, + pricingData, + initialDestAssetBalance, + targetContractAddress, + account, + status: { + // We always have a PENDING status when we start polling for a tx, don't need the Bridge API for that + // Also we know the bare minimum fields for status at this point in time + status: StatusTypes.PENDING, + srcChain: { + chainId: statusRequest.srcChainId, + txHash: statusRequest.srcTxHash, + }, + }, + }, + }, + }; + }); + + const networkClientId = this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + hexSourceChainId, + ); + this.#pollingTokensBySrcTxHash[statusRequest.srcTxHash] = + this.startPollingByNetworkClientId(networkClientId, { statusRequest }); + }; + + // This will be called after you call this.startPollingByNetworkClientId() + // The args passed in are the args you passed in to startPollingByNetworkClientId() + _executePoll = async ( + _networkClientId: string, + fetchBridgeTxStatusArgs: FetchBridgeTxStatusArgs, + ) => { + await this.#fetchBridgeTxStatus(fetchBridgeTxStatusArgs); + }; + + #getSelectedAccount() { + return this.messagingSystem.call('AccountsController:getSelectedAccount'); + } + + #fetchBridgeTxStatus = async ({ statusRequest }: FetchBridgeTxStatusArgs) => { + const { bridgeStatusState } = this.state; + + try { + // We try here because we receive 500 errors from Bridge API if we try to fetch immediately after submitting the source tx + // Oddly mostly happens on Optimism, never on Arbitrum. By the 2nd fetch, the Bridge API responds properly. + const status = await fetchBridgeTxStatus(statusRequest); + + // No need to purge these on network change or account change, TransactionController does not purge either. + // TODO In theory we can skip checking status if it's not the current account/network + // we need to keep track of the account that this is associated with as well so that we don't show it in Activity list for other accounts + // First stab at this will not stop polling when you are on a different account + this.update((_state) => { + const bridgeHistoryItem = + _state.bridgeStatusState.txHistory[statusRequest.srcTxHash]; + + _state.bridgeStatusState = { + ...bridgeStatusState, + txHistory: { + ...bridgeStatusState.txHistory, + [statusRequest.srcTxHash]: { + ...bridgeHistoryItem, + status, + }, + }, + }; + }); + + const pollingToken = + this.#pollingTokensBySrcTxHash[statusRequest.srcTxHash]; + if (status.status === StatusTypes.COMPLETE && pollingToken) { + this.stopPollingByPollingToken(pollingToken); + } + } catch (e) { + console.log('Failed to fetch bridge tx status', e); + } + }; + + // Wipes the bridge status for the given address and chainId + // Will match either source or destination chainId to the selectedChainId + #wipeBridgeStatusByChainId = (address: string, selectedChainId: Hex) => { + const sourceTxHashesToDelete = Object.keys( + this.state.bridgeStatusState.txHistory, + ).filter((sourceTxHash) => { + const bridgeHistoryItem = + this.state.bridgeStatusState.txHistory[sourceTxHash]; + + const hexSourceChainId = decimalToPrefixedHex( + bridgeHistoryItem.quote.srcChainId, + ); + const hexDestChainId = decimalToPrefixedHex( + bridgeHistoryItem.quote.destChainId, + ); + + return ( + bridgeHistoryItem.account === address && + (hexSourceChainId === selectedChainId || + hexDestChainId === selectedChainId) + ); + }); + + sourceTxHashesToDelete.forEach((sourceTxHash) => { + const pollingToken = this.#pollingTokensBySrcTxHash[sourceTxHash]; + + if (pollingToken) { + this.stopPollingByPollingToken( + this.#pollingTokensBySrcTxHash[sourceTxHash], + ); + } + }); + + this.update((_state) => { + _state.bridgeStatusState.txHistory = sourceTxHashesToDelete.reduce( + (acc, sourceTxHash) => { + delete acc[sourceTxHash]; + return acc; + }, + _state.bridgeStatusState.txHistory, + ); + }); + }; +} diff --git a/app/scripts/controllers/bridge-status/constants.ts b/app/scripts/controllers/bridge-status/constants.ts new file mode 100644 index 000000000000..83208bdc73d8 --- /dev/null +++ b/app/scripts/controllers/bridge-status/constants.ts @@ -0,0 +1,10 @@ +import { BridgeStatusControllerState } from '../../../../shared/types/bridge-status'; + +export const REFRESH_INTERVAL_MS = 10 * 1000; + +export const BRIDGE_STATUS_CONTROLLER_NAME = 'BridgeStatusController'; + +export const DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE: BridgeStatusControllerState = + { + txHistory: {}, + }; diff --git a/app/scripts/controllers/bridge-status/types.ts b/app/scripts/controllers/bridge-status/types.ts new file mode 100644 index 000000000000..040cd1e0c9bd --- /dev/null +++ b/app/scripts/controllers/bridge-status/types.ts @@ -0,0 +1,56 @@ +import { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import { + BridgeStatusAction, + BridgeStatusControllerState, +} from '../../../../shared/types/bridge-status'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; +import BridgeStatusController from './bridge-status-controller'; + +type BridgeStatusControllerAction< + FunctionName extends keyof BridgeStatusController, +> = { + type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:${FunctionName}`; + handler: BridgeStatusController[FunctionName]; +}; + +// Maps to BridgeController function names +type BridgeStatusControllerActions = + | BridgeStatusControllerAction + | BridgeStatusControllerAction + | ControllerGetStateAction< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState + >; + +type BridgeStatusControllerEvents = ControllerStateChangeEvent< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState +>; + +type AllowedActions = + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | AccountsControllerGetSelectedAccountAction; +type AllowedEvents = never; + +/** + * The messenger for the BridgeStatusController. + */ +export type BridgeStatusControllerMessenger = RestrictedControllerMessenger< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerActions | AllowedActions, + BridgeStatusControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/app/scripts/controllers/bridge-status/utils.ts b/app/scripts/controllers/bridge-status/utils.ts new file mode 100644 index 000000000000..323e7e2faeab --- /dev/null +++ b/app/scripts/controllers/bridge-status/utils.ts @@ -0,0 +1,49 @@ +import { + BRIDGE_API_BASE_URL, + BRIDGE_CLIENT_ID, +} from '../../../../shared/constants/bridge'; +import fetchWithCache from '../../../../shared/lib/fetch-with-cache'; +import { + StatusResponse, + StatusRequest, +} from '../../../../shared/types/bridge-status'; +import { validateResponse, validators } from './validators'; + +const CLIENT_ID_HEADER = { 'X-Client-Id': BRIDGE_CLIENT_ID }; + +export const BRIDGE_STATUS_BASE_URL = `${BRIDGE_API_BASE_URL}/getTxStatus`; + +export const fetchBridgeTxStatus = async (statusRequest: StatusRequest) => { + // Assemble params + const { quote, ...statusRequestNoQuote } = statusRequest; + const statusRequestNoQuoteFormatted = Object.fromEntries( + Object.entries(statusRequestNoQuote).map(([key, value]) => [ + key, + value.toString(), + ]), + ); + const params = new URLSearchParams(statusRequestNoQuoteFormatted); + + // Fetch + const url = `${BRIDGE_STATUS_BASE_URL}?${params.toString()}`; + + const rawTxStatus = await fetchWithCache({ + url, + fetchOptions: { method: 'GET', headers: CLIENT_ID_HEADER }, + cacheOptions: { cacheRefreshTime: 0 }, + functionName: 'fetchBridgeTxStatus', + }); + + // Validate + const isValid = validateResponse( + validators, + rawTxStatus, + BRIDGE_STATUS_BASE_URL, + ); + if (!isValid) { + throw new Error('Invalid response from bridge'); + } + + // Return + return rawTxStatus; +}; diff --git a/app/scripts/controllers/bridge-status/validators.test.ts b/app/scripts/controllers/bridge-status/validators.test.ts new file mode 100644 index 000000000000..18ca81d7a5b2 --- /dev/null +++ b/app/scripts/controllers/bridge-status/validators.test.ts @@ -0,0 +1,238 @@ +import { StatusResponse } from '../../../../shared/types/bridge-status'; +import { validateResponse, validators } from './validators'; + +const BridgeTxStatusResponses = { + STATUS_PENDING_VALID: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '10', + token: {}, + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS: { + status: 'PENDING', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS_2: { + status: 'PENDING', + bridge: 'hop', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + amount: '991250000000000', + token: { + chainId: 42161, + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + logoURI: 'https://media.socket.tech/tokens/all/ETH', + chainAgnosticId: null, + }, + }, + }, + STATUS_PENDING_INVALID_MISSING_FIELDS: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + token: {}, + }, + }, + STATUS_COMPLETE_VALID: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '42161', + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_VALID_MISSING_FIELDS: { + status: 'COMPLETE', + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '42161', + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_INVALID_MISSING_FIELDS: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + }, +}; + +describe('validators', () => { + describe('bridgeStatusValidator', () => { + // @ts-expect-error - it.each is a function + it.each([ + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID, + expected: true, + description: 'valid pending bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS, + expected: true, + description: 'valid pending bridge status missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS_2, + expected: true, + description: 'valid pending bridge status missing fields 2', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_INVALID_MISSING_FIELDS, + expected: false, + description: 'pending bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID, + expected: true, + description: 'valid complete bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_INVALID_MISSING_FIELDS, + expected: false, + description: 'complete bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID_MISSING_FIELDS, + expected: true, + description: 'complete bridge status with missing fields', + }, + { + input: undefined, + expected: false, + description: 'undefined', + }, + { + input: null, + expected: false, + description: 'null', + }, + { + input: {}, + expected: false, + description: 'empty object', + }, + ])( + 'should return $expected for $description', + ({ input, expected }: { input: unknown; expected: boolean }) => { + const res = validateResponse( + validators, + input, + 'dummyurl.com', + ); + expect(res).toBe(expected); + }, + ); + }); +}); diff --git a/app/scripts/controllers/bridge-status/validators.ts b/app/scripts/controllers/bridge-status/validators.ts new file mode 100644 index 000000000000..69e788025b01 --- /dev/null +++ b/app/scripts/controllers/bridge-status/validators.ts @@ -0,0 +1,179 @@ +import { validHex, validateData } from '../../../../shared/lib/swaps-utils'; +import { isValidHexAddress } from '../../../../shared/modules/hexstring-utils'; +import { + BridgeId, + DestChainStatus, + SrcChainStatus, + Asset, + StatusTypes, +} from '../../../../shared/types/bridge-status'; +import { BRIDGE_STATUS_BASE_URL } from './utils'; + +type Validator = { + property: keyof ExpectedResponse | string; + type: string; + validator: (value: DataToValidate) => boolean; +}; + +export const validateResponse = ( + validators: Validator[], + data: unknown, + urlUsed: string, +): data is ExpectedResponse => { + if (data === null || data === undefined) { + return false; + } + return validateData(validators, data, urlUsed); +}; + +const assetValidators = [ + { + property: 'chainId', + type: 'number', + validator: (v: unknown): v is number => typeof v === 'number', + }, + { + property: 'address', + type: 'string', + validator: (v: unknown): v is string => isValidHexAddress(v as string), + }, + { + property: 'symbol', + type: 'string', + validator: (v: unknown): v is string => typeof v === 'string', + }, + { + property: 'name', + type: 'string', + validator: (v: unknown): v is string => typeof v === 'string', + }, + { + property: 'decimals', + type: 'number', + validator: (v: unknown): v is number => typeof v === 'number', + }, + { + property: 'icon', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, +]; + +const assetValidator = (v: unknown): v is Asset => + validateResponse(assetValidators, v, BRIDGE_STATUS_BASE_URL); + +const srcChainStatusValidators = [ + { + property: 'chainId', + // For some reason, API returns destChain.chainId as a string, it's a number everywhere else + type: 'number|string', + validator: (v: unknown): v is number | string => + typeof v === 'number' || typeof v === 'string', + }, + { + property: 'txHash', + type: 'string', + validator: validHex, + }, + { + property: 'amount', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'token', + type: 'object|undefined', + validator: (v: unknown): v is object | undefined => + v === undefined || assetValidator(v), + }, +]; + +const srcChainStatusValidator = (v: unknown): v is SrcChainStatus => + validateResponse( + srcChainStatusValidators, + v, + BRIDGE_STATUS_BASE_URL, + ); + +const destChainStatusValidators = [ + { + property: 'chainId', + // For some reason, API returns destChain.chainId as a string, it's a number everywhere else + type: 'number|string', + validator: (v: unknown): v is number | string => + typeof v === 'number' || typeof v === 'string', + }, + { + property: 'amount', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'txHash', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'token', + type: 'object|undefined', + validator: (v: unknown): v is Asset | undefined => + v === undefined || + (v && typeof v === 'object' && Object.keys(v).length === 0) || + assetValidator(v), + }, +]; + +const destChainStatusValidator = (v: unknown): v is DestChainStatus => + validateResponse( + destChainStatusValidators, + v, + BRIDGE_STATUS_BASE_URL, + ); + +export const validators = [ + { + property: 'status', + type: 'string', + validator: (v: unknown): v is StatusTypes => + Object.values(StatusTypes).includes(v as StatusTypes), + }, + { + property: 'srcChain', + type: 'object', + validator: srcChainStatusValidator, + }, + { + property: 'destChain', + type: 'object|undefined', + validator: (v: unknown): v is object | unknown => + v === undefined || destChainStatusValidator(v), + }, + { + property: 'bridge', + type: 'string|undefined', + validator: (v: unknown): v is BridgeId | undefined => + v === undefined || Object.values(BridgeId).includes(v as BridgeId), + }, + { + property: 'isExpectedToken', + type: 'boolean|undefined', + validator: (v: unknown): v is boolean | undefined => + v === undefined || typeof v === 'boolean', + }, + { + property: 'isUnrecognizedRouterAddress', + type: 'boolean|undefined', + validator: (v: unknown): v is boolean | undefined => + v === undefined || typeof v === 'boolean', + }, + // TODO: add refuel validator + // { + // property: 'refuel', + // type: 'object', + // validator: (v: unknown) => Object.values(RefuelStatusResponse).includes(v), + // }, +]; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index ff3ad58f26d4..55888558c6d6 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -243,6 +243,7 @@ import { getProviderConfig } from '../../shared/modules/selectors/networks'; import { endTrace, trace } from '../../shared/lib/trace'; // eslint-disable-next-line import/no-restricted-paths import { isSnapId } from '../../ui/helpers/utils/snaps'; +import { BridgeStatusAction } from '../../shared/types/bridge-status'; import { BalancesController as MultichainBalancesController } from './lib/accounts/BalancesController'; import { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -371,6 +372,8 @@ import { import createTracingMiddleware from './lib/createTracingMiddleware'; import { PatchStore } from './lib/PatchStore'; import { sanitizeUIState } from './lib/state-utils'; +import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './controllers/bridge-status/constants'; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) @@ -2185,6 +2188,22 @@ export default class MetamaskController extends EventEmitter { ), }); + const bridgeStatusControllerMessenger = + this.controllerMessenger.getRestricted({ + name: BRIDGE_STATUS_CONTROLLER_NAME, + allowedActions: [ + 'AccountsController:getSelectedAccount', + 'NetworkController:getNetworkClientById', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getState', + ], + allowedEvents: [], + }); + this.bridgeStatusController = new BridgeStatusController({ + messenger: bridgeStatusControllerMessenger, + state: initState.BridgeStatusController, + }); + const smartTransactionsControllerMessenger = this.controllerMessenger.getRestricted({ name: 'SmartTransactionsController', @@ -2424,6 +2443,7 @@ export default class MetamaskController extends EventEmitter { SignatureController: this.signatureController, SwapsController: this.swapsController, BridgeController: this.bridgeController, + BridgeStatusController: this.bridgeStatusController, EnsController: this.ensController, ApprovalController: this.approvalController, PPOMController: this.ppomController, @@ -4015,6 +4035,13 @@ export default class MetamaskController extends EventEmitter { `${BRIDGE_CONTROLLER_NAME}:${BridgeUserAction.UPDATE_QUOTE_PARAMS}`, ), + // Bridge Status + [BridgeStatusAction.START_POLLING_FOR_BRIDGE_TX_STATUS]: + this.controllerMessenger.call.bind( + this.controllerMessenger, + `${BRIDGE_STATUS_CONTROLLER_NAME}:${BridgeStatusAction.START_POLLING_FOR_BRIDGE_TX_STATUS}`, + ), + // Smart Transactions fetchSmartTransactionFees: smartTransactionsController.getFees.bind( smartTransactionsController, @@ -5005,6 +5032,10 @@ export default class MetamaskController extends EventEmitter { address: selectedAddress, ignoreNetwork: false, }); + this.bridgeStatusController.wipeBridgeStatus({ + address: selectedAddress, + ignoreNetwork: false, + }); this.networkController.resetConnection(); return selectedAddress; diff --git a/shared/constants/transaction.ts b/shared/constants/transaction.ts index 24b89140f941..38311de3be76 100644 --- a/shared/constants/transaction.ts +++ b/shared/constants/transaction.ts @@ -113,6 +113,11 @@ export enum TransactionGroupCategory { * Transaction group representing a token swap through MetaMask Swaps, where the final token is sent to another address. */ swapAndSend = 'swapAndSend', + /** + * Transaction group representing a token bridge through MetaMask Bridge, + * where the final token is sent to another chain. + */ + bridge = 'bridge', } /** diff --git a/shared/modules/conversion.utils.ts b/shared/modules/conversion.utils.ts index 5c70e5ecd683..ad83e9e8c634 100644 --- a/shared/modules/conversion.utils.ts +++ b/shared/modules/conversion.utils.ts @@ -185,6 +185,12 @@ export function decimalToHex(decimal: number | string | BigNumber | BN) { return new Numeric(decimal, 10).toBase(16).toString(); } +export function decimalToPrefixedHex( + decimal: number | string | BigNumber | BN, +): Hex { + return new Numeric(decimal, 10).toPrefixedHexString() as Hex; +} + export function hexToDecimal(hexValue: number | string | BigNumber | BN) { return new Numeric(hexValue, 16).toBase(10).toString(); } diff --git a/shared/types/bridge-status.ts b/shared/types/bridge-status.ts new file mode 100644 index 000000000000..601a2209aaf9 --- /dev/null +++ b/shared/types/bridge-status.ts @@ -0,0 +1,146 @@ +// eslint-disable-next-line import/no-restricted-paths +import { ChainId, Quote, QuoteResponse } from '../../ui/pages/bridge/types'; + +// All fields need to be types not interfaces, same with their children fields +// o/w you get a type error + +export enum StatusTypes { + UNKNOWN = 'UNKNOWN', + FAILED = 'FAILED', + PENDING = 'PENDING', + COMPLETE = 'COMPLETE', +} + +export type StatusRequest = { + bridgeId: string; // lifi, socket, squid + srcTxHash: string; // lifi, socket, squid + bridge: string; // lifi, socket, squid + srcChainId: ChainId; // lifi, socket, squid + destChainId: ChainId; // lifi, socket, squid + quote?: Quote; // squid + refuel?: boolean; // lifi +}; + +export type Asset = { + chainId: ChainId; + address: string; + symbol: string; + name: string; + decimals: number; + icon?: string; +}; + +export type SrcChainStatus = { + chainId: ChainId; + txHash: string; + amount?: string; + token?: Asset; +}; + +export type DestChainStatus = { + chainId: ChainId; + txHash?: string; + amount?: string; + token?: Record | Asset; +}; + +export enum BridgeId { + HOP = 'hop', + CELER = 'celer', + CELERCIRCLE = 'celercircle', + CONNEXT = 'connext', + POLYGON = 'polygon', + AVALANCHE = 'avalanche', + MULTICHAIN = 'multichain', + AXELAR = 'axelar', + ACROSS = 'across', + STARGATE = 'stargate', +} + +export enum FeeType { + METABRIDGE = 'metabridge', + REFUEL = 'refuel', +} + +export type FeeData = { + amount: string; + asset: Asset; +}; + +export type Protocol = { + displayName?: string; + icon?: string; + name?: string; // for legacy quotes +}; + +export enum ActionTypes { + BRIDGE = 'bridge', + SWAP = 'swap', + REFUEL = 'refuel', +} + +export type Step = { + action: ActionTypes; + srcChainId: ChainId; + destChainId?: ChainId; + srcAsset: Asset; + destAsset: Asset; + srcAmount: string; + destAmount: string; + protocol: Protocol; +}; + +export type StatusResponse = { + status: StatusTypes; + srcChain: SrcChainStatus; + destChain?: DestChainStatus; + bridge?: BridgeId; + isExpectedToken?: boolean; + isUnrecognizedRouterAddress?: boolean; + refuel?: RefuelStatusResponse; +}; + +export type RefuelStatusResponse = object & StatusResponse; + +export type RefuelData = object & Step; + +export type BridgeHistoryItem = { + quote: Quote; + status: StatusResponse; + startTime?: number; + estimatedProcessingTimeInSeconds: number; + slippagePercentage: number; + completionTime?: number; + pricingData?: { + quotedGasInUsd: number; + quotedReturnInUsd: number; + amountSentInUsd: number; + quotedRefuelSrcAmountInUsd?: number; + quotedRefuelDestAmountInUsd?: number; + }; + initialDestAssetBalance?: number; + targetContractAddress?: string; + account: string; +}; + +export enum BridgeStatusAction { + START_POLLING_FOR_BRIDGE_TX_STATUS = 'startPollingForBridgeTxStatus', + WIPE_BRIDGE_STATUS = 'wipeBridgeStatus', + GET_STATE = 'getState', +} + +export type StartPollingForBridgeTxStatusArgs = { + statusRequest: StatusRequest; + quoteResponse: QuoteResponse; + startTime?: BridgeHistoryItem['startTime']; + slippagePercentage: BridgeHistoryItem['slippagePercentage']; + pricingData?: BridgeHistoryItem['pricingData']; + initialDestAssetBalance?: BridgeHistoryItem['initialDestAssetBalance']; + targetContractAddress?: BridgeHistoryItem['targetContractAddress']; +}; + +export type SourceChainTxHash = string; + +export type BridgeStatusControllerState = { + txHistory: Record; +}; diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 734845f0ca9a..b315dfa203eb 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -2035,6 +2035,9 @@ } } } + }, + "bridgeStatusState": { + "txHistory": {} } }, "ramps": { 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 e8ca8579a7a4..3c2430fbe063 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 @@ -67,38 +67,39 @@ "srcNetworkAllowlist": { "0": "string", "1": "string", "2": "string" }, "destNetworkAllowlist": { "0": "string", "1": "string", "2": "string" } }, + "srcTokens": {}, + "srcTopAssets": {}, "destTokens": {}, "destTopAssets": {}, "quoteRequest": { - "slippage": 0.5, - "srcTokenAddress": "0x0000000000000000000000000000000000000000" + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "slippage": 0.5 }, "quotes": {}, - "quotesRefreshCount": 0, - "srcTokens": {}, - "srcTopAssets": {} + "quotesRefreshCount": 0 } }, + "BridgeStatusController": { "bridgeStatusState": { "txHistory": "object" } }, "CronjobController": { "jobs": "object" }, "CurrencyController": { + "currentCurrency": "usd", "currencyRates": { "ETH": { "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 }, - "LineaETH": { + "SepoliaETH": { "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 }, - "SepoliaETH": { + "LineaETH": { "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 } - }, - "currentCurrency": "usd" + } }, "DecryptMessageController": { "unapprovedDecryptMsgs": "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 cbc9ff5b74c4..8b2efef3e517 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 @@ -47,7 +47,6 @@ "completedOnboarding": true, "knownMethodData": "object", "use4ByteResolution": true, - "showIncomingTransactions": "object", "participateInMetaMetrics": true, "dataCollectionForMarketing": "boolean", "nextNonce": null, @@ -57,12 +56,12 @@ "conversionRate": 1700, "usdConversionRate": 1700 }, - "LineaETH": { + "SepoliaETH": { "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 }, - "SepoliaETH": { + "LineaETH": { "conversionDate": "number", "conversionRate": 1700, "usdConversionRate": 1700 @@ -137,7 +136,6 @@ "forgottenPassword": false, "ipfsGateway": "string", "isIpfsGatewayEnabled": "boolean", - "isMultiAccountBalancesEnabled": "boolean", "useAddressBarEnsResolution": true, "ledgerTransportType": "webhid", "snapRegistryList": "object", @@ -147,6 +145,8 @@ "useTransactionSimulations": true, "enableMV3TimestampSave": true, "useExternalServices": "boolean", + "isMultiAccountBalancesEnabled": "boolean", + "showIncomingTransactions": "object", "metaMetricsId": "fake-metrics-id", "marketingCampaignCookieId": null, "eventsBeforeMetricsOptIn": "object", @@ -239,11 +239,11 @@ "accounts": "object", "accountsByChainId": "object", "marketData": "object", - "signatureRequests": "object", "unapprovedDecryptMsgs": "object", "unapprovedDecryptMsgCount": 0, "unapprovedEncryptionPublicKeyMsgs": "object", "unapprovedEncryptionPublicKeyMsgCount": 0, + "signatureRequests": "object", "unapprovedPersonalMsgs": "object", "unapprovedTypedMessages": "object", "unapprovedPersonalMsgCount": 0, @@ -283,17 +283,18 @@ "srcNetworkAllowlist": { "0": "string", "1": "string", "2": "string" }, "destNetworkAllowlist": { "0": "string", "1": "string", "2": "string" } }, + "srcTokens": {}, + "srcTopAssets": {}, "destTokens": {}, "destTopAssets": {}, "quoteRequest": { - "slippage": 0.5, - "srcTokenAddress": "0x0000000000000000000000000000000000000000" + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "slippage": 0.5 }, "quotes": {}, - "quotesRefreshCount": 0, - "srcTokens": {}, - "srcTopAssets": {} + "quotesRefreshCount": 0 }, + "bridgeStatusState": { "txHistory": "object" }, "ensEntries": "object", "ensResolutionsByAddress": "object", "pendingApprovals": "object", From 6dda4443aaf341643d62e5b3d042bea90b4f017b Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 26 Nov 2024 15:20:03 +0000 Subject: [PATCH 083/148] feat: Enable redesigned transaction confirmations for all users (#28321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates all users to the redesigned transactions. The setting will be automatically toggled on. This change initially broke a number of end-to-end tests. After an analysis, I grouped these failing tests into three categories: 1. User flows that have to be tested before enabling the redesign to all users, 🔴 This includes multichain and transaction insights tests. All tests on these files were tweaked to pass on redesigned confirmations. Specific tests that initially broke for new confirmations were also duplicated and included the temporary helper method `tempToggleSettingRedesignedTransactionConfirmations` so they were run for the old confirmations that we still need to support. 2. User flows that we don't need to migrate immediately, but will have to be migrated or adapted when old confirmations flows are deleted, 🟡 For these tests, we simply added `tempToggleSettingRedesignedTransactionConfirmations`. Once we remove the old flows, we can modify the tests to support the new flows. We didn't think it was as urgent to test these flows with the redesigned screens. 3. User flows that we don't need to migrate to redesigned confirmations. 🟢 For these tests, we simply added `tempToggleSettingRedesignedTransactionConfirmations`. Once we remove the old confirmation screens, we can delete these tests because they are no longer relevant, or otherwise already tested in the redesigned confirmation specific tests. ### Summary of the e2e tests that broke with the migration 1. User flows that have to be tested before enabling the redesign to all users, 🔴 - test/e2e/snaps/test-snap-txinsights-v2.spec.js ✅ - test/e2e/snaps/test-snap-txinsights.spec.js ✅ - test/e2e/json-rpc/switchEthereumChain.spec.js ✅ - test/e2e/tests/request-queuing/batch-txs-per-dapp-diff-network.spec.js ✅ - test/e2e/tests/request-queuing/batch-txs-per-dapp-extra-tx.spec.js ✅ - test/e2e/tests/request-queuing/batch-txs-per-dapp-same-network.spec.js ✅ - test/e2e/tests/request-queuing/dapp1-send-dapp2-signTypedData.spec.js ✅ - test/e2e/tests/request-queuing/dapp1-switch-dapp2-eth-request-accounts.spec.js ✅ - test/e2e/tests/request-queuing/dapp1-switch-dapp2-send.spec.js ✅ - test/e2e/tests/request-queuing/multi-dapp-sendTx-revokePermission.spec.js ✅ - test/e2e/tests/request-queuing/multiple-networks-dapps-txs.spec.js ✅ - test/e2e/tests/request-queuing/switch-network.spec.js ✅ - test/e2e/tests/request-queuing/ui.spec.js ✅ - test/e2e/tests/account/snap-account-transfers.spec.ts ✅ 2. User flows that we don't need to migrate immediately, but will have to be migrated or adapted when old confirmations flows are deleted, 🟡 - test/e2e/json-rpc/eth_sendTransaction.spec.js - test/e2e/tests/account/add-account.spec.ts - test/e2e/tests/petnames/petnames-transactions.spec.js - test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js - test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js - test/e2e/tests/tokens/custom-token-send-transfer.spec.js - test/e2e/tests/tokens/nft/erc721-interaction.spec.js - test/e2e/tests/tokens/nft/erc1155-interaction.spec.js - test/e2e/tests/tokens/nft/send-nft.spec.js - test/e2e/tests/transaction/change-assets.spec.js - test/e2e/tests/transaction/edit-gas-fee.spec.js - test/e2e/tests/transaction/gas-estimates.spec.js - test/e2e/tests/transaction/multiple-transactions.spec.js - test/e2e/tests/transaction/navigate-transactions.spec.js - test/e2e/tests/transaction/send-edit.spec.js - test/e2e/tests/transaction/send-eth.spec.js - test/e2e/tests/transaction/send-hex-address.spec.js 3. User flows that we don't need to migrate to redesigned confirmations. 🟢 - test/e2e/tests/dapp-interactions/contract-interactions.spec.js - test/e2e/tests/dapp-interactions/dapp-tx-edit.spec.js - test/e2e/tests/dapp-interactions/failing-contract.spec.js - test/e2e/tests/network/network-error.spec.js - test/e2e/tests/settings/4byte-directory.spec.js - test/e2e/tests/settings/show-hex-data.spec.js - test/e2e/tests/tokens/custom-token-add-approve.spec.js - test/e2e/tests/tokens/increase-token-allowance.spec.js - test/e2e/tests/transaction/simple-send.spec.ts [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28321?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3026 ## **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. --- app/scripts/migrations/132.test.ts | 73 + app/scripts/migrations/132.ts | 58 + app/scripts/migrations/index.js | 1 + test/e2e/helpers.js | 41 + test/e2e/json-rpc/eth_sendTransaction.spec.js | 3 + test/e2e/json-rpc/switchEthereumChain.spec.js | 1146 +++++++----- .../flows/send-transaction.flow.ts | 72 + .../e2e/snaps/test-snap-txinsights-v2.spec.js | 318 ++-- test/e2e/snaps/test-snap-txinsights.spec.js | 326 ++-- test/e2e/tests/account/add-account.spec.ts | 31 +- .../account/snap-account-transfers.spec.ts | 437 +++-- test/e2e/tests/confirmations/helpers.ts | 8 +- .../tests/confirmations/navigation.spec.ts | 8 +- .../signatures/malicious-signatures.spec.ts | 8 +- .../signatures/nft-permit.spec.ts | 6 +- .../confirmations/signatures/permit.spec.ts | 6 +- .../signatures/personal-sign.spec.ts | 6 +- .../signatures/sign-typed-data-v3.spec.ts | 6 +- .../signatures/sign-typed-data-v4.spec.ts | 6 +- .../signatures/sign-typed-data.spec.ts | 6 +- .../confirmations/signatures/siwe.spec.ts | 6 +- ...55-revoke-set-approval-for-all-redesign.ts | 6 +- ...1155-set-approval-for-all-redesign.spec.ts | 6 +- .../erc20-token-send-redesign.spec.ts | 10 +- ...21-revoke-set-approval-for-all-redesign.ts | 6 +- ...c721-set-approval-for-all-redesign.spec.ts | 6 +- .../transactions/native-send-redesign.spec.ts | 10 +- .../nft-token-send-redesign.spec.ts | 14 +- .../contract-interactions.spec.js | 4 +- .../dapp-interactions/dapp-tx-edit.spec.js | 5 + .../failing-contract.spec.js | 5 + test/e2e/tests/network/network-error.spec.js | 3 + .../petnames/petnames-transactions.spec.js | 6 + .../ppom-blockaid-alert-simple-send.spec.js | 4 +- .../batch-txs-per-dapp-diff-network.spec.js | 307 +++- .../batch-txs-per-dapp-extra-tx.spec.js | 477 +++-- .../batch-txs-per-dapp-same-network.spec.js | 394 ++-- .../dapp1-send-dapp2-signTypedData.spec.js | 430 +++-- ...-switch-dapp2-eth-request-accounts.spec.js | 359 ++-- .../dapp1-switch-dapp2-send.spec.js | 781 +++++--- ...multi-dapp-sendTx-revokePermission.spec.js | 356 ++-- .../multiple-networks-dapps-txs.spec.js | 347 ++-- .../request-queuing/switch-network.spec.js | 218 ++- test/e2e/tests/request-queuing/ui.spec.js | 1595 +++++++++++------ .../metamask-responsive-ui.spec.js | 4 + .../tests/settings/4byte-directory.spec.js | 5 + test/e2e/tests/settings/show-hex-data.spec.js | 4 + .../tokens/custom-token-add-approve.spec.js | 10 +- .../tokens/custom-token-send-transfer.spec.js | 14 +- .../tokens/increase-token-allowance.spec.js | 3 + .../tokens/nft/erc1155-interaction.spec.js | 13 + .../tokens/nft/erc721-interaction.spec.js | 15 + test/e2e/tests/tokens/nft/send-nft.spec.js | 3 + .../tests/transaction/change-assets.spec.js | 9 + .../tests/transaction/edit-gas-fee.spec.js | 9 + .../tests/transaction/gas-estimates.spec.js | 13 + .../transaction/multiple-transactions.spec.js | 5 + .../transaction/navigate-transactions.spec.js | 17 +- test/e2e/tests/transaction/send-edit.spec.js | 4 + test/e2e/tests/transaction/send-eth.spec.js | 9 + .../transaction/send-hex-address.spec.js | 6 + .../e2e/tests/transaction/simple-send.spec.ts | 9 +- .../experimental-tab.component.tsx | 1 + 63 files changed, 5385 insertions(+), 2689 deletions(-) create mode 100644 app/scripts/migrations/132.test.ts create mode 100644 app/scripts/migrations/132.ts diff --git a/app/scripts/migrations/132.test.ts b/app/scripts/migrations/132.test.ts new file mode 100644 index 000000000000..fb53f90a38fb --- /dev/null +++ b/app/scripts/migrations/132.test.ts @@ -0,0 +1,73 @@ +import { migrate, version } from './132'; + +const oldVersion = 131; + +describe('migration #132', () => { + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: {}, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.meta).toStrictEqual({ version }); + }); + + it('does nothing if no preferences controller state is set', async () => { + const oldState = { + OtherController: {}, + }; + + const transformedState = await migrate({ + meta: { version: oldVersion }, + data: oldState, + }); + + expect(transformedState.data).toEqual(oldState); + }); + + it('adds preferences property to the controller if it is not set and set the preference to true if migration runs', async () => { + const oldState = { PreferencesController: {} }; + + const expectedState = { + PreferencesController: { + preferences: { + redesignedTransactionsEnabled: true, + }, + }, + }; + + const transformedState = await migrate({ + meta: { version: oldVersion }, + data: oldState, + }); + + expect(transformedState.data).toEqual(expectedState); + }); + + it('changes property to true if migration runs', async () => { + const oldState = { + PreferencesController: { + preferences: { + redesignedTransactionsEnabled: false, + }, + }, + }; + + const expectedState = { + PreferencesController: { + preferences: { + redesignedTransactionsEnabled: true, + }, + }, + }; + + const transformedState = await migrate({ + meta: { version: oldVersion }, + data: oldState, + }); + + expect(transformedState.data).toEqual(expectedState); + }); +}); diff --git a/app/scripts/migrations/132.ts b/app/scripts/migrations/132.ts new file mode 100644 index 000000000000..ec12595d389a --- /dev/null +++ b/app/scripts/migrations/132.ts @@ -0,0 +1,58 @@ +import { isObject } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 132; + +/** + * This migration sets `redesignedTransactionsEnabled` as true by default in preferences in PreferencesController. + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + transformState(versionedData.data); + return versionedData; +} + +function transformState( + state: Record, +): Record { + if (!isObject(state?.PreferencesController)) { + return state; + } + + if (!isObject(state.PreferencesController?.preferences)) { + state.PreferencesController = { + ...state.PreferencesController, + preferences: {}, + }; + } + + const preferencesControllerState = state.PreferencesController as Record< + string, + unknown + >; + + const preferences = preferencesControllerState.preferences as Record< + string, + unknown + >; + + // `redesignedTransactionsEnabled` was previously set to `false` by + // default in `124.ts` + preferences.redesignedTransactionsEnabled = true; + + return state; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index d2c63eb2e35c..6cde292ba55d 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -152,6 +152,7 @@ const migrations = [ require('./129'), require('./130'), require('./131'), + require('./132'), ]; export default migrations; diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index b5962c0c079d..75eefb12f7c1 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -857,6 +857,46 @@ async function tempToggleSettingRedesignedConfirmations(driver) { ); } +/** + * Rather than using the FixtureBuilder#withPreferencesController to set the setting + * we need to manually set the setting because the migration #132 overrides this. + * We should be able to remove this when we delete the redesignedTransactionsEnabled setting. + * + * @param driver + */ +async function tempToggleSettingRedesignedTransactionConfirmations(driver) { + // Ensure we are on the extension window + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + + // Open settings menu button + await driver.clickElement('[data-testid="account-options-menu-button"]'); + + // fix race condition with mmi build + if (process.env.MMI) { + await driver.waitForSelector('[data-testid="global-menu-mmi-portfolio"]'); + } + + // Click settings from dropdown menu + await driver.clickElement('[data-testid="global-menu-settings"]'); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Click redesigned transactions toggle + await driver.clickElement( + '[data-testid="toggle-redesigned-transactions-container"]', + ); + + // Close settings page + await driver.clickElement( + '.settings-page__header__title-container__close-button', + ); +} + /** * Opens the account options menu safely, handling potential race conditions * with the MMI build. @@ -925,6 +965,7 @@ module.exports = { editGasFeeForm, clickNestedButton, tempToggleSettingRedesignedConfirmations, + tempToggleSettingRedesignedTransactionConfirmations, openMenuSafe, sentryRegEx, }; diff --git a/test/e2e/json-rpc/eth_sendTransaction.spec.js b/test/e2e/json-rpc/eth_sendTransaction.spec.js index c17421cca042..ed740d17bfc1 100644 --- a/test/e2e/json-rpc/eth_sendTransaction.spec.js +++ b/test/e2e/json-rpc/eth_sendTransaction.spec.js @@ -4,6 +4,7 @@ const { unlockWallet, WINDOW_TITLES, generateGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); @@ -68,6 +69,8 @@ describe('eth_sendTransaction', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // eth_sendTransaction await driver.openNewPage(`http://127.0.0.1:8080`); const request = JSON.stringify({ diff --git a/test/e2e/json-rpc/switchEthereumChain.spec.js b/test/e2e/json-rpc/switchEthereumChain.spec.js index 60ba4eb9aacb..6a8576b3f136 100644 --- a/test/e2e/json-rpc/switchEthereumChain.spec.js +++ b/test/e2e/json-rpc/switchEthereumChain.spec.js @@ -8,509 +8,669 @@ const { unlockWallet, switchToNotificationWindow, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); const { isManifestV3 } = require('../../../shared/modules/mv3.utils'); describe('Switch Ethereum Chain for two dapps', function () { - it('switches the chainId of two dapps when switchEthereumChain of one dapp is confirmed', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .build(), - dappOptions: { numberOfDapps: 2 }, - - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [{ port: 8546, chainId: 1338 }], + describe('Old confirmation screens', function () { + it('queues send tx after switchEthereum request with a warning, if switchEthereum request is cancelled should show pending tx', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [{ port: 8546, chainId: 1338 }], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open settings menu button - const accountOptionsMenuSelector = - '[data-testid="account-options-menu-button"]'; - await driver.waitForSelector(accountOptionsMenuSelector); - await driver.clickElement(accountOptionsMenuSelector); - - // Click settings from dropdown menu - const globalMenuSettingsSelector = - '[data-testid="global-menu-settings"]'; - await driver.waitForSelector(globalMenuSettingsSelector); - await driver.clickElement(globalMenuSettingsSelector); - - // Click Experimental tab - const experimentalTabRawLocator = { - text: 'Experimental', - tag: 'div', - }; - await driver.clickElement(experimentalTabRawLocator); - - // Toggle off request queue setting (on by default now) - await driver.clickElement( - '[data-testid="experimental-setting-toggle-request-queue"]', - ); - - // open two dapps - const dappOne = await openDapp(driver, undefined, DAPP_URL); - const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); - - // switchEthereumChain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x53a' }], - }); - - // Initiate switchEthereumChain on Dapp Two - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - - // Confirm switchEthereumChain - await switchToNotificationWindow(driver, 4); - await driver.findClickableElements({ - text: 'Confirm', - tag: 'button', - }); - await driver.clickElement({ text: 'Confirm', tag: 'button' }); - - // Switch to Dapp One - await driver.switchToWindow(dappOne); - assert.equal(await driver.getCurrentUrl(), `${DAPP_URL}/`); - - // Wait for chain id element to change, there's a page reload. - await driver.waitForSelector({ - css: '#chainId', - text: '0x53a', - }); - - // Dapp One ChainId assertion - await driver.findElement({ css: '#chainId', text: '0x53a' }); - - // Switch to Dapp Two - await driver.switchToWindow(dappTwo); - assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_URL}/`); - - // Dapp Two ChainId Assertion - await driver.findElement({ css: '#chainId', text: '0x53a' }); - }, - ); - }); - - it('queues switchEthereumChain request from second dapp after send tx request', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [{ port: 8546, chainId: 1338 }], + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open settings menu button + const accountOptionsMenuSelector = + '[data-testid="account-options-menu-button"]'; + await driver.waitForSelector(accountOptionsMenuSelector); + await driver.clickElement(accountOptionsMenuSelector); + + // Click settings from dropdown menu + const globalMenuSettingsSelector = + '[data-testid="global-menu-settings"]'; + await driver.waitForSelector(globalMenuSettingsSelector); + await driver.clickElement(globalMenuSettingsSelector); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Toggle off request queue setting (on by default now) + await driver.clickElement( + '[data-testid="experimental-setting-toggle-request-queue"]', + ); + + // open two dapps + const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); + const dappOne = await openDapp(driver, undefined, DAPP_URL); + + // Connect Dapp One + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Switch and connect Dapp Two + await driver.switchToWindow(dappTwo); + assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_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"]'); + + // 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}/`); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // Initiate switchEthereumChain on Dapp Two + 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}/`); + + // Initiate send tx on dapp one + await driver.clickElement('#sendButton'); + await driver.delay(2000); + + // Switch to notification that should still be switchEthereumChain request but with an warning. + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // THIS IS BROKEN + // await driver.findElement({ + // span: 'span', + // text: 'Switching networks will cancel all pending confirmations', + // }); + + // Cancel switchEthereumChain with queued pending tx + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + + // Delay for second notification of the pending tx + await driver.delay(1000); + + // Switch to new pending tx notification + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.findElement({ + text: 'Sending ETH', + tag: 'span', + }); + + // Confirm pending tx + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open settings menu button - const accountOptionsMenuSelector = - '[data-testid="account-options-menu-button"]'; - await driver.waitForSelector(accountOptionsMenuSelector); - await driver.clickElement(accountOptionsMenuSelector); - - // Click settings from dropdown menu - const globalMenuSettingsSelector = - '[data-testid="global-menu-settings"]'; - await driver.waitForSelector(globalMenuSettingsSelector); - await driver.clickElement(globalMenuSettingsSelector); - - // Click Experimental tab - const experimentalTabRawLocator = { - text: 'Experimental', - tag: 'div', - }; - await driver.clickElement(experimentalTabRawLocator); - - // Toggle off request queue setting (on by default now) - await driver.clickElement( - '[data-testid="experimental-setting-toggle-request-queue"]', - ); - - // open two dapps - await openDapp(driver, undefined, DAPP_URL); - await openDapp(driver, undefined, DAPP_ONE_URL); - - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // 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); - await driver.findClickableElements({ - text: 'Confirm', - tag: 'button', - }); - - // Switch to Dapp One - await driver.switchToWindowWithUrl(DAPP_URL); - - // Switch Ethereum chain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x539' }], - }); - - // Initiate switchEthereumChain on Dapp One - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - await switchToNotificationWindow(driver, 4); - await driver.findClickableElements({ - text: 'Confirm', - tag: 'button', - }); - await driver.clickElement({ - text: 'Confirm', - tag: 'button', - }); - // Delay here after notification for second notification popup for switchEthereumChain - await driver.delay(1000); - - // Switch and confirm to queued notification for switchEthereumChain - await switchToNotificationWindow(driver, 4); - - await driver.findClickableElements({ - text: 'Confirm', - tag: 'button', - }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Confirm', - tag: 'button', - }); - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.findElement({ css: '#chainId', text: '0x539' }); - }, - ); + ); + }); }); - it('queues send tx after switchEthereum request with a warning, confirming removes pending tx', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [{ port: 8546, chainId: 1338 }], + describe('Redesigned confirmation screens', function () { + it('switches the chainId of two dapps when switchEthereumChain of one dapp is confirmed', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .build(), + dappOptions: { numberOfDapps: 2 }, + + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [{ port: 8546, chainId: 1338 }], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open settings menu button - const accountOptionsMenuSelector = - '[data-testid="account-options-menu-button"]'; - await driver.waitForSelector(accountOptionsMenuSelector); - await driver.clickElement(accountOptionsMenuSelector); - - // Click settings from dropdown menu - const globalMenuSettingsSelector = - '[data-testid="global-menu-settings"]'; - await driver.waitForSelector(globalMenuSettingsSelector); - await driver.clickElement(globalMenuSettingsSelector); - - // Click Experimental tab - const experimentalTabRawLocator = { - text: 'Experimental', - tag: 'div', - }; - await driver.clickElement(experimentalTabRawLocator); - - // Toggle off request queue setting (on by default now) - await driver.clickElement( - '[data-testid="experimental-setting-toggle-request-queue"]', - ); - - // open two dapps - const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); - const dappOne = await openDapp(driver, undefined, DAPP_URL); - - // Connect Dapp One - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // Switch and connect Dapp Two - - await driver.switchToWindow(dappTwo); - assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_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"]'); - - // 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}/`); - - // switchEthereumChain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x539' }], - }); - - // Initiate switchEthereumChain on Dapp Two - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - 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}/`); - - // Initiate send tx on dapp one - await driver.clickElement('#sendButton'); - await driver.delay(2000); - - // Switch to notification that should still be switchEthereumChain request but with a warning. - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // THIS IS BROKEN - // await driver.findElement({ - // span: 'span', - // text: 'Switching networks will cancel all pending confirmations', - // }); - - // Confirm switchEthereumChain with queued pending tx - await driver.clickElement({ text: 'Confirm', tag: 'button' }); - - // Window handles should only be expanded mm, dapp one, dapp 2, and the offscreen document - // if this is an MV3 build(3 or 4 total) - await driver.wait(async () => { - const windowHandles = await driver.getAllWindowHandles(); - const numberOfWindowHandlesToExpect = isManifestV3 ? 4 : 3; - return windowHandles.length === numberOfWindowHandlesToExpect; - }); - }, - ); - }); - - it('queues send tx after switchEthereum request with a warning, if switchEthereum request is cancelled should show pending tx', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [{ port: 8546, chainId: 1338 }], + async ({ driver }) => { + await unlockWallet(driver); + + // Open settings menu button + const accountOptionsMenuSelector = + '[data-testid="account-options-menu-button"]'; + await driver.waitForSelector(accountOptionsMenuSelector); + await driver.clickElement(accountOptionsMenuSelector); + + // Click settings from dropdown menu + const globalMenuSettingsSelector = + '[data-testid="global-menu-settings"]'; + await driver.waitForSelector(globalMenuSettingsSelector); + await driver.clickElement(globalMenuSettingsSelector); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Toggle off request queue setting (on by default now) + await driver.clickElement( + '[data-testid="experimental-setting-toggle-request-queue"]', + ); + + // open two dapps + const dappOne = await openDapp(driver, undefined, DAPP_URL); + const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Initiate switchEthereumChain on Dapp Two + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + // Confirm switchEthereumChain + await switchToNotificationWindow(driver, 4); + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + // Switch to Dapp One + await driver.switchToWindow(dappOne); + assert.equal(await driver.getCurrentUrl(), `${DAPP_URL}/`); + + // Wait for chain id element to change, there's a page reload. + await driver.waitForSelector({ + css: '#chainId', + text: '0x53a', + }); + + // Dapp One ChainId assertion + await driver.findElement({ css: '#chainId', text: '0x53a' }); + + // Switch to Dapp Two + await driver.switchToWindow(dappTwo); + assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_URL}/`); + + // Dapp Two ChainId Assertion + await driver.findElement({ css: '#chainId', text: '0x53a' }); + }, + ); + }); + + it('queues switchEthereumChain request from second dapp after send tx request', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [{ port: 8546, chainId: 1338 }], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open settings menu button + const accountOptionsMenuSelector = + '[data-testid="account-options-menu-button"]'; + await driver.waitForSelector(accountOptionsMenuSelector); + await driver.clickElement(accountOptionsMenuSelector); + + // Click settings from dropdown menu + const globalMenuSettingsSelector = + '[data-testid="global-menu-settings"]'; + await driver.waitForSelector(globalMenuSettingsSelector); + await driver.clickElement(globalMenuSettingsSelector); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Toggle off request queue setting (on by default now) + await driver.clickElement( + '[data-testid="experimental-setting-toggle-request-queue"]', + ); + + // open two dapps + await openDapp(driver, undefined, DAPP_URL); + await openDapp(driver, undefined, DAPP_ONE_URL); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // 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); + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + + // Switch to Dapp One + await driver.switchToWindowWithUrl(DAPP_URL); + + // Switch Ethereum chain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // Initiate switchEthereumChain on Dapp One + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + await switchToNotificationWindow(driver, 4); + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + // Delay here after notification for second notification popup for switchEthereumChain + await driver.delay(1000); + + // Switch and confirm to queued notification for switchEthereumChain + await switchToNotificationWindow(driver, 4); + + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ css: '#chainId', text: '0x539' }); + }, + ); + }); + + it('queues send tx after switchEthereum request with a warning, confirming removes pending tx', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [{ port: 8546, chainId: 1338 }], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open settings menu button + const accountOptionsMenuSelector = + '[data-testid="account-options-menu-button"]'; + await driver.waitForSelector(accountOptionsMenuSelector); + await driver.clickElement(accountOptionsMenuSelector); + + // Click settings from dropdown menu + const globalMenuSettingsSelector = + '[data-testid="global-menu-settings"]'; + await driver.waitForSelector(globalMenuSettingsSelector); + await driver.clickElement(globalMenuSettingsSelector); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Toggle off request queue setting (on by default now) + await driver.clickElement( + '[data-testid="experimental-setting-toggle-request-queue"]', + ); + + // open two dapps + const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); + const dappOne = await openDapp(driver, undefined, DAPP_URL); + + // Connect Dapp One + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Switch and connect Dapp Two + + await driver.switchToWindow(dappTwo); + assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_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"]'); + + // 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}/`); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // Initiate switchEthereumChain on Dapp Two + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + 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}/`); + + // Initiate send tx on dapp one + await driver.clickElement('#sendButton'); + await driver.delay(2000); + + // Switch to notification that should still be switchEthereumChain request but with a warning. + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // THIS IS BROKEN + // await driver.findElement({ + // span: 'span', + // text: 'Switching networks will cancel all pending confirmations', + // }); + + // Confirm switchEthereumChain with queued pending tx + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + // Window handles should only be expanded mm, dapp one, dapp 2, and the offscreen document + // if this is an MV3 build(3 or 4 total) + await driver.wait(async () => { + const windowHandles = await driver.getAllWindowHandles(); + const numberOfWindowHandlesToExpect = isManifestV3 ? 4 : 3; + return windowHandles.length === numberOfWindowHandlesToExpect; + }); + }, + ); + }); + + it('queues send tx after switchEthereum request with a warning, if switchEthereum request is cancelled should show pending tx', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [{ port: 8546, chainId: 1338 }], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open settings menu button + const accountOptionsMenuSelector = + '[data-testid="account-options-menu-button"]'; + await driver.waitForSelector(accountOptionsMenuSelector); + await driver.clickElement(accountOptionsMenuSelector); + + // Click settings from dropdown menu + const globalMenuSettingsSelector = + '[data-testid="global-menu-settings"]'; + await driver.waitForSelector(globalMenuSettingsSelector); + await driver.clickElement(globalMenuSettingsSelector); + + // Click Experimental tab + const experimentalTabRawLocator = { + text: 'Experimental', + tag: 'div', + }; + await driver.clickElement(experimentalTabRawLocator); + + // Toggle off request queue setting (on by default now) + await driver.clickElement( + '[data-testid="experimental-setting-toggle-request-queue"]', + ); + + // open two dapps + const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); + const dappOne = await openDapp(driver, undefined, DAPP_URL); + + // Connect Dapp One + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Switch and connect Dapp Two + await driver.switchToWindow(dappTwo); + assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_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"]'); + + // 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}/`); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // Initiate switchEthereumChain on Dapp Two + 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}/`); + + // Initiate send tx on dapp one + await driver.clickElement('#sendButton'); + await driver.delay(2000); + + // Switch to notification that should still be switchEthereumChain request but with an warning. + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Cancel switchEthereumChain with queued pending tx + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + + // Delay for second notification of the pending tx + await driver.delay(1000); + + // Switch to new pending tx notification + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.findElement({ + text: 'Transfer request', + tag: 'h3', + }); + + await driver.findElement({ + text: '0 ETH', + tag: 'h2', + }); + + // Confirm pending tx + await driver.findClickableElements({ + text: 'Confirm', + tag: 'button', + }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open settings menu button - const accountOptionsMenuSelector = - '[data-testid="account-options-menu-button"]'; - await driver.waitForSelector(accountOptionsMenuSelector); - await driver.clickElement(accountOptionsMenuSelector); - - // Click settings from dropdown menu - const globalMenuSettingsSelector = - '[data-testid="global-menu-settings"]'; - await driver.waitForSelector(globalMenuSettingsSelector); - await driver.clickElement(globalMenuSettingsSelector); - - // Click Experimental tab - const experimentalTabRawLocator = { - text: 'Experimental', - tag: 'div', - }; - await driver.clickElement(experimentalTabRawLocator); - - // Toggle off request queue setting (on by default now) - await driver.clickElement( - '[data-testid="experimental-setting-toggle-request-queue"]', - ); - - // open two dapps - const dappTwo = await openDapp(driver, undefined, DAPP_ONE_URL); - const dappOne = await openDapp(driver, undefined, DAPP_URL); - - // Connect Dapp One - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // Switch and connect Dapp Two - await driver.switchToWindow(dappTwo); - assert.equal(await driver.getCurrentUrl(), `${DAPP_ONE_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"]'); - - // 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}/`); - - // switchEthereumChain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x539' }], - }); - - // Initiate switchEthereumChain on Dapp Two - 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}/`); - - // Initiate send tx on dapp one - await driver.clickElement('#sendButton'); - await driver.delay(2000); - - // Switch to notification that should still be switchEthereumChain request but with an warning. - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // THIS IS BROKEN - // await driver.findElement({ - // span: 'span', - // text: 'Switching networks will cancel all pending confirmations', - // }); - - // Cancel switchEthereumChain with queued pending tx - await driver.clickElement({ text: 'Cancel', tag: 'button' }); - - // Delay for second notification of the pending tx - await driver.delay(1000); - - // Switch to new pending tx notification - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.findElement({ - text: 'Sending ETH', - tag: 'span', - }); - - // Confirm pending tx - await driver.findClickableElements({ - text: 'Confirm', - tag: 'button', - }); - await driver.clickElement({ - text: 'Confirm', - tag: 'button', - }); - }, - ); + ); + }); }); }); diff --git a/test/e2e/page-objects/flows/send-transaction.flow.ts b/test/e2e/page-objects/flows/send-transaction.flow.ts index 3f56076e3934..8af88f01aca6 100644 --- a/test/e2e/page-objects/flows/send-transaction.flow.ts +++ b/test/e2e/page-objects/flows/send-transaction.flow.ts @@ -3,6 +3,7 @@ import ConfirmTxPage from '../pages/send/confirm-tx-page'; import SendTokenPage from '../pages/send/send-token-page'; import { Driver } from '../../webdriver/driver'; import SnapSimpleKeyringPage from '../pages/snap-simple-keyring-page'; +import TransactionConfirmation from '../pages/confirmations/redesign/transaction-confirmation'; /** * This function initiates the steps required to send a transaction from the homepage to final confirmation. @@ -47,6 +48,42 @@ export const sendTransactionToAddress = async ({ await confirmTxPage.confirmTx(); }; +/** + * This function initiates the steps required to send a transaction from the homepage to final confirmation. + * + * @param params - An object containing the parameters. + * @param params.driver - The webdriver instance. + * @param params.recipientAddress - The recipient address. + * @param params.amount - The amount of the asset to be sent in the transaction. + */ +export const sendRedesignedTransactionToAddress = async ({ + driver, + recipientAddress, + amount, +}: { + driver: Driver; + recipientAddress: string; + amount: string; +}): Promise => { + console.log( + `Start flow to send amount ${amount} to recipient ${recipientAddress} on home screen`, + ); + // click send button on homepage to start flow + const homePage = new HomePage(driver); + await homePage.startSendFlow(); + + // user should land on send token screen to fill recipient and amount + const sendToPage = new SendTokenPage(driver); + await sendToPage.check_pageIsLoaded(); + await sendToPage.fillRecipient(recipientAddress); + await sendToPage.fillAmount(amount); + await sendToPage.goToNextScreen(); + + // confirm transaction when user lands on confirm transaction screen + const transactionConfirmationPage = new TransactionConfirmation(driver); + await transactionConfirmationPage.clickFooterConfirmButton(); +}; + /** * This function initiates the steps required to send a transaction from the homepage to final confirmation. * @@ -132,3 +169,38 @@ export const sendTransactionWithSnapAccount = async ({ ); } }; + +/** + * This function initiates the steps required to send a transaction from snap account on homepage to final confirmation. + * + * @param params - An object containing the parameters. + * @param params.driver - The webdriver instance. + * @param params.recipientAddress - The recipient address. + * @param params.amount - The amount of the asset to be sent in the transaction. + * @param params.isSyncFlow - Indicates whether synchronous approval option is on for the snap. Defaults to true. + * @param params.approveTransaction - Indicates whether the transaction should be approved. Defaults to true. + */ +export const sendRedesignedTransactionWithSnapAccount = async ({ + driver, + recipientAddress, + amount, + isSyncFlow = true, + approveTransaction = true, +}: { + driver: Driver; + recipientAddress: string; + amount: string; + isSyncFlow?: boolean; + approveTransaction?: boolean; +}): Promise => { + await sendRedesignedTransactionToAddress({ + driver, + recipientAddress, + amount, + }); + if (!isSyncFlow) { + await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( + approveTransaction, + ); + } +}; diff --git a/test/e2e/snaps/test-snap-txinsights-v2.spec.js b/test/e2e/snaps/test-snap-txinsights-v2.spec.js index a249e9daa79b..ba2d468a870a 100644 --- a/test/e2e/snaps/test-snap-txinsights-v2.spec.js +++ b/test/e2e/snaps/test-snap-txinsights-v2.spec.js @@ -3,166 +3,172 @@ const { withFixtures, unlockWallet, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); const { TEST_SNAPS_WEBSITE_URL } = require('./enums'); describe('Test Snap TxInsights-v2', function () { - it('tests tx insights v2 functionality', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // navigate to test snaps page and connect - await driver.openNewPage(TEST_SNAPS_WEBSITE_URL); - - // wait for page to load - await driver.waitForSelector({ - text: 'Installed Snaps', - tag: 'h2', - }); - - // find and scroll to the transaction-insights test snap - const snapButton1 = await driver.findElement( - '#connecttransaction-insights', - ); - await driver.scrollToElement(snapButton1); - - // added delay for firefox (deflake) - await driver.delayFirefox(1000); - - // wait for and click connect - await driver.waitForSelector('#connecttransaction-insights'); - await driver.clickElement('#connecttransaction-insights'); - - // switch to metamask extension - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // wait for and click connect - await driver.waitForSelector({ - text: 'Connect', - tag: 'button', - }); - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - // wait for and click connect - await driver.waitForSelector({ text: 'Confirm' }); - await driver.clickElement({ - text: 'Confirm', - tag: 'button', - }); - - // wait for and click ok and wait for window to close - await driver.waitForSelector({ text: 'OK' }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'OK', - tag: 'button', - }); - - // switch to test-snaps page - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); - - // wait for and click get accounts - await driver.waitForSelector('#getAccounts'); - await driver.clickElement('#getAccounts'); - - // switch back to MetaMask window - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // wait for and click confirm and wait for window to close - await driver.waitForSelector({ - text: 'Connect', - tag: 'button', - }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // switch to test-snaps page and send tx - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); - await driver.clickElement('#sendInsights'); - - // delay added for rendering (deflake) - await driver.delay(2000); - - // switch back to MetaMask window and switch to tx insights pane - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // find confirm button - await driver.findClickableElement({ - text: 'Confirm', - tag: 'button', - }); - - // wait for and click insights snap tab - await driver.waitForSelector({ - text: 'Insights Example Snap', - tag: 'button', - }); - await driver.clickElement({ - text: 'Insights Example Snap', - tag: 'button', - }); - - // check that txinsightstest tab contains the right info - await driver.waitForSelector({ - css: '.snap-ui-renderer__content', - text: 'ERC-20', - }); - - // click confirm to continue - await driver.clickElement({ - text: 'Confirm', - tag: 'button', - }); - - // check for warning from txinsights - await driver.waitForSelector({ - css: '.snap-delineator__header__text', - text: 'Warning from Insights Example Snap', - }); - - // check info in warning - await driver.waitForSelector({ - css: '.snap-ui-renderer__text', - text: 'ERC-20', - }); - - // click the warning confirm checkbox - await driver.clickElement('.mm-checkbox__input'); - - // click confirm button to send transaction - await driver.clickElement({ - css: '.mm-box--color-error-inverse', - text: 'Confirm', - tag: 'button', - }); - - // switch back to MetaMask tab - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - // switch to activity pane - await driver.clickElement({ - tag: 'button', - text: 'Activity', - }); - // wait for transaction confirmation - await driver.waitForSelector({ - css: '.transaction-status-label', - text: 'Confirmed', - }); - }, - ); + describe('Old confirmation screens', function () { + it('tests tx insights v2 functionality', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // navigate to test snaps page and connect + await driver.openNewPage(TEST_SNAPS_WEBSITE_URL); + + // wait for page to load + await driver.waitForSelector({ + text: 'Installed Snaps', + tag: 'h2', + }); + + // find and scroll to the transaction-insights test snap + const snapButton1 = await driver.findElement( + '#connecttransaction-insights', + ); + await driver.scrollToElement(snapButton1); + + // added delay for firefox (deflake) + await driver.delayFirefox(1000); + + // wait for and click connect + await driver.waitForSelector('#connecttransaction-insights'); + await driver.clickElement('#connecttransaction-insights'); + + // switch to metamask extension + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click connect + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // wait for and click connect + await driver.waitForSelector({ text: 'Confirm' }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + + // wait for and click ok and wait for window to close + await driver.waitForSelector({ text: 'OK' }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'OK', + tag: 'button', + }); + + // switch to test-snaps page + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + + // wait for and click get accounts + await driver.waitForSelector('#getAccounts'); + await driver.clickElement('#getAccounts'); + + // switch back to MetaMask window + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click confirm and wait for window to close + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // switch to test-snaps page and send tx + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + await driver.clickElement('#sendInsights'); + + // delay added for rendering (deflake) + await driver.delay(2000); + + // switch back to MetaMask window and switch to tx insights pane + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // find confirm button + await driver.findClickableElement({ + text: 'Confirm', + tag: 'button', + }); + + // wait for and click insights snap tab + await driver.waitForSelector({ + text: 'Insights Example Snap', + tag: 'button', + }); + await driver.clickElement({ + text: 'Insights Example Snap', + tag: 'button', + }); + + // check that txinsightstest tab contains the right info + await driver.waitForSelector({ + css: '.snap-ui-renderer__content', + text: 'ERC-20', + }); + + // click confirm to continue + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + + // check for warning from txinsights + await driver.waitForSelector({ + css: '.snap-delineator__header__text', + text: 'Warning from Insights Example Snap', + }); + + // check info in warning + await driver.waitForSelector({ + css: '.snap-ui-renderer__text', + text: 'ERC-20', + }); + + // click the warning confirm checkbox + await driver.clickElement('.mm-checkbox__input'); + + // click confirm button to send transaction + await driver.clickElement({ + css: '.mm-box--color-error-inverse', + text: 'Confirm', + tag: 'button', + }); + + // switch back to MetaMask tab + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // switch to activity pane + await driver.clickElement({ + tag: 'button', + text: 'Activity', + }); + + // wait for transaction confirmation + await driver.waitForSelector({ + css: '.transaction-status-label', + text: 'Confirmed', + }); + }, + ); + }); }); }); diff --git a/test/e2e/snaps/test-snap-txinsights.spec.js b/test/e2e/snaps/test-snap-txinsights.spec.js index 21feafd06cb9..0171759587b5 100644 --- a/test/e2e/snaps/test-snap-txinsights.spec.js +++ b/test/e2e/snaps/test-snap-txinsights.spec.js @@ -3,117 +3,229 @@ const { withFixtures, unlockWallet, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); const { TEST_SNAPS_WEBSITE_URL } = require('./enums'); describe('Test Snap TxInsights', function () { - it('tests tx insights functionality', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // navigate to test snaps page and connect - await driver.driver.get(TEST_SNAPS_WEBSITE_URL); - - // wait for page to load - await driver.waitForSelector({ - text: 'Installed Snaps', - tag: 'h2', - }); - - // find and scroll to the transaction-insights test snap - const snapButton1 = await driver.findElement( - '#connecttransaction-insights', - ); - await driver.scrollToElement(snapButton1); - - // added delay for firefox (deflake) - await driver.delayFirefox(1000); - - // wait for and click connect - await driver.waitForSelector('#connecttransaction-insights'); - await driver.clickElement('#connecttransaction-insights'); - - // switch to metamask extension - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // wait for and click connect - await driver.waitForSelector({ - text: 'Connect', - tag: 'button', - }); - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - // wait for and click confirm - await driver.waitForSelector({ text: 'Confirm' }); - await driver.clickElement({ - text: 'Confirm', - tag: 'button', - }); - - // wait for and click ok and wait for window to close - await driver.waitForSelector({ text: 'OK' }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'OK', - tag: 'button', - }); - - // switch to test-snaps page and get accounts - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); - - // click get accounts - await driver.clickElement('#getAccounts'); - - // switch back to MetaMask window - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // wait for and click next and wait for window to close - await driver.waitForSelector({ - text: 'Connect', - tag: 'button', - }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // switch to test-snaps page - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); - - // click send tx - await driver.clickElement('#sendInsights'); - - // delay added for rendering (deflake) - await driver.delay(2000); - - // switch back to MetaMask window - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // wait for and switch to insight snap pane - await driver.waitForSelector({ - text: 'Insights Example Snap', - tag: 'button', - }); - await driver.clickElement({ - text: 'Insights Example Snap', - tag: 'button', - }); - - // check that txinsightstest tab contains the right info - await driver.waitForSelector({ - css: '.snap-ui-renderer__content', - text: 'ERC-20', - }); - }, - ); + describe('Old confirmation screens', function () { + it('tests tx insights functionality', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // navigate to test snaps page and connect + await driver.driver.get(TEST_SNAPS_WEBSITE_URL); + + // wait for page to load + await driver.waitForSelector({ + text: 'Installed Snaps', + tag: 'h2', + }); + + // find and scroll to the transaction-insights test snap + const snapButton1 = await driver.findElement( + '#connecttransaction-insights', + ); + await driver.scrollToElement(snapButton1); + + // added delay for firefox (deflake) + await driver.delayFirefox(1000); + + // wait for and click connect + await driver.waitForSelector('#connecttransaction-insights'); + await driver.clickElement('#connecttransaction-insights'); + + // switch to metamask extension + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click connect + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // wait for and click confirm + await driver.waitForSelector({ text: 'Confirm' }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + + // wait for and click ok and wait for window to close + await driver.waitForSelector({ text: 'OK' }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'OK', + tag: 'button', + }); + + // switch to test-snaps page and get accounts + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + + // click get accounts + await driver.clickElement('#getAccounts'); + + // switch back to MetaMask window + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click next and wait for window to close + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // switch to test-snaps page + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + + // click send tx + await driver.clickElement('#sendInsights'); + + // delay added for rendering (deflake) + await driver.delay(2000); + + // switch back to MetaMask window + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and switch to insight snap pane + await driver.waitForSelector({ + text: 'Insights Example Snap', + tag: 'button', + }); + await driver.clickElement({ + text: 'Insights Example Snap', + tag: 'button', + }); + + // check that txinsightstest tab contains the right info + await driver.waitForSelector({ + css: '.snap-ui-renderer__content', + text: 'ERC-20', + }); + }, + ); + }); + }); + + describe('Redesigned confirmation screens', function () { + it('tests tx insights functionality', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // navigate to test snaps page and connect + await driver.driver.get(TEST_SNAPS_WEBSITE_URL); + + // wait for page to load + await driver.waitForSelector({ + text: 'Installed Snaps', + tag: 'h2', + }); + + // find and scroll to the transaction-insights test snap + const snapButton1 = await driver.findElement( + '#connecttransaction-insights', + ); + await driver.scrollToElement(snapButton1); + + // added delay for firefox (deflake) + await driver.delayFirefox(1000); + + // wait for and click connect + await driver.waitForSelector('#connecttransaction-insights'); + await driver.clickElement('#connecttransaction-insights'); + + // switch to metamask extension + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click connect + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // wait for and click confirm + await driver.waitForSelector({ text: 'Confirm' }); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + + // wait for and click ok and wait for window to close + await driver.waitForSelector({ text: 'OK' }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'OK', + tag: 'button', + }); + + // switch to test-snaps page and get accounts + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + + // click get accounts + await driver.clickElement('#getAccounts'); + + // switch back to MetaMask window + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and click next and wait for window to close + await driver.waitForSelector({ + text: 'Connect', + tag: 'button', + }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // switch to test-snaps page + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestSnaps); + + // click send tx + await driver.clickElement('#sendInsights'); + + // delay added for rendering (deflake) + await driver.delay(2000); + + // switch back to MetaMask window + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // wait for and switch to insight snap pane + await driver.waitForSelector({ + text: 'Insights Example Snap', + tag: 'span', + }); + + // check that txinsightstest tab contains the right info + await driver.waitForSelector({ + css: 'p', + text: 'ERC-20', + }); + }, + ); + }); }); }); diff --git a/test/e2e/tests/account/add-account.spec.ts b/test/e2e/tests/account/add-account.spec.ts index 8824fcb70950..906e01f50b4d 100644 --- a/test/e2e/tests/account/add-account.spec.ts +++ b/test/e2e/tests/account/add-account.spec.ts @@ -1,18 +1,19 @@ +import { E2E_SRP } from '../../default-fixture'; +import FixtureBuilder from '../../fixture-builder'; import { - withFixtures, WALLET_PASSWORD, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, + withFixtures, } from '../../helpers'; -import { E2E_SRP } from '../../default-fixture'; -import FixtureBuilder from '../../fixture-builder'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; +import { completeImportSRPOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; +import { sendTransactionToAccount } from '../../page-objects/flows/send-transaction.flow'; import AccountListPage from '../../page-objects/pages/account-list-page'; import HeaderNavbar from '../../page-objects/pages/header-navbar'; import HomePage from '../../page-objects/pages/homepage'; import LoginPage from '../../page-objects/pages/login-page'; import ResetPasswordPage from '../../page-objects/pages/reset-password-page'; -import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; -import { completeImportSRPOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; -import { sendTransactionToAccount } from '../../page-objects/flows/send-transaction.flow'; describe('Add account', function () { it('should not affect public address when using secret recovery phrase to recover account with non-zero balance @no-mmi', async function () { @@ -24,17 +25,21 @@ describe('Add account', function () { }, async ({ driver, ganacheServer }) => { await completeImportSRPOnboardingFlow({ driver }); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); const headerNavbar = new HeaderNavbar(driver); await headerNavbar.openAccountMenu(); - // Create new account with default name Account 2 + // Create new account with default name `newAccountName` + const newAccountName = 'Account 2'; const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); await accountListPage.addNewAccount(); - await headerNavbar.check_accountLabel('Account 2'); + await headerNavbar.check_accountLabel(newAccountName); await homePage.check_expectedBalanceIsDisplayed(); // Switch back to the first account and transfer some balance to 2nd account so they will not be removed after recovering SRP @@ -46,7 +51,7 @@ describe('Add account', function () { await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await sendTransactionToAccount({ driver, - recipientAccount: 'Account 2', + recipientAccount: newAccountName, amount: '2.8', gasFee: '0.000042', totalFee: '2.800042', @@ -67,9 +72,11 @@ describe('Add account', function () { await homePage.check_localBlockchainBalanceIsDisplayed(ganacheServer); await headerNavbar.openAccountMenu(); await accountListPage.check_pageIsLoaded(); - await accountListPage.check_accountDisplayedInAccountList('Account 2'); - await accountListPage.switchToAccount('Account 2'); - await headerNavbar.check_accountLabel('Account 2'); + await accountListPage.check_accountDisplayedInAccountList( + newAccountName, + ); + await accountListPage.switchToAccount(newAccountName); + await headerNavbar.check_accountLabel(newAccountName); await homePage.check_expectedBalanceIsDisplayed('2.8'); }, ); diff --git a/test/e2e/tests/account/snap-account-transfers.spec.ts b/test/e2e/tests/account/snap-account-transfers.spec.ts index ee2466cf28e1..af2a61a62a39 100644 --- a/test/e2e/tests/account/snap-account-transfers.spec.ts +++ b/test/e2e/tests/account/snap-account-transfers.spec.ts @@ -2,6 +2,7 @@ import { Suite } from 'mocha'; import { multipleGanacheOptions, PRIVATE_KEY_TWO, + tempToggleSettingRedesignedTransactionConfirmations, WINDOW_TITLES, withFixtures, } from '../../helpers'; @@ -15,151 +16,311 @@ import HomePage from '../../page-objects/pages/homepage'; import SnapSimpleKeyringPage from '../../page-objects/pages/snap-simple-keyring-page'; import { installSnapSimpleKeyring } from '../../page-objects/flows/snap-simple-keyring.flow'; import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; -import { sendTransactionWithSnapAccount } from '../../page-objects/flows/send-transaction.flow'; +import { + sendRedesignedTransactionWithSnapAccount, + sendTransactionWithSnapAccount, +} from '../../page-objects/flows/send-transaction.flow'; describe('Snap Account Transfers @no-mmi', function (this: Suite) { - it('can import a private key and transfer 1 ETH (sync flow)', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - ganacheOptions: multipleGanacheOptions, - title: this.test?.fullTitle(), - }, - async ({ - driver, - ganacheServer, - }: { - driver: Driver; - ganacheServer?: Ganache; - }) => { - await loginWithBalanceValidation(driver, ganacheServer); - await installSnapSimpleKeyring(driver); - const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); - - // import snap account with private key on snap simple keyring page. - await snapSimpleKeyringPage.importAccountWithPrivateKey( - PRIVATE_KEY_TWO, - ); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - const headerNavbar = new HeaderNavbar(driver); - await headerNavbar.check_accountLabel('SSK Account'); - - // send 1 ETH from snap account to account 1 - await sendTransactionWithSnapAccount({ + // TODO: Remove the old confirmations screen tests once migration has been complete. + // See: https://github.com/MetaMask/MetaMask-planning/issues/3030 + describe('Old confirmation screens', function () { + it('can import a private key and transfer 1 ETH (sync flow)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + }, + async ({ driver, - recipientAddress: DEFAULT_FIXTURE_ACCOUNT, - amount: '1', - gasFee: '0.000042', - totalFee: '1.000042', - }); - await headerNavbar.check_pageIsLoaded(); - await headerNavbar.openAccountMenu(); - const accountList = new AccountListPage(driver); - await accountList.check_pageIsLoaded(); - - // check the balance of the 2 accounts are updated - await accountList.check_accountBalanceDisplayed('26'); - await accountList.check_accountBalanceDisplayed('24'); - }, - ); - }); + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + await installSnapSimpleKeyring(driver); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 + await sendTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + gasFee: '0.000042', + totalFee: '1.000042', + }); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openAccountMenu(); + const accountList = new AccountListPage(driver); + await accountList.check_pageIsLoaded(); + + // check the balance of the 2 accounts are updated + await accountList.check_accountBalanceDisplayed('26'); + await accountList.check_accountBalanceDisplayed('24'); + }, + ); + }); + + it('can import a private key and transfer 1 ETH (async flow approve)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + }, + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); - it('can import a private key and transfer 1 ETH (async flow approve)', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - ganacheOptions: multipleGanacheOptions, - title: this.test?.fullTitle(), - }, - async ({ - driver, - ganacheServer, - }: { - driver: Driver; - ganacheServer?: Ganache; - }) => { - await loginWithBalanceValidation(driver, ganacheServer); - await installSnapSimpleKeyring(driver, false); - const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); - - // import snap account with private key on snap simple keyring page. - await snapSimpleKeyringPage.importAccountWithPrivateKey( - PRIVATE_KEY_TWO, - ); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - const headerNavbar = new HeaderNavbar(driver); - await headerNavbar.check_accountLabel('SSK Account'); - - // send 1 ETH from snap account to account 1 and approve the transaction - await sendTransactionWithSnapAccount({ + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + await installSnapSimpleKeyring(driver, false); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 and approve the transaction + await sendTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + gasFee: '0.000042', + totalFee: '1.000042', + isSyncFlow: false, + }); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openAccountMenu(); + const accountList = new AccountListPage(driver); + await accountList.check_pageIsLoaded(); + + // check the balance of the 2 accounts are updated + await accountList.check_accountBalanceDisplayed('26'); + await accountList.check_accountBalanceDisplayed('24'); + }, + ); + }); + + it('can import a private key and transfer 1 ETH (async flow reject)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + ignoredConsoleErrors: ['Request rejected by user or snap.'], + }, + async ({ driver, - recipientAddress: DEFAULT_FIXTURE_ACCOUNT, - amount: '1', - gasFee: '0.000042', - totalFee: '1.000042', - isSyncFlow: false, - }); - await headerNavbar.check_pageIsLoaded(); - await headerNavbar.openAccountMenu(); - const accountList = new AccountListPage(driver); - await accountList.check_pageIsLoaded(); - - // check the balance of the 2 accounts are updated - await accountList.check_accountBalanceDisplayed('26'); - await accountList.check_accountBalanceDisplayed('24'); - }, - ); + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + await installSnapSimpleKeyring(driver, false); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // Import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 and reject the transaction + await sendTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + gasFee: '0.000042', + totalFee: '1.000042', + isSyncFlow: false, + approveTransaction: false, + }); + + // check the transaction is failed in MetaMask activity list + const homepage = new HomePage(driver); + await homepage.check_pageIsLoaded(); + await homepage.check_failedTxNumberDisplayedInActivity(); + }, + ); + }); }); - it('can import a private key and transfer 1 ETH (async flow reject)', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - ganacheOptions: multipleGanacheOptions, - title: this.test?.fullTitle(), - ignoredConsoleErrors: ['Request rejected by user or snap.'], - }, - async ({ - driver, - ganacheServer, - }: { - driver: Driver; - ganacheServer?: Ganache; - }) => { - await loginWithBalanceValidation(driver, ganacheServer); - await installSnapSimpleKeyring(driver, false); - const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); - - // Import snap account with private key on snap simple keyring page. - await snapSimpleKeyringPage.importAccountWithPrivateKey( - PRIVATE_KEY_TWO, - ); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - const headerNavbar = new HeaderNavbar(driver); - await headerNavbar.check_accountLabel('SSK Account'); - - // send 1 ETH from snap account to account 1 and reject the transaction - await sendTransactionWithSnapAccount({ + describe('Redesigned confirmation screens', function () { + it('can import a private key and transfer 1 ETH (sync flow)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + }, + async ({ driver, - recipientAddress: DEFAULT_FIXTURE_ACCOUNT, - amount: '1', - gasFee: '0.000042', - totalFee: '1.000042', - isSyncFlow: false, - approveTransaction: false, - }); - - // check the transaction is failed in MetaMask activity list - const homepage = new HomePage(driver); - await homepage.check_pageIsLoaded(); - await homepage.check_failedTxNumberDisplayedInActivity(); - }, - ); + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + + await installSnapSimpleKeyring(driver); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 + await sendRedesignedTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + }); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openAccountMenu(); + const accountList = new AccountListPage(driver); + await accountList.check_pageIsLoaded(); + + // check the balance of the 2 accounts are updated + await accountList.check_accountBalanceDisplayed('26'); + await accountList.check_accountBalanceDisplayed('24'); + }, + ); + }); + + it('can import a private key and transfer 1 ETH (async flow approve)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + }, + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + + await installSnapSimpleKeyring(driver, false); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 and approve the transaction + await sendRedesignedTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + isSyncFlow: false, + }); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openAccountMenu(); + const accountList = new AccountListPage(driver); + await accountList.check_pageIsLoaded(); + + // check the balance of the 2 accounts are updated + await accountList.check_accountBalanceDisplayed('26'); + await accountList.check_accountBalanceDisplayed('24'); + }, + ); + }); + + it('can import a private key and transfer 1 ETH (async flow reject)', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: multipleGanacheOptions, + title: this.test?.fullTitle(), + ignoredConsoleErrors: ['Request rejected by user or snap.'], + }, + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + + await installSnapSimpleKeyring(driver, false); + const snapSimpleKeyringPage = new SnapSimpleKeyringPage(driver); + + // Import snap account with private key on snap simple keyring page. + await snapSimpleKeyringPage.importAccountWithPrivateKey( + PRIVATE_KEY_TWO, + ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_accountLabel('SSK Account'); + + // send 1 ETH from snap account to account 1 and reject the transaction + await sendRedesignedTransactionWithSnapAccount({ + driver, + recipientAddress: DEFAULT_FIXTURE_ACCOUNT, + amount: '1', + isSyncFlow: false, + approveTransaction: false, + }); + + // check the transaction is failed in MetaMask activity list + const homepage = new HomePage(driver); + await homepage.check_pageIsLoaded(); + await homepage.check_failedTxNumberDisplayedInActivity(); + }, + ); + }); }); }); diff --git a/test/e2e/tests/confirmations/helpers.ts b/test/e2e/tests/confirmations/helpers.ts index 355f664ec61c..2b1078549c5b 100644 --- a/test/e2e/tests/confirmations/helpers.ts +++ b/test/e2e/tests/confirmations/helpers.ts @@ -14,7 +14,7 @@ export async function scrollAndConfirmAndAssertConfirm(driver: Driver) { await driver.clickElement('[data-testid="confirm-footer-button"]'); } -export function withRedesignConfirmationFixtures( +export function withTransactionEnvelopeTypeFixtures( // Default params first is discouraged because it makes it hard to call the function without the // optional parameters. But it doesn't apply here because we're always passing in a variable for // title. It's optional because it's sometimes unset. @@ -35,12 +35,6 @@ export function withRedesignConfirmationFixtures( metaMetricsId: 'fake-metrics-id', participateInMetaMetrics: true, }) - .withPreferencesController({ - preferences: { - redesignedConfirmationsEnabled: true, - isRedesignedConfirmationsDeveloperEnabled: true, - }, - }) .build(), ganacheOptions: transactionEnvelopeType === TransactionEnvelopeType.legacy diff --git a/test/e2e/tests/confirmations/navigation.spec.ts b/test/e2e/tests/confirmations/navigation.spec.ts index 38d29ad3ad77..97985381b08b 100644 --- a/test/e2e/tests/confirmations/navigation.spec.ts +++ b/test/e2e/tests/confirmations/navigation.spec.ts @@ -8,11 +8,11 @@ import { WINDOW_TITLES, } from '../../helpers'; import { Driver } from '../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from './helpers'; +import { withTransactionEnvelopeTypeFixtures } from './helpers'; describe('Navigation Signature - Different signature types', function (this: Suite) { it('initiates and queues multiple signatures and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: { driver: Driver }) => { @@ -52,7 +52,7 @@ describe('Navigation Signature - Different signature types', function (this: Sui }); it('initiates and queues a mix of signatures and transactions and navigates', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: { driver: Driver }) => { @@ -100,7 +100,7 @@ describe('Navigation Signature - Different signature types', function (this: Sui }); it('initiates multiple signatures and rejects all', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: { driver: Driver }) => { diff --git a/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts b/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts index 328ad7811e1b..5876a4d5e17f 100644 --- a/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts +++ b/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts @@ -7,7 +7,7 @@ import { Driver } from '../../../webdriver/driver'; import { mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -22,7 +22,7 @@ import { describe('Malicious Confirmation Signature - Bad Domain @no-mmi', function (this: Suite) { it('displays alert for domain binding and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: TestSuiteArguments) => { @@ -45,7 +45,7 @@ describe('Malicious Confirmation Signature - Bad Domain @no-mmi', function (this }); it('initiates and rejects from confirmation screen', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -93,7 +93,7 @@ describe('Malicious Confirmation Signature - Bad Domain @no-mmi', function (this }); it('initiates and rejects from alert friction modal', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts index 4aeda07a3758..eccdfff78a7c 100644 --- a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts @@ -9,7 +9,7 @@ import { mockSignatureApproved, mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -26,7 +26,7 @@ import { describe('Confirmation Signature - NFT Permit @no-mmi', function (this: Suite) { it('initiates and confirms and emits the correct events', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -77,7 +77,7 @@ describe('Confirmation Signature - NFT Permit @no-mmi', function (this: Suite) { }); it('initiates and rejects and emits the correct events', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/permit.spec.ts b/test/e2e/tests/confirmations/signatures/permit.spec.ts index bc74b9fd2f5f..f6c8fc972b5f 100644 --- a/test/e2e/tests/confirmations/signatures/permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/permit.spec.ts @@ -14,7 +14,7 @@ import { mockSignatureApproved, mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -31,7 +31,7 @@ import { describe('Confirmation Signature - Permit @no-mmi', function (this: Suite) { it('initiates and confirms and emits the correct events', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -76,7 +76,7 @@ describe('Confirmation Signature - Permit @no-mmi', function (this: Suite) { }); it('initiates and rejects and emits the correct events', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts b/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts index 8444deab7c61..5f87c2d6b6e8 100644 --- a/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts +++ b/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts @@ -8,7 +8,7 @@ import { Driver } from '../../../webdriver/driver'; import { mockSignatureApproved, mockSignatureRejected, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -25,7 +25,7 @@ import { describe('Confirmation Signature - Personal Sign @no-mmi', function (this: Suite) { it('initiates and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -66,7 +66,7 @@ describe('Confirmation Signature - Personal Sign @no-mmi', function (this: Suite }); it('initiates and rejects', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts b/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts index a7f2e7b81691..7ea7f0879279 100644 --- a/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts +++ b/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts @@ -9,7 +9,7 @@ import { mockSignatureApproved, mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -26,7 +26,7 @@ import { describe('Confirmation Signature - Sign Typed Data V3 @no-mmi', function (this: Suite) { it('initiates and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -70,7 +70,7 @@ describe('Confirmation Signature - Sign Typed Data V3 @no-mmi', function (this: }); it('initiates and rejects', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/sign-typed-data-v4.spec.ts b/test/e2e/tests/confirmations/signatures/sign-typed-data-v4.spec.ts index 33b94be6b332..4dfe9f04972f 100644 --- a/test/e2e/tests/confirmations/signatures/sign-typed-data-v4.spec.ts +++ b/test/e2e/tests/confirmations/signatures/sign-typed-data-v4.spec.ts @@ -9,7 +9,7 @@ import { mockSignatureApproved, mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -26,7 +26,7 @@ import { describe('Confirmation Signature - Sign Typed Data V4 @no-mmi', function (this: Suite) { it('initiates and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -74,7 +74,7 @@ describe('Confirmation Signature - Sign Typed Data V4 @no-mmi', function (this: }); it('initiates and rejects', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts b/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts index 2f1c33fe4d07..e7f8e1446f5c 100644 --- a/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts +++ b/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts @@ -8,7 +8,7 @@ import { Driver } from '../../../webdriver/driver'; import { mockSignatureApproved, mockSignatureRejected, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -25,7 +25,7 @@ import { describe('Confirmation Signature - Sign Typed Data @no-mmi', function (this: Suite) { it('initiates and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -66,7 +66,7 @@ describe('Confirmation Signature - Sign Typed Data @no-mmi', function (this: Sui }); it('initiates and rejects', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/signatures/siwe.spec.ts b/test/e2e/tests/confirmations/signatures/siwe.spec.ts index 9f261a28f569..4c7bec0ae121 100644 --- a/test/e2e/tests/confirmations/signatures/siwe.spec.ts +++ b/test/e2e/tests/confirmations/signatures/siwe.spec.ts @@ -8,7 +8,7 @@ import { mockSignatureApproved, mockSignatureRejected, scrollAndConfirmAndAssertConfirm, - withRedesignConfirmationFixtures, + withTransactionEnvelopeTypeFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; import { @@ -29,7 +29,7 @@ import { describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { it('initiates and confirms', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ @@ -74,7 +74,7 @@ describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { }); it('initiates and rejects', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ diff --git a/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts b/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts index 33a7760a050d..f79bf6835e14 100644 --- a/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts +++ b/test/e2e/tests/confirmations/transactions/erc1155-revoke-set-approval-for-all-redesign.ts @@ -7,7 +7,7 @@ import SetApprovalForAllTransactionConfirmation from '../../../page-objects/page import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { mocked4BytesSetApprovalForAll } from './erc721-revoke-set-approval-for-all-redesign'; import { TestSuiteArguments } from './shared'; @@ -16,7 +16,7 @@ const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); describe('Confirmation Redesign ERC1155 Revoke setApprovalForAll', function () { describe('Submit an revoke transaction @no-mmi', function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -28,7 +28,7 @@ describe('Confirmation Redesign ERC1155 Revoke setApprovalForAll', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts index da9da0a59873..cbb85482e6f1 100644 --- a/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc1155-set-approval-for-all-redesign.spec.ts @@ -6,7 +6,7 @@ import SetApprovalForAllTransactionConfirmation from '../../../page-objects/page import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); @@ -14,7 +14,7 @@ const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); describe('Confirmation Redesign ERC1155 setApprovalForAll', function () { describe('Submit a transaction @no-mmi', function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -29,7 +29,7 @@ describe('Confirmation Redesign ERC1155 setApprovalForAll', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts index f5293161172f..2fbe2c553a06 100644 --- a/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc20-token-send-redesign.spec.ts @@ -14,7 +14,7 @@ import SendTokenPage from '../../../page-objects/pages/send/send-token-page'; import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); @@ -22,7 +22,7 @@ const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); describe('Confirmation Redesign ERC20 Token Send @no-mmi', function () { describe('Wallet initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -37,7 +37,7 @@ describe('Confirmation Redesign ERC20 Token Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -54,7 +54,7 @@ describe('Confirmation Redesign ERC20 Token Send @no-mmi', function () { describe('dApp initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -69,7 +69,7 @@ describe('Confirmation Redesign ERC20 Token Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts b/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts index 95768c35de3f..27e2eb31a04e 100644 --- a/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts +++ b/test/e2e/tests/confirmations/transactions/erc721-revoke-set-approval-for-all-redesign.ts @@ -7,7 +7,7 @@ import SetApprovalForAllTransactionConfirmation from '../../../page-objects/page import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); @@ -15,7 +15,7 @@ const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); describe('Confirmation Redesign ERC721 Revoke setApprovalForAll', function () { describe('Submit an revoke transaction @no-mmi', function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -27,7 +27,7 @@ describe('Confirmation Redesign ERC721 Revoke setApprovalForAll', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts index ba3c877973e5..4e1f54511d40 100644 --- a/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc721-set-approval-for-all-redesign.spec.ts @@ -6,7 +6,7 @@ import SetApprovalForAllTransactionConfirmation from '../../../page-objects/page import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); @@ -14,7 +14,7 @@ const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); describe('Confirmation Redesign ERC721 setApprovalForAll', function () { describe('Submit a transaction @no-mmi', function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -29,7 +29,7 @@ describe('Confirmation Redesign ERC721 setApprovalForAll', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts index e8226977d019..46318e113c35 100644 --- a/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/native-send-redesign.spec.ts @@ -11,7 +11,7 @@ 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 { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const TOKEN_RECIPIENT_ADDRESS = '0x2f318C334780961FB129D2a6c30D0763d9a5C970'; @@ -19,7 +19,7 @@ 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( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: TestSuiteArguments) => { @@ -29,7 +29,7 @@ describe('Confirmation Redesign Native Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver }: TestSuiteArguments) => { @@ -41,7 +41,7 @@ describe('Confirmation Redesign Native Send @no-mmi', function () { describe('dApp initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver }: TestSuiteArguments) => { @@ -51,7 +51,7 @@ describe('Confirmation Redesign Native Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver }: TestSuiteArguments) => { diff --git a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts index 8d319b62c482..6fc048c7f11b 100644 --- a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts @@ -16,7 +16,7 @@ import SendTokenPage from '../../../page-objects/pages/send/send-token-page'; import TestDapp from '../../../page-objects/pages/test-dapp'; import ContractAddressRegistry from '../../../seeder/contract-address-registry'; import { Driver } from '../../../webdriver/driver'; -import { withRedesignConfirmationFixtures } from '../helpers'; +import { withTransactionEnvelopeTypeFixtures } from '../helpers'; import { TestSuiteArguments } from './shared'; const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); @@ -27,7 +27,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { describe('ERC721', function () { describe('Wallet initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -42,7 +42,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -59,7 +59,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { describe('dApp initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -74,7 +74,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -93,7 +93,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { describe('ERC1155', function () { describe('Wallet initiated', async function () { it('Sends a type 0 transaction (Legacy)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.legacy, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -108,7 +108,7 @@ describe('Confirmation Redesign Token Send @no-mmi', function () { }); it('Sends a type 2 transaction (EIP1559)', async function () { - await withRedesignConfirmationFixtures( + await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), TransactionEnvelopeType.feeMarket, async ({ driver, contractRegistry }: TestSuiteArguments) => { diff --git a/test/e2e/tests/dapp-interactions/contract-interactions.spec.js b/test/e2e/tests/dapp-interactions/contract-interactions.spec.js index e7de105e0112..a685954b5857 100644 --- a/test/e2e/tests/dapp-interactions/contract-interactions.spec.js +++ b/test/e2e/tests/dapp-interactions/contract-interactions.spec.js @@ -7,8 +7,8 @@ const { WINDOW_TITLES, locateAccountBalanceDOM, clickNestedButton, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); - const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); const FixtureBuilder = require('../../fixture-builder'); @@ -32,6 +32,8 @@ describe('Deploy contract and call contract methods', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // deploy contract await openDapp(driver, contractAddress); diff --git a/test/e2e/tests/dapp-interactions/dapp-tx-edit.spec.js b/test/e2e/tests/dapp-interactions/dapp-tx-edit.spec.js index 131ebdf4ee73..ad168a2b9332 100644 --- a/test/e2e/tests/dapp-interactions/dapp-tx-edit.spec.js +++ b/test/e2e/tests/dapp-interactions/dapp-tx-edit.spec.js @@ -4,6 +4,7 @@ const { openDapp, WINDOW_TITLES, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); const FixtureBuilder = require('../../fixture-builder'); @@ -27,6 +28,8 @@ describe('Editing confirmations of dapp initiated contract interactions', functi ); await logInWithBalanceValidation(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // deploy contract await openDapp(driver, contractAddress); // wait for deployed contract, calls and confirms a contract method where ETH is sent @@ -59,6 +62,8 @@ describe('Editing confirmations of dapp initiated contract interactions', functi async ({ driver }) => { await logInWithBalanceValidation(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver); await driver.clickElement('#sendButton'); diff --git a/test/e2e/tests/dapp-interactions/failing-contract.spec.js b/test/e2e/tests/dapp-interactions/failing-contract.spec.js index 5770adb1a3b9..c05938d668e0 100644 --- a/test/e2e/tests/dapp-interactions/failing-contract.spec.js +++ b/test/e2e/tests/dapp-interactions/failing-contract.spec.js @@ -6,6 +6,7 @@ const { WINDOW_TITLES, generateGanacheOptions, clickNestedButton, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); const FixtureBuilder = require('../../fixture-builder'); @@ -29,6 +30,8 @@ describe('Failing contract interaction ', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver, contractAddress); let windowHandles = await driver.getAllWindowHandles(); const extension = windowHandles[0]; @@ -93,6 +96,8 @@ describe('Failing contract interaction on non-EIP1559 network', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver, contractAddress); let windowHandles = await driver.getAllWindowHandles(); const extension = windowHandles[0]; diff --git a/test/e2e/tests/network/network-error.spec.js b/test/e2e/tests/network/network-error.spec.js index 4d45734edf77..61842f482151 100644 --- a/test/e2e/tests/network/network-error.spec.js +++ b/test/e2e/tests/network/network-error.spec.js @@ -4,6 +4,7 @@ const { logInWithBalanceValidation, openActionMenuAndStartSendFlow, generateGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { GAS_API_BASE_URL } = require('../../../../shared/constants/swaps'); @@ -58,6 +59,8 @@ describe('Gas API fallback', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( 'input[placeholder="Enter public address (0x) or domain name"]', diff --git a/test/e2e/tests/petnames/petnames-transactions.spec.js b/test/e2e/tests/petnames/petnames-transactions.spec.js index cc19e44a55eb..55c295c51c3a 100644 --- a/test/e2e/tests/petnames/petnames-transactions.spec.js +++ b/test/e2e/tests/petnames/petnames-transactions.spec.js @@ -5,6 +5,7 @@ const { unlockWallet, defaultGanacheOptions, openActionMenuAndStartSendFlow, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { @@ -49,6 +50,9 @@ describe('Petnames - Transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver); await createDappSendTransaction(driver); await switchToNotificationWindow(driver, 3); @@ -94,6 +98,8 @@ describe('Petnames - Transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createWalletSendTransaction(driver, ADDRESS_MOCK); await expectName(driver, ABBREVIATED_ADDRESS_MOCK, false); 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 9e904af6513e..5368c2617f13 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 @@ -1,12 +1,12 @@ const { strict: assert } = require('assert'); const FixtureBuilder = require('../../fixture-builder'); - const { defaultGanacheOptions, withFixtures, sendScreenToConfirmScreen, logInWithBalanceValidation, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { mockMultiNetworkBalancePolling, @@ -209,6 +209,8 @@ describe('Simple Send Security Alert - Blockaid @no-mmi', function () { async ({ driver }) => { await logInWithBalanceValidation(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await sendScreenToConfirmScreen( driver, '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', diff --git a/test/e2e/tests/request-queuing/batch-txs-per-dapp-diff-network.spec.js b/test/e2e/tests/request-queuing/batch-txs-per-dapp-diff-network.spec.js index deb189404fa8..958c1351d8b3 100644 --- a/test/e2e/tests/request-queuing/batch-txs-per-dapp-diff-network.spec.js +++ b/test/e2e/tests/request-queuing/batch-txs-per-dapp-diff-network.spec.js @@ -9,129 +9,246 @@ const { WINDOW_TITLES, defaultGanacheOptions, largeDelayMs, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); -const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing for Multiple Dapps and Txs on different networks', function () { - it('should batch confirmation txs for different dapps on different networks.', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should batch confirmation txs for different dapps on different networks.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // Connect to dapp 1 - await driver.clickElement({ text: 'Connect', tag: 'button' }); + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); - // Wait for the first dapp's connect confirmation to disappear - await driver.waitUntilXWindowHandles(2); + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); - // Connect to dapp 2 - await driver.clickElement({ text: 'Connect', tag: 'button' }); + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - // Dapp one send tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); + // Dapp one send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); - await driver.delay(largeDelayMs); + await driver.delay(largeDelayMs); - // Dapp two send tx - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); + // Dapp two send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); - // Reject All Transactions - await driver.clickElement('.page-container__footer-secondary a'); + // Reject All Transactions + await driver.clickElement('.page-container__footer-secondary a'); - // TODO: Do we want to confirm here? - await driver.clickElementAndWaitForWindowToClose({ - text: 'Reject all', - tag: 'button', - }); + // TODO: Do we want to confirm here? + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); - // Wait for confirmation to close - // TODO: find a better way to handle different dialog ids - await driver.delay(2000); + // Wait for confirmation to close + // TODO: find a better way to handle different dialog ids + await driver.delay(2000); - // Wait for new confirmations queued from second dapp to open - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Wait for new confirmations queued from second dapp to open + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); - // Check correct network on confirm tx. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); - }, - ); + // Check correct network on confirm tx. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + }, + ); + }); + }); + + describe('Redesigned confirmation screens', function () { + it('should batch confirmation txs for different dapps on different networks.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Dapp one send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.delay(largeDelayMs); + + // Dapp two send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); + + // Wait for confirmation to close + await driver.delay(2000); + + // Wait for new confirmations queued from second dapp to open + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + // Check correct network on confirm tx. + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/batch-txs-per-dapp-extra-tx.spec.js b/test/e2e/tests/request-queuing/batch-txs-per-dapp-extra-tx.spec.js index 265b28d0f56d..066acacab23a 100644 --- a/test/e2e/tests/request-queuing/batch-txs-per-dapp-extra-tx.spec.js +++ b/test/e2e/tests/request-queuing/batch-txs-per-dapp-extra-tx.spec.js @@ -9,169 +9,326 @@ const { unlockWallet, WINDOW_TITLES, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); -const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing for Multiple Dapps and Txs on different networks', function () { - it('should batch confirmation txs for different dapps on different networks adds extra tx after.', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation flows', function () { + it('should batch confirmation txs for different dapps on different networks adds extra tx after.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - - async ({ driver }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); - - // Connect to dapp 1 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - await driver.switchToWindowWithUrl(DAPP_URL); - - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x53a' }], - }); - - // Ensure Dapp One is on Localhost 8546 - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - - // Should auto switch without prompt since already approved via connect - - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - // Wait for the first dapp's connect confirmation to disappear - await driver.waitUntilXWindowHandles(2); - - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); - - // Connect to dapp 2 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - // Dapp 1 send 2 tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x53a', - }); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); - - await driver.waitUntilXWindowHandles(4); - - // Dapp 2 send 2 tx - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x53a', - }); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); - // We cannot wait for the dialog, since it is already opened from before - await driver.delay(largeDelayMs); - - // Dapp 1 send 1 tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x53a', - }); - await driver.clickElement('#sendButton'); - // We cannot switch directly, as the dialog is sometimes closed and re-opened - await driver.delay(largeDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); - - // Reject All Transactions - await driver.clickElement('.page-container__footer-secondary a'); - - // TODO: Do we want to confirm here? - await driver.clickElementAndWaitForWindowToClose({ - text: 'Reject all', - tag: 'button', - }); - - await driver.switchToWindowWithUrl(DAPP_URL); - - // Wait for new confirmations queued from second dapp to open - // We need a big delay to make sure dialog is not invalidated - // TODO: find a better way to handle different dialog ids - await driver.delay(2000); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); - - // Check correct network on confirm tx. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); - - // Reject All Transactions - await driver.clickElement('.page-container__footer-secondary a'); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Reject all', - tag: 'button', - }); - - // Wait for new confirmations queued from second dapp to open - // We need a big delay to make sure dialog is not invalidated - // TODO: find a better way to handle different dialog ids - await driver.delay(2000); - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - }, - ); + + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Ensure Dapp One is on Localhost 8546 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + // Should auto switch without prompt since already approved via connect + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // Dapp 1 send 2 tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.waitUntilXWindowHandles(4); + + // Dapp 2 send 2 tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + // We cannot wait for the dialog, since it is already opened from before + await driver.delay(largeDelayMs); + + // Dapp 1 send 1 tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + // We cannot switch directly, as the dialog is sometimes closed and re-opened + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); + + // Reject All Transactions + await driver.clickElement('.page-container__footer-secondary a'); + + // TODO: Do we want to confirm here? + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + // Wait for new confirmations queued from second dapp to open + // We need a big delay to make sure dialog is not invalidated + // TODO: find a better way to handle different dialog ids + await driver.delay(2000); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); + + // Check correct network on confirm tx. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + + // Reject All Transactions + await driver.clickElement('.page-container__footer-secondary a'); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); + + // Wait for new confirmations queued from second dapp to open + // We need a big delay to make sure dialog is not invalidated + // TODO: find a better way to handle different dialog ids + await driver.delay(2000); + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + }, + ); + }); + }); + + describe('Redesigned confirmation flows', function () { + it('should batch confirmation txs for different dapps on different networks adds extra tx after.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Ensure Dapp One is on Localhost 8546 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + // Should auto switch without prompt since already approved via connect + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // Dapp 1 send 2 tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.waitUntilXWindowHandles(4); + + // Dapp 2 send 2 tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + // We cannot wait for the dialog, since it is already opened from before + await driver.delay(largeDelayMs); + + // Dapp 1 send 1 tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + // We cannot switch directly, as the dialog is sometimes closed and re-opened + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + // Reject All Transactions + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + // Wait for new confirmations queued from second dapp to open + // We need a big delay to make sure dialog is not invalidated + // TODO: find a better way to handle different dialog ids + await driver.delay(2000); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + // Check correct network on confirm tx. + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); + + // Reject All Transactions + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject all', + tag: 'button', + }); + + // Wait for new confirmations queued from second dapp to open + // We need a big delay to make sure dialog is not invalidated + // TODO: find a better way to handle different dialog ids + await driver.delay(2000); + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/batch-txs-per-dapp-same-network.spec.js b/test/e2e/tests/request-queuing/batch-txs-per-dapp-same-network.spec.js index c30d6a73c063..d3241c95c9d5 100644 --- a/test/e2e/tests/request-queuing/batch-txs-per-dapp-same-network.spec.js +++ b/test/e2e/tests/request-queuing/batch-txs-per-dapp-same-network.spec.js @@ -10,163 +10,317 @@ const { WINDOW_TITLES, defaultGanacheOptions, largeDelayMs, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); -const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing for Multiple Dapps and Txs on same networks', function () { - it('should batch confirmation txs for different dapps on same networks ', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerTripleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - dappOptions: { numberOfDapps: 3 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - { - port: 7777, - chainId: 1000, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should batch confirmation txs for different dapps on same networks ', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 3 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // Connect to dapp 1 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + // Connect to dapp 1 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - await driver.delay(regularDelayMs); + await driver.delay(regularDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); - await driver.switchToWindowWithUrl(DAPP_URL); + await driver.switchToWindowWithUrl(DAPP_URL); - let switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x3e8' }], - }); + let switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x3e8' }], + }); - // Ensure Dapp One is on Localhost 7777 - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); + // Ensure Dapp One is on Localhost 7777 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); - // Should auto switch without prompt since already approved via connect + // Should auto switch without prompt since already approved via connect - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - // Wait for the first dapp's connect confirmation to disappear - await driver.waitUntilXWindowHandles(2); + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); - // Connect to dapp 2 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - await driver.delay(regularDelayMs); + await driver.delay(regularDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.switchToWindowWithUrl(DAPP_ONE_URL); - switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x53a' }], - }); + switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); - // Ensure Dapp Two is on Localhost 8545 - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); + // Ensure Dapp Two is on Localhost 8545 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); - // Should auto switch without prompt since already approved via connect + // Should auto switch without prompt since already approved via connect - // Dapp one send two tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); + // Dapp one send two tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); - await driver.delay(largeDelayMs); + await driver.delay(largeDelayMs); - // Dapp two send two tx - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); - await driver.clickElement('#sendButton'); + // Dapp two send two tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); - // Check correct network on confirm tx. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 7777', - }); + // Check correct network on confirm tx. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 7777', + }); - // Reject All Transactions - await driver.clickElement('.page-container__footer-secondary a'); + // Reject All Transactions + await driver.clickElement('.page-container__footer-secondary a'); - await driver.clickElement({ text: 'Reject all', tag: 'button' }); // TODO: Do we want to confirm here? + await driver.clickElement({ text: 'Reject all', tag: 'button' }); // TODO: Do we want to confirm here? - // Wait for confirmation to close - await driver.waitUntilXWindowHandles(4); + // Wait for confirmation to close + await driver.waitUntilXWindowHandles(4); - // Wait for new confirmations queued from second dapp to open - await driver.delay(largeDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Wait for new confirmations queued from second dapp to open + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.waitForSelector( - By.xpath("//div[normalize-space(.)='1 of 2']"), - ); + await driver.waitForSelector( + By.xpath("//div[normalize-space(.)='1 of 2']"), + ); - // Check correct network on confirm tx. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); - }, - ); + // Check correct network on confirm tx. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + }, + ); + }); + }); + + describe('Redesigned confirmation screens', function () { + it('should batch confirmation txs for different dapps on same networks ', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 3 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + let switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x3e8' }], + }); + + // Ensure Dapp One is on Localhost 7777 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + // Should auto switch without prompt since already approved via connect + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Ensure Dapp Two is on Localhost 8545 + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + // Should auto switch without prompt since already approved via connect + + // Dapp one send two tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.delay(largeDelayMs); + + // Dapp two send two tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + // Check correct network on confirm tx. + await driver.findElement({ + css: 'p', + text: 'Localhost 7777', + }); + + // Reject All Transactions + await driver.clickElement({ text: 'Reject all', tag: 'button' }); + + // Wait for confirmation to close + await driver.waitUntilXWindowHandles(4); + + // Wait for new confirmations queued from second dapp to open + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector( + By.xpath("//p[normalize-space(.)='1 of 2']"), + ); + + // Check correct network on confirm tx. + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); + }, + ); + }); }); }); 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 5814d8a60a2b..28b19efbeb04 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 @@ -10,148 +10,300 @@ const { tempToggleSettingRedesignedConfirmations, WINDOW_TITLES, largeDelayMs, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); +const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { - it('should queue signTypedData tx after eth_sendTransaction confirmation and signTypedData confirmation should target the correct network after eth_sendTransaction is confirmed @no-mmi', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerTripleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withSelectedNetworkControllerPerDomain() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - { - port: 7777, - chainId: 1000, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should queue signTypedData tx after eth_sendTransaction confirmation and signTypedData confirmation should target the correct network after eth_sendTransaction is confirmed @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - await tempToggleSettingRedesignedConfirmations(driver); - - // Open and connect Dapp One - await openDapp(driver, undefined, DAPP_URL); - - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.delay(regularDelayMs); - - await driver.waitUntilXWindowHandles(3); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); - // Open and connect to Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); - - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.delay(regularDelayMs); - - await driver.waitUntilXWindowHandles(4); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - // Switch Dapp Two to Localhost 8546 - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - let switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x53a' }], - }); - - // Initiate switchEthereumChain on Dapp one - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - - await driver.waitForSelector({ - css: '[id="chainId"]', - text: '0x53a', - }); - - // Should auto switch without prompt since already approved via connect - - // Switch back to Dapp One - await driver.switchToWindowWithUrl(DAPP_URL); - - // switch chain for Dapp One - switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x3e8' }], - }); - - // Initiate switchEthereumChain on Dapp one - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); - await driver.waitForSelector({ - css: '[id="chainId"]', - text: '0x3e8', - }); - // Should auto switch without prompt since already approved via connect - - await driver.switchToWindowWithUrl(DAPP_URL); - - // eth_sendTransaction request - await driver.clickElement('#sendButton'); - await driver.waitUntilXWindowHandles(3); - - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - - // signTypedData request - await driver.clickElement('#signTypedData'); - - await driver.waitUntilXWindowHandles(4); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - // Check correct network on the send confirmation. - 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.waitForSelector({ - css: '[data-testid="signature-request-network-display"]', - text: 'Localhost 8546', - }); - - await driver.clickElement({ text: 'Reject', tag: 'button' }); - }, - ); + async ({ driver }) => { + await unlockWallet(driver); + await tempToggleSettingRedesignedConfirmations(driver); + + // Navigate to extension home screen + await driver.navigate(PAGES.HOME); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open and connect Dapp One + await openDapp(driver, undefined, DAPP_URL); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + // Open and connect to Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // Switch Dapp Two to Localhost 8546 + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + let switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Initiate switchEthereumChain on Dapp one + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x53a', + }); + + // Should auto switch without prompt since already approved via connect + + // Switch back to Dapp One + await driver.switchToWindowWithUrl(DAPP_URL); + + // switch chain for Dapp One + switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x3e8' }], + }); + + // Initiate switchEthereumChain on Dapp one + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x3e8', + }); + // Should auto switch without prompt since already approved via connect + + await driver.switchToWindowWithUrl(DAPP_URL); + + // eth_sendTransaction request + await driver.clickElement('#sendButton'); + await driver.waitUntilXWindowHandles(3); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + // signTypedData request + await driver.clickElement('#signTypedData'); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Check correct network on the send confirmation. + 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.waitForSelector({ + css: '[data-testid="signature-request-network-display"]', + text: 'Localhost 8546', + }); + + await driver.clickElement({ text: 'Reject', tag: 'button' }); + }, + ); + }); + }); + + describe('Redesigned confirmation screens', function () { + it('should queue signTypedData tx after eth_sendTransaction confirmation and signTypedData confirmation should target the correct network after eth_sendTransaction is confirmed @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open and connect Dapp One + await openDapp(driver, undefined, DAPP_URL); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + // Open and connect to Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + // Switch Dapp Two to Localhost 8546 + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + let switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x53a' }], + }); + + // Initiate switchEthereumChain on Dapp one + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x53a', + }); + + // Should auto switch without prompt since already approved via connect + + // Switch back to Dapp One + await driver.switchToWindowWithUrl(DAPP_URL); + + // switch chain for Dapp One + switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x3e8' }], + }); + + // Initiate switchEthereumChain on Dapp one + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x3e8', + }); + // Should auto switch without prompt since already approved via connect + + await driver.switchToWindowWithUrl(DAPP_URL); + + // eth_sendTransaction request + await driver.clickElement('#sendButton'); + await driver.waitUntilXWindowHandles(3); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + // signTypedData request + await driver.clickElement('#signTypedData'); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Check correct network on the send confirmation. + await driver.waitForSelector({ + css: 'p', + 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.waitForSelector({ + css: 'p', + text: 'Localhost 8546', + }); + + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/dapp1-switch-dapp2-eth-request-accounts.spec.js b/test/e2e/tests/request-queuing/dapp1-switch-dapp2-eth-request-accounts.spec.js index 7a212533de4b..67efbfe6fee9 100644 --- a/test/e2e/tests/request-queuing/dapp1-switch-dapp2-eth-request-accounts.spec.js +++ b/test/e2e/tests/request-queuing/dapp1-switch-dapp2-eth-request-accounts.spec.js @@ -1,5 +1,4 @@ const { strict: assert } = require('assert'); - const FixtureBuilder = require('../../fixture-builder'); const { withFixtures, @@ -10,149 +9,247 @@ const { regularDelayMs, WINDOW_TITLES, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); describe('Request Queuing Dapp 1 Send Tx -> Dapp 2 Request Accounts Tx', function () { - it('should queue `eth_requestAccounts` requests when the requesting dapp does not already have connected accounts', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withPermissionControllerConnectedToTestDapp() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should queue `eth_requestAccounts` requests when the requesting dapp does not already have connected accounts', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withPermissionControllerConnectedToTestDapp() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); - - // Dapp Send Button - await driver.clickElement('#sendButton'); - await driver.delay(regularDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.waitForSelector({ - text: 'Reject', - tag: 'button', - }); - - await driver.delay(regularDelayMs); - - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - // Leave the confirmation pending - await openDapp(driver, undefined, DAPP_ONE_URL); - - const accountsOnload = await ( - await driver.findElement('#accounts') - ).getText(); - assert.deepStrictEqual(accountsOnload, ''); - - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); - - await driver.delay(regularDelayMs); - - const accountsBeforeConnect = await ( - await driver.findElement('#accounts') - ).getText(); - assert.deepStrictEqual(accountsBeforeConnect, ''); - - // Reject the pending confirmation from the first dapp - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Reject', - tag: 'button', - }); - - // Wait for switch confirmation to close then request accounts confirmation to show for the second dapp - await driver.delay(regularDelayMs); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElement({ - text: 'Connect', - tag: 'button', - }); - - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - - await driver.waitForSelector({ - text: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - css: '#accounts', - }); - }, - ); + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Dapp Send Button + await driver.clickElement('#sendButton'); + await driver.delay(regularDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector({ + text: 'Reject', + tag: 'button', + }); + + await driver.delay(regularDelayMs); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Leave the confirmation pending + await openDapp(driver, undefined, DAPP_ONE_URL); + + const accountsOnload = await ( + await driver.findElement('#accounts') + ).getText(); + assert.deepStrictEqual(accountsOnload, ''); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + const accountsBeforeConnect = await ( + await driver.findElement('#accounts') + ).getText(); + assert.deepStrictEqual(accountsBeforeConnect, ''); + + // Reject the pending confirmation from the first dapp + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Reject', + tag: 'button', + }); + + // Wait for switch confirmation to close then request accounts confirmation to show for the second dapp + await driver.delay(regularDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + await driver.waitForSelector({ + text: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + css: '#accounts', + }); + }, + ); + }); }); - it('should not queue the `eth_requestAccounts` requests when the requesting dapp already has connected accounts', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withPermissionControllerConnectedToTwoTestDapps() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Redesigned confirmation screens', function () { + it('should queue `eth_requestAccounts` requests when the requesting dapp does not already have connected accounts', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withPermissionControllerConnectedToTestDapp() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + // Dapp Send Button + await driver.clickElement('#sendButton'); + await driver.delay(regularDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - // Dapp Send Button - await driver.clickElement('#sendButton'); + await driver.waitForSelector({ + text: 'Cancel', + tag: 'button', + }); - // Leave the confirmation pending + await driver.delay(regularDelayMs); - await openDapp(driver, undefined, DAPP_ONE_URL); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - const ethRequestAccounts = JSON.stringify({ - jsonrpc: '2.0', - method: 'eth_requestAccounts', - }); + // Leave the confirmation pending + await openDapp(driver, undefined, DAPP_ONE_URL); - const accounts = await driver.executeScript( - `return window.ethereum.request(${ethRequestAccounts})`, - ); + const accountsOnload = await ( + await driver.findElement('#accounts') + ).getText(); + assert.deepStrictEqual(accountsOnload, ''); - assert.deepStrictEqual(accounts, [ - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - ]); - }, - ); + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.delay(regularDelayMs); + + const accountsBeforeConnect = await ( + await driver.findElement('#accounts') + ).getText(); + assert.deepStrictEqual(accountsBeforeConnect, ''); + + // Reject the pending confirmation from the first dapp + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Cancel', + tag: 'button', + }); + + // Wait for switch confirmation to close then request accounts confirmation to show for the second dapp + await driver.delay(regularDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + await driver.waitForSelector({ + text: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + css: '#accounts', + }); + }, + ); + }); + + it('should not queue the `eth_requestAccounts` requests when the requesting dapp already has connected accounts', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withPermissionControllerConnectedToTwoTestDapps() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Dapp Send Button + await driver.clickElement('#sendButton'); + + // Leave the confirmation pending + + await openDapp(driver, undefined, DAPP_ONE_URL); + + const ethRequestAccounts = JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_requestAccounts', + }); + + const accounts = await driver.executeScript( + `return window.ethereum.request(${ethRequestAccounts})`, + ); + + assert.deepStrictEqual(accounts, [ + '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + ]); + }, + ); + }); }); }); 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 c98e0eb229c6..24c09ee18d09 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 @@ -7,304 +7,619 @@ const { unlockWallet, WINDOW_TITLES, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); describe('Request Queuing Dapp 1, Switch Tx -> Dapp 2 Send Tx', function () { - it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is confirmed', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerTripleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withSelectedNetworkControllerPerDomain() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - { - port: 7777, - chainId: 1000, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is confirmed', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - // Connect to dapp - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Connect to dapp + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - const editButtons = await driver.findElements('[data-testid="edit"]'); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await editButtons[1].click(); + const editButtons = await driver.findElements('[data-testid="edit"]'); - // Disconnect Localhost 8545 - await driver.clickElement({ - text: 'Localhost 8545', - tag: 'p', - }); + await editButtons[1].click(); - await driver.clickElement('[data-testid="connect-more-chains-button"]'); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.clickElement( + '[data-testid="connect-more-chains-button"]', + ); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // 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'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + // Wait for switch confirmation to close then tx confirmation to show. + // 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); - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); + // Check correct network on the send confirmation. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - // Connect to dapp 2 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', + ); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); + + it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is cancelled.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - await driver.switchToWindowWithUrl(DAPP_URL); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // switchEthereumChain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x539' }], - }); + // Connect to dapp + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - // Initiate switchEthereumChain on Dapp One - await driver.executeScript( - `window.ethereum.request(${switchEthereumChainRequest})`, - ); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const editButtons = await driver.findElements('[data-testid="edit"]'); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.findElement({ - text: 'Use your enabled networks', - tag: 'p', - }); + await editButtons[1].click(); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); - await driver.clickElement('#sendButton'); + await driver.clickElement( + '[data-testid="connect-more-chains-button"]', + ); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - await driver.clickElement({ text: 'Confirm', tag: 'button' }); + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); - // Wait for switch confirmation to close then tx confirmation to show. - // There is an extra window appearing and disappearing - // so we leave this delay until the issue is fixed (#27360) - await driver.delay(5000); + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - // Check correct network on the send confirmation. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Confirm', - tag: 'button', - }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - // Switch back to the extension - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.switchToWindowWithUrl(DAPP_URL); - await driver.clickElement( - '[data-testid="account-overview__activity-tab"]', - ); + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); - // Check for transaction - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .activity-list-item', + // Initiate switchEthereumChain on Dapp One + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, ); - return confirmedTxes.length === 1; - }, 10000); - }, - ); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + await driver.clickElement('#sendButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + // Wait for switch confirmation to close then tx confirmation to show. + // 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. + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); + + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', + ); + + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); }); - it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is cancelled.', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerTripleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withSelectedNetworkControllerPerDomain() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - { - port: 7777, - chainId: 1000, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Redesigned confirmation screens', function () { + it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is confirmed', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // Connect to dapp - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + // Connect to dapp + 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 driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await editButtons[1].click(); + const editButtons = await driver.findElements('[data-testid="edit"]'); - // Disconnect Localhost 8545 - await driver.clickElement({ - text: 'Localhost 8545', - tag: 'p', - }); + await editButtons[1].click(); - await driver.clickElement('[data-testid="connect-more-chains-button"]'); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + // Disconnect Localhost 8545 + await driver.clickElement({ + text: 'Localhost 8545', + tag: 'p', + }); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.clickElement( + '[data-testid="connect-more-chains-button"]', + ); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); - // Connect to dapp 2 - await driver.findClickableElement({ text: 'Connect', tag: 'button' }); - await driver.clickElement('#connectButton'); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + await driver.switchToWindowWithUrl(DAPP_URL); - await driver.switchToWindowWithUrl(DAPP_URL); + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); - // switchEthereumChain request - const switchEthereumChainRequest = JSON.stringify({ - jsonrpc: '2.0', - method: 'wallet_switchEthereumChain', - params: [{ chainId: '0x539' }], - }); + // Initiate switchEthereumChain on Dapp One + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); - // 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.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.clickElement('#sendButton'); + await driver.clickElement('#sendButton'); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElement({ text: 'Cancel', tag: 'button' }); - await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); - // Wait for switch confirmation to close then tx confirmation to show. - // 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.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Wait for switch confirmation to close then tx confirmation to show. + // There is an extra window appearing and disappearing + // so we leave this delay until the issue is fixed (#27360) + await driver.delay(5000); - // Check correct network on the send confirmation. - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Confirm', - tag: 'button', - }); + // Check correct network on the send confirmation. + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); - // Switch back to the extension - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); - await driver.clickElement( - '[data-testid="account-overview__activity-tab"]', - ); + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); - // Check for transaction - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .activity-list-item', + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', ); - return confirmedTxes.length === 1; - }, 10000); - }, - ); + + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); + + it('should queue send tx after switch network confirmation and transaction should target the correct network after switch is cancelled.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp + 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', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + // switchEthereumChain request + const switchEthereumChainRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x539' }], + }); + + // Initiate switchEthereumChain on Dapp One + await driver.executeScript( + `window.ethereum.request(${switchEthereumChainRequest})`, + ); + + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + await driver.clickElement('#sendButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + + // Wait for switch confirmation to close then tx confirmation to show. + // 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. + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Confirm', + tag: 'button', + }); + + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', + ); + + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); }); }); 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 06d232635131..3a413f147e06 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 @@ -7,127 +7,247 @@ const { DAPP_ONE_URL, WINDOW_TITLES, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); -const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing for Multiple Dapps and Txs on different networks revokePermissions', function () { - it('should close transaction for revoked permission of eth_accounts but show queued tx from second dapp on a different network.', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should close transaction for revoked permission of eth_accounts but show queued tx from second dapp on a different network.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - - async ({ driver }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); - - // Connect to dapp 1 - await driver.clickElement({ text: 'Connect', tag: 'button' }); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); - - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); - - // Wait for the first dapp's connect confirmation to disappear - await driver.waitUntilXWindowHandles(2); - - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); - - // Connect to dapp 2 - await driver.clickElement({ text: 'Connect', tag: 'button' }); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); - - // Dapp 1 send tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x539', - }); - await driver.clickElement('#sendButton'); - - await driver.waitUntilXWindowHandles(4); - await driver.delay(3000); - - // Dapp 2 send tx - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x53a', - }); - await driver.clickElement('#sendButton'); - await driver.waitUntilXWindowHandles(4); - - // Dapp 1 revokePermissions - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.findElement({ - css: '[id="chainId"]', - text: '0x539', - }); - await driver.assertElementNotPresent({ - css: '[id="chainId"]', - text: '0x53a', - }); - - // Confirmation will close then reopen - await driver.clickElement('#revokeAccountsPermission'); - // TODO: find a better way to handle different dialog ids - await driver.delay(3000); - - // Check correct network on confirm tx. - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); - }, - ); + + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Dapp 1 send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x539', + }); + await driver.clickElement('#sendButton'); + + await driver.waitUntilXWindowHandles(4); + await driver.delay(3000); + + // Dapp 2 send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.waitUntilXWindowHandles(4); + + // Dapp 1 revokePermissions + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x539', + }); + await driver.assertElementNotPresent({ + css: '[id="chainId"]', + text: '0x53a', + }); + + // Confirmation will close then reopen + await driver.clickElement('#revokeAccountsPermission'); + // TODO: find a better way to handle different dialog ids + await driver.delay(3000); + + // Check correct network on confirm tx. + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + }, + ); + }); + }); + + describe('New confirmation screens', function () { + it('should close transaction for revoked permission of eth_accounts but show queued tx from second dapp on a different network.', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); + + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // Wait for the first dapp's connect confirmation to disappear + await driver.waitUntilXWindowHandles(2); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Dapp 1 send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x539', + }); + await driver.clickElement('#sendButton'); + + await driver.waitUntilXWindowHandles(4); + await driver.delay(3000); + + // Dapp 2 send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x53a', + }); + await driver.clickElement('#sendButton'); + await driver.waitUntilXWindowHandles(4); + + // Dapp 1 revokePermissions + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.findElement({ + css: '[id="chainId"]', + text: '0x539', + }); + await driver.assertElementNotPresent({ + css: '[id="chainId"]', + text: '0x53a', + }); + + // Confirmation will close then reopen + await driver.clickElement('#revokeAccountsPermission'); + // TODO: find a better way to handle different dialog ids + await driver.delay(3000); + + // Check correct network on confirm tx. + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.findElement({ + css: 'p', + text: 'Localhost 8546', + }); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/multiple-networks-dapps-txs.spec.js b/test/e2e/tests/request-queuing/multiple-networks-dapps-txs.spec.js index 38fe1d7204d2..f831ff1ff38d 100644 --- a/test/e2e/tests/request-queuing/multiple-networks-dapps-txs.spec.js +++ b/test/e2e/tests/request-queuing/multiple-networks-dapps-txs.spec.js @@ -8,142 +8,277 @@ const { WINDOW_TITLES, defaultGanacheOptions, largeDelayMs, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); -const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing for Multiple Dapps and Txs on different networks.', function () { - it('should switch to the dapps network automatically when handling sendTransaction calls @no-mmi', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .withSelectedNetworkControllerPerDomain() - .build(), - dappOptions: { numberOfDapps: 2 }, - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should switch to the dapps network automatically when handling sendTransaction calls @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - // Open Dapp One - await openDapp(driver, undefined, DAPP_URL); + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - // Connect to dapp 1 - await driver.clickElement({ text: 'Connect', tag: 'button' }); + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); - // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. - // Open Dapp Two - await openDapp(driver, undefined, DAPP_ONE_URL); + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); - // Connect to dapp 2 - await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); - await driver.clickElementAndWaitForWindowToClose({ - text: 'Connect', - tag: 'button', - }); + // Dapp one send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); - // Dapp one send tx - await driver.switchToWindowWithUrl(DAPP_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); + await driver.waitUntilXWindowHandles(4); - await driver.waitUntilXWindowHandles(4); + // Dapp two send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); - // Dapp two send tx - await driver.switchToWindowWithUrl(DAPP_ONE_URL); - await driver.delay(largeDelayMs); - await driver.clickElement('#sendButton'); + // First switch network + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - // First switch network - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Wait for confirm tx after switch network confirmation. + await driver.delay(largeDelayMs); - // Wait for confirm tx after switch network confirmation. - await driver.delay(largeDelayMs); + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - await driver.waitUntilXWindowHandles(4); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Reject Transaction + await driver.findClickableElement({ text: 'Reject', tag: 'button' }); + await driver.clickElement( + '[data-testid="page-container-footer-cancel"]', + ); - // Reject Transaction - await driver.findClickableElement({ text: 'Reject', tag: 'button' }); - await driver.clickElement( - '[data-testid="page-container-footer-cancel"]', - ); + // TODO: No second confirmation from dapp two will show, have to go back to the extension to see the switch chain & dapp two's tx. + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', + ); - // TODO: No second confirmation from dapp two will show, have to go back to the extension to see the switch chain & dapp two's tx. - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.clickElement( - '[data-testid="account-overview__activity-tab"]', - ); + // Check for unconfirmed transaction in tx list + await driver.wait(async () => { + const unconfirmedTxes = await driver.findElements( + '.transaction-list-item--unconfirmed', + ); + return unconfirmedTxes.length === 1; + }, 10000); - // Check for unconfirmed transaction in tx list - await driver.wait(async () => { - const unconfirmedTxes = await driver.findElements( - '.transaction-list-item--unconfirmed', + // Click Unconfirmed Tx + await driver.clickElement('.transaction-list-item--unconfirmed'); + + await driver.assertElementNotPresent({ + tag: 'p', + text: 'Network switched to Localhost 8546', + }); + + // Confirm Tx + await driver.clickElement( + '[data-testid="page-container-footer-next"]', ); - return unconfirmedTxes.length === 1; - }, 10000); - // Click Unconfirmed Tx - await driver.clickElement('.transaction-list-item--unconfirmed'); + // Check for Confirmed Transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); + }); + + describe('Redesigned confirmation screens', function () { + it('should switch to the dapps network automatically when handling sendTransaction calls @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .withSelectedNetworkControllerPerDomain() + .build(), + dappOptions: { numberOfDapps: 2 }, + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open Dapp One + await openDapp(driver, undefined, DAPP_URL); - await driver.assertElementNotPresent({ - tag: 'p', - text: 'Network switched to Localhost 8546', - }); + // Connect to dapp 1 + await driver.clickElement({ text: 'Connect', tag: 'button' }); - // Confirm Tx - await driver.clickElement('[data-testid="page-container-footer-next"]'); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - // Check for Confirmed Transaction - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .activity-list-item', + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, ); - return confirmedTxes.length === 1; - }, 10000); - }, - ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + // TODO: Request Queuing bug when opening both dapps at the same time will have them stuck on the same network, with will be incorrect for one of them. + // Open Dapp Two + await openDapp(driver, undefined, DAPP_ONE_URL); + + // Connect to dapp 2 + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + // Dapp one send tx + await driver.switchToWindowWithUrl(DAPP_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + + await driver.waitUntilXWindowHandles(4); + + // Dapp two send tx + await driver.switchToWindowWithUrl(DAPP_ONE_URL); + await driver.delay(largeDelayMs); + await driver.clickElement('#sendButton'); + + // First switch network + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Wait for confirm tx after switch network confirmation. + await driver.delay(largeDelayMs); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Reject Transaction + await driver.findClickableElement({ text: 'Cancel', tag: 'button' }); + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + + // TODO: No second confirmation from dapp two will show, have to go back to the extension to see the switch chain & dapp two's tx. + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.clickElement( + '[data-testid="account-overview__activity-tab"]', + ); + + // Check for unconfirmed transaction in tx list + await driver.wait(async () => { + const unconfirmedTxes = await driver.findElements( + '.transaction-list-item--unconfirmed', + ); + return unconfirmedTxes.length === 1; + }, 10000); + + // Click Unconfirmed Tx + await driver.clickElement('.transaction-list-item--unconfirmed'); + + await driver.assertElementNotPresent({ + tag: 'p', + text: 'Network switched to Localhost 8546', + }); + + // Confirm Tx + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + // Check for Confirmed Transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/switch-network.spec.js b/test/e2e/tests/request-queuing/switch-network.spec.js index 5949800f9840..913bdf459a26 100644 --- a/test/e2e/tests/request-queuing/switch-network.spec.js +++ b/test/e2e/tests/request-queuing/switch-network.spec.js @@ -7,88 +7,178 @@ const { regularDelayMs, WINDOW_TITLES, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { PAGES } = require('../../webdriver/driver'); describe('Request Queuing Switch Network on Dapp Send Tx while on different networks.', function () { - it('should switch to the dapps network automatically when mm network differs, dapp tx is on correct network', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPermissionControllerConnectedToTestDapp() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should switch to the dapps network automatically when mm network differs, dapp tx is on correct network', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); + async ({ driver }) => { + await unlockWallet(driver); - // Open dapp - await openDapp(driver, undefined, DAPP_URL); + await tempToggleSettingRedesignedTransactionConfirmations(driver); - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + // Open dapp + await openDapp(driver, undefined, DAPP_URL); - // Network Selector - await driver.clickElement('[data-testid="network-display"]'); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); - // Switch to second network - await driver.clickElement({ - text: 'Localhost 8546', - css: 'p', - }); + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); - await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + // Queue confirm tx should first auto switch network + await driver.clickElement('#sendButton'); - // Queue confirm tx should first auto switch network - await driver.clickElement('#sendButton'); + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Confirm Transaction + await driver.findClickableElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement( + '[data-testid="page-container-footer-next"]', + ); - await driver.delay(regularDelayMs); + await driver.delay(regularDelayMs); - await driver.waitUntilXWindowHandles(3); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.navigate(PAGES.HOME); - // Confirm Transaction - await driver.findClickableElement({ text: 'Confirm', tag: 'button' }); - await driver.clickElement('[data-testid="page-container-footer-next"]'); + // Check correct network switched and on the correct network + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8545', + }); - await driver.delay(regularDelayMs); + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); + }); - // Switch back to the extension - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.navigate(PAGES.HOME); + describe('Redesigned confirmation screens', function () { + it('should switch to the dapps network automatically when mm network differs, dapp tx is on correct network', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); - // Check correct network switched and on the correct network - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8545', - }); + // Open dapp + await openDapp(driver, undefined, DAPP_URL); - // Check for transaction - await driver.wait(async () => { - const confirmedTxes = await driver.findElements( - '.transaction-list__completed-transactions .activity-list-item', + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, ); - return confirmedTxes.length === 1; - }, 10000); - }, - ); + + // Network Selector + await driver.clickElement('[data-testid="network-display"]'); + + // Switch to second network + await driver.clickElement({ + text: 'Localhost 8546', + css: 'p', + }); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + + // Queue confirm tx should first auto switch network + await driver.clickElement('#sendButton'); + + await driver.delay(regularDelayMs); + + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + // Confirm Transaction + await driver.findClickableElement({ text: 'Confirm', tag: 'button' }); + await driver.clickElement({ text: 'Confirm', tag: 'button' }); + + await driver.delay(regularDelayMs); + + // Switch back to the extension + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.navigate(PAGES.HOME); + + // Check correct network switched and on the correct network + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8545', + }); + + // Check for transaction + await driver.wait(async () => { + const confirmedTxes = await driver.findElements( + '.transaction-list__completed-transactions .activity-list-item', + ); + return confirmedTxes.length === 1; + }, 10000); + }, + ); + }); }); }); diff --git a/test/e2e/tests/request-queuing/ui.spec.js b/test/e2e/tests/request-queuing/ui.spec.js index b857d4307d5b..707c252396b7 100644 --- a/test/e2e/tests/request-queuing/ui.spec.js +++ b/test/e2e/tests/request-queuing/ui.spec.js @@ -14,6 +14,7 @@ const { tempToggleSettingRedesignedConfirmations, veryLargeDelayMs, DAPP_TWO_URL, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { PAGES } = require('../../webdriver/driver'); const { @@ -136,6 +137,38 @@ async function switchToDialogPopoverValidateDetails(driver, expectedDetails) { assert.equal(chainId, expectedDetails.chainId); } +async function switchToDialogPopoverValidateDetailsRedesign( + driver, + expectedDetails, +) { + // Switches to the MetaMask Dialog window for confirmation + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.findElement({ + css: 'p', + text: expectedDetails.networkText, + }); + + // Get state details + await driver.waitForControllersLoaded(); + const notificationWindowState = await driver.executeScript(() => + window.stateHooks?.getCleanAppState?.(), + ); + + const { + metamask: { selectedNetworkClientId, networkConfigurationsByChainId }, + } = notificationWindowState; + + const { chainId } = Object.values(networkConfigurationsByChainId).find( + ({ rpcEndpoints }) => + rpcEndpoints.some( + ({ networkClientId }) => networkClientId === selectedNetworkClientId, + ), + ); + + assert.equal(chainId, expectedDetails.chainId); +} + async function rejectTransaction(driver) { await driver.clickElementAndWaitForWindowToClose({ tag: 'button', @@ -143,6 +176,13 @@ async function rejectTransaction(driver) { }); } +async function rejectTransactionRedesign(driver) { + await driver.clickElementAndWaitForWindowToClose({ + tag: 'button', + text: 'Cancel', + }); +} + async function confirmTransaction(driver) { await driver.clickElement({ tag: 'button', text: 'Confirm' }); } @@ -190,573 +230,1036 @@ async function validateBalanceAndActivity( } describe('Request-queue UI changes', function () { - it('should show network specific to domain @no-mmi', async function () { - const port = 8546; - const chainId = 1338; // 0x53a - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Old confirmation screens', function () { + it('should show network specific to domain @no-mmi', async function () { + const port = 8546; + const chainId = 1338; // 0x53a + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), }, - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open the first dapp - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open the second dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); - - // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Localhost 8546', - }); - - // Go to the first dapp, ensure it uses localhost - await selectDappClickSend(driver, DAPP_URL); - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x539', - networkText: 'Localhost 8545', - originText: DAPP_URL, - }); - await rejectTransaction(driver); - - // Go to the second dapp, ensure it uses Ethereum Mainnet - await selectDappClickSend(driver, DAPP_ONE_URL); - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x53a', - networkText: 'Localhost 8546', - originText: DAPP_ONE_URL, - }); - await rejectTransaction(driver); - }, - ); - }); + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + + // Go to the first dapp, ensure it uses localhost + await selectDappClickSend(driver, DAPP_URL); + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + await rejectTransaction(driver); + + // Go to the second dapp, ensure it uses Ethereum Mainnet + await selectDappClickSend(driver, DAPP_ONE_URL); + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x53a', + networkText: 'Localhost 8546', + originText: DAPP_ONE_URL, + }); + await rejectTransaction(driver); + }, + ); + }); - it('handles three confirmations on three confirmations concurrently @no-mmi', async function () { - const port = 8546; - const chainId = 1338; // 0x53a - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerTripleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - // Ganache for network 1 - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - // Ganache for network 3 - { - port: 7777, - chainId: 1000, - ganacheOptions2: defaultGanacheOptions, - }, - ], + it('handles three confirmations on three confirmations concurrently @no-mmi', async function () { + const port = 8546; + const chainId = 1338; // 0x53a + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + // Ganache for network 1 + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + // Ganache for network 3 + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 3 }, + title: this.test.fullTitle(), }, - dappOptions: { numberOfDapps: 3 }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open the first dapp - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open the second dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); - - if (!IS_FIREFOX) { - // Open the third dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_TWO_URL, '0x3e8'); - } - - // Trigger a send confirmation on the first dapp, do not confirm or reject - await selectDappClickSend(driver, DAPP_URL); - - // Trigger a send confirmation on the second dapp, do not confirm or reject - await selectDappClickSend(driver, DAPP_ONE_URL); - - if (!IS_FIREFOX) { - // Trigger a send confirmation on the third dapp, do not confirm or reject - await selectDappClickSend(driver, DAPP_TWO_URL); - } - - // Switch to the Notification window, ensure first transaction still showing - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x539', - networkText: 'Localhost 8545', - originText: DAPP_URL, - }); - - // Confirm transaction, wait for first confirmation window to close, second to display - await confirmTransaction(driver); - await driver.delay(veryLargeDelayMs); - - // Switch to the new Notification window, ensure second transaction showing - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x53a', - networkText: 'Localhost 8546', - originText: DAPP_ONE_URL, - }); - - // Reject this transaction, wait for second confirmation window to close, third to display - await rejectTransaction(driver); - await driver.delay(veryLargeDelayMs); - - if (!IS_FIREFOX) { - // Switch to the new Notification window, ensure third transaction showing + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); + + if (!IS_FIREFOX) { + // Open the third dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_TWO_URL, '0x3e8'); + } + + // Trigger a send confirmation on the first dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_URL); + + // Trigger a send confirmation on the second dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_ONE_URL); + + if (!IS_FIREFOX) { + // Trigger a send confirmation on the third dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_TWO_URL); + } + + // Switch to the Notification window, ensure first transaction still showing await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x3e8', - networkText: 'Localhost 7777', - originText: DAPP_TWO_URL, + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, }); - // Confirm transaction + // Confirm transaction, wait for first confirmation window to close, second to display await confirmTransaction(driver); - } - - // With first and last confirmations confirmed, and second rejected, - // Ensure only first and last network balances were affected - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); + await driver.delay(veryLargeDelayMs); - // Wait for transaction to be completed on final confirmation - await driver.delay(veryLargeDelayMs); + // Switch to the new Notification window, ensure second transaction showing + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x53a', + networkText: 'Localhost 8546', + originText: DAPP_ONE_URL, + }); - if (!IS_FIREFOX) { - // Start on the last joined network, whose send transaction was just confirmed + // Reject this transaction, wait for second confirmation window to close, third to display + await rejectTransaction(driver); + await driver.delay(veryLargeDelayMs); + + if (!IS_FIREFOX) { + // Switch to the new Notification window, ensure third transaction showing + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x3e8', + networkText: 'Localhost 7777', + originText: DAPP_TWO_URL, + }); + + // Confirm transaction + await confirmTransaction(driver); + } + + // With first and last confirmations confirmed, and second rejected, + // Ensure only first and last network balances were affected + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Wait for transaction to be completed on final confirmation + await driver.delay(veryLargeDelayMs); + + if (!IS_FIREFOX) { + // Start on the last joined network, whose send transaction was just confirmed + await validateBalanceAndActivity(driver, '24.9998'); + } + + // Switch to second network, ensure full balance + await switchToNetworkByName(driver, 'Localhost 8546'); + await validateBalanceAndActivity(driver, '25', 0); + + // Turn on test networks in Networks menu so Localhost 8545 is available + await driver.clickElement('[data-testid="network-display"]'); + await driver.clickElement('.mm-modal-content__dialog .toggle-button'); + 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 validateBalanceAndActivity(driver, '24.9998'); - } - - // Switch to second network, ensure full balance - await switchToNetworkByName(driver, 'Localhost 8546'); - await validateBalanceAndActivity(driver, '25', 0); - - // Turn on test networks in Networks menu so Localhost 8545 is available - await driver.clickElement('[data-testid="network-display"]'); - await driver.clickElement('.mm-modal-content__dialog .toggle-button'); - 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 validateBalanceAndActivity(driver, '24.9998'); - }, - ); - }); + }, + ); + }); - it('should gracefully handle deleted network @no-mmi', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesController({ - preferences: { showTestNetworks: true }, - }) - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + it('should gracefully handle deleted network @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesController({ + preferences: { showTestNetworks: true }, + }) + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), }, - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open the first dapp - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open the second dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); - - // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Ethereum Mainnet', - }); - - await driver.clickElement('[data-testid="network-display"]'); - - const networkRow = await driver.findElement({ - css: '.multichain-network-list-item', - text: 'Localhost 8545', - }); - - const networkMenu = await driver.findNestedElement( - networkRow, - `[data-testid="network-list-item-options-button-${CHAIN_IDS.LOCALHOST}"]`, - ); - - await networkMenu.click(); - await driver.clickElement( - '[data-testid="network-list-item-options-delete"]', - ); - - await driver.clickElement({ tag: 'button', text: 'Delete' }); - - // Go back to first dapp, try an action, ensure deleted network doesn't block UI - // The current globally selected network, Ethereum Mainnet, should be used - await selectDappClickSend(driver, DAPP_URL); - await driver.delay(veryLargeDelayMs); - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x1', - networkText: 'Ethereum Mainnet', - originText: DAPP_URL, - }); - }, - ); - }); + async ({ driver }) => { + await unlockWallet(driver); - it('should signal from UI to dapp the network change @no-mmi', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), - driverOptions: { constrainWindowSize: true }, - }, - async ({ driver }) => { - // Navigate to extension home screen - await unlockWallet(driver); - - // Open the first dapp which starts on chain '0x539 - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Ensure the dapp starts on the correct network - await driver.waitForSelector({ - css: '[id="chainId"]', - text: '0x539', - }); - - // Open the popup with shimmed activeTabOrigin - await openPopupWithActiveTabOrigin(driver, DAPP_URL); - - // Switch to mainnet - await switchToNetworkByName(driver, 'Ethereum Mainnet'); - - // Switch back to the Dapp tab - await driver.switchToWindowWithUrl(DAPP_URL); - - // Check to make sure the dapp network changed - await driver.waitForSelector({ - css: '[id="chainId"]', - text: '0x1', - }); - }, - ); - }); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + await driver.clickElement('[data-testid="network-display"]'); - it('should autoswitch networks to the last used network for domain', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + const networkRow = await driver.findElement({ + css: '.multichain-network-list-item', + text: 'Localhost 8545', + }); + + const networkMenu = await driver.findNestedElement( + networkRow, + `[data-testid="network-list-item-options-button-${CHAIN_IDS.LOCALHOST}"]`, + ); + + await networkMenu.click(); + await driver.clickElement( + '[data-testid="network-list-item-options-delete"]', + ); + + await driver.clickElement({ tag: 'button', text: 'Delete' }); + + // Go back to first dapp, try an action, ensure deleted network doesn't block UI + // The current globally selected network, Ethereum Mainnet, should be used + await selectDappClickSend(driver, DAPP_URL); + await driver.delay(veryLargeDelayMs); + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x1', + networkText: 'Ethereum Mainnet', + originText: DAPP_URL, + }); }, - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - // Open fullscreen - await unlockWallet(driver); - - // Open the first dapp which starts on chain '0x539 - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open tab 2, switch to Ethereum Mainnet - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); - - // Open the popup with shimmed activeTabOrigin - await openPopupWithActiveTabOrigin(driver, DAPP_URL); - - // Ensure network was reset to original - await driver.findElement({ - css: '.multichain-app-header__contents--avatar-network .mm-text', - text: 'Localhost 8545', - }); - - // Ensure toast is shown to the user - await driver.findElement({ - css: '.toast-text', - text: 'Localhost 8545 is now active on 127.0.0.1:8080', - }); - }, - ); - }); + ); + }); - it('should autoswitch networks when last confirmation from another network is rejected', async function () { - const port = 8546; - const chainId = 1338; - - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + it('should autoswitch networks when last confirmation from another network is rejected', async function () { + const port = 8546; + const chainId = 1338; + + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + driverOptions: { constrainWindowSize: true }, }, - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - driverOptions: { constrainWindowSize: true }, - }, - async ({ driver }) => { - await unlockWallet(driver); - - // Open the first dapp which starts on chain '0x539 - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open tab 2, switch to Ethereum Mainnet - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); - await driver.waitForSelector({ - css: '.error-message-text', - text: 'You are on the Ethereum Mainnet.', - }); - await driver.delay(veryLargeDelayMs); - - // Start a Send on Ethereum Mainnet - await driver.clickElement('#sendButton'); - await driver.delay(regularDelayMs); - - // Open the popup with shimmed activeTabOrigin - await openPopupWithActiveTabOrigin(driver, DAPP_URL); - - // Ensure the confirmation pill shows Ethereum Mainnet - await driver.waitForSelector({ - css: '[data-testid="network-display"]', - text: 'Ethereum Mainnet', - }); - - // Reject the confirmation - await driver.clickElement( - '[data-testid="page-container-footer-cancel"]', - ); - - // Wait for network to automatically change to localhost - await driver.waitForSelector({ - css: '.multichain-app-header__contents--avatar-network .mm-text', - text: 'Localhost 8545', - }); - - // Ensure toast is shown to the user - await driver.waitForSelector({ - css: '.toast-text', - text: 'Localhost 8545 is now active on 127.0.0.1:8080', - }); - }, - ); - }); + async ({ driver }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp which starts on chain '0x539 + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - it('should gracefully handle network connectivity failure for signatures @no-mmi', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + // Open tab 2, switch to Ethereum Mainnet + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + await driver.waitForSelector({ + css: '.error-message-text', + text: 'You are on the Ethereum Mainnet.', + }); + await driver.delay(veryLargeDelayMs); + + // Start a Send on Ethereum Mainnet + await driver.clickElement('#sendButton'); + await driver.delay(regularDelayMs); + + // Open the popup with shimmed activeTabOrigin + await openPopupWithActiveTabOrigin(driver, DAPP_URL); + + // Ensure the confirmation pill shows Ethereum Mainnet + await driver.waitForSelector({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + // Reject the confirmation + await driver.clickElement( + '[data-testid="page-container-footer-cancel"]', + ); + + // Wait for network to automatically change to localhost + await driver.waitForSelector({ + css: '.multichain-app-header__contents--avatar-network .mm-text', + text: 'Localhost 8545', + }); + + // Ensure toast is shown to the user + await driver.waitForSelector({ + css: '.toast-text', + text: 'Localhost 8545 is now active on 127.0.0.1:8080', + }); }, - // This test intentionally quits Ganache while the extension is using it, causing - // PollingBlockTracker errors and others. These are expected. - ignoredConsoleErrors: ['ignore-all'], - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - }, - async ({ driver, ganacheServer, secondaryGanacheServer }) => { - await unlockWallet(driver); - await tempToggleSettingRedesignedConfirmations(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open the first dapp - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open the second dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); - - // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.waitForSelector({ - css: '[data-testid="network-display"]', - text: 'Ethereum Mainnet', - }); - - // Kill ganache servers - await ganacheServer.quit(); - await secondaryGanacheServer[0].quit(); - - // Go back to first dapp, try an action, ensure network connection failure doesn't block UI - await selectDappClickPersonalSign(driver, DAPP_URL); - - // When the network is down, there is a performance degradation that causes the - // popup to take a few seconds to open in MV3 (issue #25690) - await driver.waitUntilXWindowHandles(4, 1000, 15000); - - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x539', - networkText: 'Localhost 8545', - originText: DAPP_URL, - }); - }, - ); + ); + }); + + it('should gracefully handle network connectivity failure for signatures @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + // This test intentionally quits Ganache while the extension is using it, causing + // PollingBlockTracker errors and others. These are expected. + ignoredConsoleErrors: ['ignore-all'], + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver, ganacheServer, secondaryGanacheServer }) => { + await unlockWallet(driver); + await tempToggleSettingRedesignedConfirmations(driver); + + // Navigate to extension home screen + await driver.navigate(PAGES.HOME); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.waitForSelector({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + // Kill ganache servers + await ganacheServer.quit(); + await secondaryGanacheServer[0].quit(); + + // Go back to first dapp, try an action, ensure network connection failure doesn't block UI + await selectDappClickPersonalSign(driver, DAPP_URL); + + // When the network is down, there is a performance degradation that causes the + // popup to take a few seconds to open in MV3 (issue #25690) + await driver.waitUntilXWindowHandles(4, 1000, 15000); + + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + }, + ); + }); + + it('should gracefully handle network connectivity failure for confirmations @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + // Presently confirmations take up to 10 seconds to display on a dead network + driverOptions: { timeOut: 30000 }, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + // This test intentionally quits Ganache while the extension is using it, causing + // PollingBlockTracker errors and others. These are expected. + ignoredConsoleErrors: ['ignore-all'], + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver, ganacheServer, secondaryGanacheServer }) => { + await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + // Kill ganache servers + await ganacheServer.quit(); + await secondaryGanacheServer[0].quit(); + + // Go back to first dapp, try an action, ensure network connection failure doesn't block UI + await selectDappClickSend(driver, DAPP_URL); + + // When the network is down, there is a performance degradation that causes the + // popup to take a few seconds to open in MV3 (issue #25690) + await driver.waitUntilXWindowHandles(4, 1000, 15000); + + await switchToDialogPopoverValidateDetails(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + }, + ); + }); }); - it('should gracefully handle network connectivity failure for confirmations @no-mmi', async function () { - const port = 8546; - const chainId = 1338; - await withFixtures( - { - dapp: true, - // Presently confirmations take up to 10 seconds to display on a dead network - driverOptions: { timeOut: 30000 }, - fixtures: new FixtureBuilder() - .withNetworkControllerDoubleGanache() - .withPreferencesControllerUseRequestQueueEnabled() - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - concurrent: [ - { - port, - chainId, - ganacheOptions2: defaultGanacheOptions, - }, - ], + describe('Redesigned confirmation screens', function () { + it('should show network specific to domain @no-mmi', async function () { + const port = 8546; + const chainId = 1338; // 0x53a + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), }, - // This test intentionally quits Ganache while the extension is using it, causing - // PollingBlockTracker errors and others. These are expected. - ignoredConsoleErrors: ['ignore-all'], - dappOptions: { numberOfDapps: 2 }, - title: this.test.fullTitle(), - }, - async ({ driver, ganacheServer, secondaryGanacheServer }) => { - await unlockWallet(driver); - - // Navigate to extension home screen - await driver.navigate(PAGES.HOME); - - // Open the first dapp - await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); - - // Open the second dapp and switch chains - await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); - - // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - await driver.findElement({ - css: '[data-testid="network-display"]', - text: 'Ethereum Mainnet', - }); - - // Kill ganache servers - await ganacheServer.quit(); - await secondaryGanacheServer[0].quit(); - - // Go back to first dapp, try an action, ensure network connection failure doesn't block UI - await selectDappClickSend(driver, DAPP_URL); - - // When the network is down, there is a performance degradation that causes the - // popup to take a few seconds to open in MV3 (issue #25690) - await driver.waitUntilXWindowHandles(4, 1000, 15000); - - await switchToDialogPopoverValidateDetails(driver, { - chainId: '0x539', - networkText: 'Localhost 8545', - originText: DAPP_URL, - }); - }, - ); + async ({ driver }) => { + await unlockWallet(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Localhost 8546', + }); + + // Go to the first dapp, ensure it uses localhost + await selectDappClickSend(driver, DAPP_URL); + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + await rejectTransactionRedesign(driver); + + // Go to the second dapp, ensure it uses Ethereum Mainnet + await selectDappClickSend(driver, DAPP_ONE_URL); + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x53a', + networkText: 'Localhost 8546', + originText: DAPP_ONE_URL, + }); + await rejectTransactionRedesign(driver); + }, + ); + }); + + it('handles three confirmations on three confirmations concurrently @no-mmi', async function () { + const port = 8546; + const chainId = 1338; // 0x53a + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + // Ganache for network 1 + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + // Ganache for network 3 + { + port: 7777, + chainId: 1000, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 3 }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x53a'); + + if (!IS_FIREFOX) { + // Open the third dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_TWO_URL, '0x3e8'); + } + + // Trigger a send confirmation on the first dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_URL); + + // Trigger a send confirmation on the second dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_ONE_URL); + + if (!IS_FIREFOX) { + // Trigger a send confirmation on the third dapp, do not confirm or reject + await selectDappClickSend(driver, DAPP_TWO_URL); + } + + // Switch to the Notification window, ensure first transaction still showing + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + + // Confirm transaction, wait for first confirmation window to close, second to display + await confirmTransaction(driver); + await driver.delay(veryLargeDelayMs); + + // Switch to the new Notification window, ensure second transaction showing + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x53a', + networkText: 'Localhost 8546', + originText: DAPP_ONE_URL, + }); + + // Reject this transaction, wait for second confirmation window to close, third to display + await rejectTransactionRedesign(driver); + await driver.delay(veryLargeDelayMs); + + if (!IS_FIREFOX) { + // Switch to the new Notification window, ensure third transaction showing + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x3e8', + networkText: 'Localhost 7777', + originText: DAPP_TWO_URL, + }); + + // Confirm transaction + await confirmTransaction(driver); + } + + // With first and last confirmations confirmed, and second rejected, + // Ensure only first and last network balances were affected + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + // Wait for transaction to be completed on final confirmation + await driver.delay(veryLargeDelayMs); + + if (!IS_FIREFOX) { + // Start on the last joined network, whose send transaction was just confirmed + await validateBalanceAndActivity(driver, '24.9998'); + } + + // Switch to second network, ensure full balance + await switchToNetworkByName(driver, 'Localhost 8546'); + await validateBalanceAndActivity(driver, '25', 0); + + // Turn on test networks in Networks menu so Localhost 8545 is available + await driver.clickElement('[data-testid="network-display"]'); + await driver.clickElement('.mm-modal-content__dialog .toggle-button'); + 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 validateBalanceAndActivity(driver, '24.9998'); + }, + ); + }); + + it('should gracefully handle deleted network @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesController({ + preferences: { showTestNetworks: true }, + }) + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + await driver.clickElement('[data-testid="network-display"]'); + + const networkRow = await driver.findElement({ + css: '.multichain-network-list-item', + text: 'Localhost 8545', + }); + + const networkMenu = await driver.findNestedElement( + networkRow, + `[data-testid="network-list-item-options-button-${CHAIN_IDS.LOCALHOST}"]`, + ); + + await networkMenu.click(); + await driver.clickElement( + '[data-testid="network-list-item-options-delete"]', + ); + + await driver.clickElement({ tag: 'button', text: 'Delete' }); + + // Go back to first dapp, try an action, ensure deleted network doesn't block UI + // The current globally selected network, Ethereum Mainnet, should be used + await selectDappClickSend(driver, DAPP_URL); + await driver.delay(veryLargeDelayMs); + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x1', + networkText: 'Ethereum Mainnet', + originText: DAPP_URL, + }); + }, + ); + }); + + it('should signal from UI to dapp the network change @no-mmi', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + driverOptions: { constrainWindowSize: true }, + }, + async ({ driver }) => { + // Navigate to extension home screen + await unlockWallet(driver); + + // Open the first dapp which starts on chain '0x539 + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Ensure the dapp starts on the correct network + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x539', + }); + + // Open the popup with shimmed activeTabOrigin + await openPopupWithActiveTabOrigin(driver, DAPP_URL); + + // Switch to mainnet + await switchToNetworkByName(driver, 'Ethereum Mainnet'); + + // Switch back to the Dapp tab + await driver.switchToWindowWithUrl(DAPP_URL); + + // Check to make sure the dapp network changed + await driver.waitForSelector({ + css: '[id="chainId"]', + text: '0x1', + }); + }, + ); + }); + + it('should autoswitch networks to the last used network for domain', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + // Open fullscreen + await unlockWallet(driver); + + // Open the first dapp which starts on chain '0x539 + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open tab 2, switch to Ethereum Mainnet + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Open the popup with shimmed activeTabOrigin + await openPopupWithActiveTabOrigin(driver, DAPP_URL); + + // Ensure network was reset to original + await driver.findElement({ + css: '.multichain-app-header__contents--avatar-network .mm-text', + text: 'Localhost 8545', + }); + + // Ensure toast is shown to the user + await driver.findElement({ + css: '.toast-text', + text: 'Localhost 8545 is now active on 127.0.0.1:8080', + }); + }, + ); + }); + + it('should autoswitch networks when last confirmation from another network is rejected', async function () { + const port = 8546; + const chainId = 1338; + + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + driverOptions: { constrainWindowSize: true }, + }, + async ({ driver }) => { + await unlockWallet(driver); + + // Open the first dapp which starts on chain '0x539 + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open tab 2, switch to Ethereum Mainnet + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + await driver.waitForSelector({ + css: '.error-message-text', + text: 'You are on the Ethereum Mainnet.', + }); + await driver.delay(veryLargeDelayMs); + + // Start a Send on Ethereum Mainnet + await driver.clickElement('#sendButton'); + await driver.delay(regularDelayMs); + + // Open the popup with shimmed activeTabOrigin + await openPopupWithActiveTabOrigin(driver, DAPP_URL); + + // Ensure the confirmation pill shows Ethereum Mainnet + await driver.waitForSelector({ + css: 'p', + text: 'Ethereum Mainnet', + }); + + // Reject the confirmation + await driver.clickElement({ css: 'button', text: 'Cancel' }); + + // Wait for network to automatically change to localhost + await driver.waitForSelector({ + css: '.multichain-app-header__contents--avatar-network .mm-text', + text: 'Localhost 8545', + }); + + // Ensure toast is shown to the user + await driver.waitForSelector({ + css: '.toast-text', + text: 'Localhost 8545 is now active on 127.0.0.1:8080', + }); + }, + ); + }); + + it('should gracefully handle network connectivity failure for signatures @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + // This test intentionally quits Ganache while the extension is using it, causing + // PollingBlockTracker errors and others. These are expected. + ignoredConsoleErrors: ['ignore-all'], + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver, ganacheServer, secondaryGanacheServer }) => { + await unlockWallet(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.waitForSelector({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + // Kill ganache servers + await ganacheServer.quit(); + await secondaryGanacheServer[0].quit(); + + // Go back to first dapp, try an action, ensure network connection failure doesn't block UI + await selectDappClickPersonalSign(driver, DAPP_URL); + + // When the network is down, there is a performance degradation that causes the + // popup to take a few seconds to open in MV3 (issue #25690) + await driver.waitUntilXWindowHandles(4, 1000, 15000); + + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + }, + ); + }); + + it('should gracefully handle network connectivity failure for confirmations @no-mmi', async function () { + const port = 8546; + const chainId = 1338; + await withFixtures( + { + dapp: true, + // Presently confirmations take up to 10 seconds to display on a dead network + driverOptions: { timeOut: 30000 }, + fixtures: new FixtureBuilder() + .withNetworkControllerDoubleGanache() + .withPreferencesControllerUseRequestQueueEnabled() + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + concurrent: [ + { + port, + chainId, + ganacheOptions2: defaultGanacheOptions, + }, + ], + }, + // This test intentionally quits Ganache while the extension is using it, causing + // PollingBlockTracker errors and others. These are expected. + ignoredConsoleErrors: ['ignore-all'], + dappOptions: { numberOfDapps: 2 }, + title: this.test.fullTitle(), + }, + async ({ driver, ganacheServer, secondaryGanacheServer }) => { + await unlockWallet(driver); + + // Open the first dapp + await openDappAndSwitchChain(driver, DAPP_URL, '0x539'); + + // Open the second dapp and switch chains + await openDappAndSwitchChain(driver, DAPP_ONE_URL, '0x1'); + + // Go to wallet fullscreen, ensure that the global network changed to Ethereum Mainnet + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await driver.findElement({ + css: '[data-testid="network-display"]', + text: 'Ethereum Mainnet', + }); + + // Kill ganache servers + await ganacheServer.quit(); + await secondaryGanacheServer[0].quit(); + + // Go back to first dapp, try an action, ensure network connection failure doesn't block UI + await selectDappClickSend(driver, DAPP_URL); + + // When the network is down, there is a performance degradation that causes the + // popup to take a few seconds to open in MV3 (issue #25690) + await driver.waitUntilXWindowHandles(4, 1000, 15000); + + await switchToDialogPopoverValidateDetailsRedesign(driver, { + chainId: '0x539', + networkText: 'Localhost 8545', + originText: DAPP_URL, + }); + }, + ); + }); }); }); diff --git a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js index 958854a5252c..c1ff9f3477ab 100644 --- a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js +++ b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js @@ -6,6 +6,7 @@ const { logInWithBalanceValidation, openActionMenuAndStartSendFlow, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -21,6 +22,7 @@ describe('MetaMask Responsive UI', function () { }, async ({ driver }) => { await driver.navigate(); + // agree to terms of use await driver.clickElement('[data-testid="onboarding-terms-checkbox"]'); @@ -129,6 +131,8 @@ describe('MetaMask Responsive UI', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Send ETH from inside MetaMask // starts to send a transaction await openActionMenuAndStartSendFlow(driver); diff --git a/test/e2e/tests/settings/4byte-directory.spec.js b/test/e2e/tests/settings/4byte-directory.spec.js index 483ff1e0149a..b36f72f0575c 100644 --- a/test/e2e/tests/settings/4byte-directory.spec.js +++ b/test/e2e/tests/settings/4byte-directory.spec.js @@ -6,6 +6,7 @@ const { unlockWallet, withFixtures, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); @@ -27,6 +28,8 @@ describe('4byte setting', function () { ); await logInWithBalanceValidation(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // deploy contract await openDapp(driver, contractAddress); @@ -63,6 +66,8 @@ describe('4byte setting', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // goes to the settings screen await openMenuSafe(driver); await driver.clickElement({ text: 'Settings', tag: 'div' }); diff --git a/test/e2e/tests/settings/show-hex-data.spec.js b/test/e2e/tests/settings/show-hex-data.spec.js index 4bef79ca0a3b..353847a544b4 100644 --- a/test/e2e/tests/settings/show-hex-data.spec.js +++ b/test/e2e/tests/settings/show-hex-data.spec.js @@ -2,6 +2,7 @@ const { defaultGanacheOptions, withFixtures, logInWithBalanceValidation, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -78,6 +79,9 @@ describe('Check the toggle for hex data', function () { }, async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await toggleHexData(driver); await clickOnLogo(driver); await sendTransactionAndVerifyHexData(driver); diff --git a/test/e2e/tests/tokens/custom-token-add-approve.spec.js b/test/e2e/tests/tokens/custom-token-add-approve.spec.js index 4e85aae76fd6..9226ad1a36f1 100644 --- a/test/e2e/tests/tokens/custom-token-add-approve.spec.js +++ b/test/e2e/tests/tokens/custom-token-add-approve.spec.js @@ -1,5 +1,4 @@ const { strict: assert } = require('assert'); - const { clickNestedButton, defaultGanacheOptions, @@ -8,6 +7,7 @@ const { openDapp, WINDOW_TITLES, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); @@ -84,6 +84,8 @@ describe('Create token, approve token and approve token without gas', function ( ); await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // create token await openDapp(driver, contractAddress); @@ -182,6 +184,8 @@ describe('Create token, approve token and approve token without gas', function ( ); await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // create token await openDapp(driver, contractAddress); @@ -317,6 +321,8 @@ describe('Create token, approve token and approve token without gas', function ( ); await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // create token await openDapp(driver, contractAddress); const windowHandles = await driver.getAllWindowHandles(); @@ -398,6 +404,8 @@ describe('Create token, approve token and approve token without gas', function ( ); await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver, contractAddress); const windowHandles = await driver.getAllWindowHandles(); const extension = windowHandles[0]; diff --git a/test/e2e/tests/tokens/custom-token-send-transfer.spec.js b/test/e2e/tests/tokens/custom-token-send-transfer.spec.js index 40b1872011bd..0c5498a82cca 100644 --- a/test/e2e/tests/tokens/custom-token-send-transfer.spec.js +++ b/test/e2e/tests/tokens/custom-token-send-transfer.spec.js @@ -8,6 +8,8 @@ const { editGasFeeForm, WINDOW_TITLES, clickNestedButton, + tempToggleSettingRedesignedTransactionConfirmations, + veryLargeDelayMs, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); @@ -28,6 +30,8 @@ describe('Transfer custom tokens @no-mmi', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // go to custom tokens view on extension, perform send tokens await driver.clickElement({ css: '[data-testid="multichain-token-list-item-value"]', @@ -115,10 +119,15 @@ describe('Transfer custom tokens @no-mmi', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // transfer token from dapp await openDapp(driver, contractAddress); + await driver.delay(veryLargeDelayMs); + await driver.clickElement({ text: 'Transfer Tokens', tag: 'button' }); - await switchToNotificationWindow(driver); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await driver.waitForSelector({ text: '1.5 TST', tag: 'h1' }); // edit gas fee @@ -174,8 +183,11 @@ describe('Transfer custom tokens @no-mmi', function () { ); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // transfer token from dapp await openDapp(driver, contractAddress); + await driver.delay(veryLargeDelayMs); await driver.clickElement({ text: 'Transfer Tokens Without Gas', tag: 'button', diff --git a/test/e2e/tests/tokens/increase-token-allowance.spec.js b/test/e2e/tests/tokens/increase-token-allowance.spec.js index 7df956e9df43..9ce8db2cc065 100644 --- a/test/e2e/tests/tokens/increase-token-allowance.spec.js +++ b/test/e2e/tests/tokens/increase-token-allowance.spec.js @@ -10,6 +10,7 @@ const { ACCOUNT_2, WINDOW_TITLES, clickNestedButton, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); @@ -38,6 +39,8 @@ describe('Increase Token Allowance', function () { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + const contractAddress = await contractRegistry.getContractAddress( smartContract, ); diff --git a/test/e2e/tests/tokens/nft/erc1155-interaction.spec.js b/test/e2e/tests/tokens/nft/erc1155-interaction.spec.js index c635d465353a..388247bb3fcd 100644 --- a/test/e2e/tests/tokens/nft/erc1155-interaction.spec.js +++ b/test/e2e/tests/tokens/nft/erc1155-interaction.spec.js @@ -7,6 +7,8 @@ const { unlockWallet, WINDOW_TITLES, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, + veryLargeDelayMs, } = require('../../../helpers'); const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); const FixtureBuilder = require('../../../fixture-builder'); @@ -38,6 +40,8 @@ describe('ERC1155 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -118,6 +122,8 @@ describe('ERC1155 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openDapp(driver, contract); await driver.fill('#batchTransferTokenIds', '1, 2, 3'); @@ -170,6 +176,8 @@ describe('ERC1155 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Create a set approval for all erc1155 token request in test dapp await openDapp(driver, contract); await driver.clickElement('#setApprovalForAllERC1155Button'); @@ -254,8 +262,13 @@ describe('ERC1155 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Create a revoke approval for all erc1155 token request in test dapp await openDapp(driver, contract); + + await driver.delay(veryLargeDelayMs); + await driver.clickElement('#revokeERC1155Button'); // Wait for notification popup and check the displayed message diff --git a/test/e2e/tests/tokens/nft/erc721-interaction.spec.js b/test/e2e/tests/tokens/nft/erc721-interaction.spec.js index 35750bae6d2c..8b634ffc3ce3 100644 --- a/test/e2e/tests/tokens/nft/erc721-interaction.spec.js +++ b/test/e2e/tests/tokens/nft/erc721-interaction.spec.js @@ -6,6 +6,7 @@ const { WINDOW_TITLES, defaultGanacheOptions, clickNestedButton, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../../helpers'); const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); const FixtureBuilder = require('../../../fixture-builder'); @@ -28,6 +29,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -91,6 +94,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -212,6 +217,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -310,6 +317,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -357,6 +366,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -424,6 +435,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); @@ -490,6 +503,8 @@ describe('ERC721 NFTs testdapp interaction', function () { const contract = contractRegistry.getContractAddress(smartContract); await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Open Dapp and wait for deployed contract await openDapp(driver, contract); await driver.findClickableElement('#deployButton'); diff --git a/test/e2e/tests/tokens/nft/send-nft.spec.js b/test/e2e/tests/tokens/nft/send-nft.spec.js index 35585bbaf2ea..13cb310f1416 100644 --- a/test/e2e/tests/tokens/nft/send-nft.spec.js +++ b/test/e2e/tests/tokens/nft/send-nft.spec.js @@ -4,6 +4,7 @@ const { logInWithBalanceValidation, unlockWallet, withFixtures, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../../helpers'); const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); const FixtureBuilder = require('../../../fixture-builder'); @@ -24,6 +25,8 @@ describe('Send NFT', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Fill the send NFT form and confirm the transaction await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); await driver.clickElement('.nft-item__container'); diff --git a/test/e2e/tests/transaction/change-assets.spec.js b/test/e2e/tests/transaction/change-assets.spec.js index 7ce971fd8d80..f6a997c164e9 100644 --- a/test/e2e/tests/transaction/change-assets.spec.js +++ b/test/e2e/tests/transaction/change-assets.spec.js @@ -3,6 +3,7 @@ const { defaultGanacheOptions, withFixtures, logInWithBalanceValidation, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); @@ -22,6 +23,8 @@ describe('Change assets', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Wait for balance to load await driver.delay(500); @@ -100,6 +103,8 @@ describe('Change assets', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Click the Send button await driver.clickElement({ css: '[data-testid="multichain-token-list-button"] span', @@ -179,6 +184,8 @@ describe('Change assets', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Choose the nft await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); await driver.clickElement('[data-testid="nft-default-image"]'); @@ -266,6 +273,8 @@ describe('Change assets', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Create second account await driver.clickElement('[data-testid="account-menu-icon"]'); await driver.clickElement( diff --git a/test/e2e/tests/transaction/edit-gas-fee.spec.js b/test/e2e/tests/transaction/edit-gas-fee.spec.js index 918831f8f3ad..3e7655750594 100644 --- a/test/e2e/tests/transaction/edit-gas-fee.spec.js +++ b/test/e2e/tests/transaction/edit-gas-fee.spec.js @@ -9,6 +9,7 @@ const { unlockWallet, generateGanacheOptions, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -22,6 +23,9 @@ describe('Editing Confirm Transaction', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createInternalTransaction(driver); await driver.findElement({ @@ -95,6 +99,9 @@ describe('Editing Confirm Transaction', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createInternalTransaction(driver); await driver.findElement({ @@ -172,6 +179,8 @@ describe('Editing Confirm Transaction', function () { // login to extension await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createDappTransaction(driver, { maxFeePerGas: '0x2000000000', maxPriorityFeePerGas: '0x1000000000', diff --git a/test/e2e/tests/transaction/gas-estimates.spec.js b/test/e2e/tests/transaction/gas-estimates.spec.js index 263dfc85d904..f12275ad4d9f 100644 --- a/test/e2e/tests/transaction/gas-estimates.spec.js +++ b/test/e2e/tests/transaction/gas-estimates.spec.js @@ -3,6 +3,7 @@ const { logInWithBalanceValidation, openActionMenuAndStartSendFlow, generateGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); const { CHAIN_IDS } = require('../../../../shared/constants/network'); @@ -27,6 +28,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( @@ -69,6 +72,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( @@ -108,6 +113,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( @@ -143,6 +150,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( 'input[placeholder="Enter public address (0x) or domain name"]', @@ -189,6 +198,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( 'input[placeholder="Enter public address (0x) or domain name"]', @@ -218,6 +229,8 @@ describe('Gas estimates generated by MetaMask', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await openActionMenuAndStartSendFlow(driver); await driver.fill( 'input[placeholder="Enter public address (0x) or domain name"]', diff --git a/test/e2e/tests/transaction/multiple-transactions.spec.js b/test/e2e/tests/transaction/multiple-transactions.spec.js index 4d913cb07edb..b7ad05b3a93d 100644 --- a/test/e2e/tests/transaction/multiple-transactions.spec.js +++ b/test/e2e/tests/transaction/multiple-transactions.spec.js @@ -6,6 +6,7 @@ const { unlockWallet, generateGanacheOptions, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -23,6 +24,8 @@ describe('Multiple transactions', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // initiates a transaction from the dapp await openDapp(driver); // creates first transaction @@ -85,6 +88,8 @@ describe('Multiple transactions', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // initiates a transaction from the dapp await openDapp(driver); // creates first transaction diff --git a/test/e2e/tests/transaction/navigate-transactions.spec.js b/test/e2e/tests/transaction/navigate-transactions.spec.js index 63170d027874..16e8f9374cb5 100644 --- a/test/e2e/tests/transaction/navigate-transactions.spec.js +++ b/test/e2e/tests/transaction/navigate-transactions.spec.js @@ -1,7 +1,6 @@ const { createDappTransaction, } = require('../../page-objects/flows/transaction'); - const { default: ConfirmationNavigation, } = require('../../page-objects/pages/confirmations/legacy/navigation'); @@ -13,6 +12,7 @@ const { unlockWallet, generateGanacheOptions, WINDOW_TITLES, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -32,6 +32,9 @@ describe('Navigate transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createMultipleTransactions(driver, TRANSACTION_COUNT); const navigation = new ConfirmationNavigation(driver); @@ -73,6 +76,9 @@ describe('Navigate transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createMultipleTransactions(driver, TRANSACTION_COUNT); const navigation = new ConfirmationNavigation(driver); @@ -107,6 +113,9 @@ describe('Navigate transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createMultipleTransactions(driver, TRANSACTION_COUNT); // reject transaction @@ -131,6 +140,9 @@ describe('Navigate transactions', function () { }, async ({ driver }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createMultipleTransactions(driver, TRANSACTION_COUNT); // confirm transaction @@ -155,6 +167,9 @@ describe('Navigate transactions', function () { }, async ({ driver, ganacheServer }) => { await unlockWallet(driver); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createMultipleTransactions(driver, TRANSACTION_COUNT); // reject transactions diff --git a/test/e2e/tests/transaction/send-edit.spec.js b/test/e2e/tests/transaction/send-edit.spec.js index 953f2ebf3569..8d19d6d071b1 100644 --- a/test/e2e/tests/transaction/send-edit.spec.js +++ b/test/e2e/tests/transaction/send-edit.spec.js @@ -8,6 +8,7 @@ const { withFixtures, unlockWallet, generateGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -21,6 +22,7 @@ describe('Editing Confirm Transaction', function () { }, async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); await createInternalTransaction(driver); await driver.findElement({ @@ -96,6 +98,8 @@ describe('Editing Confirm Transaction', function () { }, async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await createInternalTransaction(driver); await driver.findElement({ diff --git a/test/e2e/tests/transaction/send-eth.spec.js b/test/e2e/tests/transaction/send-eth.spec.js index 5cbcb8309a18..9ee1b58dc170 100644 --- a/test/e2e/tests/transaction/send-eth.spec.js +++ b/test/e2e/tests/transaction/send-eth.spec.js @@ -9,6 +9,7 @@ const { editGasFeeForm, WINDOW_TITLES, defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const FixtureBuilder = require('../../fixture-builder'); @@ -106,6 +107,8 @@ describe('Send ETH', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await driver.delay(1000); await openActionMenuAndStartSendFlow(driver); @@ -256,6 +259,8 @@ describe('Send ETH', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // initiates a send from the dapp await openDapp(driver); await driver.clickElement({ text: 'Send', tag: 'button' }); @@ -332,6 +337,8 @@ describe('Send ETH', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // initiates a transaction from the dapp await openDapp(driver); await driver.clickElement({ text: 'Create Token', tag: 'button' }); @@ -435,6 +442,8 @@ describe('Send ETH', function () { async ({ driver }) => { await unlockWallet(driver); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await driver.assertElementNotPresent('.loading-overlay__spinner'); const balance = await driver.findElement( '[data-testid="eth-overview__primary-currency"]', diff --git a/test/e2e/tests/transaction/send-hex-address.spec.js b/test/e2e/tests/transaction/send-hex-address.spec.js index d93f1a0d5484..b6ad969c6735 100644 --- a/test/e2e/tests/transaction/send-hex-address.spec.js +++ b/test/e2e/tests/transaction/send-hex-address.spec.js @@ -3,6 +3,7 @@ const { withFixtures, logInWithBalanceValidation, openActionMenuAndStartSendFlow, + tempToggleSettingRedesignedTransactionConfirmations, } = require('../../helpers'); const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); const FixtureBuilder = require('../../fixture-builder'); @@ -120,6 +121,8 @@ describe('Send ERC20 to a 40 character hexadecimal address', function () { async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Send TST await driver.clickElement( '[data-testid="account-overview__asset-tab"]', @@ -181,6 +184,9 @@ describe('Send ERC20 to a 40 character hexadecimal address', function () { }, async ({ driver, ganacheServer }) => { await logInWithBalanceValidation(driver, ganacheServer); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + // Send TST await driver.clickElement( '[data-testid="account-overview__asset-tab"]', diff --git a/test/e2e/tests/transaction/simple-send.spec.ts b/test/e2e/tests/transaction/simple-send.spec.ts index 25f2368a9cfc..7d2f4835cdca 100644 --- a/test/e2e/tests/transaction/simple-send.spec.ts +++ b/test/e2e/tests/transaction/simple-send.spec.ts @@ -1,7 +1,11 @@ import { Suite } from 'mocha'; import { Driver } from '../../webdriver/driver'; import { Ganache } from '../../seeder/ganache'; -import { withFixtures, defaultGanacheOptions } from '../../helpers'; +import { + withFixtures, + defaultGanacheOptions, + tempToggleSettingRedesignedTransactionConfirmations, +} from '../../helpers'; import FixtureBuilder from '../../fixture-builder'; import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; import { sendTransactionToAddress } from '../../page-objects/flows/send-transaction.flow'; @@ -23,6 +27,9 @@ describe('Simple send eth', function (this: Suite) { ganacheServer?: Ganache; }) => { await loginWithBalanceValidation(driver, ganacheServer); + + await tempToggleSettingRedesignedTransactionConfirmations(driver); + await sendTransactionToAddress({ driver, recipientAddress: '0x985c30949c92df7a0bd42e0f3e3d539ece98db24', diff --git a/ui/pages/settings/experimental-tab/experimental-tab.component.tsx b/ui/pages/settings/experimental-tab/experimental-tab.component.tsx index d81eb04966a9..5bea76d80dbe 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.component.tsx +++ b/ui/pages/settings/experimental-tab/experimental-tab.component.tsx @@ -178,6 +178,7 @@ export default class ExperimentalTab extends PureComponent description: t('redesignedTransactionsToggleDescription'), toggleValue: redesignedTransactionsEnabled, toggleCallback: (value) => setRedesignedTransactionsEnabled(!value), + toggleContainerDataTestId: 'toggle-redesigned-transactions-container', toggleDataTestId: 'toggle-redesigned-transactions', toggleOffLabel: t('off'), toggleOnLabel: t('on'), From be2b439924773a9dc5995c21ac33b446572a86e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 26 Nov 2024 16:14:59 +0000 Subject: [PATCH 084/148] chore: adds Solana support for the account overview (#28411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We added support for the Solana account overview. Now when we select a Solana address the user will be able to see its details in the home view. Also since the overview is the same for SOL and BTC, in order to not repeat components, we've renamed as "non-evm" the existing BTC ones, and reused them. ![Screenshot 2024-11-13 at 13 53 42](https://github.com/user-attachments/assets/649c1b1c-2da2-4f12-a18d-3549d8739c0e) ## **Related issues** Fixes: ## **Manual testing steps** As of right now, manually testing is a bit complex, it needs to run the snap manually and the extension, since we 1st need to publish a new release to npm with more up to date work. The snap version we have in npm is outdated and won't support this flow. That said, if you want to go ahead and run locally the steps are the following: 1. Clone the [ Solana Snap monorepo](https://github.com/MetaMask/snap-solana-wallet) and run it locally with `yarn` and then `yarn start` 2. In the extension, at this branch, apply the following changes and run the extension as flask: ``` At builds.yml add the solana feature to the flask build: features: - build-flask - keyring-snaps + - solana At shared/lib/accounts/solana-wallet-snap.ts point the snap ID to the snap localhost: -export const SOLANA_WALLET_SNAP_ID: SnapId = SolanaWalletSnap.snapId as SnapId; +//export const SOLANA_WALLET_SNAP_ID: SnapId = SolanaWalletSnap.snapId as SnapId; +export const SOLANA_WALLET_SNAP_ID: SnapId = "local:http://localhost:8080/"; ``` 3. Manually install the snap via the snap dapp at http://localhost:3000 4. Enable the Solana account via Settings > Experimental > Enable Solana account 5. Create a Solana account from the account-list menu and see the account overview of it ## **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 Co-authored-by: Charly Chevalier --- app/_locales/en/messages.json | 3 + .../lib/accounts/BalancesController.test.ts | 4 + .../lib/accounts/BalancesController.ts | 79 ++++++++++++++++--- app/scripts/metamask-controller.test.js | 2 +- package.json | 3 +- shared/constants/multichain/assets.ts | 18 +++++ shared/constants/multichain/networks.ts | 6 ++ shared/constants/network.ts | 8 +- .../errors-after-init-opt-in-ui-state.json | 16 ++-- test/jest/mocks.ts | 5 +- ui/components/app/wallet-overview/index.js | 2 +- ...ories.tsx => non-evm-overview.stories.tsx} | 6 +- ...iew.test.tsx => non-evm-overview.test.tsx} | 50 ++++++++---- ...{btc-overview.tsx => non-evm-overview.tsx} | 16 +++- .../account-list-menu/account-list-menu.tsx | 2 + .../account-overview-btc.stories.tsx | 12 --- .../account-overview-non-evm.stories.tsx | 19 +++++ ....tsx => account-overview-non-evm.test.tsx} | 12 +-- ...w-btc.tsx => account-overview-non-evm.tsx} | 10 ++- .../account-overview/account-overview.tsx | 11 ++- .../token-list-item/token-list-item.tsx | 9 ++- ui/hooks/useCurrencyDisplay.js | 11 +-- ui/selectors/multichain.ts | 32 +++++--- 23 files changed, 243 insertions(+), 93 deletions(-) rename ui/components/app/wallet-overview/{btc-overview.stories.tsx => non-evm-overview.stories.tsx} (70%) rename ui/components/app/wallet-overview/{btc-overview.test.tsx => non-evm-overview.test.tsx} (92%) rename ui/components/app/wallet-overview/{btc-overview.tsx => non-evm-overview.tsx} (73%) delete mode 100644 ui/components/multichain/account-overview/account-overview-btc.stories.tsx create mode 100644 ui/components/multichain/account-overview/account-overview-non-evm.stories.tsx rename ui/components/multichain/account-overview/{account-overview-btc.test.tsx => account-overview-non-evm.test.tsx} (88%) rename ui/components/multichain/account-overview/{account-overview-btc.tsx => account-overview-non-evm.tsx} (66%) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 54c0a782a592..e9e9cc807ccd 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3205,6 +3205,9 @@ "networkNamePolygon": { "message": "Polygon" }, + "networkNameSolana": { + "message": "Solana" + }, "networkNameTestnet": { "message": "Testnet" }, diff --git a/app/scripts/lib/accounts/BalancesController.test.ts b/app/scripts/lib/accounts/BalancesController.test.ts index e8ddd89f021e..982df0289fea 100644 --- a/app/scripts/lib/accounts/BalancesController.test.ts +++ b/app/scripts/lib/accounts/BalancesController.test.ts @@ -6,6 +6,7 @@ import { InternalAccount, } from '@metamask/keyring-api'; import { createMockInternalAccount } from '../../../../test/jest/mocks'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; import { BalancesController, AllowedActions, @@ -25,6 +26,9 @@ const mockBtcAccount = createMockInternalAccount({ name: 'mock-btc-snap', enabled: true, }, + options: { + scope: MultichainNetworks.BITCOIN_TESTNET, + }, }); const mockBalanceResult = { diff --git a/app/scripts/lib/accounts/BalancesController.ts b/app/scripts/lib/accounts/BalancesController.ts index e657fe47e64f..588053d6ea2a 100644 --- a/app/scripts/lib/accounts/BalancesController.ts +++ b/app/scripts/lib/accounts/BalancesController.ts @@ -13,6 +13,7 @@ import { type CaipAssetType, type InternalAccount, isEvmAccountType, + SolAccountType, } from '@metamask/keyring-api'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; @@ -23,6 +24,8 @@ import type { AccountsControllerAccountRemovedEvent, AccountsControllerListMultichainAccountsAction, } from '@metamask/accounts-controller'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +import { MULTICHAIN_NETWORK_TO_ASSET_TYPES } from '../../../../shared/constants/multichain/assets'; import { isBtcMainnetAddress } from '../../../../shared/lib/multichain'; import { BalancesTracker } from './BalancesTracker'; @@ -122,13 +125,17 @@ const balancesControllerMetadata = { }, }; -const BTC_TESTNET_ASSETS = ['bip122:000000000933ea01ad0ee984209779ba/slip44:0']; -const BTC_MAINNET_ASSETS = ['bip122:000000000019d6689c085ae165831e93/slip44:0']; const BTC_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds +const SOLANA_AVG_BLOCK_TIME = 400; // 400 milliseconds // NOTE: We set an interval of half the average block time to mitigate when our interval // is de-synchronized with the actual block time. -export const BALANCES_UPDATE_TIME = BTC_AVG_BLOCK_TIME / 2; +export const BTC_BALANCES_UPDATE_TIME = BTC_AVG_BLOCK_TIME / 2; + +const BALANCE_CHECK_INTERVALS = { + [BtcAccountType.P2wpkh]: BTC_BALANCES_UPDATE_TIME, + [SolAccountType.DataAccount]: SOLANA_AVG_BLOCK_TIME, +}; /** * The BalancesController is responsible for fetching and caching account @@ -165,7 +172,7 @@ export class BalancesController extends BaseController< // Register all non-EVM accounts into the tracker for (const account of this.#listAccounts()) { if (this.#isNonEvmAccount(account)) { - this.#tracker.track(account.id, BALANCES_UPDATE_TIME); + this.#tracker.track(account.id, this.#getBlockTimeFor(account)); } } @@ -193,6 +200,23 @@ export class BalancesController extends BaseController< this.#tracker.stop(); } + /** + * Gets the block time for a given account. + * + * @param account - The account to get the block time for. + * @returns The block time for the account. + */ + #getBlockTimeFor(account: InternalAccount): number { + if (account.type in BALANCE_CHECK_INTERVALS) { + return BALANCE_CHECK_INTERVALS[ + account.type as keyof typeof BALANCE_CHECK_INTERVALS + ]; + } + throw new Error( + `Unsupported account type for balance tracking: ${account.type}`, + ); + } + /** * Lists the multichain accounts coming from the `AccountsController`. * @@ -207,15 +231,16 @@ export class BalancesController extends BaseController< /** * Lists the accounts that we should get balances for. * - * Currently, we only get balances for P2WPKH accounts, but this will change - * in the future when we start support other non-EVM account types. - * * @returns A list of accounts that we should get balances for. */ #listAccounts(): InternalAccount[] { const accounts = this.#listMultichainAccounts(); - return accounts.filter((account) => account.type === BtcAccountType.P2wpkh); + return accounts.filter( + (account) => + account.type === SolAccountType.DataAccount || + account.type === BtcAccountType.P2wpkh, + ); } /** @@ -249,12 +274,13 @@ export class BalancesController extends BaseController< const partialState: BalancesControllerState = { balances: {} }; if (account.metadata.snap) { + const scope = this.#getScopeFrom(account); + const assetTypes = MULTICHAIN_NETWORK_TO_ASSET_TYPES[scope]; + partialState.balances[account.id] = await this.#getBalances( account.id, account.metadata.snap.id, - isBtcMainnetAddress(account.address) - ? BTC_MAINNET_ASSETS - : BTC_TESTNET_ASSETS, + assetTypes, ); } @@ -312,7 +338,7 @@ export class BalancesController extends BaseController< return; } - this.#tracker.track(account.id, BTC_AVG_BLOCK_TIME); + this.#tracker.track(account.id, this.#getBlockTimeFor(account)); // NOTE: Unfortunately, we cannot update the balance right away here, because // messenger's events are running synchronously and fetching the balance is // asynchronous. @@ -376,4 +402,33 @@ export class BalancesController extends BaseController< })) as Promise, }); } + + /** + * Gets the network scope for a given account. + * + * @param account - The account to get the scope for. + * @returns The network scope for the account. + * @throws If the account type is unknown or unsupported. + */ + #getScopeFrom(account: InternalAccount): MultichainNetworks { + // TODO: Use the new `account.scopes` once available in the `keyring-api`. + + // For Bitcoin accounts, we get the scope based on the address format. + if (account.type === BtcAccountType.P2wpkh) { + if (isBtcMainnetAddress(account.address)) { + return MultichainNetworks.BITCOIN; + } + return MultichainNetworks.BITCOIN_TESTNET; + } + + // For Solana accounts, we know we have a `scope` on the account's `options` bag. + if (account.type === SolAccountType.DataAccount) { + if (!account.options.scope) { + throw new Error('Solana account scope is undefined'); + } + return account.options.scope as MultichainNetworks; + } + + throw new Error(`Unsupported non-EVM account type: ${account.type}`); + } } diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 880df69aa00f..0e08a0ac27c0 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -41,7 +41,7 @@ import { createMockInternalAccount } from '../../test/jest/mocks'; import { mockNetworkState } from '../../test/stub/networks'; import { BalancesController as MultichainBalancesController, - BALANCES_UPDATE_TIME as MULTICHAIN_BALANCES_UPDATE_TIME, + BTC_BALANCES_UPDATE_TIME as MULTICHAIN_BALANCES_UPDATE_TIME, } from './lib/accounts/BalancesController'; import { BalancesTracker as MultichainBalancesTracker } from './lib/accounts/BalancesTracker'; import { deferredPromise } from './lib/util'; diff --git a/package.json b/package.json index 9ed956d639cd..b7adb1fcdcee 100644 --- a/package.json +++ b/package.json @@ -752,7 +752,8 @@ "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, - "jest-preview": false + "jest-preview": false, + "@metamask/solana-wallet-snap>@solana/web3.js>bigint-buffer": false } }, "packageManager": "yarn@4.5.1" diff --git a/shared/constants/multichain/assets.ts b/shared/constants/multichain/assets.ts index 23462d57d05a..58e0869ff045 100644 --- a/shared/constants/multichain/assets.ts +++ b/shared/constants/multichain/assets.ts @@ -1,3 +1,4 @@ +import { CaipAssetType } from '@metamask/keyring-api'; import { MultichainNetworks } from './networks'; export const MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19 = { @@ -13,3 +14,20 @@ export enum MultichainNativeAssets { SOLANA_DEVNET = `${MultichainNetworks.SOLANA_DEVNET}/slip44:501`, SOLANA_TESTNET = `${MultichainNetworks.SOLANA_TESTNET}/slip44:501`, } + +/** + * Maps network identifiers to their corresponding native asset types. + * Each network is mapped to an array containing its native asset for consistency. + */ +export const MULTICHAIN_NETWORK_TO_ASSET_TYPES: Record< + MultichainNetworks, + CaipAssetType[] +> = { + [MultichainNetworks.SOLANA]: [MultichainNativeAssets.SOLANA], + [MultichainNetworks.SOLANA_TESTNET]: [MultichainNativeAssets.SOLANA_TESTNET], + [MultichainNetworks.SOLANA_DEVNET]: [MultichainNativeAssets.SOLANA_DEVNET], + [MultichainNetworks.BITCOIN]: [MultichainNativeAssets.BITCOIN], + [MultichainNetworks.BITCOIN_TESTNET]: [ + MultichainNativeAssets.BITCOIN_TESTNET, + ], +}; diff --git a/shared/constants/multichain/networks.ts b/shared/constants/multichain/networks.ts index f5a45138d88a..659228ba1199 100644 --- a/shared/constants/multichain/networks.ts +++ b/shared/constants/multichain/networks.ts @@ -1,4 +1,5 @@ import { CaipChainId } from '@metamask/utils'; +import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; import { isBtcMainnetAddress, isBtcTestnetAddress, @@ -33,6 +34,11 @@ export enum MultichainNetworks { SOLANA_TESTNET = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', } +export const MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET = { + [BtcAccountType.P2wpkh]: MultichainNetworks.BITCOIN, + [SolAccountType.DataAccount]: MultichainNetworks.SOLANA, +} as const; + export const BITCOIN_TOKEN_IMAGE_URL = './images/bitcoin-logo.svg'; export const SOLANA_TOKEN_IMAGE_URL = './images/solana-logo.svg'; diff --git a/shared/constants/network.ts b/shared/constants/network.ts index 3fca971c338e..41f1d9fd0d95 100644 --- a/shared/constants/network.ts +++ b/shared/constants/network.ts @@ -301,7 +301,6 @@ export const CURRENCY_SYMBOLS = { AVALANCHE: 'AVAX', BNB: 'BNB', BUSD: 'BUSD', - BTC: 'BTC', // Do we wanna mix EVM and non-EVM here? CELO: 'CELO', DAI: 'DAI', GNOSIS: 'XDAI', @@ -322,8 +321,15 @@ export const CURRENCY_SYMBOLS = { ONE: 'ONE', } as const; +// Non-EVM currency symbols +export const NON_EVM_CURRENCY_SYMBOLS = { + BTC: 'BTC', + SOL: 'SOL', +} as const; + const CHAINLIST_CURRENCY_SYMBOLS_MAP = { ...CURRENCY_SYMBOLS, + ...NON_EVM_CURRENCY_SYMBOLS, BASE: 'ETH', LINEA_MAINNET: 'ETH', OPBNB: 'BNB', 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 8b2efef3e517..01f94b55d5c7 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 @@ -35,13 +35,11 @@ "petnamesEnabled": true, "showMultiRpcModal": "boolean", "isRedesignedConfirmationsDeveloperEnabled": "boolean", - "redesignedConfirmationsEnabled": true, - "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", - "tokenNetworkFilter": { - "0x539": "boolean" - }, - "shouldShowAggregatedBalancePopover": "boolean" + "shouldShowAggregatedBalancePopover": "boolean", + "tokenNetworkFilter": { "0x539": "boolean" }, + "redesignedConfirmationsEnabled": true, + "redesignedTransactionsEnabled": "boolean" }, "firstTimeFlowType": "import", "completedOnboarding": true, @@ -176,10 +174,7 @@ "gasEstimateType": "none", "nonRPCGasFeeApisDisabled": "boolean", "tokenList": "object", - "tokensChainsCache": { - "0x539": "object" - }, - "tokenBalances": "object", + "tokensChainsCache": { "0x539": "object" }, "preventPollingOnNetworkRestart": false, "tokens": "object", "ignoredTokens": "object", @@ -187,6 +182,7 @@ "allTokens": {}, "allIgnoredTokens": {}, "allDetectedTokens": {}, + "tokenBalances": "object", "smartTransactionsState": { "fees": {}, "feesByChainId": "object", diff --git a/test/jest/mocks.ts b/test/jest/mocks.ts index b0750b022e78..8822b96315b6 100644 --- a/test/jest/mocks.ts +++ b/test/jest/mocks.ts @@ -9,6 +9,7 @@ import { import { KeyringTypes } from '@metamask/keyring-controller'; import { v4 as uuidv4 } from 'uuid'; import { keyringTypeToName } from '@metamask/accounts-controller'; +import { Json } from '@metamask/utils'; import { DraftTransaction, draftTransactionInitialState, @@ -186,6 +187,7 @@ export function createMockInternalAccount({ keyringType = KeyringTypes.hd, lastSelected = 0, snapOptions = undefined, + options = undefined, }: { name?: string; address?: string; @@ -197,6 +199,7 @@ export function createMockInternalAccount({ name: string; id: string; }; + options?: Record; } = {}) { let methods; @@ -236,7 +239,7 @@ export function createMockInternalAccount({ snap: snapOptions, lastSelected, }, - options: {}, + options: options ?? {}, methods, type, }; diff --git a/ui/components/app/wallet-overview/index.js b/ui/components/app/wallet-overview/index.js index 54536007bc41..82003b364199 100644 --- a/ui/components/app/wallet-overview/index.js +++ b/ui/components/app/wallet-overview/index.js @@ -1,2 +1,2 @@ export { default as EthOverview } from './eth-overview'; -export { default as BtcOverview } from './btc-overview'; +export { default as NonEvmOverview } from './non-evm-overview'; diff --git a/ui/components/app/wallet-overview/btc-overview.stories.tsx b/ui/components/app/wallet-overview/non-evm-overview.stories.tsx similarity index 70% rename from ui/components/app/wallet-overview/btc-overview.stories.tsx rename to ui/components/app/wallet-overview/non-evm-overview.stories.tsx index 43dff2554bef..2e8ae16045ce 100644 --- a/ui/components/app/wallet-overview/btc-overview.stories.tsx +++ b/ui/components/app/wallet-overview/non-evm-overview.stories.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import BtcOverview from './btc-overview'; +import NonEvmOverview from './non-evm-overview'; export default { title: 'Components/App/WalletOverview/BtcOverview', - component: BtcOverview, + component: NonEvmOverview, parameters: { docs: { description: { @@ -14,6 +14,6 @@ export default { }, }; -const Template = (args) => ; +const Template = (args) => ; export const Default = Template.bind({}); diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/non-evm-overview.test.tsx similarity index 92% rename from ui/components/app/wallet-overview/btc-overview.test.tsx rename to ui/components/app/wallet-overview/non-evm-overview.test.tsx index 3c5697cb5853..aa49eb77e79d 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/non-evm-overview.test.tsx @@ -17,7 +17,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import useMultiPolling from '../../../hooks/useMultiPolling'; -import BtcOverview from './btc-overview'; +import NonEvmOverview from './non-evm-overview'; // We need to mock `dispatch` since we use it for `setDefaultHomeActiveTabName`. const mockDispatch = jest.fn().mockReturnValue(() => jest.fn()); @@ -134,7 +134,7 @@ function makePortfolioUrl(path: string, getParams: Record) { return `${PORTOFOLIO_URL}/${path}?${params.toString()}`; } -describe('BtcOverview', () => { +describe('NonEvmOverview', () => { beforeEach(() => { setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); // Clear previous mock implementations @@ -156,8 +156,11 @@ describe('BtcOverview', () => { }); }); - it('shows the primary balance as BTC when showNativeTokenAsMainBalance if true', async () => { - const { queryByTestId } = renderWithProvider(, getStore()); + it('shows the primary balance using the native token when showNativeTokenAsMainBalance if true', async () => { + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); const primaryBalance = queryByTestId(BTC_OVERVIEW_PRIMARY_CURRENCY); expect(primaryBalance).toBeInTheDocument(); @@ -166,7 +169,7 @@ describe('BtcOverview', () => { it('shows the primary balance as fiat when showNativeTokenAsMainBalance if false', async () => { const { queryByTestId } = renderWithProvider( - , + , getStore({ metamask: { ...mockMetamaskStore, @@ -186,7 +189,7 @@ describe('BtcOverview', () => { it('shows a spinner if balance is not available', async () => { const { container } = renderWithProvider( - , + , getStore({ metamask: { ...mockMetamaskStore, @@ -203,7 +206,10 @@ describe('BtcOverview', () => { }); it('buttons Swap/Bridge are disabled', () => { - const { queryByTestId } = renderWithProvider(, getStore()); + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); for (const buttonTestId of [BTC_OVERVIEW_SWAP, BTC_OVERVIEW_BRIDGE]) { const button = queryByTestId(buttonTestId); @@ -213,13 +219,19 @@ describe('BtcOverview', () => { }); it('shows the "Buy & Sell" button', () => { - const { queryByTestId } = renderWithProvider(, getStore()); + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); const buyButton = queryByTestId(BTC_OVERVIEW_BUY); expect(buyButton).toBeInTheDocument(); }); it('"Buy & Sell" button is disabled if BTC is not buyable', () => { - const { queryByTestId } = renderWithProvider(, getStore()); + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); const buyButton = queryByTestId(BTC_OVERVIEW_BUY); expect(buyButton).toBeInTheDocument(); @@ -234,7 +246,7 @@ describe('BtcOverview', () => { }); const { queryByTestId } = renderWithProvider( - , + , storeWithBtcBuyable, ); @@ -252,7 +264,7 @@ describe('BtcOverview', () => { }); const { queryByTestId } = renderWithProvider( - , + , storeWithBtcBuyable, ); @@ -283,7 +295,7 @@ describe('BtcOverview', () => { const mockTrackEvent = jest.fn(); const { queryByTestId } = renderWithProvider( - + , storeWithBtcBuyable, ); @@ -307,7 +319,10 @@ describe('BtcOverview', () => { }); it('always show the Receive button', () => { - const { queryByTestId } = renderWithProvider(, getStore()); + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); const receiveButton = queryByTestId(BTC_OVERVIEW_RECEIVE); expect(receiveButton).toBeInTheDocument(); }); @@ -332,7 +347,7 @@ describe('BtcOverview', () => { }); const { queryByTestId } = renderWithProvider( - , + , storeWithBtcBuyable, ); @@ -343,7 +358,10 @@ describe('BtcOverview', () => { }); it('always show the Send button', () => { - const { queryByTestId } = renderWithProvider(, getStore()); + const { queryByTestId } = renderWithProvider( + , + getStore(), + ); const sendButton = queryByTestId(BTC_OVERVIEW_SEND); expect(sendButton).toBeInTheDocument(); expect(sendButton).not.toBeDisabled(); @@ -353,7 +371,7 @@ describe('BtcOverview', () => { const mockTrackEvent = jest.fn(); const { queryByTestId } = renderWithProvider( - + , getStore(), ); diff --git a/ui/components/app/wallet-overview/btc-overview.tsx b/ui/components/app/wallet-overview/non-evm-overview.tsx similarity index 73% rename from ui/components/app/wallet-overview/btc-overview.tsx rename to ui/components/app/wallet-overview/non-evm-overview.tsx index fb315d3ab3b0..8905a3a938f1 100644 --- a/ui/components/app/wallet-overview/btc-overview.tsx +++ b/ui/components/app/wallet-overview/non-evm-overview.tsx @@ -1,5 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; +///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) +import { BtcAccountType } from '@metamask/keyring-api'; +///: END:ONLY_INCLUDE_IF import { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getMultichainIsMainnet, @@ -14,11 +17,11 @@ import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { getSelectedInternalAccount } from '../../../selectors'; import { CoinOverview } from './coin-overview'; -type BtcOverviewProps = { +type NonEvmOverviewProps = { className?: string; }; -const BtcOverview = ({ className }: BtcOverviewProps) => { +const NonEvmOverview = ({ className }: NonEvmOverviewProps) => { const { chainId } = useSelector(getMultichainProviderConfig); const balance = useSelector(getMultichainSelectedAccountCachedBalance); const account = useSelector(getSelectedInternalAccount); @@ -28,6 +31,11 @@ const BtcOverview = ({ className }: BtcOverviewProps) => { account, ); const isBtcBuyable = useSelector(getIsBitcoinBuyable); + + // TODO: Update this to add support to check if Solana is buyable when the Send flow starts + const accountType = account.type; + const isBtc = accountType === BtcAccountType.P2wpkh; + const isBuyableChain = isBtc ? isBtcBuyable && isBtcMainnetAccount : false; ///: END:ONLY_INCLUDE_IF return ( @@ -42,10 +50,10 @@ const BtcOverview = ({ className }: BtcOverviewProps) => { isSwapsChain={false} ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) isBridgeChain={false} - isBuyableChain={isBtcBuyable && isBtcMainnetAccount} + isBuyableChain={isBuyableChain} ///: END:ONLY_INCLUDE_IF /> ); }; -export default BtcOverview; +export default NonEvmOverview; diff --git a/ui/components/multichain/account-list-menu/account-list-menu.tsx b/ui/components/multichain/account-list-menu/account-list-menu.tsx index 29d79e8537b1..030b57ebe242 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.tsx +++ b/ui/components/multichain/account-list-menu/account-list-menu.tsx @@ -17,6 +17,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { BtcAccountType, EthAccountType, + SolAccountType, ///: BEGIN:ONLY_INCLUDE_IF(build-flask) InternalAccount, KeyringAccountType, @@ -232,6 +233,7 @@ export const AccountListMenu = ({ EthAccountType.Eoa, EthAccountType.Erc4337, BtcAccountType.P2wpkh, + SolAccountType.DataAccount, ], }: AccountListMenuProps) => { const t = useI18nContext(); diff --git a/ui/components/multichain/account-overview/account-overview-btc.stories.tsx b/ui/components/multichain/account-overview/account-overview-btc.stories.tsx deleted file mode 100644 index 2afc54e22b23..000000000000 --- a/ui/components/multichain/account-overview/account-overview-btc.stories.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { AccountOverviewBtc } from './account-overview-btc' -import { AccountOverviewCommonProps } from './common'; - -export default { - title: 'Components/Multichain/AccountOverviewBtc', - component: AccountOverviewBtc, -}; - -export const DefaultStory = ( - args: JSX.IntrinsicAttributes & AccountOverviewCommonProps -) => ; diff --git a/ui/components/multichain/account-overview/account-overview-non-evm.stories.tsx b/ui/components/multichain/account-overview/account-overview-non-evm.stories.tsx new file mode 100644 index 000000000000..de3ac5484baf --- /dev/null +++ b/ui/components/multichain/account-overview/account-overview-non-evm.stories.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { AccountOverviewNonEvm } from './account-overview-non-evm'; +import { AccountOverviewCommonProps } from './common'; +import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; + +export default { + title: 'Components/Multichain/AccountOverviewNonEvm', + component: AccountOverviewNonEvm, + args: { + accountType: BtcAccountType.P2wpkh, + }, +}; + +export const DefaultStory = ( + args: JSX.IntrinsicAttributes & + AccountOverviewCommonProps & { + accountType: BtcAccountType.P2wpkh | SolAccountType.DataAccount; + }, +) => ; diff --git a/ui/components/multichain/account-overview/account-overview-btc.test.tsx b/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx similarity index 88% rename from ui/components/multichain/account-overview/account-overview-btc.test.tsx rename to ui/components/multichain/account-overview/account-overview-non-evm.test.tsx index b171840a540e..17989cbf31a6 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-non-evm.test.tsx @@ -5,9 +5,9 @@ import { renderWithProvider } from '../../../../test/jest/rendering'; import { setBackgroundConnection } from '../../../store/background-connection'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { - AccountOverviewBtc, - AccountOverviewBtcProps, -} from './account-overview-btc'; + AccountOverviewNonEvm, + AccountOverviewNonEvmProps, +} from './account-overview-non-evm'; jest.mock('../../../store/actions', () => ({ tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), @@ -26,14 +26,14 @@ jest.mock('react-redux', () => { }; }); -const defaultProps: AccountOverviewBtcProps = { +const defaultProps: AccountOverviewNonEvmProps = { defaultHomeActiveTabName: null, onTabClick: jest.fn(), setBasicFunctionalityModalOpen: jest.fn(), onSupportLinkClick: jest.fn(), }; -const render = (props: AccountOverviewBtcProps = defaultProps) => { +const render = (props: AccountOverviewNonEvmProps = defaultProps) => { const store = configureStore({ metamask: { ...mockState.metamask, @@ -47,7 +47,7 @@ const render = (props: AccountOverviewBtcProps = defaultProps) => { }, }); - return renderWithProvider(, store); + return renderWithProvider(, store); }; describe('AccountOverviewBtc', () => { diff --git a/ui/components/multichain/account-overview/account-overview-btc.tsx b/ui/components/multichain/account-overview/account-overview-non-evm.tsx similarity index 66% rename from ui/components/multichain/account-overview/account-overview-btc.tsx rename to ui/components/multichain/account-overview/account-overview-non-evm.tsx index dd58b2eef414..dd7db4484306 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.tsx +++ b/ui/components/multichain/account-overview/account-overview-non-evm.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { BtcOverview } from '../../app/wallet-overview'; +import { NonEvmOverview } from '../../app/wallet-overview'; import { AccountOverviewLayout } from './account-overview-layout'; import { AccountOverviewCommonProps } from './common'; -export type AccountOverviewBtcProps = AccountOverviewCommonProps; +export type AccountOverviewNonEvmProps = AccountOverviewCommonProps; -export const AccountOverviewBtc = (props: AccountOverviewBtcProps) => { +export const AccountOverviewNonEvm = ({ + ...props +}: AccountOverviewNonEvmProps) => { return ( { > { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask,build-mmi) - + ///: END:ONLY_INCLUDE_IF } diff --git a/ui/components/multichain/account-overview/account-overview.tsx b/ui/components/multichain/account-overview/account-overview.tsx index 3d6121e41471..f3f3e427a688 100644 --- a/ui/components/multichain/account-overview/account-overview.tsx +++ b/ui/components/multichain/account-overview/account-overview.tsx @@ -1,13 +1,17 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; +import { + BtcAccountType, + EthAccountType, + SolAccountType, +} from '@metamask/keyring-api'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { BannerAlert, BannerAlertSeverity } from '../../component-library'; import { getSelectedInternalAccount } from '../../../selectors'; import { AccountOverviewEth } from './account-overview-eth'; -import { AccountOverviewBtc } from './account-overview-btc'; import { AccountOverviewUnknown } from './account-overview-unknown'; import { AccountOverviewCommonProps } from './common'; +import { AccountOverviewNonEvm } from './account-overview-non-evm'; export type AccountOverviewProps = AccountOverviewCommonProps & { useExternalServices: boolean; @@ -25,7 +29,8 @@ export function AccountOverview(props: AccountOverviewProps) { case EthAccountType.Erc4337: return ; case BtcAccountType.P2wpkh: - return ; + case SolAccountType.DataAccount: + return ; default: return ; } 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 ef49ec3126cb..5ee4c19c8c52 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -56,7 +56,10 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; -import { CURRENCY_SYMBOLS } from '../../../../shared/constants/network'; +import { + CURRENCY_SYMBOLS, + NON_EVM_CURRENCY_SYMBOLS, +} from '../../../../shared/constants/network'; import { hexToDecimal } from '../../../../shared/modules/conversion.utils'; import { NETWORKS_ROUTE } from '../../../helpers/constants/routes'; @@ -141,8 +144,10 @@ export const TokenListItem = ({ switch (title) { case CURRENCY_SYMBOLS.ETH: return t('networkNameEthereum'); - case CURRENCY_SYMBOLS.BTC: + case NON_EVM_CURRENCY_SYMBOLS.BTC: return t('networkNameBitcoin'); + case NON_EVM_CURRENCY_SYMBOLS.SOL: + return t('networkNameSolana'); default: return title; } diff --git a/ui/hooks/useCurrencyDisplay.js b/ui/hooks/useCurrencyDisplay.js index 12b2cfc06ec3..03561153ed53 100644 --- a/ui/hooks/useCurrencyDisplay.js +++ b/ui/hooks/useCurrencyDisplay.js @@ -62,7 +62,8 @@ function formatEthCurrencyDisplay({ return null; } -function formatBtcCurrencyDisplay({ +function formatNonEvmAssetCurrencyDisplay({ + tokenSymbol, isNativeCurrency, isUserPreferredCurrency, currency, @@ -77,7 +78,7 @@ function formatBtcCurrencyDisplay({ // We use `Numeric` here, so we handle those amount the same way than for EVMs (it's worth // noting that if `inputValue` is not properly defined, the amount will be set to '0', see // `Numeric` constructor for that) - return new Numeric(inputValue, 10).toString(); // BTC usually uses 10 digits + return new Numeric(inputValue, 10).toString(); } else if (isUserPreferredCurrency && conversionRate) { const amount = getTokenFiatAmount( @@ -85,7 +86,7 @@ function formatBtcCurrencyDisplay({ Number(conversionRate), // native to fiat conversion rate currentCurrency, inputValue, - 'BTC', + tokenSymbol, false, false, ) ?? '0'; // if the conversion fails, return 0 @@ -162,8 +163,8 @@ export function useCurrencyDisplay( } if (!isEvm) { - // TODO: We would need to update this for other non-EVM coins - return formatBtcCurrencyDisplay({ + return formatNonEvmAssetCurrencyDisplay({ + tokenSymbol: nativeCurrency, isNativeCurrency, isUserPreferredCurrency, currency, diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 1914dbce2dd8..903b5d0a4a71 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -9,6 +9,7 @@ import { MultichainProviderConfig, MULTICHAIN_PROVIDER_CONFIGS, MultichainNetworks, + MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET, } from '../../shared/constants/multichain/networks'; import { getCompletedOnboarding, @@ -18,7 +19,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { BalancesControllerState } from '../../app/scripts/lib/accounts/BalancesController'; -import { MultichainNativeAssets } from '../../shared/constants/multichain/assets'; +import { MULTICHAIN_NETWORK_TO_ASSET_TYPES } from '../../shared/constants/multichain/assets'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, TEST_NETWORK_IDS, @@ -333,11 +334,15 @@ export function getMultichainIsMainnet( ) { const selectedAccount = account ?? getSelectedInternalAccount(state); const providerConfig = getMultichainProviderConfig(state, selectedAccount); - return getMultichainIsEvm(state, account) - ? getIsMainnet(state) - : // TODO: For now we only check for bitcoin, but we will need to - // update this for other non-EVM networks later! - providerConfig.chainId === MultichainNetworks.BITCOIN; + + if (getMultichainIsEvm(state, account)) { + return getIsMainnet(state); + } + + const mainnet = ( + MULTICHAIN_ACCOUNT_TYPE_TO_MAINNET as Record + )[selectedAccount.type]; + return providerConfig.chainId === mainnet ?? false; } export function getMultichainIsTestnet( @@ -370,12 +375,17 @@ export const getMultichainCoinRates = (state: MultichainState) => { return state.metamask.rates; }; -function getBtcCachedBalance(state: MultichainState) { +function getNonEvmCachedBalance(state: MultichainState) { const balances = getMultichainBalances(state); const account = getSelectedInternalAccount(state); - const asset = getMultichainIsMainnet(state) - ? MultichainNativeAssets.BITCOIN - : MultichainNativeAssets.BITCOIN_TESTNET; + const network = getMultichainCurrentNetwork(state); + + // We assume that there's at least one asset type in and that is the native + // token for that network. + const asset = + MULTICHAIN_NETWORK_TO_ASSET_TYPES[ + network.chainId as MultichainNetworks + ]?.[0]; return balances?.[account.id]?.[asset]?.amount; } @@ -394,7 +404,7 @@ export function getMultichainSelectedAccountCachedBalance( ) { return getMultichainIsEvm(state) ? getSelectedAccountCachedBalance(state) - : getBtcCachedBalance(state); + : getNonEvmCachedBalance(state); } export const getMultichainSelectedAccountCachedBalanceIsZero = createSelector( From 9fccd00a10a4dc6633113beedfc050e09009614c Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:17:25 -0800 Subject: [PATCH 085/148] fix: Pass along decimal balance from asset-page to swaps UI (#28707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Decimal balance are needed to be passed along with the rest of the token info in order to properly prepopulate the swaps UI. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28707?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28509 ## **Manual testing steps** 1. Navigate from AssetList to TokenDetails to Swap UI 2. Populate text field with a balance amount 3. If balance is lower than the token balance, do not show the warning. ## **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/pages/asset/components/asset-page.test.tsx | 20 ++++++++++++------- ui/pages/asset/components/asset-page.tsx | 10 +++++++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/ui/pages/asset/components/asset-page.test.tsx b/ui/pages/asset/components/asset-page.test.tsx index 60e322f8825e..28e232a0ba0b 100644 --- a/ui/pages/asset/components/asset-page.test.tsx +++ b/ui/pages/asset/components/asset-page.test.tsx @@ -45,6 +45,8 @@ jest.mock('../../../hooks/useMultiPolling', () => ({ default: jest.fn(), })); +const selectedAccountAddress = 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3'; + describe('AssetPage', () => { const mockStore = { localeMessages: { @@ -52,13 +54,17 @@ describe('AssetPage', () => { }, metamask: { tokenList: {}, - tokenBalances: {}, + tokenBalances: { + [selectedAccountAddress]: { + [CHAIN_IDS.MAINNET]: {}, + }, + }, marketData: {}, allTokens: {}, accountsByChainId: { '0x1': { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + [selectedAccountAddress]: { + address: selectedAccountAddress, balance: '0x00', }, }, @@ -80,9 +86,9 @@ describe('AssetPage', () => { preferences: {}, internalAccounts: { accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + [selectedAccountAddress]: { + address: selectedAccountAddress, + id: selectedAccountAddress, metadata: { name: 'Test Account', keyring: { @@ -94,7 +100,7 @@ describe('AssetPage', () => { type: EthAccountType.Eoa, }, }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + selectedAccount: selectedAccountAddress, }, keyrings: [ { diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 68d7f9d18e65..9100126d54fe 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -54,6 +54,7 @@ import { useTokenBalances } from '../../../hooks/useTokenBalances'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { getMultichainShouldShowFiat } from '../../../selectors/multichain'; import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; +import { hexToDecimal } from '../../../../shared/modules/conversion.utils'; import AssetChart from './chart/asset-chart'; import TokenButtons from './token-buttons'; @@ -151,6 +152,9 @@ const AssetPage = ({ ? toChecksumHexAddress(asset.address) : getNativeTokenAddress(chainId); + const tokenHexBalance = + selectedAccountTokenBalancesAcrossChains?.[chainId]?.[address as Hex]; + const balance = calculateTokenBalance({ isNative: type === AssetType.native, chainId, @@ -187,10 +191,10 @@ const AssetPage = ({ tokenMarketDetails.allTimeHigh > 0 || tokenMarketDetails.allTimeLow > 0); - // this is needed in order to assign the correct balances to TokenButtons before sending/swapping - // without this, the balances we be populated as zero until the user refreshes the screen: https://github.com/MetaMask/metamask-extension/issues/28509 + // this is needed in order to assign the correct balances to TokenButtons before navigating to send/swap screens + asset.balance = { - value: '', // decimal value not needed + value: hexToDecimal(tokenHexBalance), display: String(balance), fiat: String(tokenFiatAmount), }; From 43e9514f4525faa85f67802f5d0e8d70b2323e4d Mon Sep 17 00:00:00 2001 From: Nidhi Kumari Date: Tue, 26 Nov 2024 17:00:20 +0000 Subject: [PATCH 086/148] fix:updated account name and length for dapp connections (#28725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is to: 1. Show correct account names when dapp tries to connect with MM and account is imported 2. Show correct length of accounts when more than one account is connected ## **Related issues** Fixes: [3685-planning](https://github.com/MetaMask/MetaMask-planning/issues/3685 ) #28312 ## **Manual testing steps** 1. Import an account in MM 2. Initiate connection request from Dapp, check correct name of account is shown on connections page 3. After connecting, select more than account, check length of accounts shown is correct ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/bb7281c6-0f92-4160-a09b-9a69fe69d671 ### **After** https://github.com/user-attachments/assets/96d45514-74b1-451c-a136-889821b31e22 ## **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. --- .../review-permissions-page/site-cell/site-cell-tooltip.js | 2 +- .../pages/review-permissions-page/site-cell/site-cell.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js index 2e4eef35d594..84ede0d4dd4a 100644 --- a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js @@ -79,7 +79,7 @@ export const SiteCellTooltip = ({ accounts, networks }) => { data-testid="accounts-list-item-connected-account-name" ellipsis > - {acc.label || acc.metadata.name} + {acc.metadata.name || acc.label} ); diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx index d5ca0b816d48..fcb104937e28 100644 --- a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx @@ -72,13 +72,13 @@ export const SiteCell: React.FC = ({ const accountMessageConnectedState = selectedAccounts.length === 1 ? t('connectedWithAccountName', [ - selectedAccounts[0].label || selectedAccounts[0].metadata.name, + selectedAccounts[0].metadata.name || selectedAccounts[0].label, ]) - : t('connectedWithAccount', [accounts.length]); + : t('connectedWithAccount', [selectedAccounts.length]); const accountMessageNotConnectedState = selectedAccounts.length === 1 ? t('requestingForAccount', [ - selectedAccounts[0].label || selectedAccounts[0].metadata.name, + selectedAccounts[0].metadata.name || selectedAccounts[0].label, ]) : t('requestingFor'); From 815c445b1e8cade8f594579817c114bcc99e2a87 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Tue, 26 Nov 2024 17:11:54 +0000 Subject: [PATCH 087/148] fix: Fix avatar size for current network (#28731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Screenshot 2024-11-26 at 15 31 52 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28731?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. --- .../app/assets/asset-list/network-filter/network-filter.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index d032712be9c1..9b5cb3797e7c 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -110,6 +110,7 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { display={Display.Flex} justifyContent={JustifyContent.spaceBetween} width={BlockSize.Full} + gap={3} > { @@ -194,6 +196,7 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { From d24389c685fc5efc4da168da4e49fb533aecbcfb Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 26 Nov 2024 22:44:21 +0530 Subject: [PATCH 088/148] fix: Revert "feat: Changing title for permit requests (#28537)" (#28734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Revert changes in the PR: https://github.com/MetaMask/metamask-extension/pull/28537 ## **Related issues** Ref: https://github.com/MetaMask/MetaMask-planning/issues/3633 ## **Manual testing steps** 1. Enable permit signature decoding locally 2. Go to test dapp 3. Check title and description of permit pages ## **Screenshots/Recordings** TODO ## **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. --- .../signatures/nft-permit.spec.ts | 4 +-- .../confirmations/signatures/permit.test.tsx | 4 +-- .../components/confirm/title/title.test.tsx | 35 +++++++++++++++++++ .../components/confirm/title/title.tsx | 22 ++++++++++++ .../__snapshots__/confirm.test.tsx.snap | 12 +++---- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts index eccdfff78a7c..de70d25b359b 100644 --- a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts @@ -126,9 +126,9 @@ async function assertInfoValues(driver: Driver) { text: '0x581c3...45947', }); - const title = driver.findElement({ text: 'Signature request' }); + const title = driver.findElement({ text: 'Withdrawal request' }); const description = driver.findElement({ - text: 'Review request details before you confirm.', + text: 'This site wants permission to withdraw your NFTs', }); const primaryType = driver.findElement({ text: 'Permit' }); const spender = driver.findElement({ diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index ba51deb7336c..7af3be743f5f 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -191,9 +191,9 @@ describe('Permit Confirmation', () => { }); await waitFor(() => { - expect(screen.getByText('Signature request')).toBeInTheDocument(); + expect(screen.getByText('Spending cap request')).toBeInTheDocument(); expect( - screen.getByText('Review request details before you confirm.'), + screen.getByText('This site wants permission to spend your tokens.'), ).toBeInTheDocument(); }); }); diff --git a/ui/pages/confirmations/components/confirm/title/title.test.tsx b/ui/pages/confirmations/components/confirm/title/title.test.tsx index b20b67b05c97..3d4d6672940d 100644 --- a/ui/pages/confirmations/components/confirm/title/title.test.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.test.tsx @@ -8,8 +8,13 @@ import { getMockPersonalSignConfirmStateForRequest, getMockSetApprovalForAllConfirmState, getMockTypedSignConfirmState, + getMockTypedSignConfirmStateForRequest, } from '../../../../../../test/data/confirmations/helper'; import { unapprovedPersonalSignMsg } from '../../../../../../test/data/confirmations/personal_sign'; +import { + permitNFTSignatureMsg, + permitSignatureMsg, +} from '../../../../../../test/data/confirmations/typed_sign'; import { renderWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; import { tEn } from '../../../../../../test/lib/i18n-helpers'; import { @@ -54,6 +59,36 @@ describe('ConfirmTitle', () => { ).toBeInTheDocument(); }); + it('should render the title and description for a permit signature', () => { + const mockStore = configureMockStore([])( + getMockTypedSignConfirmStateForRequest(permitSignatureMsg), + ); + const { getByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(getByText('Spending cap request')).toBeInTheDocument(); + expect( + getByText('This site wants permission to spend your tokens.'), + ).toBeInTheDocument(); + }); + + it('should render the title and description for a NFT permit signature', () => { + const mockStore = configureMockStore([])( + getMockTypedSignConfirmStateForRequest(permitNFTSignatureMsg), + ); + const { getByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(getByText('Withdrawal request')).toBeInTheDocument(); + expect( + getByText('This site wants permission to withdraw your NFTs'), + ).toBeInTheDocument(); + }); + it('should render the title and description for typed signature', () => { const mockStore = configureMockStore([])(getMockTypedSignConfirmState()); const { getByText } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/title/title.tsx b/ui/pages/confirmations/components/confirm/title/title.tsx index 5fa3cc5b96f9..a926c0f6b482 100644 --- a/ui/pages/confirmations/components/confirm/title/title.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.tsx @@ -4,6 +4,7 @@ import { } from '@metamask/transaction-controller'; import React, { memo, useMemo } from 'react'; +import { TokenStandard } from '../../../../../../shared/constants/transaction'; import GeneralAlert from '../../../../../components/app/alert-system/general-alert/general-alert'; import { Box, Text } from '../../../../../components/component-library'; import { @@ -13,6 +14,7 @@ import { } from '../../../../../helpers/constants/design-system'; import useAlerts from '../../../../../hooks/useAlerts'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { TypedSignSignaturePrimaryTypes } from '../../../constants'; import { useConfirmContext } from '../../../context/confirm'; import { Confirmation, SignatureRequestType } from '../../../types/confirm'; import { isSIWESignatureRequest } from '../../../utils'; @@ -59,6 +61,8 @@ const getTitle = ( customSpendingCap?: string, isRevokeSetApprovalForAll?: boolean, pending?: boolean, + primaryType?: keyof typeof TypedSignSignaturePrimaryTypes, + tokenStandard?: string, ) => { if (pending) { return ''; @@ -75,6 +79,12 @@ const getTitle = ( } return t('confirmTitleSignature'); case TransactionType.signTypedData: + if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { + if (tokenStandard === TokenStandard.ERC721) { + return t('setApprovalForAllRedesignedTitle'); + } + return t('confirmTitlePermitTokens'); + } return t('confirmTitleSignature'); case TransactionType.tokenMethodApprove: if (isNFT) { @@ -103,6 +113,8 @@ const getDescription = ( customSpendingCap?: string, isRevokeSetApprovalForAll?: boolean, pending?: boolean, + primaryType?: keyof typeof TypedSignSignaturePrimaryTypes, + tokenStandard?: string, ) => { if (pending) { return ''; @@ -119,6 +131,12 @@ const getDescription = ( } return t('confirmTitleDescSign'); case TransactionType.signTypedData: + if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { + if (tokenStandard === TokenStandard.ERC721) { + return t('confirmTitleDescApproveTransaction'); + } + return t('confirmTitleDescPermitSignature'); + } return t('confirmTitleDescSign'); case TransactionType.tokenMethodApprove: if (isNFT) { @@ -177,6 +195,8 @@ const ConfirmTitle: React.FC = memo(() => { customSpendingCap, isRevokeSetApprovalForAll, spendingCapPending || revokePending, + primaryType, + tokenStandard, ), [ currentConfirmation, @@ -199,6 +219,8 @@ const ConfirmTitle: React.FC = memo(() => { customSpendingCap, isRevokeSetApprovalForAll, spendingCapPending || revokePending, + primaryType, + tokenStandard, ), [ currentConfirmation, diff --git a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap index 1d504025a44d..9f718a4b8a03 100644 --- a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap +++ b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap @@ -457,12 +457,12 @@ exports[`Confirm should match snapshot for signature - typed sign - V4 - PermitB

- Signature request + Spending cap request

- Review request details before you confirm. + This site wants permission to spend your tokens.

- Signature request + Spending cap request

- Review request details before you confirm. + This site wants permission to spend your tokens.

- Signature request + Spending cap request

- Review request details before you confirm. + This site wants permission to spend your tokens.

Date: Tue, 26 Nov 2024 19:00:22 +0100 Subject: [PATCH 089/148] chore: Remove unnecessary event prop (#28546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes an unnecessary event prop `smart_transaction_duplicated`. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28546?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. This event prop won't be available anymore in some events after submitting a smart transaction. ## **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. --- shared/modules/metametrics.test.ts | 2 -- shared/modules/metametrics.ts | 3 --- .../tests/smart-transactions/mock-requests-for-swap-test.ts | 2 -- 3 files changed, 7 deletions(-) diff --git a/shared/modules/metametrics.test.ts b/shared/modules/metametrics.test.ts index 9d6d17bc8040..b6fe2f8ac0a2 100644 --- a/shared/modules/metametrics.test.ts +++ b/shared/modules/metametrics.test.ts @@ -92,7 +92,6 @@ describe('getSmartTransactionMetricsProperties', () => { cancellationReason: 'not_cancelled', deadlineRatio: 0.6400288486480713, minedHash: txHash, - duplicated: true, timedOut: true, proxied: true, minedTx: 'success', @@ -112,7 +111,6 @@ describe('getSmartTransactionMetricsProperties', () => { expect(result).toStrictEqual({ gas_included: true, is_smart_transaction: true, - smart_transaction_duplicated: true, smart_transaction_proxied: true, smart_transaction_timed_out: true, }); diff --git a/shared/modules/metametrics.ts b/shared/modules/metametrics.ts index b689891da1fb..389a2778a5b8 100644 --- a/shared/modules/metametrics.ts +++ b/shared/modules/metametrics.ts @@ -6,7 +6,6 @@ import { TransactionMetricsRequest } from '../../app/scripts/lib/transaction/met type SmartTransactionMetricsProperties = { is_smart_transaction: boolean; gas_included: boolean; - smart_transaction_duplicated?: boolean; smart_transaction_timed_out?: boolean; smart_transaction_proxied?: boolean; }; @@ -31,8 +30,6 @@ export const getSmartTransactionMetricsProperties = ( if (!smartTransactionStatusMetadata) { return properties; } - properties.smart_transaction_duplicated = - smartTransactionStatusMetadata.duplicated; properties.smart_transaction_timed_out = smartTransactionStatusMetadata.timedOut; properties.smart_transaction_proxied = smartTransactionStatusMetadata.proxied; diff --git a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts index 78d1497dc9cf..bf9740c257ad 100644 --- a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts +++ b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts @@ -60,7 +60,6 @@ const GET_BATCH_STATUS_RESPONSE_PENDING = { minedTx: 'not_mined', wouldRevertMessage: null, minedHash: '', - duplicated: false, timedOut: false, proxied: false, type: 'sentinel', @@ -77,7 +76,6 @@ const GET_BATCH_STATUS_RESPONSE_SUCCESS = { wouldRevertMessage: null, minedHash: '0xec9d6214684d6dc191133ae4a7ec97db3e521fff9cfe5c4f48a84cb6c93a5fa5', - duplicated: true, timedOut: true, proxied: false, type: 'sentinel', From c272b254f05195167b0349004643e883743f2c6d Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:12:21 -0800 Subject: [PATCH 090/148] fix: Provide selector that enables cross-chain polling, regardless of network filter state (#28662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We cannot rely on the same selector for all cases, as not all UI is tightly coupled to the tokenNetworkFilter, else we will not be able to compute aggregated balances across chains, when filtered by current network. Since polling for balances is UI based, we can use a different selector on the network-filter, which should execute polling loops only when the dropdown is toggled open. With the current behavior, the aggregated balance will only display when "All Networks" filter is selected, and when the "Current Network" is selected, it will aggregate balances only for that chain. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28662?quickstart=1) ## **Related issues** Fixes: Current chain aggregated balance showing up in cross chain aggregated balance when current network is filterd. ## **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. --- .../network-filter/network-filter.tsx | 4 +-- ui/selectors/selectors.js | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index 9b5cb3797e7c..de68d8d6e13e 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -5,9 +5,9 @@ import { getCurrentChainId, getCurrentNetwork, getPreferences, - getChainIdsToPoll, getShouldHideZeroBalanceTokens, getSelectedAccount, + getAllChainsToPoll, } from '../../../../../selectors'; import { getNetworkConfigurationsByChainId } from '../../../../../../shared/modules/selectors/networks'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; @@ -50,7 +50,7 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); - const allChainIDs = useSelector(getChainIdsToPoll); + const allChainIDs = useSelector(getAllChainsToPoll); const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( selectedAccount, shouldHideZeroBalanceTokens, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index eea467ec0f16..154864dc9af4 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2369,6 +2369,42 @@ export const getAllEnabledNetworks = createDeepEqualSelector( ), ); +/* + * USE THIS WITH CAUTION + * + * Only use this selector if you are absolutely sure that your UI component needs + * data from _all chains_ to compute a value. Else, use `getChainIdsToPoll`. + * + * Examples: + * - Components that should NOT use this selector: + * - Token list: This only needs to poll for chains based on the network filter + * (potentially only one chain). In this case, use `getChainIdsToPoll`. + * - Components that SHOULD use this selector: + * - Aggregated balance: This needs to display data regardless of network filter + * selection (always showing aggregated balances across all chains). + * + * Key Considerations: + * - This selector can cause expensive computations. It should only be used when + * necessary, and where possible, optimized to use `getChainIdsToPoll` instead. + * - Logic Overview: + * - If `PORTFOLIO_VIEW` is not enabled, the selector returns only the `currentChainId`. + * - Otherwise, it includes all chains from `networkConfigurations`, excluding + * `TEST_CHAINS`, while ensuring the `currentChainId` is included. + */ +export const getAllChainsToPoll = createDeepEqualSelector( + getNetworkConfigurationsByChainId, + getCurrentChainId, + (networkConfigurations, currentChainId) => { + if (!process.env.PORTFOLIO_VIEW) { + return [currentChainId]; + } + + return Object.keys(networkConfigurations).filter( + (chainId) => chainId === currentChainId || !TEST_CHAINS.includes(chainId), + ); + }, +); + export const getChainIdsToPoll = createDeepEqualSelector( getNetworkConfigurationsByChainId, getCurrentChainId, From 193da7660527930131cd012294455606f31e958e Mon Sep 17 00:00:00 2001 From: David Walsh Date: Tue, 26 Nov 2024 14:35:05 -0600 Subject: [PATCH 091/148] fix: Add metric trait for token network filter preference (#28336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a user trait for network filter preference for PortfolioView™ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28336?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Open debugger 2. Put a breakpoint in app/scripts/controllers/metametrics.ts's _buildUserTraitsObject function 3. See value being passed ## **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: Nick Gambino <35090461+gambinish@users.noreply.github.com> Co-authored-by: Nicholas Gambino --- .../controllers/metametrics-controller.test.ts | 11 ++++++----- app/scripts/controllers/metametrics-controller.ts | 4 ++++ shared/constants/metametrics.ts | 4 ++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index 58fad403fdab..99ed4d2e2e48 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -1453,7 +1453,7 @@ describe('MetaMetricsController', function () { participateInMetaMetrics: true, currentCurrency: 'usd', dataCollectionForMarketing: false, - preferences: { privacyMode: true }, + preferences: { privacyMode: true, tokenNetworkFilter: [] }, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) custodyAccountDetails: {}, ///: END:ONLY_INCLUDE_IF @@ -1494,6 +1494,7 @@ describe('MetaMetricsController', function () { ///: END:ONLY_INCLUDE_IF [MetaMetricsUserTrait.TokenSortPreference]: 'token-sort-key', [MetaMetricsUserTrait.PrivacyModeEnabled]: true, + [MetaMetricsUserTrait.NetworkFilterPreference]: [], }); }); }); @@ -1543,7 +1544,7 @@ describe('MetaMetricsController', function () { allNfts: {}, participateInMetaMetrics: true, dataCollectionForMarketing: false, - preferences: { privacyMode: true }, + preferences: { privacyMode: true, tokenNetworkFilter: [] }, securityAlertsEnabled: true, names: { ethereumAddress: {}, @@ -1607,7 +1608,7 @@ describe('MetaMetricsController', function () { allNfts: {}, participateInMetaMetrics: true, dataCollectionForMarketing: false, - preferences: { privacyMode: true }, + preferences: { privacyMode: true, tokenNetworkFilter: [] }, securityAlertsEnabled: true, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) custodyAccountDetails: {}, @@ -1672,7 +1673,7 @@ describe('MetaMetricsController', function () { }, participateInMetaMetrics: true, dataCollectionForMarketing: false, - preferences: { privacyMode: true }, + preferences: { privacyMode: true, tokenNetworkFilter: [] }, securityAlertsEnabled: true, security_providers: ['blockaid'], currentCurrency: 'usd', @@ -1718,7 +1719,7 @@ describe('MetaMetricsController', function () { allNfts: {}, participateInMetaMetrics: true, dataCollectionForMarketing: false, - preferences: { privacyMode: true }, + preferences: { privacyMode: true, tokenNetworkFilter: [] }, names: { ethereumAddress: {}, }, diff --git a/app/scripts/controllers/metametrics-controller.ts b/app/scripts/controllers/metametrics-controller.ts index d29a2840eb27..b2b78a4e6406 100644 --- a/app/scripts/controllers/metametrics-controller.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -166,6 +166,7 @@ export type MetaMaskState = { currentCurrency: string; preferences: { privacyMode: PreferencesControllerState['preferences']['privacyMode']; + tokenNetworkFilter: string[]; }; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) custodyAccountDetails: { @@ -1233,6 +1234,9 @@ export default class MetaMetricsController extends BaseController< metamaskState.tokenSortConfig?.key || '', [MetaMetricsUserTrait.PrivacyModeEnabled]: metamaskState.preferences.privacyMode, + [MetaMetricsUserTrait.NetworkFilterPreference]: Object.keys( + metamaskState.preferences.tokenNetworkFilter || {}, + ), }; if (!previousUserTraits) { diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 760e3c26a31f..c87af10544c3 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -594,6 +594,10 @@ export enum MetaMetricsUserTrait { * Identifies if the Privacy Mode is enabled */ PrivacyModeEnabled = 'privacy_mode_toggle', + /** + * Identified when the user prefers to see all tokens or current network tokens in wallet list + */ + NetworkFilterPreference = 'selected_network_filter', } /** From 4e1d3132511c65fe0bbc7540a68143b6fbdb21d8 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Tue, 26 Nov 2024 20:37:28 +0000 Subject: [PATCH 092/148] feat: add e2e tests for multichain (#28708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds E2E tests for multichain [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28708?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. --- .../page-objects/pages/account-list-page.ts | 20 ++ test/e2e/page-objects/pages/asset-list.ts | 172 +++++++++++++++ test/e2e/page-objects/pages/homepage.ts | 15 +- .../pages/send/send-token-page.ts | 41 ++++ .../pages/settings/settings-page.ts | 29 +++ .../multichain/aggregated-balances.spec.ts | 137 ++++++++++++ test/e2e/tests/multichain/asset-list.spec.ts | 205 ++++++++++++++++++ test/e2e/webdriver/driver.js | 23 ++ 8 files changed, 639 insertions(+), 3 deletions(-) create mode 100644 test/e2e/page-objects/pages/asset-list.ts create mode 100644 test/e2e/tests/multichain/aggregated-balances.spec.ts create mode 100644 test/e2e/tests/multichain/asset-list.spec.ts diff --git a/test/e2e/page-objects/pages/account-list-page.ts b/test/e2e/page-objects/pages/account-list-page.ts index 9f96d70f4972..5660dc616279 100644 --- a/test/e2e/page-objects/pages/account-list-page.ts +++ b/test/e2e/page-objects/pages/account-list-page.ts @@ -11,6 +11,9 @@ class AccountListPage { private readonly accountListBalance = '[data-testid="second-currency-display"]'; + private readonly accountValueAndSuffix = + '[data-testid="account-value-and-suffix"]'; + private readonly accountListItem = '.multichain-account-menu-popover__list--menu-item'; @@ -345,6 +348,23 @@ class AccountListPage { ); } + /** + * Checks that the account value and suffix is displayed in the account list. + * + * @param expectedValueAndSuffix - The expected value and suffix to check. + */ + async check_accountValueAndSuffixDisplayed( + expectedValueAndSuffix: string, + ): Promise { + console.log( + `Check that account value and suffix ${expectedValueAndSuffix} is displayed in account list`, + ); + await this.driver.waitForSelector({ + css: this.accountValueAndSuffix, + text: expectedValueAndSuffix, + }); + } + async openAccountOptionsMenu(): Promise { console.log(`Open account option menu`); await this.driver.waitForSelector(this.accountListItem); diff --git a/test/e2e/page-objects/pages/asset-list.ts b/test/e2e/page-objects/pages/asset-list.ts new file mode 100644 index 000000000000..6d6d8af391bd --- /dev/null +++ b/test/e2e/page-objects/pages/asset-list.ts @@ -0,0 +1,172 @@ +import { Driver } from '../../webdriver/driver'; + +class AssetListPage { + private readonly driver: Driver; + + private readonly allNetworksOption = + '[data-testid="network-filter-all__button"]'; + + private readonly currentNetworkOption = + '[data-testid="network-filter-current__button"]'; + + private readonly networksToggle = '[data-testid="sort-by-networks"]'; + + private readonly allNetworksTotal = + '[data-testid="network-filter-all__total"]'; + + private readonly currentNetworksTotal = `${this.currentNetworkOption} [data-testid="account-value-and-suffix"]`; + + constructor(driver: Driver) { + this.driver = driver; + } + + async checkNetworkFilterText(expectedText: string): Promise { + console.log( + `Verify the displayed account label in header is: ${expectedText}`, + ); + await this.driver.waitForSelector({ + css: this.networksToggle, + text: expectedText, + }); + } + + async openNetworksFilter(): Promise { + console.log(`Opening the network filter`); + await this.driver.clickElement(this.networksToggle); + await this.driver.waitUntil( + async () => { + return await this.driver.findElement(this.allNetworksOption); + }, + { + timeout: 5000, + interval: 100, + }, + ); + } + + async getNetworksFilterLabel(): Promise { + console.log(`Retrieving the network filter label`); + const toggle = await this.driver.findElement(this.networksToggle); + const text = await toggle.getText(); + return text; + } + + async clickCurrentNetworkOption(): Promise { + console.log(`Clicking on the current network option`); + await this.driver.clickElement(this.currentNetworkOption); + + await this.driver.waitUntil( + async () => { + const label = await this.getNetworksFilterLabel(); + return label !== 'All networks'; + }, + { timeout: 5000, interval: 100 }, + ); + } + + async waitUntilAssetListHasItems(expectedItemsCount: number): Promise { + console.log(`Waiting until the asset list has ${expectedItemsCount} items`); + await this.driver.waitUntil( + async () => { + const items = await this.getNumberOfAssets(); + return items === expectedItemsCount; + }, + { timeout: 5000, interval: 100 }, + ); + } + + async waitUntilFilterLabelIs(label: string): Promise { + console.log(`Waiting until the filter label is ${label}`); + await this.driver.waitUntil( + async () => { + const currentLabel = await this.getNetworksFilterLabel(); + return currentLabel === label; + }, + { timeout: 5000, interval: 100 }, + ); + } + + async getAllNetworksOptionTotal(): Promise { + console.log(`Retrieving the "All networks" option fiat value`); + const allNetworksValueElement = await this.driver.findElement( + this.allNetworksTotal, + ); + const value = await allNetworksValueElement.getText(); + return value; + } + + async clickOnAsset(assetName: string): Promise { + const buttons = await this.driver.findElements( + '[data-testid="multichain-token-list-button"]', + ); + + for (const button of buttons) { + const text = await button.getText(); + if (text.includes(assetName)) { + await button.click(); + return; + } + } + + throw new Error(`${assetName} button not found`); + } + + async getCurrentNetworksOptionTotal(): Promise { + console.log(`Retrieving the "Current network" option fiat value`); + const allNetworksValueElement = await this.driver.findElement( + this.currentNetworksTotal, + ); + const value = await allNetworksValueElement.getText(); + return value; + } + + async selectNetworkFilterAllNetworks(): Promise { + console.log(`Selecting "All networks" from the network filter`); + await this.driver.clickElement(this.allNetworksOption); + + await this.driver.waitUntil( + async () => { + const label = await this.getNetworksFilterLabel(); + return label === 'All networks'; + }, + { timeout: 5000, interval: 100 }, + ); + } + + async selectNetworkFilterCurrentNetwork(): Promise { + console.log(`Selecting "Current network" from the network filter`); + await this.driver.clickElement(this.currentNetworkOption); + + await this.driver.waitUntil( + async () => { + const label = await this.getNetworksFilterLabel(); + return label !== 'All networks'; + }, + { timeout: 5000, interval: 100 }, + ); + } + + async getNumberOfAssets(): Promise { + console.log(`Returning the total number of asset items in the token list`); + const assets = await this.driver.findElements( + '.multichain-token-list-item', + ); + return assets.length; + } + + // Added method to check if an asset is visible + async isAssetVisible(assetName: string): Promise { + const assets = await this.driver.findElements( + '[data-testid="multichain-token-list-button"]', + ); + for (const asset of assets) { + const text = await asset.getText(); + if (text.includes(assetName)) { + return true; + } + } + return false; + } +} + +export default AssetListPage; diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index c5c4d5369d4e..889ab344d91a 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -61,6 +61,8 @@ class HomePage { private readonly nftTab = '[data-testid="account-overview__nfts-tab"]'; + private readonly popoverCloseButton = '[data-testid="popover-close"]'; + private readonly successImportNftMessage = { text: 'NFT was successfully added!', tag: 'h6', @@ -85,6 +87,11 @@ class HomePage { console.log('Home page is loaded'); } + async closePopover(): Promise { + console.log('Closing popover'); + await this.driver.clickElement(this.popoverCloseButton); + } + 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 @@ -240,24 +247,26 @@ class HomePage { * Checks if the expected balance is displayed on homepage. * * @param expectedBalance - The expected balance to be displayed. Defaults to '0'. + * @param symbol - The symbol of the currency or token. Defaults to 'ETH'. */ async check_expectedBalanceIsDisplayed( expectedBalance: string = '0', + symbol: string = 'ETH', ): Promise { try { await this.driver.waitForSelector({ css: this.balance, - text: `${expectedBalance} ETH`, + text: expectedBalance, }); } catch (e) { const balance = await this.driver.waitForSelector(this.balance); const currentBalance = parseFloat(await balance.getText()); - const errorMessage = `Expected balance ${expectedBalance} ETH, got balance ${currentBalance} ETH`; + const errorMessage = `Expected balance ${expectedBalance} ${symbol}, got balance ${currentBalance} ${symbol}`; console.log(errorMessage, e); throw e; } console.log( - `Expected balance ${expectedBalance} ETH is displayed on homepage`, + `Expected balance ${expectedBalance} ${symbol} is displayed on homepage`, ); } diff --git a/test/e2e/page-objects/pages/send/send-token-page.ts b/test/e2e/page-objects/pages/send/send-token-page.ts index 29222ed3d48c..3c1d96618556 100644 --- a/test/e2e/page-objects/pages/send/send-token-page.ts +++ b/test/e2e/page-objects/pages/send/send-token-page.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'assert'; +import { WebElement } from 'selenium-webdriver'; import { Driver } from '../../../webdriver/driver'; class SendTokenPage { @@ -11,11 +12,18 @@ class SendTokenPage { tag: 'button', }; + private readonly cancelButton = { + text: 'Cancel', + tag: 'button', + }; + private readonly ensAddressAsRecipient = '[data-testid="ens-input-selected"]'; private readonly ensResolvedName = '[data-testid="multichain-send-page__recipient__item__title"]'; + private readonly assetValue = '[data-testid="account-value-and-suffix"]'; + private readonly inputAmount = '[data-testid="currency-input"]'; private readonly inputNFTAmount = '[data-testid="nft-input"]'; @@ -30,10 +38,17 @@ class SendTokenPage { private readonly tokenListButton = '[data-testid="multichain-token-list-button"]'; + private readonly toastText = '.toast-text'; + constructor(driver: Driver) { this.driver = driver; } + async getAssetPickerItems(): Promise { + console.log('Retrieving asset picker items'); + return this.driver.findElements(this.tokenListButton); + } + async check_pageIsLoaded(): Promise { try { await this.driver.waitForMultipleSelectors([ @@ -59,6 +74,22 @@ class SendTokenPage { await elements[1].click(); } + async checkAccountValueAndSuffix(value: string): Promise { + console.log(`Checking if account value and suffix is ${value}`); + const element = await this.driver.waitForSelector(this.assetValue); + const text = await element.getText(); + assert.equal( + text, + value, + `Expected account value and suffix to be ${value}, got ${text}`, + ); + console.log(`Account value and suffix is ${value}`); + } + + async clickCancelButton(): Promise { + await this.driver.clickElement(this.cancelButton); + } + async fillAmount(amount: string): Promise { console.log(`Fill amount input with ${amount} on send token screen`); const inputAmount = await this.driver.waitForSelector(this.inputAmount); @@ -74,6 +105,16 @@ class SendTokenPage { ); } + async check_networkChange(networkName: string): Promise { + const toastTextElement = await this.driver.findElement(this.toastText); + const toastText = await toastTextElement.getText(); + assert.equal( + toastText, + `You're now using ${networkName}`, + 'Toast text is correct', + ); + } + async fillNFTAmount(amount: string) { await this.driver.pasteIntoField(this.inputNFTAmount, amount); } diff --git a/test/e2e/page-objects/pages/settings/settings-page.ts b/test/e2e/page-objects/pages/settings/settings-page.ts index d103aca83f97..4444556a1b74 100644 --- a/test/e2e/page-objects/pages/settings/settings-page.ts +++ b/test/e2e/page-objects/pages/settings/settings-page.ts @@ -40,6 +40,35 @@ class SettingsPage { console.log('Settings page is loaded'); } + async clickAdvancedTab(): Promise { + console.log('Clicking on Advanced tab'); + await this.driver.clickElement({ + css: '.tab-bar__tab__content__title', + text: 'Advanced', + }); + } + + async toggleShowFiatOnTestnets(): Promise { + console.log('Toggling Show Fiat on Testnets setting'); + await this.driver.clickElement( + '.toggle-button.show-fiat-on-testnets-toggle', + ); + } + + async toggleBalanceSetting(): Promise { + console.log('Toggling balance setting'); + await this.driver.clickElement( + '.toggle-button.show-native-token-as-main-balance', + ); + } + + async exitSettings(): Promise { + console.log('Exiting settings page'); + await this.driver.clickElement( + '.settings-page__header__title-container__close-button', + ); + } + async closeSettingsPage(): Promise { console.log('Closing Settings page'); await this.driver.clickElement(this.closeSettingsPageButton); diff --git a/test/e2e/tests/multichain/aggregated-balances.spec.ts b/test/e2e/tests/multichain/aggregated-balances.spec.ts new file mode 100644 index 000000000000..57df2362b236 --- /dev/null +++ b/test/e2e/tests/multichain/aggregated-balances.spec.ts @@ -0,0 +1,137 @@ +import { strict as assert } from 'assert'; +import { Suite } from 'mocha'; +import { Driver } from '../../webdriver/driver'; +import { withFixtures, defaultGanacheOptions } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { Ganache } from '../../seeder/ganache'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; +import { SMART_CONTRACTS } from '../../seeder/smart-contracts'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import SelectNetwork from '../../page-objects/pages/dialog/select-network'; +import HomePage from '../../page-objects/pages/homepage'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; +import AccountListPage from '../../page-objects/pages/account-list-page'; +import AssetListPage from '../../page-objects/pages/asset-list'; +import SendTokenPage from '../../page-objects/pages/send/send-token-page'; + +const EXPECTED_MAINNET_BALANCE_USD = '$84,985.04'; +const EXPECTED_CURRENT_NETWORK_BALANCE_USD = '$42,492.52'; +const EXPECTED_SEPOLIA_BALANCE_NATIVE = '24.9956'; +const NETWORK_NAME_MAINNET = 'Ethereum Mainnet'; +const NETWORK_NAME_SEPOLIA = 'Sepolia'; +const SEPOLIA_NATIVE_TOKEN = 'SepoliaETH'; + +describe('Multichain Aggregated Balances', function (this: Suite) { + if (!process.env.PORTFOLIO_VIEW) { + return; + } + + it('shows correct aggregated balance when "Current Network" is selected', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withTokensControllerERC20() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract: SMART_CONTRACTS.HST, + title: this.test?.fullTitle(), + }, + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + // Step 1: Log in and set up page objects + await loginWithBalanceValidation(driver, ganacheServer); + + const homepage = new HomePage(driver); + const headerNavbar = new HeaderNavbar(driver); + const selectNetworkDialog = new SelectNetwork(driver); + const settingsPage = new SettingsPage(driver); + const accountListPage = new AccountListPage(driver); + const assetListPage = new AssetListPage(driver); + const sendTokenPage = new SendTokenPage(driver); + + // Step 2: Switch to Ethereum Mainnet + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); + + // Step 3: Enable fiat balance display in settings + await headerNavbar.openSettingsPage(); + await settingsPage.toggleBalanceSetting(); + await settingsPage.exitSettings(); + + // Step 4: Verify main balance on homepage and account menu + await homepage.check_expectedBalanceIsDisplayed( + EXPECTED_MAINNET_BALANCE_USD, + 'usd', + ); + await headerNavbar.openAccountMenu(); + await accountListPage.check_accountValueAndSuffixDisplayed( + EXPECTED_MAINNET_BALANCE_USD, + ); + await accountListPage.closeAccountModal(); + + // Step 5: Verify balance in send flow + await homepage.closePopover(); + await homepage.startSendFlow(); + await sendTokenPage.checkAccountValueAndSuffix( + EXPECTED_MAINNET_BALANCE_USD, + ); + await sendTokenPage.clickCancelButton(); + + // Step 6: Check balance for "Current Network" in network filter + await assetListPage.openNetworksFilter(); + const networkFilterTotal = + await assetListPage.getCurrentNetworksOptionTotal(); + assert.equal(networkFilterTotal, EXPECTED_CURRENT_NETWORK_BALANCE_USD); + await assetListPage.clickCurrentNetworkOption(); + + // Step 7: Verify balance after selecting "Current Network" + await homepage.check_expectedBalanceIsDisplayed( + EXPECTED_CURRENT_NETWORK_BALANCE_USD, + 'usd', + ); + await headerNavbar.openAccountMenu(); + await accountListPage.check_accountValueAndSuffixDisplayed( + EXPECTED_CURRENT_NETWORK_BALANCE_USD, + ); + await accountListPage.closeAccountModal(); + + // Step 8: Verify balance in send flow after selecting "Current Network" + await homepage.startSendFlow(); + await sendTokenPage.checkAccountValueAndSuffix( + EXPECTED_CURRENT_NETWORK_BALANCE_USD, + ); + await sendTokenPage.clickCancelButton(); + + // Step 9: Switch to Sepolia test network + await headerNavbar.clickSwitchNetworkDropDown(); + await driver.clickElement('.toggle-button'); + await driver.clickElement({ text: NETWORK_NAME_SEPOLIA, tag: 'p' }); + + // Step 10: Verify native balance on Sepolia network + await homepage.check_expectedBalanceIsDisplayed( + EXPECTED_SEPOLIA_BALANCE_NATIVE, + SEPOLIA_NATIVE_TOKEN, + ); + await assetListPage.checkNetworkFilterText(NETWORK_NAME_SEPOLIA); + + // Step 11: Enable fiat display on testnets in settings + await headerNavbar.openSettingsPage(); + await settingsPage.clickAdvancedTab(); + await settingsPage.toggleShowFiatOnTestnets(); + await settingsPage.closeSettingsPage(); + + // Step 12: Verify USD balance on Sepolia network + await homepage.check_expectedBalanceIsDisplayed( + EXPECTED_CURRENT_NETWORK_BALANCE_USD, + 'usd', + ); + }, + ); + }); +}); diff --git a/test/e2e/tests/multichain/asset-list.spec.ts b/test/e2e/tests/multichain/asset-list.spec.ts new file mode 100644 index 000000000000..5b210730ef36 --- /dev/null +++ b/test/e2e/tests/multichain/asset-list.spec.ts @@ -0,0 +1,205 @@ +import { strict as assert } from 'assert'; +import { Suite } from 'mocha'; +import { Driver } from '../../webdriver/driver'; +import { withFixtures, defaultGanacheOptions } from '../../helpers'; +import { Ganache } from '../../seeder/ganache'; +import FixtureBuilder from '../../fixture-builder'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import SelectNetwork from '../../page-objects/pages/dialog/select-network'; +import { SMART_CONTRACTS } from '../../seeder/smart-contracts'; +import SendTokenPage from '../../page-objects/pages/send/send-token-page'; +import AssetListPage from '../../page-objects/pages/asset-list'; + +const NETWORK_NAME_MAINNET = 'Ethereum Mainnet'; +const LINEA_NAME_MAINNET = 'Linea Mainnet'; +const LOCALHOST = 'Localhost 8545'; +const BALANCE_AMOUNT = '24.9956'; + +function buildFixtures(title: string) { + return { + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withTokensControllerERC20() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract: SMART_CONTRACTS.HST, + title, + }; +} + +describe('Multichain Asset List', function (this: Suite) { + if (!process.env.PORTFOLIO_VIEW) { + return; + } + + it('persists the preferred asset list preference when changing networks', async function () { + await withFixtures( + buildFixtures(this.test?.fullTitle() as string), + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + const selectNetworkDialog = new SelectNetwork(driver); + const assetListPage = new AssetListPage(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); + await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.openNetworksFilter(); + await assetListPage.clickCurrentNetworkOption(); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(LINEA_NAME_MAINNET); + await assetListPage.waitUntilFilterLabelIs(LINEA_NAME_MAINNET); + await assetListPage.waitUntilAssetListHasItems(1); + assert.equal( + await assetListPage.getNetworksFilterLabel(), + LINEA_NAME_MAINNET, + ); + }, + ); + }); + it('allows clicking into the asset details page of native token on another network', async function () { + await withFixtures( + buildFixtures(this.test?.fullTitle() as string), + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + const selectNetworkDialog = new SelectNetwork(driver); + const assetListPage = new AssetListPage(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); + await assetListPage.waitUntilAssetListHasItems(2); + await driver.clickElement('.multichain-token-list-item'); + const coinOverviewElement = await driver.findElement( + '[data-testid="coin-overview-buy"]', + ); + const multichainTokenListButton = await driver.findElement( + '[data-testid="multichain-token-list-button"]', + ); + assert.ok(coinOverviewElement, 'coin-overview-buy is present'); + assert.ok( + multichainTokenListButton, + 'multichain-token-list-button is present', + ); + }, + ); + }); + it('switches networks when clicking on send for a token on another network', async function () { + await withFixtures( + buildFixtures(this.test?.fullTitle() as string), + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + const selectNetworkDialog = new SelectNetwork(driver); + const assetListPage = new AssetListPage(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); + const sendPage = new SendTokenPage(driver); + await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.clickOnAsset('TST'); + await driver.clickElement('[data-testid="eth-overview-send"]'); + await sendPage.check_networkChange(LOCALHOST); + await sendPage.check_pageIsLoaded(); + await sendPage.fillRecipient( + '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + ); + await sendPage.clickAssetPickerButton(); + const assetPickerItems = await sendPage.getAssetPickerItems(); + assert.equal( + assetPickerItems.length, + 2, + 'Two assets should be shown in the asset picker', + ); + }, + ); + }); + it('switches networks when clicking on swap for a token on another network', async function () { + await withFixtures( + buildFixtures(this.test?.fullTitle() as string), + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + const selectNetworkDialog = new SelectNetwork(driver); + const assetListPage = new AssetListPage(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); + await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.clickOnAsset('TST'); + await driver.clickElement('.mm-box > button:nth-of-type(3)'); + const toastTextElement = await driver.findElement('.toast-text'); + const toastText = await toastTextElement.getText(); + assert.equal( + toastText, + `You're now using ${LOCALHOST}`, + 'Toast text is correct', + ); + }, + ); + }); + it('shows correct asset and balance when swapping on a different chain', async function () { + await withFixtures( + buildFixtures(this.test?.fullTitle() as string), + async ({ + driver, + ganacheServer, + }: { + driver: Driver; + ganacheServer?: Ganache; + }) => { + await loginWithBalanceValidation(driver, ganacheServer); + const headerNavbar = new HeaderNavbar(driver); + const assetListPage = new AssetListPage(driver); + const selectNetworkDialog = new SelectNetwork(driver); + await headerNavbar.clickSwitchNetworkDropDown(); + await selectNetworkDialog.selectNetworkName(LINEA_NAME_MAINNET); + await assetListPage.waitUntilAssetListHasItems(2); + + await assetListPage.clickOnAsset('Ethereum'); + + const swapButton = await driver.findElement( + '[data-testid="token-overview-button-swap"]', + ); + await swapButton.click(); + const toastTextElement = await driver.findElement('.toast-text'); + const toastText = await toastTextElement.getText(); + assert.equal( + toastText, + `You're now using ${LOCALHOST}`, + 'Toast text is correct', + ); + const balanceMessageElement = await driver.findElement( + '.prepare-swap-page__balance-message', + ); + const balanceMessage = await balanceMessageElement.getText(); + assert.equal( + balanceMessage.replace('Max', '').trim(), + `Balance: ${BALANCE_AMOUNT}`, + 'Balance message is correct', + ); + }, + ); + }); +}); diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index 42fa0f018f6d..b0a335eb0d64 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -767,6 +767,29 @@ class Driver { ); } + /** + * Waits for a condition to be met within a given timeout period. + * + * @param {Function} condition - The condition to wait for. This function should return a boolean indicating whether the condition is met. + * @param {object} options - Options for the wait. + * @param {number} options.timeout - The maximum amount of time (in milliseconds) to wait for the condition to be met. + * @param {number} options.interval - The interval (in milliseconds) between checks for the condition. + * @returns {Promise} A promise that resolves when the condition is met or the timeout is reached. + * @throws {Error} Throws an error if the condition is not met within the timeout period. + */ + async waitUntil(condition, options) { + const { timeout, interval } = options; + const endTime = Date.now() + timeout; + + while (Date.now() < endTime) { + if (await condition()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error('Condition not met within timeout'); + } + /** * Checks if an element that matches the given locator is present on the page. * From 86d6c05c58af26e62826f625bd3c21ee89b5edce Mon Sep 17 00:00:00 2001 From: jiexi Date: Tue, 26 Nov 2024 12:46:15 -0800 Subject: [PATCH 093/148] feat: Bump `@metamask/permission-controller` to `^11.0.0` (#28743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bumps `@metamask/permission-controller` to `^11.0.0` and adopt breaking changes [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28743?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** No changes in behavior. ## **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 --- .../permissions/caveat-mutators.js | 12 +- .../permissions/caveat-mutators.test.js | 14 +- lavamoat/browserify/beta/policy.json | 135 +----------------- lavamoat/browserify/flask/policy.json | 135 +----------------- lavamoat/browserify/main/policy.json | 135 +----------------- lavamoat/browserify/mmi/policy.json | 135 +----------------- package.json | 2 +- yarn.lock | 21 +-- 8 files changed, 43 insertions(+), 546 deletions(-) diff --git a/app/scripts/controllers/permissions/caveat-mutators.js b/app/scripts/controllers/permissions/caveat-mutators.js index 551d1f7b37b2..047341e34770 100644 --- a/app/scripts/controllers/permissions/caveat-mutators.js +++ b/app/scripts/controllers/permissions/caveat-mutators.js @@ -33,14 +33,14 @@ function removeAccount(targetAccount, existingAccounts) { ); if (newAccounts.length === existingAccounts.length) { - return { operation: CaveatMutatorOperation.noop }; + return { operation: CaveatMutatorOperation.Noop }; } else if (newAccounts.length > 0) { return { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: newAccounts, }; } - return { operation: CaveatMutatorOperation.revokePermission }; + return { operation: CaveatMutatorOperation.RevokePermission }; } /** @@ -60,12 +60,12 @@ function removeChainId(targetChainId, existingChainIds) { ); if (newChainIds.length === existingChainIds.length) { - return { operation: CaveatMutatorOperation.noop }; + return { operation: CaveatMutatorOperation.Noop }; } else if (newChainIds.length > 0) { return { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: newChainIds, }; } - return { operation: CaveatMutatorOperation.revokePermission }; + return { operation: CaveatMutatorOperation.RevokePermission }; } diff --git a/app/scripts/controllers/permissions/caveat-mutators.test.js b/app/scripts/controllers/permissions/caveat-mutators.test.js index a87115dc744b..8c16924514f4 100644 --- a/app/scripts/controllers/permissions/caveat-mutators.test.js +++ b/app/scripts/controllers/permissions/caveat-mutators.test.js @@ -14,20 +14,20 @@ describe('caveat mutators', () => { describe('removeAccount', () => { it('returns the no-op operation if the target account is not permitted', () => { expect(removeAccount(address2, [address1])).toStrictEqual({ - operation: CaveatMutatorOperation.noop, + operation: CaveatMutatorOperation.Noop, }); }); it('returns the update operation and a new value if the target account is permitted', () => { expect(removeAccount(address2, [address1, address2])).toStrictEqual({ - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: [address1], }); }); it('returns the revoke permission operation the target account is the only permitted account', () => { expect(removeAccount(address1, [address1])).toStrictEqual({ - operation: CaveatMutatorOperation.revokePermission, + operation: CaveatMutatorOperation.RevokePermission, }); }); @@ -36,20 +36,20 @@ describe('caveat mutators', () => { const checksummedAddress3 = '0x95222290dd7278AA3DDd389cc1E1d165Cc4BaeE5'; expect(removeAccount(checksummedAddress3, [address3])).toStrictEqual({ - operation: CaveatMutatorOperation.revokePermission, + operation: CaveatMutatorOperation.RevokePermission, }); }); describe('Multichain behaviour', () => { it('returns the no-op operation if the target account is not permitted', () => { expect(removeAccount(address2, [nonEvmAddress])).toStrictEqual({ - operation: CaveatMutatorOperation.noop, + operation: CaveatMutatorOperation.Noop, }); }); it('can revoke permission for non-EVM addresses', () => { expect(removeAccount(nonEvmAddress, [nonEvmAddress])).toStrictEqual({ - operation: CaveatMutatorOperation.revokePermission, + operation: CaveatMutatorOperation.RevokePermission, }); }); @@ -57,7 +57,7 @@ describe('caveat mutators', () => { expect( removeAccount(nonEvmAddress, [address1, nonEvmAddress]), ).toStrictEqual({ - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: [address1], }); }); diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 30456a0bd61d..93308bf0af4a 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2173,82 +2173,16 @@ "console.error": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/permission-controller>@metamask/base-controller": true, - "@metamask/permission-controller>@metamask/json-rpc-engine": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/permission-controller>@metamask/utils": true, + "@metamask/json-rpc-engine": true, "@metamask/permission-controller>nanoid": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, "deep-freeze-strict": true, "immer": true } }, - "@metamask/permission-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/permission-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/permission-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/permission-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/permission-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/permission-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/permission-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/permission-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/permission-controller>nanoid": { "globals": { "crypto.getRandomValues": true @@ -2822,9 +2756,9 @@ "@metamask/json-rpc-engine": true, "@metamask/json-rpc-middleware-stream": true, "@metamask/object-multiplex": true, + "@metamask/permission-controller": true, "@metamask/post-message-stream": true, "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>@metamask/permission-controller": true, "@metamask/snaps-controllers>@xstate/fsm": true, "@metamask/snaps-controllers>concat-stream": true, "@metamask/snaps-controllers>get-npm-tarball-url": true, @@ -2848,21 +2782,6 @@ "crypto.getRandomValues": true } }, - "@metamask/snaps-controllers>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, "@metamask/snaps-controllers>concat-stream": { "packages": { "browserify>buffer": true, @@ -2920,8 +2839,8 @@ }, "@metamask/snaps-rpc-methods": { "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, "@metamask/snaps-utils": true, @@ -2930,26 +2849,6 @@ "@noble/hashes": true } }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-sdk": { "globals": { "fetch": true @@ -3000,10 +2899,10 @@ "fetch": true }, "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, - "@metamask/snaps-utils>@metamask/permission-controller": true, "@metamask/snaps-utils>@metamask/slip44": true, "@metamask/snaps-utils>cron-parser": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, @@ -3019,26 +2918,6 @@ "semver": true } }, - "@metamask/snaps-utils>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-utils>@metamask/snaps-registry": { "packages": { "@metamask/message-signing-snap>@noble/curves": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 30456a0bd61d..93308bf0af4a 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2173,82 +2173,16 @@ "console.error": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/permission-controller>@metamask/base-controller": true, - "@metamask/permission-controller>@metamask/json-rpc-engine": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/permission-controller>@metamask/utils": true, + "@metamask/json-rpc-engine": true, "@metamask/permission-controller>nanoid": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, "deep-freeze-strict": true, "immer": true } }, - "@metamask/permission-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/permission-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/permission-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/permission-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/permission-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/permission-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/permission-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/permission-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/permission-controller>nanoid": { "globals": { "crypto.getRandomValues": true @@ -2822,9 +2756,9 @@ "@metamask/json-rpc-engine": true, "@metamask/json-rpc-middleware-stream": true, "@metamask/object-multiplex": true, + "@metamask/permission-controller": true, "@metamask/post-message-stream": true, "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>@metamask/permission-controller": true, "@metamask/snaps-controllers>@xstate/fsm": true, "@metamask/snaps-controllers>concat-stream": true, "@metamask/snaps-controllers>get-npm-tarball-url": true, @@ -2848,21 +2782,6 @@ "crypto.getRandomValues": true } }, - "@metamask/snaps-controllers>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, "@metamask/snaps-controllers>concat-stream": { "packages": { "browserify>buffer": true, @@ -2920,8 +2839,8 @@ }, "@metamask/snaps-rpc-methods": { "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, "@metamask/snaps-utils": true, @@ -2930,26 +2849,6 @@ "@noble/hashes": true } }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-sdk": { "globals": { "fetch": true @@ -3000,10 +2899,10 @@ "fetch": true }, "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, - "@metamask/snaps-utils>@metamask/permission-controller": true, "@metamask/snaps-utils>@metamask/slip44": true, "@metamask/snaps-utils>cron-parser": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, @@ -3019,26 +2918,6 @@ "semver": true } }, - "@metamask/snaps-utils>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-utils>@metamask/snaps-registry": { "packages": { "@metamask/message-signing-snap>@noble/curves": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 30456a0bd61d..93308bf0af4a 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2173,82 +2173,16 @@ "console.error": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/permission-controller>@metamask/base-controller": true, - "@metamask/permission-controller>@metamask/json-rpc-engine": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/permission-controller>@metamask/utils": true, + "@metamask/json-rpc-engine": true, "@metamask/permission-controller>nanoid": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, "deep-freeze-strict": true, "immer": true } }, - "@metamask/permission-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/permission-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/permission-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/permission-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/permission-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/permission-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/permission-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/permission-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/permission-controller>nanoid": { "globals": { "crypto.getRandomValues": true @@ -2822,9 +2756,9 @@ "@metamask/json-rpc-engine": true, "@metamask/json-rpc-middleware-stream": true, "@metamask/object-multiplex": true, + "@metamask/permission-controller": true, "@metamask/post-message-stream": true, "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>@metamask/permission-controller": true, "@metamask/snaps-controllers>@xstate/fsm": true, "@metamask/snaps-controllers>concat-stream": true, "@metamask/snaps-controllers>get-npm-tarball-url": true, @@ -2848,21 +2782,6 @@ "crypto.getRandomValues": true } }, - "@metamask/snaps-controllers>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, "@metamask/snaps-controllers>concat-stream": { "packages": { "browserify>buffer": true, @@ -2920,8 +2839,8 @@ }, "@metamask/snaps-rpc-methods": { "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, "@metamask/snaps-utils": true, @@ -2930,26 +2849,6 @@ "@noble/hashes": true } }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-sdk": { "globals": { "fetch": true @@ -3000,10 +2899,10 @@ "fetch": true }, "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, - "@metamask/snaps-utils>@metamask/permission-controller": true, "@metamask/snaps-utils>@metamask/slip44": true, "@metamask/snaps-utils>cron-parser": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, @@ -3019,26 +2918,6 @@ "semver": true } }, - "@metamask/snaps-utils>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-utils>@metamask/snaps-registry": { "packages": { "@metamask/message-signing-snap>@noble/curves": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index a604526f155d..b135a959d359 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2265,82 +2265,16 @@ "console.error": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/permission-controller>@metamask/base-controller": true, - "@metamask/permission-controller>@metamask/json-rpc-engine": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/permission-controller>@metamask/utils": true, + "@metamask/json-rpc-engine": true, "@metamask/permission-controller>nanoid": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, "deep-freeze-strict": true, "immer": true } }, - "@metamask/permission-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/permission-controller>@metamask/json-rpc-engine": { - "packages": { - "@metamask/permission-controller>@metamask/json-rpc-engine>@metamask/utils": true, - "@metamask/permission-controller>@metamask/rpc-errors": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/permission-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/permission-controller>@metamask/rpc-errors": { - "packages": { - "@metamask/permission-controller>@metamask/rpc-errors>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/permission-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/permission-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/permission-controller>nanoid": { "globals": { "crypto.getRandomValues": true @@ -2914,9 +2848,9 @@ "@metamask/json-rpc-engine": true, "@metamask/json-rpc-middleware-stream": true, "@metamask/object-multiplex": true, + "@metamask/permission-controller": true, "@metamask/post-message-stream": true, "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>@metamask/permission-controller": true, "@metamask/snaps-controllers>@xstate/fsm": true, "@metamask/snaps-controllers>concat-stream": true, "@metamask/snaps-controllers>get-npm-tarball-url": true, @@ -2940,21 +2874,6 @@ "crypto.getRandomValues": true } }, - "@metamask/snaps-controllers>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-controllers>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, "@metamask/snaps-controllers>concat-stream": { "packages": { "browserify>buffer": true, @@ -3012,8 +2931,8 @@ }, "@metamask/snaps-rpc-methods": { "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, "@metamask/snaps-utils": true, @@ -3022,26 +2941,6 @@ "@noble/hashes": true } }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-rpc-methods>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-sdk": { "globals": { "fetch": true @@ -3092,10 +2991,10 @@ "fetch": true }, "packages": { + "@metamask/permission-controller": true, "@metamask/rpc-errors": true, "@metamask/snaps-sdk": true, "@metamask/snaps-sdk>@metamask/key-tree": true, - "@metamask/snaps-utils>@metamask/permission-controller": true, "@metamask/snaps-utils>@metamask/slip44": true, "@metamask/snaps-utils>cron-parser": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, @@ -3111,26 +3010,6 @@ "semver": true } }, - "@metamask/snaps-utils>@metamask/permission-controller": { - "globals": { - "console.error": true - }, - "packages": { - "@metamask/base-controller": true, - "@metamask/controller-utils": true, - "@metamask/json-rpc-engine": true, - "@metamask/rpc-errors": true, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": true, - "@metamask/utils": true, - "deep-freeze-strict": true, - "immer": true - } - }, - "@metamask/snaps-utils>@metamask/permission-controller>nanoid": { - "globals": { - "crypto.getRandomValues": true - } - }, "@metamask/snaps-utils>@metamask/snaps-registry": { "packages": { "@metamask/message-signing-snap>@noble/curves": true, diff --git a/package.json b/package.json index b7adb1fcdcee..df700ec1b415 100644 --- a/package.json +++ b/package.json @@ -327,7 +327,7 @@ "@metamask/notification-services-controller": "^0.14.0", "@metamask/object-multiplex": "^2.0.0", "@metamask/obs-store": "^9.0.0", - "@metamask/permission-controller": "^10.0.0", + "@metamask/permission-controller": "^11.0.0", "@metamask/permission-log-controller": "^2.0.1", "@metamask/phishing-controller": "^12.3.0", "@metamask/polling-controller": "^10.0.1", diff --git a/yarn.lock b/yarn.lock index 968594154f06..1f5433e3d780 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6107,25 +6107,6 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/permission-controller@npm:10.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/utils": "npm:^8.3.0" - "@types/deep-freeze-strict": "npm:^1.1.0" - deep-freeze-strict: "npm:^1.1.1" - immer: "npm:^9.0.6" - nanoid: "npm:^3.1.31" - peerDependencies: - "@metamask/approval-controller": ^7.0.0 - checksum: 10/0c72e205be760fc471b2a6892a9ad52d5c6a40b4cf1757464e992a5ada2dec57efbb24b09351ce8c29990b59f1d731cd2b338caaef37ce7690ea2d1919afe061 - languageName: node - linkType: hard - "@metamask/permission-controller@npm:^11.0.0, @metamask/permission-controller@npm:^11.0.3": version: 11.0.3 resolution: "@metamask/permission-controller@npm:11.0.3" @@ -26864,7 +26845,7 @@ __metadata: "@metamask/notification-services-controller": "npm:^0.14.0" "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/obs-store": "npm:^9.0.0" - "@metamask/permission-controller": "npm:^10.0.0" + "@metamask/permission-controller": "npm:^11.0.0" "@metamask/permission-log-controller": "npm:^2.0.1" "@metamask/phishing-controller": "npm:^12.3.0" "@metamask/phishing-warning": "npm:^4.1.0" From 3f574c49cc976e4520cf5f35d9a3dc6d51ebd3e7 Mon Sep 17 00:00:00 2001 From: micaelae <100321200+micaelae@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:44:31 -0800 Subject: [PATCH 094/148] fix: content dialog styling is being applied to all dialogs (#28739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Problem: A scss change for preventing modal scrolling in the bridge experience was added and got unintentionally applied to all modals. Solution: Nest the styling within the `quotes-modal` className [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28739?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28722 ## **Manual testing steps** 1. Visually inspect Swap token picker 2. Visually inspect tx "Speed up" and "Cancel" layout ## **Screenshots/Recordings** ### **Before** See bug report screenshots ### **After** ![Screenshot 2024-11-26 at 9 48 13 AM](https://github.com/user-attachments/assets/f5bd07d5-f062-4489-8c11-5e64f88eed75) ## **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/bridge/quotes/index.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss index 6407309220c2..0d9eafa9ad69 100644 --- a/ui/pages/bridge/quotes/index.scss +++ b/ui/pages/bridge/quotes/index.scss @@ -63,7 +63,9 @@ } } -.mm-modal-content__dialog { - display: flex; - height: 100%; +.quotes-modal { + .mm-modal-content__dialog { + display: flex; + height: 100%; + } } From 27763f5585c272dc10be6bd0e0b8399456df3d43 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 26 Nov 2024 19:40:29 -0330 Subject: [PATCH 095/148] chore: Update `@metamask/gas-fee-controller` and peer deps (#28745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update the `@metamask/gas-fee-controller` package to v21 to satisfy the peer dependency on `@metamask/network-controller@^21.0.0`, and update the `@metamask/user-operation-controller` to satisfy its peer dependency upon `@metamask/gas-fee-controller`. Note that an older version of `@metamask/gas-fee-controller` (v18) remains in the dependency tree, but only because it's imported by `@metamask/smart-transaction-controller` for type reasons. It has no runtime impact on the application, so the associated peer dependency warnings from this older release can be ignored. This will be eliminated soon, in an upcoming PR. The updated `@metamask/user-operation-controller` still does not have its peer dependencies satisfied, but the problems are pre-existing. The `@metamask/keyring-controller` and `@metamask/transaction-controller` packages are head of where this package expects them to be. This is not made worse by this PR though, and will be addressed in a future PR. Changelogs: - `@metamask/gas-fee-controller@21`: https://github.com/MetaMask/core/blob/main/packages/gas-fee-controller/CHANGELOG.md#2100 - One breaking change with an impact: >The inherited AbstractPollingController method startPollingByNetworkClientId has been renamed to startPolling (https://github.com/MetaMask/core/pull/4752) - `@metamask/user-operation-controller@16`: https://github.com/MetaMask/core/blob/main/packages/user-operation-controller/CHANGELOG.md#1600 - No breaking changes with an impact. It appeared at first that the `startPollingByNetworkClientId` was breaking, but the user operation controller had an override for that method so it was preserved ([see here](https://github.com/MetaMask/core/pull/4752/files#diff-335af05ece636eb593b348e369dff139dfbfea49ad4e9ba3bb47b4909aab9aefR304)) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28745?quickstart=1) ## **Related issues** Related to https://github.com/MetaMask/MetaMask-planning/issues/3568 ## **Manual testing steps** N/A ## **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. --------- Co-authored-by: MetaMask Bot --- app/scripts/metamask-controller.js | 3 +- lavamoat/browserify/beta/policy.json | 69 ++++++------------ lavamoat/browserify/flask/policy.json | 69 ++++++------------ lavamoat/browserify/main/policy.json | 69 ++++++------------ lavamoat/browserify/mmi/policy.json | 69 ++++++------------ package.json | 5 +- ui/pages/bridge/index.test.tsx | 4 +- .../confirm-send-ether.test.js | 4 +- .../confirm-transaction-base.test.js | 4 +- .../confirm-transaction.transaction.test.js | 2 +- ui/pages/swaps/index.test.js | 4 +- ui/store/actions.ts | 7 +- yarn.lock | 71 +++++++++++++------ 13 files changed, 142 insertions(+), 238 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 55888558c6d6..39e34955cac2 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -4146,8 +4146,7 @@ export default class MetamaskController extends EventEmitter { ), // GasFeeController - gasFeeStartPollingByNetworkClientId: - gasFeeController.startPollingByNetworkClientId.bind(gasFeeController), + gasFeeStartPolling: gasFeeController.startPolling.bind(gasFeeController), gasFeeStopPollingByPollingToken: gasFeeController.stopPollingByPollingToken.bind(gasFeeController), diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 93308bf0af4a..cf72074493e6 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1439,14 +1439,6 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -1454,7 +1446,7 @@ "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, "uuid": true } @@ -2637,13 +2629,13 @@ "@ethersproject/providers": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, "@metamask/smart-transactions-controller>@metamask/base-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/rpc-errors": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/utils": true, @@ -2684,6 +2676,20 @@ "webpack>events": true } }, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": { + "globals": { + "clearInterval": true, + "console.error": true, + "setInterval": true + }, + "packages": { + "@metamask/controller-utils": true, + "@metamask/eth-query": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "bn.js": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": { "packages": { "@ethersproject/providers": true, @@ -3023,29 +3029,21 @@ "fetch": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, + "@metamask/rpc-errors": 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, + "@metamask/utils>@metamask/superstruct": true, "bn.js": true, "lodash": true, - "superstruct": true, "uuid": true, "webpack>events": true } }, - "@metamask/user-operation-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/user-operation-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -3053,32 +3051,11 @@ "setTimeout": true }, "packages": { + "@metamask/base-controller": true, "@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, - "@metamask/user-operation-controller>@metamask/rpc-errors>@metamask/utils": true - } - }, - "@metamask/user-operation-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/user-operation-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -5668,12 +5645,6 @@ "string.prototype.matchall>get-intrinsic": true } }, - "superstruct": { - "globals": { - "console.warn": true, - "define": true - } - }, "terser>source-map-support>buffer-from": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 93308bf0af4a..cf72074493e6 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1439,14 +1439,6 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -1454,7 +1446,7 @@ "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, "uuid": true } @@ -2637,13 +2629,13 @@ "@ethersproject/providers": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, "@metamask/smart-transactions-controller>@metamask/base-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/rpc-errors": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/utils": true, @@ -2684,6 +2676,20 @@ "webpack>events": true } }, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": { + "globals": { + "clearInterval": true, + "console.error": true, + "setInterval": true + }, + "packages": { + "@metamask/controller-utils": true, + "@metamask/eth-query": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "bn.js": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": { "packages": { "@ethersproject/providers": true, @@ -3023,29 +3029,21 @@ "fetch": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, + "@metamask/rpc-errors": 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, + "@metamask/utils>@metamask/superstruct": true, "bn.js": true, "lodash": true, - "superstruct": true, "uuid": true, "webpack>events": true } }, - "@metamask/user-operation-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/user-operation-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -3053,32 +3051,11 @@ "setTimeout": true }, "packages": { + "@metamask/base-controller": true, "@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, - "@metamask/user-operation-controller>@metamask/rpc-errors>@metamask/utils": true - } - }, - "@metamask/user-operation-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/user-operation-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -5668,12 +5645,6 @@ "string.prototype.matchall>get-intrinsic": true } }, - "superstruct": { - "globals": { - "console.warn": true, - "define": true - } - }, "terser>source-map-support>buffer-from": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 93308bf0af4a..cf72074493e6 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1439,14 +1439,6 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -1454,7 +1446,7 @@ "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, "uuid": true } @@ -2637,13 +2629,13 @@ "@ethersproject/providers": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, "@metamask/smart-transactions-controller>@metamask/base-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/rpc-errors": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/utils": true, @@ -2684,6 +2676,20 @@ "webpack>events": true } }, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": { + "globals": { + "clearInterval": true, + "console.error": true, + "setInterval": true + }, + "packages": { + "@metamask/controller-utils": true, + "@metamask/eth-query": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "bn.js": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": { "packages": { "@ethersproject/providers": true, @@ -3023,29 +3029,21 @@ "fetch": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, + "@metamask/rpc-errors": 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, + "@metamask/utils>@metamask/superstruct": true, "bn.js": true, "lodash": true, - "superstruct": true, "uuid": true, "webpack>events": true } }, - "@metamask/user-operation-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/user-operation-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -3053,32 +3051,11 @@ "setTimeout": true }, "packages": { + "@metamask/base-controller": true, "@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, - "@metamask/user-operation-controller>@metamask/rpc-errors>@metamask/utils": true - } - }, - "@metamask/user-operation-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/user-operation-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -5668,12 +5645,6 @@ "string.prototype.matchall>get-intrinsic": true } }, - "superstruct": { - "globals": { - "console.warn": true, - "define": true - } - }, "terser>source-map-support>buffer-from": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index b135a959d359..e70102a63e51 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1531,14 +1531,6 @@ "uuid": true } }, - "@metamask/gas-fee-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/gas-fee-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -1546,7 +1538,7 @@ "setTimeout": true }, "packages": { - "@metamask/gas-fee-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/snaps-utils>fast-json-stable-stringify": true, "uuid": true } @@ -2729,13 +2721,13 @@ "@ethersproject/providers": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, - "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, "@metamask/name-controller>async-mutex": true, "@metamask/network-controller": true, "@metamask/smart-transactions-controller>@metamask/base-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/tx": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@ethereumjs/util": true, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/rpc-errors": true, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/utils": true, @@ -2776,6 +2768,20 @@ "webpack>events": true } }, + "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/gas-fee-controller": { + "globals": { + "clearInterval": true, + "console.error": true, + "setInterval": true + }, + "packages": { + "@metamask/controller-utils": true, + "@metamask/eth-query": true, + "@metamask/smart-transactions-controller>@metamask/polling-controller": true, + "bn.js": true, + "uuid": true + } + }, "@metamask/smart-transactions-controller>@metamask/transaction-controller>@metamask/nonce-tracker": { "packages": { "@ethersproject/providers": true, @@ -3115,29 +3121,21 @@ "fetch": true }, "packages": { + "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, + "@metamask/rpc-errors": 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, + "@metamask/utils>@metamask/superstruct": true, "bn.js": true, "lodash": true, - "superstruct": true, "uuid": true, "webpack>events": true } }, - "@metamask/user-operation-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/user-operation-controller>@metamask/polling-controller": { "globals": { "clearTimeout": true, @@ -3145,32 +3143,11 @@ "setTimeout": true }, "packages": { + "@metamask/base-controller": true, "@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, - "@metamask/user-operation-controller>@metamask/rpc-errors>@metamask/utils": true - } - }, - "@metamask/user-operation-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/user-operation-controller>@metamask/utils": { "globals": { "TextDecoder": true, @@ -5736,12 +5713,6 @@ "string.prototype.matchall>get-intrinsic": true } }, - "superstruct": { - "globals": { - "console.warn": true, - "define": true - } - }, "terser>source-map-support>buffer-from": { "packages": { "browserify>buffer": true diff --git a/package.json b/package.json index df700ec1b415..5304df2c2ff8 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,6 @@ "@expo/config-plugins/glob": "^10.3.10", "@solana/web3.js/rpc-websockets": "^8.0.1", "@metamask/message-manager": "^10.1.0", - "@metamask/gas-fee-controller@npm:^15.1.1": "patch:@metamask/gas-fee-controller@npm%3A15.1.2#~/.yarn/patches/@metamask-gas-fee-controller-npm-15.1.2-db4d2976aa.patch", "@metamask/nonce-tracker@npm:^5.0.0": "patch:@metamask/nonce-tracker@npm%3A5.0.0#~/.yarn/patches/@metamask-nonce-tracker-npm-5.0.0-d81478218e.patch", "@metamask/network-controller@npm:^17.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:^19.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", @@ -310,7 +309,7 @@ "@metamask/ethjs": "^0.6.0", "@metamask/ethjs-contract": "^0.4.1", "@metamask/ethjs-query": "^0.7.1", - "@metamask/gas-fee-controller": "^18.0.0", + "@metamask/gas-fee-controller": "^21.0.0", "@metamask/jazzicon": "^2.0.0", "@metamask/json-rpc-engine": "^10.0.0", "@metamask/json-rpc-middleware-stream": "^8.0.4", @@ -351,7 +350,7 @@ "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", "@metamask/transaction-controller": "^40.1.0", - "@metamask/user-operation-controller": "^13.0.0", + "@metamask/user-operation-controller": "^16.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.12", "@noble/hashes": "^1.3.3", diff --git a/ui/pages/bridge/index.test.tsx b/ui/pages/bridge/index.test.tsx index 7d5f813513c5..1dc898d71685 100644 --- a/ui/pages/bridge/index.test.tsx +++ b/ui/pages/bridge/index.test.tsx @@ -16,9 +16,7 @@ setBackgroundConnection({ setSwapsLiveness: jest.fn(() => true), setSwapsTokens: jest.fn(), setSwapsTxGasPrice: jest.fn(), - gasFeeStartPollingByNetworkClientId: jest - .fn() - .mockResolvedValue('pollingToken'), + gasFeeStartPolling: jest.fn().mockResolvedValue('pollingToken'), gasFeeStopPollingByPollingToken: jest.fn(), getNetworkConfigurationByNetworkClientId: jest .fn() diff --git a/ui/pages/confirmations/confirm-send-ether/confirm-send-ether.test.js b/ui/pages/confirmations/confirm-send-ether/confirm-send-ether.test.js index 04caf63d12e0..e30dd925c4d3 100644 --- a/ui/pages/confirmations/confirm-send-ether/confirm-send-ether.test.js +++ b/ui/pages/confirmations/confirm-send-ether/confirm-send-ether.test.js @@ -10,9 +10,7 @@ import ConfirmSendEther from './confirm-send-ether'; jest.mock('../components/simulation-details/useSimulationMetrics'); setBackgroundConnection({ - gasFeeStartPollingByNetworkClientId: jest - .fn() - .mockResolvedValue('pollingToken'), + gasFeeStartPolling: jest.fn().mockResolvedValue('pollingToken'), gasFeeStopPollingByPollingToken: jest.fn(), getNetworkConfigurationByNetworkClientId: jest.fn().mockImplementation(() => Promise.resolve({ diff --git a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js index 5a846874fed3..56be8fd5aee3 100644 --- a/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js +++ b/ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js @@ -31,9 +31,7 @@ jest.mock('../components/simulation-details/useSimulationMetrics'); const middleware = [thunk]; setBackgroundConnection({ - gasFeeStartPollingByNetworkClientId: jest - .fn() - .mockResolvedValue('pollingToken'), + gasFeeStartPolling: jest.fn().mockResolvedValue('pollingToken'), gasFeeStopPollingByPollingToken: jest.fn(), getNetworkConfigurationByNetworkClientId: jest.fn().mockImplementation(() => Promise.resolve({ diff --git a/ui/pages/confirmations/confirm-transaction/confirm-transaction.transaction.test.js b/ui/pages/confirmations/confirm-transaction/confirm-transaction.transaction.test.js index 09eb13c16919..a7db429a90e8 100644 --- a/ui/pages/confirmations/confirm-transaction/confirm-transaction.transaction.test.js +++ b/ui/pages/confirmations/confirm-transaction/confirm-transaction.transaction.test.js @@ -19,7 +19,7 @@ const middleware = [thunk]; setBackgroundConnection({ getGasFeeTimeEstimate: jest.fn(), - gasFeeStartPollingByNetworkClientId: jest.fn(), + gasFeeStartPolling: jest.fn(), gasFeeStopPollingByPollingToken: jest.fn(), promisifiedBackground: jest.fn(), tryReverseResolveAddress: jest.fn(), diff --git a/ui/pages/swaps/index.test.js b/ui/pages/swaps/index.test.js index 4157b614562f..e9e16d91b208 100644 --- a/ui/pages/swaps/index.test.js +++ b/ui/pages/swaps/index.test.js @@ -33,9 +33,7 @@ setBackgroundConnection({ setSwapsLiveness: jest.fn(() => true), setSwapsTokens: jest.fn(), setSwapsTxGasPrice: jest.fn(), - gasFeeStartPollingByNetworkClientId: jest - .fn() - .mockResolvedValue('pollingToken'), + gasFeeStartPolling: jest.fn().mockResolvedValue('pollingToken'), gasFeeStopPollingByPollingToken: jest.fn(), getNetworkConfigurationByNetworkClientId: jest .fn() diff --git a/ui/store/actions.ts b/ui/store/actions.ts index f3f4e712acf5..90018c4d7389 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4728,10 +4728,9 @@ export async function accountTrackerStopPollingByPollingToken( export async function gasFeeStartPollingByNetworkClientId( networkClientId: string, ) { - const pollingToken = await submitRequestToBackground( - 'gasFeeStartPollingByNetworkClientId', - [networkClientId], - ); + const pollingToken = await submitRequestToBackground('gasFeeStartPolling', [ + { networkClientId }, + ]); await addPollingTokenToAppState(pollingToken); return pollingToken; } diff --git a/yarn.lock b/yarn.lock index 1f5433e3d780..88e84e6d37b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5695,6 +5695,26 @@ __metadata: languageName: node linkType: hard +"@metamask/gas-fee-controller@npm:^21.0.0": + version: 21.0.0 + resolution: "@metamask/gas-fee-controller@npm:21.0.0" + dependencies: + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/ethjs-unit": "npm:^0.3.0" + "@metamask/polling-controller": "npm:^11.0.0" + "@metamask/utils": "npm:^9.1.0" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + bn.js: "npm:^5.2.1" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/network-controller": ^21.0.0 + checksum: 10/8b41c7257f7dc17deb3f550cfdde0288da142d11536bb55c998bec8267fa62243e36fb6468a44224cd90ed2f49ba3ba1dbe93c2b0834a725752c5a66ae283303 + languageName: node + linkType: hard + "@metamask/jazzicon@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/jazzicon@npm:2.0.0" @@ -6183,6 +6203,22 @@ __metadata: languageName: node linkType: hard +"@metamask/polling-controller@npm:^11.0.0": + version: 11.0.0 + resolution: "@metamask/polling-controller@npm:11.0.0" + 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/67b563a5d1ce02dc9c2db25ad4ad1fb9f75d5578cf380cce85176ff2cd136addce612c3982653254647b9d8c535374e93d96abb6e500e42076bf3a524a72e75f + languageName: node + linkType: hard + "@metamask/polling-controller@npm:^12.0.1": version: 12.0.1 resolution: "@metamask/polling-controller@npm:12.0.1" @@ -6752,33 +6788,28 @@ __metadata: languageName: node linkType: hard -"@metamask/user-operation-controller@npm:^13.0.0": - version: 13.0.0 - resolution: "@metamask/user-operation-controller@npm:13.0.0" +"@metamask/user-operation-controller@npm:^16.0.0": + version: 16.0.0 + resolution: "@metamask/user-operation-controller@npm:16.0.0" dependencies: - "@metamask/approval-controller": "npm:^7.0.0" - "@metamask/base-controller": "npm:^6.0.0" - "@metamask/controller-utils": "npm:^11.0.0" + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.3.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^18.0.0" - "@metamask/keyring-controller": "npm:^17.1.0" - "@metamask/network-controller": "npm:^19.0.0" - "@metamask/polling-controller": "npm:^8.0.0" - "@metamask/rpc-errors": "npm:^6.2.1" - "@metamask/transaction-controller": "npm:^34.0.0" - "@metamask/utils": "npm:^8.3.0" + "@metamask/polling-controller": "npm:^11.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^9.1.0" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - superstruct: "npm:^1.0.3" uuid: "npm:^8.3.2" peerDependencies: "@metamask/approval-controller": ^7.0.0 - "@metamask/gas-fee-controller": ^18.0.0 + "@metamask/gas-fee-controller": ^21.0.0 "@metamask/keyring-controller": ^17.0.0 - "@metamask/network-controller": ^19.0.0 - "@metamask/transaction-controller": ^34.0.0 - checksum: 10/600dd845dfc30ff852d766bd012ce40b4a6fb2276538d358cbe3ef1ce5815e4dba8f94e4911b7cb0506857133b185923b43af73ec39c7628eb86eedfdaf8dc59 + "@metamask/network-controller": ^21.0.0 + "@metamask/transaction-controller": ^37.0.0 + checksum: 10/36ef43910f9e94ae7823902113acdaf7d4031423930f0a35fe4dd3b948a00e8088ea590354afbdb507b32712761720727f0ee905ad6d3db83ef3f0f145b8452d languageName: node linkType: hard @@ -26828,7 +26859,7 @@ __metadata: "@metamask/ethjs-contract": "npm:^0.4.1" "@metamask/ethjs-query": "npm:^0.7.1" "@metamask/forwarder": "npm:^1.1.0" - "@metamask/gas-fee-controller": "npm:^18.0.0" + "@metamask/gas-fee-controller": "npm:^21.0.0" "@metamask/jazzicon": "npm:^2.0.0" "@metamask/json-rpc-engine": "npm:^10.0.0" "@metamask/json-rpc-middleware-stream": "npm:^8.0.4" @@ -26873,7 +26904,7 @@ __metadata: "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:8.13.0" "@metamask/transaction-controller": "npm:^40.1.0" - "@metamask/user-operation-controller": "npm:^13.0.0" + "@metamask/user-operation-controller": "npm:^16.0.0" "@metamask/utils": "npm:^10.0.1" "@ngraveio/bc-ur": "npm:^1.1.12" "@noble/hashes": "npm:^1.3.3" From f711459a0857c16b32f5382216085c69c6d99dfe Mon Sep 17 00:00:00 2001 From: legobeat <109787230+legobeat@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:32:01 +0900 Subject: [PATCH 096/148] chore: node.js 20.18 (#28058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bump Node.js from `20.17.0` to `20.18.0` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28058?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 - [ ] 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 | 6 +++--- .nvmrc | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3e3ccba9005e..3178a687a617 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,13 +3,13 @@ version: 2.1 executors: node-browsers-small: docker: - - image: cimg/node:20.17-browsers + - image: cimg/node:20.18-browsers resource_class: small environment: NODE_OPTIONS: --max_old_space_size=2048 node-browsers-medium: docker: - - image: cimg/node:20.17-browsers + - image: cimg/node:20.18-browsers resource_class: medium environment: NODE_OPTIONS: --max_old_space_size=3072 @@ -21,7 +21,7 @@ executors: NODE_OPTIONS: --max_old_space_size=6144 node-browsers-medium-plus: docker: - - image: cimg/node:20.17-browsers + - image: cimg/node:20.18-browsers resource_class: medium+ environment: NODE_OPTIONS: --max_old_space_size=4096 diff --git a/.nvmrc b/.nvmrc index 8cfab175cf90..bd67975ba627 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.17 +v20.18 From b6c750d7601fc90727b7c3585626f6afe1fcda44 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:08:52 +0100 Subject: [PATCH 097/148] test: [POM] Migrate add token e2e tests to TS and Page Object Model (#28658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Fix flaky test add hide token, the reason of flakiness is a race condition that we assert number of listed tokens before all tokens are displayed - Migrate add token e2e tests to TS and Page Object Model [![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/28681 https://github.com/MetaMask/metamask-extension/issues/28664 ## **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. --- .../redesign/add-token-confirmations.ts | 49 ++++ test/e2e/page-objects/pages/homepage.ts | 125 +++++++- test/e2e/page-objects/pages/test-dapp.ts | 10 - test/e2e/tests/tokens/add-hide-token.spec.js | 270 ------------------ test/e2e/tests/tokens/add-hide-token.spec.ts | 50 ++++ .../tests/tokens/add-token-using-search.ts | 69 +++++ .../tokens/watch-asset-call-add-token.ts | 109 +++++++ 7 files changed, 400 insertions(+), 282 deletions(-) create mode 100644 test/e2e/page-objects/pages/confirmations/redesign/add-token-confirmations.ts delete mode 100644 test/e2e/tests/tokens/add-hide-token.spec.js create mode 100644 test/e2e/tests/tokens/add-hide-token.spec.ts create mode 100644 test/e2e/tests/tokens/add-token-using-search.ts create mode 100644 test/e2e/tests/tokens/watch-asset-call-add-token.ts diff --git a/test/e2e/page-objects/pages/confirmations/redesign/add-token-confirmations.ts b/test/e2e/page-objects/pages/confirmations/redesign/add-token-confirmations.ts new file mode 100644 index 000000000000..fdbfbbd77ec8 --- /dev/null +++ b/test/e2e/page-objects/pages/confirmations/redesign/add-token-confirmations.ts @@ -0,0 +1,49 @@ +import { Driver } from '../../../../webdriver/driver'; + +class AddTokenConfirmation { + driver: Driver; + + private readonly addTokenConfirmationTitle = { + css: '.page-container__title', + text: 'Add suggested tokens', + }; + + private readonly confirmAddTokenButton = + '[data-testid="page-container-footer-next"]'; + + private readonly rejectAddTokenButton = + '[data-testid="page-container-footer-cancel"]'; + + constructor(driver: Driver) { + this.driver = driver; + } + + async check_pageIsLoaded(): Promise { + try { + await this.driver.waitForSelector(this.addTokenConfirmationTitle); + } catch (e) { + console.log( + 'Timeout while waiting for Add token confirmation page to be loaded', + e, + ); + throw e; + } + console.log('Add token confirmation page is loaded'); + } + + async confirmAddToken(): Promise { + console.log('Confirm add token'); + await this.driver.clickElementAndWaitForWindowToClose( + this.confirmAddTokenButton, + ); + } + + async rejectAddToken(): Promise { + console.log('Reject add token'); + await this.driver.clickElementAndWaitForWindowToClose( + this.rejectAddTokenButton, + ); + } +} + +export default AddTokenConfirmation; diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index 889ab344d91a..3722f35f98d9 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -45,6 +45,44 @@ class HomePage { private readonly transactionAmountsInActivity = '[data-testid="transaction-list-item-primary-currency"]'; + // Token tab selectors + private readonly assetOptionsButton = '[data-testid="asset-options__button"]'; + + private readonly confirmImportTokenButton = + '[data-testid="import-tokens-modal-import-button"]'; + + private readonly confirmImportTokenMessage = { + text: 'Would you like to import this token?', + tag: 'p', + }; + + private readonly hideTokenButton = '[data-testid="asset-options__hide"]'; + + private readonly hideTokenConfirmationButton = + '[data-testid="hide-token-confirmation__hide"]'; + + private readonly hideTokenConfirmationModalTitle = { + text: 'Hide token', + css: '.hide-token-confirmation__title', + }; + + private readonly importTokenModalTitle = { text: 'Import tokens', tag: 'h4' }; + + private readonly importTokensButton = '[data-testid="importTokens"]'; + + private readonly importTokensNextButton = + '[data-testid="import-tokens-button-next"]'; + + private readonly tokenAmountValue = + '[data-testid="multichain-token-list-item-value"]'; + + private readonly tokenLisiItem = + '[data-testid="multichain-token-list-button"]'; + + private readonly tokenOptionsButton = '[data-testid="import-token-button"]'; + + private readonly tokenSearchInput = 'input[placeholder="Search tokens"]'; + // NFT selectors private readonly confirmImportNftButton = '[data-testid="import-nfts-modal-import-button"]'; @@ -87,6 +125,10 @@ class HomePage { console.log('Home page is loaded'); } + async clickNFTIconOnActivityList() { + await this.driver.clickElement(this.nftIconOnActivityList); + } + async closePopover(): Promise { console.log('Closing popover'); await this.driver.clickElement(this.popoverCloseButton); @@ -114,8 +156,20 @@ class HomePage { await this.driver.clickElement(this.nftTab); } - async clickNFTIconOnActivityList() { - await this.driver.clickElement(this.nftIconOnActivityList); + /** + * Hides a token by clicking on the token name, and confirming the hide modal. + * + * @param tokenName - The name of the token to hide. + */ + async hideToken(tokenName: string): Promise { + console.log(`Hide token ${tokenName} on homepage`); + await this.driver.clickElement({ text: tokenName, tag: 'span' }); + await this.driver.clickElement(this.assetOptionsButton); + await this.driver.clickElement(this.hideTokenButton); + await this.driver.waitForSelector(this.hideTokenConfirmationModalTitle); + await this.driver.clickElementAndWaitToDisappear( + this.hideTokenConfirmationButton, + ); } async startSendFlow(): Promise { @@ -151,6 +205,20 @@ class HomePage { } } + async importTokenBySearch(tokenName: string) { + console.log(`Import token ${tokenName} on homepage by search`); + await this.driver.clickElement(this.tokenOptionsButton); + await this.driver.clickElement(this.importTokensButton); + await this.driver.waitForSelector(this.importTokenModalTitle); + await this.driver.fill(this.tokenSearchInput, tokenName); + await this.driver.clickElement({ text: tokenName, tag: 'p' }); + await this.driver.clickElement(this.importTokensNextButton); + await this.driver.waitForSelector(this.confirmImportTokenMessage); + await this.driver.clickElementAndWaitToDisappear( + this.confirmImportTokenButton, + ); + } + /** * Checks if the toaster message for adding a network is displayed on the homepage. * @@ -329,6 +397,59 @@ class HomePage { await this.driver.waitForSelector(this.successImportNftMessage); } + /** + * Checks if the specified token amount is displayed in the token list. + * + * @param tokenAmount - The token amount to be checked for. + */ + async check_tokenAmountIsDisplayed(tokenAmount: string): Promise { + console.log(`Waiting for token amount ${tokenAmount} to be displayed`); + await this.driver.waitForSelector({ + css: this.tokenAmountValue, + text: tokenAmount, + }); + } + + /** + * Checks if the specified token amount is displayed in the token details modal. + * + * @param tokenName - The name of the token to check for. + * @param tokenAmount - The token amount to be checked for. + */ + async check_tokenAmountInTokenDetailsModal( + tokenName: string, + tokenAmount: string, + ): Promise { + console.log( + `Check that token amount ${tokenAmount} is displayed in token details modal for token ${tokenName}`, + ); + await this.driver.clickElement({ + tag: 'span', + text: tokenName, + }); + await this.driver.waitForSelector({ + css: this.tokenAmountValue, + text: tokenAmount, + }); + } + + /** + * This function checks if the specified number of token items is displayed in the token list. + * + * @param expectedNumber - The number of token items expected to be displayed. Defaults to 1. + * @returns A promise that resolves if the expected number of token items is displayed. + */ + async check_tokenItemNumber(expectedNumber: number = 1): Promise { + console.log(`Waiting for ${expectedNumber} token items to be displayed`); + await this.driver.wait(async () => { + const tokenItems = await this.driver.findElements(this.tokenLisiItem); + return tokenItems.length === expectedNumber; + }, 10000); + console.log( + `Expected number of token items ${expectedNumber} is displayed.`, + ); + } + /** * This function checks if a specified transaction amount at the specified index matches the expected one. * diff --git a/test/e2e/page-objects/pages/test-dapp.ts b/test/e2e/page-objects/pages/test-dapp.ts index afff2f37e57e..31e962933e90 100644 --- a/test/e2e/page-objects/pages/test-dapp.ts +++ b/test/e2e/page-objects/pages/test-dapp.ts @@ -29,11 +29,6 @@ class TestDapp { private readonly depositPiggyBankContractButton = '#depositButton'; - private readonly editConnectButton = { - text: 'Edit', - tag: 'button', - }; - private readonly simpleSendButton = '#sendButton'; private readonly erc721MintButton = '#mintButton'; @@ -60,11 +55,6 @@ class TestDapp { private readonly erc721SetApprovalForAllButton = '#setApprovalForAllButton'; - private readonly localhostCheckbox = { - text: 'Localhost 8545', - tag: 'p', - }; - private readonly localhostNetworkMessage = { css: '#chainId', text: '0x539', diff --git a/test/e2e/tests/tokens/add-hide-token.spec.js b/test/e2e/tests/tokens/add-hide-token.spec.js deleted file mode 100644 index 011d52849feb..000000000000 --- a/test/e2e/tests/tokens/add-hide-token.spec.js +++ /dev/null @@ -1,270 +0,0 @@ -const { strict: assert } = require('assert'); -const { toHex } = require('@metamask/controller-utils'); -const { - defaultGanacheOptions, - withFixtures, - unlockWallet, - WINDOW_TITLES, - clickNestedButton, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); -const { SMART_CONTRACTS } = require('../../seeder/smart-contracts'); -const { CHAIN_IDS } = require('../../../../shared/constants/network'); - -describe('Add hide token', function () { - it('hides the token when clicked', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder() - .withTokensController({ - allTokens: { - [toHex(1337)]: { - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1': [ - { - address: '0x86002be4cdd922de1ccb831582bf99284b99ac12', - decimals: 4, - image: null, - isERC721: false, - symbol: 'TST', - }, - ], - }, - }, - tokens: [ - { - address: '0x86002be4cdd922de1ccb831582bf99284b99ac12', - decimals: 4, - image: null, - isERC721: false, - symbol: 'TST', - }, - ], - }) - .build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await unlockWallet(driver); - - await driver.waitForSelector({ - css: '[data-testid="multichain-token-list-item-value"]', - text: '0 TST', - }); - - let assets = await driver.findElements('.multichain-token-list-item'); - assert.equal(assets.length, 2); - - await clickNestedButton(driver, 'Tokens'); - - await driver.clickElement({ text: 'TST', tag: 'span' }); - - await driver.clickElement('[data-testid="asset-options__button"]'); - - await driver.clickElement('[data-testid="asset-options__hide"]'); - // wait for confirm hide modal to be visible - const confirmHideModal = - '[data-testid="hide-token-confirmation-modal"]'; - await driver.findVisibleElement(confirmHideModal); - - await driver.clickElement( - '[data-testid="hide-token-confirmation__hide"]', - ); - - // wait for confirm hide modal to be removed from DOM. - await driver.assertElementNotPresent(confirmHideModal); - - assets = await driver.findElements('.multichain-token-list-item'); - assert.equal(assets.length, 1); - }, - ); - }); -}); - -/* eslint-disable-next-line mocha/max-top-level-suites */ -describe('Add existing token using search', function () { - // Mock call to core to fetch BAT token price - async function mockPriceFetch(mockServer) { - return [ - await mockServer - .forGet('https://price.api.cx.metamask.io/v2/chains/56/spot-prices') - .withQuery({ - tokenAddresses: '0x0d8775f648430679a709e98d2b0cb6250d2887ef', - vsCurrency: 'ETH', - }) - .thenCallback(() => { - return { - statusCode: 200, - json: { - '0x0d8775f648430679a709e98d2b0cb6250d2887ef': { - eth: 0.0001, - }, - }, - }; - }), - ]; - } - it('renders the balance for the chosen token', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder({ inputChainId: CHAIN_IDS.BSC }) - .withPreferencesController({ useTokenDetection: true }) - .withTokenListController({ - tokenList: [ - { - name: 'Basic Attention Token', - symbol: 'BAT', - address: '0x0d8775f648430679a709e98d2b0cb6250d2887ef', - }, - ], - }) - .withAppStateController({ - [CHAIN_IDS.OPTIMISM]: true, - }) - .build(), - ganacheOptions: { - ...defaultGanacheOptions, - chainId: parseInt(CHAIN_IDS.BSC, 16), - }, - title: this.test.fullTitle(), - testSpecificMock: mockPriceFetch, - }, - async ({ driver }) => { - await unlockWallet(driver); - - await driver.clickElement(`[data-testid="import-token-button"]`); - await driver.clickElement(`[data-testid="importTokens"]`); - await driver.fill('input[placeholder="Search tokens"]', 'BAT'); - await driver.clickElement({ - text: 'BAT', - tag: 'p', - }); - await driver.clickElement({ text: 'Next', tag: 'button' }); - await driver.clickElementAndWaitToDisappear( - '[data-testid="import-tokens-modal-import-button"]', - ); - await driver.clickElement( - '[data-testid="account-overview__asset-tab"]', - ); - await driver.clickElement({ - tag: 'span', - text: 'Basic Attention Token', - }); - - await driver.waitForSelector({ - css: '[data-testid="multichain-token-list-item-value"]', - text: '0 BAT', - }); - }, - ); - }); -}); - -describe('Add token using wallet_watchAsset', function () { - const smartContract = SMART_CONTRACTS.HST; - - it('opens a notification that adds a token when wallet_watchAsset is executed, then approves', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), - ganacheOptions: defaultGanacheOptions, - smartContract, - title: this.test.fullTitle(), - }, - async ({ driver, contractRegistry }) => { - const contractAddress = await contractRegistry.getContractAddress( - smartContract, - ); - await unlockWallet(driver); - - await driver.openNewPage('http://127.0.0.1:8080/'); - - await driver.executeScript(` - window.ethereum.request({ - method: 'wallet_watchAsset', - params: { - type: 'ERC20', - options: { - address: '${contractAddress}', - symbol: 'TST', - decimals: 4 - }, - } - }) - `); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - tag: 'button', - text: 'Add token', - }); - - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - await driver.waitForSelector({ - css: '[data-testid="multichain-token-list-item-value"]', - text: '0 TST', - }); - }, - ); - }); - - it('opens a notification that adds a token when wallet_watchAsset is executed, then rejects', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), - ganacheOptions: defaultGanacheOptions, - smartContract, - title: this.test.fullTitle(), - }, - async ({ driver, contractRegistry }) => { - const contractAddress = await contractRegistry.getContractAddress( - smartContract, - ); - await unlockWallet(driver); - - await driver.openNewPage('http://127.0.0.1:8080/'); - - await driver.executeScript(` - window.ethereum.request({ - method: 'wallet_watchAsset', - params: { - type: 'ERC20', - options: { - address: '${contractAddress}', - symbol: 'TST', - decimals: 4 - }, - } - }) - `); - - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await driver.clickElementAndWaitForWindowToClose({ - tag: 'button', - text: 'Cancel', - }); - - await driver.switchToWindowWithTitle( - WINDOW_TITLES.ExtensionInFullScreenView, - ); - - const assetListItems = await driver.findElements( - '.multichain-token-list-item', - ); - - assert.strictEqual(assetListItems.length, 1); - }, - ); - }); -}); diff --git a/test/e2e/tests/tokens/add-hide-token.spec.ts b/test/e2e/tests/tokens/add-hide-token.spec.ts new file mode 100644 index 000000000000..8f85f2d9914e --- /dev/null +++ b/test/e2e/tests/tokens/add-hide-token.spec.ts @@ -0,0 +1,50 @@ +import { toHex } from '@metamask/controller-utils'; +import { withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import HomePage from '../../page-objects/pages/homepage'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; + +describe('Add hide token', function () { + it('hides the token when clicked', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withTokensController({ + allTokens: { + [toHex(1337)]: { + '0x5cfe73b6021e818b776b421b1c4db2474086a7e1': [ + { + address: '0x86002be4cdd922de1ccb831582bf99284b99ac12', + decimals: 4, + image: null, + isERC721: false, + symbol: 'TST', + }, + ], + }, + }, + tokens: [ + { + address: '0x86002be4cdd922de1ccb831582bf99284b99ac12', + decimals: 4, + image: null, + isERC721: false, + symbol: 'TST', + }, + ], + }) + .build(), + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + const homepage = new HomePage(driver); + await homepage.check_tokenItemNumber(2); + await homepage.check_tokenAmountIsDisplayed('0 TST'); + + await homepage.hideToken('TST'); + await homepage.check_tokenItemNumber(1); + }, + ); + }); +}); diff --git a/test/e2e/tests/tokens/add-token-using-search.ts b/test/e2e/tests/tokens/add-token-using-search.ts new file mode 100644 index 000000000000..79b4715afb91 --- /dev/null +++ b/test/e2e/tests/tokens/add-token-using-search.ts @@ -0,0 +1,69 @@ +import { MockedEndpoint, Mockttp } from 'mockttp'; +import { defaultGanacheOptions, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import HomePage from '../../page-objects/pages/homepage'; +import { loginWithoutBalanceValidation } from '../../page-objects/flows/login.flow'; + +describe('Add existing token using search', function () { + // Mock call to core to fetch BAT token price + async function mockPriceFetch( + mockServer: Mockttp, + ): Promise { + return [ + await mockServer + .forGet('https://price.api.cx.metamask.io/v2/chains/56/spot-prices') + .withQuery({ + tokenAddresses: '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + vsCurrency: 'ETH', + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + '0x0d8775f648430679a709e98d2b0cb6250d2887ef': { + eth: 0.0001, + }, + }, + }; + }), + ]; + } + it('renders the balance for the chosen token', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ inputChainId: CHAIN_IDS.BSC }) + .withPreferencesController({ useTokenDetection: true }) + .withTokenListController({ + tokenList: [ + { + name: 'Basic Attention Token', + symbol: 'BAT', + address: '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + }, + ], + }) + .withAppStateController({ + [CHAIN_IDS.OPTIMISM]: true, + }) + .build(), + ganacheOptions: { + ...defaultGanacheOptions, + chainId: parseInt(CHAIN_IDS.BSC, 16), + }, + title: this.test?.fullTitle(), + testSpecificMock: mockPriceFetch, + }, + async ({ driver }) => { + await loginWithoutBalanceValidation(driver); + const homepage = new HomePage(driver); + await homepage.check_tokenAmountIsDisplayed('25 BNB'); + await homepage.importTokenBySearch('BAT'); + await homepage.check_tokenAmountInTokenDetailsModal( + 'Basic Attention Token', + '0 BAT', + ); + }, + ); + }); +}); diff --git a/test/e2e/tests/tokens/watch-asset-call-add-token.ts b/test/e2e/tests/tokens/watch-asset-call-add-token.ts new file mode 100644 index 000000000000..35c66dda8ce6 --- /dev/null +++ b/test/e2e/tests/tokens/watch-asset-call-add-token.ts @@ -0,0 +1,109 @@ +import { + defaultGanacheOptions, + withFixtures, + WINDOW_TITLES, +} from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { SMART_CONTRACTS } from '../../seeder/smart-contracts'; +import AddTokenConfirmation from '../../page-objects/pages/confirmations/redesign/add-token-confirmations'; +import HomePage from '../../page-objects/pages/homepage'; +import TestDapp from '../../page-objects/pages/test-dapp'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; + +describe('Add token using wallet_watchAsset', function () { + const smartContract = SMART_CONTRACTS.HST; + + it('opens a notification that adds a token when wallet_watchAsset is executed, then approves', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer, contractRegistry }) => { + const contractAddress = await contractRegistry.getContractAddress( + smartContract, + ); + await loginWithBalanceValidation(driver, ganacheServer); + const testDapp = new TestDapp(driver); + await testDapp.openTestDappPage(); + await testDapp.check_pageIsLoaded(); + + await driver.executeScript(` + window.ethereum.request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: { + address: '${contractAddress}', + symbol: 'TST', + decimals: 4 + }, + } + }) + `); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + const addTokenConfirmation = new AddTokenConfirmation(driver); + await addTokenConfirmation.check_pageIsLoaded(); + await addTokenConfirmation.confirmAddToken(); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await new HomePage(driver).check_tokenAmountIsDisplayed('0 TST'); + }, + ); + }); + + it('opens a notification that adds a token when wallet_watchAsset is executed, then rejects', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer, contractRegistry }) => { + const contractAddress = await contractRegistry.getContractAddress( + smartContract, + ); + await loginWithBalanceValidation(driver, ganacheServer); + const testDapp = new TestDapp(driver); + await testDapp.openTestDappPage(); + await testDapp.check_pageIsLoaded(); + + await driver.executeScript(` + window.ethereum.request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: { + address: '${contractAddress}', + symbol: 'TST', + decimals: 4 + }, + } + }) + `); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + const addTokenConfirmation = new AddTokenConfirmation(driver); + await addTokenConfirmation.check_pageIsLoaded(); + await addTokenConfirmation.rejectAddToken(); + + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + await new HomePage(driver).check_tokenItemNumber(1); + }, + ); + }); +}); From 96fafbf2a13e8b91eed2eea608089df8f268dded Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 27 Nov 2024 19:22:18 +0700 Subject: [PATCH 098/148] test: add integration tests for different types of Permit (#27446) 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/27446?quickstart=1) ## **Related issues** Fixes: [#26134](https://github.com/MetaMask/metamask-extension/issues/26134) ## **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. --- .../signatures/permit-batch.test.tsx | 175 ++++++++++++++++ .../signatures/permit-seaport.test.tsx | 190 ++++++++++++++++++ .../signatures/permit-single.test.tsx | 151 ++++++++++++++ .../signatures/permit-tradeOrder.test.tsx | 129 ++++++++++++ .../confirmations/signatures/permit.test.tsx | 53 +---- .../signatures/signature-helpers.ts | 99 +++++++++ .../info/__snapshots__/info.test.tsx.snap | 16 ++ .../__snapshots__/typed-sign-v1.test.tsx.snap | 2 + .../__snapshots__/typed-sign.test.tsx.snap | 45 +++++ .../confirm/info/typed-sign/typed-sign.tsx | 4 +- .../row/__snapshots__/dataTree.test.tsx.snap | 28 +++ .../components/confirm/row/dataTree.tsx | 1 + .../typedSignDataV1.test.tsx.snap | 2 + .../__snapshots__/typedSignData.test.tsx.snap | 14 ++ .../__snapshots__/confirm.test.tsx.snap | 35 ++++ 15 files changed, 894 insertions(+), 50 deletions(-) create mode 100644 test/integration/confirmations/signatures/permit-batch.test.tsx create mode 100644 test/integration/confirmations/signatures/permit-seaport.test.tsx create mode 100644 test/integration/confirmations/signatures/permit-single.test.tsx create mode 100644 test/integration/confirmations/signatures/permit-tradeOrder.test.tsx create mode 100644 test/integration/confirmations/signatures/signature-helpers.ts diff --git a/test/integration/confirmations/signatures/permit-batch.test.tsx b/test/integration/confirmations/signatures/permit-batch.test.tsx new file mode 100644 index 000000000000..ea311537001c --- /dev/null +++ b/test/integration/confirmations/signatures/permit-batch.test.tsx @@ -0,0 +1,175 @@ +import { act, fireEvent, screen } from '@testing-library/react'; +import nock from 'nock'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { integrationTestRender } from '../../../lib/render-helpers'; +import * as backgroundConnection from '../../../../ui/store/background-connection'; +import { createMockImplementation } from '../../helpers'; +import { tEn } from '../../../lib/i18n-helpers'; +import { + getMetaMaskStateWithUnapprovedPermitSign, + verifyDetails, +} from './signature-helpers'; + +jest.mock('../../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), +})); + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const renderPermitBatchSignature = async () => { + const account = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ]; + + const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( + account.address, + 'PermitBatch', + ); + + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedMetaMaskState, + selectedNetworkClientId: 'testNetworkConfigurationId', + providerConfig: { + type: 'rpc', + nickname: 'test mainnet', + chainId: '0x1', + ticker: 'ETH', + id: 'chain1', + }, + }, + backgroundConnection: backgroundConnectionMocked, + }); + }); +}; + +describe('Permit Batch Signature Tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + getTokenStandardAndDetails: { decimals: '2' }, + }), + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('renders the permit batch signature with correct titles', async () => { + await renderPermitBatchSignature(); + + expect( + await screen.findByText(tEn('confirmTitlePermitTokens') as string), + ).toBeInTheDocument(); + expect( + await screen.findByText(tEn('confirmTitleDescPermitSignature') as string), + ).toBeInTheDocument(); + }); + + it('displays the correct details in the simulation section', async () => { + await renderPermitBatchSignature(); + + const simulationSection = await screen.findByTestId( + 'confirmation__simulation_section', + ); + expect(simulationSection).toBeInTheDocument(); + + const simulationDetails = [ + 'Estimated changes', + "You're giving the spender permission to spend this many tokens from your account.", + 'Spending cap', + '0xA0b86...6eB48', + '1,461,501,637,3...', + '0xb0B86...6EB48', + '2,461,501,637,3...', + ]; + + verifyDetails(simulationSection, simulationDetails); + }); + + it('displays correct request and message details', async () => { + await renderPermitBatchSignature(); + + const requestDetailsSection = await screen.findByTestId( + 'confirmation_request-section', + ); + const requestDetails = [ + 'Spender', + '0x3fC91...b7FAD', + 'Request from', + 'metamask.github.io', + 'Interacting with', + '0x00000...78BA3', + ]; + expect(requestDetailsSection).toBeInTheDocument(); + verifyDetails(requestDetailsSection, requestDetails); + + await act(async () => { + fireEvent.click(await screen.findByTestId('sectionCollapseButton')); + }); + + const messageDetailsSection = await screen.findByTestId( + 'confirmation_message-section', + ); + expect(messageDetailsSection).toBeInTheDocument(); + expect(messageDetailsSection).toHaveTextContent('Message'); + expect(messageDetailsSection).toHaveTextContent('Primary type:'); + expect(messageDetailsSection).toHaveTextContent('PermitBatch'); + + const messageData0 = await screen.findByTestId( + 'confirmation_data-0-index-0', + ); + const messageData1 = await screen.findByTestId( + 'confirmation_data-1-index-1', + ); + expect(messageDetailsSection).toContainElement(messageData0); + expect(messageDetailsSection).toContainElement(messageData1); + + const messageDetails = [ + { + element: messageData0, + content: [ + 'Token', + 'USDC', + 'Amount', + '1,461,501,637,3...', + 'Expiration', + '05 August 2024, 19:52', + 'Nonce', + '5', + ], + }, + { + element: messageData1, + content: [ + 'Token', + '0xb0B86...6EB48', + 'Amount', + '2,461,501,637,3...', + 'Expiration', + '05 August 2024, 19:54', + 'Nonce', + '6', + ], + }, + ]; + + messageDetails.forEach(({ element, content }) => { + verifyDetails(element, content); + }); + + expect(messageDetailsSection).toHaveTextContent('Spender'); + expect(messageDetailsSection).toHaveTextContent('0x3fC91...b7FAD'); + expect(messageDetailsSection).toHaveTextContent('SigDeadline'); + expect(messageDetailsSection).toHaveTextContent('06 July 2024, 20:22'); + }); +}); diff --git a/test/integration/confirmations/signatures/permit-seaport.test.tsx b/test/integration/confirmations/signatures/permit-seaport.test.tsx new file mode 100644 index 000000000000..be4f6b6064d2 --- /dev/null +++ b/test/integration/confirmations/signatures/permit-seaport.test.tsx @@ -0,0 +1,190 @@ +import { act, screen } from '@testing-library/react'; +import nock from 'nock'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { integrationTestRender } from '../../../lib/render-helpers'; +import * as backgroundConnection from '../../../../ui/store/background-connection'; +import { createMockImplementation } from '../../helpers'; +import { tEn } from '../../../lib/i18n-helpers'; +import { + getMetaMaskStateWithUnapprovedPermitSign, + verifyDetails, +} from './signature-helpers'; + +jest.mock('../../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), +})); + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const renderSeaportSignature = async () => { + const account = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ]; + + const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( + account.address, + 'PermitSeaport', + ); + + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedMetaMaskState, + selectedNetworkClientId: 'testNetworkConfigurationId', + providerConfig: { + type: 'rpc', + nickname: 'test mainnet', + chainId: '0x1', + ticker: 'ETH', + id: 'chain1', + }, + }, + backgroundConnection: backgroundConnectionMocked, + }); + }); +}; + +describe('Permit Seaport Tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + getTokenStandardAndDetails: { decimals: '2' }, + }), + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('renders seaport signature', async () => { + await renderSeaportSignature(); + + expect( + await screen.findByText(tEn('confirmTitleSignature') as string), + ).toBeInTheDocument(); + expect( + await screen.findByText(tEn('confirmTitleDescSign') as string), + ).toBeInTheDocument(); + }); + + it('renders request details section', async () => { + await renderSeaportSignature(); + + const requestDetailsSection = await screen.findByTestId( + 'confirmation_request-section', + ); + + expect(requestDetailsSection).toBeInTheDocument(); + expect(requestDetailsSection).toHaveTextContent('Request from'); + expect(requestDetailsSection).toHaveTextContent('metamask.github.io'); + expect(requestDetailsSection).toHaveTextContent('Interacting with'); + expect(requestDetailsSection).toHaveTextContent('0x00000...78BA3'); + }); + + it('renders message details section', async () => { + await renderSeaportSignature(); + + const messageDetailsSection = await screen.findByTestId( + 'confirmation_message-section', + ); + expect(messageDetailsSection).toBeInTheDocument(); + const messageDetailsContent = [ + 'Message', + 'Primary type:', + 'OrderComponents', + 'Offerer', + '0x5a6f5...Ac994', + 'Zone', + '0x004C0...60C00', + 'Offer', + ]; + verifyDetails(messageDetailsSection, messageDetailsContent); + }); + + it('renders offer and consideration details', async () => { + await renderSeaportSignature(); + + const offers = await screen.findByTestId('confirmation_data-offer-index-2'); + const offerDetails0 = offers.querySelector( + '[data-testid="confirmation_data-0-index-0"]', + ); + const offerDetails1 = offers.querySelector( + '[data-testid="confirmation_data-1-index-1"]', + ); + const considerations = await screen.findByTestId( + 'confirmation_data-consideration-index-3', + ); + const considerationDetails0 = considerations.querySelector( + '[data-testid="confirmation_data-0-index-0"]', + ); + + expect(offerDetails0).toBeInTheDocument(); + expect(offerDetails1).toBeInTheDocument(); + expect(considerations).toBeInTheDocument(); + expect(considerationDetails0).toBeInTheDocument(); + + const details = [ + { + element: offerDetails0 as HTMLElement, + content: [ + 'ItemType', + '2', + 'Token', + 'MutantApeYachtClub', + 'IdentifierOrCriteria', + '26464', + 'StartAmount', + '1', + 'EndAmount', + '1', + ], + }, + { + element: offerDetails1 as HTMLElement, + content: [ + 'ItemType', + '2', + 'Token', + 'MutantApeYachtClub', + 'IdentifierOrCriteria', + '7779', + 'StartAmount', + '1', + 'EndAmount', + '1', + ], + }, + { + element: considerationDetails0 as HTMLElement, + content: [ + 'ItemType', + '2', + 'Token', + 'MutantApeYachtClub', + 'IdentifierOrCriteria', + '26464', + 'StartAmount', + '1', + 'EndAmount', + '1', + 'Recipient', + '0xDFdc0...25Cc1', + ], + }, + ]; + + details.forEach(({ element, content }) => { + if (element) { + verifyDetails(element, content); + } + }); + }); +}); diff --git a/test/integration/confirmations/signatures/permit-single.test.tsx b/test/integration/confirmations/signatures/permit-single.test.tsx new file mode 100644 index 000000000000..abb5d07c1587 --- /dev/null +++ b/test/integration/confirmations/signatures/permit-single.test.tsx @@ -0,0 +1,151 @@ +import { act, fireEvent, screen } from '@testing-library/react'; +import nock from 'nock'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { integrationTestRender } from '../../../lib/render-helpers'; +import * as backgroundConnection from '../../../../ui/store/background-connection'; +import { createMockImplementation } from '../../helpers'; +import { tEn } from '../../../lib/i18n-helpers'; +import { + getMetaMaskStateWithUnapprovedPermitSign, + verifyDetails, +} from './signature-helpers'; + +jest.mock('../../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), +})); + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const renderSingleBatchSignature = async () => { + const account = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ]; + + const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( + account.address, + 'PermitSingle', + ); + + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedMetaMaskState, + selectedNetworkClientId: 'testNetworkConfigurationId', + providerConfig: { + type: 'rpc', + nickname: 'test mainnet', + chainId: '0x1', + ticker: 'ETH', + id: 'chain1', + }, + }, + backgroundConnection: backgroundConnectionMocked, + }); + }); +}; + +describe('Permit Single Signature Tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + getTokenStandardAndDetails: { decimals: '2' }, + }), + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('renders permit single signature with correct titles', async () => { + await renderSingleBatchSignature(); + + expect( + await screen.findByText(tEn('confirmTitlePermitTokens') as string), + ).toBeInTheDocument(); + expect( + await screen.findByText(tEn('confirmTitleDescPermitSignature') as string), + ).toBeInTheDocument(); + }); + + it('displays correct details in simulation section', async () => { + await renderSingleBatchSignature(); + + const simulationSection = await screen.findByTestId( + 'confirmation__simulation_section', + ); + const simulationDetails = [ + 'Estimated changes', + "You're giving the spender permission to spend this many tokens from your account.", + 'Spending cap', + '0xA0b86...6eB48', + '1,461,501,637,3...', + ]; + + expect(simulationSection).toBeInTheDocument(); + verifyDetails(simulationSection, simulationDetails); + }); + + it('displays correct details in request section', async () => { + await renderSingleBatchSignature(); + + const requestDetailsSection = await screen.findByTestId( + 'confirmation_request-section', + ); + const requestDetails = [ + 'Spender', + '0x3fC91...b7FAD', + 'Request from', + 'metamask.github.io', + 'Interacting with', + '0x00000...78BA3', + ]; + + expect(requestDetailsSection).toBeInTheDocument(); + verifyDetails(requestDetailsSection, requestDetails); + }); + + it('displays correct details in message section', async () => { + await renderSingleBatchSignature(); + act(async () => { + fireEvent.click(await screen.findByTestId('sectionCollapseButton')); + }); + + const messageDetailsSection = await screen.findByTestId( + 'confirmation_message-section', + ); + const messageDetails = ['Message', 'Primary type:', 'PermitSingle']; + + expect(messageDetailsSection).toBeInTheDocument(); + verifyDetails(messageDetailsSection, messageDetails); + + const messageData0 = await screen.findByTestId( + 'confirmation_data-details-index-0', + ); + const messageData0Details = [ + 'Token', + 'USDC', + 'Amount', + '1,461,501,637,3...', + 'Expiration', + '05 August 2024, 19:52', + 'Nonce', + '5', + ]; + + expect(messageDetailsSection).toContainElement(messageData0); + verifyDetails(messageData0, messageData0Details); + + expect(messageDetailsSection).toHaveTextContent('Spender'); + expect(messageDetailsSection).toHaveTextContent('0x3fC91...b7FAD'); + expect(messageDetailsSection).toHaveTextContent('SigDeadline'); + expect(messageDetailsSection).toHaveTextContent('06 July 2024, 20:22'); + }); +}); diff --git a/test/integration/confirmations/signatures/permit-tradeOrder.test.tsx b/test/integration/confirmations/signatures/permit-tradeOrder.test.tsx new file mode 100644 index 000000000000..429e533ff8f8 --- /dev/null +++ b/test/integration/confirmations/signatures/permit-tradeOrder.test.tsx @@ -0,0 +1,129 @@ +import { act, screen } from '@testing-library/react'; +import nock from 'nock'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { integrationTestRender } from '../../../lib/render-helpers'; +import * as backgroundConnection from '../../../../ui/store/background-connection'; +import { createMockImplementation } from '../../helpers'; +import { tEn } from '../../../lib/i18n-helpers'; +import { + getMetaMaskStateWithUnapprovedPermitSign, + verifyDetails, +} from './signature-helpers'; + +jest.mock('../../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), +})); + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; + +const renderTradeOrderSignature = async () => { + const account = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ]; + + const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( + account.address, + 'TradeOrder', + ); + + await act(async () => { + await integrationTestRender({ + preloadedState: { + ...mockedMetaMaskState, + selectedNetworkClientId: 'testNetworkConfigurationId', + providerConfig: { + type: 'rpc', + nickname: 'test mainnet', + chainId: '0x1', + ticker: 'ETH', + id: 'chain1', + }, + }, + backgroundConnection: backgroundConnectionMocked, + }); + }); +}; + +describe('Permit Trade Order Tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + getTokenStandardAndDetails: { decimals: '2' }, + }), + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('renders trade order signature with correct titles', async () => { + await renderTradeOrderSignature(); + + expect( + await screen.findByText(tEn('confirmTitleSignature') as string), + ).toBeInTheDocument(); + expect( + await screen.findByText(tEn('confirmTitleDescSign') as string), + ).toBeInTheDocument(); + }); + + it('displays correct details in request section', async () => { + await renderTradeOrderSignature(); + + const requestDetailsSection = await screen.findByTestId( + 'confirmation_request-section', + ); + const requestDetails = [ + 'Request from', + 'metamask.github.io', + 'Interacting with', + '0xDef1C...25EfF', + ]; + + expect(requestDetailsSection).toBeInTheDocument(); + verifyDetails(requestDetailsSection, requestDetails); + }); + + it('displays correct details in message section', async () => { + await renderTradeOrderSignature(); + const messageDetailsSection = await screen.findByTestId( + 'confirmation_message-section', + ); + const messageDetails = [ + 'Message', + 'Primary type:', + 'ERC721Order', + 'Direction', + '0', + 'Maker', + '0x8Eeee...73D12', + 'Taker', + '0xCD2a3...DD826', + 'Expiry', + '2524604400', + 'Nonce', + '100131415900000000000000000000000000000083840314483690155566137712510085002484', + 'Erc20Token', + 'Wrapped Ether', + 'Erc20TokenAmount', + '42000000000000', + 'Fees', + 'Erc721Token', + 'Doodles', + 'Erc721TokenId', + '2516', + 'Erc721TokenProperties', + ]; + + expect(messageDetailsSection).toBeInTheDocument(); + verifyDetails(messageDetailsSection, messageDetails); + }); +}); diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index 7af3be743f5f..6b736e4add90 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -1,7 +1,5 @@ -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, @@ -13,6 +11,7 @@ import * as backgroundConnection from '../../../../ui/store/background-connectio import { integrationTestRender } from '../../../lib/render-helpers'; import mockMetaMaskState from '../../data/integration-init-state.json'; import { createMockImplementation } from '../../helpers'; +import { getMetaMaskStateWithUnapprovedPermitSign } from './signature-helpers'; jest.mock('../../../../ui/store/background-connection', () => ({ ...jest.requireActual('../../../../ui/store/background-connection'), @@ -24,52 +23,6 @@ const backgroundConnectionMocked = { onNotification: jest.fn(), }; -const getMetaMaskStateWithUnapprovedPermitSign = (accountAddress: string) => { - const pendingPermitId = 'eae47d40-42a3-11ef-9253-b105fa7dfc9c'; - const pendingPermitTime = new Date().getTime(); - const messageParams = { - from: accountAddress, - version: 'v4', - data: `{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"${accountAddress}","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}`, - origin: 'https://metamask.github.io', - signatureMethod: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4, - }; - return { - ...mockMetaMaskState, - preferences: { - ...mockMetaMaskState.preferences, - redesignedConfirmationsEnabled: true, - }, - unapprovedTypedMessages: { - [pendingPermitId]: { - id: pendingPermitId, - chainId: CHAIN_IDS.SEPOLIA, - status: 'unapproved', - time: pendingPermitTime, - type: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA, - securityProviderResponse: null, - msgParams: messageParams, - }, - }, - unapprovedTypedMessagesCount: 1, - pendingApprovals: { - [pendingPermitId]: { - id: pendingPermitId, - origin: 'origin', - time: pendingPermitTime, - type: ApprovalType.EthSignTypedData, - requestData: { - ...messageParams, - metamaskId: pendingPermitId, - }, - requestState: null, - expectsResult: false, - }, - }, - pendingApprovalCount: 1, - }; -}; - describe('Permit Confirmation', () => { beforeEach(() => { jest.resetAllMocks(); @@ -94,6 +47,7 @@ describe('Permit Confirmation', () => { const accountName = account.metadata.name; const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( account.address, + 'Permit', ); await act(async () => { @@ -181,6 +135,7 @@ describe('Permit Confirmation', () => { const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( account.address, + 'Permit', ); await act(async () => { @@ -239,6 +194,7 @@ describe('Permit Confirmation', () => { const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( account.address, + 'Permit', ); await act(async () => { @@ -294,6 +250,7 @@ describe('Permit Confirmation', () => { const mockedMetaMaskState = getMetaMaskStateWithUnapprovedPermitSign( account.address, + 'Permit', ); await act(async () => { diff --git a/test/integration/confirmations/signatures/signature-helpers.ts b/test/integration/confirmations/signatures/signature-helpers.ts new file mode 100644 index 000000000000..5576e6ddf738 --- /dev/null +++ b/test/integration/confirmations/signatures/signature-helpers.ts @@ -0,0 +1,99 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { MESSAGE_TYPE } from '../../../../shared/constants/app'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; + +const PERMIT_DATA = `{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"{ownerAddress}","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}`; + +const PERMIT_BATCH_DATA = `{"types":{"PermitBatch":[{"name":"details","type":"PermitDetails[]"},{"name":"spender","type":"address"},{"name":"sigDeadline","type":"uint256"}],"PermitDetails":[{"name":"token","type":"address"},{"name":"amount","type":"uint160"},{"name":"expiration","type":"uint48"},{"name":"nonce","type":"uint48"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"Permit2","chainId":"1","verifyingContract":"0x000000000022d473030f116ddee9f6b43ac78ba3"},"primaryType":"PermitBatch","message":{"details":[{"token":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","amount":"1461501637330902918203684832716283019655932542975","expiration":"1722887542","nonce":"5"},{"token":"0xb0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","amount":"2461501637330902918203684832716283019655932542975","expiration":"1722887642","nonce":"6"}],"spender":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad","sigDeadline":"1720297342"}}`; + +const PERMIT_SINGLE_DATA = `{"types":{"PermitSingle":[{"name":"details","type":"PermitDetails"},{"name":"spender","type":"address"},{"name":"sigDeadline","type":"uint256"}],"PermitDetails":[{"name":"token","type":"address"},{"name":"amount","type":"uint160"},{"name":"expiration","type":"uint48"},{"name":"nonce","type":"uint48"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"Permit2","chainId":"1","verifyingContract":"0x000000000022d473030f116ddee9f6b43ac78ba3"},"primaryType":"PermitSingle","message":{"details":{"token":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","amount":"1461501637330902918203684832716283019655932542975","expiration":"1722887542","nonce":"5"},"spender":"0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad","sigDeadline":"1720297342"}}`; + +const SEAPORT_DATA = `{"types":{"OrderComponents":[{"name":"offerer","type":"address"},{"name":"zone","type":"address"},{"name":"offer","type":"OfferItem[]"},{"name":"consideration","type":"ConsiderationItem[]"},{"name":"orderType","type":"uint8"},{"name":"startTime","type":"uint256"},{"name":"endTime","type":"uint256"},{"name":"zoneHash","type":"bytes32"},{"name":"salt","type":"uint256"},{"name":"conduitKey","type":"bytes32"},{"name":"counter","type":"uint256"}],"OfferItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"}],"ConsiderationItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"},{"name":"recipient","type":"address"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"Seaport","version":"1.1","chainId":1,"verifyingContract":"0x000000000022d473030f116ddee9f6b43ac78ba3"},"primaryType":"OrderComponents","message":{"offerer":"0x5a6f5477bdeb7801ba137a9f0dc39c0599bac994","zone":"0x004c00500000ad104d7dbd00e3ae0a5c00560c00","offer":[{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"26464","startAmount":"1","endAmount":"1"},{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"7779","startAmount":"1","endAmount":"1"},{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"4770","startAmount":"1","endAmount":"1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"9594","startAmount":"1","endAmount":"1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"2118","startAmount":"1","endAmount":"1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"1753","startAmount":"1","endAmount":"1"}],"consideration":[{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"26464","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"},{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"7779","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"},{"itemType":"2","token":"0x60e4d786628fea6478f785a6d7e704777c86a7c6","identifierOrCriteria":"4770","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"9594","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"2118","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"},{"itemType":"2","token":"0xba30e5f9bb24caa003e9f2f0497ad287fdf95623","identifierOrCriteria":"1753","startAmount":"1","endAmount":"1","recipient":"0xdfdc0b1cf8e9950d6a860af6501c4fecf7825cc1"}],"orderType":"2","startTime":"1681810415","endTime":"1681983215","zoneHash":"0x0000000000000000000000000000000000000000000000000000000000000000","salt":"1550213294656772168494388599483486699884316127427085531712538817979596","conduitKey":"0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000","counter":"0"}}`; + +const TRADE_ORDER_DATA = `{"types":{"ERC721Order":[{"type":"uint8","name":"direction"},{"type":"address","name":"maker"},{"type":"address","name":"taker"},{"type":"uint256","name":"expiry"},{"type":"uint256","name":"nonce"},{"type":"address","name":"erc20Token"},{"type":"uint256","name":"erc20TokenAmount"},{"type":"Fee[]","name":"fees"},{"type":"address","name":"erc721Token"},{"type":"uint256","name":"erc721TokenId"},{"type":"Property[]","name":"erc721TokenProperties"}],"Fee":[{"type":"address","name":"recipient"},{"type":"uint256","name":"amount"},{"type":"bytes","name":"feeData"}],"Property":[{"type":"address","name":"propertyValidator"},{"type":"bytes","name":"propertyData"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"ZeroEx","version":"1.0.0","chainId":"0x1","verifyingContract":"0xdef1c0ded9bec7f1a1670819833240f027b25eff"},"primaryType":"ERC721Order","message":{"direction":"0","maker":"0x8eeee1781fd885ff5ddef7789486676961873d12","taker":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","expiry":"2524604400","nonce":"100131415900000000000000000000000000000083840314483690155566137712510085002484","erc20Token":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","erc20TokenAmount":"42000000000000","fees":[],"erc721Token":"0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e","erc721TokenId":"2516","erc721TokenProperties":[]}}`; + +const getPermitData = (permitType: string, accountAddress: string) => { + switch (permitType) { + case 'Permit': + return PERMIT_DATA.replace('{ownerAddress}', accountAddress); + case 'PermitBatch': + return PERMIT_BATCH_DATA; + case 'PermitSingle': + return PERMIT_SINGLE_DATA; + case 'PermitSeaport': + return SEAPORT_DATA; + case 'TradeOrder': + return TRADE_ORDER_DATA; + default: + throw new Error(`Unknown permit type: ${permitType}`); + } +}; + +const getMessageParams = (accountAddress: string, data: string) => ({ + from: accountAddress, + version: 'v4', + data, + origin: 'https://metamask.github.io', + signatureMethod: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4, +}); + +export const getMetaMaskStateWithUnapprovedPermitSign = ( + accountAddress: string, + permitType: + | 'Permit' + | 'PermitBatch' + | 'PermitSingle' + | 'PermitSeaport' + | 'TradeOrder', +) => { + const data = getPermitData(permitType, accountAddress); + const pendingPermitId = '48a75190-45ca-11ef-9001-f3886ec2397c'; + const pendingPermitTime = new Date().getTime(); + const messageParams = getMessageParams(accountAddress, data); + + const unapprovedTypedMessage = { + id: pendingPermitId, + status: 'unapproved', + time: pendingPermitTime, + type: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA, + securityProviderResponse: null, + msgParams: messageParams, + chainId: CHAIN_IDS.SEPOLIA, + }; + + const pendingApproval = { + id: pendingPermitId, + origin: 'origin', + time: pendingPermitTime, + type: ApprovalType.EthSignTypedData, + requestData: { + ...messageParams, + metamaskId: pendingPermitId, + }, + requestState: null, + expectsResult: false, + }; + + return { + ...mockMetaMaskState, + preferences: { + ...mockMetaMaskState.preferences, + redesignedConfirmationsEnabled: true, + }, + unapprovedTypedMessages: { + [pendingPermitId]: unapprovedTypedMessage, + }, + unapprovedTypedMessagesCount: 1, + pendingApprovals: { + [pendingPermitId]: pendingApproval, + }, + pendingApprovalCount: 1, + }; +}; + +export const verifyDetails = (element: Element, expectedValues: string[]) => { + expectedValues.forEach((value) => { + expect(element).toHaveTextContent(value); + }); +}; 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 6d48461f1c89..5bd747959256 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 @@ -1186,6 +1186,7 @@ exports[`Info renders info section for typed sign request 1`] = `
{ return ( <> {isPermit && useTransactionSimulations && } - + {isPermit && ( <> @@ -84,7 +84,7 @@ const TypedSignInfo: React.FC = () => { )} - +
{ // eslint-disable-next-line @typescript-eslint/no-use-before-define diff --git a/ui/pages/confirmations/components/confirm/row/typed-sign-data-v1/__snapshots__/typedSignDataV1.test.tsx.snap b/ui/pages/confirmations/components/confirm/row/typed-sign-data-v1/__snapshots__/typedSignDataV1.test.tsx.snap index af521e453637..0e8def9c62a0 100644 --- a/ui/pages/confirmations/components/confirm/row/typed-sign-data-v1/__snapshots__/typedSignDataV1.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/row/typed-sign-data-v1/__snapshots__/typedSignDataV1.test.tsx.snap @@ -14,6 +14,7 @@ exports[`ConfirmInfoRowTypedSignData should match snapshot 1`] = ` >
Date: Wed, 27 Nov 2024 19:22:28 +0700 Subject: [PATCH 099/148] test: add e2e for transaction decoding (#28204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds test for checking transaction decoding for contract interaction from 4bytes, sourcify and uniswap Also adds tests to verify when all of the above fails then it falls back to the raw hexdata Adds a data-testId to make e2e easier [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28204?quickstart=1) ## **Related issues** Fixes: [#2877](https://github.com/MetaMask/MetaMask-planning/issues/2877) ## **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. --- .vscode/cspell.json | 1 + .../redesign/transaction-confirmation.ts | 171 ++++++++++++ .../transaction-decoding-redesign.spec.ts | 249 ++++++++++++++++++ .../__snapshots__/approve.test.tsx.snap | 1 + .../transaction-data.test.tsx.snap | 6 + .../transaction-data/transaction-data.tsx | 2 +- 6 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 test/e2e/tests/confirmations/transactions/transaction-decoding-redesign.spec.ts diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 0696498afe86..f962a85ef3ad 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -73,6 +73,7 @@ "shellcheck", "SIWE", "sourcemaps", + "Sourcify", "sprintf", "testcase", "TESTFILES", diff --git a/test/e2e/page-objects/pages/confirmations/redesign/transaction-confirmation.ts b/test/e2e/page-objects/pages/confirmations/redesign/transaction-confirmation.ts index c7f618d3fc61..d2d294c28cd0 100644 --- a/test/e2e/page-objects/pages/confirmations/redesign/transaction-confirmation.ts +++ b/test/e2e/page-objects/pages/confirmations/redesign/transaction-confirmation.ts @@ -1,3 +1,4 @@ +import { strict as assert } from 'assert'; import { tEn } from '../../../../../lib/i18n-helpers'; import { Driver } from '../../../../webdriver/driver'; import { RawLocator } from '../../../common'; @@ -8,6 +9,16 @@ class TransactionConfirmation extends Confirmation { private dappInitiatedHeadingTitle: RawLocator; + private advancedDetailsButton: RawLocator; + + private advancedDetailsSection: RawLocator; + + private advancedDetailsDataFunction: RawLocator; + + private advancedDetailsDataParam: RawLocator; + + private advancedDetailsHexData: RawLocator; + constructor(driver: Driver) { super(driver); @@ -21,6 +32,17 @@ class TransactionConfirmation extends Confirmation { css: 'h3', text: tEn('transferRequest') as string, }; + + this.advancedDetailsButton = `[data-testid="header-advanced-details-button"]`; + + this.advancedDetailsSection = + '[data-testid="advanced-details-data-section"]'; + this.advancedDetailsDataFunction = + '[data-testid="advanced-details-data-function"]'; + this.advancedDetailsDataParam = + '[data-testid="advanced-details-data-param-0"]'; + this.advancedDetailsHexData = + '[data-testid="advanced-details-transaction-hex"]'; } async check_walletInitiatedHeadingTitle() { @@ -30,6 +52,155 @@ class TransactionConfirmation extends Confirmation { async check_dappInitiatedHeadingTitle() { await this.driver.waitForSelector(this.dappInitiatedHeadingTitle); } + + async clickAdvancedDetailsButton() { + await this.driver.clickElement(this.advancedDetailsButton); + } + + async verifyAdvancedDetailsIsDisplayed(type: string) { + const advancedDetailsSection = await this.driver.findElement( + this.advancedDetailsSection, + ); + + await advancedDetailsSection.isDisplayed(); + await advancedDetailsSection + .findElement({ css: this.advancedDetailsDataFunction.toString() }) + .isDisplayed(); + await advancedDetailsSection + .findElement({ css: this.advancedDetailsDataParam.toString() }) + .isDisplayed(); + + const functionInfo = await this.driver.findElement( + this.advancedDetailsDataFunction, + ); + const functionText = await functionInfo.getText(); + + assert.ok( + functionText.includes('Function'), + 'Expected key "Function" to be included in the function text', + ); + assert.ok( + functionText.includes('mintNFTs'), + 'Expected "mintNFTs" to be included in the function text', + ); + + const paramsInfo = await this.driver.findElement( + this.advancedDetailsDataParam, + ); + const paramsText = await paramsInfo.getText(); + + if (type === '4Bytes') { + assert.ok( + paramsText.includes('Param #1'), + 'Expected "Param #1" to be included in the param text', + ); + } else if (type === 'Sourcify') { + assert.ok( + paramsText.includes('Number Of Tokens'), + 'Expected "Number Of Tokens" to be included in the param text', + ); + } + + assert.ok( + paramsText.includes('1'), + 'Expected "1" to be included in the param value', + ); + } + + async verifyAdvancedDetailsHexDataIsDisplayed() { + const advancedDetailsSection = await this.driver.findElement( + this.advancedDetailsSection, + ); + + await advancedDetailsSection.isDisplayed(); + await advancedDetailsSection + .findElement({ css: this.advancedDetailsHexData.toString() }) + .isDisplayed(); + + const hexDataInfo = ( + await this.driver.findElement(this.advancedDetailsHexData) + ).getText(); + + assert.ok( + (await hexDataInfo).includes( + '0x3b4b13810000000000000000000000000000000000000000000000000000000000000001', + ), + 'Expected hex data to be displayed', + ); + } + + async verifyUniswapDecodedTransactionAdvancedDetails() { + const dataSections = await this.driver.findElements( + this.advancedDetailsDataFunction, + ); + + const expectedData = [ + { + functionName: 'WRAP_ETH', + recipient: '0x00000...00002', + amountMin: '100000000000000', + }, + { + functionName: 'V3_SWAP_EXACT_IN', + recipient: '0x00000...00002', + amountIn: '100000000000000', + amountOutMin: '312344', + path0: 'WETH', + path1: '500', + path2: 'USDC', + payerIsUser: 'false', + }, + { + functionName: 'PAY_PORTION', + token: 'USDC', + recipient: '0x27213...71c47', + bips: '25', + }, + { + functionName: 'SWEEP', + token: 'USDC', + recipient: '0x00000...00001', + amountMin: '312344', + }, + ]; + + assert.strictEqual( + dataSections.length, + expectedData.length, + 'Mismatch between data sections and expected data count.', + ); + + await Promise.all( + dataSections.map(async (dataSection, sectionIndex) => { + await dataSection.isDisplayed(); + + const data = expectedData[sectionIndex]; + + const functionText = await dataSection.getText(); + assert.ok( + functionText.includes(data.functionName), + `Expected function name '${data.functionName}' in advanced details.`, + ); + + const params = `[data-testid="advanced-details-${functionText}-params"]`; + + const paramsData = await this.driver.findElement(params); + const paramText = await paramsData.getText(); + + for (const [key, expectedValue] of Object.entries(data)) { + if (key === 'functionName') { + continue; + } + assert.ok( + paramText.includes(expectedValue), + `Expected ${key} '${expectedValue}' in data section ${functionText}.`, + ); + + this.clickScrollToBottomButton(); + } + }), + ); + } } export default TransactionConfirmation; diff --git a/test/e2e/tests/confirmations/transactions/transaction-decoding-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/transaction-decoding-redesign.spec.ts new file mode 100644 index 000000000000..9027355ed192 --- /dev/null +++ b/test/e2e/tests/confirmations/transactions/transaction-decoding-redesign.spec.ts @@ -0,0 +1,249 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +import { MockttpServer } from 'mockttp'; +import { + createDappTransaction, + DAPP_URL, + unlockWallet, + WINDOW_TITLES, +} from '../../../helpers'; +import TestDapp from '../../../page-objects/pages/test-dapp'; +import { TRANSACTION_DATA_UNISWAP } from '../../../../data/confirmations/transaction-decode'; +import { Ganache } from '../../../seeder/ganache'; +import TransactionConfirmation from '../../../page-objects/pages/confirmations/redesign/transaction-confirmation'; +import ContractAddressRegistry from '../../../seeder/contract-address-registry'; +import { TestSuiteArguments } from './shared'; + +const { defaultGanacheOptions, withFixtures } = require('../../../helpers'); +const FixtureBuilder = require('../../../fixture-builder'); +const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); + +describe('Confirmation Redesign Contract Interaction Transaction Decoding', function () { + const smartContract = SMART_CONTRACTS.NFTS; + + describe('Create a mint nft transaction @no-mmi', function () { + it(`decodes 4 bytes transaction data`, async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptions, + testSpecificMock: mocked4BytesResponse, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, contractRegistry }: TestSuiteArguments) => { + await unlockWallet(driver); + const contractAddress = await ( + contractRegistry as ContractAddressRegistry + ).getContractAddress(smartContract); + + const testDapp = new TestDapp(driver); + const confirmation = new TransactionConfirmation(driver); + + await testDapp.openTestDappPage({ contractAddress, url: DAPP_URL }); + + await testDapp.clickERC721MintButton(); + + await driver.waitAndSwitchToWindowWithTitle(3, WINDOW_TITLES.Dialog); + + await confirmation.clickAdvancedDetailsButton(); + await confirmation.clickScrollToBottomButton(); + await confirmation.verifyAdvancedDetailsIsDisplayed('4Bytes'); + }, + ); + }); + }); + + it(`decodes Sourcify transaction data`, async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptions, + testSpecificMock: mockedSourcifyResponse, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, contractRegistry }: TestSuiteArguments) => { + await unlockWallet(driver); + const contractAddress = await ( + contractRegistry as ContractAddressRegistry + ).getContractAddress(smartContract); + + const testDapp = new TestDapp(driver); + const confirmation = new TransactionConfirmation(driver); + + await testDapp.openTestDappPage({ contractAddress, url: DAPP_URL }); + + await testDapp.clickERC721MintButton(); + await driver.waitAndSwitchToWindowWithTitle(3, WINDOW_TITLES.Dialog); + + await confirmation.clickAdvancedDetailsButton(); + await confirmation.clickScrollToBottomButton(); + await confirmation.verifyAdvancedDetailsIsDisplayed('Sourcify'); + }, + ); + }); + + it(`falls back to raw hexadecimal when no data is retreived`, async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, contractRegistry }: TestSuiteArguments) => { + await unlockWallet(driver); + const contractAddress = await ( + contractRegistry as ContractAddressRegistry + ).getContractAddress(smartContract); + + const testDapp = new TestDapp(driver); + const confirmation = new TransactionConfirmation(driver); + + await testDapp.openTestDappPage({ contractAddress, url: DAPP_URL }); + + await testDapp.clickERC721MintButton(); + await driver.waitAndSwitchToWindowWithTitle(3, WINDOW_TITLES.Dialog); + + await confirmation.clickAdvancedDetailsButton(); + await confirmation.clickScrollToBottomButton(); + await confirmation.verifyAdvancedDetailsHexDataIsDisplayed(); + }, + ); + }); + + it(`decodes uniswap transaction data`, async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptions, + testSpecificMock: mockInfura, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer }: TestSuiteArguments) => { + const addresses = await (ganacheServer as Ganache).getAccounts(); + const publicAddress = addresses?.[0] as string; + + await unlockWallet(driver); + const contractAddress = '0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B'; + + const confirmation = new TransactionConfirmation(driver); + + await createDappTransaction(driver, { + data: TRANSACTION_DATA_UNISWAP, + to: contractAddress, + from: publicAddress, + }); + + await driver.waitAndSwitchToWindowWithTitle(3, WINDOW_TITLES.Dialog); + + await confirmation.clickAdvancedDetailsButton(); + await confirmation.clickScrollToBottomButton(); + await confirmation.verifyUniswapDecodedTransactionAdvancedDetails(); + }, + ); + }); +}); + +async function mocked4BytesResponse(mockServer: MockttpServer) { + return await mockServer + .forGet('https://www.4byte.directory/api/v1/signatures/') + .always() + .withQuery({ hex_signature: '0x3b4b1381' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 1, + created_at: '2021-09-14T02:07:09.805000Z', + text_signature: 'mintNFTs(uint256)', + hex_signature: '0x3b4b1381', + bytes_signature: ';K\u0013', + }, + ], + }, + })); +} + +export const SOURCIFY_RESPONSE = { + files: [ + { + name: 'metadata.json', + path: 'contracts/partial_match/11155111/0x076146c765189d51bE3160A2140cF80BFC73ad68/metadata.json', + content: + '{"compiler":{"version":"0.8.18+commit.87f61d96"},"language":"Solidity","output":{"abi":[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"approved","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"approve","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentTokenId","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"numberOfTokens","type":"uint256"}],"name":"mintNFTs","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"bool","name":"approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"tokenURI","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"}],"devdoc":{"events":{"Approval(address,address,uint256)":{"details":"Emitted when `owner` enables `approved` to manage the `tokenId` token."},"ApprovalForAll(address,address,bool)":{"details":"Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets."},"Transfer(address,address,uint256)":{"details":"Emitted when `tokenId` token is transferred from `from` to `to`."}},"kind":"dev","methods":{"approve(address,uint256)":{"details":"See {IERC721-approve}."},"balanceOf(address)":{"details":"See {IERC721-balanceOf}."},"getApproved(uint256)":{"details":"See {IERC721-getApproved}."},"isApprovedForAll(address,address)":{"details":"See {IERC721-isApprovedForAll}."},"name()":{"details":"See {IERC721Metadata-name}."},"ownerOf(uint256)":{"details":"See {IERC721-ownerOf}."},"safeTransferFrom(address,address,uint256)":{"details":"See {IERC721-safeTransferFrom}."},"safeTransferFrom(address,address,uint256,bytes)":{"details":"See {IERC721-safeTransferFrom}."},"setApprovalForAll(address,bool)":{"details":"See {IERC721-setApprovalForAll}."},"supportsInterface(bytes4)":{"details":"See {IERC165-supportsInterface}."},"symbol()":{"details":"See {IERC721Metadata-symbol}."},"tokenURI(uint256)":{"details":"See {IERC721Metadata-tokenURI}."},"transferFrom(address,address,uint256)":{"details":"See {IERC721-transferFrom}."}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"compilationTarget":{"contracts/TestDappCollectibles.sol":"TestDappNFTs"},"evmVersion":"paris","libraries":{},"metadata":{"bytecodeHash":"ipfs"},"optimizer":{"enabled":false,"runs":200},"remappings":[]},"sources":{"@openzeppelin/contracts/token/ERC721/ERC721.sol":{"keccak256":"0x2c309e7df9e05e6ce15bedfe74f3c61b467fc37e0fae9eab496acf5ea0bbd7ff","license":"MIT","urls":["bzz-raw://7063b5c98711a98018ba4635ac74cee1c1cfa2ea01099498e062699ed9530005","dweb:/ipfs/QmeJ8rGXkcv7RrqLdAW8PCXPAykxVsddfYY6g5NaTwmRFE"]},"@openzeppelin/contracts/token/ERC721/IERC721.sol":{"keccak256":"0x5bce51e11f7d194b79ea59fe00c9e8de9fa2c5530124960f29a24d4c740a3266","license":"MIT","urls":["bzz-raw://7e66dfde185df46104c11bc89d08fa0760737aa59a2b8546a656473d810a8ea4","dweb:/ipfs/QmXvyqtXPaPss2PD7eqPoSao5Szm2n6UMoiG8TZZDjmChR"]},"@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol":{"keccak256":"0xa82b58eca1ee256be466e536706850163d2ec7821945abd6b4778cfb3bee37da","license":"MIT","urls":["bzz-raw://6e75cf83beb757b8855791088546b8337e9d4684e169400c20d44a515353b708","dweb:/ipfs/QmYvPafLfoquiDMEj7CKHtvbgHu7TJNPSVPSCjrtjV8HjV"]},"@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol":{"keccak256":"0x75b829ff2f26c14355d1cba20e16fe7b29ca58eb5fef665ede48bc0f9c6c74b9","license":"MIT","urls":["bzz-raw://a0a107160525724f9e1bbbab031defc2f298296dd9e331f16a6f7130cec32146","dweb:/ipfs/QmemujxSd7gX8A9M8UwmNbz4Ms3U9FG9QfudUgxwvTmPWf"]},"@openzeppelin/contracts/utils/Address.sol":{"keccak256":"0x006dd67219697fe68d7fbfdea512e7c4cb64a43565ed86171d67e844982da6fa","license":"MIT","urls":["bzz-raw://2455248c8ddd9cc6a7af76a13973cddf222072427e7b0e2a7d1aff345145e931","dweb:/ipfs/QmfYjnjRbWqYpuxurqveE6HtzsY1Xx323J428AKQgtBJZm"]},"@openzeppelin/contracts/utils/Context.sol":{"keccak256":"0xe2e337e6dde9ef6b680e07338c493ebea1b5fd09b43424112868e9cc1706bca7","license":"MIT","urls":["bzz-raw://6df0ddf21ce9f58271bdfaa85cde98b200ef242a05a3f85c2bc10a8294800a92","dweb:/ipfs/QmRK2Y5Yc6BK7tGKkgsgn3aJEQGi5aakeSPZvS65PV8Xp3"]},"@openzeppelin/contracts/utils/Counters.sol":{"keccak256":"0xf0018c2440fbe238dd3a8732fa8e17a0f9dce84d31451dc8a32f6d62b349c9f1","license":"MIT","urls":["bzz-raw://59e1c62884d55b70f3ae5432b44bb3166ad71ae3acd19c57ab6ddc3c87c325ee","dweb:/ipfs/QmezuXg5GK5oeA4F91EZhozBFekhq5TD966bHPH18cCqhu"]},"@openzeppelin/contracts/utils/Strings.sol":{"keccak256":"0x3088eb2868e8d13d89d16670b5f8612c4ab9ff8956272837d8e90106c59c14a0","license":"MIT","urls":["bzz-raw://b81d9ff6559ea5c47fc573e17ece6d9ba5d6839e213e6ebc3b4c5c8fe4199d7f","dweb:/ipfs/QmPCW1bFisUzJkyjroY3yipwfism9RRCigCcK1hbXtVM8n"]},"@openzeppelin/contracts/utils/introspection/ERC165.sol":{"keccak256":"0xd10975de010d89fd1c78dc5e8a9a7e7f496198085c151648f20cba166b32582b","license":"MIT","urls":["bzz-raw://fb0048dee081f6fffa5f74afc3fb328483c2a30504e94a0ddd2a5114d731ec4d","dweb:/ipfs/QmZptt1nmYoA5SgjwnSgWqgUSDgm4q52Yos3xhnMv3MV43"]},"@openzeppelin/contracts/utils/introspection/IERC165.sol":{"keccak256":"0x447a5f3ddc18419d41ff92b3773fb86471b1db25773e07f877f548918a185bf1","license":"MIT","urls":["bzz-raw://be161e54f24e5c6fae81a12db1a8ae87bc5ae1b0ddc805d82a1440a68455088f","dweb:/ipfs/QmP7C3CHdY9urF4dEMb9wmsp1wMxHF6nhA2yQE5SKiPAdy"]},"@openzeppelin/contracts/utils/math/Math.sol":{"keccak256":"0xe4455ac1eb7fc497bb7402579e7b4d64d928b846fce7d2b6fde06d366f21c2b3","license":"MIT","urls":["bzz-raw://cc8841b3cd48ad125e2f46323c8bad3aa0e88e399ec62acb9e57efa7e7c8058c","dweb:/ipfs/QmSqE4mXHA2BXW58deDbXE8MTcsL5JSKNDbm23sVQxRLPS"]},"@openzeppelin/contracts/utils/math/SignedMath.sol":{"keccak256":"0xf92515413956f529d95977adc9b0567d583c6203fc31ab1c23824c35187e3ddc","license":"MIT","urls":["bzz-raw://c50fcc459e49a9858b6d8ad5f911295cb7c9ab57567845a250bf0153f84a95c7","dweb:/ipfs/QmcEW85JRzvDkQggxiBBLVAasXWdkhEysqypj9EaB6H2g6"]},"base64-sol/base64.sol":{"keccak256":"0xa73959e6ef0b693e4423a562e612370160b934a75e618361ddd8c9c4b8ddbaaf","license":"MIT","urls":["bzz-raw://17c12e16d8d66f3af15d8787920bd41ca6c1e7517a212a6b9cebd4b6d38f36fe","dweb:/ipfs/QmcXMnZUXEz6LRKsm3CSvqdPboAzmezavi8bTg2dRxM2yE"]},"contracts/TestDappCollectibles.sol":{"keccak256":"0x3d2fa0d37970b903e628d8a7b101f8f73513d41d917fbdfac2749d9d1214176c","license":"MIT","urls":["bzz-raw://e01f3a25371accdfbbc2e3c9aeceabf92c5158f318a5e3f5b6c2b6ea3edb0c3b","dweb:/ipfs/QmT89aSnmzTdpRpoCoqCmnrR3Lp7CyXFtpnn2P52YEJULD"]}},"version":1}', + }, + ], +}; + +async function mockedSourcifyResponse(mockServer: MockttpServer) { + return await mockServer + .forGet('https://sourcify.dev/server/files/any/1337/0x') + .always() + .thenCallback(() => ({ + statusCode: 200, + json: SOURCIFY_RESPONSE, + })); +} + +async function mockInfura(mockServer: MockttpServer) { + return await mockServer + .forPost() + .always() + .withJsonBodyIncluding({ + method: 'eth_getCode', + params: ['0xef1c6e67703c7bd7107eed8303fbe6ec2554bf6b'], + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1', + result: + '0x6080604052600436101561001b575b361561001957600080fd5b005b6000803560e01c90816301ffc9a7146100be57508063150b7a02146100b557806324856bc3146100ac5780633593564c146100a3578063709a1cc21461009a578063bc197c8114610091578063f23a6e61146100885763fa461e330361000e576100836109f2565b61000e565b50610083610960565b50610083610898565b5061008361061d565b50610083610473565b506100836102c5565b50610083610202565b346101ae5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101ae57600435907fffffffff0000000000000000000000000000000000000000000000000000000082168092036101ae57507f4e2312e0000000000000000000000000000000000000000000000000000000008114908115610184575b811561015a575b50151560805260206080f35b7f01ffc9a7000000000000000000000000000000000000000000000000000000009150148161014e565b7f150b7a020000000000000000000000000000000000000000000000000000000081149150610147565b80fd5b73ffffffffffffffffffffffffffffffffffffffff8116036101cf57565b600080fd5b9181601f840112156101cf5782359167ffffffffffffffff83116101cf57602083818601950101116101cf57565b50346101cf5760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf5761023d6004356101b1565b6102486024356101b1565b60643567ffffffffffffffff81116101cf576102689036906004016101d4565b505060206040517f150b7a02000000000000000000000000000000000000000000000000000000008152f35b9181601f840112156101cf5782359167ffffffffffffffff83116101cf576020808501948460051b0101116101cf57565b506040807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf5767ffffffffffffffff600480358281116101cf5761031290369083016101d4565b90926024359081116101cf5761032b9036908401610294565b9490916001958680540361044b57600287558181036104235760005b8281106103575761001960018055565b61038b61036582858a610bde565b357fff000000000000000000000000000000000000000000000000000000000000001690565b6103a96103a361039c84868a610bf6565b3691610daf565b82611590565b91901590816103f8575b506103c057508701610347565b6103f4879186519384937f2c4029e90000000000000000000000000000000000000000000000000000000085528401610e4c565b0390fd5b7f800000000000000000000000000000000000000000000000000000000000000091501615386103b3565b8483517fff633a38000000000000000000000000000000000000000000000000000000008152fd5b8483517f6f5ffb7e000000000000000000000000000000000000000000000000000000008152fd5b5060607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf57600467ffffffffffffffff81358181116101cf576104bf90369084016101d4565b9290916024359081116101cf576104d99036908301610294565b9360443542116105f457600194858054036105cb57600286558181036105a25760005b82811061050c5761001960018055565b61051a610365828589610bde565b61052b6103a361039c848689610bf6565b9190159081610577575b50610542575086016104fc565b6103f486916040519384937f2c4029e90000000000000000000000000000000000000000000000000000000085528401610e4c565b7f80000000000000000000000000000000000000000000000000000000000000009150161538610535565b836040517fff633a38000000000000000000000000000000000000000000000000000000008152fd5b836040517f6f5ffb7e000000000000000000000000000000000000000000000000000000008152fd5b826040517f5bf6f916000000000000000000000000000000000000000000000000000000008152fd5b50346101cf576020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf5760043567ffffffffffffffff81116101cf5761066e9036906004016101d4565b604092919251928380610686600096879586956124c1565b0390827f0000000000000000000000000554f068365ed43dcc98dcd7fd7a8208a5638c725af16106b4610f1d565b501561086e576040517f70a082310000000000000000000000000000000000000000000000000000000081523060048201527f1e8f03f716bc104bf7d728131967a0c771e85ab54d09c1e2d6ed9e0bc4e2a16c916107f1919073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000f4d2888d29d722226fafa5d9b24f9164c092421e168183602481845afa928315610861575b8693610832575b506040517fa9059cbb0000000000000000000000000000000000000000000000000000000081527f000000000000000000000000ea37093ce161f090e443f304e1bf3a8f14d7bb4073ffffffffffffffffffffffffffffffffffffffff16600482015260248101849052908290829060449082908a905af18015610825575b6107f7575b50506040519081529081906020820190565b0390a180f35b8161081692903d1061081e575b61080e8183610d25565b8101906124cf565b5038806107df565b503d610804565b61082d610f89565b6107da565b610853919350823d841161085a575b61084b8183610d25565b810190610f7a565b913861075b565b503d610841565b610869610f89565b610754565b60046040517f7d529919000000000000000000000000000000000000000000000000000000008152fd5b50346101cf5760a07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf576108d36004356101b1565b6108de6024356101b1565b67ffffffffffffffff6044358181116101cf576108ff903690600401610294565b50506064358181116101cf57610919903690600401610294565b50506084359081116101cf576109339036906004016101d4565b50506040517fbc197c81000000000000000000000000000000000000000000000000000000008152602090f35b50346101cf5760a07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf5761099b6004356101b1565b6109a66024356101b1565b60843567ffffffffffffffff81116101cf576109c69036906004016101d4565b505060206040517ff23a6e61000000000000000000000000000000000000000000000000000000008152f35b50346101cf5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101cf5760243560043560443567ffffffffffffffff81116101cf57610a489036906004016101d4565b919060009384831393841580610ba4575b610b7a5782610a6d91610a9a940190613b49565b73ffffffffffffffffffffffffffffffffffffffff80911692610a8f83613bcb565b818398929a93614167565b8333911603610b505715610b425750808616908416105b15610ac65750610ac3935033916131e0565b80f35b915091604282511015600014610b0157610afb9350610ae482613c58565b610af6610af133926143d8565b613b90565b614014565b50505080f35b9192905083548211610b1857610ac39233916131e0565b60046040517f739dbe52000000000000000000000000000000000000000000000000000000008152fd5b945080841690861610610ab1565b60046040517f32b13d91000000000000000000000000000000000000000000000000000000008152fd5b60046040517f316cf0eb000000000000000000000000000000000000000000000000000000008152fd5b5085821315610a59565b507f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b90821015610bea570190565b610bf2610bae565b0190565b9190811015610c57575b60051b810135907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1813603018212156101cf57019081359167ffffffffffffffff83116101cf5760200182360381136101cf579190565b610c5f610bae565b610c00565b507f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6080810190811067ffffffffffffffff821117610cb057604052565b610cb8610c64565b604052565b6060810190811067ffffffffffffffff821117610cb057604052565b67ffffffffffffffff8111610cb057604052565b6020810190811067ffffffffffffffff821117610cb057604052565b6040810190811067ffffffffffffffff821117610cb057604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117610cb057604052565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f60209267ffffffffffffffff8111610da2575b01160190565b610daa610c64565b610d9c565b929192610dbb82610d66565b91610dc96040519384610d25565b8294818452818301116101cf578281602093846000960137010152565b60005b838110610df95750506000910152565b8181015183820152602001610de9565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602093610e4581518092818752878088019101610de6565b0116010190565b604090610e63939281528160208201520190610e09565b90565b91908260809103126101cf578151610e7d816101b1565b916020810151610e8c816101b1565b916060604083015192015190565b81601f820112156101cf578051610eb081610d66565b92610ebe6040519485610d25565b818452602082840101116101cf57610e639160208085019101610de6565b9190916040818403126101cf57805192602082015167ffffffffffffffff81116101cf57610e639201610e9a565b60405190610f1782610ced565b60008252565b3d15610f48573d90610f2e82610d66565b91610f3c6040519384610d25565b82523d6000602084013e565b606090565b908160609103126101cf578051610f63816101b1565b9160406020830151610f74816101b1565b92015190565b908160209103126101cf575190565b506040513d6000823e3d90fd5b5190610fa1826101b1565b565b908160209103126101cf5751610e63816101b1565b908160609103126101cf5780519160406020830151610f74816101b1565b60405190610fe382610d09565b601782527f43727970746f50756e6b205472616465204661696c65640000000000000000006020830152565b60009103126101cf57565b60209067ffffffffffffffff8111611034575b60051b0190565b61103c610c64565b61102d565b9060209182818303126101cf5780519067ffffffffffffffff82116101cf570181601f820112156101cf578051926110788461101a565b9360409361108885519687610d25565b818652828087019260071b850101938185116101cf578301915b8483106110b25750505050505090565b6080838303126101cf578360809187516110cb81610c94565b85516110d6816101b1565b8152828601516110e5816101b1565b83820152888601516110f6816101b1565b898201526060808701519061110a826101b1565b8201528152019201916110a2565b91908260409103126101cf5760208251610f74816101b1565b519065ffffffffffff821682036101cf57565b91908260809103126101cf5760405161115c81610c94565b606061119b818395805161116f816101b1565b8552602081015161117f816101b1565b602086015261119060408201611131565b604086015201611131565b910152565b91909180830360e081126101cf5760c0136101cf576040516111c181610cbd565b6111cb8483611144565b815260808201516111db816101b1565b602082015260a082015160408201529260c082015167ffffffffffffffff81116101cf57610e639201610e9a565b90610e63939260409173ffffffffffffffffffffffffffffffffffffffff809116845261127b60208501835160609073ffffffffffffffffffffffffffffffffffffffff80825116845260208201511660208401528165ffffffffffff91826040820151166040860152015116910152565b60208201511660a0840152015160c0820152610100908160e08201520190610e09565b519081151582036101cf57565b9160a0838303126101cf5782516112c1816101b1565b926020918282015193604083015193606084015167ffffffffffffffff81116101cf5784019180601f840112156101cf5782516112fd8161101a565b9361130b6040519586610d25565b818552838086019260051b8201019283116101cf578301905b82821061133c57505050506080610e6391930161129e565b838091835161134a816101b1565b815201910190611324565b9190916040818403126101cf5780519267ffffffffffffffff938481116101cf578201936060858303126101cf5760405161138f81610cbd565b85518281116101cf5786019583601f880112156101cf578651966113b28861101a565b906113c06040519283610d25565b888252602098898084019160071b830101918783116101cf578a809101915b83831061141b57505050509060409183526113fb888201610f96565b8884015201516040820152948301519081116101cf57610e639201610e9a565b906080916114298a85611144565b8152019101908a906113df565b939290919373ffffffffffffffffffffffffffffffffffffffff809316815260209460608683015260c082019381519460608085015285518091528760e0850196019060005b8181106114ac5750505090604091610e639697820151166080840152015160a08201526040818403910152610e09565b909196896080826115016001948c5160609073ffffffffffffffffffffffffffffffffffffffff80825116845260208201511660208401528165ffffffffffff91826040820151166040860152015116910152565b01980192910161147c565b908160609103126101cf578051611522816101b1565b9160406020830151611533816101b1565b920151610e63816101b1565b919060a0838203126101cf578251611556816101b1565b9260208101519260408201519260608301519067ffffffffffffffff82116101cf57611589608091610e63938601610e9a565b930161129e565b600192606092909160f81c601f166010811015611b085760088110156118aa578061161957506115cc81602080610fa19451830101910161153f565b909290156115fa576115f573ffffffffffffffffffffffffffffffffffffffff33955b166124e3565b613d69565b6115f573ffffffffffffffffffffffffffffffffffffffff30956115ef565b60018103611684575061163881602080610fa19451830101910161153f565b909290156116655761166073ffffffffffffffffffffffffffffffffffffffff3395166124e3565b613efb565b61166073ffffffffffffffffffffffffffffffffffffffff30956115ef565b600281036116c557506116a381602080610fa19451830101910161150c565b9173ffffffffffffffffffffffffffffffffffffffff80921691339116612ce5565b600381036117925750806020806116e193518301019101611355565b9073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba31691823b156101cf5761175f92600092836040518096819582947f2a2d80d10000000000000000000000000000000000000000000000000000000084523360048501611436565b03925af18015611785575b611772575b50565b8061177f610fa192610cd9565b8061100f565b61178d610f89565b61176a565b600481036117da57506117b181602080610fa194518301019101610f4d565b91906117d373ffffffffffffffffffffffffffffffffffffffff8092166124e3565b911661276b565b6005810361182257506117f981602080610fa194518301019101610f4d565b919061181b73ffffffffffffffffffffffffffffffffffffffff8092166124e3565b9116612514565b6006810361186a575061184181602080610fa194518301019101610f4d565b919061186373ffffffffffffffffffffffffffffffffffffffff8092166124e3565b9116612663565b9050600781146118775750565b6040517fd76a1e9e0000000000000000000000000000000000000000000000000000000081526004810191909152602490fd5b6008810361191557506118c981602080610fa1945183010191016112ab565b909290156118f6576118f173ffffffffffffffffffffffffffffffffffffffff3395166124e3565b612fbe565b6118f173ffffffffffffffffffffffffffffffffffffffff30956115ef565b60098103611980575061193481602080610fa1945183010191016112ab565b909290156119615761195c73ffffffffffffffffffffffffffffffffffffffff3395166124e3565b61387e565b61195c73ffffffffffffffffffffffffffffffffffffffff30956115ef565b600a8103611a1a57508060208061199c935183010191016111a0565b9073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba31691823b156101cf5761175f92600092836040518096819582947f2b67b5700000000000000000000000000000000000000000000000000000000084523360048501611209565b600b8103611a5d575073ffffffffffffffffffffffffffffffffffffffff611a58611a5183602080610fa196518301019101611118565b92166124e3565b612a14565b600c8103611a99575073ffffffffffffffffffffffffffffffffffffffff611a94611a5183602080610fa196518301019101611118565b612baf565b600d8103611abd5750611ab881602080610fa194518301019101611041565b612e78565b92919050600e8303611afb576040517fd76a1e9e00000000000000000000000000000000000000000000000000000000815260048101849052602490fd5b9091600f81146118775750565b919290916018811015611ffd5760108103611b6f5750506000919250611b38816020808594518301019101610edc565b90602082519201907f00000000000000000000000000000000006c3852cbef3e08e8df289169ede5815af1611b6b610f1d565b9091565b60118103611ba6575050611b6b9192507f00000000000000000000000059728544b08ab483533076417fbbb2fd0b17ce3a906121e6565b60128103611bfc5750506000919250611bc9816020808594518301019101610edc565b90602082519201907f0000000000000000000000000fc584529a2aefa997697fafacba5831fac0c22d5af1611b6b610f1d565b60138103611d61575050611c1b91925060208082518301019101610fb8565b9290927f000000000000000000000000b47e3cd837ddf8e4c57f05d70ab865de6e193bbb9260405160208101907f8264fe98000000000000000000000000000000000000000000000000000000008252611cad81611c8185602483019190602083019252565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282610d25565b600093849283925191885af194611cc2610f1d565b948615611d5357611cec9073ffffffffffffffffffffffffffffffffffffffff80911692166124e3565b813b15611d4f576040517f8b72a2ec00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff9091166004820152602481019290925290919082908183816044810161175f565b8380fd5b505050509050610e63610fd6565b60148103611d98575050611b6b9192507f00000000000000000000000059728544b08ab483533076417fbbb2fd0b17ce3a90612386565b9092919060158103611eae5750611dc19350611dfc906020948186808094518301019101610f4d565b604093919351809581927f6352211e000000000000000000000000000000000000000000000000000000008352600483019190602083019252565b038173ffffffffffffffffffffffffffffffffffffffff8096165afa928315611ea1575b600093611e70575b508116911614928315611e385750565b9091507f7dbe7e89000000000000000000000000000000000000000000000000000000006040519182015260048152610e6381610d09565b82919350611e9390873d8911611e9a575b611e8b8183610d25565b810190610fa3565b9290611e28565b503d611e81565b611ea9610f89565b611e20565b60168103611fb45750611ed29350611f306020948286808095518301019101610e66565b6040517efdd58e00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff94851660048201526024810192909252949093909284929183919082906044820190565b0392165afa908115611fa7575b600091611f8a575b501092831593611f525750565b9091507f483a6929000000000000000000000000000000000000000000000000000000006040519182015260048152610e6381610d09565b611fa19150853d871161085a5761084b8183610d25565b38611f45565b611faf610f89565b611f3d565b601714611fbe5750565b611fd481602080610fa194518301019101610f4d565b9190611ff673ffffffffffffffffffffffffffffffffffffffff8092166124e3565b9116612888565b60188103612034575050611b6b9192507f00000000000000000000000074312363e45dcaba76c59ec49a7aa8a65a67eed3906121e6565b6019810361208a5750506000919250612057816020808594518301019101610edc565b90602082519201907f0000000000000000000000002b2e8cda09bba9660dca5cb6233787738ad683295af1611b6b610f1d565b601a81036120e057505060009192506120ad816020808594518301019101610edc565b90602082519201907f000000000000000000000000a42f6cada809bcf417deefbdd69c5c5a909249c05af1611b6b610f1d565b601b8103612117575050611b6b9192507f00000000000000000000000074312363e45dcaba76c59ec49a7aa8a65a67eed390612386565b601c810361214e575050611b6b9192507f000000000000000000000000cda72070e455bb31c7690a170224ce43623d0b6f906121e6565b90929190601d810361219b575061217181602080610fa194518301019101610e66565b92909161219473ffffffffffffffffffffffffffffffffffffffff8092166124e3565b9116612928565b92919050601e83036121d9576040517fd76a1e9e00000000000000000000000000000000000000000000000000000000815260048101849052602490fd5b9091601f81146118775750565b9091815182019260a0838503126101cf57602083015193604084015167ffffffffffffffff81116101cf57602080612222930191860101610e9a565b90606084015194612232866101b1565b60a0608086015195612243876101b1565b01519173ffffffffffffffffffffffffffffffffffffffff8096169160009485928392602083519301915af195612278610f1d565b9587612286575b5050505050565b61229091166124e3565b813b15611d4f576040517f42842e0e00000000000000000000000000000000000000000000000000000000815230600482015273ffffffffffffffffffffffffffffffffffffffff91909116602482015260448101929092529091908290606490829084905af1801561231f575b61230c575b8080808061227f565b8061177f61231992610cd9565b38612303565b612327610f89565b6122fe565b60405161233881610ced565b60008152906000368137565b9192610e6395949160a09473ffffffffffffffffffffffffffffffffffffffff8092168552166020840152604083015260608201528160808201520190610e09565b9091815182019160c0818403126101cf57602081015192604082015167ffffffffffffffff81116101cf576020806123c2930191840101610e9a565b6060820151946123d1866101b1565b6080830151946123e0866101b1565b60c060a08501519401519173ffffffffffffffffffffffffffffffffffffffff8097169160009485928392602083519301915af19661241d610f1d565b968861242c575b505050505050565b61243691166124e3565b9361243f61232c565b94823b156124bd578490612483604051978896879586947ff242432a0000000000000000000000000000000000000000000000000000000086523060048701612344565b03925af180156124b0575b61249d575b8080808080612424565b8061177f6124aa92610cd9565b38612493565b6124b8610f89565b61248e565b8480fd5b908092918237016000815290565b908160209103126101cf57610e639061129e565b73ffffffffffffffffffffffffffffffffffffffff8116600181036125085750503390565b600203610e6357503090565b73ffffffffffffffffffffffffffffffffffffffff1691908261253b57610fa192506142b9565b610fa1927f800000000000000000000000000000000000000000000000000000000000000083036143275791506040517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152602081602481865afa9081156125d2575b6000916125b4575b5091614327565b6125cc915060203d811161085a5761084b8183610d25565b386125ad565b6125da610f89565b6125a5565b507f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b8181029291811591840414171561262257565b610fa16125df565b8115612634570490565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b82158015612760575b6127365773ffffffffffffffffffffffffffffffffffffffff16806126aa57506126a461269c610fa1934761260f565b612710900490565b906142b9565b6040517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152610fa1939192916127059161269c9190602081602481895afa908115612729575b60009161270b575b5061260f565b91614327565b612723915060203d811161085a5761084b8183610d25565b386126ff565b612731610f89565b6126f7565b60046040517fdeaa01e6000000000000000000000000000000000000000000000000000000008152fd5b50612710831161266c565b90919073ffffffffffffffffffffffffffffffffffffffff16806127ce5750479081106127a4578061279b575050565b610fa1916142b9565b60046040517f6a12f104000000000000000000000000000000000000000000000000000000008152fd5b6040517f70a0823100000000000000000000000000000000000000000000000000000000815230600482015290929091602083602481875afa92831561287b575b60009361285b575b508210612831578161282857505050565b610fa192614327565b60046040517f675cae38000000000000000000000000000000000000000000000000000000008152fd5b61287491935060203d811161085a5761084b8183610d25565b9138612817565b612883610f89565b61280f565b73ffffffffffffffffffffffffffffffffffffffff1691823b156101cf576040517f42842e0e00000000000000000000000000000000000000000000000000000000815230600482015273ffffffffffffffffffffffffffffffffffffffff9290921660248301526044820152906000908290606490829084905af1801561291b575b6129125750565b610fa190610cd9565b612923610f89565b61290b565b6040517efdd58e00000000000000000000000000000000000000000000000000000000815230600482015260248101849052929391929173ffffffffffffffffffffffffffffffffffffffff9190911690602083604481855afa928315612a07575b6000936129e7575b508210612831576129a1610f0a565b93813b156101cf576000809461175f604051978896879586947ff242432a0000000000000000000000000000000000000000000000000000000086523060048701612344565b612a0091935060203d811161085a5761084b8183610d25565b9138612992565b612a0f610f89565b61298a565b907f80000000000000000000000000000000000000000000000000000000000000008103612b7c575047905b81612a49575050565b73ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc21691823b156101cf57612b26926020926040517fd0e30db000000000000000000000000000000000000000000000000000000000815260008160048187875af18015612b6f575b612b5c575b5060006040518096819582947fa9059cbb000000000000000000000000000000000000000000000000000000008452600484016020909392919373ffffffffffffffffffffffffffffffffffffffff60408201951681520152565b03925af18015612b4f575b612b385750565b61176f9060203d811161081e5761080e8183610d25565b612b57610f89565b612b31565b8061177f612b6992610cd9565b38612acb565b612b77610f89565b612ac6565b9047821115612a405760046040517f6a12f104000000000000000000000000000000000000000000000000000000008152fd5b6040517f70a0823100000000000000000000000000000000000000000000000000000000815230600482015273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc21692909190602083602481875afa928315612cd8575b600093612cb8575b5082106127a45781612c4057505050565b823b156101cf576040517f2e1a7d4d00000000000000000000000000000000000000000000000000000000815260048101839052610fa1936000908290602490829084905af18015612cab575b612c98575b506142b9565b8061177f612ca592610cd9565b38612c92565b612cb3610f89565b612c8d565b612cd191935060203d811161085a5761084b8183610d25565b9138612c2f565b612ce0610f89565b612c27565b919273ffffffffffffffffffffffffffffffffffffffff91827f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba31693843b156101cf5760009484869281608496816040519b8c9a8b997f36c78516000000000000000000000000000000000000000000000000000000008b521660048a01521660248801521660448601521660648401525af1801561291b576129125750565b6001907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114612db3570190565b610bf26125df565b602090805115610bea570190565b604090805160011015610bea570190565b6020918151811015612def575b60051b010190565b612df7610bae565b612de7565b60208082019080835283518092528060408094019401926000905b838210612e2657505050505090565b8451805173ffffffffffffffffffffffffffffffffffffffff90811688528185015181168886015281830151811688840152606091820151169087015260809095019493820193600190910190612e17565b805160005b818110612f0257505073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba316803b156101cf5761175f6000929183926040519485809481937f0d58b1db00000000000000000000000000000000000000000000000000000000835260048301612dfc565b33612f47612f2e612f138487612dda565b515173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff1690565b03612f5a57612f5590612d85565b612e7d565b60046040517fe7002877000000000000000000000000000000000000000000000000000000008152fd5b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff820191821161262257565b9190820391821161262257565b61312893919294613042612fee612fd485612dbb565b5173ffffffffffffffffffffffffffffffffffffffff1690565b612ffa612fd486612dc9565b907f96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f7f0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f61324a565b9384816131c3575b505050613069612f2e612f2e612fd46130638651612f84565b86612dda565b6040517f70a082310000000000000000000000000000000000000000000000000000000080825273ffffffffffffffffffffffffffffffffffffffff8416600483015290946020948587602481875afa9687156131b6575b600097613183575b50916130dc8694928661310d9795613452565b60405180958194829383526004830191909173ffffffffffffffffffffffffffffffffffffffff6020820193169052565b03915afa918215613176575b600092613159575b5050612fb1565b1061312f57565b60046040517f849eaf98000000000000000000000000000000000000000000000000000000008152fd5b61316f9250803d1061085a5761084b8183610d25565b3880613121565b61317e610f89565b613119565b859391975086949261310d966131a86130dc93883d8a1161085a5761084b8183610d25565b9993955096509294506130c9565b6131be610f89565b6130c1565b6131d8926131d3612fd487612dbb565b6131e0565b38808461304a565b92919073ffffffffffffffffffffffffffffffffffffffff808216300361320c575050610fa192612514565b808495941161322057610fa1941692612ce5565b60046040517fc4bd89a9000000000000000000000000000000000000000000000000000000008152fd5b9091610e6393613259916133a4565b9290915b917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa06133909161336373ffffffffffffffffffffffffffffffffffffffff96946040519260208401967fffffffffffffffffffffffffffffffffffffffff000000000000000000000000809260601b16885260601b16603484015260288352606083019583871067ffffffffffffffff881117613397575b8660405283519020608084019788917fffffffffffffffffffffffffffffffffffffffff000000000000000000000000605594927fff00000000000000000000000000000000000000000000000000000000000000855260601b166001840152601583015260358201520190565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80810184520182610d25565b5190201690565b61339f610c64565b6132f5565b73ffffffffffffffffffffffffffffffffffffffff8281169082161015611b6b5791565b51906dffffffffffffffffffffffffffff821682036101cf57565b908160609103126101cf576133f7816133c8565b916040613406602084016133c8565b92015163ffffffff811681036101cf5790565b90610e63949360809373ffffffffffffffffffffffffffffffffffffffff92845260208401521660408201528160608201520190610e09565b90600292838351106137945761347f61346d612fd485612dbb565b613479612fd486612dc9565b906133a4565b508351937ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff86019501906000935b8685106134df575050505050505050565b6134ec612fd48684612dda565b906134fd612fd46001880185612dda565b73ffffffffffffffffffffffffffffffffffffffff928383169660409081519485937f0902f1ac00000000000000000000000000000000000000000000000000000000855260609788868d60049889915afa978815613787575b6000998a99613748575b50508061360695969798996dffffffffffffffffffffffffffff8091169a16921693168314978860001461373e57918291935b87875180927f70a0823100000000000000000000000000000000000000000000000000000000825281806135ea6020978896830191909173ffffffffffffffffffffffffffffffffffffffff6020820193169052565b03915afa918215613731575b600092613714575b5050036137d7565b931561370b578a600094935b878a10156137005761362c612fd4613674938c0189612dda565b907f96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f7f0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f6137be565b9390935b9761368161232c565b95813b156101cf57600086956136c7600199839751988997889687957f022c0d9f0000000000000000000000000000000000000000000000000000000087528601613419565b03925af180156136f3575b6136e0575b509401936134ce565b8061177f6136ed92610cd9565b386136d7565b6136fb610f89565b6136d2565b505088926000613678565b8a600093613612565b61372a9250803d1061085a5761084b8183610d25565b38806135fe565b613739610f89565b6135f6565b9091829193613594565b829a506136069697989950908161377392903d10613780575b61376b8183610d25565b8101906133e3565b5099909998979695613561565b503d613761565b61378f610f89565b613557565b60046040517fae52ad0c000000000000000000000000000000000000000000000000000000008152fd5b926137cc906137d4936133a4565b91819461325d565b91565b811590818015613876575b61384c57613808610e63946103e59283810293818504149015171561383f575b8261260f565b916103e8808502948504141715613832575b82018092111561262a575b61382d6125df565b61262a565b61383a6125df565b61381a565b6138476125df565b613802565b60046040517f7b9c8916000000000000000000000000000000000000000000000000000000008152fd5b5083156137e2565b91939290927f0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f947f96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f9560009560028551106139d257968451917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff928381019081116139c5575b929190835b61395b5750505050851161393157610fa1948461392c926131d3612fd486612dbb565b613452565b60046040517f8ab0bc16000000000000000000000000000000000000000000000000000000008152fd5b929897509091826139996139928b61398a612fd4613983818488018e8682116139b857612dda565b928c612dda565b9086866139fc565b919b613abc565b9980156139ab575b0192919083613909565b6139b36125df565b6139a1565b6139c06125df565b612dda565b6139cd6125df565b613904565b60046040517f20db8267000000000000000000000000000000000000000000000000000000008152fd5b919392906137cc613a0d92866133a4565b92604051907f0902f1ac00000000000000000000000000000000000000000000000000000000825273ffffffffffffffffffffffffffffffffffffffff606083600481848a165afa928315613aaf575b6000908194613a8d575b5081906dffffffffffffffffffffffffffff80911694169416911614600014611b6b5791565b829450613aa8915060603d81116137805761376b8183610d25565b5093613a67565b613ab7610f89565b613a5d565b909182158015613b41575b61384c57613ad882613b119461260f565b906103e891828102928184041490151715613b34575b82810392818411613b27575b6103e580850294850414911417156138255761262a565b60018101809111613b1f5790565b610e636125df565b613b2f6125df565b613afa565b613b3c6125df565b613aee565b508015613ac7565b91906040838203126101cf57823567ffffffffffffffff81116101cf57830181601f820112156101cf576020918183613b8493359101610daf565b920135610e63816101b1565b7f80000000000000000000000000000000000000000000000000000000000000008114613bbe575b60000390565b613bc66125df565b613bb8565b908151613bd88184613c49565b9260178210613c1f57602b60178201519210613bf557602b015191565b60046040517fa78aa27f000000000000000000000000000000000000000000000000000000008152fd5b60046040517fd9096a3e000000000000000000000000000000000000000000000000000000008152fd5b90601411613bf5576014015190565b8051907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe99182810192818411613d5c575b836008830110613d325760178210613d325781835110613d085760178214613cde57601f8416801560051b0183019182010160178201915b818110613cce5750505052565b8251815260209283019201613cc1565b60046040517fcc94a63a000000000000000000000000000000000000000000000000000000008152fd5b60046040517f3b99b53d000000000000000000000000000000000000000000000000000000008152fd5b60046040517f47aaf07a000000000000000000000000000000000000000000000000000000008152fd5b613d646125df565b613c89565b93909192937f80000000000000000000000000000000000000000000000000000000000000008314613e34575b90613dc5613dd3915b613dae604288511015956143d8565b8515613e2e57305b613dbf89613ecd565b91614126565b90919015613e275750613b90565b9115613df357613dc5613dd3913090613deb87613c58565b929190613d9f565b50109050613dfd57565b60046040517f39d35496000000000000000000000000000000000000000000000000000000008152fd5b9050613b90565b84613db6565b9150613dc5613dd391613e4e612f2e612f2e885189613c49565b6040517f70a0823100000000000000000000000000000000000000000000000000000000815230600482015290602090829060249082905afa908115613ec0575b600091613ea2575b509391509150613d96565b613eba915060203d811161085a5761084b8183610d25565b38613e97565b613ec8610f89565b613e8f565b90602b825110613d0857602b60405192600b810151600b8501520151602b830152602b825260608201604052565b613f1193919492600055610af6610af1866143d8565b90919015613f785750613f2390613b90565b03613f4e577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600055565b60046040517fd4e0248e000000000000000000000000000000000000000000000000000000008152fd5b613f829150613b90565b613f23565b9073ffffffffffffffffffffffffffffffffffffffff613fb4602092959495604085526040850190610e09565b9416910152565b91908260409103126101cf576020825192015190565b919360a093610e63969573ffffffffffffffffffffffffffffffffffffffff80941685521515602085015260408401521660608201528160808201520190610e09565b612f2e9293612f2e60006040946140b26140596140308a613bcb565b73ffffffffffffffffffffffffffffffffffffffff9b9297919b808916908d16109b8c98614167565b948484146141085761407d6401000276a49a5b611c818a5193849260208401613f87565b8751998a97889687957f128acb0800000000000000000000000000000000000000000000000000000000875260048701613fd1565b03925af180156140fb575b60009283916140cb57509192565b90506140ef91925060403d81116140f4575b6140e78183610d25565b810190613fbb565b919092565b503d6140dd565b614103610f89565b6140bd565b61407d73fffd8963efd1fc6a506488495d951d5263988d259a61406c565b612f2e9293612f2e60006040946140b26140596141428a613bcb565b73ffffffffffffffffffffffffffffffffffffffff9b9297919b808d16908916109b8c985b73ffffffffffffffffffffffffffffffffffffffff92838316848316116142b1575b62ffffff908460405194816020870195168552166040850152166060830152606082526133907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80608084019284841067ffffffffffffffff8511176142a4575b6040849052845190207fff0000000000000000000000000000000000000000000000000000000000000060a086019081527fffffffffffffffffffffffffffffffffffffffff0000000000000000000000007f0000000000000000000000001f98431c8ad98523631ae4a59f267346ea31f98460601b1660a187015260b58601919091527fe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b5460d5909501949094526055835260f50182610d25565b6142ac610c64565b6141e9565b909190614189565b600080809381935af1156142c957565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601360248201527f4554485f5452414e534645525f4641494c4544000000000000000000000000006044820152fd5b60009182604492602095604051937fa9059cbb000000000000000000000000000000000000000000000000000000008552600485015260248401525af13d15601f3d116001600051141617161561437a57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600f60248201527f5452414e534645525f4641494c454400000000000000000000000000000000006044820152fd5b7f80000000000000000000000000000000000000000000000000000000000000008110156101cf579056fea26469706673582212202f2e114dd73237126f72d60b80c2baf2d9fc70c6d00948608ca4fefdc9bf009064736f6c63430008110033', + }, + }; + }); +} diff --git a/ui/pages/confirmations/components/confirm/info/approve/__snapshots__/approve.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/approve/__snapshots__/approve.test.tsx.snap index 19050e5f3f37..8cfe63f27147 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/__snapshots__/approve.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/approve/__snapshots__/approve.test.tsx.snap @@ -780,6 +780,7 @@ exports[` renders component for approve request 1`] = `
+ {method.params.map((param, paramIndex) => ( Date: Wed, 27 Nov 2024 09:06:27 -0330 Subject: [PATCH 100/148] chore: Bump `@metamask/ens-controller` from v13 to v14 (#28746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bump `@metamask/ens-controller` to v14. There are two minor breaking changes that impact the constructor and messenger. Changelog: https://github.com/MetaMask/core/blob/main/packages/ens-controller/CHANGELOG.md#1400 This update resolves a peer dependency warning about the ENS controller's dependence upon the network controller (it was expecting v20, but we had v21). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28746?quickstart=1) ## **Related issues** Relates to https://github.com/MetaMask/MetaMask-planning/issues/3568 ## **Manual testing steps** N/A ## **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. --------- Co-authored-by: MetaMask Bot --- app/scripts/metamask-controller.js | 6 ++++-- lavamoat/browserify/beta/policy.json | 10 +--------- lavamoat/browserify/flask/policy.json | 10 +--------- lavamoat/browserify/main/policy.json | 10 +--------- lavamoat/browserify/mmi/policy.json | 10 +--------- package.json | 2 +- yarn.lock | 18 +++++++++--------- 7 files changed, 18 insertions(+), 48 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 39e34955cac2..166322b1ee78 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1052,10 +1052,12 @@ export default class MetamaskController extends EventEmitter { this.ensController = new EnsController({ messenger: this.controllerMessenger.getRestricted({ name: 'EnsController', - allowedActions: ['NetworkController:getNetworkClientById'], + allowedActions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], allowedEvents: [], }), - provider: this.provider, onNetworkDidChange: networkControllerMessenger.subscribe.bind( networkControllerMessenger, 'NetworkController:networkDidChange', diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index cf72074493e6..88a9a9db85a0 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -794,20 +794,12 @@ "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/ens-controller>@metamask/base-controller": true, "@metamask/ens-controller>@metamask/utils": true, "punycode": true } }, - "@metamask/ens-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/ens-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index cf72074493e6..88a9a9db85a0 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -794,20 +794,12 @@ "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/ens-controller>@metamask/base-controller": true, "@metamask/ens-controller>@metamask/utils": true, "punycode": true } }, - "@metamask/ens-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/ens-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index cf72074493e6..88a9a9db85a0 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -794,20 +794,12 @@ "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/ens-controller>@metamask/base-controller": true, "@metamask/ens-controller>@metamask/utils": true, "punycode": true } }, - "@metamask/ens-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/ens-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index e70102a63e51..3f79af87a50b 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -886,20 +886,12 @@ "@metamask/ens-controller": { "packages": { "@ethersproject/providers": true, + "@metamask/base-controller": true, "@metamask/controller-utils": true, - "@metamask/ens-controller>@metamask/base-controller": true, "@metamask/ens-controller>@metamask/utils": true, "punycode": true } }, - "@metamask/ens-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/ens-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/package.json b/package.json index 5304df2c2ff8..e7e74534d3e3 100644 --- a/package.json +++ b/package.json @@ -295,7 +295,7 @@ "@metamask/contract-metadata": "^2.5.0", "@metamask/controller-utils": "^11.4.0", "@metamask/design-tokens": "^4.0.0", - "@metamask/ens-controller": "^13.0.0", + "@metamask/ens-controller": "^14.0.0", "@metamask/ens-resolver-snap": "^0.1.2", "@metamask/eth-json-rpc-filters": "^9.0.0", "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch", diff --git a/yarn.lock b/yarn.lock index 88e84e6d37b1..8f040e67853b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5089,7 +5089,7 @@ __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, @metamask/controller-utils@npm:^11.4.2, @metamask/controller-utils@npm:^11.4.3": +"@metamask/controller-utils@npm:^11.0.0, @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, @metamask/controller-utils@npm:^11.4.3": version: 11.4.3 resolution: "@metamask/controller-utils@npm:11.4.3" dependencies: @@ -5114,18 +5114,18 @@ __metadata: languageName: node linkType: hard -"@metamask/ens-controller@npm:^13.0.0": - version: 13.0.1 - resolution: "@metamask/ens-controller@npm:13.0.1" +"@metamask/ens-controller@npm:^14.0.0": + version: 14.0.1 + resolution: "@metamask/ens-controller@npm:14.0.1" dependencies: "@ethersproject/providers": "npm:^5.7.0" - "@metamask/base-controller": "npm:^6.0.2" - "@metamask/controller-utils": "npm:^11.0.2" + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.3.0" "@metamask/utils": "npm:^9.1.0" punycode: "npm:^2.1.1" peerDependencies: - "@metamask/network-controller": ^20.0.0 - checksum: 10/eb3516ac444244f09a8746c0a103699c8482c34c4d0e3f0c82e7a7185f5c554e53c8d3392567c5635d1d7d76b97a15038ab8f7b4d597f033539ba0dbeaacc710 + "@metamask/network-controller": ^21.0.0 + checksum: 10/1b57a781f4c53d7e60afda11b3994e977af1149aa5651c20b4dc56010de597fd9c9ada28847491d3fe862f0a8f08b96b17a759f742e870ca5911609e07f5dc6c languageName: node linkType: hard @@ -26837,7 +26837,7 @@ __metadata: "@metamask/contract-metadata": "npm:^2.5.0" "@metamask/controller-utils": "npm:^11.4.0" "@metamask/design-tokens": "npm:^4.0.0" - "@metamask/ens-controller": "npm:^13.0.0" + "@metamask/ens-controller": "npm:^14.0.0" "@metamask/ens-resolver-snap": "npm:^0.1.2" "@metamask/eslint-config": "npm:^9.0.0" "@metamask/eslint-config-jest": "npm:^9.0.0" From afd7e543935f36c98dc3de705f78a8a1187af754 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 27 Nov 2024 09:06:39 -0330 Subject: [PATCH 101/148] chore: Bump `@metamask/permission-log-controller` to v3.0.1 (#28747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update `@metamask/permission-log-controller` from v2.0.1 to v3.0.1. No breaking changes that impact the extension. Changelog: https://github.com/MetaMask/core/blob/main/packages/permission-log-controller/CHANGELOG.md#301 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28747?quickstart=1) ## **Related issues** Relates to https://github.com/MetaMask/MetaMask-planning/issues/3568 ## **Manual testing steps** N/A ## **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. --------- Co-authored-by: MetaMask Bot --- lavamoat/browserify/beta/policy.json | 10 +-------- lavamoat/browserify/flask/policy.json | 10 +-------- lavamoat/browserify/main/policy.json | 10 +-------- lavamoat/browserify/mmi/policy.json | 10 +-------- package.json | 2 +- yarn.lock | 30 +++++++++------------------ 6 files changed, 15 insertions(+), 57 deletions(-) diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 88a9a9db85a0..a1af28ef6669 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2174,18 +2174,10 @@ }, "@metamask/permission-log-controller": { "packages": { - "@metamask/permission-log-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/permission-log-controller>@metamask/utils": true } }, - "@metamask/permission-log-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/permission-log-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 88a9a9db85a0..a1af28ef6669 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2174,18 +2174,10 @@ }, "@metamask/permission-log-controller": { "packages": { - "@metamask/permission-log-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/permission-log-controller>@metamask/utils": true } }, - "@metamask/permission-log-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/permission-log-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 88a9a9db85a0..a1af28ef6669 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2174,18 +2174,10 @@ }, "@metamask/permission-log-controller": { "packages": { - "@metamask/permission-log-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/permission-log-controller>@metamask/utils": true } }, - "@metamask/permission-log-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/permission-log-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 3f79af87a50b..574da95682be 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2266,18 +2266,10 @@ }, "@metamask/permission-log-controller": { "packages": { - "@metamask/permission-log-controller>@metamask/base-controller": true, + "@metamask/base-controller": true, "@metamask/permission-log-controller>@metamask/utils": true } }, - "@metamask/permission-log-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, "@metamask/permission-log-controller>@metamask/utils": { "globals": { "TextDecoder": true, diff --git a/package.json b/package.json index e7e74534d3e3..abd56565d35d 100644 --- a/package.json +++ b/package.json @@ -327,7 +327,7 @@ "@metamask/object-multiplex": "^2.0.0", "@metamask/obs-store": "^9.0.0", "@metamask/permission-controller": "^11.0.0", - "@metamask/permission-log-controller": "^2.0.1", + "@metamask/permission-log-controller": "^3.0.1", "@metamask/phishing-controller": "^12.3.0", "@metamask/polling-controller": "^10.0.1", "@metamask/post-message-stream": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 8f040e67853b..922fc470e8e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5026,16 +5026,6 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^5.0.1": - version: 5.0.2 - resolution: "@metamask/base-controller@npm:5.0.2" - dependencies: - "@metamask/utils": "npm:^8.3.0" - immer: "npm:^9.0.6" - checksum: 10/f9c142766d8cdb69c0cc93aa5cfdaeae97a8c126a5f30f75d31bfdebbc57e82574dc5a3743eceb9e3106d182d066d1517fb73991bb2d06d861d25fd1dac87dcc - languageName: node - linkType: hard - "@metamask/base-controller@npm:^6.0.0, @metamask/base-controller@npm:^6.0.2": version: 6.0.3 resolution: "@metamask/base-controller@npm:6.0.3" @@ -5747,7 +5737,7 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^8.0.1, @metamask/json-rpc-engine@npm:^8.0.2": +"@metamask/json-rpc-engine@npm:^8.0.2": version: 8.0.2 resolution: "@metamask/json-rpc-engine@npm:8.0.2" dependencies: @@ -5758,7 +5748,7 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^9.0.0, @metamask/json-rpc-engine@npm:^9.0.2": +"@metamask/json-rpc-engine@npm:^9.0.0, @metamask/json-rpc-engine@npm:^9.0.2, @metamask/json-rpc-engine@npm:^9.0.3": version: 9.0.3 resolution: "@metamask/json-rpc-engine@npm:9.0.3" dependencies: @@ -6146,14 +6136,14 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-log-controller@npm:^2.0.1": - version: 2.0.1 - resolution: "@metamask/permission-log-controller@npm:2.0.1" +"@metamask/permission-log-controller@npm:^3.0.1": + version: 3.0.1 + resolution: "@metamask/permission-log-controller@npm:3.0.1" dependencies: - "@metamask/base-controller": "npm:^5.0.1" - "@metamask/json-rpc-engine": "npm:^8.0.1" - "@metamask/utils": "npm:^8.3.0" - checksum: 10/e918579a44365d1eb3d100870efc207e3125f7ba3c0fb0a736f1301162b132e77b041142f358438fe0db6644beaac1e5541c9e4ff4ba126e9f4110ab0032e0f6 + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/json-rpc-engine": "npm:^9.0.3" + "@metamask/utils": "npm:^9.1.0" + checksum: 10/90ca40c0c3da705db907ad9c6d1ffaf2ad3ca080313b9d114c1b449635f774d0781a1d9505c607b63c219e5fd3d1fe20c973cf0c7bcc038735a84275d01110a4 languageName: node linkType: hard @@ -26877,7 +26867,7 @@ __metadata: "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/obs-store": "npm:^9.0.0" "@metamask/permission-controller": "npm:^11.0.0" - "@metamask/permission-log-controller": "npm:^2.0.1" + "@metamask/permission-log-controller": "npm:^3.0.1" "@metamask/phishing-controller": "npm:^12.3.0" "@metamask/phishing-warning": "npm:^4.1.0" "@metamask/polling-controller": "npm:^10.0.1" From ddb4c97da0b35f295a5682be3f84a52cf5ccc316 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Wed, 27 Nov 2024 14:00:08 +0100 Subject: [PATCH 102/148] fix: fix transaction list message on token detail page (#28764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #28766 ## **Description** If the token chosen in the token details does not correspond to the current network, the message displayed in the activity section should be updated accordingly. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28764?quickstart=1) ## **Related issues** Fixes: #28766 ## **Manual testing steps** 1. run `PORTFOLIO_VIEW=true yarn start` 2. choose any token who is not part of current network and go to token details 3. go to activity section and check the message ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/e0d379b8-dd37-4e87-a845-ff35a08deb76 ## **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). - [ ] I've completed the PR template to the best of my ability - [x] 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 | 7 ++ .../transaction-list.component.js | 17 ++++- .../transaction-list/transaction-list.test.js | 65 +++++++++++++++++++ ui/pages/asset/components/asset-page.tsx | 4 +- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e9e9cc807ccd..3b8b2bfa9682 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3403,6 +3403,13 @@ "noTransactions": { "message": "You have no transactions" }, + "noTransactionsChainIdMismatch": { + "message": "Please switch network to view transactions" + }, + "noTransactionsNetworkName": { + "message": "Please switch to $1 network to view transactions", + "description": "$1 represents the network name" + }, "noWebcamFound": { "message": "Your computer's webcam was not found. Please try again." }, diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index 2fd7d0bc6d1a..47b422457143 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -55,6 +55,7 @@ import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { getMultichainNetwork } from '../../../selectors/multichain'; import { endTrace, TraceName } from '../../../../shared/lib/trace'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; const PAGE_INCREMENT = 10; @@ -140,6 +141,7 @@ export default function TransactionList({ hideTokenTransactions, tokenAddress, boxProps, + tokenChainId, }) { const [limit, setLimit] = useState(PAGE_INCREMENT); const t = useI18nContext(); @@ -151,7 +153,16 @@ export default function TransactionList({ nonceSortedCompletedTransactionsSelector, ); const chainId = useSelector(getCurrentChainId); + const networkConfigurationsByChainId = useSelector( + getNetworkConfigurationsByChainId, + ); + const networkName = networkConfigurationsByChainId[tokenChainId]?.name; const selectedAccount = useSelector(getSelectedAccount); + const isChainIdMismatch = tokenChainId && tokenChainId !== chainId; + + const noTransactionsMessage = networkName + ? t('noTransactionsNetworkName', [networkName]) + : t('noTransactionsChainIdMismatch'); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const shouldHideZeroBalanceTokens = useSelector( @@ -383,7 +394,9 @@ export default function TransactionList({ ) : ( - {t('noTransactions')} + {isChainIdMismatch + ? noTransactionsMessage + : t('noTransactions')} )} @@ -407,10 +420,12 @@ TransactionList.propTypes = { hideTokenTransactions: PropTypes.bool, tokenAddress: PropTypes.string, boxProps: PropTypes.object, + tokenChainId: PropTypes.string, }; TransactionList.defaultProps = { hideTokenTransactions: false, tokenAddress: undefined, boxProps: undefined, + tokenChainId: null, }; diff --git a/ui/components/app/transaction-list/transaction-list.test.js b/ui/components/app/transaction-list/transaction-list.test.js index e6e42ab78fc5..ca23f364166b 100644 --- a/ui/components/app/transaction-list/transaction-list.test.js +++ b/ui/components/app/transaction-list/transaction-list.test.js @@ -85,4 +85,69 @@ describe('TransactionList', () => { }, }); }); + + it('renders TransactionList component and shows Chain ID mismatch text if network name is not available', () => { + const store = configureStore(defaultState); + + const { getByText } = renderWithProvider( + + + , + store, + ); + expect( + getByText('Please switch network to view transactions'), + ).toBeInTheDocument(); + }); + + it('renders TransactionList component and shows network name text', () => { + const defaultState2 = { + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + blockExplorerUrls: [], + chainId: '0x1', + defaultRpcEndpointIndex: 0, + name: 'Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe708': { + blockExplorerUrls: [], + chainId: '0xe708', + defaultRpcEndpointIndex: 0, + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + }, + transactions: [], + }, + }; + const store = configureStore(defaultState2); + + const { getByText } = renderWithProvider( + + + , + store, + ); + expect( + getByText('Please switch to Linea Mainnet network to view transactions'), + ).toBeInTheDocument(); + }); }); diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 9100126d54fe..570528cd1acb 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -428,9 +428,9 @@ const AssetPage = ({ {t('yourActivity')} {type === AssetType.native ? ( - + ) : ( - + )} From afe6dc0007c91ce79b495c916de50d35411a37b4 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 27 Nov 2024 19:38:48 +0530 Subject: [PATCH 103/148] feat: adding metrics for signature decoding (#28719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adding metrics for signature decoding. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3644 ## **Manual testing steps** 1. Enable signature deccoding locally 2. Open permit page 3. Check that metrics are recorded for signature decoding information ## **Screenshots/Recordings** NA ## **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. --- .../signatures/signature-helpers.ts | 20 +++ .../permit-simulation.test.tsx.snap | 6 +- .../permit-simulation.test.tsx | 21 +++ .../permit-simulation/permit-simulation.tsx | 3 + .../info/typed-sign/typed-sign.test.tsx | 1 + .../hooks/useDecodedSignatureMetrics.test.ts | 135 ++++++++++++++++++ .../hooks/useDecodedSignatureMetrics.ts | 45 ++++++ 7 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts create mode 100644 ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts diff --git a/test/e2e/tests/confirmations/signatures/signature-helpers.ts b/test/e2e/tests/confirmations/signatures/signature-helpers.ts index a4c5e9309249..d360c46cf921 100644 --- a/test/e2e/tests/confirmations/signatures/signature-helpers.ts +++ b/test/e2e/tests/confirmations/signatures/signature-helpers.ts @@ -37,6 +37,8 @@ type AssertSignatureMetricsOptions = { withAnonEvents?: boolean; securityAlertReason?: string; securityAlertResponse?: string; + decodingChangeTypes?: string[]; + decodingResponse?: string; }; type SignatureEventProperty = { @@ -49,6 +51,8 @@ type SignatureEventProperty = { security_alert_response: string; signature_type: string; eip712_primary_type?: string; + decoding_change_types?: string[]; + decoding_response?: string; ui_customizations?: string[]; location?: string; }; @@ -67,6 +71,8 @@ const signatureAnonProperties = { * @param uiCustomizations * @param securityAlertReason * @param securityAlertResponse + * @param decodingChangeTypes + * @param decodingResponse */ function getSignatureEventProperty( signatureType: string, @@ -74,6 +80,8 @@ function getSignatureEventProperty( uiCustomizations: string[], securityAlertReason: string = BlockaidReason.checkingChain, securityAlertResponse: string = BlockaidResultType.Loading, + decodingChangeTypes?: string[], + decodingResponse?: string, ): SignatureEventProperty { const signatureEventProperty: SignatureEventProperty = { account_type: 'MetaMask', @@ -91,6 +99,10 @@ function getSignatureEventProperty( signatureEventProperty.eip712_primary_type = primaryType; } + if (decodingResponse) { + signatureEventProperty.decoding_change_types = decodingChangeTypes; + signatureEventProperty.decoding_response = decodingResponse; + } return signatureEventProperty; } @@ -123,6 +135,8 @@ export async function assertSignatureConfirmedMetrics({ withAnonEvents = false, securityAlertReason, securityAlertResponse, + decodingChangeTypes, + decodingResponse, }: AssertSignatureMetricsOptions) { const events = await getEventPayloads(driver, mockedEndpoints); const signatureEventProperty = getSignatureEventProperty( @@ -131,6 +145,8 @@ export async function assertSignatureConfirmedMetrics({ uiCustomizations, securityAlertReason, securityAlertResponse, + decodingChangeTypes, + decodingResponse, ); assertSignatureRequestedMetrics( @@ -164,6 +180,8 @@ export async function assertSignatureRejectedMetrics({ withAnonEvents = false, securityAlertReason, securityAlertResponse, + decodingChangeTypes, + decodingResponse, }: AssertSignatureMetricsOptions) { const events = await getEventPayloads(driver, mockedEndpoints); const signatureEventProperty = getSignatureEventProperty( @@ -172,6 +190,8 @@ export async function assertSignatureRejectedMetrics({ uiCustomizations, securityAlertReason, securityAlertResponse, + decodingChangeTypes, + decodingResponse, ); assertSignatureRequestedMetrics( diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap index 1551e2a8bf8a..fef756f178c2 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap @@ -23,7 +23,7 @@ exports[`PermitSimulation should not render default simulation if decodingLoadin

{ @@ -14,6 +15,7 @@ jest.mock('../../../../../../../store/actions', () => { getTokenStandardAndDetails: jest .fn() .mockResolvedValue({ decimals: 2, standard: 'ERC20' }), + updateEventFragment: jest.fn(), }; }); @@ -44,6 +46,25 @@ describe('PermitSimulation', () => { }); }); + it('should call hook to register signature metrics properties', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: undefined, + }); + const mockStore = configureMockStore([])(state); + + const mockedUseDecodedSignatureMetrics = jest + .spyOn(SignatureMetrics, 'useDecodedSignatureMetrics') + .mockImplementation(() => ''); + + await act(async () => { + renderWithConfirmContextProvider(, mockStore); + + expect(mockedUseDecodedSignatureMetrics).toHaveBeenCalledTimes(1); + }); + }); + it('should render default simulation if decoding api returns error', async () => { const state = getMockTypedSignConfirmStateForRequest({ ...permitSignatureMsg, diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx index 86055425fa46..d78a9afbe1b8 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SignatureRequestType } from '../../../../../types/confirm'; import { useConfirmContext } from '../../../../../context/confirm'; +import { useDecodedSignatureMetrics } from '../../../../../hooks/useDecodedSignatureMetrics'; import { DefaultSimulation } from './default-simulation'; import { DecodedSimulation } from './decoded-simulation'; @@ -9,6 +10,8 @@ const PermitSimulation: React.FC = () => { const { currentConfirmation } = useConfirmContext(); const { decodingLoading, decodingData } = currentConfirmation; + useDecodedSignatureMetrics(); + if ( decodingData?.error || (decodingData?.stateChanges === undefined && decodingLoading !== true) diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx index e6c93e964687..68f3c011f338 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx @@ -31,6 +31,7 @@ jest.mock( jest.mock('../../../../../../store/actions', () => { return { getTokenStandardAndDetails: jest.fn().mockResolvedValue({ decimals: 2 }), + updateEventFragment: jest.fn(), }; }); diff --git a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts new file mode 100644 index 000000000000..c0023104ba8b --- /dev/null +++ b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts @@ -0,0 +1,135 @@ +import { + DecodingData, + DecodingDataChangeType, +} from '@metamask/signature-controller'; + +import { getMockTypedSignConfirmStateForRequest } from '../../../../test/data/confirmations/helper'; +import { renderHookWithConfirmContextProvider } from '../../../../test/lib/confirmations/render-helpers'; +import { permitSignatureMsg } from '../../../../test/data/confirmations/typed_sign'; +import * as SignatureEventFragment from './useSignatureEventFragment'; +import { useDecodedSignatureMetrics } from './useDecodedSignatureMetrics'; + +const decodingData: DecodingData = { + stateChanges: [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '1461501637330902918203684832716283019655932542975', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, + ], +}; + +describe('useDecodedSignatureMetrics', () => { + process.env.ENABLE_SIGNATURE_DECODING = 'true'; + it('should not call updateSignatureEventFragment if decodingLoading is true', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: true, + }); + + const mockUpdateSignatureEventFragment = jest.fn(); + jest + .spyOn(SignatureEventFragment, 'useSignatureEventFragment') + .mockImplementation(() => ({ + updateSignatureEventFragment: mockUpdateSignatureEventFragment, + })); + + renderHookWithConfirmContextProvider( + () => useDecodedSignatureMetrics(), + state, + ); + + expect(mockUpdateSignatureEventFragment).toHaveBeenCalledTimes(0); + }); + + it('should call updateSignatureEventFragment with correct parameters if there are no state changes', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + }); + + const mockUpdateSignatureEventFragment = jest.fn(); + jest + .spyOn(SignatureEventFragment, 'useSignatureEventFragment') + .mockImplementation(() => ({ + updateSignatureEventFragment: mockUpdateSignatureEventFragment, + })); + + renderHookWithConfirmContextProvider( + () => useDecodedSignatureMetrics(), + state, + ); + + expect(mockUpdateSignatureEventFragment).toHaveBeenCalledTimes(1); + expect(mockUpdateSignatureEventFragment).toHaveBeenLastCalledWith({ + properties: { + decoding_change_types: [], + decoding_response: 'NO_CHANGE', + }, + }); + }); + + it('should call updateSignatureEventFragment with correct parameters if there are state changes', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData, + }); + + const mockUpdateSignatureEventFragment = jest.fn(); + jest + .spyOn(SignatureEventFragment, 'useSignatureEventFragment') + .mockImplementation(() => ({ + updateSignatureEventFragment: mockUpdateSignatureEventFragment, + })); + + renderHookWithConfirmContextProvider( + () => useDecodedSignatureMetrics(), + state, + ); + + expect(mockUpdateSignatureEventFragment).toHaveBeenCalledTimes(1); + expect(mockUpdateSignatureEventFragment).toHaveBeenLastCalledWith({ + properties: { + decoding_change_types: ['APPROVE'], + decoding_response: 'CHANGE', + }, + }); + }); + + it('should call updateSignatureEventFragment with correct parameters if response has error', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: [], + error: { + type: 'SOME_ERROR', + message: 'some message', + }, + }, + }); + + const mockUpdateSignatureEventFragment = jest.fn(); + jest + .spyOn(SignatureEventFragment, 'useSignatureEventFragment') + .mockImplementation(() => ({ + updateSignatureEventFragment: mockUpdateSignatureEventFragment, + })); + + renderHookWithConfirmContextProvider( + () => useDecodedSignatureMetrics(), + state, + ); + + expect(mockUpdateSignatureEventFragment).toHaveBeenCalledTimes(1); + expect(mockUpdateSignatureEventFragment).toHaveBeenLastCalledWith({ + properties: { + decoding_change_types: [], + decoding_response: 'SOME_ERROR', + }, + }); + }); +}); diff --git a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts new file mode 100644 index 000000000000..1bf508bce655 --- /dev/null +++ b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts @@ -0,0 +1,45 @@ +import { DecodingDataStateChange } from '@metamask/signature-controller'; +import { useEffect } from 'react'; + +import { SignatureRequestType } from '../types/confirm'; +import { useConfirmContext } from '../context/confirm'; +import { useSignatureEventFragment } from './useSignatureEventFragment'; + +enum DecodingResponseType { + Change = 'CHANGE', + NoChange = 'NO_CHANGE', +} + +export function useDecodedSignatureMetrics() { + const { updateSignatureEventFragment } = useSignatureEventFragment(); + const { currentConfirmation } = useConfirmContext(); + const { decodingLoading, decodingData } = currentConfirmation; + + const decodingChangeTypes = (decodingData?.stateChanges ?? []).map( + (change: DecodingDataStateChange) => change.changeType, + ); + + const decodingResponse = + decodingData?.error?.type ?? + (decodingChangeTypes.length + ? DecodingResponseType.Change + : DecodingResponseType.NoChange); + + useEffect(() => { + if (decodingLoading || !process.env.ENABLE_SIGNATURE_DECODING) { + return; + } + + updateSignatureEventFragment({ + properties: { + decoding_response: decodingResponse, + decoding_change_types: decodingChangeTypes, + }, + }); + }, [ + decodingResponse, + decodingLoading, + decodingChangeTypes, + updateSignatureEventFragment, + ]); +} From 85baff382ffa17b9ccbcfa07eaf24a6f4bf412c7 Mon Sep 17 00:00:00 2001 From: David Murdoch <187813+davidmurdoch@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:43:50 -0500 Subject: [PATCH 104/148] refactor: move `getCurrentChainId` from `selectors/selectors.js` to `shared/modules/selectors/networks.ts` (#27647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the selector `getCurrentChainId` from functions from JS to TS and moves it to the `shared` folder. Also updates some functions to match the actual expect return values and fixes some types. Why only this function? I'm trying to solve circular dependency issues. `getCurrentChainId` is so widely used in the codebase, it makes it very complicated to untangle. I've moved it to `shared/modules/selectors/networks.ts` --------- Co-authored-by: Howard Braham --- app/scripts/background.js | 4 +- .../tx-verification-middleware.ts | 4 +- app/scripts/metamask-controller.js | 8 ++-- shared/modules/selectors/feature-flags.ts | 8 ++-- shared/modules/selectors/networks.ts | 37 +++++++++++-------- .../modules/selectors/smart-transactions.ts | 5 +-- .../network-filter/network-filter.tsx | 6 ++- .../assets/nfts/nft-details/nft-details.tsx | 2 +- .../app/assets/nfts/nfts-items/nfts-items.js | 2 +- .../app/currency-input/currency-input.js | 7 ++-- .../hooks/useTokenExchangeRate.tsx | 6 +-- .../detected-token-selection-popover.js | 6 ++- .../detected-token-values.js | 2 +- .../app/detected-token/detected-token.js | 10 ++--- .../hide-token-confirmation-modal.js | 6 ++- .../keyring-snap-removal-warning.tsx | 2 +- .../transaction-list.component.js | 4 +- .../aggregated-percentage-overview.test.tsx | 5 ++- .../aggregated-percentage-overview.tsx | 2 +- .../app/wallet-overview/coin-buttons.tsx | 2 +- .../app/wallet-overview/eth-overview.js | 2 +- .../account-details-display.js | 2 +- .../account-list-item-menu.js | 2 +- .../asset-picker-modal/Asset.tsx | 3 +- .../asset-picker-modal/AssetList.tsx | 2 +- .../asset-picker-modal.test.tsx | 2 +- .../asset-picker-modal/asset-picker-modal.tsx | 3 +- .../detected-token-banner.js | 4 +- .../import-nfts-modal/import-nfts-modal.js | 2 +- .../import-tokens-modal.js | 4 +- .../network-list-menu/network-list-menu.tsx | 4 +- .../quote-card/hooks/useEthFeeData.test.tsx | 2 +- .../quote-card/hooks/useEthFeeData.tsx | 2 +- .../hooks/useTranslatedNetworkName.test.tsx | 2 +- .../hooks/useTranslatedNetworkName.tsx | 2 +- .../percentage-and-amount-change.test.tsx | 5 ++- .../percentage-and-amount-change.tsx | 2 +- ui/ducks/domains.js | 2 +- ui/ducks/ramps/ramps.test.ts | 10 ++++- ui/ducks/ramps/ramps.ts | 3 +- ui/ducks/send/helpers.js | 2 +- ui/ducks/send/helpers.test.js | 9 ++++- ui/ducks/send/send.js | 10 ++--- ui/ducks/swaps/swaps.js | 2 +- ui/hooks/bridge/useLatestBalance.ts | 7 +--- ui/hooks/ramps/useRamps/useRamps.ts | 6 +-- ...eAccountTotalCrossChainFiatBalance.test.ts | 1 + ui/hooks/useAccountTotalFiatBalance.js | 2 +- ui/hooks/useCurrentAsset.js | 2 +- .../useGetFormattedTokensPerChain.test.ts | 8 +++- ui/hooks/useGetFormattedTokensPerChain.ts | 3 +- ui/hooks/useNftsCollections.js | 3 +- ui/hooks/useSwappedTokenValue.js | 2 +- ui/hooks/useTokensToSearch.js | 2 +- ui/index.js | 2 +- ui/pages/asset/components/token-buttons.tsx | 2 +- .../confirm-add-suggested-nft.js | 2 +- .../create-account/connect-hardware/index.js | 2 +- ui/pages/institutional/custody/custody.tsx | 6 +-- ui/pages/routes/routes.container.js | 10 ++--- .../smart-transaction-status-page.tsx | 3 +- ui/pages/swaps/awaiting-swap/awaiting-swap.js | 3 +- .../swaps/hooks/useUpdateSwapsState.test.ts | 2 +- ui/pages/swaps/hooks/useUpdateSwapsState.ts | 2 +- ui/pages/swaps/index.js | 2 +- .../list-with-search/list-with-search.js | 2 +- .../prepare-swap-page/prepare-swap-page.js | 2 +- .../swaps/prepare-swap-page/review-quote.js | 2 +- .../item-list/item-list.component.js | 2 +- .../list-item-search.component.js | 2 +- .../smart-transaction-status.js | 2 +- ui/selectors/confirm-transaction.js | 6 ++- ui/selectors/institutional/selectors.ts | 5 ++- ui/selectors/multichain.ts | 2 +- ui/selectors/nft.ts | 5 ++- ui/selectors/selectors.js | 16 +++----- ui/selectors/selectors.test.js | 23 +++++++++--- ui/selectors/transactions.js | 6 ++- 78 files changed, 203 insertions(+), 155 deletions(-) diff --git a/app/scripts/background.js b/app/scripts/background.js index 90a52b6c0d19..6a047c79ac16 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -53,9 +53,7 @@ import { FakeLedgerBridge, FakeTrezorBridge, } from '../../test/stub/keyring-bridge'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getCurrentChainId } from '../../ui/selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { addNonceToCsp } from '../../shared/modules/add-nonce-to-csp'; import { checkURLForProviderInjection } from '../../shared/modules/provider-injection'; import migrations from './migrations'; diff --git a/app/scripts/lib/tx-verification/tx-verification-middleware.ts b/app/scripts/lib/tx-verification/tx-verification-middleware.ts index 7abdf73e3637..e21a38f1e726 100644 --- a/app/scripts/lib/tx-verification/tx-verification-middleware.ts +++ b/app/scripts/lib/tx-verification/tx-verification-middleware.ts @@ -20,9 +20,7 @@ import { TRUSTED_SIGNERS, } from '../../../../shared/constants/verification'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getCurrentChainId } from '../../../../ui/selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; export type TxParams = { chainId?: `0x${string}`; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 166322b1ee78..1f907d94a976 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -236,10 +236,10 @@ import { TOKEN_TRANSFER_LOG_TOPIC_HASH, TRANSFER_SINFLE_LOG_TOPIC_HASH, } from '../../shared/lib/transactions-controller-utils'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getCurrentChainId } from '../../ui/selectors/selectors'; -import { getProviderConfig } from '../../shared/modules/selectors/networks'; +import { + getCurrentChainId, + getProviderConfig, +} from '../../shared/modules/selectors/networks'; import { endTrace, trace } from '../../shared/lib/trace'; // eslint-disable-next-line import/no-restricted-paths import { isSnapId } from '../../ui/helpers/utils/snaps'; diff --git a/shared/modules/selectors/feature-flags.ts b/shared/modules/selectors/feature-flags.ts index 429df2c06da6..4d27f139f68f 100644 --- a/shared/modules/selectors/feature-flags.ts +++ b/shared/modules/selectors/feature-flags.ts @@ -1,7 +1,5 @@ -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getCurrentChainId } from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file. import { getNetworkNameByChainId } from '../feature-flags'; +import { ProviderConfigState, getCurrentChainId } from './networks'; type FeatureFlagsMetaMaskState = { metamask: { @@ -21,7 +19,9 @@ type FeatureFlagsMetaMaskState = { }; }; -export function getFeatureFlagsByChainId(state: FeatureFlagsMetaMaskState) { +export function getFeatureFlagsByChainId( + state: ProviderConfigState & FeatureFlagsMetaMaskState, +) { const chainId = getCurrentChainId(state); const networkName = getNetworkNameByChainId(chainId); const featureFlags = state.metamask.swapsState?.swapsFeatureFlags; diff --git a/shared/modules/selectors/networks.ts b/shared/modules/selectors/networks.ts index 41aad6da6948..bc8d05b5164d 100644 --- a/shared/modules/selectors/networks.ts +++ b/shared/modules/selectors/networks.ts @@ -1,33 +1,32 @@ import { RpcEndpointType, type NetworkConfiguration, - type NetworkState as _NetworkState, + type NetworkState as InternalNetworkState, } from '@metamask/network-controller'; import { createSelector } from 'reselect'; import { NetworkStatus } from '../../constants/network'; import { createDeepEqualSelector } from './util'; -export type NetworkState = { metamask: _NetworkState }; +export type NetworkState = { + metamask: InternalNetworkState; +}; export type NetworkConfigurationsState = { metamask: { - networkConfigurations: Record< - string, - MetaMaskExtensionNetworkConfiguration - >; + networkConfigurations: Record; }; }; export type SelectedNetworkClientIdState = { - metamask: { - selectedNetworkClientId: string; - }; + metamask: Pick; }; -export type MetaMaskExtensionNetworkConfiguration = NetworkConfiguration; - export type NetworkConfigurationsByChainIdState = { - metamask: Pick<_NetworkState, 'networkConfigurationsByChainId'>; + metamask: Pick; +}; + +export type NetworksMetadataState = { + metamask: Pick; }; export type ProviderConfigState = NetworkConfigurationsByChainIdState & @@ -49,6 +48,7 @@ export function getSelectedNetworkClientId( * Get the provider configuration for the current selected network. * * @param state - Redux state object. + * @throws `new Error('Provider configuration not found')` If the provider configuration is not found. */ export const getProviderConfig = createSelector( (state: ProviderConfigState) => getNetworkConfigurationsByChainId(state), @@ -81,13 +81,13 @@ export const getProviderConfig = createSelector( } } } - return undefined; // should not be reachable + throw new Error('Provider configuration not found'); }, ); export function getNetworkConfigurations( state: NetworkConfigurationsState, -): Record { +): Record { return state.metamask.networkConfigurations; } @@ -106,9 +106,16 @@ export function isNetworkLoading(state: NetworkState) { ); } -export function getInfuraBlocked(state: NetworkState) { +export function getInfuraBlocked( + state: SelectedNetworkClientIdState & NetworksMetadataState, +) { return ( state.metamask.networksMetadata[getSelectedNetworkClientId(state)] .status === NetworkStatus.Blocked ); } + +export function getCurrentChainId(state: ProviderConfigState) { + const { chainId } = getProviderConfig(state); + return chainId; +} diff --git a/shared/modules/selectors/smart-transactions.ts b/shared/modules/selectors/smart-transactions.ts index b88d5f7c029b..e7c12f1e7f70 100644 --- a/shared/modules/selectors/smart-transactions.ts +++ b/shared/modules/selectors/smart-transactions.ts @@ -4,7 +4,6 @@ import { SKIP_STX_RPC_URL_CHECK_CHAIN_IDS, } from '../../constants/smartTransactions'; import { - getCurrentChainId, getCurrentNetwork, accountSupportsSmartTx, getPreferences, @@ -12,7 +11,7 @@ import { // eslint-disable-next-line import/no-restricted-paths } from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file. import { isProduction } from '../environment'; -import { NetworkState } from './networks'; +import { getCurrentChainId, NetworkState } from './networks'; type SmartTransactionsMetaMaskState = { metamask: { @@ -108,7 +107,7 @@ export const getSmartTransactionsPreferenceEnabled = createSelector( ); export const getCurrentChainSupportsSmartTransactions = ( - state: SmartTransactionsMetaMaskState, + state: NetworkState, ): boolean => { const chainId = getCurrentChainId(state); return getAllowedSmartTransactionsChainIds().includes(chainId); diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index de68d8d6e13e..e1fdd8db9535 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -2,14 +2,16 @@ import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { setTokenNetworkFilter } from '../../../../../store/actions'; import { - getCurrentChainId, getCurrentNetwork, getPreferences, getShouldHideZeroBalanceTokens, getSelectedAccount, getAllChainsToPoll, } from '../../../../../selectors'; -import { getNetworkConfigurationsByChainId } from '../../../../../../shared/modules/selectors/networks'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../../shared/modules/selectors/networks'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { SelectableListItem } from '../sort-control/sort-control'; import { Text } from '../../../../component-library/text/text'; 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 0dc9ec05250c..49408dfd5fa4 100644 --- a/ui/components/app/assets/nfts/nft-details/nft-details.tsx +++ b/ui/components/app/assets/nfts/nft-details/nft-details.tsx @@ -20,8 +20,8 @@ import { import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { shortenAddress } from '../../../../../helpers/utils/util'; import { getNftImageAlt } from '../../../../../helpers/utils/nfts'; +import { getCurrentChainId } from '../../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getCurrentCurrency, getCurrentNetwork, getIpfsGateway, diff --git a/ui/components/app/assets/nfts/nfts-items/nfts-items.js b/ui/components/app/assets/nfts/nfts-items/nfts-items.js index 9dd53cd541fc..990477e6fc89 100644 --- a/ui/components/app/assets/nfts/nfts-items/nfts-items.js +++ b/ui/components/app/assets/nfts/nfts-items/nfts-items.js @@ -19,8 +19,8 @@ import { ENVIRONMENT_TYPE_POPUP } from '../../../../../../shared/constants/app'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../../../../app/scripts/lib/util'; +import { getCurrentChainId } from '../../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getIpfsGateway, getSelectedInternalAccount, getCurrentNetwork, diff --git a/ui/components/app/currency-input/currency-input.js b/ui/components/app/currency-input/currency-input.js index 3efcecb35150..83bd3c2edce8 100644 --- a/ui/components/app/currency-input/currency-input.js +++ b/ui/components/app/currency-input/currency-input.js @@ -6,12 +6,11 @@ import { BlockSize } from '../../../helpers/constants/design-system'; import UnitInput from '../../ui/unit-input'; import CurrencyDisplay from '../../ui/currency-display'; import { getNativeCurrency } from '../../../ducks/metamask/metamask'; -import { getProviderConfig } from '../../../../shared/modules/selectors/networks'; import { + getProviderConfig, getCurrentChainId, - getCurrentCurrency, - getShouldShowFiat, -} from '../../../selectors'; +} from '../../../../shared/modules/selectors/networks'; +import { getCurrentCurrency, getShouldShowFiat } from '../../../selectors'; import { EtherDenomination } from '../../../../shared/constants/common'; import { Numeric } from '../../../../shared/modules/Numeric'; import { useIsOriginalNativeTokenSymbol } from '../../../hooks/useIsOriginalNativeTokenSymbol'; diff --git a/ui/components/app/currency-input/hooks/useTokenExchangeRate.tsx b/ui/components/app/currency-input/hooks/useTokenExchangeRate.tsx index 2460098b5f8f..d9db9b284f72 100644 --- a/ui/components/app/currency-input/hooks/useTokenExchangeRate.tsx +++ b/ui/components/app/currency-input/hooks/useTokenExchangeRate.tsx @@ -1,10 +1,8 @@ import { useMemo, useState } from 'react'; import { toChecksumAddress } from 'ethereumjs-util'; import { shallowEqual, useSelector } from 'react-redux'; -import { - getCurrentChainId, - getTokenExchangeRates, -} from '../../../../selectors'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; +import { getTokenExchangeRates } from '../../../../selectors'; import { Numeric } from '../../../../../shared/modules/Numeric'; import { getConversionRate, diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js index ff205f7844f0..4c674a5c437f 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js @@ -10,13 +10,15 @@ import { MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import { - getAllDetectedTokensForSelectedAddress, getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../shared/modules/selectors/networks'; +import { + getAllDetectedTokensForSelectedAddress, getCurrentNetwork, getDetectedTokensInCurrentNetwork, getPreferences, } from '../../../../selectors'; -import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; import Popover from '../../../ui/popover'; import Box from '../../../ui/box'; diff --git a/ui/components/app/detected-token/detected-token-values/detected-token-values.js b/ui/components/app/detected-token/detected-token-values/detected-token-values.js index 07c70edcf196..e3b377f51e88 100644 --- a/ui/components/app/detected-token/detected-token-values/detected-token-values.js +++ b/ui/components/app/detected-token/detected-token-values/detected-token-values.js @@ -8,8 +8,8 @@ import { TextVariant, } from '../../../../helpers/constants/design-system'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getSelectedAddress, getUseCurrencyRateCheck, } from '../../../../selectors'; diff --git a/ui/components/app/detected-token/detected-token.js b/ui/components/app/detected-token/detected-token.js index bb215b4f7301..12957e1aca31 100644 --- a/ui/components/app/detected-token/detected-token.js +++ b/ui/components/app/detected-token/detected-token.js @@ -9,15 +9,15 @@ import { setNewTokensImported, } from '../../../store/actions'; import { - getAllDetectedTokensForSelectedAddress, getCurrentChainId, - getDetectedTokensInCurrentNetwork, - getPreferences, -} from '../../../selectors'; -import { getSelectedNetworkClientId, getNetworkConfigurationsByChainId, } from '../../../../shared/modules/selectors/networks'; +import { + getAllDetectedTokensForSelectedAddress, + getDetectedTokensInCurrentNetwork, + getPreferences, +} from '../../../selectors'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js index c34e4897d50a..bbe72241add8 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js @@ -9,8 +9,10 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; -import { getCurrentChainId } from '../../../../selectors'; -import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../shared/modules/selectors/networks'; function mapStateToProps(state) { return { diff --git a/ui/components/app/snaps/keyring-snap-removal-warning/keyring-snap-removal-warning.tsx b/ui/components/app/snaps/keyring-snap-removal-warning/keyring-snap-removal-warning.tsx index 2377076690b0..831c69a587dd 100644 --- a/ui/components/app/snaps/keyring-snap-removal-warning/keyring-snap-removal-warning.tsx +++ b/ui/components/app/snaps/keyring-snap-removal-warning/keyring-snap-removal-warning.tsx @@ -25,7 +25,7 @@ import { } from '../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import InfoTooltip from '../../../ui/info-tooltip'; -import { getCurrentChainId } from '../../../../selectors'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { KeyringAccountListItem } from './keyring-account-list-item'; export default function KeyringRemovalSnapWarning({ diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index 47b422457143..0a0d081016fb 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -15,6 +15,9 @@ import { } from '../../../selectors/transactions'; import { getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../shared/modules/selectors/networks'; +import { getSelectedAccount, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getShouldHideZeroBalanceTokens, @@ -55,7 +58,6 @@ import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { getMultichainNetwork } from '../../../selectors/multichain'; import { endTrace, TraceName } from '../../../../shared/lib/trace'; -import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; const PAGE_INCREMENT = 10; 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 7610890d48da..10fd24ec550c 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx @@ -8,8 +8,8 @@ import { getShouldHideZeroBalanceTokens, getTokensMarketData, getPreferences, - getCurrentChainId, } from '../../../selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; @@ -27,6 +27,9 @@ jest.mock('../../../selectors', () => ({ getPreferences: jest.fn(), getShouldHideZeroBalanceTokens: jest.fn(), getTokensMarketData: jest.fn(), +})); + +jest.mock('../../../../shared/modules/selectors/networks', () => ({ getCurrentChainId: jest.fn(), })); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx index 89bc94dab774..d50feebbbd74 100644 --- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx @@ -9,8 +9,8 @@ import { getShouldHideZeroBalanceTokens, getTokensMarketData, getPreferences, - getCurrentChainId, } from '../../../selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; // TODO: Remove restricted import diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index c3708eaef344..0fcb9f2e2389 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -55,7 +55,6 @@ import { getMemoizedUnapprovedTemplatedConfirmations, ///: END:ONLY_INCLUDE_IF getNetworkConfigurationIdByChainId, - getCurrentChainId, } from '../../../selectors'; import Tooltip from '../../ui/tooltip'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -100,6 +99,7 @@ import { getMultichainNativeCurrency, } from '../../../selectors/multichain'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; type CoinButtonsProps = { account: InternalAccount; diff --git a/ui/components/app/wallet-overview/eth-overview.js b/ui/components/app/wallet-overview/eth-overview.js index c42986ab30ab..1228a995ae54 100644 --- a/ui/components/app/wallet-overview/eth-overview.js +++ b/ui/components/app/wallet-overview/eth-overview.js @@ -3,10 +3,10 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { EthMethod } from '@metamask/keyring-api'; import { isEqual } from 'lodash'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { isBalanceCached, getIsSwapsChain, - getCurrentChainId, getSelectedInternalAccount, getSelectedAccountCachedBalance, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js index ce6390b07296..d5af76004887 100644 --- a/ui/components/multichain/account-details/account-details-display.js +++ b/ui/components/multichain/account-details/account-details-display.js @@ -7,7 +7,6 @@ import EditableLabel from '../../ui/editable-label/editable-label'; import { setAccountLabel } from '../../../store/actions'; import { - getCurrentChainId, getHardwareWalletType, getInternalAccountByAddress, } from '../../../selectors'; @@ -30,6 +29,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; export const AccountDetailsDisplay = ({ accounts, diff --git a/ui/components/multichain/account-list-item-menu/account-list-item-menu.js b/ui/components/multichain/account-list-item-menu/account-list-item-menu.js index 3bb1295eabce..f81752d928fe 100644 --- a/ui/components/multichain/account-list-item-menu/account-list-item-menu.js +++ b/ui/components/multichain/account-list-item-menu/account-list-item-menu.js @@ -6,8 +6,8 @@ import { mmiActionsFactory } from '../../../store/institutional/institution-back ///: END:ONLY_INCLUDE_IF import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getHardwareWalletType, getAccountTypeForKeyring, getPinnedAccountsList, diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx index 47d487f5ffd8..f6e7e7f3ccc9 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; -import { getTokenList, getCurrentChainId } from '../../../../selectors'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; +import { getTokenList } from '../../../../selectors'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; import { TokenListItem } from '../../token-list-item'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx index 1017ef1aad6e..6f867d25ca85 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; import classnames from 'classnames'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getCurrentCurrency, getSelectedAccountCachedBalance, } from '../../../../selectors'; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx index 566783abed11..fc4796073c74 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx @@ -12,7 +12,6 @@ import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import mockState from '../../../../../test/data/mock-send-state.json'; import { AssetType } from '../../../../../shared/constants/transaction'; import { - getCurrentChainId, getCurrentCurrency, getNativeCurrencyImage, getSelectedAccountCachedBalance, @@ -30,6 +29,7 @@ import { getTopAssets } from '../../../../ducks/swaps/swaps'; import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch'; import * as actions from '../../../../store/actions'; import { getSwapsBlockedTokens } from '../../../../ducks/send'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { AssetPickerModal } from './asset-picker-modal'; import AssetList from './AssetList'; import { ERC20Asset } from './types'; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx index 729a580f269e..48c56c5106ec 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx @@ -29,10 +29,9 @@ import { import { useI18nContext } from '../../../../hooks/useI18nContext'; import { AssetType } from '../../../../../shared/constants/transaction'; - +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { getAllTokens, - getCurrentChainId, getCurrentCurrency, getNativeCurrencyImage, getSelectedAccountCachedBalance, diff --git a/ui/components/multichain/detected-token-banner/detected-token-banner.js b/ui/components/multichain/detected-token-banner/detected-token-banner.js index 4f76fa22fb35..7cd315d57cab 100644 --- a/ui/components/multichain/detected-token-banner/detected-token-banner.js +++ b/ui/components/multichain/detected-token-banner/detected-token-banner.js @@ -6,11 +6,13 @@ import classNames from 'classnames'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../shared/modules/selectors/networks'; +import { getDetectedTokensInCurrentNetwork, getAllDetectedTokensForSelectedAddress, getPreferences, } from '../../../selectors'; -import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { MetaMetricsEventCategory, diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js index 549f0ab5c70d..f0ff312f1f57 100644 --- a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js @@ -22,8 +22,8 @@ import { } from '../../../helpers/constants/design-system'; import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getIsMainnet, getSelectedInternalAccount, getOpenSeaEnabled, diff --git a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js index a1194f2285f2..b6ffda5b8d9e 100644 --- a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js +++ b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js @@ -13,6 +13,9 @@ import { Tab, Tabs } from '../../ui/tabs'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId, + getSelectedNetworkClientId, +} from '../../../../shared/modules/selectors/networks'; +import { getInternalAccounts, getIsDynamicTokenListAvailable, getIsMainnet, @@ -27,7 +30,6 @@ import { getTestNetworkBackgroundColor, getTokenExchangeRates, } from '../../../selectors'; -import { getSelectedNetworkClientId } from '../../../../shared/modules/selectors/networks'; import { addImportedTokens, clearPendingTokens, diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 8b18170adc6c..07d4147ad763 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -36,9 +36,11 @@ import { FEATURED_RPCS, TEST_CHAINS, } from '../../../../shared/constants/network'; -import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { + getNetworkConfigurationsByChainId, getCurrentChainId, +} from '../../../../shared/modules/selectors/networks'; +import { getShowTestNetworks, getOnboardedInThisUISession, getShowNetworkBanner, diff --git a/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.test.tsx b/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.test.tsx index ff3ff4265703..1ef3347e3133 100644 --- a/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.test.tsx +++ b/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.test.tsx @@ -9,9 +9,9 @@ import { getUsedSwapsGasPrice } from '../../../../../../../ducks/swaps/swaps'; import { getCurrentCurrency, checkNetworkAndAccountSupports1559, - getCurrentChainId, getIsSwapsChain, } from '../../../../../../../selectors'; +import { getCurrentChainId } from '../../../../../../../../shared/modules/selectors/networks'; import useEthFeeData from './useEthFeeData'; jest.mock('react-redux', () => ({ diff --git a/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.tsx b/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.tsx index 25a77edfced4..04fa59ce072a 100644 --- a/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.tsx +++ b/ui/components/multichain/pages/send/components/quote-card/hooks/useEthFeeData.tsx @@ -11,9 +11,9 @@ import { EtherDenomination } from '../../../../../../../../shared/constants/comm import { getCurrentCurrency, checkNetworkAndAccountSupports1559, - getCurrentChainId, getIsSwapsChain, } from '../../../../../../../selectors/selectors'; +import { getCurrentChainId } from '../../../../../../../../shared/modules/selectors/networks'; import { fetchAndSetSwapsGasPriceInfo, getUsedSwapsGasPrice, diff --git a/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.test.tsx b/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.test.tsx index 8bed4bf9eb2e..f47c988f2a74 100644 --- a/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.test.tsx +++ b/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.test.tsx @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; import { CHAIN_IDS } from '../../../../../../../../shared/constants/network'; import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; -import { getCurrentChainId } from '../../../../../../../selectors'; +import { getCurrentChainId } from '../../../../../../../../shared/modules/selectors/networks'; import useTranslatedNetworkName from './useTranslatedNetworkName'; jest.mock('react-redux', () => ({ diff --git a/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.tsx b/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.tsx index 366a87d6947c..276d84d4bc15 100644 --- a/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.tsx +++ b/ui/components/multichain/pages/send/components/quote-card/hooks/useTranslatedNetworkName.tsx @@ -2,7 +2,7 @@ import { useSelector } from 'react-redux'; import { toHex } from '@metamask/controller-utils'; import { CHAIN_IDS } from '../../../../../../../../shared/constants/network'; import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; -import { getCurrentChainId } from '../../../../../../../selectors'; +import { getCurrentChainId } from '../../../../../../../../shared/modules/selectors/networks'; export default function useTranslatedNetworkName() { const chainId = useSelector(getCurrentChainId); diff --git a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx index 439c030a59cd..f157afa64d8b 100644 --- a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx +++ b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.test.tsx @@ -8,8 +8,8 @@ import { getCurrentCurrency, getSelectedAccountCachedBalance, getTokensMarketData, - getCurrentChainId, } from '../../../../../selectors'; +import { getCurrentChainId } from '../../../../../../shared/modules/selectors/networks'; import { getConversionRate, getNativeCurrency, @@ -28,6 +28,9 @@ jest.mock('../../../../../selectors', () => ({ getCurrentCurrency: jest.fn(), getSelectedAccountCachedBalance: jest.fn(), getTokensMarketData: jest.fn(), +})); + +jest.mock('../../../../../../shared/modules/selectors/networks', () => ({ getCurrentChainId: jest.fn(), })); diff --git a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx index f1ba436ef47f..dc0aeaa5a25c 100644 --- a/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx +++ b/ui/components/multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change.tsx @@ -9,8 +9,8 @@ import { TextColor, TextVariant, } from '../../../../../helpers/constants/design-system'; +import { getCurrentChainId } from '../../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getCurrentCurrency, getSelectedAccountCachedBalance, getTokensMarketData, diff --git a/ui/ducks/domains.js b/ui/ducks/domains.js index 2b0146cde1ba..b1053fe9903e 100644 --- a/ui/ducks/domains.js +++ b/ui/ducks/domains.js @@ -7,11 +7,11 @@ import { } from '@metamask/snaps-rpc-methods'; import { getAddressBookEntry, - getCurrentChainId, getNameLookupSnapsIds, getPermissionSubjects, getSnapMetadata, } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { handleSnapRequest } from '../store/actions'; import { NO_RESOLUTION_FOR_DOMAIN } from '../pages/confirmations/send/send.constants'; import { CHAIN_CHANGED } from '../store/actionConstants'; diff --git a/ui/ducks/ramps/ramps.test.ts b/ui/ducks/ramps/ramps.test.ts index 8bd6865295d8..a2d58e258fc9 100644 --- a/ui/ducks/ramps/ramps.test.ts +++ b/ui/ducks/ramps/ramps.test.ts @@ -1,6 +1,7 @@ import { configureStore, Store } from '@reduxjs/toolkit'; import RampAPI from '../../helpers/ramps/rampApi/rampAPI'; -import { getCurrentChainId, getUseExternalServices } from '../../selectors'; +import { getUseExternalServices } from '../../selectors'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { getMultichainIsBitcoin } from '../../selectors/multichain'; import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; @@ -15,9 +16,14 @@ import { defaultBuyableChains } from './constants'; jest.mock('../../helpers/ramps/rampApi/rampAPI'); const mockedRampAPI = RampAPI as jest.Mocked; +jest.mock('../../../shared/modules/selectors/networks', () => ({ + getCurrentChainId: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getSelectedNetworkClientId: jest.fn(), +})); + jest.mock('../../selectors', () => ({ ...jest.requireActual('../../selectors'), - getCurrentChainId: jest.fn(), getUseExternalServices: jest.fn(), getNames: jest.fn(), })); diff --git a/ui/ducks/ramps/ramps.ts b/ui/ducks/ramps/ramps.ts index a6732996d74b..b4e25e19a7ae 100644 --- a/ui/ducks/ramps/ramps.ts +++ b/ui/ducks/ramps/ramps.ts @@ -1,6 +1,7 @@ import { createSelector } from 'reselect'; import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { getCurrentChainId, getUseExternalServices } from '../../selectors'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; +import { getUseExternalServices } from '../../selectors'; import RampAPI from '../../helpers/ramps/rampApi/rampAPI'; import { hexToDecimal } from '../../../shared/modules/conversion.utils'; import { getMultichainIsBitcoin } from '../../selectors/multichain'; diff --git a/ui/ducks/send/helpers.js b/ui/ducks/send/helpers.js index b48e05034f59..5fcee6f7c88b 100644 --- a/ui/ducks/send/helpers.js +++ b/ui/ducks/send/helpers.js @@ -18,10 +18,10 @@ import { generateERC1155TransferData, getAssetTransferData, } from '../../pages/confirmations/send/send.utils'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { checkNetworkAndAccountSupports1559, getConfirmationExchangeRates, - getCurrentChainId, getGasPriceInHexWei, getTokenExchangeRates, } from '../../selectors'; diff --git a/ui/ducks/send/helpers.test.js b/ui/ducks/send/helpers.test.js index 7129ffca8194..87bf1bcbcdc6 100644 --- a/ui/ducks/send/helpers.test.js +++ b/ui/ducks/send/helpers.test.js @@ -12,10 +12,10 @@ import { generateERC20TransferData, generateERC721TransferData, } from '../../pages/confirmations/send/send.utils'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { checkNetworkAndAccountSupports1559, getConfirmationExchangeRates, - getCurrentChainId, getTokenExchangeRates, } from '../../selectors'; import { getGasFeeEstimates, getNativeCurrency } from '../metamask/metamask'; @@ -48,10 +48,15 @@ jest.mock('../../pages/confirmations/send/send.utils', () => ({ getAssetTransferData: jest.fn(), })); +jest.mock('../../../shared/modules/selectors/networks', () => ({ + getCurrentChainId: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getSelectedNetworkClientId: jest.fn(), +})); + jest.mock('../../selectors', () => ({ checkNetworkAndAccountSupports1559: jest.fn(), getConfirmationExchangeRates: jest.fn(), - getCurrentChainId: jest.fn(), getGasPriceInHexWei: jest.fn(), getTokenExchangeRates: jest.fn(), })); diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 15acc3355d8f..36ccf9d260da 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -38,8 +38,12 @@ import { isTokenBalanceSufficient, } from '../../pages/confirmations/send/send.utils'; import { - getAdvancedInlineGasShown, getCurrentChainId, + getSelectedNetworkClientId, + getProviderConfig, +} from '../../../shared/modules/selectors/networks'; +import { + getAdvancedInlineGasShown, getGasPriceInHexWei, getIsMainnet, getTargetAccount, @@ -56,10 +60,6 @@ import { getIsSwapsChain, getUseExternalServices, } from '../../selectors'; -import { - getSelectedNetworkClientId, - getProviderConfig, -} from '../../../shared/modules/selectors/networks'; import { displayWarning, hideLoadingIndication, diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js index 4886d1dbdad7..bc1c675e4d21 100644 --- a/ui/ducks/swaps/swaps.js +++ b/ui/ducks/swaps/swaps.js @@ -56,12 +56,12 @@ import { getValueFromWeiHex, hexWEIToDecGWEI, } from '../../../shared/modules/conversion.utils'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { getSelectedAccount, getTokenExchangeRates, getUSDConversionRate, getSwapsDefaultToken, - getCurrentChainId, isHardwareWallet, getHardwareWalletType, checkNetworkAndAccountSupports1559, diff --git a/ui/hooks/bridge/useLatestBalance.ts b/ui/hooks/bridge/useLatestBalance.ts index 524d65503249..b3398dc18333 100644 --- a/ui/hooks/bridge/useLatestBalance.ts +++ b/ui/hooks/bridge/useLatestBalance.ts @@ -2,11 +2,8 @@ import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { Numeric } from '../../../shared/modules/Numeric'; import { DEFAULT_PRECISION } from '../useCurrencyDisplay'; -import { - getCurrentChainId, - getSelectedInternalAccount, - SwapsEthToken, -} from '../../selectors'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; +import { getSelectedInternalAccount, SwapsEthToken } from '../../selectors'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { calcLatestSrcBalance } from '../../../shared/modules/bridge-utils/balance'; import { useAsyncResult } from '../useAsyncResult'; diff --git a/ui/hooks/ramps/useRamps/useRamps.ts b/ui/hooks/ramps/useRamps/useRamps.ts index 429abbf1a96b..3dd230eaf6ea 100644 --- a/ui/hooks/ramps/useRamps/useRamps.ts +++ b/ui/hooks/ramps/useRamps/useRamps.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { CaipChainId } from '@metamask/utils'; +import { CaipChainId, Hex } from '@metamask/utils'; import { ChainId } from '../../../../shared/constants/network'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getDataCollectionForMarketing, getMetaMetricsId, getParticipateInMetaMetrics, @@ -32,7 +32,7 @@ const useRamps = ( const isMarketingEnabled = useSelector(getDataCollectionForMarketing); const getBuyURI = useCallback( - (_chainId: ChainId | CaipChainId) => { + (_chainId: Hex | CaipChainId) => { const params = new URLSearchParams(); params.set('metamaskEntry', metamaskEntry); params.set('chainId', _chainId); diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts index dd5b8aaab579..b41fc38a9930 100644 --- a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts @@ -28,6 +28,7 @@ jest.mock('../ducks/metamask/metamask', () => ({ jest.mock('../../shared/modules/selectors/networks', () => ({ getSelectedNetworkClientId: jest.fn(), getNetworkConfigurationsByChainId: jest.fn(), + getCurrentChainId: jest.fn(), })); const mockGetCurrencyRates = getCurrencyRates as jest.Mock; diff --git a/ui/hooks/useAccountTotalFiatBalance.js b/ui/hooks/useAccountTotalFiatBalance.js index aa1f906473ef..1c86b29ea8ea 100644 --- a/ui/hooks/useAccountTotalFiatBalance.js +++ b/ui/hooks/useAccountTotalFiatBalance.js @@ -1,8 +1,8 @@ import { shallowEqual, useSelector } from 'react-redux'; import { toChecksumAddress } from 'ethereumjs-util'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { getAllTokens, - getCurrentChainId, getCurrentCurrency, getMetaMaskCachedBalances, getTokenExchangeRates, diff --git a/ui/hooks/useCurrentAsset.js b/ui/hooks/useCurrentAsset.js index 97fa27fc196c..d6c9f367c22a 100644 --- a/ui/hooks/useCurrentAsset.js +++ b/ui/hooks/useCurrentAsset.js @@ -1,7 +1,7 @@ import { useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router-dom'; import { getTokens } from '../ducks/metamask/metamask'; -import { getCurrentChainId } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { ASSET_ROUTE } from '../helpers/constants/routes'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP, diff --git a/ui/hooks/useGetFormattedTokensPerChain.test.ts b/ui/hooks/useGetFormattedTokensPerChain.test.ts index 973a16b0e648..231faae66bf9 100644 --- a/ui/hooks/useGetFormattedTokensPerChain.test.ts +++ b/ui/hooks/useGetFormattedTokensPerChain.test.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { renderHook } from '@testing-library/react-hooks'; import { act } from 'react-dom/test-utils'; -import { getAllTokens, getCurrentChainId } from '../selectors'; +import { getAllTokens } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { useGetFormattedTokensPerChain } from './useGetFormattedTokensPerChain'; import { stringifyBalance } from './useTokenBalances'; @@ -9,8 +10,11 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn((selector) => selector()), })); -jest.mock('../selectors', () => ({ +jest.mock('../../shared/modules/selectors/networks', () => ({ getCurrentChainId: jest.fn(), +})); + +jest.mock('../selectors', () => ({ getAllTokens: jest.fn(), })); diff --git a/ui/hooks/useGetFormattedTokensPerChain.ts b/ui/hooks/useGetFormattedTokensPerChain.ts index a69f5be9e1e0..21a5c1a7a78a 100644 --- a/ui/hooks/useGetFormattedTokensPerChain.ts +++ b/ui/hooks/useGetFormattedTokensPerChain.ts @@ -1,7 +1,8 @@ import { useSelector } from 'react-redux'; import { BN } from 'bn.js'; import { Token } from '@metamask/assets-controllers'; -import { getAllTokens, getCurrentChainId } from '../selectors'; +import { getAllTokens } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { hexToDecimal } from '../../shared/modules/conversion.utils'; import { TokenWithBalance } from '../components/multichain/asset-picker-amount/asset-picker-modal/types'; diff --git a/ui/hooks/useNftsCollections.js b/ui/hooks/useNftsCollections.js index cfb3958da6b3..3c6a861d3d6a 100644 --- a/ui/hooks/useNftsCollections.js +++ b/ui/hooks/useNftsCollections.js @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { isEqual } from 'lodash'; import { getNfts, getNftContracts } from '../ducks/metamask/metamask'; -import { getCurrentChainId, getSelectedInternalAccount } from '../selectors'; +import { getSelectedInternalAccount } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { usePrevious } from './usePrevious'; import { useI18nContext } from './useI18nContext'; diff --git a/ui/hooks/useSwappedTokenValue.js b/ui/hooks/useSwappedTokenValue.js index c7007346d7c0..1c67577cbef5 100644 --- a/ui/hooks/useSwappedTokenValue.js +++ b/ui/hooks/useSwappedTokenValue.js @@ -5,7 +5,7 @@ import { isSwapsDefaultTokenAddress, isSwapsDefaultTokenSymbol, } from '../../shared/modules/swaps.utils'; -import { getCurrentChainId } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { useTokenFiatAmount } from './useTokenFiatAmount'; /** diff --git a/ui/hooks/useTokensToSearch.js b/ui/hooks/useTokensToSearch.js index 7604ca0a22bc..bf37f6cb08fe 100644 --- a/ui/hooks/useTokensToSearch.js +++ b/ui/hooks/useTokensToSearch.js @@ -8,9 +8,9 @@ import { getTokenExchangeRates, getCurrentCurrency, getSwapsDefaultToken, - getCurrentChainId, getTokenList, } from '../selectors'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { getConversionRate } from '../ducks/metamask/metamask'; import { getSwapsTokens } from '../ducks/swaps/swaps'; diff --git a/ui/index.js b/ui/index.js index 8cf2048cba41..a63b2acc86fa 100644 --- a/ui/index.js +++ b/ui/index.js @@ -19,6 +19,7 @@ import { COPY_OPTIONS } from '../shared/constants/copy'; import switchDirection from '../shared/lib/switch-direction'; import { setupLocale } from '../shared/lib/error-utils'; import { trace, TraceName } from '../shared/lib/trace'; +import { getCurrentChainId } from '../shared/modules/selectors/networks'; import * as actions from './store/actions'; import configureStore from './store/store'; import { @@ -29,7 +30,6 @@ import { getNetworkToAutomaticallySwitchTo, getSwitchedNetworkDetails, getUseRequestQueue, - getCurrentChainId, } from './selectors'; import { ALERT_STATE } from './ducks/alerts'; import { diff --git a/ui/pages/asset/components/token-buttons.tsx b/ui/pages/asset/components/token-buttons.tsx index 6f14e6ae8799..94e6f2674929 100644 --- a/ui/pages/asset/components/token-buttons.tsx +++ b/ui/pages/asset/components/token-buttons.tsx @@ -27,9 +27,9 @@ import { getIsBridgeChain, getCurrentKeyring, ///: END:ONLY_INCLUDE_IF - getCurrentChainId, getNetworkConfigurationIdByChainId, } from '../../../selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import useBridging from '../../../hooks/bridge/useBridging'; ///: END:ONLY_INCLUDE_IF diff --git a/ui/pages/confirm-add-suggested-nft/confirm-add-suggested-nft.js b/ui/pages/confirm-add-suggested-nft/confirm-add-suggested-nft.js index dda856d64abd..a94216acf64e 100644 --- a/ui/pages/confirm-add-suggested-nft/confirm-add-suggested-nft.js +++ b/ui/pages/confirm-add-suggested-nft/confirm-add-suggested-nft.js @@ -27,8 +27,8 @@ import { Box, Text, } from '../../components/component-library'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getRpcPrefsForCurrentProvider, getSuggestedNfts, getIpfsGateway, diff --git a/ui/pages/create-account/connect-hardware/index.js b/ui/pages/create-account/connect-hardware/index.js index 85464baccb69..22b335c75b17 100644 --- a/ui/pages/create-account/connect-hardware/index.js +++ b/ui/pages/create-account/connect-hardware/index.js @@ -2,8 +2,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import * as actions from '../../../store/actions'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getMetaMaskAccounts, getRpcPrefsForCurrentProvider, getMetaMaskAccountsConnected, diff --git a/ui/pages/institutional/custody/custody.tsx b/ui/pages/institutional/custody/custody.tsx index c4bdc4b7a252..bb4f7d570555 100644 --- a/ui/pages/institutional/custody/custody.tsx +++ b/ui/pages/institutional/custody/custody.tsx @@ -39,10 +39,8 @@ import { CUSTODY_ACCOUNT_ROUTE, DEFAULT_ROUTE, } from '../../../helpers/constants/routes'; -import { - getCurrentChainId, - getSelectedInternalAccount, -} from '../../../selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; +import { getSelectedInternalAccount } from '../../../selectors'; import { getMMIConfiguration } from '../../../selectors/institutional/selectors'; import { getInstitutionalConnectRequests } from '../../../ducks/institutional/institutional'; import CustodyAccountList from '../account-list'; diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index c155be4ba488..28f4291ee37c 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -1,6 +1,11 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { compose } from 'redux'; +import { + getCurrentChainId, + isNetworkLoading, + getProviderConfig, +} from '../../../shared/modules/selectors/networks'; import { getAllAccountsOnNetworkAreEmpty, getIsNetworkUsed, @@ -8,7 +13,6 @@ import { getPreferences, getTheme, getIsTestnet, - getCurrentChainId, getShouldShowSeedPhraseReminder, isCurrentProviderCustom, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) @@ -25,10 +29,6 @@ import { getUnapprovedTransactions, getPendingApprovals, } from '../../selectors'; -import { - isNetworkLoading, - getProviderConfig, -} from '../../../shared/modules/selectors/networks'; import { lockMetamask, hideImportNftsModal, diff --git a/ui/pages/smart-transactions/smart-transaction-status-page/smart-transaction-status-page.tsx b/ui/pages/smart-transactions/smart-transaction-status-page/smart-transaction-status-page.tsx index 5e4ef2511a51..38769760b058 100644 --- a/ui/pages/smart-transactions/smart-transaction-status-page/smart-transaction-status-page.tsx +++ b/ui/pages/smart-transactions/smart-transaction-status-page/smart-transaction-status-page.tsx @@ -26,7 +26,8 @@ import { IconColor, } from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { getCurrentChainId, getFullTxData } from '../../../selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; +import { getFullTxData } from '../../../selectors'; import { BaseUrl } from '../../../../shared/constants/urls'; import { hideLoadingIndication } from '../../../store/actions'; import { hexToDecimal } from '../../../../shared/modules/conversion.utils'; diff --git a/ui/pages/swaps/awaiting-swap/awaiting-swap.js b/ui/pages/swaps/awaiting-swap/awaiting-swap.js index 111af726acfa..d39608d49d2b 100644 --- a/ui/pages/swaps/awaiting-swap/awaiting-swap.js +++ b/ui/pages/swaps/awaiting-swap/awaiting-swap.js @@ -12,9 +12,8 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; - +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getCurrentCurrency, getRpcPrefsForCurrentProvider, getUSDConversionRate, diff --git a/ui/pages/swaps/hooks/useUpdateSwapsState.test.ts b/ui/pages/swaps/hooks/useUpdateSwapsState.test.ts index f01df2ab50bc..35034d5e888f 100644 --- a/ui/pages/swaps/hooks/useUpdateSwapsState.test.ts +++ b/ui/pages/swaps/hooks/useUpdateSwapsState.test.ts @@ -13,9 +13,9 @@ import { setTopAssets, } from '../../../ducks/swaps/swaps'; import { setSwapsTokens } from '../../../store/actions'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { checkNetworkAndAccountSupports1559, - getCurrentChainId, getIsSwapsChain, getUseExternalServices, } from '../../../selectors'; diff --git a/ui/pages/swaps/hooks/useUpdateSwapsState.ts b/ui/pages/swaps/hooks/useUpdateSwapsState.ts index dc44c51a8489..6ecb9b815073 100644 --- a/ui/pages/swaps/hooks/useUpdateSwapsState.ts +++ b/ui/pages/swaps/hooks/useUpdateSwapsState.ts @@ -12,9 +12,9 @@ import { setTopAssets, } from '../../../ducks/swaps/swaps'; import { setSwapsTokens } from '../../../store/actions'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { checkNetworkAndAccountSupports1559, - getCurrentChainId, getIsSwapsChain, getUseExternalServices, } from '../../../selectors'; diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js index e16166297545..8685bf12ca4c 100644 --- a/ui/pages/swaps/index.js +++ b/ui/pages/swaps/index.js @@ -19,12 +19,12 @@ import { I18nContext } from '../../contexts/i18n'; import { getSelectedAccount, - getCurrentChainId, getIsSwapsChain, isHardwareWallet, getHardwareWalletType, getTokenList, } from '../../selectors/selectors'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { getQuotes, clearSwapsState, diff --git a/ui/pages/swaps/list-with-search/list-with-search.js b/ui/pages/swaps/list-with-search/list-with-search.js index 6208e6d0f8bb..16951409d678 100644 --- a/ui/pages/swaps/list-with-search/list-with-search.js +++ b/ui/pages/swaps/list-with-search/list-with-search.js @@ -19,7 +19,7 @@ import ItemList from '../searchable-item-list/item-list'; import { isValidHexAddress } from '../../../../shared/modules/hexstring-utils'; import { I18nContext } from '../../../contexts/i18n'; import { fetchToken } from '../swaps.util'; -import { getCurrentChainId } from '../../../selectors/selectors'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; let timeoutIdForSearch; 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 1e5eb5179e2c..63af7ff75ad1 100644 --- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -54,11 +54,11 @@ import { getLatestAddedTokenTo, getUsedQuote, } from '../../../ducks/swaps/swaps'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { getSwapsDefaultToken, getTokenExchangeRates, getCurrentCurrency, - getCurrentChainId, getRpcPrefsForCurrentProvider, getTokenList, isHardwareWallet, diff --git a/ui/pages/swaps/prepare-swap-page/review-quote.js b/ui/pages/swaps/prepare-swap-page/review-quote.js index 680ece113f5d..96c7cf9ac4c8 100644 --- a/ui/pages/swaps/prepare-swap-page/review-quote.js +++ b/ui/pages/swaps/prepare-swap-page/review-quote.js @@ -45,13 +45,13 @@ import { getSmartTransactionFees, getCurrentSmartTransactionsEnabled, } from '../../../ducks/swaps/swaps'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { conversionRateSelector, getSelectedAccount, getCurrentCurrency, getTokenExchangeRates, getSwapsDefaultToken, - getCurrentChainId, isHardwareWallet, getHardwareWalletType, checkNetworkAndAccountSupports1559, 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 bd1bb5aa5aaf..9a35d48db403 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 @@ -7,8 +7,8 @@ import UrlIcon from '../../../../components/ui/url-icon'; import Button from '../../../../components/ui/button'; import ActionableMessage from '../../../../components/ui/actionable-message/actionable-message'; import { I18nContext } from '../../../../contexts/i18n'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { - getCurrentChainId, getRpcPrefsForCurrentProvider, getUseCurrencyRateCheck, } from '../../../../selectors'; diff --git a/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js b/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js index 079089ef36bd..5ebd28762085 100644 --- a/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js +++ b/ui/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js @@ -8,7 +8,7 @@ import TextField from '../../../../components/ui/text-field'; import { usePrevious } from '../../../../hooks/usePrevious'; import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils'; import { fetchToken } from '../../swaps.util'; -import { getCurrentChainId } from '../../../../selectors/selectors'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import SearchIcon from '../../../../components/ui/icon/search-icon'; const renderAdornment = () => ( 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 d3127c9a94f3..ae051eeeefc1 100644 --- a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js +++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js @@ -13,10 +13,10 @@ import { cancelSwapsSmartTransaction, getUsedQuote, } from '../../../ducks/swaps/swaps'; +import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { isHardwareWallet, getHardwareWalletType, - getCurrentChainId, getRpcPrefsForCurrentProvider, } from '../../../selectors'; import { diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index b68d598a0839..10183c916652 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -28,11 +28,13 @@ import { subtractHexes, sumHexes, } from '../../shared/modules/conversion.utils'; -import { getProviderConfig } from '../../shared/modules/selectors/networks'; +import { + getProviderConfig, + getCurrentChainId, +} from '../../shared/modules/selectors/networks'; import { getAveragePriceEstimateInHexWEI } from './custom-gas'; import { checkNetworkAndAccountSupports1559, - getCurrentChainId, getMetaMaskAccounts, getTokenExchangeRates, } from './selectors'; diff --git a/ui/selectors/institutional/selectors.ts b/ui/selectors/institutional/selectors.ts index eb70e0fcd72c..6ef1c0bf14ca 100644 --- a/ui/selectors/institutional/selectors.ts +++ b/ui/selectors/institutional/selectors.ts @@ -175,12 +175,13 @@ export function getIsCustodianSupportedChain( // @ts-expect-error state types don't match const selectedAccount = getSelectedInternalAccount(state); const accountType = getAccountType(state); - const providerConfig = getProviderConfig(state); - if (!selectedAccount || !accountType || !providerConfig) { + if (!selectedAccount || !accountType) { throw new Error('Invalid state'); } + const providerConfig = getProviderConfig(state); + if (typeof providerConfig.chainId !== 'string') { throw new Error('Chain ID must be a string'); } diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 903b5d0a4a71..375949db877f 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -29,10 +29,10 @@ import { getProviderConfig, NetworkState, getNetworkConfigurationsByChainId, + getCurrentChainId, } from '../../shared/modules/selectors/networks'; import { AccountsState, getSelectedInternalAccount } from './accounts'; import { - getCurrentChainId, getCurrentCurrency, getIsMainnet, getMaybeSelectedInternalAccount, diff --git a/ui/selectors/nft.ts b/ui/selectors/nft.ts index ab3836714923..15b564f3d422 100644 --- a/ui/selectors/nft.ts +++ b/ui/selectors/nft.ts @@ -1,5 +1,6 @@ import { Nft, NftContract } from '@metamask/assets-controllers'; import { createSelector } from 'reselect'; +import { NetworkState } from '../../shared/modules/selectors/networks'; import { getMemoizedCurrentChainId } from './selectors'; export type NftState = { @@ -62,9 +63,9 @@ export const getNftContractsByAddressByChain = createSelector( ); export const getNftContractsByAddressOnCurrentChain = createSelector( + (state: NftState & NetworkState) => getMemoizedCurrentChainId(state), getNftContractsByAddressByChain, - getMemoizedCurrentChainId, - (nftContractsByAddressByChain, currentChainId) => { + (currentChainId, nftContractsByAddressByChain) => { return nftContractsByAddressByChain[currentChainId] ?? {}; }, ); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 154864dc9af4..e853e28d300b 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -12,6 +12,12 @@ import { NameType } from '@metamask/name-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import { RpcEndpointType } from '@metamask/network-controller'; +import { + getCurrentChainId, + getProviderConfig, + getSelectedNetworkClientId, + getNetworkConfigurationsByChainId, +} from '../../shared/modules/selectors/networks'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { addHexPrefix, getEnvironmentType } from '../../app/scripts/lib/util'; @@ -104,11 +110,6 @@ import { MultichainNativeAssets } from '../../shared/constants/multichain/assets import { BridgeFeatureFlagsKey } from '../../app/scripts/controllers/bridge/types'; import { hasTransactionData } from '../../shared/modules/transaction.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; -import { - getProviderConfig, - getSelectedNetworkClientId, - getNetworkConfigurationsByChainId, -} from '../../shared/modules/selectors/networks'; import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { getAllUnapprovedTransactions, @@ -132,11 +133,6 @@ export function getNetworkIdentifier(state) { return nickname || rpcUrl || type; } -export function getCurrentChainId(state) { - const { chainId } = getProviderConfig(state); - return chainId; -} - export function getMetaMetricsId(state) { const { metaMetricsId } = state.metamask; return metaMetricsId; diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index c749d8ff3fe7..a7c5613eb169 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -13,9 +13,13 @@ import { createMockInternalAccount } from '../../test/jest/mocks'; import { mockNetworkState } from '../../test/stub/networks'; import { DeleteRegulationStatus } from '../../shared/constants/metametrics'; import { selectSwitchedNetworkNeverShowMessage } from '../components/app/toast-master/selectors'; -import { getProviderConfig } from '../../shared/modules/selectors/networks'; +import * as networkSelectors from '../../shared/modules/selectors/networks'; import * as selectors from './selectors'; +jest.mock('../../shared/modules/selectors/networks', () => ({ + ...jest.requireActual('../../shared/modules/selectors/networks'), +})); + jest.mock('../../app/scripts/lib/util', () => ({ ...jest.requireActual('../../app/scripts/lib/util'), getEnvironmentType: jest.fn().mockReturnValue('popup'), @@ -189,7 +193,7 @@ describe('Selectors', () => { expect(selectors.getSwitchedNetworkDetails(state)).toStrictEqual({ imageUrl: './images/eth_logo.svg', - nickname: getProviderConfig(state).nickname, + nickname: networkSelectors.getProviderConfig(state).nickname, origin, }); }); @@ -1997,7 +2001,10 @@ describe('#getConnectedSitesList', () => { }); it('returns the token object for the overridden chainId when overrideChainId is provided', () => { - const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + const getCurrentChainIdSpy = jest.spyOn( + networkSelectors, + 'getCurrentChainId', + ); const expectedToken = { symbol: 'POL', name: 'Polygon', @@ -2116,7 +2123,10 @@ describe('#getConnectedSitesList', () => { it('respects the overrideChainId parameter', () => { process.env.METAMASK_ENVIRONMENT = 'production'; - const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + const getCurrentChainIdSpy = jest.spyOn( + networkSelectors, + 'getCurrentChainId', + ); const result = selectors.getIsSwapsChain(mockState, '0x89'); expect(result).toBe(true); @@ -2169,7 +2179,10 @@ describe('#getConnectedSitesList', () => { }); it('respects the overrideChainId parameter', () => { - const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + const getCurrentChainIdSpy = jest.spyOn( + networkSelectors, + 'getCurrentChainId', + ); const result = selectors.getIsBridgeChain(mockState, '0x89'); diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 9428a6fbd8c3..4e5b038b3cc8 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -12,12 +12,14 @@ import { import txHelper from '../helpers/utils/tx-helper'; import { SmartTransactionStatus } from '../../shared/constants/transaction'; import { hexToDecimal } from '../../shared/modules/conversion.utils'; -import { getProviderConfig } from '../../shared/modules/selectors/networks'; +import { + getProviderConfig, + getCurrentChainId, +} from '../../shared/modules/selectors/networks'; import { createDeepEqualSelector, filterAndShapeUnapprovedTransactions, } from '../../shared/modules/selectors/util'; -import { getCurrentChainId } from './selectors'; import { getSelectedInternalAccount } from './accounts'; import { hasPendingApprovals, getApprovalRequestsByType } from './approvals'; From 70b017ab3ee83658fdaa748a8c768202113838d9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 27 Nov 2024 16:34:40 +0100 Subject: [PATCH 105/148] fix(wallet-overview): prevent send button clicked event to be sent twice (#28772) 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/28772?quickstart=1) ## **Related issues** Fixes: - https://github.com/MetaMask/metamask-extension/pull/28593/files#r1860667737 ## **Manual testing steps** N/A (being covered by unit tests) ## **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. --- ui/components/app/wallet-overview/coin-buttons.tsx | 13 ------------- .../app/wallet-overview/eth-overview.test.js | 2 ++ .../app/wallet-overview/non-evm-overview.test.tsx | 2 ++ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index 0fcb9f2e2389..01182ea1f70a 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -375,19 +375,6 @@ const CoinButtons = ({ } ///: END:ONLY_INCLUDE_IF default: { - trackEvent( - { - event: MetaMetricsEventName.NavSendButtonClicked, - category: MetaMetricsEventCategory.Navigation, - properties: { - token_symbol: 'ETH', - location: 'Home', - text: 'Send', - chain_id: chainId, - }, - }, - { excludeMetaMetricsId: false }, - ); await setCorrectChain(); await dispatch(startNewDraftTransaction({ type: AssetType.native })); history.push(SEND_ROUTE); diff --git a/ui/components/app/wallet-overview/eth-overview.test.js b/ui/components/app/wallet-overview/eth-overview.test.js index 5af6990d4499..104764251cf8 100644 --- a/ui/components/app/wallet-overview/eth-overview.test.js +++ b/ui/components/app/wallet-overview/eth-overview.test.js @@ -445,6 +445,7 @@ describe('EthOverview', () => { expect(buyButton).not.toBeDisabled(); fireEvent.click(buyButton); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith({ event: MetaMetricsEventName.NavBuyButtonClicked, category: MetaMetricsEventCategory.Navigation, @@ -539,6 +540,7 @@ describe('EthOverview', () => { expect(sendButton).not.toBeDisabled(); fireEvent.click(sendButton); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith( { event: MetaMetricsEventName.NavSendButtonClicked, diff --git a/ui/components/app/wallet-overview/non-evm-overview.test.tsx b/ui/components/app/wallet-overview/non-evm-overview.test.tsx index aa49eb77e79d..cfffb9bdc7ce 100644 --- a/ui/components/app/wallet-overview/non-evm-overview.test.tsx +++ b/ui/components/app/wallet-overview/non-evm-overview.test.tsx @@ -305,6 +305,7 @@ describe('NonEvmOverview', () => { expect(buyButton).not.toBeDisabled(); fireEvent.click(buyButton as HTMLElement); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith({ event: MetaMetricsEventName.NavBuyButtonClicked, category: MetaMetricsEventCategory.Navigation, @@ -381,6 +382,7 @@ describe('NonEvmOverview', () => { expect(sendButton).not.toBeDisabled(); fireEvent.click(sendButton as HTMLElement); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith( { event: MetaMetricsEventName.NavSendButtonClicked, From 78a218f7e02e6662911e7d814f93bede21fb6fb4 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 27 Nov 2024 12:07:49 -0330 Subject: [PATCH 106/148] chore: Bump `@metamask/eth-json-rpc-middleware` to v14.0.2 (#28755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bump `@metamask/eth-json-rpc-middleware` from v14.0.1 (+ patch) to v14.0.2. The v14.0.2 release contains the same change as was added in the patch being removed. This bump has no functional changes. Changelog: https://github.com/MetaMask/eth-json-rpc-middleware/blob/main/CHANGELOG.md#1402 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28755?quickstart=1) ## **Related issues** Context: * https://github.com/MetaMask/metamask-extension/pull/27021 * https://github.com/MetaMask/eth-json-rpc-middleware/pull/334 ## **Manual testing steps** N/A ## **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. --- ...rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch | 13 ----- package.json | 2 +- yarn.lock | 49 ++++++------------- 3 files changed, 16 insertions(+), 48 deletions(-) delete mode 100644 .yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch diff --git a/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch b/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch deleted file mode 100644 index e82feb182c3a..000000000000 --- a/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/dist/wallet.js b/dist/wallet.js -index fce8272ab926443df4c5971c811664f849791425..9237ffcaaea2260e01182feecec667b10edd35a0 100644 ---- a/dist/wallet.js -+++ b/dist/wallet.js -@@ -293,7 +293,7 @@ exports.createWalletMiddleware = createWalletMiddleware; - */ - function validateVerifyingContract(data) { - const { domain: { verifyingContract } = {} } = (0, normalize_1.parseTypedMessage)(data); -- if (verifyingContract && !(0, utils_1.isValidHexAddress)(verifyingContract)) { -+ if (verifyingContract && verifyingContract !== 'cosmos' && !(0, utils_1.isValidHexAddress)(verifyingContract)) { - throw rpc_errors_1.rpcErrors.invalidInput(); - } - } diff --git a/package.json b/package.json index abd56565d35d..3edf8174a2fb 100644 --- a/package.json +++ b/package.json @@ -298,7 +298,7 @@ "@metamask/ens-controller": "^14.0.0", "@metamask/ens-resolver-snap": "^0.1.2", "@metamask/eth-json-rpc-filters": "^9.0.0", - "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch", + "@metamask/eth-json-rpc-middleware": "^14.0.2", "@metamask/eth-ledger-bridge-keyring": "^5.0.1", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^7.0.1", diff --git a/yarn.lock b/yarn.lock index 922fc470e8e6..5744409b28be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5286,25 +5286,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:14.0.1": - version: 14.0.1 - resolution: "@metamask/eth-json-rpc-middleware@npm:14.0.1" - dependencies: - "@metamask/eth-block-tracker": "npm:^11.0.1" - "@metamask/eth-json-rpc-provider": "npm:^4.1.1" - "@metamask/eth-sig-util": "npm:^7.0.3" - "@metamask/json-rpc-engine": "npm:^9.0.2" - "@metamask/rpc-errors": "npm:^6.3.1" - "@metamask/utils": "npm:^9.1.0" - "@types/bn.js": "npm:^5.1.5" - bn.js: "npm:^5.2.1" - klona: "npm:^2.0.6" - pify: "npm:^5.0.0" - safe-stable-stringify: "npm:^2.4.3" - checksum: 10/39beecb0d2be19854b132fd615aee1f29195602d3db902f52755260b26a2c37c0a91cd635a09d4dc16f922d32bb229003b338228ae29577c5151d880fad04637 - languageName: node - linkType: hard - "@metamask/eth-json-rpc-middleware@npm:^13.0.0": version: 13.0.0 resolution: "@metamask/eth-json-rpc-middleware@npm:13.0.0" @@ -5324,41 +5305,41 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^15.0.0": - version: 15.0.0 - resolution: "@metamask/eth-json-rpc-middleware@npm:15.0.0" +"@metamask/eth-json-rpc-middleware@npm:^14.0.2": + version: 14.0.2 + resolution: "@metamask/eth-json-rpc-middleware@npm:14.0.2" dependencies: "@metamask/eth-block-tracker": "npm:^11.0.1" - "@metamask/eth-json-rpc-provider": "npm:^4.1.5" + "@metamask/eth-json-rpc-provider": "npm:^4.1.1" "@metamask/eth-sig-util": "npm:^7.0.3" - "@metamask/json-rpc-engine": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/json-rpc-engine": "npm:^9.0.2" + "@metamask/rpc-errors": "npm:^6.3.1" "@metamask/utils": "npm:^9.1.0" "@types/bn.js": "npm:^5.1.5" bn.js: "npm:^5.2.1" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" - checksum: 10/3c48d34264c695535f2b4e819fb602d835b6ed37309116a06d04d1b706a7335e0205cd4ccdbf1d3e9dc15ebf40d88954a9a2dc18a91f223dcd6d6392e026a5e9 + checksum: 10/8094efcd23bb5a1335f7d41cfb6193e035cc30329c2989f09b65f8870178ce960a643a4818a00fc791076a51d629511190d54f3b327165ac6f966c307dc9b90d languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch": - version: 14.0.1 - resolution: "@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch::version=14.0.1&hash=96e7e0" +"@metamask/eth-json-rpc-middleware@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:15.0.0" dependencies: "@metamask/eth-block-tracker": "npm:^11.0.1" - "@metamask/eth-json-rpc-provider": "npm:^4.1.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" "@metamask/eth-sig-util": "npm:^7.0.3" - "@metamask/json-rpc-engine": "npm:^9.0.2" - "@metamask/rpc-errors": "npm:^6.3.1" + "@metamask/json-rpc-engine": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" "@metamask/utils": "npm:^9.1.0" "@types/bn.js": "npm:^5.1.5" bn.js: "npm:^5.2.1" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" - checksum: 10/d1d97a845a8a9a5931c3853c6e2768a97ba289d676a2a8b6111077531943f9647430ef8e3f2a05f4643760ffdab1af0dc72574ca3010feadbdfab3dec345b7c8 + checksum: 10/3c48d34264c695535f2b4e819fb602d835b6ed37309116a06d04d1b706a7335e0205cd4ccdbf1d3e9dc15ebf40d88954a9a2dc18a91f223dcd6d6392e026a5e9 languageName: node linkType: hard @@ -26836,7 +26817,7 @@ __metadata: "@metamask/eslint-config-typescript": "npm:^9.0.1" "@metamask/eslint-plugin-design-tokens": "npm:^1.1.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" - "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch" + "@metamask/eth-json-rpc-middleware": "npm:^14.0.2" "@metamask/eth-json-rpc-provider": "npm:^4.1.6" "@metamask/eth-ledger-bridge-keyring": "npm:^5.0.1" "@metamask/eth-query": "npm:^4.0.0" From 478537374b88070b68d08a6c8d9a124a2f4c4003 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 27 Nov 2024 21:11:05 +0530 Subject: [PATCH 107/148] feat: on UI side filtering put typed sign V4 requests for which decoding data is displayed (#28762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Filter our typed sign requests for which decoding api results are displayed. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3682 ## **Manual testing steps** 1. Enable signature decoding locally 2. Submit request for permit signatures on test dapp of supported seaport signatures 3. It should work as expected ## **Screenshots/Recordings** Screenshot 2024-11-27 at 3 14 40 PM Screenshot 2024-11-27 at 3 14 31 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. --- shared/constants/signatures.ts | 1 + test/data/confirmations/typed_sign.ts | 53 +++ .../app/confirm/info/row/section.tsx | 5 +- .../static-simulation/static-simulation.tsx | 22 +- .../permit-simulation.test.tsx.snap | 345 ------------------ .../decoded-simulation.test.tsx.snap | 115 ------ .../default-simulation/index.ts | 1 - .../permit-simulation.test.tsx | 112 ------ .../permit-simulation/permit-simulation.tsx | 25 -- .../decoded-simulation.test.tsx | 19 +- .../decoded-simulation/decoded-simulation.tsx | 10 +- .../decoded-simulation/index.ts | 0 .../typed-sign-v4-simulation/index.ts | 1 + .../native-value-display.test.tsx | 0 .../native-value-display.tsx | 4 +- .../permit-simulation.test.tsx.snap} | 4 +- .../permit-simulation/index.ts | 0 .../permit-simulation.test.tsx} | 8 +- .../permit-simulation/permit-simulation.tsx} | 4 +- .../typed-sign-v4-simulation.test.tsx | 160 ++++++++ .../typed-sign-v4-simulation.tsx | 63 ++++ .../__snapshots__/value-display.test.tsx.snap | 0 .../value-display/value-display.test.tsx | 0 .../value-display/value-display.tsx | 4 +- .../confirm/info/typed-sign/typed-sign.tsx | 8 +- 25 files changed, 348 insertions(+), 616 deletions(-) delete mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap delete mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap delete mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts delete mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx delete mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/decoded-simulation/decoded-simulation.test.tsx (79%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/decoded-simulation/decoded-simulation.tsx (92%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/decoded-simulation/index.ts (100%) create mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/index.ts rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/native-value-display/native-value-display.test.tsx (100%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/native-value-display/native-value-display.tsx (96%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap => typed-sign-v4-simulation/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap} (98%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{ => typed-sign-v4-simulation}/permit-simulation/index.ts (100%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation/default-simulation/default-simulation.test.tsx => typed-sign-v4-simulation/permit-simulation/permit-simulation.test.tsx} (92%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation/default-simulation/default-simulation.tsx => typed-sign-v4-simulation/permit-simulation/permit-simulation.tsx} (97%) create mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx create mode 100644 ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/value-display/__snapshots__/value-display.test.tsx.snap (100%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/value-display/value-display.test.tsx (100%) rename ui/pages/confirmations/components/confirm/info/typed-sign/{permit-simulation => typed-sign-v4-simulation}/value-display/value-display.tsx (97%) diff --git a/shared/constants/signatures.ts b/shared/constants/signatures.ts index 3ecc56be8630..48b17afe51d9 100644 --- a/shared/constants/signatures.ts +++ b/shared/constants/signatures.ts @@ -9,6 +9,7 @@ export enum PrimaryTypePermit { PermitBatchTransferFrom = 'PermitBatchTransferFrom', PermitSingle = 'PermitSingle', PermitTransferFrom = 'PermitTransferFrom', + PermitWitnessTransferFrom = 'PermitWitnessTransferFrom', } /** diff --git a/test/data/confirmations/typed_sign.ts b/test/data/confirmations/typed_sign.ts index 831d561f0cb2..b0984684f12d 100644 --- a/test/data/confirmations/typed_sign.ts +++ b/test/data/confirmations/typed_sign.ts @@ -188,6 +188,59 @@ export const permitSignatureMsg = { }, } as SignatureRequestType; +export const seaportSignatureMsg = { + chainId: '0x1', + id: 'e9297d91-aca0-11ef-9ac4-417a173450d3', + messageParams: { + data: '{"types":{"OrderComponents":[{"name":"offerer","type":"address"},{"name":"zone","type":"address"},{"name":"offer","type":"OfferItem[]"},{"name":"consideration","type":"ConsiderationItem[]"},{"name":"orderType","type":"uint8"},{"name":"startTime","type":"uint256"},{"name":"endTime","type":"uint256"},{"name":"zoneHash","type":"bytes32"},{"name":"salt","type":"uint256"},{"name":"conduitKey","type":"bytes32"},{"name":"counter","type":"uint256"}],"OfferItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"}],"ConsiderationItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"},{"name":"recipient","type":"address"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"Seaport","version":"1.1","chainId":"0x1","verifyingContract":"0x00000000006c3852cbef3e08e8df289169ede581"},"primaryType":"OrderComponents","message":{"offerer":"0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477","zone":"0x004c00500000ad104d7dbd00e3ae0a5c00560c00","offer":[{"itemType":"2","token":"0x922dc160f2ab743312a6bb19dd5152c1d3ecca33","identifierOrCriteria":"176","startAmount":"1","endAmount":"1"}],"consideration":[{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"0","endAmount":"0","recipient":"0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477"},{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"25000000000000000","endAmount":"25000000000000000","recipient":"0x8de9c5a032463c561423387a9648c5c7bcc5bc90"},{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"50000000000000000","endAmount":"50000000000000000","recipient":"0x5c6139cd9ff1170197f13935c58f825b422c744c"}],"orderType":"3","startTime":"1660565524","endTime":"1661170320","zoneHash":"0x3000000000000000000000000000000000000000000000000000000000000000","salt":"5965482869793190759363249887602871532","conduitKey":"0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000","counter":"0"}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + signatureMethod: 'eth_signTypedData_v4', + metamaskId: 'e9297d90-aca0-11ef-9ac4-417a173450d3', + origin: 'https://develop.d3bkcslj57l47p.amplifyapp.com', + requestId: 1376479613, + }, + networkClientId: 'mainnet', + securityAlertResponse: { + result_type: 'loading', + reason: 'CheckingChain', + securityAlertId: 'def3b0ef-c96b-4c87-b1b1-c69cc02a0f78', + }, + status: 'unapproved', + time: 1732699257833, + type: 'eth_signTypedData', + version: 'V4', + decodingLoading: false, + decodingData: { + stateChanges: [ + { + assetType: 'NATIVE', + changeType: 'RECEIVE', + address: '', + amount: '0', + contractAddress: '', + }, + { + assetType: 'ERC721', + changeType: 'LISTING', + address: '', + amount: '', + contractAddress: '0x922dc160f2ab743312a6bb19dd5152c1d3ecca33', + tokenID: '176', + }, + ], + }, + msgParams: { + data: '{"types":{"OrderComponents":[{"name":"offerer","type":"address"},{"name":"zone","type":"address"},{"name":"offer","type":"OfferItem[]"},{"name":"consideration","type":"ConsiderationItem[]"},{"name":"orderType","type":"uint8"},{"name":"startTime","type":"uint256"},{"name":"endTime","type":"uint256"},{"name":"zoneHash","type":"bytes32"},{"name":"salt","type":"uint256"},{"name":"conduitKey","type":"bytes32"},{"name":"counter","type":"uint256"}],"OfferItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"}],"ConsiderationItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"},{"name":"recipient","type":"address"}],"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}]},"domain":{"name":"Seaport","version":"1.4","chainId":"0x1","verifyingContract":"0x00000000006c3852cbef3e08e8df289169ede581"},"primaryType":"OrderComponents","message":{"offerer":"0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477","zone":"0x004c00500000ad104d7dbd00e3ae0a5c00560c00","offer":[{"itemType":"2","token":"0x922dc160f2ab743312a6bb19dd5152c1d3ecca33","identifierOrCriteria":"176","startAmount":"1","endAmount":"1"}],"consideration":[{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"0","endAmount":"0","recipient":"0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477"},{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"25000000000000000","endAmount":"25000000000000000","recipient":"0x8de9c5a032463c561423387a9648c5c7bcc5bc90"},{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"50000000000000000","endAmount":"50000000000000000","recipient":"0x5c6139cd9ff1170197f13935c58f825b422c744c"}],"orderType":"3","startTime":"1660565524","endTime":"1661170320","zoneHash":"0x3000000000000000000000000000000000000000000000000000000000000000","salt":"5965482869793190759363249887602871532","conduitKey":"0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000","counter":"0"}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + signatureMethod: 'eth_signTypedData_v4', + metamaskId: 'e9297d90-aca0-11ef-9ac4-417a173450d3', + origin: 'https://develop.d3bkcslj57l47p.amplifyapp.com', + requestId: 1376479613, + }, +} as SignatureRequestType; + export const permitNFTSignatureMsg = { id: 'c5067710-87cf-11ef-916c-71f266571322', chainId: CHAIN_IDS.GOERLI, diff --git a/ui/components/app/confirm/info/row/section.tsx b/ui/components/app/confirm/info/row/section.tsx index 7087fd3cd138..0725c08aae35 100644 --- a/ui/components/app/confirm/info/row/section.tsx +++ b/ui/components/app/confirm/info/row/section.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { CSSProperties } from 'react'; import { Box } from '../../../../component-library'; import { BackgroundColor, @@ -8,12 +8,14 @@ import { export type ConfirmInfoSectionProps = { children: React.ReactNode | string; noPadding?: boolean; + style?: CSSProperties; 'data-testid'?: string; }; export const ConfirmInfoSection = ({ children, noPadding, + style = {}, 'data-testid': dataTestId, }: ConfirmInfoSectionProps) => { return ( @@ -23,6 +25,7 @@ export const ConfirmInfoSection = ({ borderRadius={BorderRadius.MD} padding={noPadding ? 0 : 2} marginBottom={4} + style={style} > {children} diff --git a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx index 9a34abd8009d..14a6d82d5a26 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx @@ -7,20 +7,38 @@ import { } from '../../../../../../../components/app/confirm/info/row'; import { ConfirmInfoSection } from '../../../../../../../components/app/confirm/info/row/section'; import { + AlignItems, Display, JustifyContent, } from '../../../../../../../helpers/constants/design-system'; import Preloader from '../../../../../../../components/ui/icon/preloader'; +const CollapsedSectionStyles = { + display: Display.Flex, + alignItems: AlignItems.center, + justifyContent: JustifyContent.spaceBetween, +}; + const StaticSimulation: React.FC<{ title: string; titleTooltip: string; description?: string; simulationElements: React.ReactNode; isLoading?: boolean; -}> = ({ title, titleTooltip, description, simulationElements, isLoading }) => { + isCollapsed?: boolean; +}> = ({ + title, + titleTooltip, + description, + simulationElements, + isLoading, + isCollapsed = false, +}) => { return ( - + {description && } diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap deleted file mode 100644 index fef756f178c2..000000000000 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap +++ /dev/null @@ -1,345 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PermitSimulation should not render default simulation if decodingLoading is true 1`] = ` -
-
-
-
-
-

- Estimated changes -

-
-
- -
-
-
-
-
-
- - - - - - - - - -
-
-
-`; - -exports[`PermitSimulation should render default simulation if decoding api does not return result 1`] = ` -
-
-
-
-
-

- Estimated changes -

-
-
- -
-
-
-
-
-

- You're giving the spender permission to spend this many tokens from your account. -

-
-
-
-
-
-

- Spending cap -

-
-
-
-
-
-
-
-
-

- 30 -

-
-
-
-
-
- -

- 0xCcCCc...ccccC -

-
-
-
-
-
-
-
-
-
-`; - -exports[`PermitSimulation should render default simulation if decoding api returns error 1`] = ` -
-
-
-
-
-

- Estimated changes -

-
-
- -
-
-
-
-
-

- You're giving the spender permission to spend this many tokens from your account. -

-
-
-
-
-
-

- Spending cap -

-
-
-
-
-
-
-
-
-

- 30 -

-
-
-
-
-
- -

- 0xCcCCc...ccccC -

-
-
-
-
-
-
-
-
-
-`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap deleted file mode 100644 index ed4b45276a4b..000000000000 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap +++ /dev/null @@ -1,115 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DecodedSimulation renders component correctly 1`] = ` -
-
-
-
-
-

- Estimated changes -

-
-
- -
-
-
-
-
-
-
-
-

- Spending cap -

-
-
-
-
-
-
-
-

- 1,461,501,637,3... -

-
-
-
-
-
- -

- 0x6B175...71d0F -

-
-
-
-
-
-
-
-
-`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts deleted file mode 100644 index b9f6c48b65db..000000000000 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as DefaultSimulation } from './default-simulation'; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx deleted file mode 100644 index a06c53f16ba3..000000000000 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; -import configureMockStore from 'redux-mock-store'; -import { act } from 'react-dom/test-utils'; -import { waitFor } from '@testing-library/dom'; - -import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../test/data/confirmations/helper'; -import { renderWithConfirmContextProvider } from '../../../../../../../../test/lib/confirmations/render-helpers'; -import { permitSignatureMsg } from '../../../../../../../../test/data/confirmations/typed_sign'; -import { memoizedGetTokenStandardAndDetails } from '../../../../../utils/token'; -import * as SignatureMetrics from '../../../../../hooks/useDecodedSignatureMetrics'; -import PermitSimulation from './permit-simulation'; - -jest.mock('../../../../../../../store/actions', () => { - return { - getTokenStandardAndDetails: jest - .fn() - .mockResolvedValue({ decimals: 2, standard: 'ERC20' }), - updateEventFragment: jest.fn(), - }; -}); - -describe('PermitSimulation', () => { - afterEach(() => { - jest.clearAllMocks(); - - /** Reset memoized function using getTokenStandardAndDetails for each test */ - memoizedGetTokenStandardAndDetails?.cache?.clear?.(); - }); - - it('should render default simulation if decoding api does not return result', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData: undefined, - }); - const mockStore = configureMockStore([])(state); - - await act(async () => { - const { container, findByText } = renderWithConfirmContextProvider( - , - mockStore, - ); - - expect(await findByText('30')).toBeInTheDocument(); - expect(container).toMatchSnapshot(); - }); - }); - - it('should call hook to register signature metrics properties', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData: undefined, - }); - const mockStore = configureMockStore([])(state); - - const mockedUseDecodedSignatureMetrics = jest - .spyOn(SignatureMetrics, 'useDecodedSignatureMetrics') - .mockImplementation(() => ''); - - await act(async () => { - renderWithConfirmContextProvider(, mockStore); - - expect(mockedUseDecodedSignatureMetrics).toHaveBeenCalledTimes(1); - }); - }); - - it('should render default simulation if decoding api returns error', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData: { - stateChanges: null, - error: { - message: 'some error', - type: 'SOME_ERROR', - }, - }, - }); - const mockStore = configureMockStore([])(state); - - await act(async () => { - const { container, findByText } = renderWithConfirmContextProvider( - , - mockStore, - ); - - expect(await findByText('30')).toBeInTheDocument(); - expect(container).toMatchSnapshot(); - }); - }); - - it('should not render default simulation if decodingLoading is true', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: true, - }); - const mockStore = configureMockStore([])(state); - - await act(async () => { - const { container, queryByTestId } = renderWithConfirmContextProvider( - , - mockStore, - ); - - await waitFor(() => { - expect(queryByTestId('30')).not.toBeInTheDocument(); - expect(container).toMatchSnapshot(); - }); - }); - }); -}); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx deleted file mode 100644 index d78a9afbe1b8..000000000000 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import { SignatureRequestType } from '../../../../../types/confirm'; -import { useConfirmContext } from '../../../../../context/confirm'; -import { useDecodedSignatureMetrics } from '../../../../../hooks/useDecodedSignatureMetrics'; -import { DefaultSimulation } from './default-simulation'; -import { DecodedSimulation } from './decoded-simulation'; - -const PermitSimulation: React.FC = () => { - const { currentConfirmation } = useConfirmContext(); - const { decodingLoading, decodingData } = currentConfirmation; - - useDecodedSignatureMetrics(); - - if ( - decodingData?.error || - (decodingData?.stateChanges === undefined && decodingLoading !== true) - ) { - return ; - } - - return ; -}; - -export default PermitSimulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx similarity index 79% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx index 86f30472b0e5..93cc6b9e4474 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -68,12 +68,27 @@ describe('DecodedSimulation', () => { }); const mockStore = configureMockStore([])(state); - const { container } = renderWithConfirmContextProvider( + const { findByText } = renderWithConfirmContextProvider( , mockStore, ); - expect(container).toMatchSnapshot(); + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect(await findByText('Spending cap')).toBeInTheDocument(); + expect(await findByText('1,461,501,637,3...')).toBeInTheDocument(); + }); + + it('renders unavailable message if no state change is returned', async () => { + const state = getMockTypedSignConfirmStateForRequest(permitSignatureMsg); + const mockStore = configureMockStore([])(state); + + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect(await findByText('Unavailable')).toBeInTheDocument(); }); describe('getStateChangeToolip', () => { diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx similarity index 92% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx index cf774483ee6c..53295852c566 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx @@ -8,6 +8,7 @@ import { Hex } from '@metamask/utils'; import { TokenStandard } from '../../../../../../../../../shared/constants/transaction'; import { ConfirmInfoRow } from '../../../../../../../../components/app/confirm/info/row'; +import { Text } from '../../../../../../../../components/component-library'; import { useI18nContext } from '../../../../../../../../hooks/useI18nContext'; import { SignatureRequestType } from '../../../../../../types/confirm'; import { useConfirmContext } from '../../../../../../context/confirm'; @@ -116,8 +117,15 @@ const DecodedSimulation: React.FC = () => { {t('simulationDetailsUnavailable')} + ) + } isLoading={decodingLoading} + isCollapsed={decodingLoading || !stateChangeFragment.length} /> ); }; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/index.ts b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/index.ts similarity index 100% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/index.ts rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/index.ts diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/index.ts b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/index.ts new file mode 100644 index 000000000000..a5a36ea02dff --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/index.ts @@ -0,0 +1 @@ +export { default as TypedSignV4Simulation } from './typed-sign-v4-simulation'; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/native-value-display/native-value-display.test.tsx similarity index 100% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.test.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/native-value-display/native-value-display.test.tsx diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/native-value-display/native-value-display.tsx similarity index 96% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/native-value-display/native-value-display.tsx index b198680f4e96..8451d3b6e899 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/native-value-display/native-value-display.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/native-value-display/native-value-display.tsx @@ -109,7 +109,9 @@ const NativeValueDisplay: React.FC = ({ - {fiatValue && } + {fiatValue !== undefined && ( + + )} ); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap similarity index 98% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap index 2b6dee2b8c0c..1c301e59b14f 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/__snapshots__/permit-simulation.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DefaultSimulation renders component correctly 1`] = ` +exports[`PermitSimulation renders component correctly 1`] = `
`; -exports[`DefaultSimulation renders correctly for NFT permit 1`] = ` +exports[`PermitSimulation renders correctly for NFT permit 1`] = `
{ return { @@ -19,7 +19,7 @@ jest.mock('../../../../../../../../store/actions', () => { }; }); -describe('DefaultSimulation', () => { +describe('PermitSimulation', () => { afterEach(() => { jest.clearAllMocks(); @@ -33,7 +33,7 @@ describe('DefaultSimulation', () => { await act(async () => { const { container, findByText } = renderWithConfirmContextProvider( - , + , mockStore, ); @@ -48,7 +48,7 @@ describe('DefaultSimulation', () => { await act(async () => { const { container, findByText } = renderWithConfirmContextProvider( - , + , mockStore, ); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/permit-simulation.tsx similarity index 97% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/permit-simulation.tsx index c39a88585e35..0e5c117beba9 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/permit-simulation/permit-simulation.tsx @@ -38,7 +38,7 @@ function extractTokenDetailsByPrimaryType( return isNonArrayObject ? [tokenDetails] : tokenDetails; } -const DefaultPermitSimulation: React.FC = () => { +const PermitSimulation: React.FC = () => { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); const msgData = currentConfirmation.msgParams?.data; @@ -115,4 +115,4 @@ const DefaultPermitSimulation: React.FC = () => { ); }; -export default DefaultPermitSimulation; +export default PermitSimulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx new file mode 100644 index 000000000000..36d20e30fc66 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { act } from 'react-dom/test-utils'; +import { + DecodingData, + DecodingDataChangeType, +} from '@metamask/signature-controller'; +import { waitFor } from '@testing-library/dom'; + +import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../test/data/confirmations/helper'; +import { renderWithConfirmContextProvider } from '../../../../../../../../test/lib/confirmations/render-helpers'; +import { + permitSignatureMsg, + seaportSignatureMsg, +} from '../../../../../../../../test/data/confirmations/typed_sign'; +import { memoizedGetTokenStandardAndDetails } from '../../../../../utils/token'; +import TypedSignV4Simulation from './typed-sign-v4-simulation'; + +jest.mock('../../../../../../../store/actions', () => { + return { + getTokenStandardAndDetails: jest + .fn() + .mockResolvedValue({ decimals: 2, standard: 'ERC20' }), + }; +}); + +const decodingData: DecodingData = { + stateChanges: [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '1461501637330902918203684832716283019655932542975', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, + ], +}; + +describe('PermitSimulation', () => { + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('should render default simulation if decoding api does not return result', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: undefined, + }); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('30')).toBeInTheDocument(); + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect( + await findByText( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).toBeInTheDocument(); + }); + }); + + it('should render default simulation if decoding api returns error', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: null, + error: { + message: 'some error', + type: 'SOME_ERROR', + }, + }, + }); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('30')).toBeInTheDocument(); + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect( + await findByText( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).toBeInTheDocument(); + }); + }); + + it('should not render default simulation if decodingLoading is true', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: true, + }); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { queryByTestId } = renderWithConfirmContextProvider( + , + mockStore, + ); + + await waitFor(() => { + expect(queryByTestId('30')).not.toBeInTheDocument(); + expect(queryByTestId('Estimated changes')).toBeInTheDocument(); + expect( + queryByTestId( + "You're giving the spender permission to spend this many tokens from your account.", + ), + ).not.toBeInTheDocument(); + }); + }); + }); + + it('should render decoding simulation for permits', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData, + }); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect(await findByText('Spending cap')).toBeInTheDocument(); + expect(await findByText('1,461,501,637,3...')).toBeInTheDocument(); + }); + }); + + it.only('should render decoding simulation for seaport request', async () => { + const state = getMockTypedSignConfirmStateForRequest(seaportSignatureMsg); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('You receive')).toBeInTheDocument(); + expect(await findByText('You list')).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx new file mode 100644 index 000000000000..ebea09e18f15 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { PRIMARY_TYPES_PERMIT } from '../../../../../../../../shared/constants/signatures'; +import { parseTypedDataMessage } from '../../../../../../../../shared/modules/transaction.utils'; +import { SignatureRequestType } from '../../../../../types/confirm'; +import { isPermitSignatureRequest } from '../../../../../utils'; +import { useConfirmContext } from '../../../../../context/confirm'; +import { useDecodedSignatureMetrics } from '../../../../../hooks/useDecodedSignatureMetrics'; +import { DecodedSimulation } from './decoded-simulation'; +import { PermitSimulation } from './permit-simulation'; + +const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ + { + domainName: 'Seaport', + primaryTypeList: ['BulkOrder'], + versionList: ['1.4', '1.5', '1.6'], + }, + { + domainName: 'Seaport', + primaryTypeList: ['OrderComponents'], + }, +]; + +const isSupportedByDecodingAPI = (signatureRequest: SignatureRequestType) => { + const { + domain: { name, version }, + primaryType, + } = parseTypedDataMessage( + (signatureRequest as SignatureRequestType).msgParams?.data as string, + ); + const isPermit = PRIMARY_TYPES_PERMIT.includes(primaryType); + const nonPermitSupportedTypes = NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( + ({ domainName, primaryTypeList, versionList }) => + name === domainName && + primaryTypeList.includes(primaryType) && + (!versionList || versionList.includes(version)), + ); + return isPermit || nonPermitSupportedTypes; +}; + +const TypedSignV4Simulation: React.FC = () => { + const { currentConfirmation } = useConfirmContext(); + const isPermit = isPermitSignatureRequest(currentConfirmation); + const supportedByDecodingAPI = isSupportedByDecodingAPI(currentConfirmation); + useDecodedSignatureMetrics(); + + if (!supportedByDecodingAPI) { + return null; + } + + const { decodingData, decodingLoading } = currentConfirmation; + + if ( + ((!decodingLoading && decodingData === undefined) || decodingData?.error) && + isPermit + ) { + return ; + } + + return ; +}; + +export default TypedSignV4Simulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/__snapshots__/value-display.test.tsx.snap similarity index 100% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/__snapshots__/value-display.test.tsx.snap rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/__snapshots__/value-display.test.tsx.snap diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx similarity index 100% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.test.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.test.tsx 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/typed-sign-v4-simulation/value-display/value-display.tsx similarity index 97% rename from ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx rename to ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/value-display/value-display.tsx index 2867522b42ab..c41f99f2f320 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/typed-sign-v4-simulation/value-display/value-display.tsx @@ -157,7 +157,9 @@ const PermitSimulationValueDisplay: React.FC< /> - {fiatValue && } + {fiatValue !== undefined && ( + + )} ); 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 94677d56d979..0937e0dc1117 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 @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { isValidAddress } from 'ethereumjs-util'; import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; +import { MESSAGE_TYPE } from '../../../../../../../shared/constants/app'; import { parseTypedDataMessage } from '../../../../../../../shared/modules/transaction.utils'; import { RowAlertKey } from '../../../../../../components/app/confirm/info/row/constants'; import { @@ -24,7 +25,7 @@ import { selectUseTransactionSimulations } from '../../../../selectors/preferenc import { ConfirmInfoRowTypedSignData } from '../../row/typed-sign-data/typedSignData'; import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { SigningInWithRow } from '../shared/sign-in-with-row/sign-in-with-row'; -import { PermitSimulation } from './permit-simulation'; +import { TypedSignV4Simulation } from './typed-sign-v4-simulation'; const TypedSignInfo: React.FC = () => { const t = useI18nContext(); @@ -43,6 +44,9 @@ const TypedSignInfo: React.FC = () => { } = parseTypedDataMessage(currentConfirmation.msgParams.data as string); const isPermit = isPermitSignatureRequest(currentConfirmation); + const isTypedSignV4 = + currentConfirmation.msgParams.signatureMethod === + MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4; const isOrder = isOrderSignatureRequest(currentConfirmation); const tokenContract = isPermit || isOrder ? verifyingContract : undefined; const { decimalsNumber } = useGetTokenStandardAndDetails(tokenContract); @@ -56,7 +60,7 @@ const TypedSignInfo: React.FC = () => { return ( <> - {isPermit && useTransactionSimulations && } + {isTypedSignV4 && useTransactionSimulations && } {isPermit && ( <> From a2651c025a78c5c84eb395728d371ac9127301e3 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 27 Nov 2024 16:59:45 +0100 Subject: [PATCH 108/148] fix: add dispatch detect Nfts on network switch (#28769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** PR to fix detecting NFTs when a user switches networks while on the NFT tab [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28769?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/bea181c6-b252-4c0c-bc16-dd48abaeae13 ### **After** https://github.com/user-attachments/assets/4c674e18-09c7-4b9f-81d3-fc42e59f2bc2 ## **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: salimtb --- .../multichain/network-list-menu/network-list-menu.test.js | 3 +++ .../multichain/network-list-menu/network-list-menu.tsx | 2 ++ 2 files changed, 5 insertions(+) diff --git a/ui/components/multichain/network-list-menu/network-list-menu.test.js b/ui/components/multichain/network-list-menu/network-list-menu.test.js index 93a55538a4ab..ec4539aa55da 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.test.js +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -22,6 +22,7 @@ const mockSetActiveNetwork = jest.fn(); const mockUpdateCustomNonce = jest.fn(); const mockSetNextNonce = jest.fn(); const mockSetTokenNetworkFilter = jest.fn(); +const mockDetectNfts = jest.fn(); jest.mock('../../../store/actions.ts', () => ({ setShowTestNetworks: () => mockSetShowTestNetworks, @@ -32,6 +33,7 @@ jest.mock('../../../store/actions.ts', () => ({ setNetworkClientIdForDomain: (network, id) => mockSetNetworkClientIdForDomain(network, id), setTokenNetworkFilter: () => mockSetTokenNetworkFilter, + detectNfts: () => mockDetectNfts, })); const MOCK_ORIGIN = 'https://portfolio.metamask.io'; @@ -218,6 +220,7 @@ describe('NetworkListMenu', () => { expect(mockSetActiveNetwork).toHaveBeenCalled(); expect(mockUpdateCustomNonce).toHaveBeenCalled(); expect(mockSetNextNonce).toHaveBeenCalled(); + expect(mockDetectNfts).toHaveBeenCalled(); }); it('shows the correct selected network when networks share the same chain ID', () => { diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 07d4147ad763..28680b24ba45 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -29,6 +29,7 @@ import { updateCustomNonce, setNextNonce, setTokenNetworkFilter, + detectNfts, } from '../../../store/actions'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, @@ -289,6 +290,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { dispatch(toggleNetworkMenu()); dispatch(updateCustomNonce('')); dispatch(setNextNonce('')); + dispatch(detectNfts()); // as a user, I don't want my network selection to force update my filter when I have "All Networks" toggled on // however, if I am already filtered on "Current Network", we'll want to filter by the selected network when the network changes From 068c456890caa990e5af47cd836838afbee33f74 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Wed, 27 Nov 2024 10:17:08 -0600 Subject: [PATCH 109/148] feat: PortfolioView: Add feature flag check for polling intervals (#28501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a remote endpoint check to see if the remote API wants us to update the number of seconds between polling intervals for balances. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28501?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Open the extension 2. See the network request being made in the Networks devtools panel 3. Change conditional in response to `if (true) {` and hardcode a `pollInterval` value 4. See `this.tokenBalancesController.setIntervalLength` successfully called. ## **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. --- app/scripts/metamask-controller.js | 27 ++++++++++++- app/scripts/metamask-controller.test.js | 51 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1f907d94a976..dd1d956eed1a 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -195,7 +195,7 @@ import { ExcludedSnapEndowments, } from '../../shared/constants/permissions'; import { UI_NOTIFICATIONS } from '../../shared/notifications'; -import { MILLISECOND, SECOND } from '../../shared/constants/time'; +import { MILLISECOND, MINUTE, SECOND } from '../../shared/constants/time'; import { ORIGIN_METAMASK, POLLING_TOKEN_ENVIRONMENT_TYPES, @@ -244,6 +244,7 @@ import { endTrace, trace } from '../../shared/lib/trace'; // eslint-disable-next-line import/no-restricted-paths import { isSnapId } from '../../ui/helpers/utils/snaps'; import { BridgeStatusAction } from '../../shared/types/bridge-status'; +import fetchWithCache from '../../shared/lib/fetch-with-cache'; import { BalancesController as MultichainBalancesController } from './lib/accounts/BalancesController'; import { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -2637,6 +2638,29 @@ export default class MetamaskController extends EventEmitter { } } + // Provides a method for getting feature flags for the multichain + // initial rollout, such that we can remotely modify polling interval + getInfuraFeatureFlags() { + fetchWithCache({ + url: 'https://swap.api.cx.metamask.io/featureFlags', + cacheRefreshTime: MINUTE * 20, + }) + .then(this.onFeatureFlagResponseReceived) + .catch((e) => { + // API unreachable (?) + log.warn('Feature flag endpoint is unreachable', e); + }); + } + + onFeatureFlagResponseReceived(response) { + const { multiChainAssets = {} } = response; + const { pollInterval } = multiChainAssets; + // Polling interval is provided in seconds + if (pollInterval > 0) { + this.tokenBalancesController.setIntervalLength(pollInterval * SECOND); + } + } + postOnboardingInitialization() { const { usePhishDetect } = this.preferencesController.state; @@ -2669,6 +2693,7 @@ export default class MetamaskController extends EventEmitter { triggerNetworkrequests() { this.txController.startIncomingTransactionPolling(); this.tokenDetectionController.enable(); + this.getInfuraFeatureFlags(); } stopNetworkRequests() { diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 0e08a0ac27c0..447ea3da9503 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -39,6 +39,7 @@ import { flushPromises } from '../../test/lib/timer-helpers'; import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { createMockInternalAccount } from '../../test/jest/mocks'; import { mockNetworkState } from '../../test/stub/networks'; +import { SECOND } from '../../shared/constants/time'; import { BalancesController as MultichainBalancesController, BTC_BALANCES_UPDATE_TIME as MULTICHAIN_BALANCES_UPDATE_TIME, @@ -2632,6 +2633,56 @@ describe('MetaMaskController', () => { }); }); + describe('onFeatureFlagResponseReceived', () => { + const metamaskController = new MetaMaskController({ + showUserConfirmation: noop, + encryptor: mockEncryptor, + initState: cloneDeep(firstTimeState), + initLangCode: 'en_US', + platform: { + showTransactionNotification: () => undefined, + getVersion: () => 'foo', + }, + browser: browserPolyfillMock, + infuraProjectId: 'foo', + isFirstMetaMaskControllerSetup: true, + }); + + beforeEach(() => { + jest.spyOn( + metamaskController.tokenBalancesController, + 'setIntervalLength', + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not set the interval length if the pollInterval is 0', () => { + metamaskController.onFeatureFlagResponseReceived({ + multiChainAssets: { + pollInterval: 0, + }, + }); + expect( + metamaskController.tokenBalancesController.setIntervalLength, + ).not.toHaveBeenCalled(); + }); + + it('should set the interval length if the pollInterval is greater than 0', () => { + const pollInterval = 10; + metamaskController.onFeatureFlagResponseReceived({ + multiChainAssets: { + pollInterval, + }, + }); + expect( + metamaskController.tokenBalancesController.setIntervalLength, + ).toHaveBeenCalledWith(pollInterval * SECOND); + }); + }); + describe('MV3 Specific behaviour', () => { beforeAll(async () => { mockIsManifestV3.mockReturnValue(true); From f249b8ce8ea0ffa8fd71b343b1c881ba3e98cff6 Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:00:08 -0500 Subject: [PATCH 110/148] feat: Integrate Snap notification services (#27975) 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? Snap notifications were moved into the `NotificationServicesController` to reduce code duplication. 2. What is the improvement/solution? The `NotificationController` and associated code was deleted, as well as code that was written to process snap notifications. Parts of the codebase that expect snap notifications from the `NotificationController` source were also updated. See https://github.com/MetaMask/core/pull/4809 for changes made to the `NotificationServicesController` ## **Screenshots/Recordings** ### **After** https://github.com/user-attachments/assets/e84f5209-20fa-4b1d-ace7-2eec08bb2329 ## **Manual testing steps** 1. Pull down https://github.com/hmalik88/swiss-knife-snap and run `yarn start` to serve the snap and site. 2. Build this branch using `yarn start:flask` 3. Install the snap from the local host site. 4. Trigger a notification with the "trigger a within limit notification" button and observe the results. ## **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/scripts/background.js | 19 +++-- app/scripts/constants/sentry-state.ts | 3 - app/scripts/metamask-controller.js | 82 ++++++++----------- app/scripts/metamask-controller.test.js | 30 ------- app/scripts/migrations/133.test.ts | 46 +++++++++++ app/scripts/migrations/133.ts | 43 ++++++++++ app/scripts/migrations/index.js | 1 + lavamoat/browserify/beta/policy.json | 30 ------- lavamoat/browserify/flask/policy.json | 30 ------- lavamoat/browserify/main/policy.json | 30 ------- lavamoat/browserify/mmi/policy.json | 30 ------- package.json | 1 - test/data/mock-state.json | 16 ---- ...rs-after-init-opt-in-background-state.json | 1 - .../errors-after-init-opt-in-ui-state.json | 1 - .../__snapshots__/app-header.test.js.snap | 14 +--- .../useCounter.test.tsx | 11 +-- .../metamask-notifications/useCounter.tsx | 18 ++-- ui/hooks/useNotificationTimeouts.ts | 25 ++++++ .../notification-components/snap/snap.tsx | 37 +++++++-- .../notifications-list-item.stories.tsx | 50 +---------- ...otifications-list-read-all-button.test.tsx | 2 +- .../notifications-list-read-all-button.tsx | 25 +++--- .../notifications/notifications-list.test.tsx | 3 +- ui/pages/notifications/notifications-list.tsx | 11 ++- ui/pages/notifications/notifications.test.tsx | 2 - ui/pages/notifications/notifications.tsx | 68 +++++++-------- ui/pages/notifications/snap/types/types.ts | 17 ---- ui/pages/notifications/snap/utils/utils.ts | 17 ---- .../metamask-notifications.ts | 42 +++++++++- ui/selectors/selectors.js | 51 ------------ ui/selectors/selectors.test.js | 27 ------ ui/store/actions.ts | 66 ++++++++------- yarn.lock | 12 --- 34 files changed, 334 insertions(+), 527 deletions(-) create mode 100644 app/scripts/migrations/133.test.ts create mode 100644 app/scripts/migrations/133.ts create mode 100644 ui/hooks/useNotificationTimeouts.ts delete mode 100644 ui/pages/notifications/snap/types/types.ts delete mode 100644 ui/pages/notifications/snap/utils/utils.ts diff --git a/app/scripts/background.js b/app/scripts/background.js index 6a047c79ac16..e39d96943d77 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1046,11 +1046,6 @@ export function setupController( updateBadge, ); - controller.controllerMessenger.subscribe( - METAMASK_CONTROLLER_EVENTS.NOTIFICATIONS_STATE_CHANGE, - updateBadge, - ); - /** * Formats a count for display as a badge label. * @@ -1119,8 +1114,14 @@ export function setupController( controller.notificationServicesController.state; const snapNotificationCount = Object.values( - controller.notificationController.state.notifications, - ).filter((notification) => notification.readDate === null).length; + controller.notificationServicesController.state + .metamaskNotificationsList, + ).filter( + (notification) => + notification.type === + NotificationServicesController.Constants.TRIGGER_TYPES.SNAP && + notification.readDate === null, + ).length; const featureAnnouncementCount = isFeatureAnnouncementsEnabled ? controller.notificationServicesController.state.metamaskNotificationsList.filter( @@ -1138,7 +1139,9 @@ export function setupController( !notification.isRead && notification.type !== NotificationServicesController.Constants.TRIGGER_TYPES - .FEATURES_ANNOUNCEMENT, + .FEATURES_ANNOUNCEMENT && + notification.type !== + NotificationServicesController.Constants.TRIGGER_TYPES.SNAP, ).length : 0; diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 289bc0a0d29c..f18fb96d85fd 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -203,9 +203,6 @@ export const SENTRY_BACKGROUND_STATE = { allNfts: false, ignoredNfts: false, }, - NotificationController: { - notifications: false, - }, OnboardingController: { completedOnboarding: true, firstTimeFlowType: true, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index dd1d956eed1a..052e428dd55a 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -80,7 +80,6 @@ import { LoggingController, LogType } from '@metamask/logging-controller'; import { PermissionLogController } from '@metamask/permission-log-controller'; import { RateLimitController } from '@metamask/rate-limit-controller'; -import { NotificationController } from '@metamask/notification-controller'; import { CronjobController, JsonSnapsRegistry, @@ -376,6 +375,7 @@ import { sanitizeUIState } from './lib/state-utils'; import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME } from './controllers/bridge-status/constants'; +const { TRIGGER_TYPES } = NotificationServicesController.Constants; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) // The process of updating the badge happens in app/scripts/background.js. @@ -387,7 +387,6 @@ export const METAMASK_CONTROLLER_EVENTS = { 'NotificationServicesController:notificationsListUpdated', METAMASK_NOTIFICATIONS_MARK_AS_READ: 'NotificationServicesController:markNotificationsAsRead', - NOTIFICATIONS_STATE_CHANGE: 'NotificationController:stateChange', }; // stream channels @@ -1432,13 +1431,6 @@ export default class MetamaskController extends EventEmitter { }, }); - this.notificationController = new NotificationController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'NotificationController', - }), - state: initState.NotificationController, - }); - this.rateLimitController = new RateLimitController({ state: initState.RateLimitController, messenger: this.controllerMessenger.getRestricted({ @@ -1466,11 +1458,21 @@ export default class MetamaskController extends EventEmitter { rateLimitTimeout: 300000, }, showInAppNotification: { - method: (origin, message) => { + method: (origin, args) => { + const { message } = args; + + const notification = { + data: { + message, + origin, + }, + type: TRIGGER_TYPES.SNAP, + readDate: null, + }; + this.controllerMessenger.call( - 'NotificationController:show', - origin, - message, + 'NotificationServicesController:updateMetamaskNotificationsList', + notification, ); return null; @@ -2486,7 +2488,6 @@ export default class MetamaskController extends EventEmitter { SnapController: this.snapController, CronjobController: this.cronjobController, SnapsRegistry: this.snapsRegistry, - NotificationController: this.notificationController, SnapInterfaceController: this.snapInterfaceController, SnapInsightsController: this.snapInsightsController, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -2542,7 +2543,6 @@ export default class MetamaskController extends EventEmitter { SnapController: this.snapController, CronjobController: this.cronjobController, SnapsRegistry: this.snapsRegistry, - NotificationController: this.notificationController, SnapInterfaceController: this.snapInterfaceController, SnapInsightsController: this.snapInsightsController, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -2866,7 +2866,7 @@ export default class MetamaskController extends EventEmitter { origin, 'showInAppNotification', origin, - args.message, + args, ), updateSnapState: this.controllerMessenger.call.bind( this.controllerMessenger, @@ -2912,24 +2912,6 @@ export default class MetamaskController extends EventEmitter { }; } - /** - * Deletes the specified notifications from state. - * - * @param {string[]} ids - The notifications ids to delete. - */ - dismissNotifications(ids) { - this.notificationController.dismiss(ids); - } - - /** - * Updates the readDate attribute of the specified notifications. - * - * @param {string[]} ids - The notifications ids to mark as read. - */ - markNotificationsAsRead(ids) { - this.notificationController.markRead(ids); - } - /** * Sets up BaseController V2 event subscriptions. Currently, this includes * the subscriptions necessary to notify permission subjects of account @@ -3137,16 +3119,16 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( `${this.snapController.name}:snapUninstalled`, (truncatedSnap) => { - const notificationIds = Object.values( - this.notificationController.state.notifications, - ).reduce((idList, notification) => { - if (notification.origin === truncatedSnap.id) { - idList.push(notification.id); - } - return idList; - }, []); - - this.dismissNotifications(notificationIds); + const notificationIds = this.notificationServicesController + .getNotificationsByType(TRIGGER_TYPES.SNAP) + .filter( + (notification) => notification.data.origin === truncatedSnap.id, + ) + .map((notification) => notification.id); + + this.notificationServicesController.deleteNotificationsById( + notificationIds, + ); const snapId = truncatedSnap.id; const snapCategory = this._getSnapMetadata(snapId)?.category; @@ -3919,8 +3901,6 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger, 'SnapController:revokeDynamicPermissions', ), - dismissNotifications: this.dismissNotifications.bind(this), - markNotificationsAsRead: this.markNotificationsAsRead.bind(this), disconnectOriginFromSnap: this.controllerMessenger.call.bind( this.controllerMessenger, 'SnapController:disconnectOrigin', @@ -4259,6 +4239,14 @@ export default class MetamaskController extends EventEmitter { notificationServicesController.fetchAndUpdateMetamaskNotifications.bind( notificationServicesController, ), + deleteNotificationsById: + notificationServicesController.deleteNotificationsById.bind( + notificationServicesController, + ), + getNotificationsByType: + notificationServicesController.getNotificationsByType.bind( + notificationServicesController, + ), markMetamaskNotificationsAsRead: notificationServicesController.markMetamaskNotificationsAsRead.bind( notificationServicesController, @@ -4487,8 +4475,6 @@ export default class MetamaskController extends EventEmitter { // Clear snap state this.snapController.clearState(); - // Clear notification state - this.notificationController.clear(); // clear accounts in AccountTrackerController this.accountTrackerController.clearAccounts(); diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 447ea3da9503..a596277154eb 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -207,8 +207,6 @@ const TEST_INTERNAL_ACCOUNT = { type: EthAccountType.Eoa, }; -const NOTIFICATION_ID = 'NHL8f2eSSTn9TKBamRLiU'; - const ALT_MAINNET_RPC_URL = 'http://localhost:8545'; const POLYGON_RPC_URL = 'https://polygon.llamarpc.com'; @@ -249,17 +247,6 @@ const firstTimeState = { }, ), }, - NotificationController: { - notifications: { - [NOTIFICATION_ID]: { - id: NOTIFICATION_ID, - origin: 'local:http://localhost:8086/', - createdDate: 1652967897732, - readDate: null, - message: 'Hello, http://localhost:8086!', - }, - }, - }, PhishingController: { phishingLists: [ { @@ -2006,23 +1993,6 @@ describe('MetaMaskController', () => { }); }); - describe('markNotificationsAsRead', () => { - it('marks the notification as read', () => { - metamaskController.markNotificationsAsRead([NOTIFICATION_ID]); - const readNotification = - metamaskController.getState().notifications[NOTIFICATION_ID]; - expect(readNotification.readDate).not.toBeNull(); - }); - }); - - describe('dismissNotifications', () => { - it('deletes the notification from state', () => { - metamaskController.dismissNotifications([NOTIFICATION_ID]); - const state = metamaskController.getState().notifications; - expect(Object.values(state)).not.toContain(NOTIFICATION_ID); - }); - }); - describe('getTokenStandardAndDetails', () => { it('gets token data from the token list if available, and with a balance retrieved by fetchTokenBalance', async () => { const providerResultStub = { diff --git a/app/scripts/migrations/133.test.ts b/app/scripts/migrations/133.test.ts new file mode 100644 index 000000000000..76dc66a0d611 --- /dev/null +++ b/app/scripts/migrations/133.test.ts @@ -0,0 +1,46 @@ +import { cloneDeep } from 'lodash'; +import { migrate, version } from './133'; + +const oldVersion = 132; + +describe('migration #133', () => { + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: {}, + }; + + const newStorage = await migrate(cloneDeep(oldStorage)); + + expect(newStorage.meta).toStrictEqual({ version }); + }); + + describe('NotificationController', () => { + it('does nothing if NotificationController is not in state', async () => { + const oldState = { + OtherController: {}, + }; + + const transformedState = await migrate({ + meta: { version: oldVersion }, + data: cloneDeep(oldState), + }); + + expect(transformedState.data).toEqual(oldState); + }); + + it('deletes the NotificationController from state', async () => { + const oldState = { + NotificationController: {}, + OtherController: {}, + }; + + const transformedState = await migrate({ + meta: { version: oldVersion }, + data: cloneDeep(oldState), + }); + + expect(transformedState.data).toEqual({ OtherController: {} }); + }); + }); +}); diff --git a/app/scripts/migrations/133.ts b/app/scripts/migrations/133.ts new file mode 100644 index 000000000000..864f9d1b9637 --- /dev/null +++ b/app/scripts/migrations/133.ts @@ -0,0 +1,43 @@ +import { hasProperty } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 133; + +/** + * This migration removes the notification controller from state. Previously used for + * snap notifications, it is no longer needed now that snap notifications will live in the + * notification services controller. + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly + * what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by + * controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + transformState(versionedData.data); + return versionedData; +} + +/** + * Remove the notification controller from state (and any persisted notifications). + * + * @param state - The persisted MetaMask state, keyed by controller. + */ +function transformState(state: Record): void { + // we're removing the NotificationController in favor of the NotificationServicesController + if (hasProperty(state, 'NotificationController')) { + delete state.NotificationController; + } +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 6cde292ba55d..e95a9bf7a9da 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -153,6 +153,7 @@ const migrations = [ require('./130'), require('./131'), require('./132'), + require('./133'), ]; export default migrations; diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index a1af28ef6669..077ee743996a 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1951,36 +1951,6 @@ "unstable_autotrackMemoize": true } }, - "@metamask/notification-controller": { - "packages": { - "@metamask/notification-controller>@metamask/base-controller": true, - "@metamask/notification-controller>@metamask/utils": true, - "@metamask/notification-controller>nanoid": true - } - }, - "@metamask/notification-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/notification-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/notification-controller>nanoid": { "globals": { "crypto.getRandomValues": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index a1af28ef6669..077ee743996a 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1951,36 +1951,6 @@ "unstable_autotrackMemoize": true } }, - "@metamask/notification-controller": { - "packages": { - "@metamask/notification-controller>@metamask/base-controller": true, - "@metamask/notification-controller>@metamask/utils": true, - "@metamask/notification-controller>nanoid": true - } - }, - "@metamask/notification-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/notification-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/notification-controller>nanoid": { "globals": { "crypto.getRandomValues": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index a1af28ef6669..077ee743996a 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1951,36 +1951,6 @@ "unstable_autotrackMemoize": true } }, - "@metamask/notification-controller": { - "packages": { - "@metamask/notification-controller>@metamask/base-controller": true, - "@metamask/notification-controller>@metamask/utils": true, - "@metamask/notification-controller>nanoid": true - } - }, - "@metamask/notification-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/notification-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/notification-controller>nanoid": { "globals": { "crypto.getRandomValues": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 574da95682be..80bed5591fea 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2043,36 +2043,6 @@ "unstable_autotrackMemoize": true } }, - "@metamask/notification-controller": { - "packages": { - "@metamask/notification-controller>@metamask/base-controller": true, - "@metamask/notification-controller>@metamask/utils": true, - "@metamask/notification-controller>nanoid": true - } - }, - "@metamask/notification-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/notification-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/notification-controller>nanoid": { "globals": { "crypto.getRandomValues": true diff --git a/package.json b/package.json index 3edf8174a2fb..cf851ae1c09f 100644 --- a/package.json +++ b/package.json @@ -322,7 +322,6 @@ "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/name-controller": "^8.0.0", "@metamask/network-controller": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", - "@metamask/notification-controller": "^6.0.0", "@metamask/notification-services-controller": "^0.14.0", "@metamask/object-multiplex": "^2.0.0", "@metamask/obs-store": "^9.0.0", diff --git a/test/data/mock-state.json b/test/data/mock-state.json index b315dfa203eb..3131b8335287 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -646,22 +646,6 @@ "lastUpdated": "September 26, 2024" } }, - "notifications": { - "test": { - "id": "test", - "origin": "local:http://localhost:8086/", - "createdDate": 1652967897732, - "readDate": null, - "message": "Hello, http://localhost:8086!" - }, - "test2": { - "id": "test2", - "origin": "local:http://localhost:8086/", - "createdDate": 1652967897732, - "readDate": 1652967897732, - "message": "Hello, http://localhost:8086!" - } - }, "accountsByChainId": { "0x5": { "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc": { 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 3c2430fbe063..450ef4ee7ebb 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 @@ -171,7 +171,6 @@ "allNfts": "object", "ignoredNfts": "object" }, - "NotificationController": { "notifications": "object" }, "NotificationServicesController": { "subscriptionAccountsSeen": "object", "isMetamaskNotificationsFeatureSeen": "boolean", 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 01f94b55d5c7..c890588da16a 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 @@ -209,7 +209,6 @@ "database": null, "lastUpdated": null, "databaseUnavailable": "boolean", - "notifications": "object", "interfaces": "object", "insights": "object", "names": "object", diff --git a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap index 247f7aeb5c78..fc0ab6f50467 100644 --- a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap +++ b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap @@ -609,19 +609,7 @@ exports[`App Header unlocked state matches snapshot: unlocked 1`] = `
-
-

- 1 -

-
-
+ /> -
diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/__snapshots__/typed-sign.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/__snapshots__/typed-sign.test.tsx.snap index edb33ff6fea3..20998032a049 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/__snapshots__/typed-sign.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/__snapshots__/typed-sign.test.tsx.snap @@ -1102,30 +1102,19 @@ exports[`TypedSignInfo renders origin for typed sign data request 1`] = ` data-testid="confirmation_message-section" >
-
@@ -2047,30 +2036,19 @@ exports[`TypedSignInfo should render message for typed sign v3 request 1`] = ` data-testid="confirmation_message-section" >
-
@@ -2619,30 +2597,19 @@ exports[`TypedSignInfo should render message for typed sign v4 request 1`] = ` data-testid="confirmation_message-section" >
-
diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx index 53295852c566..55197e689600 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { DecodingDataChangeType, DecodingDataStateChange, @@ -103,14 +103,18 @@ const DecodedSimulation: React.FC = () => { const chainId = currentConfirmation.chainId as Hex; const { decodingLoading, decodingData } = currentConfirmation; - const stateChangeFragment = (decodingData?.stateChanges ?? []).map( - (change: DecodingDataStateChange) => ( - - ), + const stateChangeFragment = useMemo( + () => + (decodingData?.stateChanges ?? []).map( + (change: DecodingDataStateChange) => ( + + ), + ), + [decodingData?.stateChanges], ); return ( diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx index 36d20e30fc66..756d54f57a9b 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.test.tsx @@ -21,6 +21,7 @@ jest.mock('../../../../../../../store/actions', () => { getTokenStandardAndDetails: jest .fn() .mockResolvedValue({ decimals: 2, standard: 'ERC20' }), + updateEventFragment: jest.fn(), }; }); @@ -45,11 +46,16 @@ describe('PermitSimulation', () => { }); it('should render default simulation if decoding api does not return result', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData: undefined, - }); + const state = getMockTypedSignConfirmStateForRequest( + { + ...permitSignatureMsg, + decodingLoading: false, + decodingData: undefined, + }, + { + metamask: { useTransactionSimulations: true }, + }, + ); const mockStore = configureMockStore([])(state); await act(async () => { @@ -69,17 +75,22 @@ describe('PermitSimulation', () => { }); it('should render default simulation if decoding api returns error', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData: { - stateChanges: null, - error: { - message: 'some error', - type: 'SOME_ERROR', + const state = getMockTypedSignConfirmStateForRequest( + { + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: null, + error: { + message: 'some error', + type: 'SOME_ERROR', + }, }, }, - }); + { + metamask: { useTransactionSimulations: true }, + }, + ); const mockStore = configureMockStore([])(state); await act(async () => { @@ -113,7 +124,6 @@ describe('PermitSimulation', () => { await waitFor(() => { expect(queryByTestId('30')).not.toBeInTheDocument(); - expect(queryByTestId('Estimated changes')).toBeInTheDocument(); expect( queryByTestId( "You're giving the spender permission to spend this many tokens from your account.", @@ -124,11 +134,14 @@ describe('PermitSimulation', () => { }); it('should render decoding simulation for permits', async () => { - const state = getMockTypedSignConfirmStateForRequest({ - ...permitSignatureMsg, - decodingLoading: false, - decodingData, - }); + const state = getMockTypedSignConfirmStateForRequest( + { + ...permitSignatureMsg, + decodingLoading: false, + decodingData, + }, + { metamask: { useTransactionSimulations: true } }, + ); const mockStore = configureMockStore([])(state); await act(async () => { @@ -139,12 +152,13 @@ describe('PermitSimulation', () => { expect(await findByText('Estimated changes')).toBeInTheDocument(); expect(await findByText('Spending cap')).toBeInTheDocument(); - expect(await findByText('1,461,501,637,3...')).toBeInTheDocument(); }); }); - it.only('should render decoding simulation for seaport request', async () => { - const state = getMockTypedSignConfirmStateForRequest(seaportSignatureMsg); + it('should render decoding simulation for seaport request', async () => { + const state = getMockTypedSignConfirmStateForRequest(seaportSignatureMsg, { + metamask: { useTransactionSimulations: true }, + }); const mockStore = configureMockStore([])(state); await act(async () => { diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx index ebea09e18f15..ce2c8b54c04a 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/typed-sign-v4-simulation.tsx @@ -1,50 +1,20 @@ import React from 'react'; -import { PRIMARY_TYPES_PERMIT } from '../../../../../../../../shared/constants/signatures'; -import { parseTypedDataMessage } from '../../../../../../../../shared/modules/transaction.utils'; import { SignatureRequestType } from '../../../../../types/confirm'; import { isPermitSignatureRequest } from '../../../../../utils'; import { useConfirmContext } from '../../../../../context/confirm'; import { useDecodedSignatureMetrics } from '../../../../../hooks/useDecodedSignatureMetrics'; +import { useTypesSignSimulationEnabledInfo } from '../../../../../hooks/useTypesSignSimulationEnabledInfo'; import { DecodedSimulation } from './decoded-simulation'; import { PermitSimulation } from './permit-simulation'; -const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ - { - domainName: 'Seaport', - primaryTypeList: ['BulkOrder'], - versionList: ['1.4', '1.5', '1.6'], - }, - { - domainName: 'Seaport', - primaryTypeList: ['OrderComponents'], - }, -]; - -const isSupportedByDecodingAPI = (signatureRequest: SignatureRequestType) => { - const { - domain: { name, version }, - primaryType, - } = parseTypedDataMessage( - (signatureRequest as SignatureRequestType).msgParams?.data as string, - ); - const isPermit = PRIMARY_TYPES_PERMIT.includes(primaryType); - const nonPermitSupportedTypes = NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( - ({ domainName, primaryTypeList, versionList }) => - name === domainName && - primaryTypeList.includes(primaryType) && - (!versionList || versionList.includes(version)), - ); - return isPermit || nonPermitSupportedTypes; -}; - const TypedSignV4Simulation: React.FC = () => { const { currentConfirmation } = useConfirmContext(); const isPermit = isPermitSignatureRequest(currentConfirmation); - const supportedByDecodingAPI = isSupportedByDecodingAPI(currentConfirmation); - useDecodedSignatureMetrics(); + const isSimulationSupported = useTypesSignSimulationEnabledInfo(); + useDecodedSignatureMetrics(isSimulationSupported === true); - if (!supportedByDecodingAPI) { + if (!isSimulationSupported) { return null; } diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx index 68f3c011f338..56421561ccd2 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx @@ -14,6 +14,7 @@ import { permitSignatureMsg, permitSignatureMsgWithNoDeadline, unapprovedTypedSignMsgV3, + unapprovedTypedSignMsgV4, } from '../../../../../../../test/data/confirmations/typed_sign'; import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; import * as snapUtils from '../../../../../../helpers/utils/snaps'; @@ -147,6 +148,7 @@ describe('TypedSignInfo', () => { it('displays "requestFromInfoSnap" tooltip when origin is a snap', async () => { const mockState = getMockTypedSignConfirmStateForRequest({ + ...unapprovedTypedSignMsgV4, id: '123', type: TransactionType.signTypedData, chainId: '0x5', @@ -170,6 +172,7 @@ describe('TypedSignInfo', () => { it('displays "requestFromInfo" tooltip when origin is not a snap', async () => { const mockState = getMockTypedSignConfirmStateForRequest({ + ...unapprovedTypedSignMsgV4, id: '123', type: TransactionType.signTypedData, chainId: '0x5', 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 0937e0dc1117..d21ed2b1fca9 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 @@ -1,9 +1,7 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { isValidAddress } from 'ethereumjs-util'; import { ConfirmInfoAlertRow } from '../../../../../../components/app/confirm/info/row/alert-row/alert-row'; -import { MESSAGE_TYPE } from '../../../../../../../shared/constants/app'; import { parseTypedDataMessage } from '../../../../../../../shared/modules/transaction.utils'; import { RowAlertKey } from '../../../../../../components/app/confirm/info/row/constants'; import { @@ -21,7 +19,7 @@ import { isPermitSignatureRequest, } from '../../../../utils'; import { useConfirmContext } from '../../../../context/confirm'; -import { selectUseTransactionSimulations } from '../../../../selectors/preferences'; +import { useTypesSignSimulationEnabledInfo } from '../../../../hooks/useTypesSignSimulationEnabledInfo'; import { ConfirmInfoRowTypedSignData } from '../../row/typed-sign-data/typedSignData'; import { isSnapId } from '../../../../../../helpers/utils/snaps'; import { SigningInWithRow } from '../shared/sign-in-with-row/sign-in-with-row'; @@ -30,9 +28,7 @@ import { TypedSignV4Simulation } from './typed-sign-v4-simulation'; const TypedSignInfo: React.FC = () => { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); - const useTransactionSimulations = useSelector( - selectUseTransactionSimulations, - ); + const isSimulationSupported = useTypesSignSimulationEnabledInfo(); if (!currentConfirmation?.msgParams) { return null; @@ -44,9 +40,6 @@ const TypedSignInfo: React.FC = () => { } = parseTypedDataMessage(currentConfirmation.msgParams.data as string); const isPermit = isPermitSignatureRequest(currentConfirmation); - const isTypedSignV4 = - currentConfirmation.msgParams.signatureMethod === - MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4; const isOrder = isOrderSignatureRequest(currentConfirmation); const tokenContract = isPermit || isOrder ? verifyingContract : undefined; const { decimalsNumber } = useGetTokenStandardAndDetails(tokenContract); @@ -60,7 +53,7 @@ const TypedSignInfo: React.FC = () => { return ( <> - {isTypedSignV4 && useTransactionSimulations && } + {isSimulationSupported && } {isPermit && ( <> @@ -91,7 +84,7 @@ const TypedSignInfo: React.FC = () => { diff --git a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap index ee656d5ebb53..e7e2e499dfe9 100644 --- a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap +++ b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap @@ -1927,30 +1927,19 @@ exports[`Confirm should match snapshot for signature - typed sign - V4 1`] = ` data-testid="confirmation_message-section" >
-
diff --git a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts index c0023104ba8b..b378343bc274 100644 --- a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts +++ b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.test.ts @@ -22,7 +22,27 @@ const decodingData: DecodingData = { }; describe('useDecodedSignatureMetrics', () => { - process.env.ENABLE_SIGNATURE_DECODING = 'true'; + it('should not call updateSignatureEventFragment if supportedByDecodingAPI is false', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + }); + + const mockUpdateSignatureEventFragment = jest.fn(); + jest + .spyOn(SignatureEventFragment, 'useSignatureEventFragment') + .mockImplementation(() => ({ + updateSignatureEventFragment: mockUpdateSignatureEventFragment, + })); + + renderHookWithConfirmContextProvider( + () => useDecodedSignatureMetrics(false), + state, + ); + + expect(mockUpdateSignatureEventFragment).toHaveBeenCalledTimes(0); + }); + it('should not call updateSignatureEventFragment if decodingLoading is true', async () => { const state = getMockTypedSignConfirmStateForRequest({ ...permitSignatureMsg, @@ -37,7 +57,7 @@ describe('useDecodedSignatureMetrics', () => { })); renderHookWithConfirmContextProvider( - () => useDecodedSignatureMetrics(), + () => useDecodedSignatureMetrics(true), state, ); @@ -58,7 +78,7 @@ describe('useDecodedSignatureMetrics', () => { })); renderHookWithConfirmContextProvider( - () => useDecodedSignatureMetrics(), + () => useDecodedSignatureMetrics(true), state, ); @@ -86,7 +106,7 @@ describe('useDecodedSignatureMetrics', () => { })); renderHookWithConfirmContextProvider( - () => useDecodedSignatureMetrics(), + () => useDecodedSignatureMetrics(true), state, ); @@ -120,7 +140,7 @@ describe('useDecodedSignatureMetrics', () => { })); renderHookWithConfirmContextProvider( - () => useDecodedSignatureMetrics(), + () => useDecodedSignatureMetrics(true), state, ); diff --git a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts index 1bf508bce655..98fd07984a9d 100644 --- a/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts +++ b/ui/pages/confirmations/hooks/useDecodedSignatureMetrics.ts @@ -10,7 +10,7 @@ enum DecodingResponseType { NoChange = 'NO_CHANGE', } -export function useDecodedSignatureMetrics() { +export function useDecodedSignatureMetrics(supportedByDecodingAPI: boolean) { const { updateSignatureEventFragment } = useSignatureEventFragment(); const { currentConfirmation } = useConfirmContext(); const { decodingLoading, decodingData } = currentConfirmation; @@ -26,7 +26,7 @@ export function useDecodedSignatureMetrics() { : DecodingResponseType.NoChange); useEffect(() => { - if (decodingLoading || !process.env.ENABLE_SIGNATURE_DECODING) { + if (decodingLoading || !supportedByDecodingAPI) { return; } diff --git a/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.test.ts b/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.test.ts new file mode 100644 index 000000000000..ce98f58782d7 --- /dev/null +++ b/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.test.ts @@ -0,0 +1,71 @@ +import { getMockTypedSignConfirmStateForRequest } from '../../../../test/data/confirmations/helper'; +import { renderHookWithConfirmContextProvider } from '../../../../test/lib/confirmations/render-helpers'; +import { + permitSingleSignatureMsg, + seaportSignatureMsg, + unapprovedTypedSignMsgV1, +} from '../../../../test/data/confirmations/typed_sign'; +import { useTypesSignSimulationEnabledInfo } from './useTypesSignSimulationEnabledInfo'; + +describe('useTypesSignSimulationEnabledInfo', () => { + it('return false if user has disabled simulations', async () => { + const state = getMockTypedSignConfirmStateForRequest( + permitSingleSignatureMsg, + { + metamask: { useTransactionSimulations: false }, + }, + ); + + const { result } = renderHookWithConfirmContextProvider( + () => useTypesSignSimulationEnabledInfo(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('return false if request is not types sign v3 or V4', async () => { + const state = getMockTypedSignConfirmStateForRequest( + unapprovedTypedSignMsgV1, + { + metamask: { useTransactionSimulations: true }, + }, + ); + + const { result } = renderHookWithConfirmContextProvider( + () => useTypesSignSimulationEnabledInfo(), + state, + ); + + expect(result.current).toBe(false); + }); + + it('return true for typed sign v4 permit request', async () => { + const state = getMockTypedSignConfirmStateForRequest( + permitSingleSignatureMsg, + { + metamask: { useTransactionSimulations: true }, + }, + ); + + const { result } = renderHookWithConfirmContextProvider( + () => useTypesSignSimulationEnabledInfo(), + state, + ); + + expect(result.current).toBe(true); + }); + + it('return true for typed sign v4 seaport request', async () => { + const state = getMockTypedSignConfirmStateForRequest(seaportSignatureMsg, { + metamask: { useTransactionSimulations: true }, + }); + + const { result } = renderHookWithConfirmContextProvider( + () => useTypesSignSimulationEnabledInfo(), + state, + ); + + expect(result.current).toBe(true); + }); +}); diff --git a/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.ts b/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.ts new file mode 100644 index 000000000000..96703bffc0fb --- /dev/null +++ b/ui/pages/confirmations/hooks/useTypesSignSimulationEnabledInfo.ts @@ -0,0 +1,65 @@ +import { useSelector } from 'react-redux'; + +import { MESSAGE_TYPE } from '../../../../shared/constants/app'; +import { parseTypedDataMessage } from '../../../../shared/modules/transaction.utils'; +import { SignatureRequestType } from '../types/confirm'; +import { isPermitSignatureRequest } from '../utils'; +import { selectUseTransactionSimulations } from '../selectors/preferences'; +import { useConfirmContext } from '../context/confirm'; + +const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ + { + domainName: 'Seaport', + primaryTypeList: ['BulkOrder'], + versionList: ['1.4', '1.5', '1.6'], + }, + { + domainName: 'Seaport', + primaryTypeList: ['OrderComponents'], + }, +]; + +const isNonPermitSupportedByDecodingAPI = ( + signatureRequest: SignatureRequestType, +) => { + const data = (signatureRequest as SignatureRequestType).msgParams + ?.data as string; + if (!data) { + return false; + } + const { + domain: { name, version }, + primaryType, + } = parseTypedDataMessage(data); + return NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( + ({ domainName, primaryTypeList, versionList }) => + name === domainName && + primaryTypeList.includes(primaryType) && + (!versionList || versionList.includes(version)), + ); +}; + +export function useTypesSignSimulationEnabledInfo() { + const { currentConfirmation } = useConfirmContext(); + const useTransactionSimulations = useSelector( + selectUseTransactionSimulations, + ); + + const signatureMethod = currentConfirmation?.msgParams?.signatureMethod; + const isTypedSignV3V4 = + signatureMethod === MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4 || + signatureMethod === MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V3; + const isPermit = isPermitSignatureRequest(currentConfirmation); + const nonPermitSupportedByDecodingAPI = + isTypedSignV3V4 && isNonPermitSupportedByDecodingAPI(currentConfirmation); + + if (!currentConfirmation) { + return undefined; + } + + return ( + useTransactionSimulations && + isTypedSignV3V4 && + (isPermit || nonPermitSupportedByDecodingAPI) + ); +} From a2e0b01c5b9c2cc9a6d1bc560e96e68a315ac209 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Thu, 28 Nov 2024 17:53:53 +0100 Subject: [PATCH 127/148] chore: bump `@metamask/preferences-controller` to `^14.0.0` (#28778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR bumps `@metamask/preferences-controller` to `^14.0.0` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28778?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28491 ## **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 - [ ] I’ve included tests if applicable - [ ] 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/preferences-controller.ts | 9 ++++++--- package.json | 2 +- yarn.lock | 16 ++++++++-------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index dce2ef3d0512..d705be4c3180 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -124,10 +124,14 @@ export type Preferences = { shouldShowAggregatedBalancePopover: boolean; }; -// Omitting showTestNetworks and smartTransactionsOptInStatus, as they already exists here in Preferences type +// Omitting properties that already exist in the PreferencesState, as part of the preferences property. export type PreferencesControllerState = Omit< PreferencesState, - 'showTestNetworks' | 'smartTransactionsOptInStatus' + | 'showTestNetworks' + | 'smartTransactionsOptInStatus' + | 'privacyMode' + | 'tokenSortConfig' + | 'useMultiRpcMigration' > & { useBlockie: boolean; useNonceField: boolean; @@ -135,7 +139,6 @@ export type PreferencesControllerState = Omit< dismissSeedBackUpReminder: boolean; overrideContentSecurityPolicyHeader: boolean; useMultiAccountBalanceChecker: boolean; - useSafeChainsListValidation: boolean; use4ByteResolution: boolean; useCurrencyRateCheck: boolean; useRequestQueue: boolean; diff --git a/package.json b/package.json index bf715e753f61..baffa0c6373b 100644 --- a/package.json +++ b/package.json @@ -467,7 +467,7 @@ "@metamask/eth-json-rpc-provider": "^4.1.6", "@metamask/forwarder": "^1.1.0", "@metamask/phishing-warning": "^4.1.0", - "@metamask/preferences-controller": "^13.0.2", + "@metamask/preferences-controller": "^14.0.0", "@metamask/test-bundler": "^1.0.0", "@metamask/test-dapp": "8.13.0", "@octokit/core": "^3.6.0", diff --git a/yarn.lock b/yarn.lock index 159ea9658db7..4fbf80a01a15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6080,15 +6080,15 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^13.0.2": - version: 13.0.3 - resolution: "@metamask/preferences-controller@npm:13.0.3" +"@metamask/preferences-controller@npm:^14.0.0": + version: 14.0.0 + resolution: "@metamask/preferences-controller@npm:14.0.0" dependencies: - "@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.3" peerDependencies: - "@metamask/keyring-controller": ^17.0.0 - checksum: 10/d922c2e603c7a1ef0301dcfc7d5b6aa0bbdd9c318f0857fbbc9e95606609ae806e69c46231288953ce443322039781404565a46fe42bdfa731c4f0da20448d32 + "@metamask/keyring-controller": ^18.0.0 + checksum: 10/cc1fdfe4dc6f4c058c518b59f13b7badd0de92e04e143aec6787be3a8807364d545dcb26172dd005e0d6865b06614b963385f0863a3e2a04d234bd6d33474942 languageName: node linkType: hard @@ -26581,7 +26581,7 @@ __metadata: "@metamask/polling-controller": "npm:^11.0.0" "@metamask/post-message-stream": "npm:^8.0.0" "@metamask/ppom-validator": "npm:0.35.1" - "@metamask/preferences-controller": "npm:^13.0.2" + "@metamask/preferences-controller": "npm:^14.0.0" "@metamask/preinstalled-example-snap": "npm:^0.2.0" "@metamask/profile-sync-controller": "npm:^2.0.0" "@metamask/providers": "npm:^18.2.0" From efbdf3ad04da26e526a31f7703ea4752c47df23e Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 28 Nov 2024 14:57:41 -0330 Subject: [PATCH 128/148] test: Fix `getEventPayloads` e2e test helper (#28796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The test helper `getEventPayloads` is meant to wait until all mock endpoints provided have resolved (i.e. until none are pending). However, it currently only waits until the _last_ mock endpoint in the array has been fulfilled. The pending status of the others is ignored. This results in race conditions, because the status of non-last mock endpoints is indeterminate when this returns. The `hasRequest` parameter has been renamed to `waitWhilePending` to make the expected behavior more clear, and JSDocs have been added for each parameter. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28796?quickstart=1) ## **Related issues** None. This was done to assist with debugging e2e test failures. In particular it was hoped to fix (or shed further light on) some failures we're seeing on `develop` right now. ## **Manual testing steps** N/A ## **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. --- test/e2e/helpers.js | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 75eefb12f7c1..b06c29b17acf 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -723,25 +723,32 @@ async function switchToNotificationWindow(driver) { * mockServer method, this method will allow getting all of the seen requests * for each mock in the array. * - * @param {WebDriver} driver - * @param {import('mockttp').MockedEndpoint[]} mockedEndpoints - * @param {boolean} hasRequest + * @param {WebDriver} driver - The WebDriver instance. + * @param {import('mockttp').MockedEndpoint[]} mockedEndpoints - mockttp mocked endpoints + * @param {boolean} [waitWhilePending] - Wait until no requests are pending * @returns {Promise} */ -async function getEventPayloads(driver, mockedEndpoints, hasRequest = true) { - await driver.wait( - async () => { - let isPending = true; - - for (const mockedEndpoint of mockedEndpoints) { - isPending = await mockedEndpoint.isPending(); - } +async function getEventPayloads( + driver, + mockedEndpoints, + waitWhilePending = true, +) { + if (waitWhilePending) { + await driver.wait( + async () => { + const pendingStatuses = await Promise.all( + mockedEndpoints.map((mockedEndpoint) => mockedEndpoint.isPending()), + ); + const isSomethingPending = pendingStatuses.some( + (pendingStatus) => pendingStatus, + ); - return isPending === !hasRequest; - }, - driver.timeout, - true, - ); + return !isSomethingPending; + }, + driver.timeout, + true, + ); + } const mockedRequests = []; for (const mockedEndpoint of mockedEndpoints) { mockedRequests.push(...(await mockedEndpoint.getSeenRequests())); From a9026774328c37da7b7c31e94899955eb09b1aca Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 28 Nov 2024 15:48:52 -0330 Subject: [PATCH 129/148] chore: Bump `@metamask/eth-token-tracker` from v8 to v9 (#28754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `@metamask/eth-token-tracker` package has been bumped from v8 to v9. The only breaking change is dropping support for older Node.js versions. This gets rid of some older copies of dependencies, reducing bundle size. Changelog: https://github.com/MetaMask/eth-token-tracker/blob/main/CHANGELOG.md#900 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28754?quickstart=1) ## **Related issues** N/A ## **Manual testing steps** N/A ## **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. --------- Co-authored-by: MetaMask Bot --- lavamoat/browserify/beta/policy.json | 30 +---------------- lavamoat/browserify/flask/policy.json | 30 +---------------- lavamoat/browserify/main/policy.json | 30 +---------------- lavamoat/browserify/mmi/policy.json | 30 +---------------- package.json | 2 +- yarn.lock | 47 ++++----------------------- 6 files changed, 11 insertions(+), 158 deletions(-) diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 1a46c8be2266..47227aeef932 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1739,7 +1739,7 @@ "@metamask/controller-utils": true, "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, - "@metamask/network-controller>@metamask/eth-block-tracker": true, + "@metamask/eth-token-tracker>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, @@ -1753,34 +1753,6 @@ "uuid": true } }, - "@metamask/network-controller>@metamask/eth-block-tracker": { - "globals": { - "clearTimeout": true, - "console.error": true, - "setTimeout": true - }, - "packages": { - "@metamask/eth-query>json-rpc-random-id": true, - "@metamask/network-controller>@metamask/eth-block-tracker>@metamask/utils": true, - "@metamask/safe-event-emitter": true, - "pify": true - } - }, - "@metamask/network-controller>@metamask/eth-block-tracker>@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/network-controller>@metamask/eth-json-rpc-infura": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 1a46c8be2266..47227aeef932 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1739,7 +1739,7 @@ "@metamask/controller-utils": true, "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, - "@metamask/network-controller>@metamask/eth-block-tracker": true, + "@metamask/eth-token-tracker>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, @@ -1753,34 +1753,6 @@ "uuid": true } }, - "@metamask/network-controller>@metamask/eth-block-tracker": { - "globals": { - "clearTimeout": true, - "console.error": true, - "setTimeout": true - }, - "packages": { - "@metamask/eth-query>json-rpc-random-id": true, - "@metamask/network-controller>@metamask/eth-block-tracker>@metamask/utils": true, - "@metamask/safe-event-emitter": true, - "pify": true - } - }, - "@metamask/network-controller>@metamask/eth-block-tracker>@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/network-controller>@metamask/eth-json-rpc-infura": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 1a46c8be2266..47227aeef932 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1739,7 +1739,7 @@ "@metamask/controller-utils": true, "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, - "@metamask/network-controller>@metamask/eth-block-tracker": true, + "@metamask/eth-token-tracker>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, @@ -1753,34 +1753,6 @@ "uuid": true } }, - "@metamask/network-controller>@metamask/eth-block-tracker": { - "globals": { - "clearTimeout": true, - "console.error": true, - "setTimeout": true - }, - "packages": { - "@metamask/eth-query>json-rpc-random-id": true, - "@metamask/network-controller>@metamask/eth-block-tracker>@metamask/utils": true, - "@metamask/safe-event-emitter": true, - "pify": true - } - }, - "@metamask/network-controller>@metamask/eth-block-tracker>@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/network-controller>@metamask/eth-json-rpc-infura": { "globals": { "setTimeout": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index eb82932b6c6d..89eedc822794 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1831,7 +1831,7 @@ "@metamask/controller-utils": true, "@metamask/eth-json-rpc-provider": true, "@metamask/eth-query": true, - "@metamask/network-controller>@metamask/eth-block-tracker": true, + "@metamask/eth-token-tracker>@metamask/eth-block-tracker": true, "@metamask/network-controller>@metamask/eth-json-rpc-infura": true, "@metamask/network-controller>@metamask/eth-json-rpc-middleware": true, "@metamask/network-controller>@metamask/json-rpc-engine": true, @@ -1845,34 +1845,6 @@ "uuid": true } }, - "@metamask/network-controller>@metamask/eth-block-tracker": { - "globals": { - "clearTimeout": true, - "console.error": true, - "setTimeout": true - }, - "packages": { - "@metamask/eth-query>json-rpc-random-id": true, - "@metamask/network-controller>@metamask/eth-block-tracker>@metamask/utils": true, - "@metamask/safe-event-emitter": true, - "pify": true - } - }, - "@metamask/network-controller>@metamask/eth-block-tracker>@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/network-controller>@metamask/eth-json-rpc-infura": { "globals": { "setTimeout": true diff --git a/package.json b/package.json index baffa0c6373b..e7827cb6d92b 100644 --- a/package.json +++ b/package.json @@ -302,7 +302,7 @@ "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^7.0.1", "@metamask/eth-snap-keyring": "^5.0.1", - "@metamask/eth-token-tracker": "^8.0.0", + "@metamask/eth-token-tracker": "^9.0.0", "@metamask/eth-trezor-keyring": "^3.1.3", "@metamask/etherscan-link": "^3.0.0", "@metamask/ethjs": "^0.6.0", diff --git a/yarn.lock b/yarn.lock index 4fbf80a01a15..91cc4445c553 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5199,19 +5199,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^9.0.2": - version: 9.0.3 - resolution: "@metamask/eth-block-tracker@npm:9.0.3" - dependencies: - "@metamask/eth-json-rpc-provider": "npm:^3.0.2" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^8.1.0" - json-rpc-random-id: "npm:^1.0.1" - pify: "npm:^5.0.0" - checksum: 10/f49bb158b2c9669e91813a1f34948a6c2a2d1f8507ea0b1afae9a003a4d276d892b5cde4c183f970fcec32f253a14f1abf39b6411beabeb113eeed75cd7e29b8 - languageName: node - linkType: hard - "@metamask/eth-hd-keyring@npm:^7.0.4": version: 7.0.4 resolution: "@metamask/eth-hd-keyring@npm:7.0.4" @@ -5312,17 +5299,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^3.0.2": - version: 3.0.2 - resolution: "@metamask/eth-json-rpc-provider@npm:3.0.2" - dependencies: - "@metamask/json-rpc-engine": "npm:^8.0.2" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^8.3.0" - checksum: 10/63778defd3055633cbf0aed2d6fd0f8a1d866908be7b16b516fdb26ae6dcd34b2aefdfed80828c2af105a30ec3c16d7d0894bc6a73e2661515bcad6b6b6be4e2 - languageName: node - linkType: hard - "@metamask/eth-json-rpc-provider@npm:^4.0.0, @metamask/eth-json-rpc-provider@npm:^4.1.3, @metamask/eth-json-rpc-provider@npm:^4.1.5, @metamask/eth-json-rpc-provider@npm:^4.1.6": version: 4.1.6 resolution: "@metamask/eth-json-rpc-provider@npm:4.1.6" @@ -5420,11 +5396,11 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-token-tracker@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/eth-token-tracker@npm:8.0.0" +"@metamask/eth-token-tracker@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/eth-token-tracker@npm:9.0.0" dependencies: - "@metamask/eth-block-tracker": "npm:^9.0.2" + "@metamask/eth-block-tracker": "npm:^10.0.0" "@metamask/ethjs-contract": "npm:^0.4.1" "@metamask/ethjs-query": "npm:^0.7.1" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -5433,7 +5409,7 @@ __metadata: human-standard-token-abi: "npm:^2.0.0" peerDependencies: "@babel/runtime": ^7.21.0 - checksum: 10/036540abaf13cdd5a0037d4f8cb94076b96c1c9b7de432918f2b6cb07e45abd79dff2d50313e870c711f3f408359be6329ba90f29b3ecba74577a9b913fd3e2a + checksum: 10/5cf3afefe435b708e2f0e624e5920b24d8b2271c8406580623096049b2ebc314895cafefa51714e757c84064ae02cea015f9fbcf455804893a4b36bb3ff19289 languageName: node linkType: hard @@ -5636,17 +5612,6 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^8.0.2": - version: 8.0.2 - resolution: "@metamask/json-rpc-engine@npm:8.0.2" - dependencies: - "@metamask/rpc-errors": "npm:^6.2.1" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^8.3.0" - checksum: 10/f088f4b648b9b55875b56e8237853e7282f13302a9db6a1f9bba06314dfd6cd0a23b3d27f8fde05a157b97ebb03b67bc2699ba455c99553dfb2ecccd73ab3474 - languageName: node - linkType: hard - "@metamask/json-rpc-engine@npm:^9.0.0, @metamask/json-rpc-engine@npm:^9.0.2, @metamask/json-rpc-engine@npm:^9.0.3": version: 9.0.3 resolution: "@metamask/json-rpc-engine@npm:9.0.3" @@ -26551,7 +26516,7 @@ __metadata: "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^7.0.1" "@metamask/eth-snap-keyring": "npm:^5.0.1" - "@metamask/eth-token-tracker": "npm:^8.0.0" + "@metamask/eth-token-tracker": "npm:^9.0.0" "@metamask/eth-trezor-keyring": "npm:^3.1.3" "@metamask/etherscan-link": "npm:^3.0.0" "@metamask/ethjs": "npm:^0.6.0" From 099a07b4c44473a4e8a1cc188c009fda15b5588a Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Fri, 29 Nov 2024 00:25:54 +0000 Subject: [PATCH 130/148] fix: has_marketing_consent flag on metametrics (#28795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Sends the correct value for the `has_marketing_consent` flag on trackEvent. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28795?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3699 ## **Manual testing steps** 1. Go to Security settings 2. Enable the marketing consent 3. Check that the trackEvent on metametrics is passing a `true` value 4. Check same condition when triggering it off ## **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/pages/settings/security-tab/security-tab.component.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/pages/settings/security-tab/security-tab.component.js b/ui/pages/settings/security-tab/security-tab.component.js index f9e854ff2465..fe9785f5e4c1 100644 --- a/ui/pages/settings/security-tab/security-tab.component.js +++ b/ui/pages/settings/security-tab/security-tab.component.js @@ -381,14 +381,15 @@ export default class SecurityTab extends PureComponent { { - setDataCollectionForMarketing(!value); + const newMarketingConsent = Boolean(!value); + setDataCollectionForMarketing(newMarketingConsent); if (participateInMetaMetrics) { this.context.trackEvent({ category: MetaMetricsEventCategory.Settings, event: MetaMetricsEventName.AnalyticsPreferenceSelected, properties: { is_metrics_opted_in: true, - has_marketing_consent: false, + has_marketing_consent: Boolean(newMarketingConsent), location: 'Settings', }, }); From cbb57a13a57a5e364efa7f121199a08c9fdbf77a Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:18:23 +0100 Subject: [PATCH 131/148] fix: SIWE e2e test timing out and breaking ci (#28801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** SIWE tests are timing out, making our ci broken, as they take very long to execute (there are several instances of waiting for event payload which take 20 seconds each, making reach the total limit of 80 seconds). This updates the timeout limits for that spec to unblock ci (based on a similar fix @chloeYue did for another long test). The Confirmations team can investigate further if that's expected ![Screenshot from 2024-11-29 09-21-45](https://github.com/user-attachments/assets/fd87f087-e291-49f6-b2fe-3acd8108bd2f) ![Screenshot from 2024-11-29 09-06-04](https://github.com/user-attachments/assets/da118cb2-d55f-4c92-9b60-430118ede4fb) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28801?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask 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/e2e/tests/confirmations/signatures/siwe.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/tests/confirmations/signatures/siwe.spec.ts b/test/e2e/tests/confirmations/signatures/siwe.spec.ts index 4e90ac788a08..ab614095d793 100644 --- a/test/e2e/tests/confirmations/signatures/siwe.spec.ts +++ b/test/e2e/tests/confirmations/signatures/siwe.spec.ts @@ -31,6 +31,7 @@ import { } from './signature-helpers'; describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { + this.timeout(200000); // This test is very long, so we need an unusually high timeout it('initiates and confirms', async function () { await withTransactionEnvelopeTypeFixtures( this.test?.fullTitle(), From a48abf3978ad5e5328b8827633bf54321db176cc Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Fri, 29 Nov 2024 10:20:31 +0000 Subject: [PATCH 132/148] Version v12.9.0 --- CHANGELOG.md | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 242 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcfeae11739..926adfb6e190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,245 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.9.0] +### Uncategorized +- fix: SIWE e2e test timing out and breaking ci ([#28801](https://github.com/MetaMask/metamask-extension/pull/28801)) +- fix: has_marketing_consent flag on metametrics ([#28795](https://github.com/MetaMask/metamask-extension/pull/28795)) +- chore: Bump `@metamask/eth-token-tracker` from v8 to v9 ([#28754](https://github.com/MetaMask/metamask-extension/pull/28754)) +- test: Fix `getEventPayloads` e2e test helper ([#28796](https://github.com/MetaMask/metamask-extension/pull/28796)) +- chore: bump `@metamask/preferences-controller` to `^14.0.0` ([#28778](https://github.com/MetaMask/metamask-extension/pull/28778)) +- chore: Master sync after 12.7.2 ([#28794](https://github.com/MetaMask/metamask-extension/pull/28794)) +- Merge branch 'master-sync' of https://github.com/MetaMask/metamask-extension into master-sync +- Merge origin/develop into master-sync +- feat: adding e2e for signature decoding api and enable it in extension ([#28423](https://github.com/MetaMask/metamask-extension/pull/28423)) +- feat: Support returning a txHash asap for smart transactions ([#28770](https://github.com/MetaMask/metamask-extension/pull/28770)) +- feat: multichain send action adds solana ([#28738](https://github.com/MetaMask/metamask-extension/pull/28738)) +- chore: Cleanup PortfolioView ([#28785](https://github.com/MetaMask/metamask-extension/pull/28785)) +- test: migrate signature redesign tests to page object model ([#28538](https://github.com/MetaMask/metamask-extension/pull/28538)) +- chore: Bump `@metamask/message-manager` to v11 ([#28758](https://github.com/MetaMask/metamask-extension/pull/28758)) +- feat: migrate `AppMedataController` to inherit from BaseController V2 ([#28783](https://github.com/MetaMask/metamask-extension/pull/28783)) +- chore: Add error handling for `setCorrectChain` ([#28740](https://github.com/MetaMask/metamask-extension/pull/28740)) +- fix: Add optional chaining to currencyRates check for stability ([#28753](https://github.com/MetaMask/metamask-extension/pull/28753)) +- chore: Bump `@metamask/providers` to v18 ([#28757](https://github.com/MetaMask/metamask-extension/pull/28757)) +- chore: Bump `@metamask/eth-json-rpc-middleware` to v15.0.0 ([#28756](https://github.com/MetaMask/metamask-extension/pull/28756)) +- fix: Phishing page metrics ([#28364](https://github.com/MetaMask/metamask-extension/pull/28364)) +- feat: Turn on `PortfolioView` ([#28661](https://github.com/MetaMask/metamask-extension/pull/28661)) +- fix: remove network modal ([#28765](https://github.com/MetaMask/metamask-extension/pull/28765)) +- chore: Update `@metamask/polling-controller` to v11 ([#28759](https://github.com/MetaMask/metamask-extension/pull/28759)) +- perf: Prevent Sentry from auto-generating spans for requests to Sentry ([#28613](https://github.com/MetaMask/metamask-extension/pull/28613)) +- feat: Integrate Snap notification services ([#27975](https://github.com/MetaMask/metamask-extension/pull/27975)) +- feat: PortfolioView: Add feature flag check for polling intervals ([#28501](https://github.com/MetaMask/metamask-extension/pull/28501)) +- fix: add dispatch detect Nfts on network switch ([#28769](https://github.com/MetaMask/metamask-extension/pull/28769)) +- feat: on UI side filtering put typed sign V4 requests for which decoding data is displayed ([#28762](https://github.com/MetaMask/metamask-extension/pull/28762)) +- chore: Bump `@metamask/eth-json-rpc-middleware` to v14.0.2 ([#28755](https://github.com/MetaMask/metamask-extension/pull/28755)) +- fix(wallet-overview): prevent send button clicked event to be sent twice ([#28772](https://github.com/MetaMask/metamask-extension/pull/28772)) +- refactor: move `getCurrentChainId` from `selectors/selectors.js` to `shared/modules/selectors/networks.ts` ([#27647](https://github.com/MetaMask/metamask-extension/pull/27647)) +- feat: adding metrics for signature decoding ([#28719](https://github.com/MetaMask/metamask-extension/pull/28719)) +- fix: fix transaction list message on token detail page ([#28764](https://github.com/MetaMask/metamask-extension/pull/28764)) +- chore: Bump `@metamask/permission-log-controller` to v3.0.1 ([#28747](https://github.com/MetaMask/metamask-extension/pull/28747)) +- chore: Bump `@metamask/ens-controller` from v13 to v14 ([#28746](https://github.com/MetaMask/metamask-extension/pull/28746)) +- test: add e2e for transaction decoding ([#28204](https://github.com/MetaMask/metamask-extension/pull/28204)) +- test: add integration tests for different types of Permit ([#27446](https://github.com/MetaMask/metamask-extension/pull/27446)) +- test: [POM] Migrate add token e2e tests to TS and Page Object Model ([#28658](https://github.com/MetaMask/metamask-extension/pull/28658)) +- chore: node.js 20.18 ([#28058](https://github.com/MetaMask/metamask-extension/pull/28058)) +- chore: Update `@metamask/gas-fee-controller` and peer deps ([#28745](https://github.com/MetaMask/metamask-extension/pull/28745)) +- fix: content dialog styling is being applied to all dialogs ([#28739](https://github.com/MetaMask/metamask-extension/pull/28739)) +- feat: Bump `@metamask/permission-controller` to `^11.0.0` ([#28743](https://github.com/MetaMask/metamask-extension/pull/28743)) +- feat: add e2e tests for multichain ([#28708](https://github.com/MetaMask/metamask-extension/pull/28708)) +- fix: Add metric trait for token network filter preference ([#28336](https://github.com/MetaMask/metamask-extension/pull/28336)) +- fix: Provide selector that enables cross-chain polling, regardless of network filter state ([#28662](https://github.com/MetaMask/metamask-extension/pull/28662)) +- chore: Remove unnecessary event prop ([#28546](https://github.com/MetaMask/metamask-extension/pull/28546)) +- fix: Revert "feat: Changing title for permit requests (#28537)" ([#28537](https://github.com/MetaMask/metamask-extension/pull/28537)) +- fix: Fix avatar size for current network ([#28731](https://github.com/MetaMask/metamask-extension/pull/28731)) +- fix:updated account name and length for dapp connections ([#28725](https://github.com/MetaMask/metamask-extension/pull/28725)) +- fix: Pass along decimal balance from asset-page to swaps UI ([#28707](https://github.com/MetaMask/metamask-extension/pull/28707)) +- chore: adds Solana support for the account overview ([#28411](https://github.com/MetaMask/metamask-extension/pull/28411)) +- feat: Enable redesigned transaction confirmations for all users ([#28321](https://github.com/MetaMask/metamask-extension/pull/28321)) +- feat: cross chain swaps - tx status - BridgeStatusController ([#28636](https://github.com/MetaMask/metamask-extension/pull/28636)) +- fix: use BN from bn.js instead of ethereumjs-util ([#28146](https://github.com/MetaMask/metamask-extension/pull/28146)) +- feat: Add first time interaction warning ([#28435](https://github.com/MetaMask/metamask-extension/pull/28435)) +- feat: enable account syncing in production ([#28596](https://github.com/MetaMask/metamask-extension/pull/28596)) +- test: add accounts sync test with balance detection ([#28715](https://github.com/MetaMask/metamask-extension/pull/28715)) +- chore: Bump Snaps packages ([#28678](https://github.com/MetaMask/metamask-extension/pull/28678)) +- fix: transaction flow section layout on re-designed confirmation pages ([#28720](https://github.com/MetaMask/metamask-extension/pull/28720)) +- feat: Changing title for permit requests ([#28537](https://github.com/MetaMask/metamask-extension/pull/28537)) +- fix: add e2e for portfolio view polling ([#28682](https://github.com/MetaMask/metamask-extension/pull/28682)) +- fix: PortfolioView swap native token bug ([#28639](https://github.com/MetaMask/metamask-extension/pull/28639)) +- fix: prevent non-current network tokens from being hidden incorrectly ([#28674](https://github.com/MetaMask/metamask-extension/pull/28674)) +- fix: fix `ConnectPage` when a non-EVM account is selected ([#28436](https://github.com/MetaMask/metamask-extension/pull/28436)) +- test: [POM] Migrate create btc account e2e tests to TS and Page Object Model ([#28437](https://github.com/MetaMask/metamask-extension/pull/28437)) +- fix: SonarCloud workflow_run ([#28693](https://github.com/MetaMask/metamask-extension/pull/28693)) +- fix: swaps approval checking for approvals between 0 and unlimited ([#28680](https://github.com/MetaMask/metamask-extension/pull/28680)) +- chore: Restrict MMI test runs ([#28655](https://github.com/MetaMask/metamask-extension/pull/28655)) +- feat: change description of enabling simulation message in settings ([#28536](https://github.com/MetaMask/metamask-extension/pull/28536)) +- test: blockaid e2e test for contract interaction ([#28156](https://github.com/MetaMask/metamask-extension/pull/28156)) +- perf: add React.lazy to the Routes ([#28172](https://github.com/MetaMask/metamask-extension/pull/28172)) +- fix: add missing filter for scheduled job rerun-from-failed ([#28644](https://github.com/MetaMask/metamask-extension/pull/28644)) +- fix: Add default value to custom nonce modal ([#28659](https://github.com/MetaMask/metamask-extension/pull/28659)) +- fix: Reduce max pet name length ([#28660](https://github.com/MetaMask/metamask-extension/pull/28660)) +- test: rename balance functions to cover both Ganache and Anvil in preparation for ganache migration ([#28676](https://github.com/MetaMask/metamask-extension/pull/28676)) +- fix: display new network popup only for accounts that are compatible. ([#28535](https://github.com/MetaMask/metamask-extension/pull/28535)) +- feat: adding tooltip to signature decoding state changes ([#28430](https://github.com/MetaMask/metamask-extension/pull/28430)) +- fix: add alert when selected account is different from signing account in confirmation ([#28562](https://github.com/MetaMask/metamask-extension/pull/28562)) +- test: Adding unit test for setupPhishingCommunication and setUpCookieHandlerCommunication ([#27736](https://github.com/MetaMask/metamask-extension/pull/27736)) +- chore: PortfolioView™: Design Review Cleanup: Networks, sort, & Menu ([#28663](https://github.com/MetaMask/metamask-extension/pull/28663)) +- chore: sort and display all bridge quotes ([#27731](https://github.com/MetaMask/metamask-extension/pull/27731)) +- fix: market data for native tokens with non zero addresses ([#28584](https://github.com/MetaMask/metamask-extension/pull/28584)) +- fix: Reset streams on BFCache events ([#24950](https://github.com/MetaMask/metamask-extension/pull/24950)) +- fix: add unit test for assets polling loops ([#28646](https://github.com/MetaMask/metamask-extension/pull/28646)) +- chore: Run MMI tests on long-running branches ([#28651](https://github.com/MetaMask/metamask-extension/pull/28651)) +- fix: Provide maximal asset list filter space ([#28590](https://github.com/MetaMask/metamask-extension/pull/28590)) +- chore: bump `keyring-api` to `^10.1.0` + `eth-snap-keyring` to `^5.0.1` ([#28545](https://github.com/MetaMask/metamask-extension/pull/28545)) +- chore: updating filter icon to align with figma ([#28547](https://github.com/MetaMask/metamask-extension/pull/28547)) +- test: rename the `GanacheContractAddressRegistry` class in preparation for ganache migration ([#28595](https://github.com/MetaMask/metamask-extension/pull/28595)) +- chore: Update and use selectors for which chains to poll ([#28586](https://github.com/MetaMask/metamask-extension/pull/28586)) +- feat: Display '< 0.01' instead of '0.00' for the fiat value of networ… ([#28543](https://github.com/MetaMask/metamask-extension/pull/28543)) +- Merge origin/develop into master-sync +- chore: rerun workflow from failed ([#28143](https://github.com/MetaMask/metamask-extension/pull/28143)) +- chore: change e2e quality gate reruns for new/changed tests from 5 to 4 ([#28611](https://github.com/MetaMask/metamask-extension/pull/28611)) +- feat: display native values returned from decoding api ([#28374](https://github.com/MetaMask/metamask-extension/pull/28374)) +- chore: Branch off of "New Crowdin translations by Github Action" ([#28390](https://github.com/MetaMask/metamask-extension/pull/28390)) +- refactor: move `getProviderConfig` out of `ducks/metamask.js` to `shared/selectors/networks.ts` ([#27646](https://github.com/MetaMask/metamask-extension/pull/27646)) +- test: Fixed artifacts issue due to switching window title ([#28469](https://github.com/MetaMask/metamask-extension/pull/28469)) +- fix: Network filter must respect `PORTFOLIO_VIEW` feature flag ([#28626](https://github.com/MetaMask/metamask-extension/pull/28626)) +- test: Fix flakiness caused by display of newly switched to network modal ([#28625](https://github.com/MetaMask/metamask-extension/pull/28625)) +- feat: multichain token detection ([#28380](https://github.com/MetaMask/metamask-extension/pull/28380)) +- fix: fix test networks display for portfolio view ([#28601](https://github.com/MetaMask/metamask-extension/pull/28601)) +- feat(SwapsController): Remove reliance on global network ([#28275](https://github.com/MetaMask/metamask-extension/pull/28275)) +- feat: `PortfolioView` ([#28593](https://github.com/MetaMask/metamask-extension/pull/28593)) +- fix: replace unreliable setTimeout usage with waitFor ([#28620](https://github.com/MetaMask/metamask-extension/pull/28620)) +- feat: Hook in Portfolio Entry Points ([#27607](https://github.com/MetaMask/metamask-extension/pull/27607)) +- feat: cross chain swaps - tx submit ([#27262](https://github.com/MetaMask/metamask-extension/pull/27262)) +- chore: centralize redesigned confirmation decision logic ([#28445](https://github.com/MetaMask/metamask-extension/pull/28445)) +- chore: upgrade transaction controller to increase polling rate ([#28452](https://github.com/MetaMask/metamask-extension/pull/28452)) +- fix: fix account list item for portfolio view ([#28598](https://github.com/MetaMask/metamask-extension/pull/28598)) +- feat: Better handle very long names in the name component ([#28560](https://github.com/MetaMask/metamask-extension/pull/28560)) +- fix: PortfolioView: Remove pausedChainIds from selector ([#28552](https://github.com/MetaMask/metamask-extension/pull/28552)) +- refactor: Cherry pick asset-list-control-bar updates ([#28575](https://github.com/MetaMask/metamask-extension/pull/28575)) +- fix: Gracefully handle bad responses from `net_version` calls to RPC endpoint when getting Provider Network State ([#27509](https://github.com/MetaMask/metamask-extension/pull/27509)) +- fix: use PORTFOLIO_VIEW flag to determine token list polling ([#28579](https://github.com/MetaMask/metamask-extension/pull/28579)) +- ci: limit playwright install to chromium browser only ([#28580](https://github.com/MetaMask/metamask-extension/pull/28580)) +- fix: use PORTFOLIO_VIEW flag to determine chain polling ([#28504](https://github.com/MetaMask/metamask-extension/pull/28504)) +- fix(sentry sampling): divide by 2 our sentry trace sample rate to avoid exceeding our quota ([#28573](https://github.com/MetaMask/metamask-extension/pull/28573)) +- fix: contact names should not allow duplication ([#28249](https://github.com/MetaMask/metamask-extension/pull/28249)) +- feat: account syncing various updates ([#28541](https://github.com/MetaMask/metamask-extension/pull/28541)) +- chore: Bump Snaps packages ([#28557](https://github.com/MetaMask/metamask-extension/pull/28557)) +- feat: cross chain aggregated balance ([#28456](https://github.com/MetaMask/metamask-extension/pull/28456)) +- fix: Address design review for NFT token send ([#28433](https://github.com/MetaMask/metamask-extension/pull/28433)) +- test: add token price privacy spec ([#28556](https://github.com/MetaMask/metamask-extension/pull/28556)) +- feat: display ERC20 and ERC721 token details returns by decoding api ([#28366](https://github.com/MetaMask/metamask-extension/pull/28366)) +- chore: Reduce E2E test jobs run on PRs ([#28525](https://github.com/MetaMask/metamask-extension/pull/28525)) +- fix: account tracker controller with useMultiPolling ([#28277](https://github.com/MetaMask/metamask-extension/pull/28277)) +- perf: optimize fonts by using woff2 instead of ttf ([#26554](https://github.com/MetaMask/metamask-extension/pull/26554)) +- test: [POM] Migrate onboarding metrics e2e tests to TS and Page Object Model to reduce flakiness ([#28424](https://github.com/MetaMask/metamask-extension/pull/28424)) +- test: improve logs for e2e errors ([#28479](https://github.com/MetaMask/metamask-extension/pull/28479)) +- fix: PortfolioView: Selector to determine networks to poll ([#28502](https://github.com/MetaMask/metamask-extension/pull/28502)) +- chore: Update `cross-spawn` ([#28522](https://github.com/MetaMask/metamask-extension/pull/28522)) +- feat: upgrade assets controllers to version 44 ([#28472](https://github.com/MetaMask/metamask-extension/pull/28472)) +- chore: Master sync ([#28459](https://github.com/MetaMask/metamask-extension/pull/28459)) +- Merge origin/develop into master-sync +- feat: Upgrade assets controllers to 43 with multichain polling for token lists + detection ([#28447](https://github.com/MetaMask/metamask-extension/pull/28447)) +- fix: display btc account creation while in settings ([#28379](https://github.com/MetaMask/metamask-extension/pull/28379)) +- chore: fix test path on CI ([#28482](https://github.com/MetaMask/metamask-extension/pull/28482)) +- chore: Fix flaky ERC20 transfer blockaid e2e ([#28453](https://github.com/MetaMask/metamask-extension/pull/28453)) +- test: [POM] Migrate vault decryption e2e tests to TS and Page Object Model ([#28419](https://github.com/MetaMask/metamask-extension/pull/28419)) +- feat: UI changes to show decoding data for permits ([#28342](https://github.com/MetaMask/metamask-extension/pull/28342)) +- fix: dont poll token prices during onboarding or when locked ([#28465](https://github.com/MetaMask/metamask-extension/pull/28465)) +- fix: Allow outerclick to close import modal ([#28448](https://github.com/MetaMask/metamask-extension/pull/28448)) +- feat: add simulation metrics when simulation UI is not visible ([#28427](https://github.com/MetaMask/metamask-extension/pull/28427)) +- fix: Fix attribution generation ([#28415](https://github.com/MetaMask/metamask-extension/pull/28415)) +- test: Improve test for signatures ([#27532](https://github.com/MetaMask/metamask-extension/pull/27532)) +- fix: ui customizations for redesigned transactions ([#28443](https://github.com/MetaMask/metamask-extension/pull/28443)) +- fix: Remove multiple overlapping spinners ([#28301](https://github.com/MetaMask/metamask-extension/pull/28301)) +- fix: Hide "interacting with" when simulated balance changes are shown ([#28409](https://github.com/MetaMask/metamask-extension/pull/28409)) +- chore: Begin introducing patterns for Multichain AssetList ([#28429](https://github.com/MetaMask/metamask-extension/pull/28429)) +- feat: update signature controller and integrate decoding api ([#28397](https://github.com/MetaMask/metamask-extension/pull/28397)) +- fix: Update PortfolioView flag ([#28446](https://github.com/MetaMask/metamask-extension/pull/28446)) +- perf: Create custom spans for account overview tabs ([#28086](https://github.com/MetaMask/metamask-extension/pull/28086)) +- fix: Default to dApp suggested fees only when user selects the option ([#28403](https://github.com/MetaMask/metamask-extension/pull/28403)) +- feat: btc send flow e2e ([#28340](https://github.com/MetaMask/metamask-extension/pull/28340)) +- test: fix state fixtures race condition ([#28421](https://github.com/MetaMask/metamask-extension/pull/28421)) +- test: [POM] Migrate autodetect and import nft e2e tests to use Page Object Model ([#28383](https://github.com/MetaMask/metamask-extension/pull/28383)) +- chore(deps): bump `@metamask/eth-ledger-bridge-keyring` to `^5.0.1` ([#27688](https://github.com/MetaMask/metamask-extension/pull/27688)) +- chore: limit bridge quote request frequency and cancel requests ([#27237](https://github.com/MetaMask/metamask-extension/pull/27237)) +- test: Reintegrate refactored Swap e2e tests to the pipeline ([#26493](https://github.com/MetaMask/metamask-extension/pull/26493)) +- fix: fix network client ID used on the useGasFeeInputs hook ([#28391](https://github.com/MetaMask/metamask-extension/pull/28391)) +- ci: Fix `attributions:check` silent failure ([#28413](https://github.com/MetaMask/metamask-extension/pull/28413)) +- fix: `Test Snap Cronjob can trigger a cronjob to open a di...` flaky tests ([#28363](https://github.com/MetaMask/metamask-extension/pull/28363)) +- feat: add `account_type`/`snap_id` for buy/send metrics ([#28011](https://github.com/MetaMask/metamask-extension/pull/28011)) +- fix: get `supportedChains` to avoid blocking the confirmation process ([#28313](https://github.com/MetaMask/metamask-extension/pull/28313)) +- test: [POM] Migrate reveal account srp e2e tests to use Page Object Model ([#28354](https://github.com/MetaMask/metamask-extension/pull/28354)) +- fix: Add metric trait for privacy mode ([#28335](https://github.com/MetaMask/metamask-extension/pull/28335)) +- fix: Properly ellipsize long token names ([#28392](https://github.com/MetaMask/metamask-extension/pull/28392)) +- chore: Bump snaps-utils ([#28399](https://github.com/MetaMask/metamask-extension/pull/28399)) +- feat: migrate MetaMetricsController to BaseControllerV2 ([#28113](https://github.com/MetaMask/metamask-extension/pull/28113)) +- feat: change expand icon per new design ([#28267](https://github.com/MetaMask/metamask-extension/pull/28267)) +- chore: add unit test for `useMultiPolling` ([#28387](https://github.com/MetaMask/metamask-extension/pull/28387)) +- feat(Solana): add "Add a new Solana account" link to the account creation dialog ([#28270](https://github.com/MetaMask/metamask-extension/pull/28270)) +- fix: Return to send page with different asset types ([#28382](https://github.com/MetaMask/metamask-extension/pull/28382)) +- test: [POM] Refactor import account e2e tests to use Page Object Model ([#28325](https://github.com/MetaMask/metamask-extension/pull/28325)) +- feat(1852): Implement sentry user report on error screen ([#27857](https://github.com/MetaMask/metamask-extension/pull/27857)) +- fix: disable buy for btc testnet accounts ([#28341](https://github.com/MetaMask/metamask-extension/pull/28341)) +- fix: Address design review for ERC20 token send ([#28212](https://github.com/MetaMask/metamask-extension/pull/28212)) +- refactor: remove global network usage from transaction confirmations ([#28236](https://github.com/MetaMask/metamask-extension/pull/28236)) +- build: update yarn to v4.5.1 ([#28365](https://github.com/MetaMask/metamask-extension/pull/28365)) +- fix: Bug 28347 - Privacy mode tweaks ([#28367](https://github.com/MetaMask/metamask-extension/pull/28367)) +- fix: mv2 firefox csp header ([#27770](https://github.com/MetaMask/metamask-extension/pull/27770)) +- perf: ensure `setupLocale` doesn't fetch `_locales/en/messages.json` twice ([#26553](https://github.com/MetaMask/metamask-extension/pull/26553)) +- fix: bump `@metamask/queued-request-controller` with patch fix ([#28355](https://github.com/MetaMask/metamask-extension/pull/28355)) +- fix: Revert "fix: Negate privacy mode in Send screen" ([#28360](https://github.com/MetaMask/metamask-extension/pull/28360)) +- fix: disable account syncing ([#28359](https://github.com/MetaMask/metamask-extension/pull/28359)) +- feat: Convert mmi controller to a non-controller ([#27983](https://github.com/MetaMask/metamask-extension/pull/27983)) +- fix: Updates to the simulations component ([#28107](https://github.com/MetaMask/metamask-extension/pull/28107)) +- refactor: rename SECURITY_PROVIDER_SUPPORTED_CHAIN_IDS_FALLBACK_LIST ([#28337](https://github.com/MetaMask/metamask-extension/pull/28337)) +- chore: adds Solana snap to preinstall list ([#28141](https://github.com/MetaMask/metamask-extension/pull/28141)) +- feat: Show network badge in detected tokens modal ([#28231](https://github.com/MetaMask/metamask-extension/pull/28231)) +- fix: Negate privacy mode in Send screen ([#28248](https://github.com/MetaMask/metamask-extension/pull/28248)) +- feat: adds solana feature, code fence ([#28320](https://github.com/MetaMask/metamask-extension/pull/28320)) +- build(webpack): fix cache issues in webpack build by updating `html-bundler-webpack-plugin` to v4.4.1 ([#28225](https://github.com/MetaMask/metamask-extension/pull/28225)) +- feat: team-label-token ([#28134](https://github.com/MetaMask/metamask-extension/pull/28134)) +- chore: add Solana shared utilities and constants ([#28269](https://github.com/MetaMask/metamask-extension/pull/28269)) +- chore: Remove STX opt in modal ([#28291](https://github.com/MetaMask/metamask-extension/pull/28291)) +- chore: revert commit `3da34f4` (feat: btc e2e tests (#27986)) ([#27986](https://github.com/MetaMask/metamask-extension/pull/27986)) +- chore: e2e quality gate enhancement ([#28206](https://github.com/MetaMask/metamask-extension/pull/28206)) +- chore: adding e2e tests for NFT permit ([#28004](https://github.com/MetaMask/metamask-extension/pull/28004)) +- feat: Enable simulation metrics for redesign transactions ([#28280](https://github.com/MetaMask/metamask-extension/pull/28280)) +- fix: GasDetailItem invalid paddingStart prop ([#28281](https://github.com/MetaMask/metamask-extension/pull/28281)) +- fix: use transaction address to get lock for custom nonce ([#28272](https://github.com/MetaMask/metamask-extension/pull/28272)) +- fix: flaky test `Phishing Detection Via Iframe should redirect users to the the MetaMask Phishing Detection page when an iframe domain is on the phishing blocklist` ([#28293](https://github.com/MetaMask/metamask-extension/pull/28293)) +- chore: add the gas_included prop into Quotes Requested event ([#28295](https://github.com/MetaMask/metamask-extension/pull/28295)) +- test: [POM] Refactor e2e tests to use onboarding flows defined in Page Object Models ([#28202](https://github.com/MetaMask/metamask-extension/pull/28202)) +- feat: btc e2e tests ([#27986](https://github.com/MetaMask/metamask-extension/pull/27986)) +- fix: remove scroll-to-bottom requirement in redesigned transaction confirmations ([#27910](https://github.com/MetaMask/metamask-extension/pull/27910)) +- chore: Add gravity logo and image mappings ([#28306](https://github.com/MetaMask/metamask-extension/pull/28306)) +- chore: Bump Snaps packages ([#28215](https://github.com/MetaMask/metamask-extension/pull/28215)) +- feat: Add simulation metrics to "Transaction Submitted" and "Transaction Finalized" events ([#28240](https://github.com/MetaMask/metamask-extension/pull/28240)) +- fix: smart transactions in redesigned confirmations ([#28273](https://github.com/MetaMask/metamask-extension/pull/28273)) +- fix: unit flaky test `AddContact component › should disable submit button when input is not a valid address` ([#27941](https://github.com/MetaMask/metamask-extension/pull/27941)) +- fix: Hide fiat values on test networks ([#28219](https://github.com/MetaMask/metamask-extension/pull/28219)) +- chore: display bridge quotes ([#28031](https://github.com/MetaMask/metamask-extension/pull/28031)) +- fix: Permit message, dataTree value incorrectly using default ERC20 decimals for non-ERC20 token values ([#28142](https://github.com/MetaMask/metamask-extension/pull/28142)) +- fix: ignore error when getTokenStandardAndDetails fails ([#28030](https://github.com/MetaMask/metamask-extension/pull/28030)) +- fix: notification settings type ([#28271](https://github.com/MetaMask/metamask-extension/pull/28271)) +- chore: use accounts api for token detection ([#28254](https://github.com/MetaMask/metamask-extension/pull/28254)) +- fix: Fix alignment of long RPC labels in Networks menu ([#28244](https://github.com/MetaMask/metamask-extension/pull/28244)) +- feat: adds the experimental toggle for Solana ([#28190](https://github.com/MetaMask/metamask-extension/pull/28190)) +- feat: multi chain polling for token prices ([#28158](https://github.com/MetaMask/metamask-extension/pull/28158)) +- refactor: move `getInternalAccounts` from `selectors.js` to `accounts.ts` ([#27645](https://github.com/MetaMask/metamask-extension/pull/27645)) +- fix: Add different copy for tooltip when a snap is requesting a signature ([#27492](https://github.com/MetaMask/metamask-extension/pull/27492)) +- fix: Prevent coercing symbols to zero in the edit spending cap modal ([#28192](https://github.com/MetaMask/metamask-extension/pull/28192)) +- test: [POM] Migrate edit network rpc e2e tests and create related page class functions ([#28161](https://github.com/MetaMask/metamask-extension/pull/28161)) +- refactor: remove global network usage from signatures ([#28167](https://github.com/MetaMask/metamask-extension/pull/28167)) +- fix: margin on asset chart min/max indicators ([#27916](https://github.com/MetaMask/metamask-extension/pull/27916)) +- feat: add token verification source count and link to block explorer ([#27759](https://github.com/MetaMask/metamask-extension/pull/27759)) +- chore: Remove obsolete preview build support ([#27968](https://github.com/MetaMask/metamask-extension/pull/27968)) +- fix: Removing `warning` prop from settings ([#27990](https://github.com/MetaMask/metamask-extension/pull/27990)) +- chore: Adding installType to Sentry Tags for easy filtering ([#28084](https://github.com/MetaMask/metamask-extension/pull/28084)) +- chore: remove broken link in docs ([#28232](https://github.com/MetaMask/metamask-extension/pull/28232)) +- fix: Error handling for the state log download failure ([#26999](https://github.com/MetaMask/metamask-extension/pull/26999)) +- feat: Upgrade alert controller to base controller v2 ([#28054](https://github.com/MetaMask/metamask-extension/pull/28054)) +- chore: improve token lookup performance in `useAccountTotalFiatBalance` ([#28233](https://github.com/MetaMask/metamask-extension/pull/28233)) + ## [12.7.2] ### Fixed - Fix message signatures for Gridplus lattice hardware wallets ([#28694](https://github.com/MetaMask/metamask-extension/pull/28694)) @@ -5356,7 +5595,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.7.2...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.9.0...HEAD +[12.9.0]: https://github.com/MetaMask/metamask-extension/compare/v12.7.2...v12.9.0 [12.7.2]: https://github.com/MetaMask/metamask-extension/compare/v12.7.1...v12.7.2 [12.7.1]: https://github.com/MetaMask/metamask-extension/compare/v12.7.0...v12.7.1 [12.7.0]: https://github.com/MetaMask/metamask-extension/compare/v12.6.2...v12.7.0 diff --git a/package.json b/package.json index e7827cb6d92b..c86ea2d35cb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "12.7.2", + "version": "12.9.0", "private": true, "repository": { "type": "git", From 8c7e8b154d91bd721499ba46b2aea93dcf492430 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:00:14 +0100 Subject: [PATCH 133/148] chore: Fix changelog title v12.9.0 (#28807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updating the title in the changelog for v12.9.0 entries to help pass the CI [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28807?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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 926adfb6e190..7cb76fdef740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [12.9.0] -### Uncategorized +### Fixed - fix: SIWE e2e test timing out and breaking ci ([#28801](https://github.com/MetaMask/metamask-extension/pull/28801)) - fix: has_marketing_consent flag on metametrics ([#28795](https://github.com/MetaMask/metamask-extension/pull/28795)) - chore: Bump `@metamask/eth-token-tracker` from v8 to v9 ([#28754](https://github.com/MetaMask/metamask-extension/pull/28754)) From 717745208357ea656d65d2ae25b351236d1e5892 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 29 Nov 2024 14:57:39 +0100 Subject: [PATCH 134/148] [cherry-pick] fix: Correct preferences controller usage for `isOnPhishingList` hook (#28806) ## **Description** This is a cherry-pick to the RC for the following commit: https://github.com/MetaMask/metamask-extension/commit/db4386f6e25c8a96bd2510588c9ba462a51d9ca1 In [this commit](https://github.com/MetaMask/metamask-extension/commit/cedabc62e45601c77871689425320c54d717275e) the preferences controller was converted to `BaseControllerV2`, however the `isOnPhishingList` hook was not corrected to reference the state properly. The hook will currently always throw which means that link validation fails for Snaps notifications, making them unable to display. This PR corrects that mistake. Note: This is an edge-case of the Snaps API that doesn't have good E2E coverage yet. We should prioritize that. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28803?quickstart=1) ## **Manual testing steps** The following Snap should work correctly and display a notification: ``` export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request, }) => { switch (request.method) { case 'hello': return snap.request({ method: 'snap_notify', params: { type: 'inApp', message: 'Hello! [metamask.io](https://metamask.io)', }, }); default: throw new Error('Method not found.'); } }; ``` --- app/scripts/metamask-controller.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 992302983baf..d60d937e1c3c 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2897,8 +2897,7 @@ export default class MetamaskController extends EventEmitter { ); }, isOnPhishingList: (url) => { - const { usePhishDetect } = - this.preferencesController.store.getState(); + const { usePhishDetect } = this.preferencesController.state; if (!usePhishDetect) { return false; From b744b09a2c0164b8f446c6fa7b2ee8b21601ba16 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Mon, 2 Dec 2024 15:22:16 +0100 Subject: [PATCH 135/148] fix: fix asset-list e2e test (#28822) (#28841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** cherry pick #28822 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28841?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. --- test/e2e/fixture-builder.js | 16 ++++++++++++ test/e2e/tests/multichain/asset-list.spec.ts | 27 ++++++++++---------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 844c4766db3e..0a397acb6c97 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -305,6 +305,22 @@ class FixtureBuilder { }); } + withNetworkControllerOnPolygon() { + return this.withNetworkController({ + networkConfigurations: { + networkConfigurationId: { + chainId: CHAIN_IDS.POLYGON, + nickname: 'Polygon Mainnet', + rpcPrefs: {}, + rpcUrl: 'https://mainnet.infura.io', + ticker: 'ETH', + networkConfigurationId: 'networkConfigurationId', + id: 'networkConfigurationId', + }, + }, + }); + } + withNetworkControllerDoubleGanache() { const ganacheNetworks = mockNetworkStateOld({ id: '76e9cd59-d8e2-47e7-b369-9c205ccb602c', diff --git a/test/e2e/tests/multichain/asset-list.spec.ts b/test/e2e/tests/multichain/asset-list.spec.ts index 5b210730ef36..d7120a1fcace 100644 --- a/test/e2e/tests/multichain/asset-list.spec.ts +++ b/test/e2e/tests/multichain/asset-list.spec.ts @@ -13,14 +13,15 @@ import AssetListPage from '../../page-objects/pages/asset-list'; const NETWORK_NAME_MAINNET = 'Ethereum Mainnet'; const LINEA_NAME_MAINNET = 'Linea Mainnet'; -const LOCALHOST = 'Localhost 8545'; +const POLYGON_NAME_MAINNET = 'Polygon'; const BALANCE_AMOUNT = '24.9956'; -function buildFixtures(title: string) { +function buildFixtures(title: string, chainId: number = 1337) { return { fixtures: new FixtureBuilder() .withPermissionControllerConnectedToTestDapp() - .withTokensControllerERC20() + .withNetworkControllerOnPolygon() + .withTokensControllerERC20({ chainId }) .build(), ganacheOptions: defaultGanacheOptions, smartContract: SMART_CONTRACTS.HST, @@ -49,7 +50,7 @@ describe('Multichain Asset List', function (this: Suite) { const assetListPage = new AssetListPage(driver); await headerNavbar.clickSwitchNetworkDropDown(); await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); - await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.waitUntilAssetListHasItems(3); await assetListPage.openNetworksFilter(); await assetListPage.clickCurrentNetworkOption(); await headerNavbar.clickSwitchNetworkDropDown(); @@ -79,7 +80,7 @@ describe('Multichain Asset List', function (this: Suite) { const assetListPage = new AssetListPage(driver); await headerNavbar.clickSwitchNetworkDropDown(); await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); - await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.waitUntilAssetListHasItems(3); await driver.clickElement('.multichain-token-list-item'); const coinOverviewElement = await driver.findElement( '[data-testid="coin-overview-buy"]', @@ -97,7 +98,7 @@ describe('Multichain Asset List', function (this: Suite) { }); it('switches networks when clicking on send for a token on another network', async function () { await withFixtures( - buildFixtures(this.test?.fullTitle() as string), + buildFixtures(this.test?.fullTitle() as string, 137), async ({ driver, ganacheServer, @@ -112,10 +113,10 @@ describe('Multichain Asset List', function (this: Suite) { await headerNavbar.clickSwitchNetworkDropDown(); await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); const sendPage = new SendTokenPage(driver); - await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.waitUntilAssetListHasItems(4); await assetListPage.clickOnAsset('TST'); await driver.clickElement('[data-testid="eth-overview-send"]'); - await sendPage.check_networkChange(LOCALHOST); + await sendPage.check_networkChange(POLYGON_NAME_MAINNET); await sendPage.check_pageIsLoaded(); await sendPage.fillRecipient( '0x2f318C334780961FB129D2a6c30D0763d9a5C970', @@ -132,7 +133,7 @@ describe('Multichain Asset List', function (this: Suite) { }); it('switches networks when clicking on swap for a token on another network', async function () { await withFixtures( - buildFixtures(this.test?.fullTitle() as string), + buildFixtures(this.test?.fullTitle() as string, 137), async ({ driver, ganacheServer, @@ -146,14 +147,14 @@ describe('Multichain Asset List', function (this: Suite) { const assetListPage = new AssetListPage(driver); await headerNavbar.clickSwitchNetworkDropDown(); await selectNetworkDialog.selectNetworkName(NETWORK_NAME_MAINNET); - await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.waitUntilAssetListHasItems(4); await assetListPage.clickOnAsset('TST'); await driver.clickElement('.mm-box > button:nth-of-type(3)'); const toastTextElement = await driver.findElement('.toast-text'); const toastText = await toastTextElement.getText(); assert.equal( toastText, - `You're now using ${LOCALHOST}`, + `You're now using ${POLYGON_NAME_MAINNET}`, 'Toast text is correct', ); }, @@ -175,7 +176,7 @@ describe('Multichain Asset List', function (this: Suite) { const selectNetworkDialog = new SelectNetwork(driver); await headerNavbar.clickSwitchNetworkDropDown(); await selectNetworkDialog.selectNetworkName(LINEA_NAME_MAINNET); - await assetListPage.waitUntilAssetListHasItems(2); + await assetListPage.waitUntilAssetListHasItems(3); await assetListPage.clickOnAsset('Ethereum'); @@ -187,7 +188,7 @@ describe('Multichain Asset List', function (this: Suite) { const toastText = await toastTextElement.getText(); assert.equal( toastText, - `You're now using ${LOCALHOST}`, + `You're now using Ethereum Mainnet`, 'Toast text is correct', ); const balanceMessageElement = await driver.findElement( From 95a62e834402618f3cc8d68dca97d6c94c3bbc16 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Dec 2024 20:01:28 +0530 Subject: [PATCH 136/148] cherry-pick: Fix decoding data display for ERC-1155 tokens (#28924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Signature decoding data was not being displayed for ERC-1155 tokens, this PR fixes it. Original PR: https://github.com/MetaMask/metamask-extension/pull/28921 Cherry-picked link: https://github.com/MetaMask/metamask-extension/commit/fc8e51e9479890a0c25caca246ce9f2515d51a42 ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28903 ## **Manual testing steps** Detailed [here](https://www.notion.so/metamask-consensys/Signature-Decoding-v12-9-QA-151f86d67d68802baddfebf3e44aea5e?pvs=4#151f86d67d6880f5a69aff17d227329d) ## **Screenshots/Recordings** Screenshot 2024-12-04 at 4 01 53 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/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../decoded-simulation.test.tsx | 55 +++++++++++++++++++ .../decoded-simulation/decoded-simulation.tsx | 3 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx index 93cc6b9e4474..26b4c46f26cd 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -41,6 +41,23 @@ const decodingDataListing: DecodingDataStateChanges = [ }, ]; +const decodingDataListingERC1155: DecodingDataStateChanges = [ + { + assetType: 'NATIVE', + changeType: DecodingDataChangeType.Receive, + address: '', + amount: '900000000000000000', + contractAddress: '', + }, + { + assetType: 'ERC1155', + changeType: DecodingDataChangeType.Listing, + address: '', + amount: '', + contractAddress: '0xafd4896984CA60d2feF66136e57f958dCe9482d5', + tokenID: '2233', + }, +]; const decodingDataBidding: DecodingDataStateChanges = [ { assetType: 'ERC721', @@ -78,6 +95,44 @@ describe('DecodedSimulation', () => { expect(await findByText('1,461,501,637,3...')).toBeInTheDocument(); }); + it('render correctly for ERC712 token', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { stateChanges: decodingDataListing }, + }); + const mockStore = configureMockStore([])(state); + + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect(await findByText('You receive')).toBeInTheDocument(); + expect(await findByText('You list')).toBeInTheDocument(); + expect(await findByText('#2101')).toBeInTheDocument(); + }); + + it('render correctly for ERC1155 token', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { stateChanges: decodingDataListingERC1155 }, + }); + const mockStore = configureMockStore([])(state); + + const { findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('Estimated changes')).toBeInTheDocument(); + expect(await findByText('You receive')).toBeInTheDocument(); + expect(await findByText('You list')).toBeInTheDocument(); + expect(await findByText('#2233')).toBeInTheDocument(); + }); + it('renders unavailable message if no state change is returned', async () => { const state = getMockTypedSignConfirmStateForRequest(permitSignatureMsg); const mockStore = configureMockStore([])(state); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx index 55197e689600..ec07ae253405 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign-v4-simulation/decoded-simulation/decoded-simulation.tsx @@ -75,7 +75,8 @@ const StateChangeRow = ({ tooltip={tooltip} > {(assetType === TokenStandard.ERC20 || - assetType === TokenStandard.ERC721) && ( + assetType === TokenStandard.ERC721 || + assetType === TokenStandard.ERC1155) && ( Date: Thu, 5 Dec 2024 13:34:38 +0000 Subject: [PATCH 137/148] fix: updated analytics preferences to be logged during onboarding (cherrypick-28897) (#28930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During onboarding while clicking on I agree button, the analytics preferences event was not being triggered. This PR is to ensure analytics preferences are logged when user click on `I agree` button in Onboarding Page ## **Related issues** Fixes: [https://github.com/MetaMask/MetaMask-planning/issues/3723](https://github.com/MetaMask/MetaMask-planning/issues/3723) ## **Manual testing steps** 1. Run extension with yarn start 2. Add console.log in trackEvent in ui/contexts/metametrics.js to see the payload 3. Do a fresh install and go on onboading flow 4. Click on I agree button on onboarding screen, check analytics preferences selected are being logged in console ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/35e156a1-ea81-4dd6-a037-87cfcd581773 ## **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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28930?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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../metametrics/metametrics.js | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/ui/pages/onboarding-flow/metametrics/metametrics.js b/ui/pages/onboarding-flow/metametrics/metametrics.js index 242638b08c5e..3a36ea90bb14 100644 --- a/ui/pages/onboarding-flow/metametrics/metametrics.js +++ b/ui/pages/onboarding-flow/metametrics/metametrics.js @@ -17,7 +17,6 @@ import { setDataCollectionForMarketing, } from '../../../store/actions'; import { - getParticipateInMetaMetrics, getDataCollectionForMarketing, getFirstTimeFlowType, getFirstTimeFlowTypeRouteAfterMetaMetricsOptIn, @@ -53,7 +52,6 @@ export default function OnboardingMetametrics() { const firstTimeFlowType = useSelector(getFirstTimeFlowType); const dataCollectionForMarketing = useSelector(getDataCollectionForMarketing); - const participateInMetaMetrics = useSelector(getParticipateInMetaMetrics); const trackEvent = useContext(MetaMetricsContext); @@ -82,22 +80,20 @@ export default function OnboardingMetametrics() { }, ); - if (participateInMetaMetrics) { - trackEvent({ - category: MetaMetricsEventCategory.Onboarding, - event: MetaMetricsEventName.AppInstalled, - }); + trackEvent({ + category: MetaMetricsEventCategory.Onboarding, + event: MetaMetricsEventName.AppInstalled, + }); - trackEvent({ - category: MetaMetricsEventCategory.Onboarding, - event: MetaMetricsEventName.AnalyticsPreferenceSelected, - properties: { - is_metrics_opted_in: true, - has_marketing_consent: Boolean(dataCollectionForMarketing), - location: 'onboarding_metametrics', - }, - }); - } + trackEvent({ + category: MetaMetricsEventCategory.Onboarding, + event: MetaMetricsEventName.AnalyticsPreferenceSelected, + properties: { + is_metrics_opted_in: true, + has_marketing_consent: Boolean(dataCollectionForMarketing), + location: 'onboarding_metametrics', + }, + }); } finally { history.push(nextRoute); } From 8cd3ced78943c6cae0bbd55eb5973c739cb2d215 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 5 Dec 2024 21:37:46 +0530 Subject: [PATCH 138/148] cherry-pick: Adding production URL for signature decoding (#28951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Production URL for signature decoding. Original PR: https://github.com/MetaMask/metamask-extension/pull/28918 Cherry-picked commit: https://github.com/MetaMask/metamask-extension/commit/bf946bc494c71bfaaef89ae3b0c43f8e25490613 ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28892 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- builds.yml | 2 +- privacy-snapshot.json | 2 +- test/e2e/tests/confirmations/helpers.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/builds.yml b/builds.yml index 3963aeda93e5..5bbe17480496 100644 --- a/builds.yml +++ b/builds.yml @@ -236,7 +236,7 @@ env: # Used to enable confirmation redesigned pages - ENABLE_CONFIRMATION_REDESIGN: '' # URL of the decoding API used to provide additional data from signature requests - - DECODING_API_URL: 'https://qtgdj2huxh.execute-api.us-east-2.amazonaws.com/uat/v1' + - DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1' # Determines if feature flagged Settings Page - Developer Options should be used - ENABLE_SETTINGS_PAGE_DEV_OPTIONS: false # Used for debugging changes to the phishing warning page. diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 5620903a5c73..6ee430ca943c 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -48,7 +48,7 @@ "price.api.cx.metamask.io", "proxy.api.cx.metamask.io", "proxy.dev-api.cx.metamask.io", - "qtgdj2huxh.execute-api.us-east-2.amazonaws.com", + "signature-insights.api.cx.metamask.io", "raw.githubusercontent.com", "registry.npmjs.org", "responsive-rpc.test", diff --git a/test/e2e/tests/confirmations/helpers.ts b/test/e2e/tests/confirmations/helpers.ts index 3ea2a2927729..c9001d3e0914 100644 --- a/test/e2e/tests/confirmations/helpers.ts +++ b/test/e2e/tests/confirmations/helpers.ts @@ -11,7 +11,7 @@ import { Driver } from '../../webdriver/driver'; import Confirmation from '../../page-objects/pages/confirmations/redesign/confirmation'; export const DECODING_E2E_API_URL = - 'https://qtgdj2huxh.execute-api.us-east-2.amazonaws.com/uat/v1'; + 'https://signature-insights.api.cx.metamask.io/v1'; export async function scrollAndConfirmAndAssertConfirm(driver: Driver) { const confirmation = new Confirmation(driver); From e8a5d50076d6ecd74eb2b0f2603553da22a4794a Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Thu, 5 Dec 2024 08:08:33 -0800 Subject: [PATCH 139/148] fix: cherry-pick fix check for undefined marketData (#28870) (#28950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry pick to 12.9 RC of: https://github.com/MetaMask/metamask-extension/pull/28870 This PR fixes app crash after user removes a network then adds it back and clicks on import token banner [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28870?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28019#issuecomment-2513839152 Fixes: https://github.com/MetaMask/metamask-extension/issues/28882 Fixes: https://github.com/MetaMask/metamask-extension/issues/28864 ## **Manual testing steps** Manual steps are also described in the github [issue](https://github.com/MetaMask/metamask-extension/issues/28019#issuecomment-2513839152). However; I do not think that the Show native token as main balance needs to be ONt o repro the initial issue. Also no need to add new RPC from chainList; Settings: 1. Show balance and token price OFF 2. Token autodetect ON On main view 1. Select an account which has some ERC20 tokens in Polygon 2. Add Polygon default network 3. See tokens are autodetected and you can open the modal -> but don't import the tokens! 4. Switch to another network 5. Delete Polygon network 6. Re-add Polygon default network 7. Click Import tokens --> Wallet should not crash 12. ## **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/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28950?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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Co-authored-by: sahar-fehri --- .../asset-list-control-bar.tsx | 2 +- .../app/assets/asset-list/asset-list.tsx | 5 +++-- .../app/assets/token-list/token-list.tsx | 4 +--- ui/hooks/useTokenFiatAmount.js | 19 ++++++++++--------- 4 files changed, 15 insertions(+), 15 deletions(-) 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 8e6abb940d1c..00e0692cfc73 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 @@ -99,7 +99,7 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { // When a network gets added/removed we want to make sure that we switch to the filtered list of the current network // We only want to do this if the "Current Network" filter is selected useEffect(() => { - if (Object.keys(tokenNetworkFilter).length === 1) { + if (Object.keys(tokenNetworkFilter || {}).length === 1) { dispatch(setTokenNetworkFilter({ [currentNetwork.chainId]: true })); } }, [Object.keys(allNetworks).length]); diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 6a3036f88764..ab2adb308bf4 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -117,14 +117,15 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ); const totalTokens = - process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + process.env.PORTFOLIO_VIEW && + !allNetworksFilterShown && + detectedTokensMultichain ? (Object.values(detectedTokensMultichain).reduce( // @ts-expect-error TS18046: 'tokenArray' is of type 'unknown' (count, tokenArray) => count + tokenArray.length, 0, ) as number) : detectedTokens.length; - return ( <> {totalTokens && diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 5e25c7ff5f41..d3e67f6204cc 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -116,8 +116,7 @@ export default function TokenList({ const allNetworkFilters = Object.fromEntries( Object.keys(allNetworks).map((chainId) => [chainId, true]), ); - - if (Object.keys(tokenNetworkFilter).length > 1) { + if (Object.keys(tokenNetworkFilter || {}).length > 1) { dispatch(setTokenNetworkFilter(allNetworkFilters)); } } @@ -125,7 +124,6 @@ export default function TokenList({ const consolidatedBalances = () => { const tokensWithBalance: TokenWithFiatAmount[] = []; - Object.entries(selectedAccountTokensChains).forEach( ([stringChainKey, tokens]) => { const chainId = stringChainKey as Hex; diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index 486b8ba14fd6..325dbf5eea22 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -42,15 +42,16 @@ export function useTokenFiatAmount( shallowEqual, ); - const contractMarketData = chainId - ? Object.entries(allMarketData[chainId]).reduce( - (acc, [address, marketData]) => { - acc[address] = marketData?.price ?? null; - return acc; - }, - {}, - ) - : null; + const contractMarketData = + chainId && allMarketData[chainId] + ? Object.entries(allMarketData[chainId]).reduce( + (acc, [address, marketData]) => { + acc[address] = marketData?.price ?? null; + return acc; + }, + {}, + ) + : null; const tokenMarketData = chainId ? contractMarketData : contractExchangeRates; From a2e99dc1ddf2cfece9e7e01ca3cc14474ebbb57b Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Thu, 5 Dec 2024 13:10:55 -0300 Subject: [PATCH 140/148] chore: cherry pick merge of #28898 (#28965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** cherry pick https://github.com/MetaMask/metamask-extension/issues/28898 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28841?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. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28965?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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Co-authored-by: George Weiler Co-authored-by: Dan J Miller --- ui/hooks/ramps/useRamps/useRamps.test.tsx | 14 +++++++++++ ui/hooks/ramps/useRamps/useRamps.ts | 30 ++++++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/ui/hooks/ramps/useRamps/useRamps.test.tsx b/ui/hooks/ramps/useRamps/useRamps.test.tsx index 0b0b8124666b..a1c4101bcbcd 100644 --- a/ui/hooks/ramps/useRamps/useRamps.test.tsx +++ b/ui/hooks/ramps/useRamps/useRamps.test.tsx @@ -103,4 +103,18 @@ describe('useRamps', () => { }); }, ); + it('should return the default URL when an invalid URL is provided', () => { + jest.resetModules(); + + const originalPortfolioUrl = process.env.PORTFOLIO_URL; + process.env = { PORTFOLIO_URL: 'invalid-url' }; + + const { result } = renderHook(() => useRamps(), { wrapper }); + + const buyURI = result.current.getBuyURI('0x1'); + expect(buyURI).toBe('https://portfolio.metamask.io/buy'); + + process.env.PORTFOLIO_URL = originalPortfolioUrl; + jest.resetModules(); + }); }); diff --git a/ui/hooks/ramps/useRamps/useRamps.ts b/ui/hooks/ramps/useRamps/useRamps.ts index 3dd230eaf6ea..b4c457809be6 100644 --- a/ui/hooks/ramps/useRamps/useRamps.ts +++ b/ui/hooks/ramps/useRamps/useRamps.ts @@ -22,7 +22,6 @@ export enum RampsMetaMaskEntry { BtcBanner = 'ext_buy_banner_btc', } -const portfolioUrl = process.env.PORTFOLIO_URL; const useRamps = ( metamaskEntry: RampsMetaMaskEntry = RampsMetaMaskEntry.BuySellButton, ): IUseRamps => { @@ -33,18 +32,25 @@ const useRamps = ( const getBuyURI = useCallback( (_chainId: Hex | CaipChainId) => { - const params = new URLSearchParams(); - params.set('metamaskEntry', metamaskEntry); - params.set('chainId', _chainId); - if (metaMetricsId) { - params.set('metametricsId', metaMetricsId); - } - params.set('metricsEnabled', String(isMetaMetricsEnabled)); - if (isMarketingEnabled) { - params.set('marketingEnabled', String(isMarketingEnabled)); - } + try { + const params = new URLSearchParams(); + params.set('metamaskEntry', metamaskEntry); + params.set('chainId', _chainId); + if (metaMetricsId) { + params.set('metametricsId', metaMetricsId); + } + params.set('metricsEnabled', String(isMetaMetricsEnabled)); + if (isMarketingEnabled) { + params.set('marketingEnabled', String(isMarketingEnabled)); + } - return `${portfolioUrl}/buy?${params.toString()}`; + const url = new URL(process.env.PORTFOLIO_URL || ''); + url.pathname = 'buy'; + url.search = params.toString(); + return url.toString(); + } catch { + return 'https://portfolio.metamask.io/buy'; + } }, [metaMetricsId], ); From 01d276c2175c3f48cbf39cddc69258d60bebfeb7 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 6 Dec 2024 14:47:37 +0000 Subject: [PATCH 141/148] fix: cherry-pick: Add origin row to transfer confirmations (#28989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cherry-picks: https://github.com/MetaMask/metamask-extension/pull/28936 ## **Description** This PR adds an origin row as well as a content divider as per the latest designs (see screenshot below). Additionally, ConfirmInfoSection has been moved to inside the SimulationDetails component, to fix a visual bug that showed additional margin on the UI, even when the SimulationDetails component was not being rendered. Screenshot 2024-12-04 at 17 34 20 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28936?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28928 ## **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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28989?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/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../base-transaction-info.tsx | 14 ++-- .../native-transfer.test.tsx.snap | 53 +++++++++++- .../info/native-transfer/native-transfer.tsx | 17 ++-- .../nft-token-transfer.test.tsx.snap | 53 +++++++++++- .../nft-token-transfer/nft-token-transfer.tsx | 17 ++-- .../token-details-section.test.tsx.snap | 51 +++++++++++- .../token-transfer.test.tsx.snap | 53 +++++++++++- .../token-transfer/token-details-section.tsx | 38 +++++---- .../info/token-transfer/token-transfer.tsx | 17 ++-- .../simulation-details/simulation-details.tsx | 80 +++++++++++++------ 10 files changed, 300 insertions(+), 93 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/info/base-transaction-info/base-transaction-info.tsx b/ui/pages/confirmations/components/confirm/info/base-transaction-info/base-transaction-info.tsx index 23629ee5096c..2b986aa42f3f 100644 --- a/ui/pages/confirmations/components/confirm/info/base-transaction-info/base-transaction-info.tsx +++ b/ui/pages/confirmations/components/confirm/info/base-transaction-info/base-transaction-info.tsx @@ -1,7 +1,5 @@ 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'; @@ -18,13 +16,11 @@ const BaseTransactionInfo = () => { return ( <> - - - + 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 index dcfe4ff35624..d1cf681798e7 100644 --- 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 @@ -107,7 +107,7 @@ exports[`NativeTransferInfo renders correctly 1`] = `
+
+
+
+
+

+ Request from +

+
+
+ +
+
+
+
+
+

+ metamask.github.io +

+
+
{ <> - { - - - - } + diff --git a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap index e835130ff73c..bcf20679ccc6 100644 --- a/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/nft-token-transfer/__snapshots__/nft-token-transfer.test.tsx.snap @@ -135,7 +135,7 @@ exports[`NFTTokenTransferInfo renders correctly 1`] = `
+
+
+
+
+

+ Request from +

+
+
+ +
+
+
+
+
+

+ metamask.github.io +

+
+
{ <> - { - - - - } + diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap index 6ab7ebb270b7..ef5bff116f7d 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/__snapshots__/token-details-section.test.tsx.snap @@ -4,7 +4,7 @@ exports[`TokenDetailsSection renders correctly 1`] = `
+
+
+
+
+

+ Request from +

+
+
+ +
+
+
+
+
+

+ metamask.github.io +

+
+
+
+
+
+
+

+ Request from +

+
+
+ +
+
+
+
+
+

+ metamask.github.io +

+
+
{ const t = useI18nContext(); @@ -80,22 +83,29 @@ export const TokenDetailsSection = () => { ); - const tokenRow = transactionMeta.type !== TransactionType.simpleSend && - (showAdvancedDetails || isSimulationEmpty || isSimulationError) && ( - - - - ); + const shouldShowTokenRow = + transactionMeta.type !== TransactionType.simpleSend && + (showAdvancedDetails || isSimulationEmpty || isSimulationError); + + const tokenRow = shouldShowTokenRow && ( + + + + ); + + const shouldShowOriginRow = transactionMeta?.origin !== ORIGIN_METAMASK; return ( - + {networkRow} + {(shouldShowOriginRow || shouldShowTokenRow) && } + {shouldShowOriginRow && } {tokenRow} ); diff --git a/ui/pages/confirmations/components/confirm/info/token-transfer/token-transfer.tsx b/ui/pages/confirmations/components/confirm/info/token-transfer/token-transfer.tsx index dd95e841c2c0..6a51534705e5 100644 --- a/ui/pages/confirmations/components/confirm/info/token-transfer/token-transfer.tsx +++ b/ui/pages/confirmations/components/confirm/info/token-transfer/token-transfer.tsx @@ -1,6 +1,5 @@ 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'; @@ -19,16 +18,12 @@ const TokenTransferInfo = () => { <> - { - - - - } + diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx index 412467b4ff8a..9fbc8309251b 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx @@ -4,6 +4,9 @@ import { TransactionMeta, } from '@metamask/transaction-controller'; import React from 'react'; +import { ConfirmInfoAlertRow } from '../../../../components/app/confirm/info/row/alert-row/alert-row'; +import { RowAlertKey } from '../../../../components/app/confirm/info/row/constants'; +import { ConfirmInfoSection } from '../../../../components/app/confirm/info/row/section'; import { Box, Icon, @@ -25,8 +28,6 @@ import { TextVariant, } from '../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { ConfirmInfoAlertRow } from '../../../../components/app/confirm/info/row/alert-row/alert-row'; -import { RowAlertKey } from '../../../../components/app/confirm/info/row/constants'; import { BalanceChangeList } from './balance-change-list'; import { useBalanceChanges } from './useBalanceChanges'; import { useSimulationMetrics } from './useSimulationMetrics'; @@ -189,31 +190,58 @@ const SimulationDetailsLayout: React.FC<{ inHeader?: React.ReactNode; isTransactionsRedesign: boolean; transactionId: string; -}> = ({ inHeader, isTransactionsRedesign, transactionId, children }) => ( - - = ({ inHeader, isTransactionsRedesign, transactionId, children }) => + isTransactionsRedesign ? ( + + + + {inHeader} + + {children} + + + ) : ( + - {inHeader} - - {children} - -); + + {inHeader} + + {children} + + ); /** * Preview of a transaction's effects using simulation data. From baf387b41c422b2c879593cb42dd65f254966931 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 6 Dec 2024 19:03:46 +0000 Subject: [PATCH 142/148] fix (cherry-pick): hide first time interaction alert if internal account (#28990) (#28998) --- .../useFirstTimeInteractionAlert.test.ts | 84 ++++++++++++------- .../useFirstTimeInteractionAlert.ts | 16 +++- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts index 964b218e8501..a695abe29d8a 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.test.ts @@ -9,51 +9,52 @@ import { getMockConfirmState } from '../../../../../../test/data/confirmations/h import { renderHookWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; import { Severity } from '../../../../../helpers/constants/design-system'; import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; -import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction'; import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; const ACCOUNT_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; const TRANSACTION_ID_MOCK = '123-456'; -const CONFIRMATION_MOCK = genUnapprovedContractInteractionConfirmation({ - chainId: '0x5', -}) as TransactionMeta; - const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK, chainId: '0x5', - status: TransactionStatus.submitted, + status: TransactionStatus.unapproved, type: TransactionType.contractInteraction, txParams: { from: ACCOUNT_ADDRESS, }, time: new Date().getTime() - 10000, - firstTimeInteraction: true, } as TransactionMeta; function runHook({ currentConfirmation, - transactions = [], + internalAccountAddresses, }: { currentConfirmation?: TransactionMeta; - transactions?: TransactionMeta[]; + internalAccountAddresses?: string[]; } = {}) { - let pendingApprovals = {}; - if (currentConfirmation) { - pendingApprovals = { - [currentConfirmation.id as string]: { - id: currentConfirmation.id, - type: ApprovalType.Transaction, - }, - }; - transactions.push(currentConfirmation); - } + const pendingApprovals = currentConfirmation + ? { + [currentConfirmation.id as string]: { + id: currentConfirmation.id, + type: ApprovalType.Transaction, + }, + } + : {}; + + const transactions = currentConfirmation ? [currentConfirmation] : []; + + const internalAccounts = { + accounts: internalAccountAddresses?.map((address) => ({ address })) ?? [], + }; + const state = getMockConfirmState({ metamask: { + internalAccounts, pendingApprovals, transactions, }, }); + const response = renderHookWithConfirmContextProvider( useFirstTimeInteractionAlert, state, @@ -71,19 +72,22 @@ describe('useFirstTimeInteractionAlert', () => { expect(runHook()).toEqual([]); }); - it('returns no alerts if no transactions', () => { + it('returns no alerts if firstTimeInteraction is false', () => { + const notFirstTimeConfirmation = { + ...TRANSACTION_META_MOCK, + isFirstTimeInteraction: false, + }; expect( runHook({ - currentConfirmation: CONFIRMATION_MOCK, - transactions: [], + currentConfirmation: notFirstTimeConfirmation, }), ).toEqual([]); }); - it('returns no alerts if firstTimeInteraction is false', () => { + it('returns no alerts if firstTimeInteraction is undefined', () => { const notFirstTimeConfirmation = { ...TRANSACTION_META_MOCK, - firstTimeInteraction: false, + isFirstTimeInteraction: undefined, }; expect( runHook({ @@ -92,21 +96,43 @@ describe('useFirstTimeInteractionAlert', () => { ).toEqual([]); }); - it('returns no alerts if firstTimeInteraction is undefined', () => { - const notFirstTimeConfirmation = { + it('returns no alerts if transaction destination is internal account', () => { + const firstTimeConfirmation = { ...TRANSACTION_META_MOCK, - firstTimeInteraction: undefined, + isFirstTimeInteraction: true, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: ACCOUNT_ADDRESS, + }, }; expect( runHook({ - currentConfirmation: notFirstTimeConfirmation, + currentConfirmation: firstTimeConfirmation, + internalAccountAddresses: [ACCOUNT_ADDRESS], + }), + ).toEqual([]); + }); + + it('returns no alerts if transaction destination is internal account with different case', () => { + const firstTimeConfirmation = { + ...TRANSACTION_META_MOCK, + isFirstTimeInteraction: true, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: ACCOUNT_ADDRESS.toLowerCase(), + }, + }; + expect( + runHook({ + currentConfirmation: firstTimeConfirmation, + internalAccountAddresses: [ACCOUNT_ADDRESS.toUpperCase()], }), ).toEqual([]); }); it('returns alert if isFirstTimeInteraction is true', () => { const firstTimeConfirmation = { - ...CONFIRMATION_MOCK, + ...TRANSACTION_META_MOCK, isFirstTimeInteraction: true, }; const alerts = runHook({ diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts index 7e4a86c3802f..c74552575667 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useFirstTimeInteractionAlert.ts @@ -1,22 +1,32 @@ import { useMemo } from 'react'; import { TransactionMeta } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; 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'; +import { getInternalAccounts } from '../../../../../selectors'; export function useFirstTimeInteractionAlert(): Alert[] { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); + const internalAccounts = useSelector(getInternalAccounts); - const { isFirstTimeInteraction } = currentConfirmation ?? {}; + const { txParams, isFirstTimeInteraction } = currentConfirmation ?? {}; + const { to } = txParams ?? {}; + + const isInternalAccount = internalAccounts.some( + (account) => account.address?.toLowerCase() === to?.toLowerCase(), + ); + + const showAlert = !isInternalAccount && isFirstTimeInteraction; return useMemo(() => { // If isFirstTimeInteraction is undefined that means it's either disabled or error in accounts API // If it's false that means account relationship found - if (!isFirstTimeInteraction) { + if (!showAlert) { return []; } @@ -31,5 +41,5 @@ export function useFirstTimeInteractionAlert(): Alert[] { severity: Severity.Warning, }, ]; - }, [isFirstTimeInteraction, t]); + }, [showAlert, t]); } From e0a0a7a69e75b0998c94f385ebc73ba75dd16338 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Sat, 7 Dec 2024 05:05:24 +0530 Subject: [PATCH 143/148] cherry-pick: Update signature controller (#28992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update signature controller to fix signature decoding issues. PR: 1. https://github.com/MetaMask/core/pull/5028 2. https://github.com/MetaMask/core/pull/5033 Original Extension PR: https://github.com/MetaMask/metamask-extension/pull/28988 ## **Related issues** * Related to: https://github.com/MetaMask/MetaMask-planning/issues/3756 * Related to: https://github.com/MetaMask/MetaMask-planning/issues/3757 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 2 +- yarn.lock | 32 +++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index c86ea2d35cb4..5d8865c3851d 100644 --- a/package.json +++ b/package.json @@ -339,7 +339,7 @@ "@metamask/safe-event-emitter": "^3.1.1", "@metamask/scure-bip39": "^2.0.3", "@metamask/selected-network-controller": "^18.0.2", - "@metamask/signature-controller": "^23.0.0", + "@metamask/signature-controller": "^23.1.0", "@metamask/smart-transactions-controller": "^15.0.0", "@metamask/snaps-controllers": "^9.14.0", "@metamask/snaps-execution-environments": "^6.10.0", diff --git a/yarn.lock b/yarn.lock index 91cc4445c553..e50d9878e356 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5056,9 +5056,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @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, @metamask/controller-utils@npm:^11.4.3": - version: 11.4.3 - resolution: "@metamask/controller-utils@npm:11.4.3" +"@metamask/controller-utils@npm:^11.0.0, @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, @metamask/controller-utils@npm:^11.4.3, @metamask/controller-utils@npm:^11.4.4": + version: 11.4.4 + resolution: "@metamask/controller-utils@npm:11.4.4" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" @@ -5070,7 +5070,9 @@ __metadata: bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/5703b0721daf679cf44affc690f2b313e40893b64b0aafaf203e69ee51438197cc3634ef7094145f580a8a8aaadcb79026b2fbd4065c1bb4a8c26627a2c4c69a + peerDependencies: + "@babel/runtime": ^7.0.0 + checksum: 10/0833800d4733f52fbf232efedc97ce66603430fd20ec10e71e6dc4c23295b3b59cc3c8109b86b8039b9ae0c0d2428815428924c367b88f9ea6013152a86d862b languageName: node linkType: hard @@ -6191,15 +6193,15 @@ __metadata: languageName: node linkType: hard -"@metamask/signature-controller@npm:^23.0.0": - version: 23.0.0 - resolution: "@metamask/signature-controller@npm:23.0.0" +"@metamask/signature-controller@npm:^23.1.0": + version: 23.1.0 + resolution: "@metamask/signature-controller@npm:23.1.0" dependencies: "@metamask/base-controller": "npm:^7.0.2" - "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/controller-utils": "npm:^11.4.4" "@metamask/eth-sig-util": "npm:^8.0.0" "@metamask/utils": "npm:^10.0.0" - jsonschema: "npm:^1.2.4" + jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" uuid: "npm:^8.3.2" peerDependencies: @@ -6207,7 +6209,7 @@ __metadata: "@metamask/keyring-controller": ^19.0.0 "@metamask/logging-controller": ^6.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/5e2fda2d89dd3433f00028da0fa7743a6934b72f33fc0e4803dafa98702b9bdd9d093a326060d5480e6eb065c6b4cc1dc3e39382c00702f28b5a6061e8f105bf + checksum: 10/2f97e6412bc3a3a13aa2106dfd679500680881918a6e3e2621d4e225e6380df7a01218f7ce26747575b09c85d8ac8be2f5029240339b28fc6d849fad480c5b72 languageName: node linkType: hard @@ -24742,10 +24744,10 @@ __metadata: languageName: node linkType: hard -"jsonschema@npm:^1.2.4": - version: 1.2.4 - resolution: "jsonschema@npm:1.2.4" - checksum: 10/7b959737416a5716f2df3142e30c8685bc5449974d56d1cd5acbbd61c0f71041af38fa315327c8577fcdbe30907fd9b633c4d3484baf2cc8563609afac5b4e14 +"jsonschema@npm:^1.2.4, jsonschema@npm:^1.4.1": + version: 1.4.1 + resolution: "jsonschema@npm:1.4.1" + checksum: 10/d7a188da7a3100a2caa362b80e98666d46607b7a7153aac405b8e758132961911c6df02d444d4700691330874e21a62639f550e856b21ddd28423690751ca9c6 languageName: node linkType: hard @@ -26556,7 +26558,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:^23.0.0" + "@metamask/signature-controller": "npm:^23.1.0" "@metamask/smart-transactions-controller": "npm:^15.0.0" "@metamask/snaps-controllers": "npm:^9.14.0" "@metamask/snaps-execution-environments": "npm:^6.10.0" From b36fb52350e3ee5957553159561027eae1db6b45 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Mon, 9 Dec 2024 20:40:22 +0000 Subject: [PATCH 144/148] fix (cherry-pick): only display Signing in with for SIWE #28984 (#29025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry-pick of #28984 for release `12.9.0`. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/29025?quickstart=1) ## **Related issues** Fixes: ## **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/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/_locales/en/messages.json | 3 + .../info/row/alert-row/alert-row.test.tsx | 18 + .../confirm/info/row/alert-row/alert-row.tsx | 7 + .../info/__snapshots__/info.test.tsx.snap | 375 --------------- .../__snapshots__/approve.test.tsx.snap | 75 --- .../base-transaction-info.test.tsx.snap | 75 --- .../__snapshots__/personal-sign.test.tsx.snap | 150 ------ .../set-approval-for-all-info.test.tsx.snap | 75 --- .../sign-in-with-row.test.tsx | 23 +- .../sign-in-with-row/sign-in-with-row.tsx | 5 +- .../transaction-details.test.tsx.snap | 75 --- .../__snapshots__/typed-sign-v1.test.tsx.snap | 75 --- .../__snapshots__/typed-sign.test.tsx.snap | 375 --------------- .../__snapshots__/confirm.test.tsx.snap | 450 ------------------ 14 files changed, 54 insertions(+), 1727 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 3b8b2bfa9682..f160fd22782b 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5054,6 +5054,9 @@ "signingInWith": { "message": "Signing in with" }, + "signingWith": { + "message": "Signing with" + }, "simulationApproveHeading": { "message": "Withdraw" }, diff --git a/ui/components/app/confirm/info/row/alert-row/alert-row.test.tsx b/ui/components/app/confirm/info/row/alert-row/alert-row.test.tsx index df7f8827b67f..070bd97d0a6b 100644 --- a/ui/components/app/confirm/info/row/alert-row/alert-row.test.tsx +++ b/ui/components/app/confirm/info/row/alert-row/alert-row.test.tsx @@ -91,6 +91,24 @@ describe('AlertRow', () => { expect(queryByTestId('inline-alert')).toBeNull(); }); + describe('display row only when there is an alert', () => { + it('does not render when isShownWithAlertsOnly is true and there is no alert', () => { + const { queryByTestId } = renderAlertRow({ + isShownWithAlertsOnly: true, + }); + expect(queryByTestId('inline-alert')).toBeNull(); + }); + + it('renders when isShownWithAlertsOnly is false and there is an alert', () => { + const { getByTestId } = renderAlertRow({ + alertKey: KEY_ALERT_KEY_MOCK, + ownerId: OWNER_ID_MOCK, + isShownWithAlertsOnly: false, + }); + expect(getByTestId('inline-alert')).toBeDefined(); + }); + }); + describe('Modal visibility', () => { it('show when clicked in the inline alert', () => { const { getByTestId } = renderAlertRow({ diff --git a/ui/components/app/confirm/info/row/alert-row/alert-row.tsx b/ui/components/app/confirm/info/row/alert-row/alert-row.tsx index 3956cc3095eb..094a5b72acc7 100644 --- a/ui/components/app/confirm/info/row/alert-row/alert-row.tsx +++ b/ui/components/app/confirm/info/row/alert-row/alert-row.tsx @@ -17,6 +17,8 @@ import { useAlertMetrics } from '../../../../alert-system/contexts/alertMetricsC export type ConfirmInfoAlertRowProps = ConfirmInfoRowProps & { alertKey: string; ownerId: string; + /** Determines whether to display the row only when an alert is present. */ + isShownWithAlertsOnly?: boolean; }; function getAlertTextColors( @@ -41,6 +43,7 @@ export const ConfirmInfoAlertRow = ({ alertKey, ownerId, variant, + isShownWithAlertsOnly = false, ...rowProperties }: ConfirmInfoAlertRowProps) => { const { trackInlineAlertClicked } = useAlertMetrics(); @@ -68,6 +71,10 @@ export const ConfirmInfoAlertRow = ({ variant, }; + if (isShownWithAlertsOnly && !hasFieldAlert) { + return null; + } + const inlineAlert = hasFieldAlert ? (
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
renders component for approve request 1`] = `

-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
renders component for contract interaction requ
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
renders component for approve request 1`] = `

-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
{ + const originalUtils = jest.requireActual('../../../../../utils'); + return { + ...originalUtils, + isSIWESignatureRequest: jest.fn().mockReturnValue(false), + }; +}); + describe('', () => { const middleware = [thunk]; - it('renders component for transaction details', () => { + it('does not display the row for non SIWE requests', () => { + const state = getMockContractInteractionConfirmState(); + const mockStore = configureMockStore(middleware)(state); + const { container } = renderWithConfirmContextProvider( + , + mockStore, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders component for SIWE transaction details', () => { + (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(true); + const state = getMockContractInteractionConfirmState(); const mockStore = configureMockStore(middleware)(state); const { getByText } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx b/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx index 7b20cbc08062..5ef84e0bb9a1 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/sign-in-with-row/sign-in-with-row.tsx @@ -7,11 +7,13 @@ import { RowAlertKey } from '../../../../../../../components/app/confirm/info/ro import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; import { useConfirmContext } from '../../../../../context/confirm'; import { SignatureRequestType } from '../../../../../types/confirm'; +import { isSIWESignatureRequest } from '../../../../../utils'; export const SigningInWithRow = () => { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); + const isSIWE = isSIWESignatureRequest(currentConfirmation); const chainId = currentConfirmation?.chainId as string; const from = @@ -25,8 +27,9 @@ export const SigningInWithRow = () => { return ( diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap index 9e9b9b23eca5..f5183e21206c 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap @@ -151,81 +151,6 @@ exports[` renders component for transaction details 1`] =
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x2e0D7...5d09B -

-
-
-
`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap index f00b51538750..441b05c888f0 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/__snapshots__/typed-sign-v1.test.tsx.snap @@ -47,81 +47,6 @@ exports[`TypedSignInfo correctly renders typed sign data request 1`] = `

-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- Test Account -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
-
-
-
-

- Signing in with -

-
-
-
-
- -

- 0x935E7...05477 -

-
-
-
Date: Mon, 9 Dec 2024 23:17:37 +0200 Subject: [PATCH 145/148] chore: V12.9.0 changelog (#28987) ## **Description** RC 12.9.0 Changelog [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28987?quickstart=1) --- CHANGELOG.md | 284 +++++++++------------------------------------------ 1 file changed, 47 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c8ccd2bbbe..3d4b24b8a1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,243 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [12.9.0] -### Fixed -- fix: SIWE e2e test timing out and breaking ci ([#28801](https://github.com/MetaMask/metamask-extension/pull/28801)) -- fix: has_marketing_consent flag on metametrics ([#28795](https://github.com/MetaMask/metamask-extension/pull/28795)) -- chore: Bump `@metamask/eth-token-tracker` from v8 to v9 ([#28754](https://github.com/MetaMask/metamask-extension/pull/28754)) -- test: Fix `getEventPayloads` e2e test helper ([#28796](https://github.com/MetaMask/metamask-extension/pull/28796)) -- chore: bump `@metamask/preferences-controller` to `^14.0.0` ([#28778](https://github.com/MetaMask/metamask-extension/pull/28778)) -- chore: Master sync after 12.7.2 ([#28794](https://github.com/MetaMask/metamask-extension/pull/28794)) -- Merge branch 'master-sync' of https://github.com/MetaMask/metamask-extension into master-sync -- Merge origin/develop into master-sync -- feat: adding e2e for signature decoding api and enable it in extension ([#28423](https://github.com/MetaMask/metamask-extension/pull/28423)) -- feat: Support returning a txHash asap for smart transactions ([#28770](https://github.com/MetaMask/metamask-extension/pull/28770)) -- feat: multichain send action adds solana ([#28738](https://github.com/MetaMask/metamask-extension/pull/28738)) -- chore: Cleanup PortfolioView ([#28785](https://github.com/MetaMask/metamask-extension/pull/28785)) -- test: migrate signature redesign tests to page object model ([#28538](https://github.com/MetaMask/metamask-extension/pull/28538)) -- chore: Bump `@metamask/message-manager` to v11 ([#28758](https://github.com/MetaMask/metamask-extension/pull/28758)) -- feat: migrate `AppMedataController` to inherit from BaseController V2 ([#28783](https://github.com/MetaMask/metamask-extension/pull/28783)) -- chore: Add error handling for `setCorrectChain` ([#28740](https://github.com/MetaMask/metamask-extension/pull/28740)) -- fix: Add optional chaining to currencyRates check for stability ([#28753](https://github.com/MetaMask/metamask-extension/pull/28753)) -- chore: Bump `@metamask/providers` to v18 ([#28757](https://github.com/MetaMask/metamask-extension/pull/28757)) -- chore: Bump `@metamask/eth-json-rpc-middleware` to v15.0.0 ([#28756](https://github.com/MetaMask/metamask-extension/pull/28756)) -- fix: Phishing page metrics ([#28364](https://github.com/MetaMask/metamask-extension/pull/28364)) -- feat: Turn on `PortfolioView` ([#28661](https://github.com/MetaMask/metamask-extension/pull/28661)) -- fix: remove network modal ([#28765](https://github.com/MetaMask/metamask-extension/pull/28765)) -- chore: Update `@metamask/polling-controller` to v11 ([#28759](https://github.com/MetaMask/metamask-extension/pull/28759)) -- perf: Prevent Sentry from auto-generating spans for requests to Sentry ([#28613](https://github.com/MetaMask/metamask-extension/pull/28613)) -- feat: Integrate Snap notification services ([#27975](https://github.com/MetaMask/metamask-extension/pull/27975)) -- feat: PortfolioView: Add feature flag check for polling intervals ([#28501](https://github.com/MetaMask/metamask-extension/pull/28501)) -- fix: add dispatch detect Nfts on network switch ([#28769](https://github.com/MetaMask/metamask-extension/pull/28769)) -- feat: on UI side filtering put typed sign V4 requests for which decoding data is displayed ([#28762](https://github.com/MetaMask/metamask-extension/pull/28762)) -- chore: Bump `@metamask/eth-json-rpc-middleware` to v14.0.2 ([#28755](https://github.com/MetaMask/metamask-extension/pull/28755)) -- fix(wallet-overview): prevent send button clicked event to be sent twice ([#28772](https://github.com/MetaMask/metamask-extension/pull/28772)) -- refactor: move `getCurrentChainId` from `selectors/selectors.js` to `shared/modules/selectors/networks.ts` ([#27647](https://github.com/MetaMask/metamask-extension/pull/27647)) -- feat: adding metrics for signature decoding ([#28719](https://github.com/MetaMask/metamask-extension/pull/28719)) -- fix: fix transaction list message on token detail page ([#28764](https://github.com/MetaMask/metamask-extension/pull/28764)) -- chore: Bump `@metamask/permission-log-controller` to v3.0.1 ([#28747](https://github.com/MetaMask/metamask-extension/pull/28747)) -- chore: Bump `@metamask/ens-controller` from v13 to v14 ([#28746](https://github.com/MetaMask/metamask-extension/pull/28746)) -- test: add e2e for transaction decoding ([#28204](https://github.com/MetaMask/metamask-extension/pull/28204)) -- test: add integration tests for different types of Permit ([#27446](https://github.com/MetaMask/metamask-extension/pull/27446)) -- test: [POM] Migrate add token e2e tests to TS and Page Object Model ([#28658](https://github.com/MetaMask/metamask-extension/pull/28658)) -- chore: node.js 20.18 ([#28058](https://github.com/MetaMask/metamask-extension/pull/28058)) -- chore: Update `@metamask/gas-fee-controller` and peer deps ([#28745](https://github.com/MetaMask/metamask-extension/pull/28745)) -- fix: content dialog styling is being applied to all dialogs ([#28739](https://github.com/MetaMask/metamask-extension/pull/28739)) -- feat: Bump `@metamask/permission-controller` to `^11.0.0` ([#28743](https://github.com/MetaMask/metamask-extension/pull/28743)) -- feat: add e2e tests for multichain ([#28708](https://github.com/MetaMask/metamask-extension/pull/28708)) -- fix: Add metric trait for token network filter preference ([#28336](https://github.com/MetaMask/metamask-extension/pull/28336)) -- fix: Provide selector that enables cross-chain polling, regardless of network filter state ([#28662](https://github.com/MetaMask/metamask-extension/pull/28662)) -- chore: Remove unnecessary event prop ([#28546](https://github.com/MetaMask/metamask-extension/pull/28546)) -- fix: Revert "feat: Changing title for permit requests (#28537)" ([#28537](https://github.com/MetaMask/metamask-extension/pull/28537)) -- fix: Fix avatar size for current network ([#28731](https://github.com/MetaMask/metamask-extension/pull/28731)) -- fix:updated account name and length for dapp connections ([#28725](https://github.com/MetaMask/metamask-extension/pull/28725)) -- fix: Pass along decimal balance from asset-page to swaps UI ([#28707](https://github.com/MetaMask/metamask-extension/pull/28707)) -- chore: adds Solana support for the account overview ([#28411](https://github.com/MetaMask/metamask-extension/pull/28411)) -- feat: Enable redesigned transaction confirmations for all users ([#28321](https://github.com/MetaMask/metamask-extension/pull/28321)) -- feat: cross chain swaps - tx status - BridgeStatusController ([#28636](https://github.com/MetaMask/metamask-extension/pull/28636)) -- fix: use BN from bn.js instead of ethereumjs-util ([#28146](https://github.com/MetaMask/metamask-extension/pull/28146)) -- feat: Add first time interaction warning ([#28435](https://github.com/MetaMask/metamask-extension/pull/28435)) -- feat: enable account syncing in production ([#28596](https://github.com/MetaMask/metamask-extension/pull/28596)) -- test: add accounts sync test with balance detection ([#28715](https://github.com/MetaMask/metamask-extension/pull/28715)) -- chore: Bump Snaps packages ([#28678](https://github.com/MetaMask/metamask-extension/pull/28678)) -- fix: transaction flow section layout on re-designed confirmation pages ([#28720](https://github.com/MetaMask/metamask-extension/pull/28720)) -- feat: Changing title for permit requests ([#28537](https://github.com/MetaMask/metamask-extension/pull/28537)) -- fix: add e2e for portfolio view polling ([#28682](https://github.com/MetaMask/metamask-extension/pull/28682)) -- fix: PortfolioView swap native token bug ([#28639](https://github.com/MetaMask/metamask-extension/pull/28639)) -- fix: prevent non-current network tokens from being hidden incorrectly ([#28674](https://github.com/MetaMask/metamask-extension/pull/28674)) -- fix: fix `ConnectPage` when a non-EVM account is selected ([#28436](https://github.com/MetaMask/metamask-extension/pull/28436)) -- test: [POM] Migrate create btc account e2e tests to TS and Page Object Model ([#28437](https://github.com/MetaMask/metamask-extension/pull/28437)) -- fix: SonarCloud workflow_run ([#28693](https://github.com/MetaMask/metamask-extension/pull/28693)) -- fix: swaps approval checking for approvals between 0 and unlimited ([#28680](https://github.com/MetaMask/metamask-extension/pull/28680)) -- chore: Restrict MMI test runs ([#28655](https://github.com/MetaMask/metamask-extension/pull/28655)) -- feat: change description of enabling simulation message in settings ([#28536](https://github.com/MetaMask/metamask-extension/pull/28536)) -- test: blockaid e2e test for contract interaction ([#28156](https://github.com/MetaMask/metamask-extension/pull/28156)) -- perf: add React.lazy to the Routes ([#28172](https://github.com/MetaMask/metamask-extension/pull/28172)) -- fix: add missing filter for scheduled job rerun-from-failed ([#28644](https://github.com/MetaMask/metamask-extension/pull/28644)) -- fix: Add default value to custom nonce modal ([#28659](https://github.com/MetaMask/metamask-extension/pull/28659)) -- fix: Reduce max pet name length ([#28660](https://github.com/MetaMask/metamask-extension/pull/28660)) -- test: rename balance functions to cover both Ganache and Anvil in preparation for ganache migration ([#28676](https://github.com/MetaMask/metamask-extension/pull/28676)) -- fix: display new network popup only for accounts that are compatible. ([#28535](https://github.com/MetaMask/metamask-extension/pull/28535)) -- feat: adding tooltip to signature decoding state changes ([#28430](https://github.com/MetaMask/metamask-extension/pull/28430)) -- fix: add alert when selected account is different from signing account in confirmation ([#28562](https://github.com/MetaMask/metamask-extension/pull/28562)) -- test: Adding unit test for setupPhishingCommunication and setUpCookieHandlerCommunication ([#27736](https://github.com/MetaMask/metamask-extension/pull/27736)) -- chore: PortfolioView™: Design Review Cleanup: Networks, sort, & Menu ([#28663](https://github.com/MetaMask/metamask-extension/pull/28663)) -- chore: sort and display all bridge quotes ([#27731](https://github.com/MetaMask/metamask-extension/pull/27731)) -- fix: market data for native tokens with non zero addresses ([#28584](https://github.com/MetaMask/metamask-extension/pull/28584)) -- fix: Reset streams on BFCache events ([#24950](https://github.com/MetaMask/metamask-extension/pull/24950)) -- fix: add unit test for assets polling loops ([#28646](https://github.com/MetaMask/metamask-extension/pull/28646)) -- chore: Run MMI tests on long-running branches ([#28651](https://github.com/MetaMask/metamask-extension/pull/28651)) -- fix: Provide maximal asset list filter space ([#28590](https://github.com/MetaMask/metamask-extension/pull/28590)) -- chore: bump `keyring-api` to `^10.1.0` + `eth-snap-keyring` to `^5.0.1` ([#28545](https://github.com/MetaMask/metamask-extension/pull/28545)) -- chore: updating filter icon to align with figma ([#28547](https://github.com/MetaMask/metamask-extension/pull/28547)) -- test: rename the `GanacheContractAddressRegistry` class in preparation for ganache migration ([#28595](https://github.com/MetaMask/metamask-extension/pull/28595)) -- chore: Update and use selectors for which chains to poll ([#28586](https://github.com/MetaMask/metamask-extension/pull/28586)) -- feat: Display '< 0.01' instead of '0.00' for the fiat value of networ… ([#28543](https://github.com/MetaMask/metamask-extension/pull/28543)) -- Merge origin/develop into master-sync -- chore: rerun workflow from failed ([#28143](https://github.com/MetaMask/metamask-extension/pull/28143)) -- chore: change e2e quality gate reruns for new/changed tests from 5 to 4 ([#28611](https://github.com/MetaMask/metamask-extension/pull/28611)) -- feat: display native values returned from decoding api ([#28374](https://github.com/MetaMask/metamask-extension/pull/28374)) -- chore: Branch off of "New Crowdin translations by Github Action" ([#28390](https://github.com/MetaMask/metamask-extension/pull/28390)) -- refactor: move `getProviderConfig` out of `ducks/metamask.js` to `shared/selectors/networks.ts` ([#27646](https://github.com/MetaMask/metamask-extension/pull/27646)) -- test: Fixed artifacts issue due to switching window title ([#28469](https://github.com/MetaMask/metamask-extension/pull/28469)) -- fix: Network filter must respect `PORTFOLIO_VIEW` feature flag ([#28626](https://github.com/MetaMask/metamask-extension/pull/28626)) -- test: Fix flakiness caused by display of newly switched to network modal ([#28625](https://github.com/MetaMask/metamask-extension/pull/28625)) -- feat: multichain token detection ([#28380](https://github.com/MetaMask/metamask-extension/pull/28380)) -- fix: fix test networks display for portfolio view ([#28601](https://github.com/MetaMask/metamask-extension/pull/28601)) -- feat(SwapsController): Remove reliance on global network ([#28275](https://github.com/MetaMask/metamask-extension/pull/28275)) -- feat: `PortfolioView` ([#28593](https://github.com/MetaMask/metamask-extension/pull/28593)) -- fix: replace unreliable setTimeout usage with waitFor ([#28620](https://github.com/MetaMask/metamask-extension/pull/28620)) -- feat: Hook in Portfolio Entry Points ([#27607](https://github.com/MetaMask/metamask-extension/pull/27607)) -- feat: cross chain swaps - tx submit ([#27262](https://github.com/MetaMask/metamask-extension/pull/27262)) -- chore: centralize redesigned confirmation decision logic ([#28445](https://github.com/MetaMask/metamask-extension/pull/28445)) -- chore: upgrade transaction controller to increase polling rate ([#28452](https://github.com/MetaMask/metamask-extension/pull/28452)) -- fix: fix account list item for portfolio view ([#28598](https://github.com/MetaMask/metamask-extension/pull/28598)) -- feat: Better handle very long names in the name component ([#28560](https://github.com/MetaMask/metamask-extension/pull/28560)) -- fix: PortfolioView: Remove pausedChainIds from selector ([#28552](https://github.com/MetaMask/metamask-extension/pull/28552)) -- refactor: Cherry pick asset-list-control-bar updates ([#28575](https://github.com/MetaMask/metamask-extension/pull/28575)) -- fix: Gracefully handle bad responses from `net_version` calls to RPC endpoint when getting Provider Network State ([#27509](https://github.com/MetaMask/metamask-extension/pull/27509)) -- fix: use PORTFOLIO_VIEW flag to determine token list polling ([#28579](https://github.com/MetaMask/metamask-extension/pull/28579)) -- ci: limit playwright install to chromium browser only ([#28580](https://github.com/MetaMask/metamask-extension/pull/28580)) -- fix: use PORTFOLIO_VIEW flag to determine chain polling ([#28504](https://github.com/MetaMask/metamask-extension/pull/28504)) -- fix(sentry sampling): divide by 2 our sentry trace sample rate to avoid exceeding our quota ([#28573](https://github.com/MetaMask/metamask-extension/pull/28573)) -- fix: contact names should not allow duplication ([#28249](https://github.com/MetaMask/metamask-extension/pull/28249)) -- feat: account syncing various updates ([#28541](https://github.com/MetaMask/metamask-extension/pull/28541)) -- chore: Bump Snaps packages ([#28557](https://github.com/MetaMask/metamask-extension/pull/28557)) -- feat: cross chain aggregated balance ([#28456](https://github.com/MetaMask/metamask-extension/pull/28456)) -- fix: Address design review for NFT token send ([#28433](https://github.com/MetaMask/metamask-extension/pull/28433)) -- test: add token price privacy spec ([#28556](https://github.com/MetaMask/metamask-extension/pull/28556)) -- feat: display ERC20 and ERC721 token details returns by decoding api ([#28366](https://github.com/MetaMask/metamask-extension/pull/28366)) -- chore: Reduce E2E test jobs run on PRs ([#28525](https://github.com/MetaMask/metamask-extension/pull/28525)) -- fix: account tracker controller with useMultiPolling ([#28277](https://github.com/MetaMask/metamask-extension/pull/28277)) -- perf: optimize fonts by using woff2 instead of ttf ([#26554](https://github.com/MetaMask/metamask-extension/pull/26554)) -- test: [POM] Migrate onboarding metrics e2e tests to TS and Page Object Model to reduce flakiness ([#28424](https://github.com/MetaMask/metamask-extension/pull/28424)) -- test: improve logs for e2e errors ([#28479](https://github.com/MetaMask/metamask-extension/pull/28479)) -- fix: PortfolioView: Selector to determine networks to poll ([#28502](https://github.com/MetaMask/metamask-extension/pull/28502)) -- chore: Update `cross-spawn` ([#28522](https://github.com/MetaMask/metamask-extension/pull/28522)) -- feat: upgrade assets controllers to version 44 ([#28472](https://github.com/MetaMask/metamask-extension/pull/28472)) -- chore: Master sync ([#28459](https://github.com/MetaMask/metamask-extension/pull/28459)) -- Merge origin/develop into master-sync -- feat: Upgrade assets controllers to 43 with multichain polling for token lists + detection ([#28447](https://github.com/MetaMask/metamask-extension/pull/28447)) -- fix: display btc account creation while in settings ([#28379](https://github.com/MetaMask/metamask-extension/pull/28379)) -- chore: fix test path on CI ([#28482](https://github.com/MetaMask/metamask-extension/pull/28482)) -- chore: Fix flaky ERC20 transfer blockaid e2e ([#28453](https://github.com/MetaMask/metamask-extension/pull/28453)) -- test: [POM] Migrate vault decryption e2e tests to TS and Page Object Model ([#28419](https://github.com/MetaMask/metamask-extension/pull/28419)) -- feat: UI changes to show decoding data for permits ([#28342](https://github.com/MetaMask/metamask-extension/pull/28342)) -- fix: dont poll token prices during onboarding or when locked ([#28465](https://github.com/MetaMask/metamask-extension/pull/28465)) -- fix: Allow outerclick to close import modal ([#28448](https://github.com/MetaMask/metamask-extension/pull/28448)) -- feat: add simulation metrics when simulation UI is not visible ([#28427](https://github.com/MetaMask/metamask-extension/pull/28427)) -- fix: Fix attribution generation ([#28415](https://github.com/MetaMask/metamask-extension/pull/28415)) -- test: Improve test for signatures ([#27532](https://github.com/MetaMask/metamask-extension/pull/27532)) -- fix: ui customizations for redesigned transactions ([#28443](https://github.com/MetaMask/metamask-extension/pull/28443)) -- fix: Remove multiple overlapping spinners ([#28301](https://github.com/MetaMask/metamask-extension/pull/28301)) -- fix: Hide "interacting with" when simulated balance changes are shown ([#28409](https://github.com/MetaMask/metamask-extension/pull/28409)) -- chore: Begin introducing patterns for Multichain AssetList ([#28429](https://github.com/MetaMask/metamask-extension/pull/28429)) -- feat: update signature controller and integrate decoding api ([#28397](https://github.com/MetaMask/metamask-extension/pull/28397)) -- fix: Update PortfolioView flag ([#28446](https://github.com/MetaMask/metamask-extension/pull/28446)) -- perf: Create custom spans for account overview tabs ([#28086](https://github.com/MetaMask/metamask-extension/pull/28086)) -- fix: Default to dApp suggested fees only when user selects the option ([#28403](https://github.com/MetaMask/metamask-extension/pull/28403)) -- feat: btc send flow e2e ([#28340](https://github.com/MetaMask/metamask-extension/pull/28340)) -- test: fix state fixtures race condition ([#28421](https://github.com/MetaMask/metamask-extension/pull/28421)) -- test: [POM] Migrate autodetect and import nft e2e tests to use Page Object Model ([#28383](https://github.com/MetaMask/metamask-extension/pull/28383)) -- chore(deps): bump `@metamask/eth-ledger-bridge-keyring` to `^5.0.1` ([#27688](https://github.com/MetaMask/metamask-extension/pull/27688)) -- chore: limit bridge quote request frequency and cancel requests ([#27237](https://github.com/MetaMask/metamask-extension/pull/27237)) -- test: Reintegrate refactored Swap e2e tests to the pipeline ([#26493](https://github.com/MetaMask/metamask-extension/pull/26493)) -- fix: fix network client ID used on the useGasFeeInputs hook ([#28391](https://github.com/MetaMask/metamask-extension/pull/28391)) -- ci: Fix `attributions:check` silent failure ([#28413](https://github.com/MetaMask/metamask-extension/pull/28413)) -- fix: `Test Snap Cronjob can trigger a cronjob to open a di...` flaky tests ([#28363](https://github.com/MetaMask/metamask-extension/pull/28363)) -- feat: add `account_type`/`snap_id` for buy/send metrics ([#28011](https://github.com/MetaMask/metamask-extension/pull/28011)) -- fix: get `supportedChains` to avoid blocking the confirmation process ([#28313](https://github.com/MetaMask/metamask-extension/pull/28313)) -- test: [POM] Migrate reveal account srp e2e tests to use Page Object Model ([#28354](https://github.com/MetaMask/metamask-extension/pull/28354)) -- fix: Add metric trait for privacy mode ([#28335](https://github.com/MetaMask/metamask-extension/pull/28335)) -- fix: Properly ellipsize long token names ([#28392](https://github.com/MetaMask/metamask-extension/pull/28392)) -- chore: Bump snaps-utils ([#28399](https://github.com/MetaMask/metamask-extension/pull/28399)) -- feat: migrate MetaMetricsController to BaseControllerV2 ([#28113](https://github.com/MetaMask/metamask-extension/pull/28113)) -- feat: change expand icon per new design ([#28267](https://github.com/MetaMask/metamask-extension/pull/28267)) -- chore: add unit test for `useMultiPolling` ([#28387](https://github.com/MetaMask/metamask-extension/pull/28387)) -- feat(Solana): add "Add a new Solana account" link to the account creation dialog ([#28270](https://github.com/MetaMask/metamask-extension/pull/28270)) -- fix: Return to send page with different asset types ([#28382](https://github.com/MetaMask/metamask-extension/pull/28382)) -- test: [POM] Refactor import account e2e tests to use Page Object Model ([#28325](https://github.com/MetaMask/metamask-extension/pull/28325)) -- feat(1852): Implement sentry user report on error screen ([#27857](https://github.com/MetaMask/metamask-extension/pull/27857)) -- fix: disable buy for btc testnet accounts ([#28341](https://github.com/MetaMask/metamask-extension/pull/28341)) -- fix: Address design review for ERC20 token send ([#28212](https://github.com/MetaMask/metamask-extension/pull/28212)) -- refactor: remove global network usage from transaction confirmations ([#28236](https://github.com/MetaMask/metamask-extension/pull/28236)) -- build: update yarn to v4.5.1 ([#28365](https://github.com/MetaMask/metamask-extension/pull/28365)) -- fix: Bug 28347 - Privacy mode tweaks ([#28367](https://github.com/MetaMask/metamask-extension/pull/28367)) -- fix: mv2 firefox csp header ([#27770](https://github.com/MetaMask/metamask-extension/pull/27770)) -- perf: ensure `setupLocale` doesn't fetch `_locales/en/messages.json` twice ([#26553](https://github.com/MetaMask/metamask-extension/pull/26553)) -- fix: bump `@metamask/queued-request-controller` with patch fix ([#28355](https://github.com/MetaMask/metamask-extension/pull/28355)) -- fix: Revert "fix: Negate privacy mode in Send screen" ([#28360](https://github.com/MetaMask/metamask-extension/pull/28360)) -- fix: disable account syncing ([#28359](https://github.com/MetaMask/metamask-extension/pull/28359)) -- feat: Convert mmi controller to a non-controller ([#27983](https://github.com/MetaMask/metamask-extension/pull/27983)) -- fix: Updates to the simulations component ([#28107](https://github.com/MetaMask/metamask-extension/pull/28107)) -- refactor: rename SECURITY_PROVIDER_SUPPORTED_CHAIN_IDS_FALLBACK_LIST ([#28337](https://github.com/MetaMask/metamask-extension/pull/28337)) -- chore: adds Solana snap to preinstall list ([#28141](https://github.com/MetaMask/metamask-extension/pull/28141)) -- feat: Show network badge in detected tokens modal ([#28231](https://github.com/MetaMask/metamask-extension/pull/28231)) -- fix: Negate privacy mode in Send screen ([#28248](https://github.com/MetaMask/metamask-extension/pull/28248)) -- feat: adds solana feature, code fence ([#28320](https://github.com/MetaMask/metamask-extension/pull/28320)) -- build(webpack): fix cache issues in webpack build by updating `html-bundler-webpack-plugin` to v4.4.1 ([#28225](https://github.com/MetaMask/metamask-extension/pull/28225)) -- feat: team-label-token ([#28134](https://github.com/MetaMask/metamask-extension/pull/28134)) -- chore: add Solana shared utilities and constants ([#28269](https://github.com/MetaMask/metamask-extension/pull/28269)) -- chore: Remove STX opt in modal ([#28291](https://github.com/MetaMask/metamask-extension/pull/28291)) -- chore: revert commit `3da34f4` (feat: btc e2e tests (#27986)) ([#27986](https://github.com/MetaMask/metamask-extension/pull/27986)) -- chore: e2e quality gate enhancement ([#28206](https://github.com/MetaMask/metamask-extension/pull/28206)) -- chore: adding e2e tests for NFT permit ([#28004](https://github.com/MetaMask/metamask-extension/pull/28004)) -- feat: Enable simulation metrics for redesign transactions ([#28280](https://github.com/MetaMask/metamask-extension/pull/28280)) -- fix: GasDetailItem invalid paddingStart prop ([#28281](https://github.com/MetaMask/metamask-extension/pull/28281)) -- fix: use transaction address to get lock for custom nonce ([#28272](https://github.com/MetaMask/metamask-extension/pull/28272)) -- fix: flaky test `Phishing Detection Via Iframe should redirect users to the the MetaMask Phishing Detection page when an iframe domain is on the phishing blocklist` ([#28293](https://github.com/MetaMask/metamask-extension/pull/28293)) -- chore: add the gas_included prop into Quotes Requested event ([#28295](https://github.com/MetaMask/metamask-extension/pull/28295)) -- test: [POM] Refactor e2e tests to use onboarding flows defined in Page Object Models ([#28202](https://github.com/MetaMask/metamask-extension/pull/28202)) -- feat: btc e2e tests ([#27986](https://github.com/MetaMask/metamask-extension/pull/27986)) -- fix: remove scroll-to-bottom requirement in redesigned transaction confirmations ([#27910](https://github.com/MetaMask/metamask-extension/pull/27910)) -- chore: Add gravity logo and image mappings ([#28306](https://github.com/MetaMask/metamask-extension/pull/28306)) -- chore: Bump Snaps packages ([#28215](https://github.com/MetaMask/metamask-extension/pull/28215)) -- feat: Add simulation metrics to "Transaction Submitted" and "Transaction Finalized" events ([#28240](https://github.com/MetaMask/metamask-extension/pull/28240)) -- fix: smart transactions in redesigned confirmations ([#28273](https://github.com/MetaMask/metamask-extension/pull/28273)) -- fix: unit flaky test `AddContact component › should disable submit button when input is not a valid address` ([#27941](https://github.com/MetaMask/metamask-extension/pull/27941)) -- fix: Hide fiat values on test networks ([#28219](https://github.com/MetaMask/metamask-extension/pull/28219)) -- chore: display bridge quotes ([#28031](https://github.com/MetaMask/metamask-extension/pull/28031)) -- fix: Permit message, dataTree value incorrectly using default ERC20 decimals for non-ERC20 token values ([#28142](https://github.com/MetaMask/metamask-extension/pull/28142)) -- fix: ignore error when getTokenStandardAndDetails fails ([#28030](https://github.com/MetaMask/metamask-extension/pull/28030)) -- fix: notification settings type ([#28271](https://github.com/MetaMask/metamask-extension/pull/28271)) -- chore: use accounts api for token detection ([#28254](https://github.com/MetaMask/metamask-extension/pull/28254)) -- fix: Fix alignment of long RPC labels in Networks menu ([#28244](https://github.com/MetaMask/metamask-extension/pull/28244)) -- feat: adds the experimental toggle for Solana ([#28190](https://github.com/MetaMask/metamask-extension/pull/28190)) -- feat: multi chain polling for token prices ([#28158](https://github.com/MetaMask/metamask-extension/pull/28158)) -- refactor: move `getInternalAccounts` from `selectors.js` to `accounts.ts` ([#27645](https://github.com/MetaMask/metamask-extension/pull/27645)) -- fix: Add different copy for tooltip when a snap is requesting a signature ([#27492](https://github.com/MetaMask/metamask-extension/pull/27492)) -- fix: Prevent coercing symbols to zero in the edit spending cap modal ([#28192](https://github.com/MetaMask/metamask-extension/pull/28192)) -- test: [POM] Migrate edit network rpc e2e tests and create related page class functions ([#28161](https://github.com/MetaMask/metamask-extension/pull/28161)) -- refactor: remove global network usage from signatures ([#28167](https://github.com/MetaMask/metamask-extension/pull/28167)) -- fix: margin on asset chart min/max indicators ([#27916](https://github.com/MetaMask/metamask-extension/pull/27916)) -- feat: add token verification source count and link to block explorer ([#27759](https://github.com/MetaMask/metamask-extension/pull/27759)) -- chore: Remove obsolete preview build support ([#27968](https://github.com/MetaMask/metamask-extension/pull/27968)) -- fix: Removing `warning` prop from settings ([#27990](https://github.com/MetaMask/metamask-extension/pull/27990)) -- chore: Adding installType to Sentry Tags for easy filtering ([#28084](https://github.com/MetaMask/metamask-extension/pull/28084)) -- chore: remove broken link in docs ([#28232](https://github.com/MetaMask/metamask-extension/pull/28232)) -- fix: Error handling for the state log download failure ([#26999](https://github.com/MetaMask/metamask-extension/pull/26999)) -- feat: Upgrade alert controller to base controller v2 ([#28054](https://github.com/MetaMask/metamask-extension/pull/28054)) -- chore: improve token lookup performance in `useAccountTotalFiatBalance` ([#28233](https://github.com/MetaMask/metamask-extension/pull/28233)) + +### Added +- Added error handling to ensure users are not redirected to an incorrect network when sending or swapping tokens ([#28740](https://github.com/MetaMask/metamask-extension/pull/28740)) +- Added optional chaining to currency rates check for improved stability ([#28753](https://github.com/MetaMask/metamask-extension/pull/28753)) +- Enabled Portfolio View ([#28661](https://github.com/MetaMask/metamask-extension/pull/28661)) +- Added a selector to enable cross-chain polling for aggregated balances ([#28662](https://github.com/MetaMask/metamask-extension/pull/28662)) +- Ensured the network filter respects the PortfolioView feature flag, displaying tokens accordingly ([#28626](https://github.com/MetaMask/metamask-extension/pull/28626)) +- Implemented multichain token detection, enabling periodic polling and storing detected tokens across all supported networks ([#28380](https://github.com/MetaMask/metamask-extension/pull/28380)) +- Added PortfolioView to display tokens across all networks in one list ([#28593](https://github.com/MetaMask/metamask-extension/pull/28593)) +- Added cross-chain aggregated balance calculation ([#28456](https://github.com/MetaMask/metamask-extension/pull/28456)) +- Enabled redesigned transaction confirmations for all users, with automatic toggling ([#28321](https://github.com/MetaMask/metamask-extension/pull/28321)) +- Added a first-time interaction warning to alert users when interacting with an address for the first time ([#28435](https://github.com/MetaMask/metamask-extension/pull/28435)) +- Added a default value to the custom nonce modal ([#28659](https://github.com/MetaMask/metamask-extension/pull/28659)) +- Added an alert when the selected account differs from the signing account in the confirmation screen ([#28562](https://github.com/MetaMask/metamask-extension/pull/28562)) +- Display "< 0.01" instead of "0.00" for the fiat value of network fees ([#28543](https://github.com/MetaMask/metamask-extension/pull/28543)) +- Improved handling of very long names by truncating names longer than 15 characters with an ellipsis ([#28560](https://github.com/MetaMask/metamask-extension/pull/28560)) +- Enabled account syncing in production ([#28596](https://github.com/MetaMask/metamask-extension/pull/28596)) +- Added various updates to account syncing in preparation for re-enablement ([#28541](https://github.com/MetaMask/metamask-extension/pull/28541)) +- Added entry points to the Portfolio for viewing and managing spending caps from the extension ([#27607](https://github.com/MetaMask/metamask-extension/pull/27607)) + +### Changed +- Updated the new network popup to only display for compatible accounts ([#28535](https://github.com/MetaMask/metamask-extension/pull/28535)) +- Removed the "You're now using..." network modal after adding a network ([#28765](https://github.com/MetaMask/metamask-extension/pull/28765)) +- Updated the transaction list message on the token detail page to reflect the current network ([#28764](https://github.com/MetaMask/metamask-extension/pull/28764)) +- Updated the description of the setting to enable simulation to include signatures ([#28536](https://github.com/MetaMask/metamask-extension/pull/28536)) +- Reduced maximum pet name length to 12 characters ([#28660](https://github.com/MetaMask/metamask-extension/pull/28660)) +- Updated NFT token send design ([#28433](https://github.com/MetaMask/metamask-extension/pull/28433)) +- Improved design aspects of PortfolioView, including networks, sorting, and menu ([#28663](https://github.com/MetaMask/metamask-extension/pull/28663)) +- Provided maximal space for asset list filter to display "All networks" text fully and ellipsize long account names properly ([#28590](https://github.com/MetaMask/metamask-extension/pull/28590)) + +### Fixed +- [FLASK] Fixed issue where non-EVM accounts were incorrectly included in the account connection flow ([#28436](https://github.com/MetaMask/metamask-extension/pull/28436)) +- Fixed issue with detecting NFTs when switching networks on the NFT tab ([#28769](https://github.com/MetaMask/metamask-extension/pull/28769)) +- Passed decimal balance from asset page to swaps UI to ensure proper prepopulation ([#28707](https://github.com/MetaMask/metamask-extension/pull/28707)) +- Fixed issue where the incorrect native token was prepopulated in the swap UI when swapping from a different chain in PortfolioView ([#28639](https://github.com/MetaMask/metamask-extension/pull/28639)) +- Fixed issue where tokens from non-current networks were being hidden incorrectly ([#28674](https://github.com/MetaMask/metamask-extension/pull/28674)) +- Fixed market data retrieval for native tokens with non-zero addresses, such as Polygon's native token ([#28584](https://github.com/MetaMask/metamask-extension/pull/28584)) +- Fixed display issues for test networks in Portfolio View when the price checker setting is off ([#28601](https://github.com/MetaMask/metamask-extension/pull/28601)) +- Fixed account list item display for PortfolioView with and without the feature flag ([#28598](https://github.com/MetaMask/metamask-extension/pull/28598)) +- Fixed display bug on coin overview and account list item when the "Show balance and token price checker" setting is off ([#28569](https://github.com/MetaMask/metamask-extension/pull/28569)) +- Fixed styling issue affecting all dialogs by limiting it to the quotes modal ([#28739](https://github.com/MetaMask/metamask-extension/pull/28739)) +- Fixed swaps approval checking for amounts greater than 0 but less than the swap amount ([#28680](https://github.com/MetaMask/metamask-extension/pull/28680)) +- Fixed transaction flow section layout on redesigned confirmation pages ([#28720](https://github.com/MetaMask/metamask-extension/pull/28720)) +- Prevented duplicate contact names and added warnings for duplicates in the contact list ([#28249](https://github.com/MetaMask/metamask-extension/pull/28249)) +- Made QR scanner more strict about the contents it allows, fixing unexpected behavior with certain QR codes ([#28521](https://github.com/MetaMask/metamask-extension/pull/28521)) +- Fixed avatar size for the current network ([#28731](https://github.com/MetaMask/metamask-extension/pull/28731)) +- Fixed account names and length display for dApp connections ([#28725](https://github.com/MetaMask/metamask-extension/pull/28725)) ## [12.8.1] ### Fixed From 528d31c9c7566e69c75d83afb8ea8799dc8b92a6 Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Mon, 9 Dec 2024 21:20:36 +0000 Subject: [PATCH 146/148] Update Attributions --- attribution.txt | 1818 +++++++++++++---------------------------------- 1 file changed, 499 insertions(+), 1319 deletions(-) diff --git a/attribution.txt b/attribution.txt index bd4b636a2170..826ec1b5c246 100644 --- a/attribution.txt +++ b/attribution.txt @@ -1773,6 +1773,34 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +****************************** + +base-x +5.0.0 +The MIT License (MIT) + +Copyright (c) 2018 base-x contributors +Copyright (c) 2014-2018 The Bitcoin Core developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ****************************** bchaddrjs @@ -2250,10 +2278,10 @@ SOFTWARE. ****************************** bitwise -2.1.0 +2.2.1 # The MIT License (MIT) -Copyright (c) `2019` Florian Wendelborn +Copyright (c) `2023` Florian Wendelborn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2415,7 +2443,7 @@ SOFTWARE. ****************************** borc -2.1.2 +3.0.0 The MIT License (MIT) Copyright (c) 2016 Friedel Ziegelmayer @@ -2887,6 +2915,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +****************************** + +bs58 +6.0.0 +MIT License + +Copyright (c) 2018 cryptocoinjs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ****************************** bs58check @@ -2941,6 +2996,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +****************************** + +bs58check +4.0.0 +The MIT License (MIT) + +Copyright (c) 2017 Daniel Cousens + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ****************************** buffer @@ -3589,34 +3671,6 @@ public licenses. Creative Commons may be contacted at creativecommons.org. -****************************** - -case -1.6.3 -Copyright (c) 2013 Nathan Bubna - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - - ****************************** cashaddrjs @@ -3685,322 +3739,75 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ****************************** -@chainsafe/as-sha256 -0.3.1 - Copyright 2019 ChainSafe Systems +chalk +2.4.2 +MIT License - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Copyright (c) Sindre Sorhus (sindresorhus.com) - http://www.apache.org/licenses/LICENSE-2.0 +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ****************************** -@chainsafe/persistent-merkle-tree -0.4.2 - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +chalk +4.1.2 +MIT License - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Copyright (c) Sindre Sorhus (sindresorhus.com) - 1. Definitions. +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. +****************************** - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. +chalk +5.3.0 +MIT License - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. +Copyright (c) Sindre Sorhus (https://sindresorhus.com) - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. +****************************** - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. +character-entities +1.2.1 +(The MIT License) - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: +Copyright (c) 2015 Titus Wormer - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -****************************** - -@chainsafe/ssz -0.9.4 <> -Copyright 2019- ChainSafe Systems - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - -****************************** - -chalk -2.4.2 -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -****************************** - -chalk -4.1.2 -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -****************************** - -chalk -5.3.0 -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -****************************** - -character-entities -1.2.1 -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ****************************** @@ -4710,51 +4517,238 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ****************************** crc-32 -1.2.0 -Copyright (C) 2014-present SheetJS +1.2.2 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - http://www.apache.org/licenses/LICENSE-2.0 + 1. Definitions. - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -****************************** + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -create-hash -1.2.0 -The MIT License (MIT) + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -Copyright (c) 2017 crypto-browserify contributors + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. -****************************** + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (C) 2014-present SheetJS LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +****************************** + +create-hash +1.2.0 +The MIT License (MIT) + +Copyright (c) 2017 crypto-browserify contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +****************************** create-hmac 1.1.7 @@ -5245,34 +5239,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -delimit-stream -0.1.0 -Copyright (c) 2013, Jason Kuhrt -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ****************************** detect-browser @@ -5634,14 +5600,7 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE ****************************** elliptic -6.5.4 -license: MIT -authors: Fedor Indutny - -****************************** - -elliptic -6.6.1 +6.5.7 license: MIT authors: Fedor Indutny @@ -6538,7 +6497,7 @@ eslint-visitor-keys ****************************** eth-chainlist -0.0.498 +0.0.519 The MIT License (MIT) Copyright (c) 2022 Zane J. Chua @@ -6563,13 +6522,6 @@ THE SOFTWARE. ****************************** -eth-eip712-util-browser -0.0.3 -license: ISC -authors: Dan Finlay - -****************************** - eth-ens-namehash 2.0.8 license: ISC @@ -6607,32 +6559,6 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -ethereum-cryptography -1.1.2 -The MIT License (MIT) - -Copyright (c) 2021 Patricio Palladino, Paul Miller, ethereum-cryptography contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ****************************** ethereum-cryptography @@ -6662,7 +6588,7 @@ THE SOFTWARE. ****************************** @ethereumjs/common -3.1.1 +3.2.0 The MIT License (MIT) Copyright (c) 2015 @@ -6690,7 +6616,7 @@ SOFTWARE. ****************************** @ethereumjs/common -3.2.0 +4.3.0 The MIT License (MIT) Copyright (c) 2015 @@ -7504,7 +7430,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice ****************************** @ethereumjs/tx -4.1.1 +4.2.0 Mozilla Public License Version 2.0 ================================== @@ -7883,7 +7809,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice ****************************** @ethereumjs/tx -4.2.0 +5.3.0 Mozilla Public License Version 2.0 ================================== @@ -10868,25 +10794,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -exit-on-epipe -1.0.1 -Copyright (C) 2015-present SheetJS - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ****************************** extend @@ -12196,7 +12103,7 @@ SOFTWARE. ****************************** gridplus-sdk -2.5.1 +2.7.1 MIT License Copyright (c) 2019 GridPlus, Inc @@ -14063,7 +13970,7 @@ SOFTWARE. ****************************** iso-url -0.4.7 +1.2.1 MIT License Copyright (c) Hugo Dias (hugodias.me) @@ -15107,27 +15014,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -json-rpc-middleware-stream -5.0.1 -ISC License - -Copyright (c) 2020 MetaMask - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ****************************** json-rpc-random-id @@ -15165,7 +15051,7 @@ SOFTWARE. ****************************** jsonschema -1.2.4 +1.4.1 jsonschema is licensed under MIT license. Copyright (C) 2012-2015 Tom de Grunt @@ -15234,7 +15120,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** json-text-sequence -0.1.1 +0.3.0 The MIT License (MIT) Copyright (c) 2014 Joe Hildebrand @@ -17522,33 +17408,7 @@ authors: Maarten Zuidhoorn ****************************** @metamask/accounts-controller -17.2.0 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - -****************************** - -@metamask/accounts-controller -18.2.2 +20.0.0 MIT License Copyright (c) 2018 MetaMask @@ -17859,33 +17719,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/assets-controllers -43.1.1 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - -****************************** - -@metamask/base-controller -5.0.2 +45.1.0 MIT License Copyright (c) 2018 MetaMask @@ -18012,7 +17846,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/controller-utils -11.4.3 +11.4.4 MIT License Copyright (c) 2018 MetaMask @@ -18045,7 +17879,7 @@ authors: undefined ****************************** @metamask/ens-controller -13.0.1 +14.0.1 MIT License Copyright (c) 2018 MetaMask @@ -18304,33 +18138,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/eth-block-tracker -11.0.1 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - -****************************** - -@metamask/eth-block-tracker -9.0.3 +11.0.2 MIT License Copyright (c) 2018 MetaMask @@ -18569,7 +18377,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/eth-json-rpc-middleware -14.0.1 +15.0.0 ISC License Copyright (c) 2020 MetaMask @@ -18611,28 +18419,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/eth-json-rpc-provider -3.0.2 -ISC License - -Copyright (c) 2022 MetaMask - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -****************************** - -@metamask/eth-json-rpc-provider -4.1.3 +4.1.6 ISC License Copyright (c) 2022 MetaMask @@ -18879,14 +18666,31 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/eth-snap-keyring -4.4.0 -license: Custom: https://metamask.github.io/eth-snap-keyring/latest/ -authors: undefined +5.0.1 +Copyright ConsenSys Software Inc. 2022. All rights reserved. + +You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. + +Subject to the limited license below, you may not (and you may not permit anyone else to) distribute, publish, copy, modify, merge, combine with another program, create derivative works of, reverse engineer, decompile or otherwise attempt to extract the source code of, the Program or any part thereof, except that you may contribute to this repository. + +You are granted a non-exclusive, non-transferable, non-sublicensable license to distribute, publish, copy, modify, merge, combine with another program or create derivative works of the Program (such resulting program, collectively, the “Resulting Program”) solely for Non-Commercial Use as long as you: + 1. give prominent notice (“Notice”) with each copy of the Resulting Program that the Program is used in the Resulting Program and that the Program is the copyright of ConsenSys; and + 2. subject the Resulting Program and any distribution, publication, copy, modification, merger therewith, combination with another program or derivative works thereof to the same Notice requirement and Non-Commercial Use restriction set forth herein. + +“Non-Commercial Use” means each use as described in clauses (1)-(3) below, as reasonably determined by ConsenSys in its sole discretion: + 1. personal use for research, personal study, private entertainment, hobby projects or amateur pursuits, in each case without any anticipated commercial application; + 2. use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization or government institution; or + 3. the number of monthly active users of the Resulting Program across all versions thereof and platforms globally do not exceed 10,000 at any time. + +You will not use any trade mark, service mark, trade name, logo of ConsenSys or any other company or organization in a way that is likely or intended to cause confusion about the owner or authorized user of such marks, names or logos. + +If you have any questions, comments or interest in pursuing any other use cases, please reach out to us at communications@metamask.io. + ****************************** @metamask/eth-token-tracker -8.0.0 +9.0.0 ISC License Copyright (c) 2020 MetaMask @@ -18928,7 +18732,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/gas-fee-controller -18.0.0 +21.0.0 MIT License Copyright (c) 2018 MetaMask @@ -19338,27 +19142,6 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -****************************** - -@metamask/json-rpc-engine -8.0.2 -ISC License - -Copyright (c) 2022 MetaMask - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ****************************** @metamask/json-rpc-engine @@ -19401,6 +19184,30 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +****************************** + +@metamask/keyring-api +10.1.0 +Copyright ConsenSys Software Inc. 2022. All rights reserved. + +You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. + +Subject to the limited license below, you may not (and you may not permit anyone else to) distribute, publish, copy, modify, merge, combine with another program, create derivative works of, reverse engineer, decompile or otherwise attempt to extract the source code of, the Program or any part thereof, except that you may contribute to this repository. + +You are granted a non-exclusive, non-transferable, non-sublicensable license to distribute, publish, copy, modify, merge, combine with another program or create derivative works of the Program (such resulting program, collectively, the “Resulting Program”) solely for Non-Commercial Use as long as you: + 1. give prominent notice (“Notice”) with each copy of the Resulting Program that the Program is used in the Resulting Program and that the Program is the copyright of ConsenSys; and + 2. subject the Resulting Program and any distribution, publication, copy, modification, merger therewith, combination with another program or derivative works thereof to the same Notice requirement and Non-Commercial Use restriction set forth herein. + +“Non-Commercial Use” means each use as described in clauses (1)-(3) below, as reasonably determined by ConsenSys in its sole discretion: + 1. personal use for research, personal study, private entertainment, hobby projects or amateur pursuits, in each case without any anticipated commercial application; + 2. use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization or government institution; or + 3. the number of monthly active users of the Resulting Program across all versions thereof and platforms globally do not exceed 10,000 at any time. + +You will not use any trade mark, service mark, trade name, logo of ConsenSys or any other company or organization in a way that is likely or intended to cause confusion about the owner or authorized user of such marks, names or logos. + +If you have any questions, comments or interest in pursuing any other use cases, please reach out to us at communications@metamask.io. + + ****************************** @metamask/keyring-api @@ -19418,7 +19225,7 @@ authors: undefined ****************************** @metamask/keyring-controller -17.2.2 +19.0.0 MIT License Copyright (c) 2018 MetaMask @@ -19499,7 +19306,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/message-manager -10.1.0 +11.0.1 MIT License Copyright (c) 2018 MetaMask @@ -19601,33 +19408,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -****************************** - -@metamask/nonce-tracker -5.0.0 -MIT License - -Copyright (c) 2019 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ****************************** @metamask/nonce-tracker @@ -19655,36 +19435,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -@metamask/notification-controller -6.0.0 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - ****************************** @metamask/notification-services-controller -0.11.0 +0.14.0 MIT License Copyright (c) 2024 MetaMask @@ -19776,32 +19530,6 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -****************************** - -@metamask/permission-controller -10.0.0 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - ****************************** @metamask/permission-controller @@ -19831,7 +19559,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/permission-log-controller -2.0.1 +3.0.1 Copyright ConsenSys Software Inc. 2022. All rights reserved. You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. @@ -19881,7 +19609,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/polling-controller -10.0.1 +11.0.0 MIT License Copyright (c) 2018 MetaMask @@ -19932,41 +19660,15 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** -@metamask/polling-controller -8.0.0 -MIT License - -Copyright (c) 2018 MetaMask +@metamask/post-message-stream +8.1.1 +ISC License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Copyright (c) 2020 MetaMask -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - -****************************** - -@metamask/post-message-stream -8.1.1 -ISC License - -Copyright (c) 2020 MetaMask - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF @@ -20194,7 +19896,7 @@ authors: undefined ****************************** @metamask/profile-sync-controller -0.9.7 +2.0.0 MIT License Copyright (c) 2024 MetaMask @@ -20220,34 +19922,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/providers -14.0.2 -MIT License - -Copyright (c) 2020 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -****************************** - -@metamask/providers -18.1.1 +18.2.0 MIT License Copyright (c) 2020 MetaMask @@ -20454,7 +20129,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/signature-controller -21.1.0 +23.1.0 MIT License Copyright (c) 2023 MetaMask @@ -20480,21 +20155,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/slip44 -3.1.0 -license: ISC -authors: Dan Finlay - -****************************** - -@metamask/slip44 -4.0.0 +4.1.0 license: ISC authors: Dan Finlay ****************************** @metamask/smart-transactions-controller -13.0.0 +15.0.0 Copyright ConsenSys Software Inc. 2020. All rights reserved. You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. @@ -20518,7 +20186,7 @@ If you have any questions, comments or interest in pursuing any other use cases, ****************************** @metamask/snaps-controllers -9.12.0 +9.14.0 Copyright ConsenSys Software Inc. 2021. All rights reserved. You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. @@ -20542,7 +20210,7 @@ If you have any questions, comments or interest in pursuing any other use cases, ****************************** @metamask/snaps-execution-environments -6.9.2 +6.10.0 Copyright ConsenSys Software Inc. 2022. All rights reserved. You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. @@ -20775,7 +20443,7 @@ If you have any questions, comments or interest in pursuing any other use cases, ****************************** @metamask/snaps-rpc-methods -11.5.1 +11.6.0 Copyright ConsenSys Software Inc. 2021. All rights reserved. You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. @@ -20799,7 +20467,7 @@ If you have any questions, comments or interest in pursuing any other use cases, ****************************** @metamask/snaps-sdk -6.10.0 +6.12.0 ISC License Copyright (c) 2023 MetaMask @@ -20820,28 +20488,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ****************************** @metamask/snaps-utils -7.8.1 -ISC License - -Copyright (c) 2022 MetaMask - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -****************************** - -@metamask/snaps-utils -8.5.2 +8.6.0 ISC License Copyright (c) 2022 MetaMask @@ -20891,33 +20538,7 @@ authors: undefined ****************************** @metamask/transaction-controller -34.0.0 -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - - -****************************** - -@metamask/transaction-controller -38.3.0 +40.1.0 MIT License Copyright (c) 2018 MetaMask @@ -20943,7 +20564,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ****************************** @metamask/user-operation-controller -13.0.0 +16.0.0 MIT License Copyright (c) 2023 MetaMask @@ -21692,58 +21313,6 @@ THE SOFTWARE. ****************************** -@noble/hashes -1.1.2 -The MIT License (MIT) - -Copyright (c) 2022 Paul Miller (https://paulmillr.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -****************************** - -@noble/hashes -1.1.3 -The MIT License (MIT) - -Copyright (c) 2022 Paul Miller (https://paulmillr.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -****************************** - @noble/hashes 1.3.2 The MIT License (MIT) @@ -21822,11 +21391,11 @@ THE SOFTWARE. ****************************** -@noble/secp256k1 -1.6.3 +@noble/hashes +1.5.0 The MIT License (MIT) -Copyright (c) 2019 Paul Miller (https://paulmillr.com) +Copyright (c) 2022 Paul Miller (https://paulmillr.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal @@ -22486,26 +22055,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -printj -1.1.2 -Copyright (C) 2016-present SheetJS - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - ****************************** process @@ -26015,7 +25564,7 @@ THE SOFTWARE. ****************************** @scure/base -1.1.7 +1.1.9 The MIT License (MIT) Copyright (c) 2022 Paul Miller (https://paulmillr.com) @@ -26039,33 +25588,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -@scure/bip32 -1.1.0 -The MIT License (MIT) - -Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ****************************** @scure/bip32 @@ -26096,7 +25618,7 @@ THE SOFTWARE. ****************************** @scure/bip39 -1.1.0 +1.3.0 The MIT License (MIT) Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com) @@ -26122,14 +25644,17 @@ THE SOFTWARE. ****************************** -@scure/bip39 -1.3.0 +secp256k1 +4.0.4 The MIT License (MIT) -Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com) +Copyright (c) 2014-2016 secp256k1-node contributors + +Parts of this software are based on bn.js, elliptic, hash.js +Copyright (c) 2014-2016 Fedor Indutny Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal +of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is @@ -26138,7 +25663,7 @@ furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -26150,7 +25675,7 @@ THE SOFTWARE. ****************************** secp256k1 -4.0.4 +5.0.1 The MIT License (MIT) Copyright (c) 2014-2016 secp256k1-node contributors @@ -27182,6 +26707,13 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +****************************** + +@sovpro/delimited-stream +1.1.0 +license: MIT +authors: sovpro + ****************************** space-separated-tokens @@ -27686,21 +27218,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -****************************** - -superstruct -1.0.3 -The MIT License - -Copyright © 2017, [Ian Storm Taylor](https://ianstormtaylor.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ****************************** superstruct @@ -29694,6 +29211,33 @@ authors: Mohamed Hegazy SOFTWARE +****************************** + +@types/uuid +10.0.0 + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + + ****************************** @types/uuid @@ -30488,7 +30032,7 @@ SOFTWARE. ****************************** uuid -8.3.2 +10.0.0 The MIT License (MIT) Copyright (c) 2010-2020 Robert Kieffer and other contributors @@ -30503,7 +30047,22 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ****************************** uuid -9.0.1 +8.3.2 +The MIT License (MIT) + +Copyright (c) 2010-2020 Robert Kieffer and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +****************************** + +uuid +9.0.1 The MIT License (MIT) Copyright (c) 2010-2020 Robert Kieffer and other contributors @@ -30757,385 +30316,6 @@ web-encoding license: MIT authors: Irakli Gozalishvili -****************************** - -webextension-polyfill -0.10.0 -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. - - ****************************** webextension-polyfill From 61ee2a6f944b0b9155ca9c3f26df4b7d3ef91a83 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Mon, 9 Dec 2024 18:56:11 -0330 Subject: [PATCH 147/148] Lint fix for the v12.9.0 changelog (#29038) --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4b24b8a1d3..2d7b03f6751d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [12.9.0] - ### Added - Added error handling to ensure users are not redirected to an incorrect network when sending or swapping tokens ([#28740](https://github.com/MetaMask/metamask-extension/pull/28740)) - Added optional chaining to currency rates check for improved stability ([#28753](https://github.com/MetaMask/metamask-extension/pull/28753)) From 8d86aeafd725f0c04a934385c93b484ed16b01c4 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Mon, 9 Dec 2024 20:13:54 -0330 Subject: [PATCH 148/148] =?UTF-8?q?chore:=20Skip=20flaky=20smart-transacti?= =?UTF-8?q?ons.spec.ts=20until=20we=20determine=20the=20r=E2=80=A6=20(#290?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picks 64a69f709834947c4f459206ade1e2f856ea787464a69f709834947c4f459206ade1e2f856ea7874 (https://github.com/MetaMask/metamask-extension/pull/28943) to v12.9.0, to get the flaky test passing --- test/e2e/tests/smart-transactions/smart-transactions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts index 36324b9ea797..b2bb04a68b44 100644 --- a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts +++ b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts @@ -64,7 +64,7 @@ export const waitForTransactionToComplete = async ( }; describe('smart transactions @no-mmi', function () { - it('Completes a Swap', async function () { + it.skip('Completes a Swap', async function () { await withFixturesForSmartTransactions( { title: this.test?.fullTitle(),

{)LCLh|H1ko=-#Plv$?al5(a?X&IO;k7&Kl%e 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 TGZ8;@IXwp@1{?neO5oSrb3o-; diff --git a/test/e2e/mock-cdn/ppom-version-headers.json b/test/e2e/mock-cdn/ppom-version-headers.json index ad50d161d1dd..1ab52017d4a0 100644 --- a/test/e2e/mock-cdn/ppom-version-headers.json +++ b/test/e2e/mock-cdn/ppom-version-headers.json @@ -1,3 +1,3 @@ { - "Etag": "W/\"7aa74f7c18a5cb2601e4fc6afcadc9cc\"" + "Etag": "W/\"0b264c1a98f3bb20ea7741b37850b69a\"" } diff --git a/test/e2e/mock-cdn/ppom-version.json b/test/e2e/mock-cdn/ppom-version.json index b529f71a0f1c..59216b0ce281 100644 --- a/test/e2e/mock-cdn/ppom-version.json +++ b/test/e2e/mock-cdn/ppom-version.json @@ -152,302 +152,302 @@ { "name": "stale_diff", "chainId": "0x38", - "version": "0.0.482", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x38/0.0.482", - "timestamp": "2024-10-27T12:49:57.410484" + "version": "0.0.483", + "checksum": "b979504b7de98aad15400df3e366ab734725c11a30d4f893c7dc9ac07bdc230a", + "signature": "1333191445c23cebd097f6405d50298f7866fda9f209522054292d66f2c01d227f9663597976af133dfcfe16fcd325d8c65ac51345863a5412211b979f1fc80e", + "hashSignature": "ce019245ed127bbb5c0cff6c06d7485ed4eb79df1870b4f513e75e230ee6719637bdcb7fc22046315ccb8d88996ab77edd7cc53ffa359740d66f3ef4f6260e00", + "filePath": "stale_diff/0x38/0.0.483", + "timestamp": "2024-10-28T13:04:21.808185" }, { "name": "config", "chainId": "0x38", - "version": "0.0.482", + "version": "0.0.483", "checksum": "e29783f8be2392ee395055382aa7b19a78564f99915bb749492577712c18c6f0", "signature": "0e4e28c1e10d8e0243fd6009787dc5742a76cc5460c2565459094aa112fe2cbddb62001b8d5771ed75e836d842f87baf2a8bdc099003797ebc6c7a80079f4701", "hashSignature": "ac2ba4a855e4e271f1ce7f4f6b67ac1b10a56378ab3f31a638aff4d5f5ccccc9385d976eec2fb304427810995ed22bd755adac62d15d4fcf6fd4934ee3883d00", - "filePath": "config/0x38/0.0.482", - "timestamp": "2024-10-27T12:49:57.410737" + "filePath": "config/0x38/0.0.483", + "timestamp": "2024-10-28T13:04:21.808484" }, { "name": "stale_diff", "chainId": "0x1", - "version": "0.0.525", - "checksum": "ae3059765d220e8cda72aa43638916b9baac84f264a39a1d147a5b144378df62", - "signature": "3ed03cfa8bee704816a9ceb270fda86d7b007f0fe510d6acc40f96b15819c114fbd768d8845d75ab803c981eb151b4b0a24af705a27e1f96381bdc6dc5e3b50f", - "hashSignature": "9394bc16a948ab41ee74c0270a900092cbb8707fe72d3576fd75f0b87c698089c0a10b45a20ea47691a90cee427e605f81838b87424291902a9b54fec19e0709", - "filePath": "stale_diff/0x1/0.0.525", - "timestamp": "2024-10-27T12:50:03.871663" + "version": "0.0.526", + "checksum": "d0182b5ad9ce2bdf7c237fdf344e25c83eaab132e770db5d259f50c98894249e", + "signature": "51403083c28a827d20a2d998a8f25e6ed64e79fa8a330e7760a4090987c0162ab14b0ed12c4bb9c82dfe2d198e242e4211b7c882ae64bce4bdbcc37cca68990e", + "hashSignature": "b6196a6ef87f47fcbf91d84367a26e05491ef1ca7d7204f7cd4e2f78edbdacff842b3189ff4a5c73ff7eae3f10f1d0d2f677954658556c5af2add0176daa1b00", + "filePath": "stale_diff/0x1/0.0.526", + "timestamp": "2024-10-28T13:04:33.812986" }, { "name": "config", "chainId": "0x1", - "version": "0.0.525", + "version": "0.0.526", "checksum": "abe69e1c8f6084d26b1d556bb3ae4919628ac4bf3b468bea95e3c14254bdf290", "signature": "52ffaf9e1a543f8164ea93558467f7f4e02c15650daf92f1a1e374848c53b91dcca96037fd6d7bd63b13e7fcf88a1bcc9fe7c7915d8d6949bd153e6bf6b1a403", "hashSignature": "83c1edb28635049e4c99d8610782506818ef071de95df49f9242f229991924b4ea829782b0ac125de3f763fc7415baaebf3732a920fb4d01861e1fdd5cb86207", - "filePath": "config/0x1/0.0.525", - "timestamp": "2024-10-27T12:50:03.871914" + "filePath": "config/0x1/0.0.526", + "timestamp": "2024-10-28T13:04:33.813277" }, { "name": "stale_diff", "chainId": "0x89", - "version": "0.0.481", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x89/0.0.481", - "timestamp": "2024-10-27T12:50:09.561729" + "version": "0.0.482", + "checksum": "5cd3b461f37480a0f84c5769156ed4d362f0dade06c1bf3fc266ed3de7fadb6d", + "signature": "5f049aab6c8a5c18dcdf1d730b1c306ae3728f288af4bcc22e3cc6a243bbe2673444a6e4316b2dbd084e471aec85f102f864011c1e7438367b243b43d1b3e604", + "hashSignature": "78ae36e78e6c3c3d2aa48a86c19cbd667b907c1ada62aaac8a34e3982be16ec9727041264bf61d445469de2fd56a94f8c4289e17a560f25cb31240c8d41ca303", + "filePath": "stale_diff/0x89/0.0.482", + "timestamp": "2024-10-28T13:04:41.430575" }, { "name": "config", "chainId": "0x89", - "version": "0.0.481", + "version": "0.0.482", "checksum": "eb3b41ae8c3bbf9595dcded8e1b9090c8ed2427e236375652f6fd701c6e134b4", "signature": "79552c7fa525e05c9c086fe8d8ecb49375be796176877e17332fa1137d4c653f224873bdac2b6fd4fe63fcfe6d404778684116e98fdb0563f63ae1efcfafe60d", "hashSignature": "9c8a84e430578290eceea60ba7dec3695e0cfd343f7ac3e2667c8634afa3dd65d6ec25b112ac8260dbe3e6bc9e00ba906c4212975652354b1423f6ee0ec7b20e", - "filePath": "config/0x89/0.0.481", - "timestamp": "2024-10-27T12:50:09.561989" + "filePath": "config/0x89/0.0.482", + "timestamp": "2024-10-28T13:04:41.430884" }, { "name": "stale_diff", "chainId": "0xa", - "version": "0.0.481", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xa/0.0.481", - "timestamp": "2024-10-27T12:50:15.189820" + "version": "0.0.482", + "checksum": "8f41a8616fc5fb1e55de136d6e0aef1e834049a1452e148325bbbb059233acb8", + "signature": "e7e1a80c48a32902cc526dae4ae8e05980f059de0447bfbfe533c26ff03f8721c004e4f35888708964a19bebc5f0d91753f741dabe0418a0d8a8af38f71e0406", + "hashSignature": "056b583909986fee8cb115d2a0827a704e391b8a3f3392d9d42be17e60344b397fab2ae31561c4211a67dfcf6af86b1e22facac7c47393cb3b978a9396888d0a", + "filePath": "stale_diff/0xa/0.0.482", + "timestamp": "2024-10-28T13:04:49.257520" }, { "name": "config", "chainId": "0xa", - "version": "0.0.481", + "version": "0.0.482", "checksum": "eb3b41ae8c3bbf9595dcded8e1b9090c8ed2427e236375652f6fd701c6e134b4", "signature": "79552c7fa525e05c9c086fe8d8ecb49375be796176877e17332fa1137d4c653f224873bdac2b6fd4fe63fcfe6d404778684116e98fdb0563f63ae1efcfafe60d", "hashSignature": "9c8a84e430578290eceea60ba7dec3695e0cfd343f7ac3e2667c8634afa3dd65d6ec25b112ac8260dbe3e6bc9e00ba906c4212975652354b1423f6ee0ec7b20e", - "filePath": "config/0xa/0.0.481", - "timestamp": "2024-10-27T12:50:15.190077" + "filePath": "config/0xa/0.0.482", + "timestamp": "2024-10-28T13:04:49.257852" }, { "name": "stale_diff", "chainId": "0xa4b1", - "version": "0.0.481", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xa4b1/0.0.481", - "timestamp": "2024-10-27T12:50:21.791169" + "version": "0.0.482", + "checksum": "d7de3157af44ddbb82ca8913f6396a4690e61f6d7179ed65b0cc2bf591b6da3a", + "signature": "d3c8a37b8778f2db1e91a49839f1b0b24f2c96321e7b0c659d947c360f68dd61ea83e3983ee116fd2fc23fb78b8fee1dc9e00e89463c86b8d5c06dd36cd0110e", + "hashSignature": "327ca5b7af1931368881c98f762741f54152c75e06878e89bb341eed1095a58ca88fccd04eb6a1018c7d539947b0bbec37bb60a5c6eadb0221865b1d59d4030f", + "filePath": "stale_diff/0xa4b1/0.0.482", + "timestamp": "2024-10-28T13:05:00.218564" }, { "name": "config", "chainId": "0xa4b1", - "version": "0.0.481", + "version": "0.0.482", "checksum": "eb3b41ae8c3bbf9595dcded8e1b9090c8ed2427e236375652f6fd701c6e134b4", "signature": "79552c7fa525e05c9c086fe8d8ecb49375be796176877e17332fa1137d4c653f224873bdac2b6fd4fe63fcfe6d404778684116e98fdb0563f63ae1efcfafe60d", "hashSignature": "9c8a84e430578290eceea60ba7dec3695e0cfd343f7ac3e2667c8634afa3dd65d6ec25b112ac8260dbe3e6bc9e00ba906c4212975652354b1423f6ee0ec7b20e", - "filePath": "config/0xa4b1/0.0.481", - "timestamp": "2024-10-27T12:50:21.791427" + "filePath": "config/0xa4b1/0.0.482", + "timestamp": "2024-10-28T13:05:00.218867" }, { "name": "stale_diff", "chainId": "0xa86a", - "version": "0.0.481", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xa86a/0.0.481", - "timestamp": "2024-10-27T12:50:27.602732" + "version": "0.0.482", + "checksum": "88ab495f0dc39bb9bb0d607b562ef8b16ededfd48aab7b5825126b4f19c02ccf", + "signature": "84e1b810e93bd3cd0732ffbdf9f7003140da90758c5ebbefc9516979bb47b3c9a4117e757883c4089c44f165f507d43885700bb50e6126fb037e1f62b8e75708", + "hashSignature": "b38ce4c3a012de90e8949df5b32a8cf74a3a9edf2d602197d01ebfd4b1cd8fd32defbb0d363f93abf8241cd7224614723cb7fba7e744fccd82bee3b082138e0b", + "filePath": "stale_diff/0xa86a/0.0.482", + "timestamp": "2024-10-28T13:05:09.315523" }, { "name": "config", "chainId": "0xa86a", - "version": "0.0.481", + "version": "0.0.482", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0xa86a/0.0.481", - "timestamp": "2024-10-27T12:50:27.602991" + "filePath": "config/0xa86a/0.0.482", + "timestamp": "2024-10-28T13:05:09.315795" }, { "name": "stale_diff", "chainId": "0xe708", - "version": "0.0.435", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xe708/0.0.435", - "timestamp": "2024-10-27T12:50:39.077328" + "version": "0.0.436", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0xe708/0.0.436", + "timestamp": "2024-10-28T13:05:28.153528" }, { "name": "config", "chainId": "0xe708", - "version": "0.0.435", + "version": "0.0.436", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0xe708/0.0.435", - "timestamp": "2024-10-27T12:50:39.077595" + "filePath": "config/0xe708/0.0.436", + "timestamp": "2024-10-28T13:05:28.153794" }, { "name": "stale_diff", "chainId": "0x2105", - "version": "0.0.370", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x2105/0.0.370", - "timestamp": "2024-10-27T12:50:33.715820" + "version": "0.0.371", + "checksum": "2ac83e8d092ae95bdcc2e8078e458f0c3f2ea9bf46c00fb40b37b070bb92dc63", + "signature": "6c04fcdc49fd49502da3a73d6e3c721004d40eec65f2dec02cfbe8a2a5f253d3747d85253894508bba57d850d647707b26f2c78a5dfbff8038985ae413474f05", + "hashSignature": "d791983edf3b111bf4448acc6b54cf4f9072358c7748a6e247768899d0ae36c1cdc70c45caa9e8c6d34f151256792e365f9224eef236610067c4489d48bc840f", + "filePath": "stale_diff/0x2105/0.0.371", + "timestamp": "2024-10-28T13:05:19.542338" }, { "name": "config", "chainId": "0x2105", - "version": "0.0.370", + "version": "0.0.371", "checksum": "eb3b41ae8c3bbf9595dcded8e1b9090c8ed2427e236375652f6fd701c6e134b4", "signature": "79552c7fa525e05c9c086fe8d8ecb49375be796176877e17332fa1137d4c653f224873bdac2b6fd4fe63fcfe6d404778684116e98fdb0563f63ae1efcfafe60d", "hashSignature": "9c8a84e430578290eceea60ba7dec3695e0cfd343f7ac3e2667c8634afa3dd65d6ec25b112ac8260dbe3e6bc9e00ba906c4212975652354b1423f6ee0ec7b20e", - "filePath": "config/0x2105/0.0.370", - "timestamp": "2024-10-27T12:50:33.716077" + "filePath": "config/0x2105/0.0.371", + "timestamp": "2024-10-28T13:05:19.542688" }, { "name": "stale_diff", "chainId": "0xaa36a7", - "version": "0.0.289", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xaa36a7/0.0.289", - "timestamp": "2024-10-27T12:50:44.187191" + "version": "0.0.290", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0xaa36a7/0.0.290", + "timestamp": "2024-10-28T13:05:35.180841" }, { "name": "config", "chainId": "0xaa36a7", - "version": "0.0.289", + "version": "0.0.290", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0xaa36a7/0.0.289", - "timestamp": "2024-10-27T12:50:44.187491" + "filePath": "config/0xaa36a7/0.0.290", + "timestamp": "2024-10-28T13:05:35.181112" }, { "name": "stale_diff", "chainId": "0xcc", - "version": "0.0.238", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0xcc/0.0.238", - "timestamp": "2024-10-27T12:50:49.423599" + "version": "0.0.239", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0xcc/0.0.239", + "timestamp": "2024-10-28T13:05:42.918569" }, { "name": "config", "chainId": "0xcc", - "version": "0.0.238", + "version": "0.0.239", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0xcc/0.0.238", - "timestamp": "2024-10-27T12:50:49.423903" + "filePath": "config/0xcc/0.0.239", + "timestamp": "2024-10-28T13:05:42.918850" }, { "name": "stale_diff", "chainId": "0x0", - "version": "0.0.179", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x0/0.0.179", - "timestamp": "2024-10-27T12:50:57.113651" + "version": "0.0.180", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x0/0.0.180", + "timestamp": "2024-10-28T13:05:51.610174" }, { "name": "config", "chainId": "0x0", - "version": "0.0.179", + "version": "0.0.180", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x0/0.0.179", - "timestamp": "2024-10-27T12:50:57.113910" + "filePath": "config/0x0/0.0.180", + "timestamp": "2024-10-28T13:05:51.610489" }, { "name": "stale_diff", "chainId": "0x144", - "version": "0.0.258", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x144/0.0.258", - "timestamp": "2024-10-27T12:51:10.842031" + "version": "0.0.259", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x144/0.0.259", + "timestamp": "2024-10-28T13:05:59.914265" }, { "name": "config", "chainId": "0x144", - "version": "0.0.258", + "version": "0.0.259", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x144/0.0.258", - "timestamp": "2024-10-27T12:51:10.842292" + "filePath": "config/0x144/0.0.259", + "timestamp": "2024-10-28T13:05:59.914582" }, { "name": "stale_diff", "chainId": "0x82750", - "version": "0.0.180", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x82750/0.0.180", - "timestamp": "2024-10-27T12:51:16.756456" + "version": "0.0.181", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x82750/0.0.181", + "timestamp": "2024-10-28T13:06:08.538356" }, { "name": "config", "chainId": "0x82750", - "version": "0.0.180", + "version": "0.0.181", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x82750/0.0.180", - "timestamp": "2024-10-27T12:51:16.756829" + "filePath": "config/0x82750/0.0.181", + "timestamp": "2024-10-28T13:06:08.538665" }, { "name": "stale_diff", "chainId": "0x1b58", - "version": "0.0.180", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x1b58/0.0.180", - "timestamp": "2024-10-27T12:51:23.355191" + "version": "0.0.181", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x1b58/0.0.181", + "timestamp": "2024-10-28T13:06:16.179180" }, { "name": "config", "chainId": "0x1b58", - "version": "0.0.180", + "version": "0.0.181", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x1b58/0.0.180", - "timestamp": "2024-10-27T12:51:23.355470" + "filePath": "config/0x1b58/0.0.181", + "timestamp": "2024-10-28T13:06:16.179453" }, { "name": "stale_diff", "chainId": "0x138d5", - "version": "0.0.180", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x138d5/0.0.180", - "timestamp": "2024-10-27T12:51:29.701505" + "version": "0.0.181", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x138d5/0.0.181", + "timestamp": "2024-10-28T13:06:24.630573" }, { "name": "config", "chainId": "0x138d5", - "version": "0.0.180", + "version": "0.0.181", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x138d5/0.0.180", - "timestamp": "2024-10-27T12:51:29.701771" + "filePath": "config/0x138d5/0.0.181", + "timestamp": "2024-10-28T13:06:24.630854" }, { "name": "stale", @@ -462,42 +462,42 @@ { "name": "stale_diff", "chainId": "0x1b6e6", - "version": "0.0.114", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x1b6e6/0.0.114", - "timestamp": "2024-10-27T12:51:43.249992" + "version": "0.0.115", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x1b6e6/0.0.115", + "timestamp": "2024-10-28T13:06:41.431736" }, { "name": "config", "chainId": "0x1b6e6", - "version": "0.0.114", + "version": "0.0.115", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x1b6e6/0.0.114", - "timestamp": "2024-10-27T12:51:43.250277" + "filePath": "config/0x1b6e6/0.0.115", + "timestamp": "2024-10-28T13:06:41.432043" }, { "name": "stale_diff", "chainId": "0x138d4", - "version": "0.0.75", - "checksum": "3bab009ec87420bb6de56069cb3b76614c891688beb233bfe408aa60757478fb", - "signature": "481f19700f7399df4eefc8162e0229ea2a81fbfcbd6c327fac4ad79aaa7ac3aa8215b36bbdc74986c008cd60ffec428aa7ea04cd166faad938e1f9f4cef4b804", - "hashSignature": "f4991d29cefb09343ed5b3332416e2bab7428499373ea4bb0f6dd9bc1955eed33f80f383f70350a74ccddbea07e52eb4a144559626214a6c0f8c726b28be2f03", - "filePath": "stale_diff/0x138d4/0.0.75", - "timestamp": "2024-10-27T12:51:36.220430" + "version": "0.0.76", + "checksum": "bf5bd9d3a9db56228750731c953aed9ba51ac879cd9b5f65ed5076051b5a6754", + "signature": "ae2fe7d9b1655ebc126684570c00579e2cf0f34f1af999beb6ff83d38d770060674b0c41477809d2cd621ffc5dc6bdbb1240e00644fd3ed15527ac951462cf08", + "hashSignature": "0610331fd64fa3b95bb269329979583f96bbaf4c7298fc76e3dba09a877c28af7d7a0ecab4e1e8bcae0173c917c2c09949210fd7a3582120110c500a61e89f02", + "filePath": "stale_diff/0x138d4/0.0.76", + "timestamp": "2024-10-28T13:06:31.754098" }, { "name": "config", "chainId": "0x138d4", - "version": "0.0.75", + "version": "0.0.76", "checksum": "3e1772693c4e2fa91ae00c4b79d546f36e97525daa60baab372c145e979e19f4", "signature": "f56c9387e07892aceb1018db6dc7e32cce0528c131b6ac1822e12e87dd43d40fdb9f3e17a9c6d7fd25a095e000db19acaf3f93c42142c48f35b06f07998f0f0e", "hashSignature": "ae3fbbe3f87e48e537e9c1c9601fa5706ed72145250e5f4a2d7a4902d59e2c770c7c1ef88bde15505fa6bcbf24f85ef1b5ad48b76447a3479f7f7a462f082302", - "filePath": "config/0x138d4/0.0.75", - "timestamp": "2024-10-27T12:51:36.220693" + "filePath": "config/0x138d4/0.0.76", + "timestamp": "2024-10-28T13:06:31.754438" }, { "name": "stale", diff --git a/test/e2e/tests/ppom/mocks/mock-server-json-rpc.ts b/test/e2e/tests/ppom/mocks/mock-server-json-rpc.ts index ce197e462c80..550dc5f2acd9 100644 --- a/test/e2e/tests/ppom/mocks/mock-server-json-rpc.ts +++ b/test/e2e/tests/ppom/mocks/mock-server-json-rpc.ts @@ -14,6 +14,10 @@ type RequestConfig = [ // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any result?: any; + /** optional result value returned in JSON response */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error?: any; }, ]; @@ -48,14 +52,21 @@ export async function mockServerJsonRpc( listOfRequestConfigs: RequestConfig[], ) { for (const [method, options] of listOfRequestConfigs) { - const { methodResultVariant, params, result: _result } = options || {}; + const { + methodResultVariant, + params, + result: _result, + error: _error, + } = options || {}; const result = _result || + _error || mockJsonRpcResult[method][methodResultVariant || DEFAULT_VARIANT]; await mockServer - .forPost() + .forPost(/infura/u) + .always() .withJsonBodyIncluding(params ? { method, params } : { method }) // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/e2e/tests/ppom/ppom-blockaid-alert-contract-interaction.spec.js b/test/e2e/tests/ppom/ppom-blockaid-alert-contract-interaction.spec.js new file mode 100644 index 000000000000..e35c23f22c49 --- /dev/null +++ b/test/e2e/tests/ppom/ppom-blockaid-alert-contract-interaction.spec.js @@ -0,0 +1,238 @@ +const FixtureBuilder = require('../../fixture-builder'); + +const { + WINDOW_TITLES, + defaultGanacheOptions, + openDapp, + unlockWallet, + withFixtures, +} = require('../../helpers'); +const { mockServerJsonRpc } = require('./mocks/mock-server-json-rpc'); + +async function mockInfura(mockServer) { + await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_estimateGas'], + [ + 'eth_call', + { + params: [ + { + to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + data: '0xf0002ea90000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005cfe73b6021e818b776b421b1c4db2474086a7e100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000', + }, + ], + result: + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000006c8aafe1077a8', + }, + ], + [ + 'eth_call', + { + params: [ + { + to: '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', + }, + ], + error: { + code: -32000, + message: 'execution reverted', + }, + }, + ], + [ + 'eth_call', + { + params: [ + { + to: '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', + }, + ], + error: { + code: -32000, + message: 'execution reverted', + }, + }, + ], + [ + 'eth_call', + { + params: [ + { + to: '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + data: '0x95d89b41', + }, + ], + error: { + code: -32000, + message: 'execution reverted', + }, + }, + ], + [ + 'eth_call', + { + params: [ + { + to: '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + data: '0x313ce567', + }, + ], + error: { + code: -32000, + message: 'execution reverted', + }, + }, + ], + [ + 'eth_getStorageAt', + { + params: [ + '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + '0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3', + ], + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + ], + [ + 'eth_getStorageAt', + { + params: [ + '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc', + ], + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + ], + ['eth_getBlockByNumber'], + ['eth_getBalance'], + [ + 'eth_getCode', + { + params: ['0x00008f1149168c1d2fa1eba1ad3e9cd644510000'], + result: + '0x6080604052600436106100545760003560e01c8062f714ce1461005957806312065fe0146100825780633158952e146100ad578063715018a6146100b75780638da5cb5b146100ce578063f2fde38b146100f9575b600080fd5b34801561006557600080fd5b50610080600480360381019061007b91906104d4565b610122565b005b34801561008e57600080fd5b50610097610227565b6040516100a49190610523565b60405180910390f35b6100b561022f565b005b3480156100c357600080fd5b506100cc610231565b005b3480156100da57600080fd5b506100e3610245565b6040516100f0919061054d565b60405180910390f35b34801561010557600080fd5b50610120600480360381019061011b9190610568565b61026e565b005b61012a6102f1565b4782111561016d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161016490610618565b60405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036101dc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101d3906106aa565b60405180910390fd5b8073ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f19350505050158015610222573d6000803e3d6000fd5b505050565b600047905090565b565b6102396102f1565b610243600061036f565b565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6102766102f1565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036102e5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016102dc9061073c565b60405180910390fd5b6102ee8161036f565b50565b6102f9610433565b73ffffffffffffffffffffffffffffffffffffffff16610317610245565b73ffffffffffffffffffffffffffffffffffffffff161461036d576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610364906107a8565b60405180910390fd5b565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050816000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508173ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e060405160405180910390a35050565b600033905090565b600080fd5b6000819050919050565b61045381610440565b811461045e57600080fd5b50565b6000813590506104708161044a565b92915050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006104a182610476565b9050919050565b6104b181610496565b81146104bc57600080fd5b50565b6000813590506104ce816104a8565b92915050565b600080604083850312156104eb576104ea61043b565b5b60006104f985828601610461565b925050602061050a858286016104bf565b9150509250929050565b61051d81610440565b82525050565b60006020820190506105386000830184610514565b92915050565b61054781610496565b82525050565b6000602082019050610562600083018461053e565b92915050565b60006020828403121561057e5761057d61043b565b5b600061058c848285016104bf565b91505092915050565b600082825260208201905092915050565b7f52657175657374656420616d6f756e7420657863656564732074686520636f6e60008201527f74726163742062616c616e63652e000000000000000000000000000000000000602082015250565b6000610602602e83610595565b915061060d826105a6565b604082019050919050565b60006020820190508181036000830152610631816105f5565b9050919050565b7f526563697069656e7420616464726573732063616e6e6f74206265207468652060008201527f7a65726f20616464726573732e00000000000000000000000000000000000000602082015250565b6000610694602d83610595565b915061069f82610638565b604082019050919050565b600060208201905081810360008301526106c381610687565b9050919050565b7f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160008201527f6464726573730000000000000000000000000000000000000000000000000000602082015250565b6000610726602683610595565b9150610731826106ca565b604082019050919050565b6000602082019050818103600083015261075581610719565b9050919050565b7f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572600082015250565b6000610792602083610595565b915061079d8261075c565b602082019050919050565b600060208201905081810360008301526107c181610785565b905091905056fea2646970667358221220ac74f30418aa2326105b7dea03d605de28d6069773bd4b434837ceb2008a023a64736f6c63430008130033', + }, + ], + [ + 'eth_getTransactionCount', + { + params: ['0x5cfe73b6021e818b776b421b1c4db2474086a7e1'], + result: '0x0', + }, + ], + ]); + + await mockServer + .forPost(/infura/u) + .withJsonBodyIncluding({ + method: 'debug_traceCall', + params: [ + { + accessList: [], + data: '0xef5cfb8c0000000000000000000000000b3e87a076ac4b0d1975f0f232444af6deb96c59', + from: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + gas: '0x1c9c380', + maxFeePerGas: '0x1fc3f678c', + maxPriorityFeePerGas: '0x0', + to: '0x00008f1149168c1d2fa1eba1ad3e9cd644510000', + type: '0x02', + }, + ], + }) + .thenCallback(async (req) => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: (await req.body.getJson()).id, + error: { + code: -32601, + message: + 'The method debug_traceCall does not exist/is not available', + }, + }, + }; + }); + + await mockServer + .forGet('https://www.4byte.directory/api/v1/signatures/') + .always() + .withQuery({ hex_signature: '0xef5cfb8c' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 187294, + created_at: '2021-05-12T10:20:16.502438Z', + text_signature: 'claimRewards(address)', + hex_signature: '0xef5cfb8c', + bytes_signature: 'ï\\ûŒ', + }, + ], + }, + })); +} + +describe('PPOM Blockaid Alert - Malicious Contract interaction @no-mmi', function () { + it('should show banner alert', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .withPermissionControllerConnectedToTestDapp({ + useLocalhostHostname: true, + }) + .withPreferencesController({ + securityAlertsEnabled: true, + preferences: { + redesignedTransactionsEnabled: true, + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + defaultGanacheOptions, + testSpecificMock: mockInfura, + title: this.test.fullTitle(), + }, + + async ({ driver }) => { + await unlockWallet(driver); + await openDapp(driver, null, 'http://localhost:8080'); + + const expectedTitle = 'This is a deceptive request'; + const expectedDescription = + 'If you approve this request, a third party known for scams will take all your assets.'; + + // Click TestDapp button to send JSON-RPC request + await driver.clickElement('#maliciousContractInteractionButton'); + + // Wait for confirmation pop-up + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.assertElementNotPresent('.loading-indicator'); + + await driver.findElement({ + css: '[data-testid="confirm-banner-alert"]', + text: expectedTitle, + }); + + await driver.findElement({ + css: '[data-testid="confirm-banner-alert"]', + text: expectedDescription, + }); + }, + ); + }); +}); From b148c25974c962701bb2ccb6bfa263506be28b99 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Mon, 25 Nov 2024 20:42:02 +0530 Subject: [PATCH 066/148] feat: change description of enabling simulation message in settings (#28536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Change description of setting top enable simulation to include signatures. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3642 ## **Manual testing steps** 1. Go to settings page 2. Check setting to enable simulation ## **Screenshots/Recordings** Screenshot 2024-11-19 at 3 39 22 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 - [ ] 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 --- app/_locales/de/messages.json | 3 --- app/_locales/el/messages.json | 3 --- app/_locales/en/messages.json | 2 +- app/_locales/es/messages.json | 3 --- app/_locales/fr/messages.json | 3 --- app/_locales/hi/messages.json | 3 --- app/_locales/id/messages.json | 3 --- app/_locales/ja/messages.json | 3 --- app/_locales/ko/messages.json | 3 --- app/_locales/pt/messages.json | 3 --- app/_locales/ru/messages.json | 3 --- app/_locales/tl/messages.json | 3 --- app/_locales/tr/messages.json | 3 --- app/_locales/vi/messages.json | 3 --- app/_locales/zh_CN/messages.json | 3 --- .../security-tab/__snapshots__/security-tab.test.js.snap | 2 +- 16 files changed, 2 insertions(+), 44 deletions(-) diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 172fd05a5a73..0c8ba19030f2 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Wir konnten das Gas nicht schätzen. Es könnte einen Fehler im Contract geben und diese Transaktion könnte fehlschlagen." }, - "simulationsSettingDescription": { - "message": "Schalten Sie diese Option ein, um die Saldoänderungen von Transaktionen zu schätzen, bevor Sie diese bestätigen. Dies ist keine Garantie für das endgültige Ergebnis Ihrer Transaktionen. $1" - }, "simulationsSettingSubHeader": { "message": "Geschätzte Saldoänderungen" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 132265ee4167..31cd6809080b 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Δεν ήμασταν σε θέση να εκτιμήσουμε το τέλος συναλλαγής. Μπορεί να υπάρχει σφάλμα στο συμβόλαιο και η συναλλαγή αυτή να αποτύχει." }, - "simulationsSettingDescription": { - "message": "Ενεργοποιήστε το για να εκτιμήσετε τις αλλαγές στο υπόλοιπο των συναλλαγών πριν τις επιβεβαιώσετε. Αυτό δεν εγγυάται το τελικό αποτέλεσμα στις συναλλαγές σας. $1" - }, "simulationsSettingSubHeader": { "message": "Εκτίμηση μεταβολών υπολοίπου" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 060ba2a43dca..39c1b20d1a52 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5085,7 +5085,7 @@ "message": "We were not able to estimate gas. There might be an error in the contract and this transaction may fail." }, "simulationsSettingDescription": { - "message": "Turn this on to estimate balance changes of transactions before you confirm them. This doesn't guarantee the final outcome of your transactions. $1" + "message": "Turn this on to estimate balance changes of transactions and signatures before you confirm them. This doesn't guarantee their final outcome. $1" }, "simulationsSettingSubHeader": { "message": "Estimate balance changes" diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 746c099ae75e..f2afe012534a 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "No pudimos estimar el gas. Podría haber un error en el contrato y esta transacción podría fallar." }, - "simulationsSettingDescription": { - "message": "Active esta opción para estimar los cambios de saldo de las transacciones antes de confirmarlas. Esto no garantiza el resultado final de sus transacciones. $1" - }, "simulationsSettingSubHeader": { "message": "Estimar cambios de saldo" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index bcfa71f5661b..99c3a6da2333 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Nous n’avons pas pu estimer le prix de carburant. Par conséquent, il se peut qu’il y ait une erreur dans le contrat et que cette transaction échoue." }, - "simulationsSettingDescription": { - "message": "Activez cette option pour estimer les changements de solde des transactions avant de les confirmer. Cela ne garantit pas le résultat final de vos transactions. $1" - }, "simulationsSettingSubHeader": { "message": "Estimer les changements de solde" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index f5484e624348..92d8d0bc7fa8 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "हम गैस का अनुमान नहीं लगा पाए। कॉन्ट्रैक्ट में कोई गड़बड़ी हो सकती है और यह ट्रांसेक्शन विफल हो सकता है।" }, - "simulationsSettingDescription": { - "message": "ट्रांसेक्शन को कन्फर्म करने से पहले बैलेंस अमाउंट में बदलाव का अनुमान लगाने के लिए इसे चालू करें। यह आपके ट्रांसेक्शन के फाइनल आउटकम की गारंटी नहीं देता है।$1" - }, "simulationsSettingSubHeader": { "message": "बैलेंस अमाउंट में बदलावों का अनुमान लगाएं" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index e7c719318f69..a474fc1afa1a 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Kami tidak dapat memperkirakan gas. Tampaknya ada kesalahan dalam kontrak dan transaksi ini berpotensi gagal." }, - "simulationsSettingDescription": { - "message": "Aktifkan untuk mengestimasikan perubahan saldo transaksi sebelum Anda mengonfirmasikannya. Ini tidak menjamin hasil akhir transaksi Anda. $1" - }, "simulationsSettingSubHeader": { "message": "Estimasikan perubahan saldo" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index a3e917d46600..80982f5b4144 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "ガス代を見積もることができませんでした。コントラクトにエラーがある可能性があり、このトランザクションは失敗するかもしれません。" }, - "simulationsSettingDescription": { - "message": "トランザクションを確定する前に残高の増減を予測するには、この機能をオンにします。これはトランザクションの最終的な結果を保証するものではありません。$1" - }, "simulationsSettingSubHeader": { "message": "予測される残高の増減" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index e1eeda4487a6..5fc7deb6a9ef 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "가스를 추정할 수 없었습니다. 계약에 오류가 있을 수 있으며 이 트랜잭션이 실패할 수 있습니다." }, - "simulationsSettingDescription": { - "message": "이 기능을 켜면 트랜잭션을 확정하기 전에 트랜잭션의 잔액 변동을 추정할 수 있습니다. 이 기능이 트랜잭션의 최종 결과를 보장하지는 않습니다. $1" - }, "simulationsSettingSubHeader": { "message": "예상 잔액 변동" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 1e39077afa51..18e1c97fb129 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Não conseguimos estimar o preço do gás. Pode haver um erro no contrato, e essa transação poderá falhar." }, - "simulationsSettingDescription": { - "message": "Ative para estimar as alterações de saldo das transações antes de confirmá-las. Isso não garante o resultado das suas transações. $1" - }, "simulationsSettingSubHeader": { "message": "Estimar alterações de saldo" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index f68ee11792a1..43e617a0aecd 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Мы не смогли оценить размер платы за газ. В контракте может быть ошибка, и эта транзакция может завершиться неудачно." }, - "simulationsSettingDescription": { - "message": "Включите эту опцию, чтобы спрогнозировать изменения баланса транзакций перед их подтверждением. Это не гарантирует окончательный результат ваших транзакций. $1" - }, "simulationsSettingSubHeader": { "message": "Спрогнозировать изменения баланса" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 4f6e3098171d..5bc72667356e 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Hindi namin nagawang tantyahin ang gas. Maaaring may error sa kontrata at maaaring mabigo ang transaksyong ito." }, - "simulationsSettingDescription": { - "message": "I-on ito para gumawa ng pagtataya sa mga pagbabago ng balanse ng mga transaksyon bago mo kumpirmahin ang mga iyon. Hindi nito iginagarantiya ang panghuling resulta ng iyong mga transaksyon. $1" - }, "simulationsSettingSubHeader": { "message": "Tinatayang mga pagbabago sa balanse" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 771089050317..a71a64576142 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Gaz tahmini yapamadık. Sözleşmede bir hata olabilir ve bu işlem başarısız olabilir." }, - "simulationsSettingDescription": { - "message": "Onaylamadan önce işlemlerdeki bakiye değişikliklerini tahmin etmek için bunu açın. İşlemlerinizin nihai sonucunu garanti etmez. $1" - }, "simulationsSettingSubHeader": { "message": "Bakiye değişikliklerini tahmin edin" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 1983185816c0..5fb6ee43bfb9 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "Chúng tôi không thể ước tính gas. Có thể đã xảy ra lỗi trong hợp đồng và giao dịch này có thể thất bại." }, - "simulationsSettingDescription": { - "message": "Bật tính năng này để ước tính thay đổi số dư của các giao dịch trước khi bạn xác nhận. Điều này không đảm bảo cho kết quả cuối cùng của giao dịch. $1" - }, "simulationsSettingSubHeader": { "message": "Ước tính thay đổi số dư" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index b6c050f6b264..ca1d0e3f7b48 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -4920,9 +4920,6 @@ "simulationErrorMessageV2": { "message": "我们无法估算燃料。合约中可能存在错误,这笔交易可能会失败。" }, - "simulationsSettingDescription": { - "message": "开启此选项,以便在确认交易之前估计交易余额的变化。这并不能保证交易的最终结果。$1" - }, "simulationsSettingSubHeader": { "message": "预计余额变化" }, 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 0927d04f89cb..e7306f64bba7 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 @@ -740,7 +740,7 @@ exports[`Security Tab should match snapshot 1`] = ` > - Turn this on to estimate balance changes of transactions before you confirm them. This doesn't guarantee the final outcome of your transactions. + Turn this on to estimate balance changes of transactions and signatures before you confirm them. This doesn't guarantee their final outcome. Date: Mon, 25 Nov 2024 11:46:11 -0330 Subject: [PATCH 067/148] chore: Restrict MMI test runs (#28655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The standard MMI e2e test suite now only runs on long-running branches, and for MMI-related changes. This should reduce CircleCI credit usage substantially. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28655?quickstart=1) ## **Related issues** No issue. This is just to reduce credit usage. ## **Manual testing steps** We should see in the logs for the `test-e2e-mmi` job on this PR that the tests were skipped. ## **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 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0817f18c36a4..3e3ccba9005e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -178,6 +178,7 @@ workflows: - prep-build-test-mmi: requires: - prep-deps + - check-mmi-trigger - prep-build-test-mmi-playwright: requires: - prep-deps @@ -803,6 +804,7 @@ jobs: - run: corepack enable - attach_workspace: at: . + - run: *check-mmi-trigger - run: name: Build extension for testing command: yarn build:test:mmi @@ -1197,6 +1199,7 @@ jobs: - run: sudo corepack enable - attach_workspace: at: . + - run: *check-mmi-trigger - run: name: Move test build to dist command: mv ./dist-test-mmi ./dist @@ -1286,6 +1289,7 @@ jobs: - run: sudo corepack enable - attach_workspace: at: . + - run: *check-mmi-trigger - run: name: Move test build to dist command: mv ./dist-test-mmi ./dist From 42c132eaeead2bffef536c41ac243a82af852f91 Mon Sep 17 00:00:00 2001 From: Jack Clancy Date: Mon, 25 Nov 2024 15:56:57 +0000 Subject: [PATCH 068/148] fix: swaps approval checking for approvals between 0 and unlimited (#28680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** For approval amounts that were greater than 0, but less than the amount the user was trying to swap. The swaps controller was filtering out quotes for users. This PR fixes a longstanding bug in the swaps controller, where we only checked if the approval amount was 0. Instead of when the approval is 0 OR approval amount is less than swap amount. This PR updates the logic to correctly reflect this case [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28680?quickstart=1) ## **Related issues** Fixes issue reported in Slack here https://consensys.slack.com/archives/C0220SURQ3E/p1732474780474849 ## **Manual testing steps** **Testing wallet with seeded state:** 1. Load [this wallet](https://share.1password.com/s#fZ0NxJ-NZWOMxhbKHBTIgE_0tkMir4wY2KdZI-iZu4Y) into your MetaMask 2. Try and Swap 1 USDC for Polygon on Polygon 3. Quotes should be returned New Testing Wallet 1. Create a swaps approval for some small amount 2. Try and swap a larger amount 3. Swaps quotes should be returned ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **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/scripts/controllers/swaps/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/scripts/controllers/swaps/index.ts b/app/scripts/controllers/swaps/index.ts index 308021cd14cf..9160eb0b66e1 100644 --- a/app/scripts/controllers/swaps/index.ts +++ b/app/scripts/controllers/swaps/index.ts @@ -383,12 +383,11 @@ export default class SwapsController extends BaseController< const [firstQuote] = Object.values(newQuotes); // For a user to be able to swap a token, they need to have approved the MetaSwap contract to withdraw that token. - // _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is greater - // than 0, it means that approval has already occurred and is not needed. Otherwise, for tokens to be swapped, a new - // call of the ERC-20 approve method is required. + // _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is either + // zero or less than the soucreAmount of the swap, a new call of the ERC-20 approve method is required. approvalRequired = firstQuote.approvalNeeded && - allowance.eq(0) && + (allowance.eq(0) || allowance.lt(firstQuote.sourceAmount)) && firstQuote.aggregator !== 'wrappedNative'; if (!approvalRequired) { newQuotes = mapValues(newQuotes, (quote) => ({ From 0bf00e62156ed4a9bbf8bec3ce1206ba31c5b442 Mon Sep 17 00:00:00 2001 From: Norbert Elter <72046715+itsyoboieltr@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:02:15 +0400 Subject: [PATCH 069/148] fix: SonarCloud workflow_run (#28693) 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/28693?quickstart=1) There was a typo in the name of the workflow, causing the SonarCloud workflow to not run. This PR corrects the mistake. ## **Related issues** Fixes: https://consensys.slack.com/archives/CTQAGKY5V/p1732116793123109 ## **Manual testing steps** 1. As workflows called by `workflow_run` only run on the default branch, we can only make sure that SonarCloud 100% runs after merging. I tested it on a private repository though on my GitHub account, and the workflow worked there. ## **Screenshots/Recordings** Not applicable ## **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/workflows/sonarcloud.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 9ca9f02e2ae5..8b12876de1cd 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -8,7 +8,7 @@ name: SonarCloud on: workflow_run: workflows: - - Run tests + - Main types: - completed From 2e2edb5269e39d9baf2f866128776fe2d4bd8dc6 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:09:48 +0100 Subject: [PATCH 070/148] test: [POM] Migrate create btc account e2e tests to TS and Page Object Model (#28437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Migrate create btc account e2e tests to TS and Page Object Model [![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/28542 ## **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. --- test/e2e/flask/btc/common-btc.ts | 35 +-- test/e2e/flask/btc/create-btc-account.spec.ts | 234 ++++++++---------- test/e2e/helpers.js | 79 ------ .../page-objects/pages/account-list-page.ts | 109 ++++++-- .../tests/account/account-custom-name.spec.ts | 2 +- test/e2e/tests/account/add-account.spec.ts | 4 +- .../notifications/account-syncing/helpers.ts | 15 -- .../account-syncing/new-user-sync.spec.ts | 24 +- .../onboarding-with-opt-out.spec.ts | 22 +- .../sync-after-adding-account.spec.ts | 6 +- test/e2e/tests/tokens/nft/import-nft.spec.ts | 2 +- 11 files changed, 255 insertions(+), 277 deletions(-) diff --git a/test/e2e/flask/btc/common-btc.ts b/test/e2e/flask/btc/common-btc.ts index 452f9ad44f6b..05f382281e29 100644 --- a/test/e2e/flask/btc/common-btc.ts +++ b/test/e2e/flask/btc/common-btc.ts @@ -1,6 +1,6 @@ import { Mockttp } from 'mockttp'; import FixtureBuilder from '../../fixture-builder'; -import { withFixtures, unlockWallet } from '../../helpers'; +import { withFixtures } from '../../helpers'; import { DEFAULT_BTC_ACCOUNT, DEFAULT_BTC_BALANCE, @@ -11,7 +11,9 @@ import { } from '../../constants'; import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; import { Driver } from '../../webdriver/driver'; -import messages from '../../../../app/_locales/en/messages.json'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; +import AccountListPage from '../../page-objects/pages/account-list-page'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; const QUICKNODE_URL_REGEX = /^https:\/\/.*\.btc.*\.quiknode\.pro(\/|$)/u; @@ -21,27 +23,6 @@ export enum SendFlowPlaceHolders { LOADING = 'Preparing transaction', } -export async function createBtcAccount(driver: Driver) { - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '[data-testid="multichain-account-menu-popover-action-button"]', - ); - await driver.clickElement({ - text: messages.addNewBitcoinAccount.message, - tag: 'button', - }); - await driver.clickElementAndWaitToDisappear( - { - text: 'Add account', - tag: 'button', - }, - // Longer timeout than usual, this reduces the flakiness - // around Bitcoin account creation (mainly required for - // Firefox) - 5000, - ); -} - export function btcToSats(btc: number): number { // Watchout, we're not using BigNumber(s) here (but that's ok for test purposes) return btc * SATS_IN_1_BTC; @@ -231,8 +212,12 @@ export async function withBtcAccountSnap( ], }, async ({ driver, mockServer }: { driver: Driver; mockServer: Mockttp }) => { - await unlockWallet(driver); - await createBtcAccount(driver); + await loginWithBalanceValidation(driver); + // create one BTC account + await new HeaderNavbar(driver).openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.addNewBtcAccount(); await test(driver, mockServer); }, ); diff --git a/test/e2e/flask/btc/create-btc-account.spec.ts b/test/e2e/flask/btc/create-btc-account.spec.ts index 1b10599bf5ca..0dfb24a8a931 100644 --- a/test/e2e/flask/btc/create-btc-account.spec.ts +++ b/test/e2e/flask/btc/create-btc-account.spec.ts @@ -1,26 +1,22 @@ import { strict as assert } from 'assert'; import { Suite } from 'mocha'; -import messages from '../../../../app/_locales/en/messages.json'; - -import { - WALLET_PASSWORD, - completeSRPRevealQuiz, - getSelectedAccountAddress, - openSRPRevealQuiz, - removeSelectedAccount, - tapAndHoldToRevealSRP, -} from '../../helpers'; -import { createBtcAccount, withBtcAccountSnap } from './common-btc'; +import { WALLET_PASSWORD } from '../../helpers'; +import AccountListPage from '../../page-objects/pages/account-list-page'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import LoginPage from '../../page-objects/pages/login-page'; +import PrivacySettings from '../../page-objects/pages/settings/privacy-settings'; +import ResetPasswordPage from '../../page-objects/pages/reset-password-page'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; +import { withBtcAccountSnap } from './common-btc'; describe('Create BTC Account', function (this: Suite) { it('create BTC account from the menu', async function () { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.check_accountLabel('Bitcoin Account'); }, ); }); @@ -29,27 +25,23 @@ describe('Create BTC Account', function (this: Suite) { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - await driver.delay(500); - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '[data-testid="multichain-account-menu-popover-action-button"]', - ); - - const createButton = await driver.findElement({ - text: messages.addNewBitcoinAccount.message, - tag: 'button', + // check that we have one BTC account + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + // check user cannot create second BTC account + await headerNavbar.openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.addNewBtcAccount({ + btcAccountCreationEnabled: false, }); - assert.equal(await createButton.isEnabled(), false); - // modal will still be here - await driver.clickElement('.mm-box button[aria-label="Close"]'); - - // check the number of accounts. it should only be 2. - await driver.clickElement('[data-testid="account-menu-icon"]'); - const menuItems = await driver.findElements( - '.multichain-account-list-item', - ); - assert.equal(menuItems.length, 2); + // check the number of available accounts is 2 + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_numberOfAvailableAccounts(2); }, ); }); @@ -58,29 +50,22 @@ describe('Create BTC Account', function (this: Suite) { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); - - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '.multichain-account-list-item--selected [data-testid="account-list-item-menu-button"]', - ); - await driver.clickElement('[data-testid="account-list-menu-remove"]'); - await driver.clickElement({ text: 'Nevermind', tag: 'button' }); - - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); - - // check the number of accounts. it should only be 2. - await driver.clickElement('[data-testid="account-menu-icon"]'); - const menuItems = await driver.findElements( - '.multichain-account-list-item', - ); - assert.equal(menuItems.length, 2); + // check that we have one BTC account + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + // check user can cancel the removal of the BTC account + await headerNavbar.openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.removeAccount('Bitcoin Account', false); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + // check the number of accounts. it should be 2. + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_numberOfAvailableAccounts(2); }, ); }); @@ -89,22 +74,33 @@ describe('Create BTC Account', function (this: Suite) { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); - - const accountAddress = await getSelectedAccountAddress(driver); - await removeSelectedAccount(driver); - - // Recreate account - await createBtcAccount(driver); - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); + // check that we have one BTC account + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + // get the address of the BTC account and remove it + await headerNavbar.openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + const accountAddress = await accountListPage.getAccountAddress( + 'Bitcoin Account', + ); + await headerNavbar.openAccountMenu(); + await accountListPage.removeAccount('Bitcoin Account'); + + // Recreate account and check that the address is the same + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + await accountListPage.addNewBtcAccount(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + const recreatedAccountAddress = await accountListPage.getAccountAddress( + 'Bitcoin Account', + ); - const recreatedAccountAddress = await getSelectedAccountAddress(driver); assert(accountAddress === recreatedAccountAddress); }, ); @@ -114,62 +110,50 @@ describe('Create BTC Account', function (this: Suite) { await withBtcAccountSnap( { title: this.test?.fullTitle() }, async (driver) => { - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); - - const accountAddress = await getSelectedAccountAddress(driver); - - await openSRPRevealQuiz(driver); - await completeSRPRevealQuiz(driver); - await driver.fill('[data-testid="input-password"]', WALLET_PASSWORD); - await driver.press('[data-testid="input-password"]', driver.Key.ENTER); - await tapAndHoldToRevealSRP(driver); - const seedPhrase = await ( - await driver.findElement('[data-testid="srp_text"]') - ).getText(); - - // Reset wallet - await driver.clickElement( - '[data-testid="account-options-menu-button"]', - ); - await driver.clickElement({ - css: '[data-testid="global-menu-lock"]', - text: 'Lock MetaMask', - }); - - await driver.clickElement({ - text: 'Forgot password?', - tag: 'a', - }); - - await driver.pasteIntoField( - '[data-testid="import-srp__srp-word-0"]', - seedPhrase, + // check that we have one BTC account + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + await headerNavbar.openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + const accountAddress = await accountListPage.getAccountAddress( + 'Bitcoin Account', ); - await driver.fill( - '[data-testid="create-vault-password"]', - WALLET_PASSWORD, + // go to privacy settings page and get the SRP + await headerNavbar.openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp(WALLET_PASSWORD); + const seedPhrase = await privacySettings.getSrpInRevealSrpDialog(); + + // lock metamask and reset wallet by clicking forgot password button + await headerNavbar.lockMetaMask(); + await new LoginPage(driver).gotoResetPasswordPage(); + const resetPasswordPage = new ResetPasswordPage(driver); + await resetPasswordPage.check_pageIsLoaded(); + await resetPasswordPage.resetPassword(seedPhrase, WALLET_PASSWORD); + + // create a BTC account and check that the address is the same + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + await accountListPage.addNewBtcAccount(); + await headerNavbar.check_accountLabel('Bitcoin Account'); + + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + const recreatedAccountAddress = await accountListPage.getAccountAddress( + 'Bitcoin Account', ); - await driver.fill( - '[data-testid="create-vault-confirm-password"]', - WALLET_PASSWORD, - ); - - await driver.clickElement({ - text: 'Restore', - tag: 'button', - }); - - await createBtcAccount(driver); - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: 'Bitcoin Account', - }); - - const recreatedAccountAddress = await getSelectedAccountAddress(driver); assert(accountAddress === recreatedAccountAddress); }, ); diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index ae86720da46f..19c9aeecd6c7 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -5,7 +5,6 @@ const mockttp = require('mockttp'); const detectPort = require('detect-port'); const { difference } = require('lodash'); const createStaticServer = require('../../development/create-static-server'); -const { tEn } = require('../lib/i18n-helpers'); const { setupMocking } = require('./mock-e2e'); const { Ganache } = require('./seeder/ganache'); const FixtureServer = require('./fixture-server'); @@ -385,51 +384,6 @@ const getWindowHandles = async (driver, handlesCount) => { return { extension, dapp, popup }; }; -const openSRPRevealQuiz = async (driver) => { - // navigate settings to reveal SRP - await driver.clickElement('[data-testid="account-options-menu-button"]'); - - // fix race condition with mmi build - if (process.env.MMI) { - await driver.waitForSelector('[data-testid="global-menu-mmi-portfolio"]'); - } - - await driver.clickElement({ text: 'Settings', tag: 'div' }); - await driver.clickElement({ text: 'Security & privacy', tag: 'div' }); - await driver.clickElement('[data-testid="reveal-seed-words"]'); -}; - -/** - * @deprecated Please use page object functions in `test/e2e/page-objects/pages/settings/privacy-settings.ts`. - * @param driver - */ -const completeSRPRevealQuiz = async (driver) => { - // start quiz - await driver.clickElement('[data-testid="srp-quiz-get-started"]'); - - // tap correct answer 1 - await driver.clickElement('[data-testid="srp-quiz-right-answer"]'); - - // tap Continue 1 - await driver.clickElement('[data-testid="srp-quiz-continue"]'); - - // tap correct answer 2 - await driver.clickElement('[data-testid="srp-quiz-right-answer"]'); - - // tap Continue 2 - await driver.clickElement('[data-testid="srp-quiz-continue"]'); -}; - -const tapAndHoldToRevealSRP = async (driver) => { - await driver.holdMouseDownOnElement( - { - text: tEn('holdToRevealSRP'), - tag: 'span', - }, - 3000, - ); -}; - const DAPP_HOST_ADDRESS = '127.0.0.1:8080'; const DAPP_URL = `http://${DAPP_HOST_ADDRESS}`; const DAPP_ONE_URL = 'http://127.0.0.1:8081'; @@ -871,34 +825,6 @@ async function initBundler(bundlerServer, ganacheServer, usePaymaster) { } } -/** - * @deprecated Please use page object functions in `pages/account-list-page`. - * @param driver - */ -async function removeSelectedAccount(driver) { - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '.multichain-account-list-item--selected [data-testid="account-list-item-menu-button"]', - ); - await driver.clickElement('[data-testid="account-list-menu-remove"]'); - await driver.clickElement({ text: 'Remove', tag: 'button' }); -} - -/** - * @deprecated Please use page object functions in `pages/account-list-page`. - * @param driver - */ -async function getSelectedAccountAddress(driver) { - await driver.clickElement('[data-testid="account-options-menu-button"]'); - await driver.clickElement('[data-testid="account-list-menu-details"]'); - const accountAddress = await ( - await driver.findElement('[data-testid="address-copy-button-text"]') - ).getText(); - await driver.clickElement('.mm-box button[aria-label="Close"]'); - - return accountAddress; -} - /** * Rather than using the FixtureBuilder#withPreferencesController to set the setting * we need to manually set the setting because the migration #122 overrides this. @@ -970,9 +896,6 @@ module.exports = { largeDelayMs, veryLargeDelayMs, withFixtures, - openSRPRevealQuiz, - completeSRPRevealQuiz, - tapAndHoldToRevealSRP, createDownloadFolder, openDapp, openDappConnectionsPage, @@ -1004,8 +927,6 @@ module.exports = { getCleanAppState, editGasFeeForm, clickNestedButton, - removeSelectedAccount, - getSelectedAccountAddress, tempToggleSettingRedesignedConfirmations, openMenuSafe, sentryRegEx, diff --git a/test/e2e/page-objects/pages/account-list-page.ts b/test/e2e/page-objects/pages/account-list-page.ts index fb7c1232c08c..f68cdaa333a0 100644 --- a/test/e2e/page-objects/pages/account-list-page.ts +++ b/test/e2e/page-objects/pages/account-list-page.ts @@ -1,9 +1,13 @@ +import { strict as assert } from 'assert'; import { Driver } from '../../webdriver/driver'; import { largeDelayMs } from '../../helpers'; +import messages from '../../../../app/_locales/en/messages.json'; class AccountListPage { private readonly driver: Driver; + private readonly accountAddressText = '.qr-code__address-segments'; + private readonly accountListBalance = '[data-testid="second-currency-display"]'; @@ -25,6 +29,11 @@ class AccountListPage { private readonly addAccountConfirmButton = '[data-testid="submit-add-account-with-name"]'; + private readonly addBtcAccountButton = { + text: messages.addNewBitcoinAccount.message, + tag: 'button', + }; + private readonly addEthereumAccountButton = '[data-testid="multichain-account-menu-popover-add-account"]'; @@ -93,6 +102,11 @@ class AccountListPage { tag: 'div', }; + private readonly removeAccountNevermindButton = { + text: 'Nevermind', + tag: 'button', + }; + private readonly saveAccountLabelButton = '[data-testid="save-account-label-input"]'; @@ -114,15 +128,21 @@ class AccountListPage { } /** - * Adds a new account with a custom label. + * Adds a new account with an optional custom label. * - * @param customLabel - The custom label for the new account. + * @param customLabel - The custom label for the new account. If not provided, a default name will be used. */ - async addNewAccountWithCustomLabel(customLabel: string): Promise { - console.log(`Adding new account with custom label: ${customLabel}`); + async addNewAccount(customLabel?: string): Promise { + if (customLabel) { + console.log(`Adding new account with custom label: ${customLabel}`); + } else { + console.log(`Adding new account with default name`); + } await this.driver.clickElement(this.createAccountButton); await this.driver.clickElement(this.addEthereumAccountButton); - await this.driver.fill(this.accountNameInput, customLabel); + if (customLabel) { + await this.driver.fill(this.accountNameInput, customLabel); + } // needed to mitigate a race condition with the state update // there is no condition we can wait for in the UI await this.driver.delay(largeDelayMs); @@ -132,19 +152,47 @@ class AccountListPage { } /** - * Adds a new account with default next available name. + * Adds a new BTC account with an optional custom name. * + * @param options - Options for adding a new BTC account. + * @param [options.btcAccountCreationEnabled] - Indicates if the BTC account creation is expected to be enabled or disabled. Defaults to true. + * @param [options.accountName] - The custom name for the BTC account. Defaults to an empty string, which means the default name will be used. */ - async addNewAccountWithDefaultName(): Promise { - console.log(`Adding new account with next available name`); - await this.driver.clickElement(this.createAccountButton); - await this.driver.clickElement(this.addEthereumAccountButton); - // needed to mitigate a race condition with the state update - // there is no condition we can wait for in the UI - await this.driver.delay(largeDelayMs); - await this.driver.clickElementAndWaitToDisappear( - this.addAccountConfirmButton, + async addNewBtcAccount({ + btcAccountCreationEnabled = true, + accountName = '', + }: { + btcAccountCreationEnabled?: boolean; + accountName?: string; + } = {}): Promise { + console.log( + `Adding new BTC account${ + accountName ? ` with custom name: ${accountName}` : ' with default name' + }`, ); + await this.driver.clickElement(this.createAccountButton); + if (btcAccountCreationEnabled) { + await this.driver.clickElement(this.addBtcAccountButton); + // needed to mitigate a race condition with the state update + // there is no condition we can wait for in the UI + await this.driver.delay(largeDelayMs); + if (accountName) { + await this.driver.fill(this.accountNameInput, accountName); + } + await this.driver.clickElementAndWaitToDisappear( + this.addAccountConfirmButton, + // Longer timeout than usual, this reduces the flakiness + // around Bitcoin account creation (mainly required for + // Firefox) + 5000, + ); + } else { + const createButton = await this.driver.findElement( + this.addBtcAccountButton, + ); + assert.equal(await createButton.isEnabled(), false); + await this.driver.clickElement(this.closeAccountModalButton); + } } /** @@ -195,6 +243,23 @@ class AccountListPage { ); } + /** + * Get the address of the specified account. + * + * @param accountLabel - The label of the account to get the address. + */ + async getAccountAddress(accountLabel: string): Promise { + console.log(`Get account address in account list`); + await this.openAccountOptionsInAccountList(accountLabel); + await this.driver.clickElement(this.accountMenuButton); + await this.driver.waitForSelector(this.accountAddressText); + const accountAddress = await ( + await this.driver.findElement(this.accountAddressText) + ).getText(); + await this.driver.clickElement(this.closeAccountModalButton); + return accountAddress; + } + async hideAccount(): Promise { console.log(`Hide account in account list`); await this.driver.clickElement(this.hideUnhideAccountButton); @@ -284,13 +349,23 @@ class AccountListPage { * Remove the specified account from the account list. * * @param accountLabel - The label of the account to remove. + * @param confirmRemoval - Whether to confirm the removal of the account. Defaults to true. */ - async removeAccount(accountLabel: string): Promise { + async removeAccount( + accountLabel: string, + confirmRemoval: boolean = true, + ): Promise { console.log(`Remove account in account list`); await this.openAccountOptionsInAccountList(accountLabel); await this.driver.clickElement(this.removeAccountButton); await this.driver.waitForSelector(this.removeAccountMessage); - await this.driver.clickElement(this.removeAccountConfirmButton); + if (confirmRemoval) { + console.log('Confirm removal of account'); + await this.driver.clickElement(this.removeAccountConfirmButton); + } else { + console.log('Click nevermind button to cancel account removal'); + await this.driver.clickElement(this.removeAccountNevermindButton); + } } async switchToAccount(expectedLabel: string): Promise { diff --git a/test/e2e/tests/account/account-custom-name.spec.ts b/test/e2e/tests/account/account-custom-name.spec.ts index 138317b1dcf0..4c0ecbe196f9 100644 --- a/test/e2e/tests/account/account-custom-name.spec.ts +++ b/test/e2e/tests/account/account-custom-name.spec.ts @@ -32,7 +32,7 @@ describe('Account Custom Name Persistence', function (this: Suite) { // Add new account with custom label and verify new added account label await headerNavbar.openAccountMenu(); await accountListPage.check_pageIsLoaded(); - await accountListPage.addNewAccountWithCustomLabel(anotherAccountLabel); + await accountListPage.addNewAccount(anotherAccountLabel); await headerNavbar.check_accountLabel(anotherAccountLabel); // Switch back to the first account and verify first custom account persists diff --git a/test/e2e/tests/account/add-account.spec.ts b/test/e2e/tests/account/add-account.spec.ts index 76060611198b..8824fcb70950 100644 --- a/test/e2e/tests/account/add-account.spec.ts +++ b/test/e2e/tests/account/add-account.spec.ts @@ -33,7 +33,7 @@ describe('Add account', function () { // Create new account with default name Account 2 const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - await accountListPage.addNewAccountWithDefaultName(); + await accountListPage.addNewAccount(); await headerNavbar.check_accountLabel('Account 2'); await homePage.check_expectedBalanceIsDisplayed(); @@ -93,7 +93,7 @@ describe('Add account', function () { // Create new account with default name Account 2 const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - await accountListPage.addNewAccountWithDefaultName(); + await accountListPage.addNewAccount(); await headerNavbar.check_accountLabel('Account 2'); await homePage.check_expectedBalanceIsDisplayed(); diff --git a/test/e2e/tests/notifications/account-syncing/helpers.ts b/test/e2e/tests/notifications/account-syncing/helpers.ts index 5e2694067eed..e54a5f6f96ae 100644 --- a/test/e2e/tests/notifications/account-syncing/helpers.ts +++ b/test/e2e/tests/notifications/account-syncing/helpers.ts @@ -1,18 +1,3 @@ import { isManifestV3 } from '../../../../../shared/modules/mv3.utils'; -import { - completeSRPRevealQuiz, - openSRPRevealQuiz, - tapAndHoldToRevealSRP, -} from '../../../helpers'; -import { Driver } from '../../../webdriver/driver'; export const IS_ACCOUNT_SYNCING_ENABLED = isManifestV3; - -export const getSRP = async (driver: Driver, password: string) => { - await openSRPRevealQuiz(driver); - await completeSRPRevealQuiz(driver); - await driver.fill('[data-testid="input-password"]', password); - await driver.press('[data-testid="input-password"]', driver.Key.ENTER); - await tapAndHoldToRevealSRP(driver); - return (await driver.findElement('[data-testid="srp_text"]')).getText(); -}; diff --git a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts index 8e2908682542..ae283c47fa8b 100644 --- a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts @@ -12,7 +12,9 @@ import { completeCreateNewWalletOnboardingFlow, completeImportSRPOnboardingFlow, } from '../../../page-objects/flows/onboarding.flow'; -import { getSRP, IS_ACCOUNT_SYNCING_ENABLED } from './helpers'; +import PrivacySettings from '../../../page-objects/pages/settings/privacy-settings'; +import SettingsPage from '../../../page-objects/pages/settings/settings-page'; +import { IS_ACCOUNT_SYNCING_ENABLED } from './helpers'; describe('Account syncing - New User @no-mmi', function () { if (!IS_ACCOUNT_SYNCING_ENABLED) { @@ -65,12 +67,24 @@ describe('Account syncing - New User @no-mmi', function () { // Add a second account await accountListPage.openAccountOptionsMenu(); - await accountListPage.addNewAccountWithCustomLabel( - 'My Second Account', - ); + await accountListPage.addNewAccount('My Second Account'); // Set SRP to use for retreival - walletSrp = await getSRP(driver, NOTIFICATIONS_TEAM_PASSWORD); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp( + NOTIFICATIONS_TEAM_PASSWORD, + ); + walletSrp = await privacySettings.getSrpInRevealSrpDialog(); if (!walletSrp) { throw new Error('Wallet SRP was not set'); } diff --git a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts index 209d3a51fdaf..3ec44e4fd07e 100644 --- a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts @@ -18,7 +18,9 @@ import { importSRPOnboardingFlow, completeImportSRPOnboardingFlow, } from '../../../page-objects/flows/onboarding.flow'; -import { getSRP, IS_ACCOUNT_SYNCING_ENABLED } from './helpers'; +import PrivacySettings from '../../../page-objects/pages/settings/privacy-settings'; +import SettingsPage from '../../../page-objects/pages/settings/settings-page'; +import { IS_ACCOUNT_SYNCING_ENABLED } from './helpers'; import { accountsSyncMockResponse } from './mockData'; describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { @@ -139,9 +141,23 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { await accountListPage.check_accountDisplayedInAccountList( 'Account 1', ); - await accountListPage.addNewAccountWithCustomLabel('New Account'); + await accountListPage.addNewAccount('New Account'); // Set SRP to use for retreival - walletSrp = await getSRP(driver, NOTIFICATIONS_TEAM_PASSWORD); + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.check_pageIsLoaded(); + await headerNavbar.openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp( + NOTIFICATIONS_TEAM_PASSWORD, + ); + walletSrp = await privacySettings.getSrpInRevealSrpDialog(); if (!walletSrp) { throw new Error('Wallet SRP was not set'); } diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts index 23a5d1eaf47b..1c1f7b3119d7 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts @@ -68,9 +68,7 @@ describe('Account syncing - Add Account @no-mmi', function () { await accountListPage.check_accountDisplayedInAccountList( 'My Second Synced Account', ); - await accountListPage.addNewAccountWithCustomLabel( - 'My third account', - ); + await accountListPage.addNewAccount('My third account'); }, ); @@ -175,7 +173,7 @@ describe('Account syncing - Add Account @no-mmi', function () { await accountListPage.check_accountDisplayedInAccountList( 'My Second Synced Account', ); - await accountListPage.addNewAccountWithDefaultName(); + await accountListPage.addNewAccount(); }, ); diff --git a/test/e2e/tests/tokens/nft/import-nft.spec.ts b/test/e2e/tests/tokens/nft/import-nft.spec.ts index 808bc26ac6e6..bc709ba463c0 100644 --- a/test/e2e/tests/tokens/nft/import-nft.spec.ts +++ b/test/e2e/tests/tokens/nft/import-nft.spec.ts @@ -65,7 +65,7 @@ describe('Import NFT', function () { await headerNavbar.openAccountMenu(); const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - await accountListPage.addNewAccountWithDefaultName(); + await accountListPage.addNewAccount(); await headerNavbar.check_accountLabel('Account 2'); await homepage.check_expectedBalanceIsDisplayed(); From eeb085fb4a2e7280ed3677e7597f970172839572 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 25 Nov 2024 18:21:52 +0100 Subject: [PATCH 071/148] fix: fix `ConnectPage` when a non-EVM account is selected (#28436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The currently selected non-EVM account was part of the requested account on during the account connection flow. The `ConnectPage` was allowing to "confirm" the request even if the UI was showing 0 account. To fix this, we no longer uses the currently selected account if this account is not EVM-compatible. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28436?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/accounts-planning/issues/670 ## **Manual testing steps** 1. `yarn start:flask` 2. Enable Bitcoin support 3. Create a Bitcoin account 4. Select this account 5. Go to: https://metamask.github.io/test-dapp/ 6. Try to connect your accounts - None accounts should be selected by default - You should not be able to "confirm" the dialog ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-11-13 at 14 57 11](https://github.com/user-attachments/assets/0b1b3de2-e968-408a-9aea-c9ea4006a1b1) If you try to connect, you will get: ![Screenshot 2024-11-13 at 14 57 44](https://github.com/user-attachments/assets/a0c8e507-7530-4de7-a73f-5414ba1a2656) ### **After** ![Screenshot 2024-11-13 at 15 01 39](https://github.com/user-attachments/assets/170f4ec3-01a9-422e-a32c-66ad81d5568e) ## **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/jest/mocks.ts | 64 ++++++++++ .../connect-page/connect-page.test.tsx | 117 +++++++++++------- .../connect-page/connect-page.tsx | 9 +- .../permissions-connect.component.js | 2 + 4 files changed, 145 insertions(+), 47 deletions(-) diff --git a/test/jest/mocks.ts b/test/jest/mocks.ts index bc7127fb2383..b0750b022e78 100644 --- a/test/jest/mocks.ts +++ b/test/jest/mocks.ts @@ -4,6 +4,7 @@ import { BtcMethod, BtcAccountType, InternalAccount, + isEvmAccountType, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import { v4 as uuidv4 } from 'uuid'; @@ -14,6 +15,9 @@ import { initialState, } from '../../ui/ducks/send'; import { MetaMaskReduxState } from '../../ui/store/store'; +import mockState from '../data/mock-state.json'; + +export type MockState = typeof mockState; export const MOCK_DEFAULT_ADDRESS = '0xd5e099c71b797516c10ed0f0d895f429c2781111'; @@ -245,3 +249,63 @@ export const getSelectedInternalAccountFromMockState = ( state.metamask.internalAccounts.selectedAccount ]; }; + +export function overrideAccountsFromMockState< + MockMetaMaskState extends MockState['metamask'], +>( + state: { metamask: MockMetaMaskState }, + accounts: InternalAccount[], + selectedAccountId?: string, +): { metamask: MockMetaMaskState } { + // First, re-create the accounts mapping and the currently selected account. + const [{ id: newFirstAccountId }] = accounts; + const newSelectedAccount = selectedAccountId ?? newFirstAccountId ?? ''; + const newInternalAccounts = accounts.reduce( + ( + acc: MetaMaskReduxState['metamask']['internalAccounts']['accounts'], + account, + ) => { + acc[account.id] = account; + return acc; + }, + {}, + ); + + // Re-create the keyring mapping too, since some selectors are using their internal + // account list. + const newKeyrings: MetaMaskReduxState['metamask']['keyrings'] = []; + for (const keyring of state.metamask.keyrings) { + const newAccountsForKeyring = []; + for (const account of accounts) { + if (account.metadata.keyring.type === keyring.type) { + newAccountsForKeyring.push(account.address); + } + } + newKeyrings.push({ + type: keyring.type, + accounts: newAccountsForKeyring, + }); + } + + // Compute balances for EVM addresses: + // FIXME: Looks like there's no `balances` type in `MetaMaskReduxState`. + const newBalances: Record = {}; + for (const account of accounts) { + if (isEvmAccountType(account.type)) { + newBalances[account.address] = '0x0'; + } + } + + return { + ...state, + metamask: { + ...state.metamask, + internalAccounts: { + accounts: newInternalAccounts, + selectedAccount: newSelectedAccount, + }, + keyrings: newKeyrings, + balances: newBalances, + }, + }; +} diff --git a/ui/pages/permissions-connect/connect-page/connect-page.test.tsx b/ui/pages/permissions-connect/connect-page/connect-page.test.tsx index ef705e474ad9..86fa769c3206 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.test.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.test.tsx @@ -7,34 +7,42 @@ import { EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; -import { ConnectPage, ConnectPageRequest } from './connect-page'; +import { overrideAccountsFromMockState } from '../../../../test/jest/mocks'; +import { + MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_EOA, +} from '../../../../test/data/mock-accounts'; +import { ConnectPage, ConnectPageProps } from './connect-page'; + +const mockTestDappUrl = 'https://test.dapp'; const render = ( - props: { - request: ConnectPageRequest; - permissionsRequestId: string; - rejectPermissionsRequest: (id: string) => void; - approveConnection: (request: ConnectPageRequest) => void; - activeTabOrigin: string; - } = { - request: { - id: '1', - origin: 'https://test.dapp', - }, - permissionsRequestId: '1', - rejectPermissionsRequest: jest.fn(), - approveConnection: jest.fn(), - activeTabOrigin: 'https://test.dapp', - }, - state = {}, + options: { + props?: ConnectPageProps; + state?: object; + } = {}, ) => { + const { + props = { + request: { + id: '1', + origin: mockTestDappUrl, + }, + permissionsRequestId: '1', + rejectPermissionsRequest: jest.fn(), + approveConnection: jest.fn(), + activeTabOrigin: mockTestDappUrl, + }, + state, + } = options; + const store = configureStore({ ...mockState, metamask: { ...mockState.metamask, ...state, permissionHistory: { - 'https://test.dapp': { + mockTestDappUrl: { eth_accounts: { accounts: { '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1709225290848, @@ -44,7 +52,7 @@ const render = ( }, }, activeTab: { - origin: 'https://test.dapp', + origin: mockTestDappUrl, }, }); return renderWithProvider(, store); @@ -82,33 +90,56 @@ describe('ConnectPage', () => { it('should render with defaults from the requested permissions', () => { const { container } = render({ - request: { - id: '1', - origin: 'https://test.dapp', - permissions: { - [RestrictedMethods.eth_accounts]: { - caveats: [ - { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], - }, - ], - }, - [EndowmentTypes.permittedChains]: { - caveats: [ - { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], - }, - ], + props: { + request: { + id: '1', + origin: mockTestDappUrl, + permissions: { + [RestrictedMethods.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + ], + }, + [EndowmentTypes.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, }, }, + permissionsRequestId: '1', + rejectPermissionsRequest: jest.fn(), + approveConnection: jest.fn(), + activeTabOrigin: mockTestDappUrl, }, - permissionsRequestId: '1', - rejectPermissionsRequest: jest.fn(), - approveConnection: jest.fn(), - activeTabOrigin: 'https://test.dapp', }); expect(container).toMatchSnapshot(); }); + + it('should render a disabled confirm if current account is a non-EVM account', () => { + // NOTE: We select the non-EVM account by default here! + const mockSelectedAccountId = MOCK_ACCOUNT_BIP122_P2WPKH.id; + const mockAccounts = [MOCK_ACCOUNT_EOA, MOCK_ACCOUNT_BIP122_P2WPKH]; + const mockAccountsState = overrideAccountsFromMockState( + mockState, + mockAccounts, + mockSelectedAccountId, + ); + + const { getByText } = render({ + state: mockAccountsState.metamask, + }); + const confirmButton = getByText('Connect'); + const cancelButton = getByText('Cancel'); + // The currently selected account is a Bitcoin account, the "connecting account list" would be + // empty by default and thus, we cannot confirm without explictly select an EVM account. + expect(confirmButton).toBeDisabled(); + expect(cancelButton).toBeDefined(); + }); }); diff --git a/ui/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index 99791a8d5333..32001a75d3a7 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -50,7 +50,7 @@ export type ConnectPageRequest = { >; }; -type ConnectPageProps = { +export type ConnectPageProps = { request: ConnectPageRequest; permissionsRequestId: string; rejectPermissionsRequest: (id: string) => void; @@ -124,10 +124,11 @@ export const ConnectPage: React.FC = ({ }, [accounts, internalAccounts]); const currentAccount = useSelector(getSelectedInternalAccount); + const currentAccountAddress = isEvmAccountType(currentAccount.type) + ? [currentAccount.address] + : []; // We do not support non-EVM accounts connections const defaultAccountsAddresses = - requestedAccounts.length > 0 - ? requestedAccounts - : [currentAccount?.address]; + requestedAccounts.length > 0 ? requestedAccounts : currentAccountAddress; const [selectedAccountAddresses, setSelectedAccountAddresses] = useState( defaultAccountsAddresses, ); diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index e32f85609406..6d37b46e39e2 100644 --- a/ui/pages/permissions-connect/permissions-connect.component.js +++ b/ui/pages/permissions-connect/permissions-connect.component.js @@ -41,10 +41,12 @@ function getDefaultSelectedAccounts(currentAddress, permissionsRequest) { return new Set( requestedAccounts .map((address) => address.toLowerCase()) + // We only consider EVM accounts here (used for `eth_requestAccounts` or `eth_accounts`) .filter(isEthAddress), ); } + // We only consider EVM accounts here (used for `eth_requestAccounts` or `eth_accounts`) return new Set(isEthAddress(currentAddress) ? [currentAddress] : []); } From 9c4d1b41146fae9e4b2888a1c73c35f2213a5254 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Mon, 25 Nov 2024 19:12:23 +0100 Subject: [PATCH 072/148] fix: prevent non-current network tokens from being hidden incorrectly (#28674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** fix to prevent non current network tokens from being hidden incorrecly core PR: https://github.com/MetaMask/core/pull/4967 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28674?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. add different network accros different chains 2. choose ethereum as current chain 3. try to hide token of polygon for instance ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/2637ed94-6ad1-4025-8000-963906aca187 ### **After** https://github.com/user-attachments/assets/4c5d6cbd-4af2-43c5-bcbd-879a71b9997e ## **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. --- ...ts-controllers-npm-45.1.0-d914c453f0.patch | 24 +++++++++ .../hide-token-confirmation-modal.test.js | 51 +++++++++++++++++++ yarn.lock | 4 +- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch b/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch index ca412ba89489..5dec24d6e625 100644 --- a/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch +++ b/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch @@ -33,3 +33,27 @@ index a13403446a2376d4d905a9ef733941798da89c88..3c8229f9ea40f4c1ee760a22884e1066 /** * The list of currencies that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint, in lowercase form. +diff --git a/dist/TokensController.cjs b/dist/TokensController.cjs +index 343b343b8300136756d96acac77aab8140efc95a..69d8e2ea84d6303a3af02bd95458ef3060c76f2b 100644 +--- a/dist/TokensController.cjs ++++ b/dist/TokensController.cjs +@@ -270,13 +270,16 @@ class TokensController extends base_controller_1.BaseController { + * @param networkClientId - Optional network client ID used to determine interacting chain ID. + */ + ignoreTokens(tokenAddressesToIgnore, networkClientId) { +- const { ignoredTokens, detectedTokens, tokens } = this.state; +- const ignoredTokensMap = {}; +- let newIgnoredTokens = [...ignoredTokens]; + let interactingChainId; + if (networkClientId) { + interactingChainId = this.messagingSystem.call('NetworkController:getNetworkClientById', networkClientId).configuration.chainId; + } ++ const { allTokens, allDetectedTokens, allIgnoredTokens } = this.state; ++ const ignoredTokensMap = {}; ++ const ignoredTokens = allIgnoredTokens[interactingChainId ?? __classPrivateFieldGet(this, _TokensController_chainId, "f")]?.[__classPrivateFieldGet(this, _TokensController_instances, "m", _TokensController_getSelectedAddress).call(this)] || []; ++ let newIgnoredTokens = [...ignoredTokens]; ++ const tokens = allTokens[interactingChainId ?? __classPrivateFieldGet(this, _TokensController_chainId, "f")]?.[__classPrivateFieldGet(this, _TokensController_instances, "m", _TokensController_getSelectedAddress).call(this)] || []; ++ const detectedTokens = allDetectedTokens[interactingChainId ?? __classPrivateFieldGet(this, _TokensController_chainId, "f")]?.[__classPrivateFieldGet(this, _TokensController_instances, "m", _TokensController_getSelectedAddress).call(this)] || []; + const checksummedTokenAddresses = tokenAddressesToIgnore.map((address) => { + const checksumAddress = (0, controller_utils_1.toChecksumHexAddress)(address); + ignoredTokensMap[address.toLowerCase()] = true; diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js index 982a6a272943..04ba342e9c48 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js @@ -5,6 +5,7 @@ import thunk from 'redux-thunk'; import * as actions from '../../../../store/actions'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import mockState from '../../../../../test/data/mock-state.json'; +import { mockNetworkState } from '../../../../../test/stub/networks'; import HideTokenConfirmationModal from '.'; const mockHistoryPush = jest.fn(); @@ -25,6 +26,13 @@ describe('Hide Token Confirmation Modal', () => { image: '', }; + const tokenState2 = { + address: '0xTokenAddress2', + symbol: 'TKN2', + image: '', + chainId: '0x89', + }; + const tokenModalState = { ...mockState, appState: { @@ -82,4 +90,47 @@ describe('Hide Token Confirmation Modal', () => { networkClientId: 'goerli', }); }); + + it('should hide token from another chain', () => { + const tokenModalStateWithDifferentChain = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'bsc', + ...mockNetworkState({ chainId: '0x89', id: 'bsc' }), + }, + appState: { + ...mockState.appState, + modal: { + modalState: { + props: { + history: { + push: mockHistoryPush, + }, + token: tokenState2, + }, + }, + }, + }, + }; + + const mockStoreDifferentChain = configureMockStore([thunk])( + tokenModalStateWithDifferentChain, + ); + + const { queryByTestId } = renderWithProvider( + , + mockStoreDifferentChain, + ); + + const hideButton = queryByTestId('hide-token-confirmation__hide'); + + fireEvent.click(hideButton); + + expect(mockHideModal).toHaveBeenCalled(); + expect(actions.ignoreTokens).toHaveBeenCalledWith({ + tokensToIgnore: tokenState2.address, + networkClientId: 'bsc', + }); + }); }); diff --git a/yarn.lock b/yarn.lock index db952a4b1e70..9f8daf6ce2d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4975,7 +4975,7 @@ __metadata: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch": version: 45.1.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch::version=45.1.0&hash=86167d" + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A45.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-45.1.0-d914c453f0.patch::version=45.1.0&hash=cfcadc" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5008,7 +5008,7 @@ __metadata: "@metamask/keyring-controller": ^19.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^15.0.0 - checksum: 10/985ec7dffb75aaff8eea00f556157e42cd5db063cbfa94dfd4f070c5b9d98b1315f3680fa7370f4c734a1688598bbda9c44a7c33c342e1d123d6ee2edd6120fc + checksum: 10/d2f7d5bb07feceb5b972beda019f411cd073ece3ed682b21373fc6d4c06812ec10245b40c78ce6316c5fb1718278fd269b73e13d37c2ff07b5bb3ecdfd8278f7 languageName: node linkType: hard From ff635d238ce177c124e70b5f9e33a03d606e031a Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:23:03 -0800 Subject: [PATCH 073/148] fix: PortfolioView swap native token bug (#28639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When `PORTFOLIO_VIEW` feature flag is enabled, when swapping a native token from a different chain than the globally selected chain, the incorrect native token would be prepoulated in the `fromToken` in the swap UI. For instance, if user is on Ethereum mainnet, navigated to POL, then attempted to swap, the globally selected network would change from Ethereum mainnet to Polygon mainnet (expected), but the swaps `fromToken` would still be POL (unexpected) Changes in this PR fixes this, and prepoulates `fromToken` with the native token from the correct chain. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28639?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/metamask-extension/issues/28534 ## **Manual testing steps** 1. `PORTFOLIO_VIEW=1 yarn webpack --watch` 2. Import wallet with at least two networks added 3. When "All Networks" is toggled, attempt to swap a native token from another network. Ensure that the token prepopulated in the swap UI is the native token from the correct chain 4. Ensure swap completes successfully. ## **Screenshots/Recordings** https://github.com/user-attachments/assets/016ffa54-9ed1-450c-9aa0-da27f0fd6caa ## **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: David Walsh --- ui/pages/asset/components/asset-page.tsx | 17 +- ui/selectors/selectors.js | 17 +- ui/selectors/selectors.test.js | 199 +++++++++++++++++++++++ 3 files changed, 223 insertions(+), 10 deletions(-) diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 0f4529861dbc..68d7f9d18e65 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -101,11 +101,21 @@ const AssetPage = ({ const selectedAccount = useSelector(getSelectedAccount); const currency = useSelector(getCurrentCurrency); const conversionRate = useSelector(getConversionRate); - const isBridgeChain = useSelector(getIsBridgeChain); const isBuyableChain = useSelector(getIsNativeTokenBuyable); - const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual); + + const { chainId, type, symbol, name, image, decimals } = asset; + + // These need to be specific to the asset and not the current chain + const defaultSwapsToken = useSelector( + (state) => getSwapsDefaultToken(state, chainId), + isEqual, + ); + const isSwapsChain = useSelector((state) => getIsSwapsChain(state, chainId)); + const isBridgeChain = useSelector((state) => + getIsBridgeChain(state, chainId), + ); + const account = useSelector(getSelectedInternalAccount, isEqual); - const isSwapsChain = useSelector(getIsSwapsChain); const isSigningEnabled = account.methods.includes(EthMethod.SignTransaction) || account.methods.includes(EthMethod.SignUserOperation); @@ -132,7 +142,6 @@ const AssetPage = ({ const selectedAccountTokenBalancesAcrossChains = tokenBalances[selectedAccount.address]; - const { chainId, type, symbol, name, image, decimals } = asset; const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); const metaMetricsId = useSelector(getMetaMetricsId); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index b6df07bd2bd0..eea467ec0f16 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -1495,14 +1495,17 @@ export function getWeb3ShimUsageStateForOrigin(state, origin) { * objects, per the above description. * * @param {object} state - the redux state object + * @param {string} overrideChainId - the chainId to override the current chainId * @returns {SwapsEthToken} The token object representation of the currently * selected account's ETH balance, as expected by the Swaps API. */ -export function getSwapsDefaultToken(state) { +export function getSwapsDefaultToken(state, overrideChainId = null) { const selectedAccount = getSelectedAccount(state); const balance = selectedAccount?.balance; - const chainId = getCurrentChainId(state); + const currentChainId = getCurrentChainId(state); + + const chainId = overrideChainId ?? currentChainId; const defaultTokenObject = SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId]; return { @@ -1516,8 +1519,9 @@ export function getSwapsDefaultToken(state) { }; } -export function getIsSwapsChain(state) { - const chainId = getCurrentChainId(state); +export function getIsSwapsChain(state, overrideChainId) { + const currentChainId = getCurrentChainId(state); + const chainId = overrideChainId ?? currentChainId; const isNotDevelopment = process.env.METAMASK_ENVIRONMENT !== 'development' && process.env.METAMASK_ENVIRONMENT !== 'testing'; @@ -1526,8 +1530,9 @@ export function getIsSwapsChain(state) { : ALLOWED_DEV_SWAPS_CHAIN_IDS.includes(chainId); } -export function getIsBridgeChain(state) { - const chainId = getCurrentChainId(state); +export function getIsBridgeChain(state, overrideChainId) { + const currentChainId = getCurrentChainId(state); + const chainId = overrideChainId ?? currentChainId; return ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId); } diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index b2c3cd894e44..c749d8ff3fe7 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1978,4 +1978,203 @@ describe('#getConnectedSitesList', () => { expect(selectors.getSelectedEvmInternalAccount(state)).toBe(undefined); }); }); + + describe('getSwapsDefaultToken', () => { + it('returns the token object for the current chainId when no overrideChainId is provided', () => { + const expectedToken = { + symbol: 'ETH', + name: 'Ether', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + balance: '966987986469506564059', + string: '966.988', + iconUrl: './images/black-eth-logo.svg', + }; + + const result = selectors.getSwapsDefaultToken(mockState); + + expect(result).toStrictEqual(expectedToken); + }); + + it('returns the token object for the overridden chainId when overrideChainId is provided', () => { + const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + const expectedToken = { + symbol: 'POL', + name: 'Polygon', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + balance: '966987986469506564059', + string: '966.988', + iconUrl: './images/pol-token.svg', + }; + + const result = selectors.getSwapsDefaultToken( + mockState, + CHAIN_IDS.POLYGON, + ); + + expect(result).toStrictEqual(expectedToken); + expect(getCurrentChainIdSpy).not.toHaveBeenCalled(); // Ensure overrideChainId is used + }); + }); + + describe('getIsSwapsChain', () => { + it('returns true for an allowed chainId in production environment', () => { + process.env.METAMASK_ENVIRONMENT = 'production'; + + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'testNetworkConfigurationId', // corresponds to mainnet RPC in mockState + }, + }; + + const result = selectors.getIsSwapsChain(state); + + expect(result).toBe(true); + }); + + it('returns true for an allowed chainId in development environment', () => { + process.env.METAMASK_ENVIRONMENT = 'development'; + + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'goerli', + }, + }; + + const result = selectors.getIsSwapsChain(state); + + expect(result).toBe(true); + }); + + it('returns false for a disallowed chainId in production environment', () => { + process.env.METAMASK_ENVIRONMENT = 'production'; + + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'fooChain', // corresponds to mainnet RPC in mockState + networkConfigurationsByChainId: { + '0x8080': { + chainId: '0x8080', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: 'custom', + url: 'https://testrpc.com', + networkClientId: 'fooChain', + }, + ], + }, + }, + }, + }; + + const result = selectors.getIsSwapsChain(state); + + expect(result).toBe(false); + }); + + it('returns false for a disallowed chainId in development environment', () => { + process.env.METAMASK_ENVIRONMENT = 'development'; + + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'fooChain', // corresponds to mainnet RPC in mockState + networkConfigurationsByChainId: { + '0x8080': { + chainId: '0x8080', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: 'custom', + url: 'https://testrpc.com', + networkClientId: 'fooChain', + }, + ], + }, + }, + }, + }; + + const result = selectors.getIsSwapsChain(state); + + expect(result).toBe(false); + }); + + it('respects the overrideChainId parameter', () => { + process.env.METAMASK_ENVIRONMENT = 'production'; + + const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + + const result = selectors.getIsSwapsChain(mockState, '0x89'); + expect(result).toBe(true); + expect(getCurrentChainIdSpy).not.toHaveBeenCalled(); // Ensure overrideChainId is used + }); + }); + + describe('getIsBridgeChain', () => { + it('returns true for an allowed bridge chainId', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'testNetworkConfigurationId', // corresponds to mainnet RPC in mockState + }, + }; + + const result = selectors.getIsBridgeChain(state); + + expect(result).toBe(true); + }); + + it('returns false for a disallowed bridge chainId', () => { + const state = { + ...mockState, + metamask: { + ...mockState.metamask, + selectedNetworkClientId: 'fooChain', // corresponds to mainnet RPC in mockState + networkConfigurationsByChainId: { + '0x8080': { + chainId: '0x8080', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: 'custom', + url: 'https://testrpc.com', + networkClientId: 'fooChain', + }, + ], + }, + }, + }, + }; + + const result = selectors.getIsBridgeChain(state); + + expect(result).toBe(false); + }); + + it('respects the overrideChainId parameter', () => { + const getCurrentChainIdSpy = jest.spyOn(selectors, 'getCurrentChainId'); + + const result = selectors.getIsBridgeChain(mockState, '0x89'); + + expect(result).toBe(true); + expect(getCurrentChainIdSpy).not.toHaveBeenCalled(); // Ensure overrideChainId is used + }); + }); }); From 03028487ee498e65cabc231bd30f73d69592aaed Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 26 Nov 2024 10:40:50 +0100 Subject: [PATCH 074/148] fix: add e2e for portfolio view polling (#28682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** PR adds an e2e test to check polling activity [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28682?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. --- privacy-snapshot.json | 2 + test/e2e/tests/privacy/polling.spec.ts | 383 +++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 test/e2e/tests/privacy/polling.spec.ts diff --git a/privacy-snapshot.json b/privacy-snapshot.json index f5cf068b8728..36249b132bca 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -29,6 +29,8 @@ "github.com", "goerli.infura.io", "lattice.gridplus.io", + "linea-mainnet.infura.io", + "linea-sepolia.infura.io", "localhost:8000", "localhost:8545", "mainnet.infura.io", diff --git a/test/e2e/tests/privacy/polling.spec.ts b/test/e2e/tests/privacy/polling.spec.ts new file mode 100644 index 000000000000..b63e1bce6191 --- /dev/null +++ b/test/e2e/tests/privacy/polling.spec.ts @@ -0,0 +1,383 @@ +import { strict as assert } from 'assert'; +import { JsonRpcRequest } from '@metamask/utils'; +import { MockedEndpoint } from 'mockttp'; +import { expect } from '@playwright/test'; +import FixtureBuilder from '../../fixture-builder'; +import { defaultGanacheOptions, withFixtures } from '../../helpers'; +import { Mockttp } from '../../mock-e2e'; +import HomePage from '../../page-objects/pages/homepage'; +import { loginWithoutBalanceValidation } from '../../page-objects/flows/login.flow'; + +const infuraMainnetUrl = + 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraSepoliaUrl = + 'https://sepolia.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaMainnetUrl = + 'https://linea-mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaSepoliaUrl = + 'https://linea-sepolia.infura.io/v3/00000000000000000000000000000000'; + +const ethGetBlockByNumberResult = { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: 367912711400466, + result: { + hash: '0x8f1697a1dfd439404fccc9ea370ab8ca4e1bb3465a6b74e5bf59891b909c5b86', + parentHash: + '0xc745f42de8dcb553511e5953b00220d2872c889261f606bbc6940600da3e24ad', + sha3Uncles: + '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + miner: '0x0000000000000000000000000000000000000000', + stateRoot: + '0x3e6f4a18a3d430fcb3748c89a32c98b7822c26ece58a28010c502af0247a5a05', + transactionsRoot: + '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + receiptsRoot: + '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + logsBloom: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + difficulty: '0x1', + number: '0xd', + gasLimit: '0x1c9c380', + gasUsed: '0x0', + timestamp: '0x67409c7e', + extraData: '0x', + mixHash: + '0x0000000000000000000000000000000000000000000000000000000000000000', + nonce: '0x0000000000000000', + totalDifficulty: '0xe', + size: '0x1fd', + transactions: [], + uncles: [], + }, + }, +}; + +async function mockInfura(mockServer: Mockttp): Promise { + const blockNumber = { value: 0 }; + return [ + // Mocks for mainnet + await mockServer + .forPost(infuraMainnetUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6327576363628226', + result: '0x1', + }, + })), + await mockServer + .forPost(infuraMainnetUrl) + .withBodyIncluding('eth_blockNumber') + .thenCallback(() => { + blockNumber.value += 1; + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: 8723760595506777, + result: blockNumber.value.toString(16), + }, + }; + }), + await mockServer + .forPost(infuraMainnetUrl) + .withBodyIncluding('eth_getBlockByNumber') + .thenCallback(() => { + return ethGetBlockByNumberResult; + }), + await mockServer + .forPost(infuraMainnetUrl) + .withBodyIncluding('eth_call') + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '3aca99b4-92a1-4ad2-be3a-ae9fdd76fdaa', + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000004e2adedda15fd6', + }, + }; + }), + // Mocks for linea mainnet + await mockServer + .forPost(infuraLineaMainnetUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6327576363628226', + result: '0x1', + }, + })), + await mockServer + .forPost(infuraLineaMainnetUrl) + .withBodyIncluding('eth_blockNumber') + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: 8794509830454968, + result: blockNumber.value.toString(16), + }, + }; + }), + await mockServer + .forPost(infuraLineaMainnetUrl) + .withBodyIncluding('eth_getBlockByNumber') + .thenCallback(() => { + return ethGetBlockByNumberResult; + }), + await mockServer + .forPost(infuraLineaMainnetUrl) + .withBodyIncluding('eth_call') + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '3aca99b4-92a1-4ad2-be3a-ae9fdd76fdaa', + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000004e2adedda15fd6', + }, + }; + }), + // Mocks for Sepolia + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6327576363628226', + result: '0x1', + }, + })), + await mockServer + .forPost(infuraSepoliaUrl) + .withBodyIncluding('eth_blockNumber') + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: 8794509830454968, + result: blockNumber.value.toString(16), + }, + }; + }), + await mockServer + .forPost(infuraSepoliaUrl) + .withBodyIncluding('eth_getBlockByNumber') + .thenCallback(() => { + return ethGetBlockByNumberResult; + }), + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '367912711400467', + result: '0x15af1d78b58c40000', + }, + })), + // Mocks for Linea Sepolia + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6327576363628226', + result: '0x1', + }, + })), + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withBodyIncluding('eth_blockNumber') + .thenCallback(() => { + return { + statusCode: 200, + json: { + jsonrpc: '2.0', + id: 8794509830454968, + result: blockNumber.value.toString(16), + }, + }; + }), + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withBodyIncluding('eth_getBlockByNumber') + .thenCallback(() => { + return ethGetBlockByNumberResult; + }), + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '367912711400467', + result: '0x15af1d78b58c40000', + }, + })), + ]; +} +const DELAY_UNTIL_NEXT_POLL = 20000; +async function getAllInfuraJsonRpcRequests( + mockedEndpoint: MockedEndpoint[], +): Promise { + const allInfuraJsonRpcRequests: JsonRpcRequest[] = []; + let seenRequests; + let seenProviderRequests; + + for (const m of mockedEndpoint) { + seenRequests = await m.getSeenRequests(); + seenProviderRequests = seenRequests.filter((request) => + request.url.match('infura'), + ); + + for (const r of seenProviderRequests) { + const json = (await r.body.getJson()) as JsonRpcRequest | undefined; + if (json !== undefined) { + allInfuraJsonRpcRequests.push(json); + } + } + } + + return allInfuraJsonRpcRequests; +} +describe('Account Tracker API polling', function () { + it('should make the expected RPC calls to infura', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .withPreferencesControllerShowNativeTokenAsMainBalanceDisabled() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test?.fullTitle(), + testSpecificMock: mockInfura, + }, + async ({ driver, mockedEndpoint }) => { + await loginWithoutBalanceValidation(driver); + const homepage = new HomePage(driver); + await homepage.check_pageIsLoaded(); + // Want to wait long enough to pull requests relevant to a single loop cycle + await driver.delay(DELAY_UNTIL_NEXT_POLL); + const infuraJsonRpcRequests = await getAllInfuraJsonRpcRequests( + mockedEndpoint, + ); + + // TODO: expecting the length of infuraJsonRpcRequests would be more accurate + if (process.env.PORTFOLIO_VIEW) { + const ethCallInfuraRequests = infuraJsonRpcRequests.filter( + (obj) => + obj.method === 'eth_call' && + (obj.params as unknown[])?.[1] === '3', + ); + + const ethGetBalanceInfuraRequests = infuraJsonRpcRequests.filter( + (obj) => + obj.method === 'eth_getBalance' && + (obj.params as unknown[])?.[1] === '3', + ); + + // We will call eth_getBalance for Sepolia and Linea Sepolia because multicall is not available for them + expect(ethGetBalanceInfuraRequests.length).toEqual(2); + // We will call eth_call for linea mainnet and mainnet + expect(ethCallInfuraRequests.length).toEqual(2); + } else { + expect( + infuraJsonRpcRequests.some( + (obj) => obj.method === 'eth_blockNumber', + ), + ).toBeTruthy(); + expect( + infuraJsonRpcRequests.some( + (obj) => obj.method === 'eth_getBlockByNumber', + ), + ).toBeTruthy(); + expect( + infuraJsonRpcRequests.some((obj) => obj.method === 'eth_call'), + ).toBeTruthy(); + } + }, + ); + }); +}); + +describe('Token Detection', function () { + async function mockAccountApiForPortfolioView(mockServer: Mockttp) { + return [ + await mockServer + .forGet( + 'https://accounts.api.cx.metamask.io/v2/accounts/0x5cfe73b6021e818b776b421b1c4db2474086a7e1/balances', + ) + .withQuery({ + networks: '1,59144', + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 0, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '20', + }, + ], + unprocessedNetworks: [], + }, + })), + ]; + } + it('should make calls to account api as expected', async function () { + if (process.env.PORTFOLIO_VIEW) { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .withPreferencesControllerShowNativeTokenAsMainBalanceDisabled() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test?.fullTitle(), + testSpecificMock: mockAccountApiForPortfolioView, + }, + async ({ driver, mockedEndpoint: mockedEndpoints }) => { + await loginWithoutBalanceValidation(driver); + const homepage = new HomePage(driver); + await homepage.check_pageIsLoaded(); + await driver.delay(DELAY_UNTIL_NEXT_POLL); + + for (const single of mockedEndpoints) { + const requests = await single.getSeenRequests(); + assert.equal( + requests.length, + 1, + `${single} should make requests after onboarding`, + ); + } + }, + ); + } + }); +}); From 6830a39762eee1854f0fd86b0c13d31872fa556e Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 26 Nov 2024 15:16:24 +0530 Subject: [PATCH 075/148] feat: Changing title for permit requests (#28537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Change title and description for permit pages. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3633 ## **Manual testing steps** 1. Enable permit signature decoding locally 2. Go to test dapp 3. Check title and description of permit pages ## **Screenshots/Recordings** Screenshot 2024-11-19 at 3 46 02 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. --------- Co-authored-by: MetaMask Bot --- .../signatures/nft-permit.spec.ts | 4 +-- .../confirmations/signatures/permit.test.tsx | 4 +-- .../components/confirm/title/title.test.tsx | 35 ------------------- .../components/confirm/title/title.tsx | 22 ------------ .../__snapshots__/confirm.test.tsx.snap | 12 +++---- 5 files changed, 10 insertions(+), 67 deletions(-) diff --git a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts index 383a3bd6b924..4aeda07a3758 100644 --- a/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts +++ b/test/e2e/tests/confirmations/signatures/nft-permit.spec.ts @@ -126,9 +126,9 @@ async function assertInfoValues(driver: Driver) { text: '0x581c3...45947', }); - const title = driver.findElement({ text: 'Withdrawal request' }); + const title = driver.findElement({ text: 'Signature request' }); const description = driver.findElement({ - text: 'This site wants permission to withdraw your NFTs', + text: 'Review request details before you confirm.', }); const primaryType = driver.findElement({ text: 'Permit' }); const spender = driver.findElement({ diff --git a/test/integration/confirmations/signatures/permit.test.tsx b/test/integration/confirmations/signatures/permit.test.tsx index 7af3be743f5f..ba51deb7336c 100644 --- a/test/integration/confirmations/signatures/permit.test.tsx +++ b/test/integration/confirmations/signatures/permit.test.tsx @@ -191,9 +191,9 @@ describe('Permit Confirmation', () => { }); await waitFor(() => { - expect(screen.getByText('Spending cap request')).toBeInTheDocument(); + expect(screen.getByText('Signature request')).toBeInTheDocument(); expect( - screen.getByText('This site wants permission to spend your tokens.'), + screen.getByText('Review request details before you confirm.'), ).toBeInTheDocument(); }); }); diff --git a/ui/pages/confirmations/components/confirm/title/title.test.tsx b/ui/pages/confirmations/components/confirm/title/title.test.tsx index 3d4d6672940d..b20b67b05c97 100644 --- a/ui/pages/confirmations/components/confirm/title/title.test.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.test.tsx @@ -8,13 +8,8 @@ import { getMockPersonalSignConfirmStateForRequest, getMockSetApprovalForAllConfirmState, getMockTypedSignConfirmState, - getMockTypedSignConfirmStateForRequest, } from '../../../../../../test/data/confirmations/helper'; import { unapprovedPersonalSignMsg } from '../../../../../../test/data/confirmations/personal_sign'; -import { - permitNFTSignatureMsg, - permitSignatureMsg, -} from '../../../../../../test/data/confirmations/typed_sign'; import { renderWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; import { tEn } from '../../../../../../test/lib/i18n-helpers'; import { @@ -59,36 +54,6 @@ describe('ConfirmTitle', () => { ).toBeInTheDocument(); }); - it('should render the title and description for a permit signature', () => { - const mockStore = configureMockStore([])( - getMockTypedSignConfirmStateForRequest(permitSignatureMsg), - ); - const { getByText } = renderWithConfirmContextProvider( - , - mockStore, - ); - - expect(getByText('Spending cap request')).toBeInTheDocument(); - expect( - getByText('This site wants permission to spend your tokens.'), - ).toBeInTheDocument(); - }); - - it('should render the title and description for a NFT permit signature', () => { - const mockStore = configureMockStore([])( - getMockTypedSignConfirmStateForRequest(permitNFTSignatureMsg), - ); - const { getByText } = renderWithConfirmContextProvider( - , - mockStore, - ); - - expect(getByText('Withdrawal request')).toBeInTheDocument(); - expect( - getByText('This site wants permission to withdraw your NFTs'), - ).toBeInTheDocument(); - }); - it('should render the title and description for typed signature', () => { const mockStore = configureMockStore([])(getMockTypedSignConfirmState()); const { getByText } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/title/title.tsx b/ui/pages/confirmations/components/confirm/title/title.tsx index a926c0f6b482..5fa3cc5b96f9 100644 --- a/ui/pages/confirmations/components/confirm/title/title.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.tsx @@ -4,7 +4,6 @@ import { } from '@metamask/transaction-controller'; import React, { memo, useMemo } from 'react'; -import { TokenStandard } from '../../../../../../shared/constants/transaction'; import GeneralAlert from '../../../../../components/app/alert-system/general-alert/general-alert'; import { Box, Text } from '../../../../../components/component-library'; import { @@ -14,7 +13,6 @@ import { } from '../../../../../helpers/constants/design-system'; import useAlerts from '../../../../../hooks/useAlerts'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; -import { TypedSignSignaturePrimaryTypes } from '../../../constants'; import { useConfirmContext } from '../../../context/confirm'; import { Confirmation, SignatureRequestType } from '../../../types/confirm'; import { isSIWESignatureRequest } from '../../../utils'; @@ -61,8 +59,6 @@ const getTitle = ( customSpendingCap?: string, isRevokeSetApprovalForAll?: boolean, pending?: boolean, - primaryType?: keyof typeof TypedSignSignaturePrimaryTypes, - tokenStandard?: string, ) => { if (pending) { return ''; @@ -79,12 +75,6 @@ const getTitle = ( } return t('confirmTitleSignature'); case TransactionType.signTypedData: - if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { - if (tokenStandard === TokenStandard.ERC721) { - return t('setApprovalForAllRedesignedTitle'); - } - return t('confirmTitlePermitTokens'); - } return t('confirmTitleSignature'); case TransactionType.tokenMethodApprove: if (isNFT) { @@ -113,8 +103,6 @@ const getDescription = ( customSpendingCap?: string, isRevokeSetApprovalForAll?: boolean, pending?: boolean, - primaryType?: keyof typeof TypedSignSignaturePrimaryTypes, - tokenStandard?: string, ) => { if (pending) { return ''; @@ -131,12 +119,6 @@ const getDescription = ( } return t('confirmTitleDescSign'); case TransactionType.signTypedData: - if (primaryType === TypedSignSignaturePrimaryTypes.PERMIT) { - if (tokenStandard === TokenStandard.ERC721) { - return t('confirmTitleDescApproveTransaction'); - } - return t('confirmTitleDescPermitSignature'); - } return t('confirmTitleDescSign'); case TransactionType.tokenMethodApprove: if (isNFT) { @@ -195,8 +177,6 @@ const ConfirmTitle: React.FC = memo(() => { customSpendingCap, isRevokeSetApprovalForAll, spendingCapPending || revokePending, - primaryType, - tokenStandard, ), [ currentConfirmation, @@ -219,8 +199,6 @@ const ConfirmTitle: React.FC = memo(() => { customSpendingCap, isRevokeSetApprovalForAll, spendingCapPending || revokePending, - primaryType, - tokenStandard, ), [ currentConfirmation, diff --git a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap index 9f718a4b8a03..1d504025a44d 100644 --- a/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap +++ b/ui/pages/confirmations/confirm/__snapshots__/confirm.test.tsx.snap @@ -457,12 +457,12 @@ exports[`Confirm should match snapshot for signature - typed sign - V4 - PermitB

- Spending cap request + Signature request

- This site wants permission to spend your tokens. + Review request details before you confirm.

- Spending cap request + Signature request

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 Qf2aP%ep^7Ne%nF-0HQ(_4gdfE literal 0 HcmV?d00001 diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf deleted file mode 100644 index 7a2ae44a166de9dfdb2aecce6290a99fe43e1387..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160832 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�$p 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 literal 0 HcmV?d00001 diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf deleted file mode 100644 index 35345ba0f23b31905d031337331a43620a32cb4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 157072 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