@xswap-link/sdk 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.d.mts +4 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.global.js +92 -92
  5. package/dist/index.js +3208 -2862
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +3083 -2740
  8. package/dist/index.mjs.map +1 -1
  9. package/localTheme.ts +6 -4
  10. package/package.json +1 -1
  11. package/src/assets/icons/AdjustmentsIcon.tsx +5 -7
  12. package/src/assets/icons/ChainlinkMarkIcon.tsx +4 -5
  13. package/src/assets/icons/CheckCircleThinIcon.tsx +22 -0
  14. package/src/assets/icons/ChevronDownThinIcon.tsx +39 -0
  15. package/src/assets/icons/ChevronDownWideThinIcon.tsx +18 -0
  16. package/src/assets/icons/ChevronsUpDownThinIcon.tsx +21 -0
  17. package/src/assets/icons/ClockIcon.tsx +3 -7
  18. package/src/assets/icons/CloseIcon.tsx +9 -4
  19. package/src/assets/icons/ErrorCircleThinIcon.tsx +22 -0
  20. package/src/assets/icons/LinkExternalThinIcon.tsx +19 -0
  21. package/src/assets/icons/LoadingRingIcon.tsx +22 -0
  22. package/src/assets/icons/ProgressRingThinIcon.tsx +29 -0
  23. package/src/assets/icons/SearchThinIcon.tsx +15 -0
  24. package/src/assets/icons/SignThinIcon.tsx +15 -0
  25. package/src/assets/icons/WalletCheckThinIcon.tsx +24 -0
  26. package/src/assets/icons/WarningIcon.tsx +8 -5
  27. package/src/assets/icons/index.ts +11 -0
  28. package/src/components/Modal/index.tsx +14 -9
  29. package/src/components/PoweredBy/index.tsx +6 -8
  30. package/src/components/Swap/Header/index.tsx +2 -4
  31. package/src/components/Swap/HistoryView/ActivityCard/index.tsx +110 -115
  32. package/src/components/Swap/HistoryView/index.tsx +10 -7
  33. package/src/components/Swap/PickerView/index.tsx +151 -0
  34. package/src/components/Swap/ReorderButton/ReorderButton.tsx +4 -4
  35. package/src/components/Swap/SettingsView/Delivery/index.tsx +19 -17
  36. package/src/components/Swap/SettingsView/InfiniteApproval/index.tsx +19 -17
  37. package/src/components/Swap/SettingsView/Slippage/index.tsx +55 -49
  38. package/src/components/Swap/SwapView/ConfirmationView/TokenTiles/index.tsx +69 -0
  39. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/Header/index.tsx +13 -25
  40. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/Steps/index.tsx +65 -113
  41. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/index.tsx +68 -26
  42. package/src/components/Swap/SwapView/ConfirmationView/TxResult/index.tsx +51 -74
  43. package/src/components/Swap/SwapView/FeesPanel/DeliveryInfo/index.tsx +1 -1
  44. package/src/components/Swap/SwapView/FeesPanel/index.tsx +5 -5
  45. package/src/components/Swap/SwapView/SwapButton/index.tsx +2 -31
  46. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/Balance/index.tsx +2 -2
  47. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/index.tsx +2 -2
  48. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/ChainPicker/index.tsx +143 -0
  49. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/NetworkFilter/index.tsx +19 -16
  50. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/TokenItem/index.tsx +11 -4
  51. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/index.tsx +185 -105
  52. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/index.tsx +54 -120
  53. package/src/components/Swap/SwapView/SwapPanel/UsdValue/index.tsx +2 -2
  54. package/src/components/Swap/SwapView/SwapPanel/WalletPanel/index.tsx +10 -16
  55. package/src/components/Swap/SwapView/SwapPanel/index.tsx +4 -4
  56. package/src/components/Swap/SwapView/WalletPicker/index.tsx +131 -139
  57. package/src/components/Swap/SwapView/index.tsx +11 -7
  58. package/src/components/Swap/WalletPickerView/index.tsx +65 -0
  59. package/src/components/Swap/index.tsx +39 -8
  60. package/src/components/ToggleButton/index.tsx +5 -7
  61. package/src/components/TokenLogo/index.tsx +1 -1
  62. package/src/components/TxConfigForm/index.tsx +2 -2
  63. package/src/components/TxModal/index.tsx +14 -4
  64. package/src/components/global.css +18 -1
  65. package/src/constants/index.ts +4 -0
  66. package/src/context/HistoryProvider.tsx +1 -1
  67. package/src/context/SwapProvider.tsx +193 -81
  68. package/src/context/TxUIWrapper.tsx +3 -3
  69. package/src/hooks/useEvmContractApi.ts +28 -4
  70. package/src/models/payloads/GetBalancesPayload.ts +1 -0
  71. package/src/services/api.ts +37 -0
  72. package/src/utils/index.ts +27 -1
  73. package/src/utils/tokens.ts +20 -1
  74. package/src/utils/validation.ts +9 -4
  75. package/tailwind.config.js +4 -1
  76. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/ArrowIcon.tsx +0 -13
  77. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/SwapPanel/index.tsx +0 -72
  78. package/src/components/Swap/SwapView/ConfirmationView/TxResult/TokenItem/index.tsx +0 -25
