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`] = `
`;
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"