Skip to content

Commit f3a256d

Browse files
authored
fix: display non ETH native transactions in the native token detail page (#28977)
## **Description** This pull request refines the transaction filtering logic for native and token assets. It adds comprehensive unit tests for the `useTokenTransactions` hook. The most important changes are: **Filtering Logic:** * Updated the filter selection in `useTokenTransactions.ts` to use an `isNativeToken` check, ensuring that native tokens (including non-ETH chains) use the correct transaction filter. This prevents token-category transactions from appearing in native token views and vice versa. **Testing:** * Added a new test suite `useTokenTransactions.test.ts` that covers various scenarios for native tokens (ETH and non-ETH like MON), ERC20 tokens, cross-chain filtering, and edge cases (such as gas-sponsored native sends and exclusion of unrelated transactions). This ensures robust regression coverage and correct behavior for transaction filtering. These changes improve both the reliability of the `useTokenTransactions` hook and confidence in its behavior through targeted unit tests. ## **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: add transactions in the token details for gas fees sponsored transactions ## **Related issues** Fixes: [https://github.com/MetaMask/metamask-mobile/issues/27247](https://github.com/MetaMask/metamask-mobile/issues/27247) [Bug]: Gas Sponsored Send and Swap are not displayed on the Activity section of the Token details page ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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 Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/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-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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 transaction filtering logic in `useTokenTransactions` for native vs token assets, which can impact what users see in token detail activity across chains. Risk is mitigated by a new unit test suite covering native/non-native and cross-chain scenarios. > > **Overview** > Fixes `useTokenTransactions` filter selection to treat any *native* asset (`asset.isNative` or `asset.isETH`) as using the native/`ethFilter`, instead of keying off symbol/address (which could hide non-ETH native transactions). > > Adds a comprehensive `useTokenTransactions.test.ts` suite that regression-tests native-token vs ERC20 filtering (including gas-sponsored/zero-fee native sends), exclusion of token-category txs from native views, and cross-chain exclusion. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 49c9f74. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dd8cb1d commit f3a256d

2 files changed

Lines changed: 336 additions & 4 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import { renderHook, waitFor } from '@testing-library/react-native';
2+
import { useSelector } from 'react-redux';
3+
import { useTokenTransactions } from './useTokenTransactions';
4+
import { TokenI } from '../../Tokens/types';
5+
import { TX_CONFIRMED } from '../../../../constants/transaction';
6+
import {
7+
selectTransactions,
8+
selectSwapsTransactions,
9+
} from '../../../../selectors/transactionController';
10+
import { selectTokens } from '../../../../selectors/tokensController';
11+
import { selectSelectedInternalAccount } from '../../../../selectors/accountsController';
12+
import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts';
13+
import {
14+
selectConversionRate,
15+
selectCurrentCurrency,
16+
} from '../../../../selectors/currencyRateController';
17+
18+
jest.mock('react-redux', () => ({
19+
useSelector: jest.fn(),
20+
}));
21+
22+
jest.mock('@metamask/bridge-controller', () => ({
23+
formatChainIdToCaip: jest.fn((chainId: string) => `eip155:${chainId}`),
24+
}));
25+
26+
jest.mock('../../../../selectors/tokensController', () => ({
27+
selectTokens: jest.fn(),
28+
}));
29+
30+
jest.mock('../../../../selectors/transactionController', () => ({
31+
selectTransactions: jest.fn(),
32+
selectSwapsTransactions: jest.fn(),
33+
}));
34+
35+
jest.mock('../../../../selectors/accountsController', () => ({
36+
selectSelectedInternalAccount: jest.fn(),
37+
selectSelectedInternalAccountAddress: jest.fn(),
38+
}));
39+
40+
jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({
41+
selectSelectedInternalAccountByScope: jest.fn(),
42+
}));
43+
44+
jest.mock('../../../../selectors/currencyRateController', () => ({
45+
selectConversionRate: jest.fn(),
46+
selectCurrentCurrency: jest.fn(),
47+
}));
48+
49+
jest.mock('../../../../util/activity', () => ({
50+
sortTransactions: jest.fn((txs: unknown[]) => txs),
51+
}));
52+
53+
jest.mock('../../../../util/transactions', () => ({
54+
addAccountTimeFlagFilter: jest.fn(() => false),
55+
}));
56+
57+
jest.mock('../../../../util/transaction-controller', () => ({
58+
updateIncomingTransactions: jest.fn(),
59+
}));
60+
61+
jest.mock('../../../../core/Multichain/utils', () => ({
62+
isNonEvmChainId: jest.fn(() => false),
63+
}));
64+
65+
jest.mock('../../Earn/utils/musd', () => ({
66+
isMusdClaimForCurrentView: jest.fn(() => false),
67+
}));
68+
69+
jest.mock('../../../../selectors/multichain', () => ({
70+
selectNonEvmTransactionsForSelectedAccountGroup: jest.fn(),
71+
}));
72+
73+
jest.mock('../../../../selectors/accountTrackerController', () => ({
74+
selectAccounts: jest.fn(),
75+
selectAccountsByChainId: jest.fn(),
76+
}));
77+
78+
jest.mock('../../../UI/TransactionElement/utils', () => ({
79+
TOKEN_CATEGORY_HASH: {
80+
tokenMethodApprove: true,
81+
tokenMethodSetApprovalForAll: true,
82+
tokenMethodTransfer: true,
83+
tokenMethodTransferFrom: true,
84+
tokenMethodIncreaseAllowance: true,
85+
},
86+
}));
87+
88+
jest.mock('../../../../store', () => ({
89+
store: {
90+
getState: () => ({
91+
inpageProvider: { networkId: '1' },
92+
}),
93+
},
94+
}));
95+
96+
const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';
97+
const MOCK_RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
98+
const MONAD_CHAIN_ID = '0x279f';
99+
const ETH_CHAIN_ID = '0x1';
100+
const MOCK_TOKEN_ADDRESS = '0x6b175474e89094c44da98b954eedeac495271d0f';
101+
102+
const mockUseSelector = jest.mocked(useSelector);
103+
104+
const createMockTransaction = (overrides: Record<string, unknown> = {}) => ({
105+
id: 'tx-1',
106+
chainId: ETH_CHAIN_ID,
107+
status: TX_CONFIRMED,
108+
time: Date.now(),
109+
txParams: {
110+
from: MOCK_ADDRESS,
111+
to: MOCK_RECIPIENT,
112+
},
113+
isTransfer: false,
114+
...overrides,
115+
});
116+
117+
const createAsset = (overrides: Partial<TokenI> = {}): TokenI => ({
118+
address: '',
119+
decimals: 18,
120+
image: '',
121+
name: 'Ether',
122+
symbol: 'ETH',
123+
balance: '1000000000000000000',
124+
logo: undefined,
125+
isETH: true,
126+
chainId: ETH_CHAIN_ID,
127+
isNative: true,
128+
...overrides,
129+
});
130+
131+
const setupMocks = (transactions: unknown[] = []) => {
132+
mockUseSelector.mockImplementation((selector) => {
133+
if (selector === selectTransactions) return transactions;
134+
if (selector === selectSwapsTransactions) return {};
135+
if (selector === selectTokens) return [];
136+
if (selector === selectSelectedInternalAccount) {
137+
return { address: MOCK_ADDRESS, metadata: { importTime: 0 } };
138+
}
139+
if (selector === selectSelectedInternalAccountByScope) {
140+
return () => ({ address: MOCK_ADDRESS });
141+
}
142+
if (selector === selectConversionRate) return 1;
143+
if (selector === selectCurrentCurrency) return 'usd';
144+
// Inline selector for selectedAddressForAsset
145+
return MOCK_ADDRESS;
146+
});
147+
};
148+
149+
describe('useTokenTransactions', () => {
150+
beforeEach(() => {
151+
jest.clearAllMocks();
152+
});
153+
154+
describe('filter selection for native tokens', () => {
155+
it('includes native send transaction for ETH (isETH=true)', async () => {
156+
const tx = createMockTransaction({ chainId: ETH_CHAIN_ID });
157+
setupMocks([tx]);
158+
159+
const asset = createAsset({
160+
symbol: 'ETH',
161+
isETH: true,
162+
isNative: true,
163+
address: '',
164+
chainId: ETH_CHAIN_ID,
165+
});
166+
167+
const { result } = renderHook(() => useTokenTransactions(asset));
168+
169+
await waitFor(() => {
170+
expect(result.current.transactionsUpdated).toBe(true);
171+
});
172+
173+
expect(result.current.confirmedTxs.length).toBe(1);
174+
});
175+
176+
it('includes native send transaction for non-ETH native token (MON on Monad)', async () => {
177+
const tx = createMockTransaction({
178+
chainId: MONAD_CHAIN_ID,
179+
txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT },
180+
});
181+
setupMocks([tx]);
182+
183+
const asset = createAsset({
184+
symbol: 'MON',
185+
isETH: false,
186+
isNative: true,
187+
address: '0x0000000000000000000000000000000000000000',
188+
chainId: MONAD_CHAIN_ID,
189+
});
190+
191+
const { result } = renderHook(() => useTokenTransactions(asset));
192+
193+
await waitFor(() => {
194+
expect(result.current.transactionsUpdated).toBe(true);
195+
});
196+
197+
// Core regression test: MON native sends must appear
198+
expect(result.current.confirmedTxs.length).toBe(1);
199+
});
200+
201+
it('excludes token-category transactions from native token view', async () => {
202+
const tx = createMockTransaction({
203+
chainId: MONAD_CHAIN_ID,
204+
type: 'tokenMethodTransfer',
205+
txParams: { from: MOCK_ADDRESS, to: MOCK_TOKEN_ADDRESS },
206+
});
207+
setupMocks([tx]);
208+
209+
const asset = createAsset({
210+
symbol: 'MON',
211+
isETH: false,
212+
isNative: true,
213+
address: '0x0000000000000000000000000000000000000000',
214+
chainId: MONAD_CHAIN_ID,
215+
});
216+
217+
const { result } = renderHook(() => useTokenTransactions(asset));
218+
219+
await waitFor(() => {
220+
expect(result.current.transactionsUpdated).toBe(true);
221+
});
222+
223+
expect(result.current.confirmedTxs.length).toBe(0);
224+
expect(result.current.transactions.length).toBe(0);
225+
});
226+
227+
it('includes ERC20 token transaction in token-specific view', async () => {
228+
const tx = createMockTransaction({
229+
chainId: ETH_CHAIN_ID,
230+
txParams: { from: MOCK_ADDRESS, to: MOCK_TOKEN_ADDRESS },
231+
});
232+
setupMocks([tx]);
233+
234+
const asset = createAsset({
235+
symbol: 'DAI',
236+
isETH: false,
237+
isNative: false,
238+
address: MOCK_TOKEN_ADDRESS,
239+
chainId: ETH_CHAIN_ID,
240+
});
241+
242+
const { result } = renderHook(() => useTokenTransactions(asset));
243+
244+
await waitFor(() => {
245+
expect(result.current.transactionsUpdated).toBe(true);
246+
});
247+
248+
expect(result.current.confirmedTxs.length).toBe(1);
249+
});
250+
251+
it('excludes unrelated transactions from ERC20 token view', async () => {
252+
const tx = createMockTransaction({
253+
chainId: ETH_CHAIN_ID,
254+
txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT },
255+
});
256+
setupMocks([tx]);
257+
258+
const asset = createAsset({
259+
symbol: 'DAI',
260+
isETH: false,
261+
isNative: false,
262+
address: MOCK_TOKEN_ADDRESS,
263+
chainId: ETH_CHAIN_ID,
264+
});
265+
266+
const { result } = renderHook(() => useTokenTransactions(asset));
267+
268+
await waitFor(() => {
269+
expect(result.current.transactionsUpdated).toBe(true);
270+
});
271+
272+
expect(result.current.confirmedTxs.length).toBe(0);
273+
});
274+
275+
it('includes gas-sponsored native send for non-ETH chain', async () => {
276+
const tx = createMockTransaction({
277+
chainId: MONAD_CHAIN_ID,
278+
txParams: {
279+
from: MOCK_ADDRESS,
280+
to: MOCK_RECIPIENT,
281+
gasPrice: '0x0',
282+
maxFeePerGas: '0x0',
283+
},
284+
});
285+
setupMocks([tx]);
286+
287+
const asset = createAsset({
288+
symbol: 'MON',
289+
isETH: false,
290+
isNative: true,
291+
address: '0x0000000000000000000000000000000000000000',
292+
chainId: MONAD_CHAIN_ID,
293+
});
294+
295+
const { result } = renderHook(() => useTokenTransactions(asset));
296+
297+
await waitFor(() => {
298+
expect(result.current.transactionsUpdated).toBe(true);
299+
});
300+
301+
// Gas-sponsored (zero gas fee) native sends must still appear
302+
expect(result.current.confirmedTxs.length).toBe(1);
303+
});
304+
});
305+
306+
describe('cross-chain filtering', () => {
307+
it('excludes transactions from a different chain', async () => {
308+
const tx = createMockTransaction({
309+
chainId: ETH_CHAIN_ID,
310+
txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT },
311+
});
312+
setupMocks([tx]);
313+
314+
const asset = createAsset({
315+
symbol: 'MON',
316+
isETH: false,
317+
isNative: true,
318+
address: '0x0000000000000000000000000000000000000000',
319+
chainId: MONAD_CHAIN_ID,
320+
});
321+
322+
const { result } = renderHook(() => useTokenTransactions(asset));
323+
324+
await waitFor(() => {
325+
expect(result.current.transactionsUpdated).toBe(true);
326+
});
327+
328+
expect(result.current.confirmedTxs.length).toBe(0);
329+
});
330+
});
331+
});

app/components/UI/TokenDetails/hooks/useTokenTransactions.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,13 @@ export const useTokenTransactions = (
374374
);
375375

376376
// Determine which filter to use
377+
const isNativeToken = asset.isNative || asset.isETH;
377378
const filter = useMemo(() => {
378-
if (navSymbol.toUpperCase() !== 'ETH' && navAddress !== '') {
379-
return noEthFilter;
379+
if (isNativeToken) {
380+
return ethFilter;
380381
}
381-
return ethFilter;
382-
}, [navSymbol, navAddress, ethFilter, noEthFilter]);
382+
return noEthFilter;
383+
}, [isNativeToken, ethFilter, noEthFilter]);
383384

384385
// Check if pending tx statuses changed
385386
const didTxStatusesChange = useCallback(

0 commit comments

Comments
 (0)