diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 7680268a2b38..ad65c3dcb536 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2414,12 +2414,7 @@ export default class MetamaskController extends EventEmitter { } triggerNetworkrequests() { - this.txController.stopIncomingTransactionPolling(); - - this.txController.startIncomingTransactionPolling([ - this.#getGlobalChainId(), - ]); - + this.#restartSmartTransactionPoller(); this.tokenDetectionController.enable(); this.getInfuraFeatureFlags(); } @@ -2781,20 +2776,9 @@ export default class MetamaskController extends EventEmitter { 'PreferencesController:stateChange', previousValueComparator(async (prevState, currState) => { const { currentLocale } = currState; - const chainId = this.#getGlobalChainId(); + this.#restartSmartTransactionPoller(); await updateCurrentLocale(currentLocale); - - if (currState.incomingTransactionsPreferences?.[chainId]) { - this.txController.stopIncomingTransactionPolling(); - - this.txController.startIncomingTransactionPolling([ - this.#getGlobalChainId(), - ]); - } else { - this.txController.stopIncomingTransactionPolling(); - } - this.#checkTokenListPolling(currState, prevState); }, this.preferencesController.state), ); @@ -2949,15 +2933,22 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( 'NetworkController:networkDidChange', async () => { - await this.txController.stopIncomingTransactionPolling(); + const filteredChainIds = this.#getAllAddedNetworks().filter( + (networkId) => + this.preferencesController.state.incomingTransactionsPreferences[ + networkId + ], + ); - await this.txController.updateIncomingTransactions([ - this.#getGlobalChainId(), - ]); + if (filteredChainIds.length > 0) { + await this.txController.stopIncomingTransactionPolling(); - await this.txController.startIncomingTransactionPolling([ - this.#getGlobalChainId(), - ]); + await this.txController.updateIncomingTransactions(filteredChainIds); + + await this.txController.startIncomingTransactionPolling( + filteredChainIds, + ); + } }, ); @@ -8156,6 +8147,28 @@ export default class MetamaskController extends EventEmitter { return globalNetworkClient.configuration.chainId; } + #getAllAddedNetworks() { + const networksConfig = + this.networkController.state.networkConfigurationsByChainId; + const chainIds = Object.keys(networksConfig); + + return chainIds; + } + + #restartSmartTransactionPoller() { + const filteredChainIds = this.#getAllAddedNetworks().filter( + (networkId) => + this.preferencesController.state.incomingTransactionsPreferences[ + networkId + ], + ); + + if (filteredChainIds.length > 0) { + this.txController.stopIncomingTransactionPolling(); + this.txController.startIncomingTransactionPolling(filteredChainIds); + } + } + /** * @deprecated Avoid new references to the global network. * Will be removed once multi-chain support is fully implemented. diff --git a/package.json b/package.json index 0ff699ebefb2..8aec2bcd11e6 100644 --- a/package.json +++ b/package.json @@ -318,7 +318,7 @@ "@metamask/scure-bip39": "^2.0.3", "@metamask/selected-network-controller": "^19.0.0", "@metamask/signature-controller": "^26.0.0", - "@metamask/smart-transactions-controller": "^16.1.0", + "@metamask/smart-transactions-controller": "^16.2.0", "@metamask/snaps-controllers": "^11.0.0", "@metamask/snaps-execution-environments": "^7.0.0", "@metamask/snaps-rpc-methods": "^12.0.0", diff --git a/test/data/transaction-data.json b/test/data/transaction-data.json index ad2dacfe082c..4a589b5cb1c1 100644 --- a/test/data/transaction-data.json +++ b/test/data/transaction-data.json @@ -751,7 +751,7 @@ "status": "confirmed", "originalGasEstimate": "0x118e0", "userEditedGasLimit": false, - "chainId": "0x5", + "chainId": "0x1", "loadingDefaults": false, "dappSuggestedGasFees": { "maxPriorityFeePerGas": "0x3B9ACA00", diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js index e2deb9d10c75..2a37ae11ac8a 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -24,6 +24,7 @@ import { MetaMetricsEventCategory } from '../../../../shared/constants/metametri import { getURLHostName } from '../../../helpers/utils/util'; import { NETWORKS_ROUTE } from '../../../helpers/constants/routes'; import { COPY_OPTIONS } from '../../../../shared/constants/copy'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../shared/constants/network'; export default class TransactionListItemDetails extends PureComponent { static contextTypes = { @@ -50,7 +51,6 @@ export default class TransactionListItemDetails extends PureComponent { recipientAddress: PropTypes.string, recipientName: PropTypes.string, recipientMetadataName: PropTypes.string, - rpcPrefs: PropTypes.object, senderAddress: PropTypes.string.isRequired, tryReverseResolveAddress: PropTypes.func.isRequired, senderNickname: PropTypes.string.isRequired, @@ -60,6 +60,8 @@ export default class TransactionListItemDetails extends PureComponent { showErrorBanner: PropTypes.bool, history: PropTypes.object, blockExplorerLinkText: PropTypes.object, + chainId: PropTypes.string, + networkConfiguration: PropTypes.object, }; state = { @@ -69,11 +71,22 @@ export default class TransactionListItemDetails extends PureComponent { handleBlockExplorerClick = () => { const { transactionGroup: { primaryTransaction }, - rpcPrefs, + networkConfiguration, isCustomNetwork, history, onClose, + chainId, } = this.props; + const blockExplorerUrl = + networkConfiguration?.[chainId]?.blockExplorerUrls[ + networkConfiguration?.[chainId]?.defaultBlockExplorerUrlIndex + ]; + + const rpcPrefs = { + blockExplorerUrl, + imageUrl: CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[chainId], + }; + const blockExplorerLink = getBlockExplorerLink( primaryTransaction, rpcPrefs, @@ -300,6 +313,7 @@ export default class TransactionListItemDetails extends PureComponent { transaction={transaction} primaryCurrency={primaryCurrency} className="transaction-list-item-details__transaction-breakdown" + chainId={chainId} /> {transactionGroup.initialTransaction.type !== TransactionType.incoming && ( diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js index e9c75b732649..5abf4aab62bc 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js @@ -13,6 +13,7 @@ import { getMetadataContractName, getInternalAccounts, } from '../../../selectors'; +import { getNetworkConfigurationsByChainId } from '../../../../shared/modules/selectors/networks'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import TransactionListItemDetails from './transaction-list-item-details.component'; @@ -39,10 +40,12 @@ const mapStateToProps = (state, ownProps) => { }; const rpcPrefs = getRpcPrefsForCurrentProvider(state); + const networkConfiguration = getNetworkConfigurationsByChainId(state); const isCustomNetwork = getIsCustomNetwork(state); return { rpcPrefs, + networkConfiguration, recipientEns, senderNickname: getNickName(senderAddress), recipientNickname: recipientAddress ? getNickName(recipientAddress) : null, diff --git a/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js b/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js index 547f2e460497..fe8fa5a78705 100644 --- a/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js +++ b/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js @@ -31,6 +31,7 @@ export default function SmartTransactionListItem({ smartTransaction, transactionGroup, isEarliestNonce = false, + chainId, }) { const dispatch = useDispatch(); const [cancelSwapLinkClicked, setCancelSwapLinkClicked] = useState(false); @@ -128,6 +129,7 @@ export default function SmartTransactionListItem({ shouldShowTooltip={false} /> )} + chainId={chainId} /> )} @@ -138,4 +140,5 @@ SmartTransactionListItem.propTypes = { smartTransaction: PropTypes.object.isRequired, isEarliestNonce: PropTypes.bool, transactionGroup: PropTypes.object, + chainId: PropTypes.string, }; diff --git a/ui/components/app/transaction-list-item/transaction-list-item.component.js b/ui/components/app/transaction-list-item/transaction-list-item.component.js index bbc02db0a614..0f97f774ff0d 100644 --- a/ui/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/components/app/transaction-list-item/transaction-list-item.component.js @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { + CHAIN_IDS, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; @@ -48,11 +49,7 @@ import { TransactionModalContextProvider, useTransactionModalContext, } from '../../../contexts/transaction-modal'; -import { - checkNetworkAndAccountSupports1559, - getCurrentNetwork, - getTestNetworkBackgroundColor, -} from '../../../selectors'; +import { checkNetworkAndAccountSupports1559 } from '../../../selectors'; import { isLegacyTransaction } from '../../../helpers/utils/transactions.util'; import { formatDateWithYearContext } from '../../../helpers/utils/util'; import Button from '../../ui/button'; @@ -69,11 +66,16 @@ import { FINAL_NON_CONFIRMED_STATUSES, } from '../../../hooks/bridge/useBridgeTxHistoryData'; import BridgeActivityItemTxSegments from '../../../pages/bridge/transaction-details/bridge-activity-item-tx-segments'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + NETWORK_TO_NAME_MAP, +} from '../../../../shared/constants/network'; function TransactionListItemInner({ transactionGroup, setEditGasMode, isEarliestNonce = false, + chainId, }) { const t = useI18nContext(); const history = useHistory(); @@ -84,7 +86,6 @@ function TransactionListItemInner({ const [showRetryEditGasPopover, setShowRetryEditGasPopover] = useState(false); const { supportsEIP1559 } = useGasFeeContext(); const { openModal } = useTransactionModalContext(); - const testNetworkBackgroundColor = useSelector(getTestNetworkBackgroundColor); const isSmartTransaction = useSelector(getIsSmartTransaction); const dispatch = useDispatch(); @@ -97,6 +98,17 @@ function TransactionListItemInner({ isEarliestNonce, }); + const getTestNetworkBackgroundColor = (networkId) => { + switch (true) { + case networkId === CHAIN_IDS.GOERLI: + return BackgroundColor.goerli; + case networkId === CHAIN_IDS.SEPOLIA: + return BackgroundColor.sepolia; + default: + return undefined; + } + }; + const { initialTransaction: { id }, primaryTransaction: { error, status }, @@ -247,7 +259,6 @@ function TransactionListItemInner({ retryTransaction, cancelTransaction, ]); - const currentChain = useSelector(getCurrentNetwork); const showCancelButton = !hasCancelled && isPending && !isUnapproved && !isSubmitting && !isBridgeTx; @@ -271,10 +282,10 @@ function TransactionListItemInner({ className="activity-tx__network-badge" data-testid="activity-tx-network-badge" size={AvatarNetworkSize.Xs} - name={currentChain?.nickname} - src={currentChain?.rpcPrefs?.imageUrl} + name={NETWORK_TO_NAME_MAP[chainId]} + src={CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[chainId]} borderColor={BackgroundColor.backgroundDefault} - backgroundColor={testNetworkBackgroundColor} + backgroundColor={getTestNetworkBackgroundColor(chainId)} /> } > @@ -374,6 +385,7 @@ function TransactionListItemInner({ shouldShowTooltip={false} /> )} + chainId={chainId} /> )} {!supportsEIP1559 && showRetryEditGasPopover && ( @@ -398,6 +410,7 @@ TransactionListItemInner.propTypes = { transactionGroup: PropTypes.object.isRequired, isEarliestNonce: PropTypes.bool, setEditGasMode: PropTypes.func, + chainId: PropTypes.string, }; const TransactionListItem = (props) => { diff --git a/ui/components/app/transaction-list/__snapshots__/transaction-list.test.js.snap b/ui/components/app/transaction-list/__snapshots__/transaction-list.test.js.snap new file mode 100644 index 000000000000..24ad2dd8aa1d --- /dev/null +++ b/ui/components/app/transaction-list/__snapshots__/transaction-list.test.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TransactionList renders TransactionList component correctly 1`] = ` +
+
+
+ +
+
+
+
+
+
+`; + +exports[`TransactionList renders TransactionList component with props hideNetworkFilter correctly 1`] = ` +
+
+
+
+
+
+
+`; diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index c52fa3b39e20..6deeb757c6be 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -17,10 +17,14 @@ import { isEvmAccountType } from '@metamask/keyring-api'; ///: END:ONLY_INCLUDE_IF import { nonceSortedCompletedTransactionsSelector, + nonceSortedCompletedTransactionsSelectorAllChains, nonceSortedPendingTransactionsSelector, + nonceSortedPendingTransactionsSelectorAllChains, } from '../../../selectors/transactions'; import { getCurrentChainId } from '../../../../shared/modules/selectors/networks'; import { + getCurrentNetwork, + getIsTokenNetworkFilterEqualCurrentNetwork, getSelectedAccount, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getShouldHideZeroBalanceTokens, @@ -35,6 +39,20 @@ import SmartTransactionListItem from '../transaction-list-item/smart-transaction import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions'; import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP } from '../../../../shared/constants/swaps'; import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { + getSelectedInternalAccount, + ///: BEGIN:ONLY_INCLUDE_IF(multichain) + isSelectedInternalAccountSolana, + ///: END:ONLY_INCLUDE_IF +} from '../../../selectors/accounts'; +import { + getMultichainNetwork, + ///: BEGIN:ONLY_INCLUDE_IF(multichain) + getSelectedAccountMultichainTransactions, + ///: END:ONLY_INCLUDE_IF +} from '../../../selectors/multichain'; + import { Box, Button, @@ -53,7 +71,6 @@ import TransactionStatusLabel from '../transaction-status-label/transaction-stat import { MultichainTransactionDetailsModal } from '../multichain-transaction-details-modal'; import { formatTimestamp } from '../multichain-transaction-details-modal/helpers'; ///: END:ONLY_INCLUDE_IF - import { ///: BEGIN:ONLY_INCLUDE_IF(multichain) Display, @@ -75,12 +92,6 @@ import { openBlockExplorer } from '../../multichain/menu-items/view-explorer-men import { getMultichainAccountUrl } from '../../../helpers/utils/multichain/blockExplorer'; import { ActivityListItem } from '../../multichain'; import { MetaMetricsContext } from '../../../contexts/metametrics'; -import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; -import { - getMultichainNetwork, - getSelectedAccountMultichainTransactions, -} from '../../../selectors/multichain'; -import { isSelectedInternalAccountSolana } from '../../../selectors/accounts'; import { MULTICHAIN_PROVIDER_CONFIGS, MultichainNetworks, @@ -91,6 +102,14 @@ import { useMultichainTransactionDisplay } from '../../../hooks/useMultichainTra ///: END:ONLY_INCLUDE_IF import { endTrace, TraceName } from '../../../../shared/lib/trace'; +import { TEST_CHAINS } from '../../../../shared/constants/network'; +// eslint-disable-next-line import/no-restricted-paths +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { + ENVIRONMENT_TYPE_NOTIFICATION, + ENVIRONMENT_TYPE_POPUP, +} from '../../../../shared/constants/app'; +import { NetworkFilterComponent } from '../../multichain/network-filter-menu'; const PAGE_INCREMENT = 10; @@ -114,6 +133,18 @@ const getTransactionGroupRecipientAddressFilter = ( }; }; +const getTransactionGroupRecipientAddressFilterAllChain = ( + recipientAddress, +) => { + return ({ initialTransaction: { txParams } }) => { + return ( + isEqualCaseInsensitive(txParams?.to, recipientAddress) || + (txParams?.to === SWAPS_CHAINID_CONTRACT_ADDRESS_MAP && + txParams.data.match(recipientAddress.slice(2))) + ); + }; +}; + const tokenTransactionFilter = ({ initialTransaction: { type, destinationTokenSymbol, sourceTokenSymbol }, }) => { @@ -143,6 +174,21 @@ const getFilteredTransactionGroups = ( return transactionGroups; }; +const getFilteredTransactionGroupsAllChains = ( + transactionGroups, + hideTokenTransactions, + tokenAddress, +) => { + if (hideTokenTransactions) { + return transactionGroups.filter(tokenTransactionFilter); + } else if (tokenAddress) { + return transactionGroups.filter( + getTransactionGroupRecipientAddressFilterAllChain(tokenAddress), + ); + } + return transactionGroups; +}; + const groupTransactionsByDate = ( transactionGroups, getTransactionTimestamp, @@ -194,9 +240,14 @@ export default function TransactionList({ hideTokenTransactions, tokenAddress, boxProps, + hideNetworkFilter, }) { const [limit, setLimit] = useState(PAGE_INCREMENT); const t = useI18nContext(); + const currentNetworkConfig = useSelector(getCurrentNetwork); + const isTokenNetworkFilterEqualCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); ///: BEGIN:ONLY_INCLUDE_IF(multichain) const [selectedTransaction, setSelectedTransaction] = useState(null); @@ -210,15 +261,50 @@ export default function TransactionList({ useSolanaBridgeTransactionMapping(nonEvmTransactions); ///: END:ONLY_INCLUDE_IF - const unfilteredPendingTransactions = useSelector( + const unfilteredPendingTransactionsCurrentChain = useSelector( nonceSortedPendingTransactionsSelector, ); - const unfilteredCompletedTransactions = useSelector( + + const unfilteredPendingTransactionsAllChains = useSelector( + nonceSortedPendingTransactionsSelectorAllChains, + ); + + const unfilteredPendingTransactions = useMemo(() => { + return isTokenNetworkFilterEqualCurrentNetwork + ? unfilteredPendingTransactionsCurrentChain + : unfilteredPendingTransactionsAllChains; + }, [ + isTokenNetworkFilterEqualCurrentNetwork, + unfilteredPendingTransactionsAllChains, + unfilteredPendingTransactionsCurrentChain, + ]); + + const isTestNetwork = useMemo(() => { + return TEST_CHAINS.includes(currentNetworkConfig.chainId); + }, [currentNetworkConfig.chainId]); + + const unfilteredCompletedTransactionsCurrentChain = useSelector( nonceSortedCompletedTransactionsSelector, ); + const unfilteredCompletedTransactionsAllChains = useSelector( + nonceSortedCompletedTransactionsSelectorAllChains, + ); + + const unfilteredCompletedTransactions = useMemo(() => { + return isTokenNetworkFilterEqualCurrentNetwork + ? unfilteredCompletedTransactionsCurrentChain + : unfilteredCompletedTransactionsAllChains; + }, [ + isTokenNetworkFilterEqualCurrentNetwork, + unfilteredCompletedTransactionsAllChains, + unfilteredCompletedTransactionsCurrentChain, + ]); + const chainId = useSelector(getCurrentChainId); const selectedAccount = useSelector(getSelectedAccount); + const account = useSelector(getSelectedInternalAccount); + const { isEvmNetwork } = useMultichainSelector(getMultichainNetwork, account); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const shouldHideZeroBalanceTokens = useSelector( @@ -233,13 +319,21 @@ export default function TransactionList({ const showRampsCard = isBuyableChain && balanceIsZero; ///: END:ONLY_INCLUDE_IF + const [isNetworkFilterPopoverOpen, setIsNetworkFilterPopoverOpen] = + useState(false); + + const windowType = getEnvironmentType(); + const isFullScreen = + windowType !== ENVIRONMENT_TYPE_NOTIFICATION && + windowType !== ENVIRONMENT_TYPE_POPUP; + const renderDateStamp = (index, dateGroup) => { return index === 0 ? ( {dateGroup.date} @@ -268,19 +362,13 @@ export default function TransactionList({ const completedTransactions = useMemo( () => groupEvmTransactionsByDate( - getFilteredTransactionGroups( + getFilteredTransactionGroupsAllChains( unfilteredCompletedTransactions, hideTokenTransactions, tokenAddress, - chainId, ), ), - [ - hideTokenTransactions, - tokenAddress, - unfilteredCompletedTransactions, - chainId, - ], + [hideTokenTransactions, tokenAddress, unfilteredCompletedTransactions], ); const viewMore = useCallback( @@ -288,6 +376,14 @@ export default function TransactionList({ [], ); + const toggleNetworkFilterPopover = useCallback(() => { + setIsNetworkFilterPopoverOpen(!isNetworkFilterPopoverOpen); + }, [isNetworkFilterPopoverOpen]); + + const closePopover = useCallback(() => { + setIsNetworkFilterPopoverOpen(false); + }, []); + // Remove transactions within each date group that are incoming transactions // to a user that not the current one. const removeIncomingTxsButToAnotherAddress = (dateGroup) => { @@ -309,6 +405,35 @@ export default function TransactionList({ return dateGroup; }; + const renderFilterButton = useCallback(() => { + if (hideNetworkFilter) { + return null; + } + return isEvmNetwork ? ( + + ) : null; + }, [ + hideNetworkFilter, + isEvmNetwork, + isFullScreen, + isNetworkFilterPopoverOpen, + currentNetworkConfig, + isTokenNetworkFilterEqualCurrentNetwork, + toggleNetworkFilterPopover, + closePopover, + isTestNetwork, + ]); + // Remove transaction groups with no transactions const removeTxGroupsWithNoTx = (dateGroup) => { dateGroup.transactionGroups = dateGroup.transactionGroups.filter( @@ -427,6 +552,7 @@ export default function TransactionList({ ///: END:ONLY_INCLUDE_IF } + {renderFilterButton()} {pendingTransactions.length > 0 && ( @@ -445,6 +571,9 @@ export default function TransactionList({ transactionGroup.initialTransaction } transactionGroup={transactionGroup} + chainId={ + transactionGroup.initialTransaction.chainId + } /> ); @@ -455,6 +584,7 @@ export default function TransactionList({ ); @@ -489,10 +619,16 @@ export default function TransactionList({ smartTransaction={ transactionGroup.initialTransaction } + chainId={ + transactionGroup.initialTransaction.chainId + } /> ) : ( )} @@ -637,6 +773,7 @@ TransactionList.propTypes = { tokenAddress: PropTypes.string, boxProps: PropTypes.object, tokenChainId: PropTypes.string, + hideNetworkFilter: PropTypes.bool, }; TransactionList.defaultProps = { diff --git a/ui/components/app/transaction-list/transaction-list.test.js b/ui/components/app/transaction-list/transaction-list.test.js index 9fa56b4494ce..487e1c858d89 100644 --- a/ui/components/app/transaction-list/transaction-list.test.js +++ b/ui/components/app/transaction-list/transaction-list.test.js @@ -209,6 +209,22 @@ describe('TransactionList', () => { jest.clearAllMocks(); }); + it('renders TransactionList component correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('renders TransactionList component with props hideNetworkFilter correctly', () => { + const store = configureStore(defaultState); + const { container } = renderWithProvider( + + + , + store, + ); + expect(container).toMatchSnapshot(); + }); + it('renders TransactionList component and does not show You have no transactions text', () => { const { queryByText } = render(); expect(queryByText('You have no transactions')).toBeNull(); diff --git a/ui/components/multichain/account-overview/account-overview-tabs.tsx b/ui/components/multichain/account-overview/account-overview-tabs.tsx index 497bb09d8da2..8b9015679ef9 100644 --- a/ui/components/multichain/account-overview/account-overview-tabs.tsx +++ b/ui/components/multichain/account-overview/account-overview-tabs.tsx @@ -177,7 +177,7 @@ export const AccountOverviewTabs = ({ data-testid="account-overview__activity-tab" {...tabProps} > - + { ///: BEGIN:ONLY_INCLUDE_IF(build-main) +
+ +
+
+`; diff --git a/ui/components/multichain/network-filter-menu/index.test.tsx b/ui/components/multichain/network-filter-menu/index.test.tsx new file mode 100644 index 000000000000..d78d6c461b62 --- /dev/null +++ b/ui/components/multichain/network-filter-menu/index.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { NetworkFilterComponent } from '.'; + +// Mock the i18n hook to simply return the key +jest.mock('../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => key, +})); + +// Mock the NetworkFilter component to render a simple div for testing purposes +jest.mock('../../app/assets/asset-list/network-filter', () => () => ( +
NetworkFilter
+)); + +describe('NetworkFilterComponent', () => { + const defaultProps = { + isFullScreen: false, + toggleNetworkFilterPopover: jest.fn(), + isTestNetwork: false, + currentNetworkConfig: { + chainId: '0x1', + nickname: 'Ethereum', + }, + isNetworkFilterPopoverOpen: false, + closePopover: jest.fn(), + isTokenNetworkFilterEqualCurrentNetwork: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('renders button with current network nickname when isTokenNetworkFilterEqualCurrentNetwork is true', () => { + render(); + const button = screen.getByTestId('sort-by-popover-toggle'); + expect(button).toHaveTextContent('Ethereum'); + }); + + it('renders button with popularNetworks text when isTokenNetworkFilterEqualCurrentNetwork is false', () => { + render( + , + ); + const button = screen.getByTestId('sort-by-popover-toggle'); + expect(button).toHaveTextContent('popularNetworks'); + }); + + it('calls toggleNetworkFilterPopover when button is clicked', () => { + render(); + const button = screen.getByTestId('sort-by-popover-toggle'); + fireEvent.click(button); + expect(defaultProps.toggleNetworkFilterPopover).toHaveBeenCalled(); + }); + + it('disables button when isTestNetwork is true', () => { + render(); + const button = screen.getByTestId('sort-by-popover-toggle'); + expect(button).toBeDisabled(); + }); + + it('disables button when currentNetworkConfig.chainId is not in FEATURED_NETWORK_CHAIN_IDS', () => { + // Use a chain id that is not in the featured list + render( + , + ); + const button = screen.getByTestId('sort-by-popover-toggle'); + expect(button).toBeDisabled(); + }); + + it('renders the popover with NetworkFilter component when isNetworkFilterPopoverOpen is true', () => { + render( + , + ); + // Check that the NetworkFilter mock is rendered inside the popover + expect(screen.getByText('NetworkFilter')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/multichain/network-filter-menu/index.tsx b/ui/components/multichain/network-filter-menu/index.tsx new file mode 100644 index 000000000000..420fe9cce8fd --- /dev/null +++ b/ui/components/multichain/network-filter-menu/index.tsx @@ -0,0 +1,96 @@ +import { IconName } from '@metamask/snaps-sdk/jsx'; +import React, { useRef } from 'react'; +import { + BackgroundColor, + JustifyContent, + TextColor, +} from '../../../helpers/constants/design-system'; +import { + ButtonBase, + ButtonBaseSize, +} from '../../component-library/button-base'; +import { Popover, PopoverPosition } from '../../component-library/popover'; +import { Box } from '../../component-library'; +import { FEATURED_NETWORK_CHAIN_IDS } from '../../../../shared/constants/network'; +import NetworkFilter from '../../app/assets/asset-list/network-filter'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export const NetworkFilterComponent = ({ + isFullScreen, + toggleNetworkFilterPopover, + isTestNetwork, + currentNetworkConfig, + isNetworkFilterPopoverOpen, + closePopover, + isTokenNetworkFilterEqualCurrentNetwork, +}: { + isFullScreen: boolean; + toggleNetworkFilterPopover: () => void; + isTestNetwork: boolean; + currentNetworkConfig: { + chainId: string; + nickname: string; + }; + isNetworkFilterPopoverOpen: boolean; + closePopover: () => void; + isTokenNetworkFilterEqualCurrentNetwork: boolean; +}) => { + const popoverRef = useRef(null); + const t = useI18nContext(); + + return ( + + + {isTokenNetworkFilterEqualCurrentNetwork + ? currentNetworkConfig?.nickname ?? t('currentNetwork') + : t('popularNetworks')} + + + + + + + ); +}; + +export default NetworkFilterComponent; diff --git a/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap b/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap index e8e4173094b6..df7edd1d095c 100644 --- a/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap +++ b/ui/components/ui/currency-display/__snapshots__/currency-display.component.test.js.snap @@ -4,12 +4,14 @@ exports[`CurrencyDisplay Component should match default snapshot 1`] = `
+ > + 0 +
`; diff --git a/ui/hooks/useCurrencyDisplay.js b/ui/hooks/useCurrencyDisplay.js index e9b3db0eca1a..272738635807 100644 --- a/ui/hooks/useCurrencyDisplay.js +++ b/ui/hooks/useCurrencyDisplay.js @@ -9,10 +9,14 @@ import { } from '../selectors/multichain'; import { getValueFromWeiHex } from '../../shared/modules/conversion.utils'; -import { TEST_NETWORK_TICKER_MAP } from '../../shared/constants/network'; +import { + CHAIN_ID_TO_CURRENCY_SYMBOL_MAP, + TEST_NETWORK_TICKER_MAP, +} from '../../shared/constants/network'; import { Numeric } from '../../shared/modules/Numeric'; import { EtherDenomination } from '../../shared/constants/common'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; +import { getCurrencyRates } from '../ducks/metamask/metamask'; import { useMultichainSelector } from './useMultichainSelector'; // The smallest non-zero amount that can be displayed. @@ -126,6 +130,7 @@ function formatNonEvmAssetCurrencyDisplay({ * * @param {string} inputValue - The value to format for display * @param {UseCurrencyOptions} opts - An object for options to format the inputValue + * @param {string} chainId - chainId to use * @returns {[string, CurrencyDisplayParts]} */ export function useCurrencyDisplay( @@ -140,6 +145,7 @@ export function useCurrencyDisplay( isAggregatedFiatOverviewBalance, ...opts }, + chainId = null, ) { const isEvm = useMultichainSelector(getMultichainIsEvm, account); const currentCurrency = useMultichainSelector( @@ -155,8 +161,11 @@ export function useCurrencyDisplay( account, ); + const currencyRates = useMultichainSelector(getCurrencyRates, account); const isUserPreferredCurrency = currency === currentCurrency; - const isNativeCurrency = currency === nativeCurrency; + const isNativeCurrency = + currency === nativeCurrency || + currency === CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[chainId]; const value = useMemo(() => { if (displayValue) { @@ -172,7 +181,10 @@ export function useCurrencyDisplay( currentCurrency, nativeCurrency, inputValue, - conversionRate, + conversionRate: chainId + ? currencyRates?.[CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[chainId]] + ?.conversionRate + : conversionRate, }); } @@ -203,6 +215,8 @@ export function useCurrencyDisplay( numberOfDecimals, currentCurrency, isAggregatedFiatOverviewBalance, + chainId, + currencyRates, ]); let suffix; diff --git a/ui/hooks/useShouldShowSpeedUp.js b/ui/hooks/useShouldShowSpeedUp.js index 5cd2b67707a7..4dc716b7de02 100644 --- a/ui/hooks/useShouldShowSpeedUp.js +++ b/ui/hooks/useShouldShowSpeedUp.js @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { SECOND } from '../../shared/constants/time'; +import { getCurrentChainId } from '../../shared/modules/selectors/networks'; /** * Evaluates whether the transaction is eligible to be sped up, and registers @@ -11,10 +13,20 @@ import { SECOND } from '../../shared/constants/time'; */ export function useShouldShowSpeedUp(transactionGroup, isEarliestNonce) { const { transactions, hasRetried } = transactionGroup; + const currentChainId = useSelector(getCurrentChainId); + const [earliestTransaction = {}] = transactions; + + const matchCurrentChainId = earliestTransaction.chainId === currentChainId; + const { submittedTime } = earliestTransaction; const [speedUpEnabled, setSpeedUpEnabled] = useState(() => { - return Date.now() - submittedTime > 5000 && isEarliestNonce && !hasRetried; + return ( + Date.now() - submittedTime > 5000 && + isEarliestNonce && + !hasRetried && + matchCurrentChainId + ); }); useEffect(() => { // because this hook is optimized to only run on changes we have to diff --git a/ui/hooks/useShouldShowSpeedUp.test.ts b/ui/hooks/useShouldShowSpeedUp.test.ts new file mode 100644 index 000000000000..2de83c26021f --- /dev/null +++ b/ui/hooks/useShouldShowSpeedUp.test.ts @@ -0,0 +1,118 @@ +import { act } from '@testing-library/react-hooks'; +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import mockState from '../../test/data/mock-state.json'; +import { useShouldShowSpeedUp } from './useShouldShowSpeedUp'; + +describe('useShouldShowSpeedUp', () => { + const currentChainId = '0x1'; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should return true immediately if the transaction was submitted over 5 seconds ago and conditions are met', () => { + const now = Date.now(); + + const transactionGroup = { + transactions: [ + { + chainId: currentChainId, + submittedTime: now - 6000, + }, + ], + hasRetried: false, + }; + + const isEarliestNonce = true; + + const { result } = renderHookWithProvider( + () => useShouldShowSpeedUp(transactionGroup, isEarliestNonce), + mockState, + ); + + expect(result.current).toBe(true); + }); + + it('should initially return false when transaction is not older than 5 seconds and then become true after timeout', () => { + const now = Date.now(); + + const transactionGroup = { + transactions: [ + { + chainId: currentChainId, + submittedTime: now - 3000, // submitted 3 seconds ago + }, + ], + hasRetried: false, + }; + + const isEarliestNonce = true; + + const { result } = renderHookWithProvider( + () => useShouldShowSpeedUp(transactionGroup, isEarliestNonce), + mockState, + ); + + // Initially, speed up is not enabled + expect(result.current).toBe(false); + + // Advance timers until just past the 5 second threshold. + act(() => { + const remainingTime = 5001 - (Date.now() - (now - 3000)); + jest.advanceTimersByTime(remainingTime); + }); + + expect(result.current).toBe(true); + }); + + it('should remain false if hasRetried is true, regardless of timing', () => { + const now = Date.now(); + + const transactionGroup = { + transactions: [ + { + chainId: currentChainId, + submittedTime: now - 6000, + }, + ], + hasRetried: true, + }; + + const isEarliestNonce = true; + + const { result } = renderHookWithProvider( + () => useShouldShowSpeedUp(transactionGroup, isEarliestNonce), + mockState, + ); + + expect(result.current).toBe(false); + }); + + it('should remain false if isEarliestNonce is false', () => { + const now = Date.now(); + + const transactionGroup = { + transactions: [ + { + chainId: currentChainId, + submittedTime: now - 6000, + }, + ], + hasRetried: false, + }; + + const isEarliestNonce = false; + + const { result } = renderHookWithProvider( + () => useShouldShowSpeedUp(transactionGroup, isEarliestNonce), + mockState, + ); + + expect(result.current).toBe(false); + }); +}); diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index ea19b897bb33..674d8204bf71 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -409,22 +409,34 @@ export function useTransactionDisplayData(transactionGroup) { ); } - const primaryCurrencyPreferences = useUserPreferencedCurrency(PRIMARY); + const primaryCurrencyPreferences = useUserPreferencedCurrency( + PRIMARY, + {}, + transactionGroup?.initialTransaction?.chainId, + ); const secondaryCurrencyPreferences = useUserPreferencedCurrency(SECONDARY); - const [primaryCurrency] = useCurrencyDisplay(primaryValue, { - prefix, - displayValue: primaryDisplayValue, - suffix: primarySuffix, - ...primaryCurrencyPreferences, - }); + const [primaryCurrency] = useCurrencyDisplay( + primaryValue, + { + prefix, + displayValue: primaryDisplayValue, + suffix: primarySuffix, + ...primaryCurrencyPreferences, + }, + transactionGroup?.initialTransaction?.chainId, + ); - const [secondaryCurrency] = useCurrencyDisplay(primaryValue, { - prefix, - displayValue: secondaryDisplayValue, - hideLabel: isTokenCategory || Boolean(swapTokenValue), - ...secondaryCurrencyPreferences, - }); + const [secondaryCurrency] = useCurrencyDisplay( + primaryValue, + { + prefix, + displayValue: secondaryDisplayValue, + hideLabel: isTokenCategory || Boolean(swapTokenValue), + ...secondaryCurrencyPreferences, + }, + transactionGroup?.initialTransaction?.chainId, + ); return { title, diff --git a/ui/hooks/useUserPreferencedCurrency.js b/ui/hooks/useUserPreferencedCurrency.js index e9e8133cf9e2..9ac2633d762e 100644 --- a/ui/hooks/useUserPreferencedCurrency.js +++ b/ui/hooks/useUserPreferencedCurrency.js @@ -9,6 +9,7 @@ import { import { PRIMARY } from '../helpers/constants/common'; import { EtherDenomination } from '../../shared/constants/common'; import { ETH_DEFAULT_DECIMALS } from '../constants'; +import { CHAIN_ID_TO_CURRENCY_SYMBOL_MAP } from '../../shared/constants/network'; import { useMultichainSelector } from './useMultichainSelector'; /** @@ -42,9 +43,10 @@ import { useMultichainSelector } from './useMultichainSelector'; * * @param {"PRIMARY" | "SECONDARY"} type - what display type is being rendered * @param {UseUserPreferencedCurrencyOptions} opts - options to override default values + * @param {string} chainId - chainId to use * @returns {UserPreferredCurrency} */ -export function useUserPreferencedCurrency(type, opts = {}) { +export function useUserPreferencedCurrency(type, opts = {}, chainId = null) { const selectedAccount = useSelector(getSelectedInternalAccount); const account = opts.account ?? selectedAccount; const nativeCurrency = useMultichainSelector( @@ -68,7 +70,11 @@ export function useUserPreferencedCurrency(type, opts = {}) { }; const nativeReturn = { - currency: nativeCurrency || EtherDenomination.ETH, + currency: chainId + ? CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[chainId] || + nativeCurrency || + EtherDenomination.ETH + : nativeCurrency || EtherDenomination.ETH, numberOfDecimals: opts.numberOfDecimals || opts.ethNumberOfDecimals || ETH_DEFAULT_DECIMALS, }; diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index df5c28b4e600..91be949ee6e1 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -479,9 +479,9 @@ const AssetPage = ({ {t('yourActivity')} {type === AssetType.native ? ( - + ) : ( - + )} diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js index 4f2a3e005782..de435b1f093e 100644 --- a/ui/selectors/transactions.js +++ b/ui/selectors/transactions.js @@ -20,6 +20,7 @@ import { createDeepEqualSelector, filterAndShapeUnapprovedTransactions, } from '../../shared/modules/selectors/util'; +import { FEATURED_NETWORK_CHAIN_IDS } from '../../shared/constants/network'; import { getSelectedInternalAccount } from './accounts'; import { hasPendingApprovals, getApprovalRequestsByType } from './approvals'; @@ -53,6 +54,21 @@ export const getTransactions = createDeepEqualSelector( (transactions) => transactions, ); +export const getAllNetworkTransactions = createDeepEqualSelector( + // Input Selector: Retrieve all transactions from the state. + getTransactions, + // Output Selector: Filter transactions by popular networks. + (transactions) => { + if (!transactions.length) { + return []; + } + const popularNetworks = FEATURED_NETWORK_CHAIN_IDS; + return transactions.filter((transaction) => + popularNetworks.includes(transaction.chainId), + ); + }, +); + export const getCurrentNetworkTransactions = createDeepEqualSelector( (state) => { const transactions = getTransactions(state); @@ -70,6 +86,25 @@ export const getCurrentNetworkTransactions = createDeepEqualSelector( (transactions) => transactions, ); +export const incomingTxListSelectorAllChains = createDeepEqualSelector( + (state) => { + const { incomingTransactionsPreferences } = state.metamask; + if (!incomingTransactionsPreferences) { + return []; + } + + const allNetworkTransactions = getAllNetworkTransactions(state); + const { address: selectedAddress } = getSelectedInternalAccount(state); + + return allNetworkTransactions.filter( + (tx) => + tx.type === TransactionType.incoming && + tx.txParams.to === selectedAddress, + ); + }, + (transactions) => transactions, +); + export const getUnapprovedTransactions = createDeepEqualSelector( (state) => { const currentNetworkTransactions = getCurrentNetworkTransactions(state); @@ -175,6 +210,20 @@ export const smartTransactionsListSelector = (state) => { })); }; +export const selectedAddressTxListSelectorAllChain = createSelector( + getSelectedInternalAccount, + getAllNetworkTransactions, + smartTransactionsListSelector, + (selectedInternalAccount, transactions = [], smTransactions = []) => { + return transactions + .filter( + ({ txParams }) => txParams.from === selectedInternalAccount.address, + ) + .filter(({ type }) => type !== TransactionType.incoming) + .concat(smTransactions); + }, +); + export const selectedAddressTxListSelector = createSelector( getSelectedInternalAccount, getCurrentNetworkTransactions, @@ -212,6 +261,14 @@ export const unapprovedMessagesSelector = createSelector( ) || [], ); +export const transactionSubSelectorAllChains = createSelector( + unapprovedMessagesSelector, + incomingTxListSelectorAllChains, + (unapprovedMessages = [], incomingTxList = []) => { + return unapprovedMessages.concat(incomingTxList); + }, +); + export const transactionSubSelector = createSelector( unapprovedMessagesSelector, incomingTxListSelector, @@ -230,6 +287,16 @@ export const transactionsSelector = createSelector( }, ); +export const transactionsSelectorAllChains = createSelector( + transactionSubSelectorAllChains, + selectedAddressTxListSelectorAllChain, + (subSelectorTxList = [], selectedAddressTxList = []) => { + const txsToRender = selectedAddressTxList.concat(subSelectorTxList); + + return [...txsToRender].sort((a, b) => b.time - a.time); + }, +); + /** * @name insertOrderedNonce * @private @@ -338,6 +405,208 @@ const mergeNonNonceTransactionGroups = ( }); }; +const groupAndSortTransactionsByNonce = (transactions) => { + const unapprovedTransactionGroups = []; + const incomingTransactionGroups = []; + const orderedNonces = []; + const nonceToTransactionsMap = {}; + + transactions.forEach((transaction) => { + const { + txParams: { nonce } = {}, + status, + type, + time: txTime, + txReceipt, + } = transaction; + + // Don't group transactions by nonce if: + // 1. Tx nonce is undefined + // 2. Tx is incoming (deposit) + // 3. Tx is custodial (mmi specific) + let shouldNotBeGrouped = + typeof nonce === 'undefined' || type === TransactionType.incoming; + + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + shouldNotBeGrouped = shouldNotBeGrouped || Boolean(transaction.custodyId); + ///: END:ONLY_INCLUDE_IF + + if (shouldNotBeGrouped) { + const transactionGroup = { + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: false, + hasCancelled: false, + nonce, + }; + + if (type === TransactionType.incoming) { + incomingTransactionGroups.push(transactionGroup); + } else { + insertTransactionGroupByTime( + unapprovedTransactionGroups, + transactionGroup, + ); + } + } else if (nonce in nonceToTransactionsMap) { + const nonceProps = nonceToTransactionsMap[nonce]; + insertTransactionByTime(nonceProps.transactions, transaction); + + const { + primaryTransaction: { time: primaryTxTime = 0 } = {}, + initialTransaction: { time: initialTxTime = 0 } = {}, + } = nonceProps; + + const currentTransaction = { + // A on-chain failure means the current transaction was submitted and + // considered for inclusion in a block but something prevented it + // from being included, such as slippage on gas prices and conversion + // when doing a swap. These transactions will have a '0x0' value in + // the txReceipt.status field. + isOnChainFailure: txReceipt?.status === '0x0', + // Another type of failure is a "off chain" or "network" failure, + // where the error occurs on the JSON RPC call to the network client + // (Like Infura). These transactions are never broadcast for + // inclusion and the nonce associated with them is not consumed. When + // this occurs the next transaction will have the same nonce as the + // current, failed transaction. A failed on chain transaction will + // not have the FAILED status although it should (future TODO: add a + // new FAILED_ON_CHAIN) status. I use the word "Ephemeral" here + // because a failed transaction that does not get broadcast is not + // known outside of the user's local MetaMask and the nonce + // associated will be applied to the next. + isEphemeral: + status === TransactionStatus.failed && txReceipt?.status !== '0x0', + // We never want to use a speed up (retry) or cancel as the initial + // transaction in a group, regardless of time order. This is because + // useTransactionDisplayData cannot parse a retry or cancel because + // it lacks information on whether it's a simple send, token transfer, + // etc. + isRetryOrCancel: INVALID_INITIAL_TRANSACTION_TYPES.includes(type), + // Primary transactions usually are the latest transaction by time, + // but not always. This value shows whether this transaction occurred + // after the current primary. + occurredAfterPrimary: txTime > primaryTxTime, + // Priority Statuses are those that are either already confirmed + // on-chain, submitted to the network, or waiting for user approval. + // These statuses typically indicate a transaction that needs to have + // its status reflected in the UI. + hasPriorityStatus: status in PRIORITY_STATUS_HASH, + // A confirmed transaction is the most valid transaction status to + // display because no other transaction of the same nonce can have a + // more valid status. + isConfirmed: status === TransactionStatus.confirmed, + // Initial transactions usually are the earliest transaction by time, + // but not always. This value shows whether this transaction occurred + // before the current initial. + occurredBeforeInitial: txTime < initialTxTime, + // We only allow users to retry the transaction in certain scenarios + // to help shield from expensive operations and other unwanted side + // effects. This value is used to determine if the entire transaction + // group should be marked as having had a retry. + isValidRetry: + type === TransactionType.retry && + (status in PRIORITY_STATUS_HASH || + status === TransactionStatus.dropped), + // We only allow users to cancel the transaction in certain scenarios + // to help shield from expensive operations and other unwanted side + // effects. This value is used to determine if the entire transaction + // group should be marked as having had a cancel. + isValidCancel: + type === TransactionType.cancel && + (status in PRIORITY_STATUS_HASH || + status === TransactionStatus.dropped), + eligibleForInitial: + !INVALID_INITIAL_TRANSACTION_TYPES.includes(type) && + status !== TransactionStatus.failed, + shouldBePrimary: + status === TransactionStatus.confirmed || txReceipt?.status === '0x0', + }; + + const previousPrimaryTransaction = { + isEphemeral: + nonceProps.primaryTransaction.status === TransactionStatus.failed && + nonceProps.primaryTransaction?.txReceipt?.status !== '0x0', + }; + + const previousInitialTransaction = { + isEphemeral: + nonceProps.initialTransaction.status === TransactionStatus.failed && + nonceProps.initialTransaction.txReceipt?.status !== '0x0', + }; + + if ( + currentTransaction.shouldBePrimary || + previousPrimaryTransaction.isEphemeral || + (currentTransaction.occurredAfterPrimary && + currentTransaction.hasPriorityStatus) + ) { + nonceProps.primaryTransaction = transaction; + } + + if ( + (currentTransaction.occurredBeforeInitial && + currentTransaction.eligibleForInitial) || + (previousInitialTransaction.isEphemeral && + currentTransaction.eligibleForInitial) + ) { + nonceProps.initialTransaction = transaction; + } + + if (currentTransaction.isValidRetry) { + nonceProps.hasRetried = true; + } + + if (currentTransaction.isValidCancel) { + nonceProps.hasCancelled = true; + } + } else { + nonceToTransactionsMap[nonce] = { + nonce, + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: + type === TransactionType.retry && + (status in PRIORITY_STATUS_HASH || + status === TransactionStatus.dropped), + hasCancelled: + type === TransactionType.cancel && + (status in PRIORITY_STATUS_HASH || + status === TransactionStatus.dropped), + }; + insertOrderedNonce(orderedNonces, nonce); + } + }); + + const orderedTransactionGroups = orderedNonces.map( + (nonce) => nonceToTransactionsMap[nonce], + ); + mergeNonNonceTransactionGroups( + orderedTransactionGroups, + incomingTransactionGroups, + ); + + return unapprovedTransactionGroups + .concat(orderedTransactionGroups) + .map((txGroup) => { + if ( + INVALID_INITIAL_TRANSACTION_TYPES.includes( + txGroup.initialTransaction?.type, + ) + ) { + const nonRetryOrCancel = txGroup.transactions.find( + (tx) => !INVALID_INITIAL_TRANSACTION_TYPES.includes(tx.type), + ); + if (nonRetryOrCancel) { + return { ...txGroup, initialTransaction: nonRetryOrCancel }; + } + } + return txGroup; + }); +}; + /** * @name nonceSortedTransactionsSelector * @description Returns an array of transactionGroups sorted by nonce in ascending order. @@ -345,258 +614,49 @@ const mergeNonNonceTransactionGroups = ( */ export const nonceSortedTransactionsSelector = createSelector( transactionsSelector, - (transactions = []) => { - const unapprovedTransactionGroups = []; - const incomingTransactionGroups = []; - const orderedNonces = []; - const nonceToTransactionsMap = {}; - - transactions.forEach((transaction) => { - const { - txParams: { nonce } = {}, - status, - type, - time: txTime, - txReceipt, - } = transaction; - - // Don't group transactions by nonce if: - // 1. Tx nonce is undefined - // 2. Tx is incoming (deposit) - const shouldNotBeGrouped = - typeof nonce === 'undefined' || type === TransactionType.incoming; - - if (shouldNotBeGrouped) { - const transactionGroup = { - transactions: [transaction], - initialTransaction: transaction, - primaryTransaction: transaction, - hasRetried: false, - hasCancelled: false, - nonce, - }; - - if (type === TransactionType.incoming) { - incomingTransactionGroups.push(transactionGroup); - } else { - insertTransactionGroupByTime( - unapprovedTransactionGroups, - transactionGroup, - ); - } - } else if (nonce in nonceToTransactionsMap) { - const nonceProps = nonceToTransactionsMap[nonce]; - insertTransactionByTime(nonceProps.transactions, transaction); - - const { - primaryTransaction: { time: primaryTxTime = 0 } = {}, - initialTransaction: { time: initialTxTime = 0 } = {}, - } = nonceProps; - - // Current Transaction Logic Cases - // -------------------------------------------------------------------- - // Current transaction: The transaction we are examining in this loop. - // Each iteration should be in time order, but that is not guaranteed. - // -------------------------------------------------------------------- - const currentTransaction = { - // A on chain failure means the current transaction was submitted and - // considered for inclusion in a block but something prevented it - // from being included, such as slippage on gas prices and conversion - // when doing a swap. These transactions will have a '0x0' value in - // the txReceipt.status field. - isOnChainFailure: txReceipt?.status === '0x0', - // Another type of failure is a "off chain" or "network" failure, - // where the error occurs on the JSON RPC call to the network client - // (Like Infura). These transactions are never broadcast for - // inclusion and the nonce associated with them is not consumed. When - // this occurs the next transaction will have the same nonce as the - // current, failed transaction. A failed on chain transaction will - // not have the FAILED status although it should (future TODO: add a - // new FAILED_ON_CHAIN) status. I use the word "Ephemeral" here - // because a failed transaction that does not get broadcast is not - // known outside of the user's local MetaMask and the nonce - // associated will be applied to the next. - isEphemeral: - status === TransactionStatus.failed && txReceipt?.status !== '0x0', - // We never want to use a speed up (retry) or cancel as the initial - // transaction in a group, regardless of time order. This is because - // useTransactionDisplayData cannot parse a retry or cancel because - // it lacks information on whether its a simple send, token transfer, - // etc. - isRetryOrCancel: INVALID_INITIAL_TRANSACTION_TYPES.includes(type), - // Primary transactions usually are the latest transaction by time, - // but not always. This value shows whether this transaction occurred - // after the current primary. - occurredAfterPrimary: txTime > primaryTxTime, - // Priority Statuses are those that are ones either already confirmed - // on chain, submitted to the network, or waiting for user approval. - // These statuses typically indicate a transaction that needs to have - // its status reflected in the UI. - hasPriorityStatus: status in PRIORITY_STATUS_HASH, - // A confirmed transaction is the most valid transaction status to - // display because no other transaction of the same nonce can have a - // more valid status. - isConfirmed: status === TransactionStatus.confirmed, - // Initial transactions usually are the earliest transaction by time, - // but not always. THis value shows whether this transaction occurred - // before the current initial. - occurredBeforeInitial: txTime < initialTxTime, - // We only allow users to retry the transaction in certain scenarios - // to help shield from expensive operations and other unwanted side - // effects. This value is used to determine if the entire transaction - // group should be marked as having had a retry. - isValidRetry: - type === TransactionType.retry && - (status in PRIORITY_STATUS_HASH || - status === TransactionStatus.dropped), - // We only allow users to cancel the transaction in certain scenarios - // to help shield from expensive operations and other unwanted side - // effects. This value is used to determine if the entire transaction - // group should be marked as having had a cancel. - isValidCancel: - type === TransactionType.cancel && - (status in PRIORITY_STATUS_HASH || - status === TransactionStatus.dropped), - }; - - // We should never assign a retry or cancel transaction as the initial, - // likewise an ephemeral transaction should not be initial. - currentTransaction.eligibleForInitial = - !currentTransaction.isRetryOrCancel && - !currentTransaction.isEphemeral; - - // If a transaction failed on chain or was confirmed then it should - // always be the primary because no other transaction is more valid. - currentTransaction.shouldBePrimary = - currentTransaction.isConfirmed || currentTransaction.isOnChainFailure; - - // Primary Transaction Logic Cases - // -------------------------------------------------------------------- - // Primary transaction: The transaction for any given nonce which has - // the most valid status on the network. - // Example: - // 1. Submit transaction A - // 2. Speed up Transaction A. - // 3. This creates a new Transaction (B) with higher gas params. - // 4. Transaction A and Transaction B are both submitted. - // 5. We expect Transaction B to be the most valid transaction to use - // for the status of the transaction group because it has higher - // gas params and should be included first. - // The following logic variables are used for edge cases that protect - // against UI bugs when this breaks down. - const previousPrimaryTransaction = { - // As we loop through the transactions in state we may temporarily - // assign a primaryTransaction that is an "Ephemeral" transaction, - // which is one that failed before being broadcast for inclusion in a - // block. When this happens, and we have another transaction to - // consider in a nonce group, we should use the new transaction. - isEphemeral: - nonceProps.primaryTransaction.status === TransactionStatus.failed && - nonceProps.primaryTransaction?.txReceipt?.status !== '0x0', - }; - - // Initial Transaction Logic Cases - // -------------------------------------------------------------------- - // Initial Transaction: The transaction that most likely represents the - // user's intent when creating/approving the transaction. In most cases - // this is the first transaction of a nonce group, by time, but this - // breaks down in the case of users with the advanced setting enabled - // to set their own nonces manually. In that case a user may submit two - // completely different transactions of the same nonce and they will be - // bundled together by this selector as the same activity entry. - const previousInitialTransaction = { - // As we loop through the transactions in state we may temporarily - // assign a initialTransaction that is an "Ephemeral" transaction, - // which is one that failed before being broadcast for inclusion in a - // block. When this happens, and we have another transaction to - // consider in a nonce group, we should use the new transaction. - isEphemeral: - nonceProps.initialTransaction.status === TransactionStatus.failed && - nonceProps.initialTransaction.txReceipt?.status !== '0x0', - }; - - // Check the above logic cases and assign a new primaryTransaction if - // appropriate - if ( - currentTransaction.shouldBePrimary || - previousPrimaryTransaction.isEphemeral || - (currentTransaction.occurredAfterPrimary && - currentTransaction.hasPriorityStatus) - ) { - nonceProps.primaryTransaction = transaction; - } - - // Check the above logic cases and assign a new initialTransaction if - // appropriate - if ( - (currentTransaction.occurredBeforeInitial && - currentTransaction.eligibleForInitial) || - (previousInitialTransaction.isEphemeral && - currentTransaction.eligibleForInitial) - ) { - nonceProps.initialTransaction = transaction; - } + (transactions = []) => groupAndSortTransactionsByNonce(transactions), +); - if (currentTransaction.isValidRetry) { - nonceProps.hasRetried = true; - } +/** + * @name nonceSortedTransactionsSelectorAllChains + * @description Returns an array of transactionGroups sorted by nonce in ascending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedTransactionsSelectorAllChains = createSelector( + transactionsSelectorAllChains, + (transactions = []) => groupAndSortTransactionsByNonce(transactions), +); - if (currentTransaction.isValidCancel) { - nonceProps.hasCancelled = true; - } - } else { - nonceToTransactionsMap[nonce] = { - nonce, - transactions: [transaction], - initialTransaction: transaction, - primaryTransaction: transaction, - hasRetried: - transaction.type === TransactionType.retry && - (transaction.status in PRIORITY_STATUS_HASH || - transaction.status === TransactionStatus.dropped), - hasCancelled: - transaction.type === TransactionType.cancel && - (transaction.status in PRIORITY_STATUS_HASH || - transaction.status === TransactionStatus.dropped), - }; - - insertOrderedNonce(orderedNonces, nonce); - } - }); +/** + * @name nonceSortedPendingTransactionsSelectorAllChains + * @description Returns an array of transactionGroups where transactions are still pending sorted by + * nonce in descending order for all chains. + * @returns {transactionGroup[]} + */ +export const nonceSortedPendingTransactionsSelectorAllChains = createSelector( + nonceSortedTransactionsSelectorAllChains, + (transactions = []) => + transactions.filter( + ({ primaryTransaction }) => + primaryTransaction.status in PENDING_STATUS_HASH, + ), +); - const orderedTransactionGroups = orderedNonces.map( - (nonce) => nonceToTransactionsMap[nonce], - ); - mergeNonNonceTransactionGroups( - orderedTransactionGroups, - incomingTransactionGroups, - ); - return unapprovedTransactionGroups - .concat(orderedTransactionGroups) - .map((txGroup) => { - // In the case that we have a cancel or retry as initial transaction - // and there is a valid transaction in the group, we should reassign - // the other valid transaction as initial. In this case validity of the - // transaction is expanded to include off-chain failures because it is - // valid to retry those with higher gas prices. - if ( - INVALID_INITIAL_TRANSACTION_TYPES.includes( - txGroup.initialTransaction?.type, - ) - ) { - const nonRetryOrCancel = txGroup.transactions.find( - (tx) => !INVALID_INITIAL_TRANSACTION_TYPES.includes(tx.type), - ); - if (nonRetryOrCancel) { - return { - ...txGroup, - initialTransaction: nonRetryOrCancel, - }; - } - } - return txGroup; - }); - }, +/** + * @name nonceSortedCompletedTransactionsSelectorAllChains + * @description Returns an array of transactionGroups where transactions are confirmed sorted by + * nonce in descending order for all chains. + * @returns {transactionGroup[]} + */ +export const nonceSortedCompletedTransactionsSelectorAllChains = createSelector( + nonceSortedTransactionsSelectorAllChains, + (transactions = []) => + transactions + .filter( + ({ primaryTransaction }) => + !(primaryTransaction.status in PENDING_STATUS_HASH), + ) + .reverse(), ); /** diff --git a/ui/selectors/transactions.test.js b/ui/selectors/transactions.test.js index ea0098581105..8dc56321e32e 100644 --- a/ui/selectors/transactions.test.js +++ b/ui/selectors/transactions.test.js @@ -25,6 +25,11 @@ import { getApprovedAndSignedTransactions, smartTransactionsListSelector, getTransactions, + getAllNetworkTransactions, + incomingTxListSelectorAllChains, + selectedAddressTxListSelectorAllChain, + transactionSubSelectorAllChains, + transactionsSelectorAllChains, } from './transactions'; describe('Transaction Selectors', () => { @@ -809,6 +814,930 @@ describe('Transaction Selectors', () => { }); }); + describe('getAllNetworkTransactions', () => { + it('returns an empty array if there are no transactions', () => { + const state = { + metamask: { + transactions: [], + }, + }; + + const result = getAllNetworkTransactions(state); + + expect(result).toStrictEqual([]); + }); + + it('returns all transactions when there are multiple transactions', () => { + const transactions = [ + { + id: 1, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.submitted, + txParams: { + from: '0xAddress1', + to: '0xRecipient1', + }, + }, + { + id: 2, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.approved, + txParams: { + from: '0xAddress2', + to: '0xRecipient2', + }, + }, + ]; + + const state = { + metamask: { + transactions, + }, + }; + + const result = getAllNetworkTransactions(state); + + expect(result).toStrictEqual(transactions); + }); + + it('returns all transactions, preserving order when they have different statuses', () => { + const transactions = [ + { + id: 1, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.submitted, + txParams: { + from: '0xAddress1', + to: '0xRecipient1', + }, + }, + { + id: 2, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.signed, + txParams: { + from: '0xAddress2', + to: '0xRecipient2', + }, + }, + { + id: 3, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.unapproved, + txParams: { + from: '0xAddress3', + to: '0xRecipient3', + }, + }, + ]; + + const state = { + metamask: { + transactions, + }, + }; + + const result = getAllNetworkTransactions(state); + + expect(result).toStrictEqual(transactions); + }); + + it('returns the same reference when called multiple times with the same state', () => { + const transactions = [ + { + id: 1, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.submitted, + txParams: { + from: '0xAddress1', + to: '0xRecipient1', + }, + }, + ]; + const state = { + metamask: { transactions }, + }; + + const firstResult = getAllNetworkTransactions(state); + const secondResult = getAllNetworkTransactions(state); + + // Both calls should return the same reference since the input hasn't changed. + expect(firstResult).toBe(secondResult); + }); + + it('returns the same result reference even when a new but deeply equal state is provided', () => { + const transactions = [ + { + id: 1, + chainId: CHAIN_IDS.MAINNET, + status: TransactionStatus.submitted, + txParams: { + from: '0xAddress1', + to: '0xRecipient1', + }, + }, + ]; + const state1 = { + metamask: { transactions }, + }; + + // Create a new transactions array that is deeply equal to the original. + const newTransactions = JSON.parse(JSON.stringify(transactions)); + const state2 = { + metamask: { transactions: newTransactions }, + }; + + const result1 = getAllNetworkTransactions(state1); + const result2 = getAllNetworkTransactions(state2); + + // If using deep equality in the selector, the result should be memoized + // and both references should be equal. + expect(result1).toBe(result2); + }); + }); + + describe('incomingTxListSelectorAllChains', () => { + it('returns an empty array if incomingTransactionsPreferences is not present', () => { + const state = { + metamask: { + incomingTransactionsPreferences: null, + transactions: [ + { + id: 1, + type: TransactionType.incoming, + txParams: { to: '0xAddress' }, + }, + ], + internalAccounts: { + selectedAccount: '0xAddress', + }, + }, + }; + + const result = incomingTxListSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + + it('returns an empty array if there are no incoming transactions', () => { + const state = { + metamask: { + incomingTransactionsPreferences: true, + transactions: [ + { + id: 1, + type: TransactionType.outgoing, + txParams: { to: '0xAddress' }, + }, + ], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + }, + }; + + const result = incomingTxListSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + + it('returns only incoming transactions for the selected address across networks', () => { + const state = { + metamask: { + incomingTransactionsPreferences: true, + transactions: [ + { + id: 1, + chainId: '0x1', + type: TransactionType.incoming, + txParams: { to: '0xSelectedAddress' }, + }, + { + id: 2, + chainId: '0x1', + type: TransactionType.incoming, + txParams: { to: '0xOtherAddress' }, + }, + { + id: 3, + chainId: '0x1', + type: TransactionType.outgoing, + txParams: { to: '0xSelectedAddress' }, + }, + { + id: 4, + chainId: '0x1', + type: TransactionType.incoming, + txParams: { to: '0xSelectedAddress' }, + }, + ], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + }, + }; + + const result = incomingTxListSelectorAllChains(state); + + expect(result).toStrictEqual([ + state.metamask.transactions[0], + state.metamask.transactions[3], + ]); + }); + + it('returns an empty array if no transactions match the selected address', () => { + const state = { + metamask: { + incomingTransactionsPreferences: true, + transactions: [ + { + id: 1, + type: TransactionType.incoming, + txParams: { to: '0xOtherAddress' }, + }, + ], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + }, + }; + + const result = incomingTxListSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + }); + + describe('selectedAddressTxListSelectorAllChain', () => { + it('returns an empty array if there are no transactions or smart transactions', () => { + const state = { + metamask: { + transactions: [], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + smartTransactionsState: { + smartTransactions: [], + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = selectedAddressTxListSelectorAllChain(state); + + expect(result).toStrictEqual([]); + }); + + it('filters out incoming transactions for the selected address', () => { + const state = { + metamask: { + transactions: [ + { + id: 1, + chainId: '0x1', + type: TransactionType.incoming, + txParams: { from: '0xSelectedAddress', to: '0xAnotherAddress' }, + }, + { + id: 2, + chainId: '0x1', + type: TransactionType.contractInteraction, + txParams: { from: '0xSelectedAddress', to: '0xAnotherAddress' }, + }, + ], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + smartTransactionsState: { + smartTransactions: [], + }, + }, + }; + + const result = selectedAddressTxListSelectorAllChain(state); + + expect(result).toStrictEqual([state.metamask.transactions[1]]); + }); + + it('returns only non-incoming transactions for the selected address', () => { + const state = { + metamask: { + transactions: [ + { + id: 1, + chainId: '0x1', + type: TransactionType.incoming, + txParams: { from: '0xAnotherAddress', to: '0xSelectedAddress' }, + }, + { + id: 2, + chainId: '0x1', + type: TransactionType.simpleSend, + txParams: { from: '0xSelectedAddress', to: '0xAnotherAddress' }, + }, + { + id: 3, + chainId: '0x1', + type: TransactionType.contractInteraction, + txParams: { from: '0xSelectedAddress', to: '0xAnotherAddress' }, + }, + ], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + smartTransactionsState: { + smartTransactions: [], + }, + }, + }; + + const result = selectedAddressTxListSelectorAllChain(state); + + expect(result).toStrictEqual([ + state.metamask.transactions[1], + state.metamask.transactions[2], + ]); + }); + }); + + describe('transactionSubSelectorAllChains', () => { + it('returns an empty array when both unapprovedMessages and incomingTxList are empty', () => { + const state = { + metamask: { + unapprovedPersonalMsgs: {}, + incomingTransactionsPreferences: true, + transactions: [], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionSubSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + + it('returns only unapprovedMessages when incomingTxList is empty', () => { + const unapprovedMessages = [ + { + id: 1, + status: 'unapproved', + msgParams: { from: '0xAddress', data: '0xData' }, + }, + ]; + + const state = { + metamask: { + unapprovedPersonalMsgs: { + 1: unapprovedMessages[0], + }, + incomingTransactionsPreferences: true, + transactions: [], + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionSubSelectorAllChains(state); + + expect(result).toStrictEqual(unapprovedMessages); + }); + + it('returns only incomingTxList when unapprovedMessages is empty', () => { + const incomingTxList = [ + { + id: 1, + chainId: '0x1', + type: 'incoming', + txParams: { to: '0xSelectedAddress', from: '0xOtherAddress' }, + }, + ]; + + const state = { + metamask: { + unapprovedPersonalMsgs: {}, + incomingTransactionsPreferences: true, + transactions: incomingTxList, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionSubSelectorAllChains(state); + + expect(result).toStrictEqual(incomingTxList); + }); + + it('concatenates unapprovedMessages and incomingTxList when both are present', () => { + const unapprovedMessages = [ + { + id: 1, + chainId: '0x1', + status: 'unapproved', + msgParams: { from: '0xAddress', data: '0xData' }, + }, + ]; + + const incomingTxList = [ + { + id: 2, + chainId: '0x1', + type: 'incoming', + txParams: { to: '0xSelectedAddress', from: '0xOtherAddress' }, + }, + ]; + + const state = { + metamask: { + unapprovedPersonalMsgs: { + 1: unapprovedMessages[0], + }, + incomingTransactionsPreferences: true, + transactions: incomingTxList, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionSubSelectorAllChains(state); + + expect(result).toStrictEqual([...unapprovedMessages, ...incomingTxList]); + }); + + describe('transactionsSelectorAllChains', () => { + it('returns an empty array when both subSelectorTxList and selectedAddressTxList are empty', () => { + const state = { + metamask: { + transactions: [], + unapprovedPersonalMsgs: {}, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionsSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + + it('returns only subSelectorTxList when selectedAddressTxList is empty', () => { + const subSelectorTxList = [ + { + id: 2, + time: 1, + txParams: { from: '0xOtherAddress', to: '0xSelectedAddress' }, + }, + ]; + + const state = { + metamask: { + transactions: [], + unapprovedPersonalMsgs: { 1: subSelectorTxList[0] }, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionsSelectorAllChains(state); + + expect(result).toStrictEqual(subSelectorTxList); + }); + }); + }); + + describe('transactionsSelectorAllChains', () => { + it('returns an empty array when both subSelectorTxList and selectedAddressTxList are empty', () => { + const state = { + metamask: { + transactions: [], + unapprovedPersonalMsgs: {}, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: 'ETH_EOA_METHODS', + type: 'EthAccountType.Eoa', + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionsSelectorAllChains(state); + + expect(result).toStrictEqual([]); + }); + + it('returns only subSelectorTxList when selectedAddressTxList is empty', () => { + const subSelectorTxList = [ + { + id: 2, + time: 1, + txParams: { from: '0xOtherAddress', to: '0xSelectedAddress' }, + }, + ]; + + const state = { + metamask: { + transactions: [], + unapprovedPersonalMsgs: { 1: subSelectorTxList[0] }, + internalAccounts: { + accounts: { + 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { + address: '0xSelectedAddress', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: 'ETH_EOA_METHODS', + type: 'EthAccountType.Eoa', + }, + }, + selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + }, + selectedNetworkClientId: 'testNetworkConfigurationId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Custom Mainnet RPC', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + url: 'https://testrpc.com', + networkClientId: 'testNetworkConfigurationId', + type: 'custom', + }, + ], + }, + }, + }, + }; + + const result = transactionsSelectorAllChains(state); + + expect(result).toStrictEqual(subSelectorTxList); + }); + }); + describe('getTransactions', () => { it('returns all transactions for all networks', () => { const state = { diff --git a/yarn.lock b/yarn.lock index 42b7f50e5dc1..c7a18c0cd3a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6252,9 +6252,9 @@ __metadata: languageName: node linkType: hard -"@metamask/smart-transactions-controller@npm:^16.1.0": - version: 16.1.0 - resolution: "@metamask/smart-transactions-controller@npm:16.1.0" +"@metamask/smart-transactions-controller@npm:^16.2.0": + version: 16.2.0 + resolution: "@metamask/smart-transactions-controller@npm:16.2.0" dependencies: "@babel/runtime": "npm:^7.24.1" "@ethereumjs/tx": "npm:^5.2.1" @@ -6276,7 +6276,7 @@ __metadata: optional: true "@metamask/approval-controller": optional: true - checksum: 10/1b81adf30ee9b070cf36299110b15703941033cc8470943cbbda391a10c1b4d7014e30892d72b7620193a865c75a8251bc18034b3796f4597317d746554d38e5 + checksum: 10/3cf74a5ab01ce62e32d0c006d1519bdaccf3252636d83be9537c915caba049e151d981b74a58e4a1122defcf6a1d13d689d53b8f3cbbc31870d2896fa43a5b31 languageName: node linkType: hard @@ -27310,7 +27310,7 @@ __metadata: "@metamask/scure-bip39": "npm:^2.0.3" "@metamask/selected-network-controller": "npm:^19.0.0" "@metamask/signature-controller": "npm:^26.0.0" - "@metamask/smart-transactions-controller": "npm:^16.1.0" + "@metamask/smart-transactions-controller": "npm:^16.2.0" "@metamask/snaps-controllers": "npm:^11.0.0" "@metamask/snaps-execution-environments": "npm:^7.0.0" "@metamask/snaps-rpc-methods": "npm:^12.0.0"