Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
selectEVMEnabledNetworks,
selectNonEVMEnabledNetworks,
} from '../../../selectors/networkEnablementController';
import { selectLocalTransactions } from '../../../selectors/transactionController';
import {
selectLocalTransactions,
selectRelatedChainIdsByTransactionId,
} from '../../../selectors/transactionController';
import { baseStyles } from '../../../styles/common';
import { areAddressesEqual, isHardwareAccount } from '../../../util/address';
import { getBlockExplorerAddressUrl } from '../../../util/networks';
Expand Down Expand Up @@ -167,6 +170,10 @@ const UnifiedTransactionsView = ({
[enabledNonEVMNetworks],
);

const relatedChainIdsByTransactionId = useSelector(
selectRelatedChainIdsByTransactionId,
);

/** Drop confirmed rows not on currently enabled EVM chains (guards stale query pages). */
const allConfirmedForEnabledChains = useMemo<TransactionViewModel[]>(() => {
const chains = enabledEVMChainIds ?? [];
Expand Down Expand Up @@ -207,12 +214,18 @@ const UnifiedTransactionsView = ({
}

const { chainId: _chainId, txParams } = tx;

if (!enabledEvmSet.size) {
return false;
}
if (!_chainId || !enabledEvmSet.has(String(_chainId).toLowerCase())) {

const relatedChainIds = relatedChainIdsByTransactionId.get(tx.id) ?? [
String(_chainId ?? '').toLowerCase(),
];
if (!relatedChainIds.some((id) => enabledEvmSet.has(id))) {
Comment thread
cursor[bot] marked this conversation as resolved.
return false;
}

const isBridgeTransaction = isBridgeHistoryForEvmTransaction(
tx,
bridgeHistoryValues,
Expand Down Expand Up @@ -283,6 +296,7 @@ const UnifiedTransactionsView = ({
enabledEVMChainIds,
enabledNonEVMChainIds,
bridgeHistory,
relatedChainIdsByTransactionId,
]);

const { data, nonEvmTransactionsForSelectedChain } = useMemo<{
Expand Down
162 changes: 136 additions & 26 deletions app/selectors/transactionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
selectLastWithdrawTokenByType,
selectLocalTransactions,
selectNonReplacedTransactions,
selectRelatedChainIdsByTransactionId,
selectRequiredTransactionIds,
selectRequiredTransactionHashes,
selectRequiredTransactions,
Expand All @@ -27,6 +28,17 @@ jest.mock('./smartTransactionsController', () => ({
}) => state.pendingSmartTransactionsForGroup || [],
}));

jest.mock('./multichainAccounts/accountTreeController', () => ({
selectSelectedAccountGroupEvmInternalAccount: (state: {
groupEvmAccount?: { address: string } | null;
}) => state.groupEvmAccount ?? null,
}));

jest.mock('./accountsController', () => ({
selectEvmAddress: (state: { fallbackEvmAddress?: string }) =>
state.fallbackEvmAddress,
}));

describe('TransactionController Selectors', () => {
describe('selectTransactions', () => {
it('returns transactions from TransactionController state', () => {
Expand Down Expand Up @@ -181,58 +193,156 @@ describe('TransactionController Selectors', () => {
});
});

describe('selectRelatedChainIdsByTransactionId', () => {
const buildState = (transactions: unknown[]) =>
({
engine: {
backgroundState: {
TransactionController: { transactions },
},
},
}) as unknown as RootState;

it('returns the transaction own chain id, lower-cased', () => {
const state = buildState([{ id: 'lone', chainId: '0xA4B1' }]);

expect(selectRelatedChainIdsByTransactionId(state).get('lone')).toEqual([
'0xa4b1',
]);
});

it('includes metamaskPay.chainId for gasless MetaMask Pay parents', () => {
const state = buildState([
{
id: 'pay-parent',
chainId: '0xA4B1',
metamaskPay: { chainId: '0x1' },
},
]);

expect(
selectRelatedChainIdsByTransactionId(state).get('pay-parent')?.sort(),
).toEqual(['0x1', '0xa4b1']);
});

it('includes chain ids of required (child) transactions', () => {
const state = buildState([
{
id: 'parent',
chainId: '0xA4B1',
requiredTransactionIds: ['child-1', 'child-2'],
},
{ id: 'child-1', chainId: '0x1' },
{ id: 'child-2', chainId: '0xA' },
]);

expect(
selectRelatedChainIdsByTransactionId(state).get('parent')?.sort(),
).toEqual(['0x1', '0xa', '0xa4b1']);
});

it('dedupes overlapping chain ids', () => {
const state = buildState([
{
id: 'parent',
chainId: '0x1',
metamaskPay: { chainId: '0x1' },
requiredTransactionIds: ['child'],
},
{ id: 'child', chainId: '0x1' },
]);

expect(selectRelatedChainIdsByTransactionId(state).get('parent')).toEqual(
['0x1'],
);
});

it('ignores required ids that point to missing children', () => {
const state = buildState([
{ id: 'parent', chainId: '0x1', requiredTransactionIds: ['ghost'] },
]);

expect(selectRelatedChainIdsByTransactionId(state).get('parent')).toEqual(
['0x1'],
);
});
});

describe('selectLocalTransactions', () => {
it('filters required child transactions before nonce dedupe', () => {
const activeEvmAddress = '0x0000000000000000000000000000000000000001';
const state = {
const evmAddress = '0x0000000000000000000000000000000000000001';

const buildLocalTxState = ({
groupEvmAccount = { address: evmAddress },
transactions,
}: {
groupEvmAccount?: { address: string } | null;
transactions?: unknown[];
} = {}) =>
({
engine: {
backgroundState: {
AccountsController: {
internalAccounts: {
selectedAccount: 'account-1',
accounts: {
'account-1': {
id: 'account-1',
address: activeEvmAddress,
type: 'eip155:eoa',
},
},
},
},
TransactionController: {
transactions: [
transactions: transactions ?? [
{
id: 'child',
hash: '0xCHILD',
chainId: '0x1',
time: 200,
txParams: {
from: activeEvmAddress,
nonce: '0x1',
},
txParams: { from: evmAddress, nonce: '0x1' },
},
{
id: 'parent',
chainId: '0x1',
requiredTransactionIds: ['child'],
time: 100,
type: TransactionType.predictDeposit,
txParams: {
from: activeEvmAddress,
nonce: '0x1',
},
txParams: { from: evmAddress, nonce: '0x1' },
},
],
},
},
},
groupEvmAccount,
pendingSmartTransactionsForGroup: [],
} as unknown as RootState;
}) as unknown as RootState;

expect(selectLocalTransactions(state)).toStrictEqual([
it('filters required child transactions before nonce dedupe', () => {
expect(selectLocalTransactions(buildLocalTxState())).toStrictEqual([
expect.objectContaining({ id: 'parent' }),
]);
});

it('returns no transactions when the selected group has no EVM account', () => {
expect(
selectLocalTransactions(buildLocalTxState({ groupEvmAccount: null })),
).toStrictEqual([]);
});

it('excludes incoming transactions populated by the TransactionController incoming-transactions feature', () => {
const state = buildLocalTxState({
transactions: [
{
id: 'outgoing',
hash: '0xOUTGOING',
chainId: '0x1',
time: 200,
txParams: { from: evmAddress, nonce: '0x1' },
},
{
id: 'incoming-duplicate',
hash: '0xOUTGOING',
chainId: '0x1',
time: 100,
isTransfer: true,
txParams: { from: evmAddress },
},
],
});

expect(selectLocalTransactions(state)).toStrictEqual([
expect.objectContaining({ id: 'outgoing' }),
]);
});
});

describe('selectTransactionMetadataById', () => {
Expand Down
45 changes: 40 additions & 5 deletions app/selectors/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
selectPendingSmartTransactionsForSelectedAccountGroup,
} from './smartTransactionsController';
import { selectEvmAddress } from './accountsController';
import { selectSelectedAccountGroupEvmInternalAccount } from './multichainAccounts/accountTreeController';
import {
TransactionMeta,
TransactionType,
Expand All @@ -26,13 +27,14 @@ function dedupeTransactions(transactions: LocalTransaction[]) {
const seenTransactions = new Set<string>();

return transactions.filter((transaction) => {
const { chainId, txParams } = transaction;
const { from, nonce, actionId } = txParams || {};
const { chainId, txParams, id, isTransfer } =
transaction as TransactionMeta;
const { from, nonce } = txParams || {};
const hash = 'hash' in transaction ? transaction.hash : undefined;
const isBridgeTransaction = transaction.type === TransactionType.bridge;
const hasNonce = nonce !== undefined && nonce !== null;

if (!from) {
if (!from || isTransfer !== undefined) {
return false;
}

Expand All @@ -42,7 +44,7 @@ function dedupeTransactions(transactions: LocalTransaction[]) {
? `${dedupeKeyPrefix}-bridge-${hash.toLowerCase()}`
: hasNonce
? `${dedupeKeyPrefix}-${nonce}`
: `${dedupeKeyPrefix}-${actionId}`;
: `${dedupeKeyPrefix}-${id}`;

// Keep only the first local transaction for each dedupe key
if (seenTransactions.has(dedupeKey)) {
Expand Down Expand Up @@ -115,6 +117,35 @@ export const selectRequiredTransactionHashes = createSelector(
),
);

export const selectRelatedChainIdsByTransactionId = createSelector(
selectTransactionsStrict,
(transactions) => {
const transactionsById = new Map<string, TransactionMeta>(
transactions.map((tx) => [tx.id, tx]),
);

return new Map<string, string[]>(
transactions
.map((tx) => {
const childChainIds = (tx.requiredTransactionIds ?? []).map(
(childId) => transactionsById.get(childId)?.chainId,
);

const chainIds = [
tx.chainId,
tx.metamaskPay?.chainId,
...childChainIds,
]
.filter((chainId): chainId is Hex => Boolean(chainId))
.map((chainId) => chainId.toLowerCase());

return [tx.id, [...new Set(chainIds)]] satisfies [string, string[]];
})
.filter(([, chainIds]) => chainIds.length > 0),
);
},
);

export const selectTransactions = createDeepEqualSelector(
selectTransactionsStrict,
(transactions) => transactions,
Expand Down Expand Up @@ -189,15 +220,19 @@ export const selectLocalTransactions = createDeepEqualSelector(
[
selectNonReplacedTransactions,
selectPendingSmartTransactionsForSelectedAccountGroup,
selectSelectedAccountGroupEvmInternalAccount,
selectEvmAddress,
selectRequiredTransactionIds,
],
(
nonReplacedTransactions,
pendingSmartTransactions,
activeEvmAddress,
groupEvmAccount,
fallbackEvmAddress,
requiredTransactionIds,
) => {
const activeEvmAddress = groupEvmAccount?.address ?? fallbackEvmAddress;

const transactions = nonReplacedTransactions.filter((transaction) => {
if (requiredTransactionIds.has(transaction.id)) {
return false;
Expand Down
Loading