Skip to content

Commit 44c33a8

Browse files
authored
feat(ramp): implement dynamic token fetching for token selection screen (#22892)
## **Description** This PR implements **TRAM-2820**: Dynamic token fetching for the Token Selection screen in the Ramp flow, replacing hardcoded mock data with real-time API data from the on-ramp-cache endpoint. ### **What changed and why:** **Problem:** The Token Selection screen was using hardcoded `MOCK_CRYPTOCURRENCIES` data, preventing users from seeing the actual available tokens for their region and selected flow (Buy vs Deposit). **Solution:** 1. **Created `useRampTokens` hook** to fetch tokens from the on-ramp-cache API endpoint 2. **Integrated hook into TokenSelection component** with loading and error states 3. **Added network filtering** to only show tokens for networks the user has added to their wallet 4. **Implemented smart list switching** - displays curated `topTokens` by default, switches to complete `allTokens` when searching ### **Key Features:** - ✅ **Environment-aware API calls**: Uses staging or production URLs based on `METAMASK_ENVIRONMENT` - ✅ **Region-based tokens**: Fetches tokens based on user's detected geolocation - ✅ **Flow-aware**: Different token lists for Aggregator (buy) vs Deposit flows - ✅ **Network filtering**: Only shows tokens for networks in user's wallet (e.g., excludes Tron if user doesn't have Tron network) - ✅ **Smart UX**: Shows curated top tokens initially, searches across all tokens - ✅ **Loading/Error states**: Proper UI feedback during fetch and on errors - ✅ **Type-safe**: Full TypeScript support with `RampsToken` interface ### **Technical Details:** **API Endpoint:** ``` {baseUrl}/regions/{region}/tokens?action={action}&sdk=2.1.5 ``` **Mapping:** - `UnifiedRampRoutingType.AGGREGATOR` → `action=buy` - `UnifiedRampRoutingType.DEPOSIT` → `action=deposit` **Response Structure:** ```json { "topTokens": [...], // Curated list "allTokens": [...] // Complete list } ``` **Previous work included in this PR (refactoring):** - Standardized navigation using `getDepositNavbarOptions()` - Migrated to `ScreenLayout` component - Converted styles to Tailwind CSS with `twClassName` - Updated parameter structure from `selectedCryptoAssetId` to `intent.assetId` --- ## **Changelog** CHANGELOG entry: null --- ## **Related issues** Refs: [TRAM-2820](https://consensyssoftware.atlassian.net/browse/TRAM-2820) --- ## **Manual testing steps** ```gherkin Feature: Dynamic Token Fetching Scenario: user views token selection screen with real tokens Given user is in a supported region (e.g., US-CA) And user has Ethereum network in wallet When user navigates to token selection screen Then user sees loading indicator And user sees tokens fetched from API And user sees supported tokens displayed normally And user sees unsupported tokens greyed out with info icon Scenario: user searches across all available tokens Given user is on token selection screen And curated top tokens are displayed When user types in search field Then search switches to complete token list And user sees all matching tokens (supported and unsupported) When user clears search Then screen returns to showing curated top tokens Scenario: user only sees tokens for networks they have Given user has only Ethereum network in wallet And API returns tokens for Ethereum, Tron, and Solana When screen loads Then user sees only Ethereum tokens And Tron and Solana tokens are filtered out Scenario: error handling when API fails Given API is unavailable or returns error When user navigates to token selection screen Then user sees error message And user can close the screen Scenario: no region detected Given user's region cannot be detected When user navigates to token selection screen Then no API call is made And appropriate state is shown ``` --- ## **Screenshots/Recordings** ### **Before** - Used hardcoded `MOCK_CRYPTOCURRENCIES` (5 tokens only) - No loading states - No error handling - Custom navigation setup - StyleSheet-based styling ### **After** - Dynamic token fetching from API (25+ tokens) - Loading indicator while fetching - Error message on fetch failure - Standard Ramp navigation pattern - Tailwind CSS styling - Network-based filtering - Smart list switching (topTokens/allTokens) https://github.com/user-attachments/assets/fc623885-1fc7-4f5e-8b19-921511e4e255 --- ## **Pre-merge author checklist** - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. --- ## **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. --- ## **Files Changed Summary** ### **New Files:** 1. `app/components/UI/Ramp/hooks/useRampTokens.ts` - Hook to fetch tokens from API 2. `app/components/UI/Ramp/hooks/useRampTokens.test.ts` - Comprehensive tests (24 test cases) 3. `app/components/UI/Ramp/components/TokenListItem/TokenListItem.test.tsx` - Component tests (12 test cases) ### **Modified Files:** 4. `app/components/UI/Ramp/components/TokenSelection/TokenSelection.tsx` - Integrated hook, added loading/error states 5. `app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx` - Updated tests for hook integration 6. `app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx` - Made `isSelected` optional, removed from usage 7. `babel.config.tests.js` - Added hook files to process.env exclude list 8. `locales/languages/en.json` - Added error message localization ### **Deleted Files:** 9. `app/components/UI/Ramp/components/TokenSelection/TokenSelection.styles.ts` - Migrated to Tailwind [TRAM-2820]: https://consensyssoftware.atlassian.net/browse/TRAM-2820?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce useRampTokens to fetch region- and flow-aware tokens from on-ramp cache and integrate into TokenSelection with loading/error states, network filtering, and UI tweaks. > > - **Ramp tokens fetching**: > - Add `useRampTokens` hook to fetch tokens from env-aware on-ramp cache (`production`/`staging`) with `action=buy|deposit` and `sdk` version. > - Filter tokens by user-added networks; expose `topTokens`, `allTokens`, `isLoading`, `error`. > - **TokenSelection**: > - Replace mock data with `useRampTokens` output; default to `topTokens`, switch to `allTokens` when searching. > - Add loading spinner and localized error state (`deposit.token_modal.error_loading_tokens`). > - Apply network filter bar using unique chains from fetched tokens. > - Remove intent-based selection; mark unsupported via `!token.tokenSupported` and show info modal. > - **TokenListItem**: > - Make `isSelected` optional; show `token.name` primary and `token.symbol` secondary; use `depositNetworkName ?? networkName` for badge; enable info button for disabled items. > - **Tests & config**: > - Add comprehensive tests for `useRampTokens`, `TokenSelection`, and `TokenListItem` (snapshots and behaviors). > - Update `babel.config.tests.js` to avoid inlining env vars for new hook files. > - Update snapshots reflecting name/symbol swap and disabled/state changes. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5dadc12. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b60cba2 commit 44c33a8

File tree

11 files changed

+1913
-2218
lines changed

11 files changed

+1913
-2218
lines changed

app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3019,8 +3019,6 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
30193019
style={null}
30203020
>
30213021
<TouchableOpacity
3022-
accessibilityRole="button"
3023-
accessible={true}
30243022
disabled={false}
30253023
onPress={[Function]}
30263024
style={
@@ -3207,7 +3205,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
32073205
}
32083206
}
32093207
>
3210-
USDC
3208+
USD Coin
32113209
</Text>
32123210
<Text
32133211
accessibilityRole="text"
@@ -3221,7 +3219,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
32213219
}
32223220
}
32233221
>
3224-
Ethereum
3222+
USDC
32253223
</Text>
32263224
</View>
32273225
</View>
@@ -3261,8 +3259,6 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
32613259
style={null}
32623260
>
32633261
<TouchableOpacity
3264-
accessibilityRole="button"
3265-
accessible={true}
32663262
disabled={false}
32673263
onPress={[Function]}
32683264
style={
@@ -3449,7 +3445,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
34493445
}
34503446
}
34513447
>
3452-
USDT
3448+
Tether USD
34533449
</Text>
34543450
<Text
34553451
accessibilityRole="text"
@@ -3463,7 +3459,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
34633459
}
34643460
}
34653461
>
3466-
Ethereum
3462+
USDT
34673463
</Text>
34683464
</View>
34693465
</View>
@@ -3476,8 +3472,6 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
34763472
style={null}
34773473
>
34783474
<TouchableOpacity
3479-
accessibilityRole="button"
3480-
accessible={true}
34813475
disabled={false}
34823476
onPress={[Function]}
34833477
style={
@@ -3664,7 +3658,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
36643658
}
36653659
}
36663660
>
3667-
BTC
3661+
Bitcoin
36683662
</Text>
36693663
<Text
36703664
accessibilityRole="text"
@@ -3678,7 +3672,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
36783672
}
36793673
}
36803674
>
3681-
Bitcoin
3675+
BTC
36823676
</Text>
36833677
</View>
36843678
</View>
@@ -3691,8 +3685,6 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
36913685
style={null}
36923686
>
36933687
<TouchableOpacity
3694-
accessibilityRole="button"
3695-
accessible={true}
36963688
disabled={false}
36973689
onPress={[Function]}
36983690
style={
@@ -3879,7 +3871,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
38793871
}
38803872
}
38813873
>
3882-
ETH
3874+
Ethereum
38833875
</Text>
38843876
<Text
38853877
accessibilityRole="text"
@@ -3893,7 +3885,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
38933885
}
38943886
}
38953887
>
3896-
Ethereum
3888+
ETH
38973889
</Text>
38983890
</View>
38993891
</View>
@@ -3906,8 +3898,6 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
39063898
style={null}
39073899
>
39083900
<TouchableOpacity
3909-
accessibilityRole="button"
3910-
accessible={true}
39113901
disabled={false}
39123902
onPress={[Function]}
39133903
style={
@@ -4094,7 +4084,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
40944084
}
40954085
}
40964086
>
4097-
USDC
4087+
USD Coin
40984088
</Text>
40994089
<Text
41004090
accessibilityRole="text"
@@ -4108,7 +4098,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`]
41084098
}
41094099
}
41104100
>
4111-
Solana
4101+
USDC
41124102
</Text>
41134103
</View>
41144104
</View>
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import React from 'react';
2+
import { fireEvent } from '@testing-library/react-native';
3+
import renderWithProvider from '../../../../../util/test/renderWithProvider';
4+
import TokenListItem from './TokenListItem';
5+
import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
6+
import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk';
7+
import initialRootState from '../../../../../util/test/initial-root-state';
8+
9+
const mockGetTokenNetworkInfo = jest.fn();
10+
jest.mock('../../hooks/useTokenNetworkInfo', () => ({
11+
...jest.requireActual('../../hooks/useTokenNetworkInfo'),
12+
useTokenNetworkInfo: jest.fn(),
13+
}));
14+
15+
const createMockToken = (
16+
overrides: Partial<DepositCryptoCurrency> = {},
17+
): DepositCryptoCurrency => ({
18+
assetId: 'eip155:1/slip44:60',
19+
chainId: 'eip155:1',
20+
name: 'Ethereum',
21+
symbol: 'ETH',
22+
decimals: 18,
23+
iconUrl: 'https://example.com/eth.png',
24+
...overrides,
25+
});
26+
27+
function render(component: React.ReactElement) {
28+
return renderWithProvider(component, {
29+
state: initialRootState,
30+
});
31+
}
32+
33+
describe('TokenListItem', () => {
34+
const mockOnPress = jest.fn();
35+
const mockOnInfoPress = jest.fn();
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
mockGetTokenNetworkInfo.mockReturnValue({
40+
networkName: 'Ethereum Mainnet',
41+
depositNetworkName: undefined,
42+
networkImageSource: { uri: 'https://example.com/network.png' },
43+
});
44+
(useTokenNetworkInfo as jest.Mock).mockReturnValue(mockGetTokenNetworkInfo);
45+
});
46+
47+
describe('basic rendering', () => {
48+
it('renders correctly and matches snapshot', () => {
49+
const token = createMockToken();
50+
51+
const { toJSON } = render(
52+
<TokenListItem token={token} onPress={mockOnPress} />,
53+
);
54+
55+
expect(toJSON()).toMatchSnapshot();
56+
});
57+
58+
it('displays token name and symbol', () => {
59+
const token = createMockToken({ name: 'USD Coin', symbol: 'USDC' });
60+
61+
const { getByText } = render(
62+
<TokenListItem token={token} onPress={mockOnPress} />,
63+
);
64+
65+
expect(getByText('USD Coin')).toBeTruthy();
66+
expect(getByText('USDC')).toBeTruthy();
67+
});
68+
69+
it('renders disabled token with info button and matches snapshot', () => {
70+
const token = createMockToken();
71+
72+
const { toJSON } = render(
73+
<TokenListItem
74+
token={token}
75+
onPress={mockOnPress}
76+
isDisabled
77+
onInfoPress={mockOnInfoPress}
78+
/>,
79+
);
80+
81+
expect(toJSON()).toMatchSnapshot();
82+
});
83+
});
84+
85+
describe('info button visibility', () => {
86+
it('displays info button when isDisabled is true and onInfoPress is provided', () => {
87+
const token = createMockToken();
88+
89+
const { getByTestId } = render(
90+
<TokenListItem
91+
token={token}
92+
onPress={mockOnPress}
93+
isDisabled
94+
onInfoPress={mockOnInfoPress}
95+
/>,
96+
);
97+
98+
expect(getByTestId('token-unsupported-info-button')).toBeTruthy();
99+
});
100+
101+
it('hides info button when isDisabled is false', () => {
102+
const token = createMockToken();
103+
104+
const { queryByTestId } = render(
105+
<TokenListItem
106+
token={token}
107+
onPress={mockOnPress}
108+
isDisabled={false}
109+
onInfoPress={mockOnInfoPress}
110+
/>,
111+
);
112+
113+
expect(queryByTestId('token-unsupported-info-button')).toBeNull();
114+
});
115+
116+
it('hides info button when onInfoPress is not provided', () => {
117+
const token = createMockToken();
118+
119+
const { queryByTestId } = render(
120+
<TokenListItem token={token} onPress={mockOnPress} isDisabled />,
121+
);
122+
123+
expect(queryByTestId('token-unsupported-info-button')).toBeNull();
124+
});
125+
});
126+
127+
describe('interaction', () => {
128+
it('calls onPress when list item is pressed', () => {
129+
const token = createMockToken();
130+
131+
const { getByText } = render(
132+
<TokenListItem token={token} onPress={mockOnPress} />,
133+
);
134+
135+
const tokenNameText = getByText(token.name);
136+
fireEvent.press(tokenNameText);
137+
138+
expect(mockOnPress).toHaveBeenCalledTimes(1);
139+
});
140+
141+
it('calls onInfoPress when info button is pressed', () => {
142+
const token = createMockToken();
143+
144+
const { getByTestId } = render(
145+
<TokenListItem
146+
token={token}
147+
onPress={mockOnPress}
148+
isDisabled
149+
onInfoPress={mockOnInfoPress}
150+
/>,
151+
);
152+
153+
const infoButton = getByTestId('token-unsupported-info-button');
154+
fireEvent.press(infoButton);
155+
156+
expect(mockOnInfoPress).toHaveBeenCalledTimes(1);
157+
});
158+
});
159+
160+
describe('network information', () => {
161+
it('calls useTokenNetworkInfo hook with token chainId', () => {
162+
const token = createMockToken({ chainId: 'eip155:137' });
163+
164+
render(<TokenListItem token={token} onPress={mockOnPress} />);
165+
166+
expect(mockGetTokenNetworkInfo).toHaveBeenCalledWith('eip155:137');
167+
});
168+
169+
it('displays token with network information', () => {
170+
mockGetTokenNetworkInfo.mockReturnValue({
171+
networkName: 'Ethereum',
172+
depositNetworkName: 'Ethereum Mainnet',
173+
networkImageSource: { uri: 'https://example.com/network.png' },
174+
});
175+
const token = createMockToken({ name: 'USD Coin', symbol: 'USDC' });
176+
177+
const { getByText } = render(
178+
<TokenListItem token={token} onPress={mockOnPress} />,
179+
);
180+
181+
expect(getByText('USD Coin')).toBeTruthy();
182+
expect(getByText('USDC')).toBeTruthy();
183+
});
184+
});
185+
});

app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
2525

2626
interface TokenListItemProps {
2727
token: DepositCryptoCurrency;
28-
isSelected: boolean;
28+
isSelected?: boolean;
2929
onPress: () => void;
3030
textColor?: string;
3131
isDisabled?: boolean;
@@ -53,14 +53,15 @@ function TokenListItem({
5353
isSelected={isSelected}
5454
onPress={onPress}
5555
isDisabled={isDisabled}
56-
accessibilityRole="button"
57-
accessible
5856
>
5957
<ListItemColumn widthType={WidthType.Auto}>
6058
<BadgeWrapper
6159
badgePosition={BadgePosition.BottomRight}
6260
badgeElement={
63-
<BadgeNetwork name={networkName} imageSource={networkImageSource} />
61+
<BadgeNetwork
62+
name={depositNetworkName ?? networkName}
63+
imageSource={networkImageSource}
64+
/>
6465
}
6566
>
6667
<AvatarToken
@@ -71,9 +72,9 @@ function TokenListItem({
7172
</BadgeWrapper>
7273
</ListItemColumn>
7374
<ListItemColumn widthType={WidthType.Fill}>
74-
<Text variant={TextVariant.BodyLGMedium}>{token.symbol}</Text>
75+
<Text variant={TextVariant.BodyLGMedium}>{token.name}</Text>
7576
<Text variant={TextVariant.BodyMD} color={textColor}>
76-
{depositNetworkName ?? networkName}
77+
{token.symbol}
7778
</Text>
7879
</ListItemColumn>
7980
{isDisabled && onInfoPress && (

0 commit comments

Comments
 (0)