Skip to content

Commit 687f807

Browse files
authored
fix(swaps): backfill sparse post-trade suggestions (#33273)
## **Description** The post-trade “What to swap next” section can return only one or two suggestions on lower-liquidity networks such as Linea because the trending API applies network-specific liquidity and volume thresholds. This change preserves those quality filters and backfills sparse destination-chain results with Ethereum trending tokens: - Destination-chain tokens remain first. - Ethereum fallback results fill the remaining slots up to the existing 20-token limit. - Destination and Ethereum requests start in parallel so the list is revealed as one complete set. - Ethereum fallback failures degrade to the available destination results. - Each suggestion uses the network badge matching its own asset ID. - Existing post-trade selection and analytics behavior remain unchanged. ## **Changelog** CHANGELOG entry: Fixed sparse token suggestions in the post-trade swap section ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4688 ## **Manual testing steps** N/A — simulator/manual testing was not run in this thread. The behavior is covered by targeted hook and bottom-sheet tests. ## **Screenshots/Recordings** ### **Before** N/A — no screenshots captured. ### **After** N/A — no screenshots captured. ## **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 for the changed behavior. - [x] I've documented my code using JSDoc format where applicable. - [x] I've applied the appropriate labels; existing PR labels and reviewer assignments remain unchanged. #### Performance checks - [x] N/A — this is a read-only suggestions query/UI change with no new transaction or wallet operation. ## **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. ## **Validation** - `yarn jest app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTrendingTokens.test.ts --collectCoverage=false --watchman=false` - `yarn jest app/components/UI/Bridge/components/PostTradeBottomSheet/PostTradeBottomSheet.test.ts --collectCoverage=false --watchman=false` - `yarn lint:tsc` - `bash scripts/check-ab-testing-compliance.sh --staged` - `git diff --check` All targeted tests, TypeScript checks, formatting checks, and A/B compliance checks passed. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Read-only trending fetch and UI badge logic in the post-trade bottom sheet; no wallet, auth, or transaction changes. > > **Overview** > Fixes sparse **“What to swap next”** pills on low-liquidity destination chains by **backfilling** up to the existing 20-token cap with Ethereum trending tokens while keeping destination-chain picks first. > > `usePostTradeTrendingTokens` now loads destination and Ethereum trending lists via separate React Query calls (Ethereum prefetch runs for non-Ethereum destinations), merges only when the destination list is short, and **keeps showing destination-only results** if the Ethereum request fails. `PostTradeTokenSuggestions` assigns each pill’s **network badge from the token’s asset ID** (Ethereum vs destination) instead of a single badge for the whole row. > > Hook tests cover sparse fill, fallback failure, and skipping Ethereum backfill when the destination is already Ethereum. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 166fd44. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c4ef40c commit 687f807

3 files changed

Lines changed: 218 additions & 37 deletions

File tree

app/components/UI/Bridge/components/PostTradeBottomSheet/PostTradeTokenSuggestions.tsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import BadgeWrapper, {
1515
BadgePosition,
1616
} from '../../../../../component-library/components/Badges/BadgeWrapper';
1717
import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
18-
import { getNetworkBadgeSource } from '../../../Trending/components/TrendingTokenRowItem/utils';
18+
import {
19+
getCaipChainIdFromAssetId,
20+
getNetworkBadgeSource,
21+
} from '../../../Trending/components/TrendingTokenRowItem/utils';
22+
import { NetworkToCaipChainId } from '../../../NetworkMultiSelector/NetworkMultiSelector.constants';
1923
import { formatPercentChange } from '../../../Trending/utils/formatPercentChange';
2024
import { strings } from '../../../../../../locales/i18n';
2125
import { PostTradeStatus } from './PostTradeBottomSheet.types';
@@ -31,6 +35,10 @@ interface PostTradeSuggestionPillProps {
3135
onPress: (token: TrendingAsset) => void;
3236
}
3337

38+
const ETHEREUM_NETWORK_BADGE_SOURCE = getNetworkBadgeSource(
39+
NetworkToCaipChainId.ETHEREUM,
40+
);
41+
3442
const PostTradeSuggestionPill = React.memo(
3543
({
3644
token,
@@ -99,23 +107,30 @@ export const PostTradeTokenSuggestions = ({
99107
destToken,
100108
enabled: shouldShowSuggestions,
101109
});
102-
const networkBadgeImageSource = useMemo(
110+
const destinationNetworkBadgeSource = useMemo(
103111
() =>
104112
destToken?.chainId
105113
? getNetworkBadgeSource(formatChainIdToCaip(destToken.chainId))
106114
: undefined,
107115
[destToken?.chainId],
108116
);
109-
110117
const renderItem = useCallback(
111-
(token: TrendingAsset) => (
112-
<PostTradeSuggestionPill
113-
token={token}
114-
networkBadgeImageSource={networkBadgeImageSource}
115-
onPress={onTokenPress}
116-
/>
117-
),
118-
[networkBadgeImageSource, onTokenPress],
118+
(token: TrendingAsset) => {
119+
const networkBadgeImageSource =
120+
getCaipChainIdFromAssetId(token.assetId) ===
121+
NetworkToCaipChainId.ETHEREUM
122+
? ETHEREUM_NETWORK_BADGE_SOURCE
123+
: destinationNetworkBadgeSource;
124+
125+
return (
126+
<PostTradeSuggestionPill
127+
token={token}
128+
networkBadgeImageSource={networkBadgeImageSource}
129+
onPress={onTokenPress}
130+
/>
131+
);
132+
},
133+
[destinationNetworkBadgeSource, onTokenPress],
119134
);
120135

121136
if (!shouldShowSuggestions || (!isLoading && tokens.length === 0)) {

app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTrendingTokens.test.ts

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { renderHook, waitFor } from '@testing-library/react-native';
2+
import { act, renderHook, waitFor } from '@testing-library/react-native';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import {
55
getTrendingTokens,
@@ -23,13 +23,16 @@ jest.mock('../../../../../selectors/currencyRateController', () => ({
2323
selectCurrentCurrency: jest.fn(() => 'usd'),
2424
}));
2525

26-
const mockGetTrendingTokens = getTrendingTokens as jest.MockedFunction<
27-
typeof getTrendingTokens
28-
>;
26+
const mockGetTrendingTokens = jest.mocked(getTrendingTokens);
2927

3028
const createWrapper = () => {
3129
const queryClient = new QueryClient({
3230
defaultOptions: { queries: { retry: false, cacheTime: Infinity } },
31+
logger: {
32+
log: () => undefined,
33+
warn: () => undefined,
34+
error: () => undefined,
35+
},
3336
});
3437

3538
const Wrapper = ({ children }: { children: React.ReactNode }) =>
@@ -117,6 +120,7 @@ describe('usePostTradeTrendingTokens', () => {
117120
vsCurrency: expect.any(String),
118121
}),
119122
);
123+
expect(mockGetTrendingTokens).toHaveBeenCalledTimes(1);
120124
expect(result.current.tokens).toHaveLength(
121125
POST_TRADE_TRENDING_TOKENS_LIMIT,
122126
);
@@ -144,4 +148,129 @@ describe('usePostTradeTrendingTokens', () => {
144148
expect(result.current.tokens).toEqual([]);
145149
expect(mockGetTrendingTokens).not.toHaveBeenCalled();
146150
});
151+
152+
it('fills sparse destination results with Ethereum tokens', async () => {
153+
const { Wrapper } = createWrapper();
154+
const lineaToken = createTrendingToken(
155+
'eip155:59144/erc20:0x0000000000000000000000000000000000000001',
156+
'LINEA',
157+
1,
158+
);
159+
const ethereumTokens = Array.from({ length: 20 }, (_, index) =>
160+
createTrendingToken(
161+
`eip155:1/erc20:0x${(index + 1).toString(16).padStart(40, '0')}`,
162+
`ETH${index + 1}`,
163+
index + 1,
164+
),
165+
);
166+
let resolveEthereumTokens: (tokens: TrendingAsset[]) => void = () =>
167+
undefined;
168+
const ethereumTokensPromise = new Promise<TrendingAsset[]>((resolve) => {
169+
resolveEthereumTokens = resolve;
170+
});
171+
mockGetTrendingTokens
172+
.mockResolvedValueOnce([lineaToken])
173+
.mockReturnValueOnce(ethereumTokensPromise);
174+
175+
const { result } = renderHook(
176+
() =>
177+
usePostTradeTrendingTokens({
178+
destToken: createBridgeToken({ chainId: '0xe708' }),
179+
}),
180+
{ wrapper: Wrapper },
181+
);
182+
183+
await waitFor(() => {
184+
expect(mockGetTrendingTokens).toHaveBeenCalledTimes(2);
185+
});
186+
187+
expect(result.current.isLoading).toBe(true);
188+
expect(result.current.tokens).toEqual([]);
189+
190+
expect(mockGetTrendingTokens).toHaveBeenNthCalledWith(
191+
1,
192+
expect.objectContaining({
193+
chainIds: ['eip155:59144'],
194+
minLiquidity: 100000,
195+
minVolume24hUsd: 25000,
196+
}),
197+
);
198+
expect(mockGetTrendingTokens).toHaveBeenNthCalledWith(
199+
2,
200+
expect.objectContaining({
201+
chainIds: ['eip155:1'],
202+
minLiquidity: 100000,
203+
minVolume24hUsd: 500000,
204+
}),
205+
);
206+
207+
await act(async () => {
208+
resolveEthereumTokens(ethereumTokens);
209+
});
210+
211+
await waitFor(() => {
212+
expect(result.current.tokens).toHaveLength(
213+
POST_TRADE_TRENDING_TOKENS_LIMIT,
214+
);
215+
});
216+
217+
expect(result.current.tokens).toHaveLength(
218+
POST_TRADE_TRENDING_TOKENS_LIMIT,
219+
);
220+
expect(result.current.tokens[0]).toBe(lineaToken);
221+
expect(result.current.tokens[1].symbol).toBe('ETH20');
222+
});
223+
224+
it('returns destination results when the Ethereum fallback fails', async () => {
225+
const { Wrapper } = createWrapper();
226+
const lineaToken = createTrendingToken(
227+
'eip155:59144/erc20:0x0000000000000000000000000000000000000001',
228+
'LINEA',
229+
1,
230+
);
231+
mockGetTrendingTokens
232+
.mockResolvedValueOnce([lineaToken])
233+
.mockRejectedValueOnce(new Error('Ethereum unavailable'));
234+
235+
const { result } = renderHook(
236+
() =>
237+
usePostTradeTrendingTokens({
238+
destToken: createBridgeToken({ chainId: '0xe708' }),
239+
}),
240+
{ wrapper: Wrapper },
241+
);
242+
243+
await waitFor(() => {
244+
expect(mockGetTrendingTokens).toHaveBeenCalledTimes(2);
245+
});
246+
247+
await waitFor(() => {
248+
expect(result.current.isLoading).toBe(false);
249+
});
250+
251+
expect(result.current.tokens).toEqual([lineaToken]);
252+
expect(result.current.error).toBeNull();
253+
});
254+
255+
it('does not request an Ethereum fallback for sparse Ethereum results', async () => {
256+
const { Wrapper } = createWrapper();
257+
const ethereumToken = createTrendingToken(
258+
'eip155:1/erc20:0x0000000000000000000000000000000000000001',
259+
'ETH1',
260+
1,
261+
);
262+
mockGetTrendingTokens.mockResolvedValueOnce([ethereumToken]);
263+
264+
const { result } = renderHook(
265+
() => usePostTradeTrendingTokens({ destToken: createBridgeToken() }),
266+
{ wrapper: Wrapper },
267+
);
268+
269+
await waitFor(() => {
270+
expect(result.current.isLoading).toBe(false);
271+
});
272+
273+
expect(mockGetTrendingTokens).toHaveBeenCalledTimes(1);
274+
expect(result.current.tokens).toEqual([ethereumToken]);
275+
});
147276
});

app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTrendingTokens.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
type TrendingAsset,
66
} from '@metamask/assets-controllers';
77
import { formatChainIdToCaip } from '@metamask/bridge-controller';
8+
import type { CaipChainId } from '@metamask/utils';
89
import type { BridgeToken } from '../../types';
910
import {
1011
getMinLiquidityForChains,
1112
getMinVolume24hForChains,
1213
} from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest';
14+
import { NetworkToCaipChainId } from '../../../NetworkMultiSelector/NetworkMultiSelector.constants';
1315
import {
1416
PriceChangeOption,
1517
SortDirection,
@@ -22,6 +24,24 @@ const STALE_TIME_MS = 5 * 60 * 1000;
2224
const POST_TRADE_TRENDING_TOKENS_QUERY_KEY =
2325
'bridge-post-trade-trending-tokens';
2426

27+
const getSortedTrendingTokens = async (chainId: CaipChainId) => {
28+
const chainIds = [chainId];
29+
const tokens = await getTrendingTokens({
30+
chainIds,
31+
sort: 'h24_trending',
32+
minLiquidity: getMinLiquidityForChains(chainIds),
33+
minVolume24hUsd: getMinVolume24hForChains(chainIds),
34+
minMarketCap: 0,
35+
excludeLabels: ['stable_coin', 'blue_chip'],
36+
});
37+
38+
return sortTrendingTokens(
39+
tokens,
40+
PriceChangeOption.MarketCap,
41+
SortDirection.Descending,
42+
);
43+
};
44+
2545
export const usePostTradeTrendingTokens = ({
2646
destToken,
2747
enabled = true,
@@ -32,46 +52,63 @@ export const usePostTradeTrendingTokens = ({
3252
const destinationCaipChainId = destToken?.chainId
3353
? formatChainIdToCaip(destToken.chainId)
3454
: undefined;
35-
const chainIds = destinationCaipChainId ? [destinationCaipChainId] : [];
3655
const isQueryEnabled = enabled && Boolean(destinationCaipChainId);
3756

38-
const query = useQuery<TrendingAsset[], Error>({
57+
const destinationQuery = useQuery<TrendingAsset[], Error>({
3958
queryKey: [POST_TRADE_TRENDING_TOKENS_QUERY_KEY, destinationCaipChainId],
4059
queryFn: () => {
4160
if (!destinationCaipChainId) {
4261
return Promise.resolve([]);
4362
}
4463

45-
return getTrendingTokens({
46-
chainIds,
47-
sort: 'h24_trending',
48-
minLiquidity: getMinLiquidityForChains(chainIds),
49-
minVolume24hUsd: getMinVolume24hForChains(chainIds),
50-
minMarketCap: 0,
51-
excludeLabels: ['stable_coin', 'blue_chip'],
52-
});
64+
return getSortedTrendingTokens(destinationCaipChainId);
5365
},
5466
enabled: isQueryEnabled,
5567
staleTime: STALE_TIME_MS,
5668
cacheTime: STALE_TIME_MS,
5769
});
58-
70+
const destinationTokens = useMemo(
71+
() =>
72+
destinationQuery.data?.slice(0, POST_TRADE_TRENDING_TOKENS_LIMIT) ?? [],
73+
[destinationQuery.data],
74+
);
75+
const isFallbackEnabled =
76+
isQueryEnabled && destinationCaipChainId !== NetworkToCaipChainId.ETHEREUM;
77+
const shouldFillWithFallback =
78+
isFallbackEnabled &&
79+
destinationQuery.isSuccess &&
80+
destinationTokens.length < POST_TRADE_TRENDING_TOKENS_LIMIT;
81+
const fallbackQuery = useQuery<TrendingAsset[], Error>({
82+
queryKey: [
83+
POST_TRADE_TRENDING_TOKENS_QUERY_KEY,
84+
NetworkToCaipChainId.ETHEREUM,
85+
],
86+
queryFn: () => getSortedTrendingTokens(NetworkToCaipChainId.ETHEREUM),
87+
enabled: isFallbackEnabled,
88+
staleTime: STALE_TIME_MS,
89+
cacheTime: STALE_TIME_MS,
90+
});
5991
const tokens = useMemo(() => {
60-
if (!query.data?.length) {
61-
return [];
92+
if (!shouldFillWithFallback || !fallbackQuery.data?.length) {
93+
return destinationTokens;
6294
}
6395

64-
return sortTrendingTokens(
65-
query.data,
66-
PriceChangeOption.MarketCap,
67-
SortDirection.Descending,
68-
).slice(0, POST_TRADE_TRENDING_TOKENS_LIMIT);
69-
}, [query.data]);
96+
return [
97+
...destinationTokens,
98+
...fallbackQuery.data.slice(
99+
0,
100+
POST_TRADE_TRENDING_TOKENS_LIMIT - destinationTokens.length,
101+
),
102+
];
103+
}, [destinationTokens, fallbackQuery.data, shouldFillWithFallback]);
70104

71105
return {
72106
tokens,
73-
isLoading: isQueryEnabled && query.isLoading,
74-
error: query.error,
75-
refetch: query.refetch,
107+
isLoading:
108+
isQueryEnabled &&
109+
(destinationQuery.isLoading ||
110+
(shouldFillWithFallback && fallbackQuery.isLoading)),
111+
error: destinationQuery.error,
112+
refetch: destinationQuery.refetch,
76113
};
77114
};

0 commit comments

Comments
 (0)