Skip to content

Commit 8a0e475

Browse files
feat(frontend): extend swap tokens filter utils (#12632)
# Motivation - Extends resolveSwapTokenLookup to return a category field ('icp' | 'evm' | 'sol') alongside info and identifier, enabling per-category post-filtering. - Adds an optional compatibleTokenIds parameter to filterSwapTokens — a partial map from category to a Set<string> of allowed token identifiers. When a category is present in the map, only tokens whose identifier is in the set pass through; categories absent from the map are unaffected. - Coverage rules still act as the primary gate — compatibleTokenIds is only evaluated on tokens that already pass the coverage check. This enables cross-chain swap providers to restrict valid destination tokens (e.g. limit EVM destinations) without hiding unrelated same-chain tokens (e.g. ICP tokens).
1 parent 1b5b80d commit 8a0e475

2 files changed

Lines changed: 145 additions & 12 deletions

File tree

src/frontend/src/lib/utils/swap-tokens-filter.utils.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
import type { Token } from '$lib/types/token';
88
import type { TokenToggleable } from '$lib/types/token-toggleable';
99
import { isTokenSpl } from '$sol/utils/spl.utils';
10-
import { isNullish } from '@dfinity/utils';
10+
import { isNullish, nonNullish } from '@dfinity/utils';
1111

1212
/**
1313
* Resolves the provider-group info and the token identifier used for matching
@@ -21,25 +21,27 @@ const resolveSwapTokenLookup = ({
2121
}: {
2222
token: Token;
2323
supportedData: SwapSupportedTokensData;
24-
}): { info: SwapSupportedTokensInfo; identifier: string } | undefined => {
24+
}):
25+
| { info: SwapSupportedTokensInfo; identifier: string; category: 'icp' | 'evm' | 'sol' }
26+
| undefined => {
2527
if (isIcToken(token)) {
26-
return { info: supportedData.icp, identifier: token.ledgerCanisterId };
28+
return { info: supportedData.icp, identifier: token.ledgerCanisterId, category: 'icp' };
2729
}
2830

2931
if (isTokenErcFungible(token)) {
30-
return { info: supportedData.evm, identifier: token.address.toLowerCase() };
32+
return { info: supportedData.evm, identifier: token.address.toLowerCase(), category: 'evm' };
3133
}
3234

3335
if (isTokenSpl(token)) {
34-
return { info: supportedData.sol, identifier: token.address };
36+
return { info: supportedData.sol, identifier: token.address, category: 'sol' };
3537
}
3638

3739
if (token.standard.code === 'ethereum') {
38-
return { info: supportedData.evm, identifier: token.symbol.toLowerCase() };
40+
return { info: supportedData.evm, identifier: token.symbol.toLowerCase(), category: 'evm' };
3941
}
4042

4143
if (token.standard.code === 'solana') {
42-
return { info: supportedData.sol, identifier: token.symbol.toLowerCase() };
44+
return { info: supportedData.sol, identifier: token.symbol.toLowerCase(), category: 'sol' };
4345
}
4446
};
4547

@@ -55,13 +57,19 @@ const resolveSwapTokenLookup = ({
5557
* - S = union of supported tokens across providers that expose a list
5658
* - A = active (enabled) tokens
5759
* - I = inactive (disabled) tokens
60+
*
61+
* The optional `compatibleTokenIds` parameter narrows destinations on a per-category basis.
62+
* Only categories present in the map are filtered; others pass through unchanged.
63+
* This lets cross-chain providers restrict EVM destinations without hiding same-chain ICP tokens.
5864
*/
5965
export const filterSwapTokens = <T extends Token>({
6066
tokens,
61-
supportedData
67+
supportedData,
68+
compatibleTokenIds
6269
}: {
6370
tokens: TokenToggleable<T>[];
6471
supportedData: SwapSupportedTokensData | undefined;
72+
compatibleTokenIds?: Partial<Record<'icp' | 'evm' | 'sol', Set<string>>>;
6573
}): TokenToggleable<T>[] => {
6674
if (isNullish(supportedData)) {
6775
return tokens;
@@ -74,7 +82,7 @@ export const filterSwapTokens = <T extends Token>({
7482
return token.enabled;
7583
}
7684

77-
const { info, identifier } = lookup;
85+
const { info, identifier, category } = lookup;
7886

7987
if (isNullish(info)) {
8088
return token.enabled;
@@ -87,11 +95,19 @@ export const filterSwapTokens = <T extends Token>({
8795
}
8896

8997
const isSupported = supportedTokenIds.has(identifier);
98+
const passesCoverage = coverage === 'all' ? isSupported : token.enabled || isSupported;
99+
100+
if (!passesCoverage) {
101+
return false;
102+
}
90103

91-
if (coverage === 'all') {
92-
return isSupported;
104+
if (nonNullish(compatibleTokenIds)) {
105+
const categoryFilter = compatibleTokenIds[category];
106+
if (nonNullish(categoryFilter)) {
107+
return categoryFilter.has(identifier);
108+
}
93109
}
94110

95-
return token.enabled || isSupported;
111+
return true;
96112
});
97113
};

src/frontend/src/tests/lib/utils/swap-tokens-filter.utils.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,4 +306,121 @@ describe('filterSwapTokens', () => {
306306
expect(result).not.toContain(erc20Inactive);
307307
});
308308
});
309+
310+
describe('compatibleTokenIds', () => {
311+
const supportedData: SwapSupportedTokensData = {
312+
icp: {
313+
coverage: 'all',
314+
supportedTokenIds: new Set([
315+
icpTokenActive.ledgerCanisterId,
316+
icpTokenInactive.ledgerCanisterId
317+
])
318+
},
319+
evm: {
320+
coverage: 'all',
321+
supportedTokenIds: new Set([
322+
erc20Active.address.toLowerCase(),
323+
erc20Inactive.address.toLowerCase()
324+
])
325+
},
326+
sol: { coverage: 'all', supportedTokenIds: new Set([splActive.address]) }
327+
};
328+
329+
it('includes token when it passes the category filter', () => {
330+
const result = filterSwapTokens({
331+
tokens: [erc20Active],
332+
supportedData,
333+
compatibleTokenIds: { evm: new Set([erc20Active.address.toLowerCase()]) }
334+
});
335+
336+
expect(result).toContain(erc20Active);
337+
});
338+
339+
it('excludes token when it does not pass the category filter', () => {
340+
const result = filterSwapTokens({
341+
tokens: [erc20Inactive],
342+
supportedData,
343+
compatibleTokenIds: { evm: new Set([erc20Active.address.toLowerCase()]) }
344+
});
345+
346+
expect(result).not.toContain(erc20Inactive);
347+
});
348+
349+
it('does not restrict categories absent from the map', () => {
350+
const result = filterSwapTokens({
351+
tokens: [icpTokenActive, icpTokenInactive, erc20Active],
352+
supportedData,
353+
compatibleTokenIds: { evm: new Set([erc20Active.address.toLowerCase()]) }
354+
});
355+
356+
// EVM filtered
357+
expect(result).toContain(erc20Active);
358+
// ICP not filtered — passes through on coverage rules
359+
expect(result).toContain(icpTokenActive);
360+
expect(result).toContain(icpTokenInactive);
361+
});
362+
363+
it('filters out all tokens in a category when the filter set is empty', () => {
364+
const result = filterSwapTokens({
365+
tokens: [erc20Active, erc20Inactive],
366+
supportedData,
367+
compatibleTokenIds: { evm: new Set() }
368+
});
369+
370+
expect(result).not.toContain(erc20Active);
371+
expect(result).not.toContain(erc20Inactive);
372+
});
373+
374+
it('behaves like no filter when compatibleTokenIds is an empty map', () => {
375+
const result = filterSwapTokens({
376+
tokens: [icpTokenActive, icpTokenInactive, erc20Active],
377+
supportedData,
378+
compatibleTokenIds: {}
379+
});
380+
381+
expect(result).toContain(icpTokenActive);
382+
expect(result).toContain(icpTokenInactive);
383+
expect(result).toContain(erc20Active);
384+
});
385+
386+
it('applies independent filters per category simultaneously', () => {
387+
const result = filterSwapTokens({
388+
tokens: [icpTokenActive, icpTokenInactive, erc20Active, erc20Inactive, splActive],
389+
supportedData,
390+
compatibleTokenIds: {
391+
icp: new Set([icpTokenActive.ledgerCanisterId]),
392+
evm: new Set([erc20Inactive.address.toLowerCase()])
393+
}
394+
});
395+
396+
// ICP: only icpTokenActive passes the category filter
397+
expect(result).toContain(icpTokenActive);
398+
expect(result).not.toContain(icpTokenInactive);
399+
400+
// EVM: only erc20Inactive passes the category filter
401+
expect(result).not.toContain(erc20Active);
402+
expect(result).toContain(erc20Inactive);
403+
404+
// SOL: no category filter → passes through on coverage rules
405+
expect(result).toContain(splActive);
406+
});
407+
408+
it('coverage still gates tokens before compatibleTokenIds is applied', () => {
409+
const noCoverageData: SwapSupportedTokensData = {
410+
icp: { coverage: 'none', supportedTokenIds: new Set() },
411+
evm: { coverage: 'none', supportedTokenIds: new Set() },
412+
sol: { coverage: 'none', supportedTokenIds: new Set() }
413+
};
414+
415+
const result = filterSwapTokens({
416+
tokens: [erc20Active, erc20Inactive],
417+
supportedData: noCoverageData,
418+
compatibleTokenIds: { evm: new Set([erc20Inactive.address.toLowerCase()]) }
419+
});
420+
421+
// coverage=none returns enabled-only regardless of compatibleTokenIds
422+
expect(result).toContain(erc20Active);
423+
expect(result).not.toContain(erc20Inactive);
424+
});
425+
});
309426
});

0 commit comments

Comments
 (0)