Skip to content

Commit 97b4823

Browse files
n3psCopilot
andauthored
feat: filter malicious non-evm activity transactions (#42176)
## **Description** Filter non-EVM activity when a token in the transaction is already marked Malicious in tokenScanCache ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: feat: filter malicious non-evm activity transactions ## **Related issues** Fixes: ## **Manual testing steps** Go to this Activity tab of a known account with scam transactions ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes which non-EVM transactions are shown by filtering activity based on `tokenScanCache` trust results, which could unintentionally hide legitimate transactions if scan keys or cache entries are wrong. Scope is limited to UI selectors/rendering and adds test coverage. > > **Overview** > Non-EVM activity rows are now **filtered out when any fungible token in the transaction is already marked `Malicious` in `tokenScanCache`**. `ActivityList` switches to a new selector (`selectNonEvmTransactionsForActivity`) so the merged activity feed excludes flagged Solana token movements while leaving native-only and unscanned transactions visible. > > This introduces a shared `token-scan` utility for generating normalized cache keys and extracting token scan keys from non-EVM transaction movements/fees, plus a new `selectTokenScanResults` selector and exports `getTokenScanCache` for reuse. Adds unit/integration tests covering key generation, key collection, selector filtering behavior, and ActivityList rendering for malicious vs non-malicious non-EVM transactions. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ecf4d30. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Copilot <copilot@github.com>
1 parent 5bbb619 commit 97b4823

11 files changed

Lines changed: 603 additions & 26 deletions

File tree

ui/components/multichain/activity-v2/activity-list.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,21 @@ mockUseVirtualizer.mockReturnValue(defaultVirtualizer);
6969

7070
function createStore({
7171
nonEvmTransactions = {},
72+
tokenScanCache = {},
7273
}: {
7374
nonEvmTransactions?: Record<
7475
string,
7576
Record<string, { transactions: unknown[] }>
7677
>;
78+
tokenScanCache?: Record<
79+
string,
80+
{
81+
data?: {
82+
// eslint-disable-next-line @typescript-eslint/naming-convention
83+
result_type?: string;
84+
};
85+
}
86+
>;
7787
} = {}) {
7888
return configureMockStore()({
7989
metamask: {
@@ -114,6 +124,7 @@ function createStore({
114124
transactionsByAccount: {},
115125
},
116126
nonEvmTransactions,
127+
tokenScanCache,
117128
smartTransactionsState: { smartTransactions: {} },
118129
transactions: [],
119130
},
@@ -207,6 +218,125 @@ describe('ActivityList', () => {
207218
expect(screen.getByTestId('non-evm-item')).toBeInTheDocument();
208219
});
209220

221+
it('filters malicious non-evm token transactions from the activity list', () => {
222+
const maliciousNonEvmTx = {
223+
chain: 'solana:mainnet',
224+
id: 'non-evm-malicious',
225+
timestamp: 1735689601000,
226+
from: [
227+
{
228+
address: 'BadMintHolder',
229+
asset: {
230+
fungible: true,
231+
type: 'solana:mainnet/token:BadMint111',
232+
unit: 'SPAM',
233+
amount: '1000',
234+
},
235+
},
236+
],
237+
to: [],
238+
};
239+
const benignNonEvmTx = {
240+
chain: 'solana:mainnet',
241+
id: 'non-evm-benign',
242+
timestamp: 1735689602000,
243+
from: [
244+
{
245+
address: 'GoodMintHolder',
246+
asset: {
247+
fungible: true,
248+
type: 'solana:mainnet/token:GoodMint222',
249+
unit: 'USDC',
250+
amount: '10',
251+
},
252+
},
253+
],
254+
to: [],
255+
};
256+
257+
mockUseTransactionsQuery.mockReturnValue({
258+
data: { pages: [] },
259+
fetchNextPage: jest.fn(),
260+
hasNextPage: false,
261+
isFetchingNextPage: false,
262+
});
263+
264+
enableVisibleVirtualItems();
265+
266+
const store = createStore({
267+
nonEvmTransactions: {
268+
'1': {
269+
'solana:mainnet': {
270+
transactions: [maliciousNonEvmTx, benignNonEvmTx],
271+
},
272+
},
273+
},
274+
tokenScanCache: {
275+
'solana:mainnet:badmint111': {
276+
data: {
277+
// eslint-disable-next-line @typescript-eslint/naming-convention
278+
result_type: 'Malicious',
279+
},
280+
},
281+
},
282+
});
283+
284+
render(
285+
<Provider store={store}>
286+
<ActivityList />
287+
</Provider>,
288+
);
289+
290+
expect(screen.getAllByTestId('non-evm-item')).toHaveLength(1);
291+
});
292+
293+
it('keeps non-evm token transactions visible when no malicious scan result exists', () => {
294+
const benignNonEvmTx = {
295+
chain: 'solana:mainnet',
296+
id: 'non-evm-benign',
297+
timestamp: 1735689602000,
298+
from: [
299+
{
300+
address: 'GoodMintHolder',
301+
asset: {
302+
fungible: true,
303+
type: 'solana:mainnet/token:GoodMint222',
304+
unit: 'USDC',
305+
amount: '10',
306+
},
307+
},
308+
],
309+
to: [],
310+
};
311+
312+
mockUseTransactionsQuery.mockReturnValue({
313+
data: { pages: [] },
314+
fetchNextPage: jest.fn(),
315+
hasNextPage: false,
316+
isFetchingNextPage: false,
317+
});
318+
319+
enableVisibleVirtualItems();
320+
321+
const store = createStore({
322+
nonEvmTransactions: {
323+
'1': {
324+
'solana:mainnet': {
325+
transactions: [benignNonEvmTx],
326+
},
327+
},
328+
},
329+
});
330+
331+
render(
332+
<Provider store={store}>
333+
<ActivityList />
334+
</Provider>,
335+
);
336+
337+
expect(screen.getByTestId('non-evm-item')).toBeInTheDocument();
338+
});
339+
210340
it('applies tokenAddress filter and shows empty state when no transaction matches', () => {
211341
const evmTx = {
212342
amounts: {

ui/components/multichain/activity-v2/activity-list.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { useScrollContainer } from '../../../contexts/scroll-container';
99
import { TransactionActivityEmptyState } from '../../app/transaction-activity-empty-state';
1010
import { TabEmptyState } from '../../ui/tab-empty-state';
1111
import { PENDING_STATUS_HASH } from '../../../helpers/constants/transactions';
12-
import { selectLocalTransactions } from '../../../selectors/activity';
12+
import {
13+
selectNonEvmTransactionsForActivity,
14+
selectLocalTransactions,
15+
} from '../../../selectors/activity';
1316
import { selectEvmAddress } from '../../../selectors/accounts';
14-
import { selectCurrentAccountNonEvmTransactions } from '../../../selectors/multichain-transactions';
1517
import { selectEnabledNetworksAsCaipChainIds } from '../../../selectors/multichain/networks';
1618
import { useEarliestNonceByChain } from '../../../hooks/useEarliestNonceByChain';
1719
import type { TransactionViewModel } from '../../../../shared/lib/multichain/types';
@@ -77,12 +79,10 @@ export const ActivityList = ({ filter }: Props) => {
7779
const localTransactions = useSelector(selectLocalTransactions);
7880

7981
// Non-EVM transactions - not in API
80-
const nonEvmTransactions = useSelector(
81-
selectCurrentAccountNonEvmTransactions,
82-
);
82+
const nonEvmTransactions = useSelector(selectNonEvmTransactionsForActivity);
8383

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

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

123-
// Merge all three types by time
124-
const mergedByTime = mergeAllTransactionsByTime(
125-
filteredLocalTransactions,
123+
return {
126124
evmTransactions,
125+
filteredLocalTransactions,
127126
filteredNonEvmTransactions,
127+
};
128+
}, [data, nonEvmTransactions, localTransactions, enabledNetworks, filter]);
129+
130+
// Merge and flatten for virtualization
131+
const flattenedItems = useMemo(() => {
132+
// Merge all three types by time
133+
const mergedByTime = mergeAllTransactionsByTime(
134+
transactionSources.filteredLocalTransactions,
135+
transactionSources.evmTransactions,
136+
transactionSources.filteredNonEvmTransactions,
128137
);
129138

130139
return groupAndFlattenMergedTransactions(mergedByTime);
131-
}, [data, nonEvmTransactions, localTransactions, enabledNetworks, filter]);
140+
}, [transactionSources]);
132141

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

ui/helpers/utils/token-cache-utils.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Transaction } from '@metamask/keyring-api';
2+
import {
3+
collectTransactionTokenScanKeys,
4+
filterMaliciousTransactions,
5+
generateTokenCacheKey,
6+
type MultichainTokenScanKey,
7+
} from './token-scan';
8+
9+
describe('token-scan', () => {
10+
describe('generateTokenCacheKey', () => {
11+
it('normalizes chain ID and token address casing', () => {
12+
expect(generateTokenCacheKey('Solana:Mainnet', 'BadMint111')).toBe(
13+
'solana:mainnet:badmint111',
14+
);
15+
});
16+
});
17+
18+
describe('non-EVM malicious token filtering', () => {
19+
const maliciousTokenKeys = new Set<MultichainTokenScanKey>([
20+
'solana:mainnet:badmint111',
21+
]);
22+
23+
it('collects unique token scan keys from token movements and fees', () => {
24+
const tx = {
25+
from: [
26+
{
27+
asset: {
28+
fungible: true,
29+
type: 'solana:mainnet/token:BadMint111',
30+
},
31+
},
32+
],
33+
to: [
34+
{
35+
asset: {
36+
fungible: true,
37+
type: 'solana:mainnet/token:BadMint111',
38+
},
39+
},
40+
{
41+
asset: {
42+
fungible: true,
43+
type: 'solana:mainnet/slip44:501',
44+
},
45+
},
46+
],
47+
fees: [
48+
{
49+
asset: {
50+
fungible: true,
51+
type: 'solana:mainnet/token:GoodMint222',
52+
},
53+
},
54+
],
55+
} as unknown as Transaction;
56+
57+
expect(collectTransactionTokenScanKeys(tx)).toEqual([
58+
'solana:mainnet:badmint111',
59+
'solana:mainnet:goodmint222',
60+
]);
61+
});
62+
63+
it('filters out only malicious non-EVM transactions', () => {
64+
const maliciousTx = {
65+
id: 'malicious-tx',
66+
from: [
67+
{
68+
asset: {
69+
fungible: true,
70+
type: 'solana:mainnet/token:BadMint111',
71+
},
72+
},
73+
],
74+
to: [],
75+
} as unknown as Transaction;
76+
const benignTx = {
77+
id: 'benign-tx',
78+
from: [
79+
{
80+
asset: {
81+
fungible: true,
82+
type: 'solana:mainnet/token:GoodMint222',
83+
},
84+
},
85+
],
86+
to: [],
87+
} as unknown as Transaction;
88+
const nativeOnlyTx = {
89+
id: 'native-only-tx',
90+
from: [
91+
{
92+
asset: {
93+
fungible: true,
94+
type: 'solana:mainnet/slip44:501',
95+
},
96+
},
97+
],
98+
to: [],
99+
} as unknown as Transaction;
100+
101+
expect(
102+
filterMaliciousTransactions(
103+
[maliciousTx, benignTx, nativeOnlyTx],
104+
maliciousTokenKeys,
105+
),
106+
).toEqual([benignTx, nativeOnlyTx]);
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)