Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
130 changes: 130 additions & 0 deletions ui/components/multichain/activity-v2/activity-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,21 @@ mockUseVirtualizer.mockReturnValue(defaultVirtualizer);

function createStore({
nonEvmTransactions = {},
tokenScanCache = {},
}: {
nonEvmTransactions?: Record<
string,
Record<string, { transactions: unknown[] }>
>;
tokenScanCache?: Record<
string,
{
data?: {
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type?: string;
};
}
>;
} = {}) {
return configureMockStore()({
metamask: {
Expand Down Expand Up @@ -114,6 +124,7 @@ function createStore({
transactionsByAccount: {},
},
nonEvmTransactions,
tokenScanCache,
smartTransactionsState: { smartTransactions: {} },
transactions: [],
},
Expand Down Expand Up @@ -207,6 +218,125 @@ describe('ActivityList', () => {
expect(screen.getByTestId('non-evm-item')).toBeInTheDocument();
});

it('filters malicious non-evm token transactions from the activity list', () => {
const maliciousNonEvmTx = {
chain: 'solana:mainnet',
id: 'non-evm-malicious',
timestamp: 1735689601000,
from: [
{
address: 'BadMintHolder',
asset: {
fungible: true,
type: 'solana:mainnet/token:BadMint111',
unit: 'SPAM',
amount: '1000',
},
},
],
to: [],
};
const benignNonEvmTx = {
chain: 'solana:mainnet',
id: 'non-evm-benign',
timestamp: 1735689602000,
from: [
{
address: 'GoodMintHolder',
asset: {
fungible: true,
type: 'solana:mainnet/token:GoodMint222',
unit: 'USDC',
amount: '10',
},
},
],
to: [],
};

mockUseTransactionsQuery.mockReturnValue({
data: { pages: [] },
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
});

enableVisibleVirtualItems();

const store = createStore({
nonEvmTransactions: {
'1': {
'solana:mainnet': {
transactions: [maliciousNonEvmTx, benignNonEvmTx],
},
},
},
tokenScanCache: {
'solana:mainnet:badmint111': {
data: {
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: 'Malicious',
},
},
},
});

render(
<Provider store={store}>
<ActivityList />
</Provider>,
);

expect(screen.getAllByTestId('non-evm-item')).toHaveLength(1);
});

it('keeps non-evm token transactions visible when no malicious scan result exists', () => {
const benignNonEvmTx = {
chain: 'solana:mainnet',
id: 'non-evm-benign',
timestamp: 1735689602000,
from: [
{
address: 'GoodMintHolder',
asset: {
fungible: true,
type: 'solana:mainnet/token:GoodMint222',
unit: 'USDC',
amount: '10',
},
},
],
to: [],
};

mockUseTransactionsQuery.mockReturnValue({
data: { pages: [] },
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
});

enableVisibleVirtualItems();

const store = createStore({
nonEvmTransactions: {
'1': {
'solana:mainnet': {
transactions: [benignNonEvmTx],
},
},
},
});

render(
<Provider store={store}>
<ActivityList />
</Provider>,
);

expect(screen.getByTestId('non-evm-item')).toBeInTheDocument();
});

it('applies tokenAddress filter and shows empty state when no transaction matches', () => {
const evmTx = {
amounts: {
Expand Down
31 changes: 20 additions & 11 deletions ui/components/multichain/activity-v2/activity-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { useScrollContainer } from '../../../contexts/scroll-container';
import { TransactionActivityEmptyState } from '../../app/transaction-activity-empty-state';
import { TabEmptyState } from '../../ui/tab-empty-state';
import { PENDING_STATUS_HASH } from '../../../helpers/constants/transactions';
import { selectLocalTransactions } from '../../../selectors/activity';
import {
selectNonEvmTransactionsForActivity,
selectLocalTransactions,
} from '../../../selectors/activity';
import { selectEvmAddress } from '../../../selectors/accounts';
import { selectCurrentAccountNonEvmTransactions } from '../../../selectors/multichain-transactions';
import { selectEnabledNetworksAsCaipChainIds } from '../../../selectors/multichain/networks';
import { useEarliestNonceByChain } from '../../../hooks/useEarliestNonceByChain';
import type { TransactionViewModel } from '../../../../shared/lib/multichain/types';
Expand Down Expand Up @@ -77,12 +79,10 @@ export const ActivityList = ({ filter }: Props) => {
const localTransactions = useSelector(selectLocalTransactions);

// Non-EVM transactions - not in API
const nonEvmTransactions = useSelector(
selectCurrentAccountNonEvmTransactions,
);
const nonEvmTransactions = useSelector(selectNonEvmTransactionsForActivity);

// Merge and flatten for virtualization
const flattenedItems = useMemo(() => {
// Prepare the filtered transaction sources before merging them for rendering.
const transactionSources = useMemo(() => {
let evmTransactions = data?.pages?.flatMap((page) => page.data ?? []) ?? [];

// Filter local transactions by converting hex chainId to CAIP-2
Expand Down Expand Up @@ -120,15 +120,24 @@ export const ActivityList = ({ filter }: Props) => {
);
}

// Merge all three types by time
const mergedByTime = mergeAllTransactionsByTime(
filteredLocalTransactions,
return {
evmTransactions,
filteredLocalTransactions,
filteredNonEvmTransactions,
};
}, [data, nonEvmTransactions, localTransactions, enabledNetworks, filter]);

// Merge and flatten for virtualization
const flattenedItems = useMemo(() => {
// Merge all three types by time
const mergedByTime = mergeAllTransactionsByTime(
transactionSources.filteredLocalTransactions,
transactionSources.evmTransactions,
transactionSources.filteredNonEvmTransactions,
);

return groupAndFlattenMergedTransactions(mergedByTime);
}, [data, nonEvmTransactions, localTransactions, enabledNetworks, filter]);
}, [transactionSources]);

const [scrollMargin, setScrollMargin] = useState(0);

Expand Down
10 changes: 0 additions & 10 deletions ui/helpers/utils/token-cache-utils.ts

This file was deleted.

109 changes: 109 additions & 0 deletions ui/helpers/utils/token-scan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Transaction } from '@metamask/keyring-api';
import {
collectTransactionTokenScanKeys,
filterMaliciousTransactions,
generateTokenCacheKey,
type MultichainTokenScanKey,
} from './token-scan';

describe('token-scan', () => {
describe('generateTokenCacheKey', () => {
it('normalizes chain ID and token address casing', () => {
expect(generateTokenCacheKey('Solana:Mainnet', 'BadMint111')).toBe(
'solana:mainnet:badmint111',
);
});
});

describe('non-EVM malicious token filtering', () => {
const maliciousTokenKeys = new Set<MultichainTokenScanKey>([
'solana:mainnet:badmint111',
]);

it('collects unique token scan keys from token movements and fees', () => {
const tx = {
from: [
{
asset: {
fungible: true,
type: 'solana:mainnet/token:BadMint111',
},
},
],
to: [
{
asset: {
fungible: true,
type: 'solana:mainnet/token:BadMint111',
},
},
{
asset: {
fungible: true,
type: 'solana:mainnet/slip44:501',
},
},
],
fees: [
{
asset: {
fungible: true,
type: 'solana:mainnet/token:GoodMint222',
},
},
],
} as unknown as Transaction;

expect(collectTransactionTokenScanKeys(tx)).toEqual([
'solana:mainnet:badmint111',
'solana:mainnet:goodmint222',
]);
});

it('filters out only malicious non-EVM transactions', () => {
const maliciousTx = {
id: 'malicious-tx',
from: [
{
asset: {
fungible: true,
type: 'solana:mainnet/token:BadMint111',
},
},
],
to: [],
} as unknown as Transaction;
const benignTx = {
id: 'benign-tx',
from: [
{
asset: {
fungible: true,
type: 'solana:mainnet/token:GoodMint222',
},
},
],
to: [],
} as unknown as Transaction;
const nativeOnlyTx = {
id: 'native-only-tx',
from: [
{
asset: {
fungible: true,
type: 'solana:mainnet/slip44:501',
},
},
],
to: [],
} as unknown as Transaction;

expect(
filterMaliciousTransactions(
[maliciousTx, benignTx, nativeOnlyTx],
maliciousTokenKeys,
),
).toEqual([benignTx, nativeOnlyTx]);
});
});
});
Loading
Loading