@@ -1,6 +1,6 @@
1
1
  import { faCheck } from "@fortawesome/free-solid-svg-icons";
2
2
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3
- import { CloseIcon, CopyIcon, SearchIcon } from "@src/assets/icons";
3
+ import { CloseIcon, CopyIcon, SearchThinIcon } from "@src/assets/icons";
4
4
  import {
5
5
  Button,
6
6
  Spinner,
@@ -9,10 +9,9 @@ import {
9
9
  } from "@src/components";
10
10
  import { SwapPanelType } from "@src/components/Swap/SwapView";
11
11
  import { useSwapContext } from "@src/context";
12
- import { ERC20Abi } from "@src/contracts";
13
- import { useEvmContractApi } from "@src/hooks";
14
12
  import { Chain, Ecosystem, ImportedTokenData, TokenOption } from "@src/models";
15
13
  import {
14
+ isBridgeTokenConnectionSupported,
16
15
  isChainConnectionSupported,
17
16
  isETHAddressValid,
18
17
  isServer,
@@ -21,13 +20,14 @@ import {
21
20
  } from "@src/utils";
22
21
  import { BigNumber, ethers } from "ethers";
23
22
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
- import { GroupedVirtuoso } from "react-virtuoso";
23
+ import { Virtuoso } from "react-virtuoso";
25
24
  import { NetworkFilter } from "./NetworkFilter";
26
25
  import { TokenItem } from "./TokenItem";
27
26
  import { useAccount } from "wagmi";
28
27
  import { Connection, PublicKey } from "@solana/web3.js";
29
28
  import { METADATA_PROGRAM_ID } from "@src/constants";
30
29
  import { useWallet } from "@src/context/WalletProvider";
30
+ import { getTokenMetadata } from "@src/services/api";
31
31
 
32
32
  type Props = {
33
33
  type: SwapPanelType;
@@ -55,7 +55,11 @@ export const TokenPicker = ({
55
55
  const [loadingCustomTokenData, setLoadingCustomTokenData] = useState(false);
56
56
  const [customTokenData, setCustomTokenData] =
57
57
  useState<ImportedTokenData | null>(null);
58
- const [networkFilter, setNetworkFilter] = useState<Chain | undefined>(chain);
58
+ // Always opens on "All Networks" — the cross-chain token list is the
59
+ // default view even when a chain is already set on the swap panel.
60
+ const [networkFilter, setNetworkFilter] = useState<Chain | undefined>(
61
+ undefined,
62
+ );
59
63
  const currentRouteRequestNonce = useRef<number>(0);
60
64
  const tokenSearchRef = useRef<HTMLInputElement | null>(null);
61
65
 
@@ -67,21 +71,26 @@ export const TokenPicker = ({
67
71
  findTokenDataByAddressAndChain,
68
72
  tokenPrices,
69
73
  addTokenToChainIfNotExists,
74
+ bridgeTokensDictionary,
70
75
  } = useSwapContext();
71
76
  const { address } = useAccount();
72
77
  const {
73
78
  solana: { address: solanaAddress },
74
79
  } = useWallet();
75
- const evmContractApi = useEvmContractApi();
76
80
 
77
81
  useEffect(() => {
78
82
  setShowImportWarning(false);
79
83
  setAcceptRisks(false);
80
84
  }, [isOpen]);
81
85
 
86
+ // Custom token imports follow the network filter, not the chain currently
87
+ // set on the swap panel — the picker lists tokens across networks, so a
88
+ // pasted address is resolved on the network the user is browsing.
89
+ const importChain = networkFilter ?? chain;
90
+
82
91
  const importCustomToken = useCallback(() => {
83
92
  if (
84
- chain &&
93
+ importChain &&
85
94
  customTokenData &&
86
95
  (isETHAddressValid(searchValue) || isSolanaAddressValid(searchValue))
87
96
  ) {
@@ -101,12 +110,12 @@ export const TokenPicker = ({
101
110
  quickPick: false,
102
111
  supported: true,
103
112
  priority: 0,
104
- chainId: chain.chainId,
113
+ chainId: importChain.chainId,
105
114
  };
106
115
 
107
116
  if (!isServer) {
108
- customTokens[chain.chainId] = {
109
- ...(customTokens[chain.chainId] || {}),
117
+ customTokens[importChain.chainId] = {
118
+ ...(customTokens[importChain.chainId] || {}),
110
119
  [searchValue.toLowerCase()]: newCustomToken,
111
120
  };
112
121
  localStorage.setItem("custom-tokens", JSON.stringify(customTokens));
@@ -117,7 +126,7 @@ export const TokenPicker = ({
117
126
  setModalOpen(false);
118
127
  }
119
128
  }, [
120
- chain,
129
+ importChain,
121
130
  customTokenData,
122
131
  searchValue,
123
132
  addTokenToChainIfNotExists,
@@ -125,42 +134,32 @@ export const TokenPicker = ({
125
134
  ]);
126
135
 
127
136
  const getCustomTokenData = useCallback(async () => {
137
+ if (!importChain) {
138
+ return;
139
+ }
128
140
  const nonce = Date.now();
129
141
  currentRouteRequestNonce.current = nonce;
130
- const rpcUrl = supportedChains.find(
131
- ({ ecosystem, chainId }) =>
132
- ecosystem === Ecosystem.EVM && chainId === chain?.chainId,
133
- )?.publicRpcUrls[0];
134
- const customTokenContract = new ethers.Contract(
135
- searchValue.toLowerCase(),
136
- ERC20Abi,
137
- ethers.getDefaultProvider(rpcUrl),
138
- );
139
142
  setLoadingCustomTokenData(true);
140
143
  try {
141
- const responses = await Promise.all([
142
- evmContractApi.query(customTokenContract, "decimals"),
143
- evmContractApi.query(customTokenContract, "name"),
144
- evmContractApi.query(customTokenContract, "symbol"),
145
- ]);
144
+ // Resolved server-side through private RPCs — public ones often 429
145
+ const tokenData = await getTokenMetadata({
146
+ chainId: importChain.chainId,
147
+ tokenAddress: searchValue.toLowerCase(),
148
+ });
146
149
  if (nonce !== currentRouteRequestNonce.current) {
147
150
  return;
148
151
  }
149
- const [decimals, name, symbol] = responses;
150
- setCustomTokenData({
151
- address: searchValue.toLowerCase(),
152
- decimals,
153
- name,
154
- symbol,
155
- });
152
+ setCustomTokenData(tokenData);
156
153
  } catch (_) {
157
- setCustomTokenData(null);
154
+ if (nonce === currentRouteRequestNonce.current) {
155
+ setCustomTokenData(null);
156
+ }
158
157
  } finally {
159
158
  if (nonce === currentRouteRequestNonce.current) {
160
159
  setLoadingCustomTokenData(false);
161
160
  }
162
161
  }
163
- }, [chain, searchValue, evmContractApi, supportedChains]);
162
+ }, [importChain, searchValue]);
164
163
 
165
164
  const getCustomSolanaTokenData = useCallback(async () => {
166
165
  const nonce = Date.now();
@@ -168,7 +167,7 @@ export const TokenPicker = ({
168
167
 
169
168
  const rpcUrl = supportedChains.find(
170
169
  ({ ecosystem, chainId }) =>
171
- ecosystem === Ecosystem.SOLANA && chainId === chain?.chainId,
170
+ ecosystem === Ecosystem.SOLANA && chainId === importChain?.chainId,
172
171
  )?.publicRpcUrls[0];
173
172
 
174
173
  if (!rpcUrl || !searchValue || !isSolanaAddressValid(searchValue)) {
@@ -258,23 +257,26 @@ export const TokenPicker = ({
258
257
  setLoadingCustomTokenData(false);
259
258
  }
260
259
  }
261
- }, [chain, searchValue, supportedChains]);
260
+ }, [importChain, searchValue, supportedChains]);
262
261
 
263
262
  useEffect(() => {
264
263
  if (
265
264
  !bridgeUI &&
266
- chain &&
265
+ importChain &&
267
266
  !findTokenDataByAddressAndChain(
268
- chain.ecosystem === Ecosystem.EVM
267
+ importChain.ecosystem === Ecosystem.EVM
269
268
  ? searchValue.toLowerCase()
270
269
  : searchValue,
271
- chain.chainId,
270
+ importChain.chainId,
272
271
  )
273
272
  ) {
274
- if (chain.ecosystem === Ecosystem.EVM && isETHAddressValid(searchValue)) {
273
+ if (
274
+ importChain.ecosystem === Ecosystem.EVM &&
275
+ isETHAddressValid(searchValue)
276
+ ) {
275
277
  getCustomTokenData();
276
278
  } else if (
277
- chain.ecosystem === Ecosystem.SOLANA &&
279
+ importChain.ecosystem === Ecosystem.SOLANA &&
278
280
  isSolanaAddressValid(searchValue)
279
281
  ) {
280
282
  getCustomSolanaTokenData();
@@ -292,13 +294,12 @@ export const TokenPicker = ({
292
294
  getCustomTokenData,
293
295
  getCustomSolanaTokenData,
294
296
  findTokenDataByAddressAndChain,
295
- chain,
297
+ importChain,
296
298
  ]);
297
299
 
298
300
  useEffect(() => {
299
301
  setSearchValue("");
300
- setNetworkFilter(chain);
301
- // eslint-disable-next-line react-hooks/exhaustive-deps
302
+ setNetworkFilter(undefined);
302
303
  }, [isOpen]);
303
304
 
304
305
  const isMatch = (value, searchValue) =>
@@ -330,6 +331,47 @@ export const TokenPicker = ({
330
331
  [tokenPrices],
331
332
  );
332
333
 
334
+ const getResetWarning = useCallback(
335
+ (token: TokenOption): SwapPanelType | null => {
336
+ if (type === "destination") {
337
+ if (
338
+ srcChain &&
339
+ !isChainConnectionSupported(srcChain, token.chainId, bridgeUI)
340
+ ) {
341
+ return "source";
342
+ }
343
+ return null;
344
+ }
345
+ const tokenChain = supportedChains.find(
346
+ ({ chainId }) => chainId === token.chainId,
347
+ );
348
+ if (
349
+ dstChain &&
350
+ tokenChain &&
351
+ (!isChainConnectionSupported(tokenChain, dstChain.chainId, bridgeUI) ||
352
+ (bridgeUI &&
353
+ !isBridgeTokenConnectionSupported({
354
+ token,
355
+ tokenChain,
356
+ dstChainId: dstChain.chainId,
357
+ bridgeTokensDictionary,
358
+ })))
359
+ ) {
360
+ return "destination";
361
+ }
362
+ return null;
363
+ },
364
+ [type, srcChain, dstChain, bridgeUI, supportedChains, bridgeTokensDictionary],
365
+ );
366
+
367
+ // Tokens that reset the other side of the swap on selection go below
368
+ // the ones that can be picked freely.
369
+ const compareByResetWarning = useCallback(
370
+ (tokenA: TokenOption, tokenB: TokenOption) =>
371
+ Number(!!getResetWarning(tokenA)) - Number(!!getResetWarning(tokenB)),
372
+ [getResetWarning],
373
+ );
374
+
333
375
  const ownedTokens = useMemo(() => {
334
376
  if (!address && !solanaAddress) {
335
377
  return [];
@@ -337,6 +379,10 @@ export const TokenPicker = ({
337
379
  return visibleTokens
338
380
  .filter((token) => BigNumber.from(token?.balance || "0").gt(0))
339
381
  .sort((tokenA, tokenB) => {
382
+ const warningOrder = compareByResetWarning(tokenA, tokenB);
383
+ if (warningOrder !== 0) {
384
+ return warningOrder;
385
+ }
340
386
  const valueA = calculateTokenValue(tokenA);
341
387
  const valueB = calculateTokenValue(tokenB);
342
388
  // First sort by USD value
@@ -346,7 +392,13 @@ export const TokenPicker = ({
346
392
  // If USD values are equal (or both 0), sort by priority
347
393
  return tokenB.priority - tokenA.priority;
348
394
  });
349
- }, [visibleTokens, address, solanaAddress, calculateTokenValue]);
395
+ }, [
396
+ visibleTokens,
397
+ address,
398
+ solanaAddress,
399
+ calculateTokenValue,
400
+ compareByResetWarning,
401
+ ]);
350
402
 
351
403
  const recommendedTokens = useMemo(
352
404
  () =>
@@ -357,9 +409,11 @@ export const TokenPicker = ({
357
409
  BigNumber.from(token?.balance || "0").eq(0),
358
410
  )
359
411
  .sort(
360
- (tokenDataA, tokenDataB) => tokenDataB.priority - tokenDataA.priority,
412
+ (tokenDataA, tokenDataB) =>
413
+ compareByResetWarning(tokenDataA, tokenDataB) ||
414
+ tokenDataB.priority - tokenDataA.priority,
361
415
  ),
362
- [visibleTokens, address, solanaAddress],
416
+ [visibleTokens, address, solanaAddress, compareByResetWarning],
363
417
  );
364
418
 
365
419
  const tokenGroups = useMemo(() => {
@@ -371,36 +425,49 @@ export const TokenPicker = ({
371
425
  : [{ label: "RECOMMENDED.", tokens: recommendedTokens }];
372
426
  }, [ownedTokens, recommendedTokens]);
373
427
 
374
- const allTokens = useMemo(
375
- () => tokenGroups.flatMap((group) => group.tokens),
428
+ // The current group's label renders OUTSIDE the scroll container (pinned
429
+ // above it, transparent bg), so rows physically clip on the label's bottom
430
+ // edge — the scroller's top edge. Labels of the following groups stay
431
+ // in-flow and take over the pinned slot once they reach that line.
432
+ const listRows = useMemo(
433
+ () =>
434
+ tokenGroups.flatMap((group, groupIndex) => {
435
+ const rows: (
436
+ | { kind: "label"; label: string; groupLabel: string }
437
+ | { kind: "empty"; groupLabel: string }
438
+ | { kind: "token"; token: TokenOption; groupLabel: string }
439
+ )[] =
440
+ groupIndex === 0
441
+ ? []
442
+ : [
443
+ {
444
+ kind: "label",
445
+ label: group.label,
446
+ groupLabel: group.label,
447
+ },
448
+ ];
449
+ if (group.tokens.length) {
450
+ rows.push(
451
+ ...group.tokens.map((token) => ({
452
+ kind: "token" as const,
453
+ token,
454
+ groupLabel: group.label,
455
+ })),
456
+ );
457
+ } else {
458
+ rows.push({ kind: "empty", groupLabel: group.label });
459
+ }
460
+ return rows;
461
+ }),
376
462
  [tokenGroups],
377
463
  );
378
464
 
379
- const getResetWarning = useCallback(
380
- (token: TokenOption): SwapPanelType | null => {
381
- if (type === "destination") {
382
- if (
383
- srcChain &&
384
- !isChainConnectionSupported(srcChain, token.chainId, bridgeUI)
385
- ) {
386
- return "source";
387
- }
388
- return null;
389
- }
390
- const tokenChain = supportedChains.find(
391
- ({ chainId }) => chainId === token.chainId,
392
- );
393
- if (
394
- dstChain &&
395
- tokenChain &&
396
- !isChainConnectionSupported(tokenChain, dstChain.chainId, bridgeUI)
397
- ) {
398
- return "destination";
399
- }
400
- return null;
401
- },
402
- [type, srcChain, dstChain, bridgeUI, supportedChains],
403
- );
465
+ // Index of the topmost (partially) visible row, reported by Virtuoso.
466
+ const [topRowIndex, setTopRowIndex] = useState(0);
467
+ const pinnedGroupLabel =
468
+ listRows[Math.min(topRowIndex, listRows.length - 1)]?.groupLabel ??
469
+ tokenGroups[0]?.label ??
470
+ "";
404
471
 
405
472
  useEffect(() => {
406
473
  if (isOpen && tokenSearchRef.current) {
@@ -520,8 +587,8 @@ export const TokenPicker = ({
520
587
 
521
588
  <div className="flex items-center justify-between gap-2 h-14 pl-4 pr-1 rounded-xl bg-t_bg_tertiary bg-opacity-[0.03] border border-solid border-t_text_primary border-opacity-5 mb-6">
522
589
  <div className="flex items-center gap-2 flex-1 min-w-0">
523
- <div className="fill-t_text_primary opacity-50 [&_svg]:w-4 [&_svg]:h-4 flex items-center shrink-0">
524
- <SearchIcon />
590
+ <div className="text-t_text_primary opacity-60 flex items-center shrink-0">
591
+ <SearchThinIcon />
525
592
  </div>
526
593
  <input
527
594
  ref={tokenSearchRef}
@@ -549,7 +616,7 @@ export const TokenPicker = ({
549
616
  <div className="flex gap-3 py-3 px-4 items-center rounded-xl bg-t_bg_tertiary bg-opacity-[0.03]">
550
617
  <TokenLogoWithChain
551
618
  token={customTokenData}
552
- chain={chain}
619
+ chain={importChain}
553
620
  tokenLogoClassName="w-9 h-9"
554
621
  generatedLogoClassName="w-9 h-9 text-[16px]"
555
622
  chainLogoClassName="w-5"
@@ -560,7 +627,7 @@ export const TokenPicker = ({
560
627
  {customTokenData.name}
561
628
  </div>
562
629
  <div className="text-xs text-t_text_primary !text-opacity-50 font-medium">
563
- {chain?.displayName}
630
+ {importChain?.displayName}
564
631
  </div>
565
632
  </div>
566
633
  <div className="text-sm text-t_text_primary text-opacity-50">
@@ -578,41 +645,54 @@ export const TokenPicker = ({
578
645
  </div>
579
646
  </div>
580
647
  ) : (
581
- <div className="flex flex-col h-full">
582
- <GroupedVirtuoso
583
- style={{ maxHeight: "700px", height: "100%" }}
584
- groupCounts={tokenGroups.map((group) => group.tokens.length)}
585
- groupContent={(index) => (
586
- <div className="pb-3 bg-t_bg_primary">
587
- <p className="text-[10px] font-medium leading-4 tracking-[0.015em] text-t_text_primary text-opacity-30 text-left">
588
- {tokenGroups[index]?.label}
589
- </p>
590
- {!tokenGroups[index]?.tokens.length && (
591
- <div className="text-center py-1 text-sm text-t_text_primary text-opacity-50">
592
- No token found
593
- </div>
594
- )}
595
- </div>
596
- )}
597
- itemContent={(index) => {
598
- const token = allTokens[index];
599
- return (
600
- token && (
648
+ <div className="flex flex-col h-full min-h-0">
649
+ {/* Pinned label of the group at the top of the list; sits
650
+ above the scroller, so rows clip on its bottom edge. */}
651
+ <p className="pb-3 text-[10px] font-medium leading-4 tracking-[0.015em] text-t_text_primary text-opacity-30 text-left shrink-0">
652
+ {pinnedGroupLabel}
653
+ </p>
654
+ <div className="relative flex flex-col flex-1 min-h-0">
655
+ <Virtuoso
656
+ style={{ maxHeight: "700px", height: "100%" }}
657
+ totalCount={listRows.length}
658
+ rangeChanged={({ startIndex }) => setTopRowIndex(startIndex)}
659
+ itemContent={(index) => {
660
+ const row = listRows[index];
661
+ if (!row) {
662
+ return null;
663
+ }
664
+ if (row.kind === "label") {
665
+ return (
666
+ <p className="pt-2 pb-3 text-[10px] font-medium leading-4 tracking-[0.015em] text-t_text_primary text-opacity-30 text-left">
667
+ {row.label}
668
+ </p>
669
+ );
670
+ }
671
+ if (row.kind === "empty") {
672
+ return (
673
+ <div className="text-center py-1 pb-3 text-sm text-t_text_primary text-opacity-50">
674
+ No token found
675
+ </div>
676
+ );
677
+ }
678
+ return (
601
679
  <TokenItem
602
680
  panelType={type}
603
- token={token}
681
+ token={row.token}
604
682
  selectedChainId={chain?.chainId}
605
683
  selectedTokenAddress={selectedTokenAddress}
606
- resetWarning={getResetWarning(token)}
684
+ resetWarning={getResetWarning(row.token)}
607
685
  onClick={() => {
608
- onSelect(token);
686
+ onSelect(row.token);
609
687
  setModalOpen(false);
610
688
  }}
611
689
  />
612
- )
613
- );
614
- }}
615
- />
690
+ );
691
+ }}
692
+ />
693
+ {/* Scroll fade — rows dissolve towards the widget bottom */}
694
+ <div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-t_bg_secondary/65 to-t_bg_secondary/0" />
695
+ </div>
616
696
  </div>
617
697
  )}
618
698
  </>