Skip to content

Commit 045a39f

Browse files
authored
perf: Speed up the creation of the token list for Predict withdraw (#27735)
## **Description** This speeds up the withdraw token-selection path by moving the allowlist filter earlier in the pipeline. Before this change, withdraw flows loaded the full token catalog, built `AssetType`s for all matching catalog entries, sorted the full result, and only then filtered down to the small allowlisted set we actually care about. This change adds a `tokenFilter` hook parameter so `useAccountTokens` can skip irrelevant catalog entries before building them, and `useWithdrawTokenFilter` passes the withdraw allowlist down to that lower-level loop. It also keeps the existing post-filter in place for correctness, so allowlisted tokens already owned by the user and native-token address mapping still work as before. Local dev measurements while opening **Predict Withdraw**: | Metric | Before | After | | --- | ---: | ---: | | `useAccountTokens` | 954-1,143ms | 41-48ms | | Catalog tokens built | 18,524 | 0 | | Total tokens sorted | 18,618 | 15 | This brings the measured token-loading path from roughly `~1.05s` down to `~44ms` (`~24x` faster). ## **Changelog** CHANGELOG entry: Improved performance when loading Predict withdraw token selection ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1054 ## **Manual testing steps** 1. Open Predict Withdraw, it will load much faster now <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how withdraw token lists are constructed and filtered, which could inadvertently hide valid tokens if chainId/address normalization or `assetId` handling is wrong. Scope is limited to token-selection hooks and is covered by updated/added unit tests. > > **Overview** > Speeds up Predict Withdraw token selection by pushing the withdraw allowlist filter down into token collection, avoiding building/sorting the full token catalog before narrowing to allowlisted entries. > > This introduces an optional `tokenFilter(chainId, address)` plumbed from `useWithdrawTokenFilter` → `useSendTokens` → `useAccountTokens`, and replaces the previous post-filtering in `useWithdrawTokenFilter` with a precomputed allowlist lookup that also maps zero-address entries to chain-specific native token addresses. > > Tests are updated to assert `useSendTokens` is called with the new filtering options and to validate `tokenFilter` behavior (case-insensitive matching, chain gating, and native-token mapping), plus new `useAccountTokens` coverage ensuring both owned assets and catalog token building respect `tokenFilter`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b8e1cc7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com>
1 parent 5ff018e commit 045a39f

6 files changed

Lines changed: 244 additions & 170 deletions

File tree

app/components/Views/confirmations/hooks/pay/useWithdrawTokenFilter.test.ts

Lines changed: 63 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { otherControllersMock } from '../../__mocks__/controllers/other-controll
88
import { TransactionType } from '@metamask/transaction-controller';
99
import { AssetType, TokenStandard } from '../../types/token';
1010
import { EthAccountType } from '@metamask/keyring-api';
11+
import { getNativeTokenAddress } from '@metamask/assets-controllers';
1112
import { useSendTokens } from '../send/useSendTokens';
1213

1314
jest.mock('../send/useSendTokens');
@@ -133,7 +134,7 @@ describe('useWithdrawTokenFilter', () => {
133134
expect(result.current(input)).toBe(input);
134135
});
135136

136-
it('filters useSendTokens result to only those matching the allowlist', () => {
137+
it('returns allTokens from useSendTokens for withdraw with allowlist', () => {
137138
const { result } = runHook({
138139
type: TransactionType.predictWithdraw,
139140
postQuoteFlags: {
@@ -144,189 +145,119 @@ describe('useWithdrawTokenFilter', () => {
144145
},
145146
});
146147

147-
const filtered = result.current([]);
148+
const returned = result.current([]);
148149

149-
expect(filtered).toHaveLength(1);
150-
expect(filtered[0].address).toBe('0xaaa');
151-
expect(filtered[0].balance).toBe('1.0');
150+
expect(returned).toBe(ALL_TOKENS_MOCK);
152151
});
153152

154-
it('includes zero-balance tokens from useSendTokens that match the allowlist', () => {
155-
const { result } = runHook({
156-
type: TransactionType.predictWithdraw,
157-
postQuoteFlags: {
158-
default: {
159-
enabled: true,
160-
tokens: { '0x1': ['0xaaa', '0xddd'] },
161-
},
162-
},
163-
});
164-
165-
const filtered = result.current([]);
153+
it('calls useSendTokens without full catalog options for non-withdraw transactions', () => {
154+
runHook({ type: TransactionType.simpleSend });
166155

167-
expect(filtered).toHaveLength(2);
168-
expect(filtered[0].address).toBe('0xaaa');
169-
expect(filtered[1].address).toBe('0xDDD');
170-
expect(filtered[1].name).toBe('Catalog Token');
171-
expect(filtered[1].balance).toBe('0');
156+
expect(mockUseSendTokens).toHaveBeenCalledWith({
157+
includeNoBalance: false,
158+
includeAllTokens: false,
159+
tokenFilter: undefined,
160+
});
172161
});
173162

174-
it('uses override allowlist when override key matches transaction type', () => {
175-
const { result } = runHook({
163+
it('calls useSendTokens without full catalog options when withdraw allowlist is missing', () => {
164+
runHook({
176165
type: TransactionType.predictWithdraw,
177166
postQuoteFlags: {
178-
default: {
179-
enabled: true,
180-
tokens: { '0x1': ['0xaaa'] },
181-
},
182-
overrides: {
183-
predictWithdraw: {
184-
enabled: true,
185-
tokens: { '0x1': ['0xbbb'] },
186-
},
187-
},
167+
default: { enabled: true },
188168
},
189169
});
190170

191-
const filtered = result.current([]);
192-
193-
expect(filtered).toHaveLength(1);
194-
expect(filtered[0].address).toBe('0xbbb');
171+
expect(mockUseSendTokens).toHaveBeenCalledWith({
172+
includeNoBalance: false,
173+
includeAllTokens: false,
174+
tokenFilter: undefined,
175+
});
195176
});
196177

197-
it('override tokens replace default tokens for same property', () => {
198-
const { result } = runHook({
178+
it('calls useSendTokens with full catalog options and tokenFilter when withdraw allowlist is present', () => {
179+
runHook({
199180
type: TransactionType.predictWithdraw,
200181
postQuoteFlags: {
201-
default: {
202-
enabled: true,
203-
tokens: { '0x1': ['0xaaa'], '0x89': ['0xccc'] },
204-
},
205-
overrides: {
206-
predictWithdraw: {
207-
enabled: true,
208-
tokens: { '0x89': ['0xccc'] },
209-
},
210-
},
182+
default: { enabled: true, tokens: { '0x1': ['0xaaa'] } },
211183
},
212184
});
213185

214-
const filtered = result.current([]);
215-
216-
expect(filtered).toHaveLength(1);
217-
expect(filtered[0].address).toBe('0xccc');
218-
expect(filtered[0].chainId).toBe('0x89');
186+
expect(mockUseSendTokens).toHaveBeenCalledWith({
187+
includeNoBalance: true,
188+
includeAllTokens: true,
189+
tokenFilter: expect.any(Function),
190+
});
219191
});
220192

221-
it('falls back to default tokens when override has no tokens property', () => {
222-
const { result } = runHook({
193+
it('passes a tokenFilter that matches allowlisted chainId and address', () => {
194+
runHook({
223195
type: TransactionType.predictWithdraw,
224196
postQuoteFlags: {
225-
default: {
226-
enabled: true,
227-
tokens: { '0x1': ['0xaaa'] },
228-
},
229-
overrides: {
230-
predictWithdraw: { enabled: false },
231-
},
197+
default: { enabled: true, tokens: { '0x1': ['0xaaa', '0xbbb'] } },
232198
},
233199
});
234200

235-
const filtered = result.current([]);
201+
const args = mockUseSendTokens.mock.calls[0]?.[0] ?? {};
202+
const filter = args.tokenFilter;
236203

237-
expect(filtered).toHaveLength(1);
238-
expect(filtered[0].address).toBe('0xaaa');
204+
expect(filter).toBeDefined();
205+
expect(filter?.('0x1', '0xaaa')).toBe(true);
206+
expect(filter?.('0x1', '0xbbb')).toBe(true);
207+
expect(filter?.('0x1', '0xccc')).toBe(false);
208+
expect(filter?.('0x89', '0xaaa')).toBe(false);
239209
});
240210

241-
it('matches native token via zero address in allowlist', () => {
242-
const { result } = runHook({
211+
it('passes a tokenFilter that matches case-insensitively', () => {
212+
runHook({
243213
type: TransactionType.predictWithdraw,
244214
postQuoteFlags: {
245-
default: {
246-
enabled: true,
247-
tokens: {
248-
'0x1': ['0x0000000000000000000000000000000000000000', '0xaaa'],
249-
},
250-
},
215+
default: { enabled: true, tokens: { '0x1': ['0xAAA'] } },
251216
},
252217
});
253218

254-
const filtered = result.current([]);
219+
const args = mockUseSendTokens.mock.calls[0]?.[0] ?? {};
220+
const filter = args.tokenFilter;
255221

256-
expect(filtered).toHaveLength(2);
257-
expect(filtered[0].address).toBe('0xaaa');
258-
expect(filtered[1].isNative).toBe(true);
259-
expect(filtered[1].symbol).toBe('ETH');
222+
expect(filter).toBeDefined();
223+
expect(filter?.('0x1', '0xaaa')).toBe(true);
224+
expect(filter?.('0X1', '0xAAA')).toBe(true);
260225
});
261226

262-
it('excludes tokens not in allowlist', () => {
263-
const { result } = runHook({
227+
it('passes a tokenFilter that returns false for chains not in allowlist', () => {
228+
runHook({
264229
type: TransactionType.predictWithdraw,
265230
postQuoteFlags: {
266-
default: {
267-
enabled: true,
268-
tokens: { '0x1': ['0xaaa', '0xzzz'] },
269-
},
231+
default: { enabled: true, tokens: { '0x1': ['0xaaa'] } },
270232
},
271233
});
272234

273-
const filtered = result.current([]);
235+
const args = mockUseSendTokens.mock.calls[0]?.[0] ?? {};
236+
const filter = args.tokenFilter;
274237

275-
expect(filtered).toHaveLength(1);
276-
expect(filtered[0].address).toBe('0xaaa');
238+
expect(filter).toBeDefined();
239+
expect(filter?.('0x999', '0xaaa')).toBe(false);
277240
});
278241

279-
it('matches chain ID case-insensitively', () => {
280-
const { result } = runHook({
242+
it('passes a tokenFilter that matches native tokens via zero address in allowlist', () => {
243+
runHook({
281244
type: TransactionType.predictWithdraw,
282245
postQuoteFlags: {
283246
default: {
284247
enabled: true,
285-
tokens: { '0x89': ['0xccc'] },
248+
tokens: {
249+
'0x89': ['0x0000000000000000000000000000000000000000'],
250+
},
286251
},
287252
},
288253
});
289254

290-
const filtered = result.current([]);
291-
292-
expect(filtered).toHaveLength(1);
293-
expect(filtered[0].address).toBe('0xccc');
294-
});
295-
296-
it('calls useSendTokens without full catalog options for non-withdraw transactions', () => {
297-
runHook({ type: TransactionType.simpleSend });
298-
299-
expect(mockUseSendTokens).toHaveBeenCalledWith({
300-
includeNoBalance: false,
301-
includeAllTokens: false,
302-
});
303-
});
255+
const args = mockUseSendTokens.mock.calls[0]?.[0] ?? {};
256+
const filter = args.tokenFilter;
257+
const nativeAddress = getNativeTokenAddress('0x89');
304258

305-
it('calls useSendTokens without full catalog options when withdraw allowlist is missing', () => {
306-
runHook({
307-
type: TransactionType.predictWithdraw,
308-
postQuoteFlags: {
309-
default: { enabled: true },
310-
},
311-
});
312-
313-
expect(mockUseSendTokens).toHaveBeenCalledWith({
314-
includeNoBalance: false,
315-
includeAllTokens: false,
316-
});
317-
});
318-
319-
it('calls useSendTokens with full catalog options when withdraw allowlist is present', () => {
320-
runHook({
321-
type: TransactionType.predictWithdraw,
322-
postQuoteFlags: {
323-
default: { enabled: true, tokens: { '0x1': ['0xaaa'] } },
324-
},
325-
});
326-
327-
expect(mockUseSendTokens).toHaveBeenCalledWith({
328-
includeNoBalance: true,
329-
includeAllTokens: true,
330-
});
259+
expect(filter).toBeDefined();
260+
expect(filter?.('0x89', nativeAddress)).toBe(true);
261+
expect(filter?.('0x89', '0xrandom')).toBe(false);
331262
});
332263
});

app/components/Views/confirmations/hooks/pay/useWithdrawTokenFilter.ts

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -29,58 +29,58 @@ export function useWithdrawTokenFilter(): (tokens: AssetType[]) => AssetType[] {
2929
);
3030
const allowlist = config.tokens;
3131
const shouldLoadFullTokenCatalog = isWithdraw && Boolean(allowlist);
32+
33+
const tokenFilter = useMemo(() => {
34+
if (!allowlist) {
35+
return undefined;
36+
}
37+
const lookup = buildAllowlistLookup(allowlist);
38+
return (chainId: string, address: string) =>
39+
lookup.get(chainId.toLowerCase())?.has(address.toLowerCase()) ?? false;
40+
}, [allowlist]);
41+
3242
const allTokens = useSendTokens({
3343
includeNoBalance: shouldLoadFullTokenCatalog,
3444
includeAllTokens: shouldLoadFullTokenCatalog,
45+
tokenFilter,
3546
});
3647

37-
const filtered = useMemo(() => {
38-
if (!shouldLoadFullTokenCatalog || !allowlist) {
39-
return undefined;
40-
}
41-
return allTokens.filter((token) => isAllowlisted(token, allowlist));
42-
}, [allTokens, allowlist, shouldLoadFullTokenCatalog]);
43-
4448
return useCallback(
4549
(tokens: AssetType[]): AssetType[] => {
46-
if (!isWithdraw || !filtered) {
50+
if (!isWithdraw || !shouldLoadFullTokenCatalog) {
4751
return tokens;
4852
}
49-
return filtered;
53+
return allTokens;
5054
},
51-
[isWithdraw, filtered],
55+
[isWithdraw, shouldLoadFullTokenCatalog, allTokens],
5256
);
5357
}
5458

55-
function isAllowlisted(
56-
token: AssetType,
59+
/**
60+
* Pre-computes a Map<chainId, Set<address>> from the allowlist for O(1) lookups.
61+
* Expands zero-address entries to also include the chain-specific native address.
62+
*/
63+
function buildAllowlistLookup(
5764
allowlist: Record<Hex, Hex[]>,
58-
): boolean {
59-
const chainId = token.chainId?.toLowerCase() as Hex | undefined;
60-
if (!chainId) {
61-
return false;
62-
}
65+
): Map<string, Set<string>> {
66+
const lookup = new Map<string, Set<string>>();
6367

64-
const allowlistKey = Object.keys(allowlist).find(
65-
(key) => key.toLowerCase() === chainId,
66-
) as Hex | undefined;
67-
const addresses = allowlistKey ? allowlist[allowlistKey] : undefined;
68-
if (!addresses) {
69-
return false;
70-
}
68+
for (const [chainId, addresses] of Object.entries(allowlist)) {
69+
const lowerChainId = chainId.toLowerCase();
70+
const addressSet = new Set<string>();
7171

72-
const tokenAddr = token.address?.toLowerCase();
73-
return addresses.some((allowed) => {
74-
const allowedLower = allowed.toLowerCase();
75-
if (tokenAddr === allowedLower) {
76-
return true;
77-
}
78-
// Allowlist may use 0x000…000 for native tokens while the token list
79-
// uses the chain-specific native address (e.g. 0x…1010 on Polygon).
80-
if (isNativeAddress(allowedLower)) {
81-
const nativeAddr = getNativeTokenAddress(chainId);
82-
return tokenAddr === nativeAddr.toLowerCase();
72+
for (const addr of addresses) {
73+
const lowerAddr = addr.toLowerCase();
74+
addressSet.add(lowerAddr);
75+
76+
if (isNativeAddress(lowerAddr)) {
77+
const nativeAddr = getNativeTokenAddress(lowerChainId as Hex);
78+
addressSet.add(nativeAddr.toLowerCase());
79+
}
8380
}
84-
return false;
85-
});
81+
82+
lookup.set(lowerChainId, addressSet);
83+
}
84+
85+
return lookup;
8686
}

0 commit comments

Comments
 (0)