diff --git a/.madgerc b/.madgerc index 7f83b44a330a..9fe4e9579f42 100644 --- a/.madgerc +++ b/.madgerc @@ -22,11 +22,5 @@ "skipAsyncImports": true } }, - "allowedCircularGlob": [ - "ui/ducks/**", - "ui/selectors/**", - "ui/store/**", - "ui/components/app/**", - "shared/lib/selectors/**" - ] + "allowedCircularGlob": [] } diff --git a/development/circular-deps.jsonc b/development/circular-deps.jsonc index e08d3fdd50d1..978ae552f535 100644 --- a/development/circular-deps.jsonc +++ b/development/circular-deps.jsonc @@ -6,55 +6,4 @@ // - To prevent new circular dependencies, ensure your changes don't add new cycles // - For more information contact the Extension Platform team. -[ - [ - "shared/lib/selectors/account.ts", - "shared/lib/selectors/index.ts", - "ui/ducks/metamask/metamask.js", - "ui/selectors/selectors.js", - "ui/store/actions.ts" - ], - [ - "ui/components/app/metamask-template-renderer/index.js", - "ui/components/app/metamask-template-renderer/metamask-template-renderer.js", - "ui/components/app/metamask-template-renderer/safe-component-list.js", - "ui/components/app/metamask-translation/index.js", - "ui/components/app/metamask-translation/metamask-translation.js" - ], - [ - "ui/ducks/metamask/metamask.js", - "ui/selectors/confirm-transaction.js", - "ui/selectors/custom-gas.js", - "ui/selectors/index.js", - "ui/selectors/selectors.js", - "ui/store/actions.ts" - ], - [ - "ui/ducks/metamask/metamask.js", - "ui/selectors/confirm-transaction.js", - "ui/selectors/custom-gas.js", - "ui/selectors/index.js", - "ui/store/actions.ts" - ], - [ - "ui/ducks/metamask/metamask.js", - "ui/selectors/confirm-transaction.js", - "ui/selectors/index.js", - "ui/selectors/selectors.js", - "ui/store/actions.ts" - ], - [ - "ui/ducks/metamask/metamask.js", - "ui/selectors/confirm-transaction.js", - "ui/selectors/index.js", - "ui/store/actions.ts" - ], - [ - "ui/ducks/metamask/metamask.js", - "ui/selectors/index.js", - "ui/selectors/selectors.js", - "ui/store/actions.ts" - ], - ["ui/ducks/metamask/metamask.js", "ui/selectors/selectors.js"], - ["ui/ducks/metamask/metamask.js", "ui/store/actions.ts"] -] +[] diff --git a/scripts/count-circular-deps.sh b/scripts/count-circular-deps.sh new file mode 100755 index 000000000000..60a9df6c8306 --- /dev/null +++ b/scripts/count-circular-deps.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Count circular dependency cycles from development/circular-deps.jsonc +# Usage: ./scripts/count-circular-deps.sh (run from mm-extension root) +# Run after: yarn circular-deps:update +# Outputs: single integer (cycle count) + +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +FILE="development/circular-deps.jsonc" +if [[ ! -f "$FILE" ]]; then + echo "Error: $FILE not found. Run yarn circular-deps:update first." >&2 + exit 1 +fi + +node -e " +const fs = require('fs'); +const content = fs.readFileSync('$FILE', 'utf8'); +const json = content.split('\n').filter(l => !l.trim().startsWith('//')).join('\n'); +const arr = JSON.parse(json); +console.log(Array.isArray(arr) ? arr.length : 0); +" diff --git a/shared/lib/selectors/account.ts b/shared/lib/selectors/account.ts index 4ff0dec0b261..4701594a3656 100644 --- a/shared/lib/selectors/account.ts +++ b/shared/lib/selectors/account.ts @@ -1,5 +1,40 @@ -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { isHardwareWallet } from '../../../ui/selectors/selectors'; +import { getCurrentKeyringFromState } from './metamask-keyring'; +import { + isHardwareWalletFromKeyring, + getHardwareWalletTypeFromKeyring, +} from './keyring-utils'; -export { isHardwareWallet }; +type MetamaskAccountsState = { + metamask?: { + internalAccounts?: { + selectedAccount?: string; + accounts?: Record; + }; + }; +}; + +/** + * Returns true if the current wallet is a hardware wallet. + * Implemented in shared to avoid circular dependency on ui/selectors. + * + * @param state - Redux state + * @returns true if hardware wallet + */ +export function isHardwareWallet(state: MetamaskAccountsState): boolean { + const keyring = getCurrentKeyringFromState(state); + return isHardwareWalletFromKeyring(keyring); +} + +/** + * Returns the hardware wallet type string (e.g. "Ledger Hardware"), or undefined. + * Implemented in shared to avoid circular dependency on ui/selectors. + * + * @param state - Redux state + * @returns type string or undefined + */ +export function getHardwareWalletType( + state: MetamaskAccountsState, +): string | undefined { + const keyring = getCurrentKeyringFromState(state); + return getHardwareWalletTypeFromKeyring(keyring); +} diff --git a/shared/lib/selectors/index.ts b/shared/lib/selectors/index.ts index 6e983385b5cf..efd2a919dfd0 100644 --- a/shared/lib/selectors/index.ts +++ b/shared/lib/selectors/index.ts @@ -1,8 +1,2 @@ -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { getHardwareWalletType } from '../../../ui/selectors/selectors'; - export * from './smart-transactions'; export * from './account'; - -export { getHardwareWalletType }; diff --git a/shared/lib/selectors/keyring-utils.ts b/shared/lib/selectors/keyring-utils.ts new file mode 100644 index 000000000000..525188f64e79 --- /dev/null +++ b/shared/lib/selectors/keyring-utils.ts @@ -0,0 +1,31 @@ +/** + * Pure keyring helpers. No UI or state dependencies. + * Used by shared selectors to avoid circular dependency on ui/selectors. + */ + +/** + * Keyring-like object with optional type string. + */ +type KeyringLike = { type?: string } | null | undefined; + +/** + * Returns true if the keyring is a hardware wallet (type includes 'Hardware'). + * + * @param keyring - Keyring object from state + * @returns true if hardware wallet + */ +export function isHardwareWalletFromKeyring(keyring: KeyringLike): boolean { + return Boolean(keyring?.type?.includes('Hardware')); +} + +/** + * Returns the hardware wallet type string, or undefined if not a hardware wallet. + * + * @param keyring - Keyring object from state + * @returns type string e.g. "Ledger Hardware" or undefined + */ +export function getHardwareWalletTypeFromKeyring( + keyring: KeyringLike, +): string | undefined { + return isHardwareWalletFromKeyring(keyring) ? keyring?.type : undefined; +} diff --git a/shared/lib/selectors/metamask-keyring.ts b/shared/lib/selectors/metamask-keyring.ts new file mode 100644 index 000000000000..70ad14345a30 --- /dev/null +++ b/shared/lib/selectors/metamask-keyring.ts @@ -0,0 +1,51 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; + +/** + * Minimal accessors for MetaMask state. + * No UI imports - only reads state shape to break shared→ui cycle. + */ + +type InternalAccountsState = { + metamask?: { + internalAccounts?: { + selectedAccount?: string; + accounts?: Record; + }; + preferences?: Record; + }; +}; + +/** + * Returns the keyring for the currently selected internal account. + * Uses minimal state shape to avoid depending on ui/selectors. + * + * @param state - Redux state with metamask.internalAccounts + * @returns keyring object or null + */ +export function getCurrentKeyringFromState( + state: InternalAccountsState, +): { type?: string } | null { + const selectedAccount = state.metamask?.internalAccounts?.selectedAccount; + const accounts = state.metamask?.internalAccounts?.accounts; + if (!selectedAccount || !accounts) { + return null; + } + const account = accounts[selectedAccount]; + return account?.metadata?.keyring ?? null; +} + +/** + * Returns metamask preferences. Used by smart-transactions to avoid ui import. + */ +export function getPreferences(state: InternalAccountsState): Record { + return state.metamask?.preferences ?? {}; +} + +/** + * Returns true if the current account supports smart transactions (not a snap account). + * Used by smart-transactions to avoid ui import. + */ +export function accountSupportsSmartTx(state: InternalAccountsState): boolean { + const keyring = getCurrentKeyringFromState(state); + return keyring?.type !== KeyringTypes.snap; +} diff --git a/shared/lib/selectors/networks.ts b/shared/lib/selectors/networks.ts index 045d4402848e..18a832214782 100644 --- a/shared/lib/selectors/networks.ts +++ b/shared/lib/selectors/networks.ts @@ -68,6 +68,30 @@ export const getNetworkConfigurationsByChainId = ( state: NetworkConfigurationsByChainIdState, ) => state.metamask.networkConfigurationsByChainId; +/** + * Parameterized selector: returns network configuration for a given chain ID. + */ +export const selectNetworkConfigurationByChainId = createSelector( + getNetworkConfigurationsByChainId, + (_state: NetworkConfigurationsByChainIdState, chainId: string) => chainId, + (networkConfigurationsByChainId, chainId) => + networkConfigurationsByChainId?.[chainId], +); + +/** + * Parameterized selector: returns the default RPC endpoint for a given chain ID. + */ +export const selectDefaultRpcEndpointByChainId = createSelector( + selectNetworkConfigurationByChainId, + (networkConfiguration) => { + if (!networkConfiguration) { + return undefined; + } + const { defaultRpcEndpointIndex, rpcEndpoints } = networkConfiguration; + return rpcEndpoints[defaultRpcEndpointIndex]; + }, +); + export const selectDefaultNetworkClientIdsByChainId = createSelector( getNetworkConfigurationsByChainId, (networkConfigurationsByChainId) => { diff --git a/shared/lib/selectors/smart-transactions.ts b/shared/lib/selectors/smart-transactions.ts index fa0ad52b9b28..d3c34a5d1937 100644 --- a/shared/lib/selectors/smart-transactions.ts +++ b/shared/lib/selectors/smart-transactions.ts @@ -12,17 +12,18 @@ import { import { accountSupportsSmartTx, getPreferences, +} from './metamask-keyring'; +import { + getCurrentChainId, selectDefaultRpcEndpointByChainId, - // TODO: Remove restricted import - // eslint-disable-next-line import/no-restricted-paths -} from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file. + type NetworkState, +} from './networks'; import { getRemoteFeatureFlags, type RemoteFeatureFlagsState, // eslint-disable-next-line import/no-restricted-paths } from '../../../ui/selectors/remote-feature-flags'; import { isProduction } from '../environment'; -import { getCurrentChainId, type NetworkState } from './networks'; import { createDeepEqualSelector } from './util'; export type SmartTransactionsMetaMaskState = { diff --git a/ui/components/app/metamask-template-renderer/metamask-template-renderer.js b/ui/components/app/metamask-template-renderer/metamask-template-renderer.js index 4120c9959e1d..f058ded5a312 100644 --- a/ui/components/app/metamask-template-renderer/metamask-template-renderer.js +++ b/ui/components/app/metamask-template-renderer/metamask-template-renderer.js @@ -1,6 +1,7 @@ import React, { memo } from 'react'; import { isEqual } from 'lodash'; import { safeComponentList } from './safe-component-list'; +import { TemplateRendererContext } from './template-renderer-context'; import { ValidChildren } from './section-shape'; function getElement(section) { @@ -42,25 +43,16 @@ function getPropComponents(components) { } const MetaMaskTemplateRenderer = ({ sections }) => { - if (!sections) { - // If sections is null eject early by returning null - return null; - } else if (typeof sections === 'string') { - // React can render strings directly, so return the string - return sections; - } else if ( - sections && - typeof sections === 'object' && - !Array.isArray(sections) - ) { - // If dealing with a single entry, then render a single object without key - return renderElement(sections); - } - - // The last case is dealing with an array of objects - return ( - <> - {sections.reduce((allChildren, child) => { + const content = + !sections ? null : typeof sections === 'string' ? ( + sections + ) : sections && + typeof sections === 'object' && + !Array.isArray(sections) ? ( + renderElement(sections) + ) : ( + <> + {sections.reduce((allChildren, child) => { if (child === undefined || child?.hide === true) { return allChildren; } @@ -97,7 +89,13 @@ const MetaMaskTemplateRenderer = ({ sections }) => { } return allChildren; }, [])} - + + ); + + return ( + + {content} + ); }; diff --git a/ui/components/app/metamask-template-renderer/template-renderer-context.ts b/ui/components/app/metamask-template-renderer/template-renderer-context.ts new file mode 100644 index 000000000000..bf1fa1c65be8 --- /dev/null +++ b/ui/components/app/metamask-template-renderer/template-renderer-context.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +/** + * Context to inject the template renderer component into MetaMaskTranslation. + * Breaks the circular dependency: MetaMaskTranslation no longer imports + * MetaMaskTemplateRenderer directly; it gets the renderer from this context. + */ +export type TemplateRendererComponent = React.ComponentType<{ + sections: unknown; +}>; + +export const TemplateRendererContext = + React.createContext(null); diff --git a/ui/components/app/metamask-translation/metamask-translation.js b/ui/components/app/metamask-translation/metamask-translation.js index 1694ce684fc8..6d5c0630af4d 100644 --- a/ui/components/app/metamask-translation/metamask-translation.js +++ b/ui/components/app/metamask-translation/metamask-translation.js @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import MetaMaskTemplateRenderer from '../metamask-template-renderer'; +import { TemplateRendererContext } from '../metamask-template-renderer/template-renderer-context'; import { SectionShape } from '../metamask-template-renderer/section-shape'; /** @@ -26,6 +26,7 @@ import { SectionShape } from '../metamask-template-renderer/section-shape'; */ export default function MetaMaskTranslation({ translationKey, variables }) { const t = useI18nContext(); + const TemplateRenderer = useContext(TemplateRendererContext); return t( translationKey, @@ -59,8 +60,13 @@ export default function MetaMaskTranslation({ translationKey, variables }) { 'MetaMaskTranslation does not allow for component trees of non trivial depth', ); } + if (!TemplateRenderer) { + throw new Error( + 'MetaMaskTranslation must be used inside MetaMaskTemplateRenderer (TemplateRendererContext.Provider)', + ); + } return ( - diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 1e9c530848dd..58a049e00649 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -21,7 +21,7 @@ import { accountsWithSendEtherInfoSelector, checkNetworkAndAccountSupports1559, getAddressBook, -} from '../../selectors/selectors'; +} from '../../selectors/send-ether-selectors'; import { getProviderConfig, getSelectedNetworkClientId, @@ -31,6 +31,13 @@ import * as actionConstants from '../../store/actionConstants'; import { updateTransactionGasFees } from '../../store/actions'; import { setCustomGasLimit, setCustomGasPrice } from '../gas/gas.duck'; import { FirstTimeFlowType } from '../../../shared/constants/onboarding'; +import { + getAlertEnabledness, + getUnconnectedAccountAlertEnabledness, + getCompletedOnboarding, +} from '../../selectors/metamask-state-basic'; + +export { getAlertEnabledness, getUnconnectedAccountAlertEnabledness, getCompletedOnboarding }; const initialState = { isInitialized: false, @@ -248,11 +255,6 @@ export function updateGasFees({ // Selectors -export const getAlertEnabledness = (state) => state.metamask.alertEnabledness; - -export const getUnconnectedAccountAlertEnabledness = (state) => - getAlertEnabledness(state)[AlertTypes.unconnectedAccount]; - export const getWeb3ShimUsageAlertEnabledness = (state) => getAlertEnabledness(state)[AlertTypes.web3ShimUsage]; @@ -361,16 +363,6 @@ export function isNotEIP1559Network(state) { * @param state * @param networkClientId - The optional network client ID to check for EIP-1559 support. Defaults to the currently selected network. */ -export function isEIP1559Network(state, networkClientId) { - const selectedNetworkClientId = getSelectedNetworkClientId(state); - - return ( - state.metamask.networksMetadata?.[ - networkClientId ?? selectedNetworkClientId - ]?.EIPS[1559] === true - ); -} - function getGasFeeControllerEstimateType(state) { return state.metamask.gasEstimateType; } @@ -526,9 +518,6 @@ export function getIsNetworkBusyByChainId(state, chainId) { return gasFeeEstimates?.networkCongestion >= NetworkCongestionThresholds.busy; } -export function getCompletedOnboarding(state) { - return state.metamask.completedOnboarding; -} export function getIsInitialized(state) { return state.metamask.isInitialized; } diff --git a/ui/pages/confirmations/confirmation/templates/approval-types.ts b/ui/pages/confirmations/confirmation/templates/approval-types.ts new file mode 100644 index 000000000000..ef93c1cba9f4 --- /dev/null +++ b/ui/pages/confirmations/confirmation/templates/approval-types.ts @@ -0,0 +1,30 @@ +/** + * List of approval types that use templated confirmations. + * Extracted to break circular dependency: selectors → templates → actions → selectors. + * Only imports from constants - no store/actions or selectors. + */ +import { ApprovalType } from '@metamask/controller-utils'; +import { + HYPERLIQUID_APPROVAL_TYPE, + ASTERDEX_APPROVAL_TYPE, + GMX_APPROVAL_TYPE, + SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES, + SMART_TRANSACTION_CONFIRMATION_TYPES, +} from '../../../../../shared/constants/app'; + +export const TEMPLATED_CONFIRMATION_APPROVAL_TYPES: readonly string[] = [ + ApprovalType.SwitchEthereumChain, + ApprovalType.ResultSuccess, + ApprovalType.ResultError, + SMART_TRANSACTION_CONFIRMATION_TYPES.showSmartTransactionStatusPage, + ApprovalType.SnapDialogAlert, + ApprovalType.SnapDialogConfirmation, + ApprovalType.SnapDialogPrompt, + ApprovalType.SnapDialogDefault, + SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountCreation, + SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountRemoval, + SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.showSnapAccountRedirect, + HYPERLIQUID_APPROVAL_TYPE, + ASTERDEX_APPROVAL_TYPE, + GMX_APPROVAL_TYPE, +]; diff --git a/ui/pages/confirmations/confirmation/templates/index.js b/ui/pages/confirmations/confirmation/templates/index.js index 556996eb65a5..94a02e0fc6eb 100644 --- a/ui/pages/confirmations/confirmation/templates/index.js +++ b/ui/pages/confirmations/confirmation/templates/index.js @@ -49,8 +49,7 @@ const APPROVAL_TEMPLATES = { [GMX_APPROVAL_TYPE]: defiReferralConsent, }; -export const TEMPLATED_CONFIRMATION_APPROVAL_TYPES = - Object.keys(APPROVAL_TEMPLATES); +export { TEMPLATED_CONFIRMATION_APPROVAL_TYPES } from './approval-types'; const ALLOWED_TEMPLATE_KEYS = [ 'cancelText', diff --git a/ui/pages/confirmations/hooks/useConfirmationNavigation.test.ts b/ui/pages/confirmations/hooks/useConfirmationNavigation.test.ts index cb9d96b85698..94551a7a3bc6 100644 --- a/ui/pages/confirmations/hooks/useConfirmationNavigation.test.ts +++ b/ui/pages/confirmations/hooks/useConfirmationNavigation.test.ts @@ -33,7 +33,7 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../confirmation/templates', () => ({ +jest.mock('../confirmation/templates/approval-types', () => ({ TEMPLATED_CONFIRMATION_APPROVAL_TYPES: ['wallet_addEthereumChain'], })); diff --git a/ui/pages/confirmations/hooks/useConfirmationNavigation.ts b/ui/pages/confirmations/hooks/useConfirmationNavigation.ts index c8d65dabe53a..2bc13b7bddf9 100644 --- a/ui/pages/confirmations/hooks/useConfirmationNavigation.ts +++ b/ui/pages/confirmations/hooks/useConfirmationNavigation.ts @@ -6,7 +6,7 @@ import { isEqual } from 'lodash'; import { ApprovalRequest } from '@metamask/approval-controller'; import { Json } from '@metamask/utils'; -import { TEMPLATED_CONFIRMATION_APPROVAL_TYPES } from '../confirmation/templates'; +import { TEMPLATED_CONFIRMATION_APPROVAL_TYPES } from '../confirmation/templates/approval-types'; import { CONFIRM_ADD_SUGGESTED_NFT_ROUTE, CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index 857fef2cb36c..9d1153069898 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -14,7 +14,7 @@ import { getGasEstimateType, getGasFeeEstimates, getNativeCurrency, -} from '../ducks/metamask/metamask'; +} from './metamask-gas-selectors'; import { GasEstimateTypes, CUSTOM_GAS_ESTIMATE, diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index 2f0a9a6c6299..63fb8a8308b4 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -9,12 +9,12 @@ import { GasEstimateTypes as GAS_FEE_CONTROLLER_ESTIMATE_TYPES } from '../../sha import { getGasEstimateType, getGasFeeEstimates, - isEIP1559Network, -} from '../ducks/metamask/metamask'; +} from './metamask-gas-selectors'; +import { isEIP1559Network } from './send-ether-selectors'; import { calcGasTotal } from '../../shared/lib/transactions-controller-utils'; import { Numeric } from '../../shared/lib/Numeric'; import { EtherDenomination } from '../../shared/constants/common'; -import { getIsMainnet } from './selectors'; +import { getIsMainnet } from './metamask-state-basic'; export function getCustomGasLimit(state) { return state.gas.customData.limit; diff --git a/ui/selectors/metamask-gas-selectors.ts b/ui/selectors/metamask-gas-selectors.ts new file mode 100644 index 000000000000..913b38356a77 --- /dev/null +++ b/ui/selectors/metamask-gas-selectors.ts @@ -0,0 +1,62 @@ +/** + * Gas-related selectors that do not depend on metamask duck or the rest of + * selectors. Used to break the metamask → actions → selectors → confirm-transaction → metamask cycle. + * + * Imports only from: shared, reselect, transaction-controller. + */ + +import { createSelector } from 'reselect'; +import { mergeGasFeeEstimates } from '@metamask/transaction-controller'; +import { getProviderConfig } from '../../shared/lib/selectors/networks'; +import type { MetaMaskReduxState } from '../store/store'; + +function getGasFeeControllerEstimateType( + state: MetaMaskReduxState, +): string | undefined { + return state.metamask.gasEstimateType; +} + +function getGasFeeControllerEstimates(state: MetaMaskReduxState): unknown { + return state.metamask.gasFeeEstimates; +} + +function getTransactionGasFeeEstimates(state: MetaMaskReduxState): unknown { + const transactionMetadata = state.confirmTransaction?.txData; + return transactionMetadata?.gasFeeEstimates; +} + +const getTransactionGasFeeEstimateType = createSelector( + getTransactionGasFeeEstimates, + (transactionGasFeeEstimates: unknown) => + (transactionGasFeeEstimates as { type?: string })?.type, +); + +export const getGasEstimateType = createSelector( + getGasFeeControllerEstimateType, + getTransactionGasFeeEstimateType, + ( + gasFeeControllerEstimateType: string | undefined, + transactionGasFeeEstimateType: string | undefined, + ) => { + return transactionGasFeeEstimateType ?? gasFeeControllerEstimateType; + }, +); + +export const getGasFeeEstimates = createSelector( + getGasFeeControllerEstimates, + getTransactionGasFeeEstimates, + (gasFeeControllerEstimates, transactionGasFeeEstimates) => { + if (transactionGasFeeEstimates) { + return mergeGasFeeEstimates({ + gasFeeControllerEstimates, + transactionGasFeeEstimates, + }); + } + + return gasFeeControllerEstimates; + }, +); + +export function getNativeCurrency(state: MetaMaskReduxState): string { + return getProviderConfig(state).ticker; +} diff --git a/ui/selectors/metamask-selectors-standalone.ts b/ui/selectors/metamask-selectors-standalone.ts new file mode 100644 index 000000000000..f001014cc97c --- /dev/null +++ b/ui/selectors/metamask-selectors-standalone.ts @@ -0,0 +1,61 @@ +/** + * Metamask selectors that do not depend on metamask duck or the rest of selectors. + * Used to break the metamask → actions → index → selectors → metamask cycle. + * + * Imports only from: shared, metamask-state-basic, send-ether-selectors. + */ + +import { addHexPrefix, stripHexPrefix } from 'ethereumjs-util'; +import { KeyringType } from '../../shared/constants/keyring'; +import { isEqualCaseInsensitive } from '../../shared/lib/string-utils'; +import { getProviderConfig } from '../../shared/lib/selectors/networks'; +import { getCurrencyRateControllerCurrencyRates } from '../../shared/lib/selectors/assets-migration'; +import type { MetaMaskReduxState } from '../store/store'; +import { getCompletedOnboarding } from './metamask-state-basic'; +import { isNotEIP1559Network } from './send-ether-selectors'; + +export { getCompletedOnboarding, isNotEIP1559Network }; + +export function getConversionRate( + state: MetaMaskReduxState, +): number | undefined { + return ( + getCurrencyRateControllerCurrencyRates(state)[ + getProviderConfig(state).ticker + ]?.conversionRate ?? undefined + ); +} + +export function getIsUnlocked(state: MetaMaskReduxState): boolean { + return state.metamask.isUnlocked; +} + +export function getLedgerTransportType( + state: MetaMaskReduxState, +): string | undefined { + return state.metamask.ledgerTransportType; +} + +function findKeyringForAddress( + state: MetaMaskReduxState, + address: string, +): { type?: string; accounts: string[] } | undefined { + const keyring = state.metamask.keyrings.find((kr: { accounts: string[] }) => { + return kr.accounts.some((account: string) => { + return ( + isEqualCaseInsensitive(account, addHexPrefix(address)) || + isEqualCaseInsensitive(account, stripHexPrefix(address)) + ); + }); + }); + + return keyring; +} + +export function isAddressLedger( + state: MetaMaskReduxState, + address: string, +): boolean { + const keyring = findKeyringForAddress(state, address); + return keyring?.type === KeyringType.ledger; +} diff --git a/ui/selectors/metamask-state-basic.ts b/ui/selectors/metamask-state-basic.ts new file mode 100644 index 000000000000..f0c0b5320d48 --- /dev/null +++ b/ui/selectors/metamask-state-basic.ts @@ -0,0 +1,25 @@ +/** + * Minimal metamask state selectors with no dependency on store/actions or ducks/metamask. + * Used to break the metamask.js ↔ actions.ts circular dependency. + */ +import { AlertTypes } from '../../shared/constants/alerts'; +import { CHAIN_IDS } from '../../shared/constants/network'; +import { getCurrentChainId } from '../../shared/lib/selectors/networks'; +import type { MetaMaskReduxState } from '../store/store'; + +export const getAlertEnabledness = ( + state: MetaMaskReduxState, +): Record => state.metamask.alertEnabledness; + +export const getUnconnectedAccountAlertEnabledness = ( + state: MetaMaskReduxState, +): boolean => getAlertEnabledness(state)[AlertTypes.unconnectedAccount]; + +export function getCompletedOnboarding(state: MetaMaskReduxState): boolean { + return state.metamask.completedOnboarding; +} + +export function getIsMainnet(state: MetaMaskReduxState): boolean { + const chainId = getCurrentChainId(state); + return chainId === CHAIN_IDS.MAINNET; +} diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 495c6124712c..81fcbe3df850 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -127,7 +127,7 @@ import { sortSelectedInternalAccounts, } from '../helpers/utils/util'; -import { TEMPLATED_CONFIRMATION_APPROVAL_TYPES } from '../pages/confirmations/confirmation/templates'; +import { TEMPLATED_CONFIRMATION_APPROVAL_TYPES } from '../pages/confirmations/confirmation/templates/approval-types'; 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'; @@ -138,12 +138,11 @@ import { import { getConversionRate, isNotEIP1559Network, - isEIP1559Network, getLedgerTransportType, isAddressLedger, getIsUnlocked, getCompletedOnboarding, -} from '../ducks/metamask/metamask'; +} from './metamask-selectors-standalone'; import { getLedgerWebHidConnectedStatus, getLedgerTransportStatus, @@ -184,6 +183,8 @@ import { getCurrentNetworkTransactions, } from './transactions'; import { EMPTY_ARRAY, EMPTY_OBJECT } from './shared'; +import { accountsWithSendEtherInfoSelector } from './send-ether-selectors'; +import { getIsMainnet } from './metamask-state-basic'; /** * @typedef {import('../../ui/store/store').MetaMaskReduxState} MetaMaskReduxState @@ -403,10 +404,9 @@ export function getCurrentKeyring(state) { * @param state * @param [networkClientId] - The optional network client ID to check network and account for EIP-1559 support */ -export function checkNetworkAndAccountSupports1559(state, networkClientId) { - const networkSupports1559 = isEIP1559Network(state, networkClientId); - return networkSupports1559; -} +export { + checkNetworkAndAccountSupports1559, +} from './send-ether-selectors'; /** * The function returns true if network and account details are fetched and @@ -1168,13 +1168,7 @@ export const getTokensMarketData = (state) => { export { getTokenRatesControllerMarketData as getMarketData }; -export function getAddressBook(state) { - const chainId = getCurrentChainId(state); - if (!state.metamask.addressBook[chainId]) { - return []; - } - return Object.values(state.metamask.addressBook[chainId]); -} +export { getAddressBook } from './send-ether-selectors'; export function getCompleteAddressBook(state) { const addresses = state.metamask.addressBook; @@ -1233,21 +1227,7 @@ export function getAccountName(accounts, accountAddress) { return account && account.metadata.name !== '' ? account.metadata.name : ''; } -export function accountsWithSendEtherInfoSelector(state) { - const accounts = getMetaMaskAccounts(state); - const internalAccounts = getInternalAccounts(state); - - const accountsWithSendEtherInfo = Object.values(internalAccounts).map( - (internalAccount) => { - return { - ...internalAccount, - ...accounts[internalAccount.address], - }; - }, - ); - - return accountsWithSendEtherInfo; -} +export { accountsWithSendEtherInfoSelector } from './send-ether-selectors'; export const getAccountsWithLabels = createSelector( getMetaMaskAccountsOrdered, @@ -1432,10 +1412,7 @@ export function getSuggestedNfts(state) { ); } -export function getIsMainnet(state) { - const chainId = getCurrentChainId(state); - return chainId === CHAIN_IDS.MAINNET; -} +export { getIsMainnet } from './metamask-state-basic'; export function getIsLineaMainnet(state) { const chainId = getCurrentChainId(state); diff --git a/ui/selectors/send-ether-selectors.ts b/ui/selectors/send-ether-selectors.ts new file mode 100644 index 000000000000..f6d0f721dfb9 --- /dev/null +++ b/ui/selectors/send-ether-selectors.ts @@ -0,0 +1,222 @@ +/** + * Send-ether-related selectors that do not depend on metamask duck or the rest + * of selectors. Used to break the metamask ↔ selectors circular dependency. + * + * Imports only from: shared, ui/selectors/accounts, ui/selectors/shared. + */ + +import { createSelector } from 'reselect'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { + getCurrentChainId, + getSelectedNetworkClientId, +} from '../../shared/lib/selectors/networks'; +import { + getAccountTrackerControllerAccountsByChainId, + getMultiChainBalancesControllerBalances, +} from '../../shared/lib/selectors/assets-migration'; +import { getEnabledNetworks } from '../../shared/lib/selectors/multichain'; +import { createParameterizedShallowEqualSelector } from '../../shared/lib/selectors/selector-creators'; +import { MULTICHAIN_PROVIDER_CONFIGS } from '../../shared/constants/multichain/networks'; +import { MULTICHAIN_NETWORK_TO_ASSET_TYPES } from '../../shared/constants/multichain/assets'; +import type { MetaMaskReduxState } from '../store/store'; +import { getInternalAccounts } from './accounts'; +import { EMPTY_OBJECT } from './shared'; + +// ----------------------------------------------------------------------------- +// getAddressBook +// ----------------------------------------------------------------------------- + +export function getAddressBook(state: MetaMaskReduxState): unknown[] { + const chainId = getCurrentChainId(state); + if (!state.metamask.addressBook[chainId]) { + return []; + } + return Object.values(state.metamask.addressBook[chainId]); +} + +// ----------------------------------------------------------------------------- +// isEIP1559Network & checkNetworkAndAccountSupports1559 +// ----------------------------------------------------------------------------- + +export function isEIP1559Network( + state: MetaMaskReduxState, + networkClientId?: string, +): boolean { + const selectedNetworkClientId = getSelectedNetworkClientId(state); + + return ( + state.metamask.networksMetadata?.[ + networkClientId ?? selectedNetworkClientId + ]?.EIPS?.[1559] === true + ); +} + +export function checkNetworkAndAccountSupports1559( + state: MetaMaskReduxState, + networkClientId?: string, +): boolean { + return isEIP1559Network(state, networkClientId); +} + +export function isNotEIP1559Network(state: MetaMaskReduxState): boolean { + const selectedNetworkClientId = getSelectedNetworkClientId(state); + return ( + state.metamask.networksMetadata?.[selectedNetworkClientId]?.EIPS?.[1559] === + false + ); +} + +// ----------------------------------------------------------------------------- +// getMetaMaskAccountBalances, getMetaMaskCachedBalances, getMetaMaskAccounts +// (internal helpers for accountsWithSendEtherInfoSelector) +// ----------------------------------------------------------------------------- + +const getMetaMaskAccountBalances = createSelector( + getAccountTrackerControllerAccountsByChainId, + getCurrentChainId, + (accountsByChainId, currentChainId) => { + const balancesForCurrentChain = accountsByChainId?.[currentChainId] ?? {}; + if (Object.keys(balancesForCurrentChain).length === 0) { + return EMPTY_OBJECT; + } + return Object.entries(balancesForCurrentChain).reduce( + (acc: Record, [address, value]) => { + acc[address.toLowerCase()] = value; + return acc; + }, + {}, + ); + }, +); + +const getMetaMaskCachedBalances = createSelector( + getAccountTrackerControllerAccountsByChainId, + getEnabledNetworks, + getCurrentChainId, + (_state: MetaMaskReduxState, networkChainId?: string) => networkChainId, + (accountsByChainId, enabledNetworks, currentChainId, networkChainId) => { + const eip155 = enabledNetworks?.eip155 ?? {}; + const enabledIds = Object.keys(eip155).filter((id) => Boolean(eip155[id])); + if (enabledIds.length === 1) { + const chainId = enabledIds[0]; + if (Object.keys(accountsByChainId?.[chainId] ?? {}).length === 0) { + return EMPTY_OBJECT; + } + return Object.entries(accountsByChainId[chainId]).reduce( + (accumulator: Record, [key, value]) => { + accumulator[key.toLowerCase()] = value.balance ?? '0'; + return accumulator; + }, + {}, + ); + } + + const chainId = networkChainId ?? currentChainId; + if (Object.keys(accountsByChainId?.[chainId] ?? {}).length === 0) { + return EMPTY_OBJECT; + } + return Object.entries(accountsByChainId[chainId]).reduce( + (accumulator: Record, [key, value]) => { + accumulator[key.toLowerCase()] = value.balance ?? '0'; + return accumulator; + }, + {}, + ); + }, +); + +const createChainIdSelector = createParameterizedShallowEqualSelector(10); + +const getMetaMaskAccounts = createChainIdSelector( + getInternalAccounts, + getMetaMaskAccountBalances, + getMetaMaskCachedBalances, + getMultiChainBalancesControllerBalances, + getCurrentChainId, + (_state: MetaMaskReduxState, chainId?: string) => chainId, + ( + internalAccounts: InternalAccount[], + balances: Record, + cachedBalances: Record, + multichainBalances: + | Record> + | undefined, + currentChainId: string, + chainId: string | undefined, + ) => { + return internalAccounts.reduce( + ( + accounts: Record, + internalAccount, + ) => { + let account: InternalAccount & { balance?: string } = internalAccount; + + if (chainId === undefined || currentChainId === chainId) { + if (isEvmAccountType(internalAccount.type)) { + if (balances?.[internalAccount.address]) { + account = { + ...account, + ...(balances[internalAccount.address] as object), + }; + } + } else { + const multichainNetwork = Object.values( + MULTICHAIN_PROVIDER_CONFIGS, + ).find((network) => + internalAccount.scopes?.some( + (scope) => scope === network.chainId, + ), + ); + account = { + ...account, + balance: + multichainBalances?.[internalAccount.id]?.[ + multichainNetwork + ? MULTICHAIN_NETWORK_TO_ASSET_TYPES[ + multichainNetwork.chainId as keyof typeof MULTICHAIN_NETWORK_TO_ASSET_TYPES + ]?.[0] ?? '' + : '' + ]?.amount ?? '0', + }; + } + + if (account.balance === null || account.balance === undefined) { + account = { + ...account, + balance: cachedBalances?.[internalAccount.address] ?? '0x0', + }; + } + } else { + account = { + ...account, + balance: cachedBalances?.[internalAccount.address] ?? '0x0', + }; + } + + accounts[internalAccount.address] = account; + return accounts; + }, + {}, + ); + }, +); + +// ----------------------------------------------------------------------------- +// accountsWithSendEtherInfoSelector (memoized) +// ----------------------------------------------------------------------------- + +export const accountsWithSendEtherInfoSelector = createSelector( + getInternalAccounts, + (state: MetaMaskReduxState) => getMetaMaskAccounts(state), + ( + internalAccounts: InternalAccount[], + accounts: Record, + ) => { + return internalAccounts.map((internalAccount) => ({ + ...internalAccount, + ...accounts[internalAccount.address], + })); + }, +); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 60f0e03c3aa8..196610d9f0d4 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -127,7 +127,7 @@ import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-accoun import { getUnconnectedAccountAlertEnabledness, getCompletedOnboarding, -} from '../ducks/metamask/metamask'; +} from '../selectors/metamask-state-basic'; import { toChecksumHexAddress } from '../../shared/lib/hexstring-utils'; import { HardwareDeviceNames,