@xswap-link/sdk 0.13.1 → 0.14.1

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 (70) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.d.mts +10 -2
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.global.js +87 -86
  5. package/dist/index.js +2888 -2521
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2828 -2458
  8. package/dist/index.mjs.map +1 -1
  9. package/localTheme.ts +5 -3
  10. package/package.json +1 -1
  11. package/src/assets/icons/AdjustmentsIcon.tsx +16 -0
  12. package/src/assets/icons/CaretDownIcon.tsx +12 -0
  13. package/src/assets/icons/CaretsUpDownIcon.tsx +13 -0
  14. package/src/assets/icons/ChainlinkMarkIcon.tsx +17 -0
  15. package/src/assets/icons/ClockIcon.tsx +14 -0
  16. package/src/assets/icons/CloseIcon.tsx +6 -2
  17. package/src/assets/icons/WarningIcon.tsx +19 -0
  18. package/src/assets/icons/index.ts +6 -0
  19. package/src/components/Modal/index.tsx +14 -9
  20. package/src/components/PoweredBy/index.tsx +12 -5
  21. package/src/components/Swap/Header/Controls/index.tsx +50 -36
  22. package/src/components/Swap/Header/index.tsx +12 -5
  23. package/src/components/Swap/HistoryView/ActivityCard/index.tsx +212 -0
  24. package/src/components/Swap/HistoryView/index.tsx +24 -6
  25. package/src/components/Swap/PickerView/index.tsx +151 -0
  26. package/src/components/Swap/ReorderButton/ReorderButton.tsx +5 -7
  27. package/src/components/Swap/SettingsView/Delivery/index.tsx +27 -28
  28. package/src/components/Swap/SettingsView/InfiniteApproval/index.tsx +27 -24
  29. package/src/components/Swap/SettingsView/Slippage/index.tsx +44 -43
  30. package/src/components/Swap/SettingsView/index.tsx +1 -1
  31. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/index.tsx +50 -4
  32. package/src/components/Swap/SwapView/FeesPanel/DeliveryInfo/index.tsx +9 -19
  33. package/src/components/Swap/SwapView/FeesPanel/Fee/index.tsx +4 -4
  34. package/src/components/Swap/SwapView/FeesPanel/Fees/index.tsx +5 -9
  35. package/src/components/Swap/SwapView/FeesPanel/index.tsx +21 -22
  36. package/src/components/Swap/SwapView/SwapButton/index.tsx +25 -6
  37. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/Balance/index.tsx +56 -12
  38. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/index.tsx +42 -41
  39. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/ChainPicker/index.tsx +143 -0
  40. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/NetworkFilter/index.tsx +159 -0
  41. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/TokenItem/index.tsx +105 -55
  42. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/index.tsx +226 -276
  43. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/index.tsx +57 -97
  44. package/src/components/Swap/SwapView/SwapPanel/UsdValue/index.tsx +27 -0
  45. package/src/components/Swap/SwapView/SwapPanel/WalletPanel/index.tsx +60 -49
  46. package/src/components/Swap/SwapView/SwapPanel/index.tsx +20 -25
  47. package/src/components/Swap/SwapView/WalletPicker/index.tsx +6 -1
  48. package/src/components/Swap/SwapView/index.tsx +2 -1
  49. package/src/components/Swap/index.tsx +20 -4
  50. package/src/components/ToggleButton/index.tsx +18 -15
  51. package/src/components/TxConfigForm/index.tsx +5 -5
  52. package/src/components/TxWidgetWC/index.tsx +4 -0
  53. package/src/components/TxWidgetWCWrapped/index.tsx +2 -0
  54. package/src/context/HistoryProvider.tsx +2 -1
  55. package/src/context/SwapProvider.tsx +87 -62
  56. package/src/context/TxUIWrapper.tsx +3 -3
  57. package/src/hooks/useEvmContractApi.ts +28 -4
  58. package/src/models/TransactionHistory.ts +1 -0
  59. package/src/models/payloads/GetBalancesPayload.ts +1 -0
  60. package/src/models/payloads/ModalIntegrationPayload.ts +1 -0
  61. package/src/models/payloads/WidgetIntegrationPayload.ts +1 -0
  62. package/src/services/api.ts +37 -0
  63. package/src/utils/index.ts +48 -1
  64. package/src/utils/tokens.ts +20 -1
  65. package/tailwind.config.js +4 -1
  66. package/src/components/Swap/SwapView/SwapPanel/ChainPanel/ChainPicker/ChainItem/index.tsx +0 -97
  67. package/src/components/Swap/SwapView/SwapPanel/ChainPanel/ChainPicker/index.tsx +0 -103
  68. package/src/components/Swap/SwapView/SwapPanel/ChainPanel/index.tsx +0 -65
  69. package/src/components/Swap/SwapView/SwapPanel/MaxPanel/index.tsx +0 -43
  70. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/QuickPickTokenItem/index.tsx +0 -37
@@ -28,10 +28,11 @@ import {
28
28
  getPrices,
29
29
  getRoute,
30
30
  getSolanaRoute,
31
+ getTokenBalance,
31
32
  } from "@src/services";
33
+ import { getCustomTokenRefs } from "@src/utils/tokens";
32
34
  import {
33
35
  deepMergeObjects,
34
- getBalanceOf,
35
36
  humanReadableToWei,
36
37
  isServer,
37
38
  resolveTokenAddress,
@@ -129,6 +130,10 @@ type SwapContext = {
129
130
  chainId: string | undefined,
130
131
  token: TokenOption,
131
132
  ) => void;
133
+ // Which side's token/network picker is open. The picker renders in place
134
+ // of the swap view (desktop) or as a bottom sheet (mobile).
135
+ activePicker: "source" | "destination" | null;
136
+ setActivePicker: Dispatch<SetStateAction<"source" | "destination" | null>>;
132
137
  };
133
138
 
134
139
  const functionNotImplemented = () => {
@@ -142,6 +147,8 @@ const init: SwapContext = {
142
147
  tab: "Swap",
143
148
  overlay: false,
144
149
  error: "",
150
+ activePicker: null,
151
+ setActivePicker: functionNotImplemented,
145
152
  feeToken: undefined,
146
153
  srcChain: undefined,
147
154
  srcChainTokensOptions: [],
@@ -224,6 +231,7 @@ export const SwapProvider = ({
224
231
  // Settings
225
232
  const [tab, setTab] = useState(init.tab);
226
233
  const [error, setError] = useState(init.error);
234
+ const [activePicker, setActivePicker] = useState(init.activePicker);
227
235
  const [expressDelivery, setExpressDelivery] = useState(init.expressDelivery);
228
236
  const [slippage, setSlippage] = useState(init.slippage);
229
237
  const [infiniteApproval, setInfiniteApproval] = useState(
@@ -240,6 +248,9 @@ export const SwapProvider = ({
240
248
  const [tokenBalances, setTokenBalances] = useState<
241
249
  Record<string, Record<string, string>>
242
250
  >({});
251
+ // bumped when the user imports a token, so the supported tokens mapping and
252
+ // the balances list get rebuilt with the new custom token included
253
+ const [customTokensVersion, setCustomTokensVersion] = useState(0);
243
254
  const [bridgeTokens, setBridgeTokens] = useState<BridgeToken[]>([]);
244
255
  // Values
245
256
  const [srcValue, setSrcValue] = useState(init.srcValue);
@@ -334,8 +345,9 @@ export const SwapProvider = ({
334
345
  });
335
346
  });
336
347
  return deepMergeObjects(chainIdToTokenAddressToData, customTokens);
348
+ // customTokensVersion re-reads localStorage after an in-session import
337
349
  // eslint-disable-next-line react-hooks/exhaustive-deps
338
- }, [supportedChains]);
350
+ }, [supportedChains, customTokensVersion]);
339
351
 
340
352
  // useMemo: creates a mapping from each chain ID to available token options sorted by token priority.
341
353
  const chainToTokenOptionsMap = useMemo(() => {
@@ -686,68 +698,70 @@ export const SwapProvider = ({
686
698
  refreshSelectedTokenBalanceNonce.current = nonce;
687
699
  setIsFetchingBalance(true);
688
700
  let balance = "0";
689
- if (srcChain?.ecosystem === Ecosystem.EVM && evmAddress) {
690
- const balanceFromBackend =
691
- tokenBalances[srcChain?.chainId]?.[srcToken.address];
692
- if (options?.forceRpc || balanceFromBackend === undefined) {
693
- const balanceUI = await getBalanceOf(
694
- evmAddress,
695
- srcToken.address,
696
- srcChain?.publicRpcUrls[0],
697
- );
698
- balance = humanReadableToWei({
699
- amount: balanceUI,
700
- decimals: srcToken.decimals,
701
- });
702
- if (options?.forceRpc && balanceFromBackend !== undefined) {
703
- // keep the backend cache in sync so token pickers and future
704
- // non-forced refreshes see the fresh value
705
- const chainId = srcChain.chainId;
706
- const tokenAddress = srcToken.address;
707
- const freshBalance = balance;
708
- setTokenBalances((prev) => ({
709
- ...prev,
710
- [chainId]: {
711
- ...prev[chainId],
712
- [tokenAddress]: freshBalance,
713
- },
701
+ try {
702
+ if (srcChain?.ecosystem === Ecosystem.EVM && evmAddress) {
703
+ const balanceFromBackend =
704
+ tokenBalances[srcChain?.chainId]?.[srcToken.address];
705
+ if (options?.forceRpc || balanceFromBackend === undefined) {
706
+ // single-token read resolved server-side through private RPCs
707
+ ({ balance } = await getTokenBalance({
708
+ chainId: srcChain.chainId,
709
+ tokenAddress: srcToken.address,
710
+ walletAddress: evmAddress,
714
711
  }));
712
+ if (options?.forceRpc && balanceFromBackend !== undefined) {
713
+ // keep the backend cache in sync so token pickers and future
714
+ // non-forced refreshes see the fresh value
715
+ const chainId = srcChain.chainId;
716
+ const tokenAddress = srcToken.address;
717
+ const freshBalance = balance;
718
+ setTokenBalances((prev) => ({
719
+ ...prev,
720
+ [chainId]: {
721
+ ...prev[chainId],
722
+ [tokenAddress]: freshBalance,
723
+ },
724
+ }));
725
+ }
726
+ } else {
727
+ balance = balanceFromBackend;
715
728
  }
716
- } else {
717
- balance = balanceFromBackend;
718
- }
719
- } else if (srcChain?.ecosystem === Ecosystem.SOLANA && solanaAddress) {
720
- const solanaPublicKey = new PublicKey(solanaAddress);
721
- const connection = new Connection(srcChain.publicRpcUrls[0]!);
722
- if (srcToken.address === SOLANA_NATIVE_TOKEN_ADDRESS.toBase58()) {
723
- balance = new BigNumberJS(
724
- await connection.getBalance(solanaPublicKey),
725
- ).toString();
726
- } else {
727
- const tokenAccounts = await connection.getParsedTokenAccountsByOwner(
728
- solanaPublicKey,
729
- {
730
- programId: SOLANA_TOKEN_PROGRAM_ID,
731
- },
732
- );
733
- const token2022Accounts =
734
- await connection.getParsedTokenAccountsByOwner(solanaPublicKey, {
735
- programId: SOLANA_TOKEN_2022_PROGRAM_ID,
736
- });
737
- const allTokenAccount = [
738
- ...tokenAccounts.value,
739
- ...token2022Accounts.value,
740
- ];
741
- const tokenAccount = allTokenAccount.find(
742
- (account) =>
743
- account.account.data.parsed.info.mint === srcToken.address,
744
- );
745
- if (!tokenAccount) {
746
- balance = "0";
729
+ } else if (srcChain?.ecosystem === Ecosystem.SOLANA && solanaAddress) {
730
+ const solanaPublicKey = new PublicKey(solanaAddress);
731
+ const connection = new Connection(srcChain.publicRpcUrls[0]!);
732
+ if (srcToken.address === SOLANA_NATIVE_TOKEN_ADDRESS.toBase58()) {
733
+ balance = new BigNumberJS(
734
+ await connection.getBalance(solanaPublicKey),
735
+ ).toString();
747
736
  } else {
748
- balance = tokenAccount.account.data.parsed.info.tokenAmount.amount;
737
+ const tokenAccounts =
738
+ await connection.getParsedTokenAccountsByOwner(solanaPublicKey, {
739
+ programId: SOLANA_TOKEN_PROGRAM_ID,
740
+ });
741
+ const token2022Accounts =
742
+ await connection.getParsedTokenAccountsByOwner(solanaPublicKey, {
743
+ programId: SOLANA_TOKEN_2022_PROGRAM_ID,
744
+ });
745
+ const allTokenAccount = [
746
+ ...tokenAccounts.value,
747
+ ...token2022Accounts.value,
748
+ ];
749
+ const tokenAccount = allTokenAccount.find(
750
+ (account) =>
751
+ account.account.data.parsed.info.mint === srcToken.address,
752
+ );
753
+ if (!tokenAccount) {
754
+ balance = "0";
755
+ } else {
756
+ balance =
757
+ tokenAccount.account.data.parsed.info.tokenAmount.amount;
758
+ }
749
759
  }
750
760
  }
761
+ } catch (_) {
762
+ // failed read — keep the loader on: the balance must be loaded before
763
+ // a swap is allowed, so we never show a stale value as loaded
764
+ return;
751
765
  }
752
766
  if (nonce !== refreshSelectedTokenBalanceNonce.current) {
753
767
  return;
@@ -790,6 +804,10 @@ export const SwapProvider = ({
790
804
  ? chain
791
805
  : { ...chain, tokens: [...chain.tokens, token] };
792
806
 
807
+ if (!tokenExists) {
808
+ setCustomTokensVersion((version) => version + 1);
809
+ }
810
+
793
811
  setSrcChain(updated);
794
812
  setSrcToken(token);
795
813
  },
@@ -821,8 +839,9 @@ export const SwapProvider = ({
821
839
  quoteRoute();
822
840
  }, [quoteRoute]);
823
841
  // calls refreshBalance to update source token balance on mount or when refreshBalance changes.
824
- // when the connected wallet changed, force an RPC read — the cached backend
825
- // balances still belong to the previous wallet until getBalances resolves.
842
+ // when the connected wallet changed, force a fresh single-token read — the
843
+ // cached balances list still belongs to the previous wallet until
844
+ // getBalances resolves.
826
845
  useEffect(() => {
827
846
  const walletAddresses = `${evmAddress ?? ""}:${solanaAddress ?? ""}`;
828
847
  const walletChanged =
@@ -832,6 +851,8 @@ export const SwapProvider = ({
832
851
  refreshBalance(walletChanged ? { forceRpc: true } : undefined);
833
852
  }, [refreshBalance, evmAddress, solanaAddress]);
834
853
  // fetches user all token balances when a wallet address is available and supported chains are loaded.
854
+ // imported tokens from localStorage are passed along so their balances get
855
+ // resolved server-side too.
835
856
  useEffect(() => {
836
857
  let isCancelled = false;
837
858
  if ((evmAddress || solanaAddress) && supportedChains.length > 0) {
@@ -839,6 +860,7 @@ export const SwapProvider = ({
839
860
  getBalances({
840
861
  walletAddress: evmAddress,
841
862
  solanaWalletAddress: solanaAddress,
863
+ customTokens: getCustomTokenRefs(),
842
864
  }).then((balances) => {
843
865
  if (!isCancelled) {
844
866
  setTokenBalances(balances);
@@ -850,7 +872,7 @@ export const SwapProvider = ({
850
872
  return () => {
851
873
  isCancelled = true; // prevent state update from stale async call
852
874
  };
853
- }, [evmAddress, solanaAddress, supportedChains.length]);
875
+ }, [evmAddress, solanaAddress, supportedChains.length, customTokensVersion]);
854
876
  // sets the fee token from the source chain's tokens by selecting the native token (AddressZero).
855
877
  useEffect(() => {
856
878
  if (srcChain?.chainId === "mainnet-beta") {
@@ -1079,6 +1101,7 @@ export const SwapProvider = ({
1079
1101
  return;
1080
1102
  }
1081
1103
 
1104
+ setActivePicker(null);
1082
1105
  setSrcToken(undefined);
1083
1106
  setDstToken(undefined);
1084
1107
  setSrcChain(undefined);
@@ -1337,6 +1360,8 @@ export const SwapProvider = ({
1337
1360
  onConnectWallet,
1338
1361
  hasOnConnectWalletCallback,
1339
1362
  addTokenToChainIfNotExists,
1363
+ activePicker,
1364
+ setActivePicker,
1340
1365
  }}
1341
1366
  >
1342
1367
  {children}
@@ -113,7 +113,7 @@ export const TxUIWrapper = ({ children }: Props) => {
113
113
  </Button>
114
114
 
115
115
  <div
116
- className="fill-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
116
+ className="fill-t_text_primary text-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
117
117
  onClick={() => closeSnackbar(snackbarKey)}
118
118
  >
119
119
  <CloseIcon />
@@ -225,7 +225,7 @@ export const TxUIWrapper = ({ children }: Props) => {
225
225
  View
226
226
  </Button>
227
227
  <div
228
- className="fill-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
228
+ className="fill-t_text_primary text-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
229
229
  onClick={() => closeSnackbar(snackbarKey)}
230
230
  >
231
231
  <CloseIcon />
@@ -321,7 +321,7 @@ export const TxUIWrapper = ({ children }: Props) => {
321
321
  </Button>
322
322
 
323
323
  <div
324
- className="fill-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
324
+ className="fill-t_text_primary text-t_text_primary w-[14px] h-[14px] cursor-pointer p-[14px] leading-[0px]"
325
325
  onClick={() => closeSnackbar(snackbarKey)}
326
326
  >
327
327
  <CloseIcon />
@@ -2,6 +2,14 @@ import { GAS_LIMIT_MULTIPLIER } from "@src/constants";
2
2
  import { ethers } from "ethers";
3
3
  import { useMemo } from "react";
4
4
 
5
+ // RPC nodes behind a load balancer can lag right after a tx is mined (e.g. the
6
+ // swap's gas estimation not seeing the allowance from a just-confirmed
7
+ // approval), so retry the estimation with increasing delays before giving up.
8
+ const GAS_ESTIMATION_RETRY_DELAYS_MS = [500, 1000, 2000];
9
+
10
+ const wait = (ms: number) =>
11
+ new Promise((resolve) => setTimeout(resolve, ms));
12
+
5
13
  export const useEvmContractApi = () =>
6
14
  useMemo(
7
15
  () => ({
@@ -59,11 +67,27 @@ export const useEvmContractApi = () =>
59
67
  throw new Error(decodedError?.name);
60
68
  }
61
69
  },
62
- executeCalldata: async (signer, transaction) => {
70
+ executeCalldata: async (
71
+ signer,
72
+ transaction,
73
+ { retryGasEstimation = false } = {},
74
+ ) => {
63
75
  try {
64
- const gasEstimation = await signer.estimateGas({
65
- ...transaction,
66
- });
76
+ let gasEstimation;
77
+ for (let attempt = 0; ; attempt++) {
78
+ try {
79
+ gasEstimation = await signer.estimateGas({
80
+ ...transaction,
81
+ });
82
+ break;
83
+ } catch (estimationErr) {
84
+ const delayMs = GAS_ESTIMATION_RETRY_DELAYS_MS[attempt];
85
+ if (!retryGasEstimation || delayMs === undefined) {
86
+ throw estimationErr;
87
+ }
88
+ await wait(delayMs);
89
+ }
90
+ }
67
91
  return await signer.sendTransaction({
68
92
  ...transaction,
69
93
  gasLimit: Math.floor(
@@ -43,6 +43,7 @@ export type TransactionStatus =
43
43
 
44
44
  export type Transaction = {
45
45
  messageId?: string;
46
+ receiver?: string;
46
47
  hash: string;
47
48
  timestamp: number;
48
49
  sourceChainId: string;
@@ -1,4 +1,5 @@
1
1
  export type GetTokenBalancesPayload = {
2
2
  walletAddress?: string;
3
3
  solanaWalletAddress?: string;
4
+ customTokens?: { chainId: string; tokenAddress: string }[];
4
5
  };
@@ -47,6 +47,7 @@ export type ModalIntegrationPayload = {
47
47
  srcToken?: string;
48
48
  customContractCalls?: ContractCall[];
49
49
  desc?: string;
50
+ widgetTitle?: string;
50
51
  dstDisplayToken?: string;
51
52
  lightTheme?: boolean;
52
53
  defaultWalletPicker?: boolean;
@@ -9,6 +9,7 @@ export type WidgetIntegrationPayload = {
9
9
  srcToken?: string;
10
10
  customContractCalls?: ContractCall[];
11
11
  desc?: string;
12
+ widgetTitle?: string;
12
13
  dstDisplayToken?: string;
13
14
  lightTheme?: boolean;
14
15
  defaultWalletPicker?: boolean;
@@ -7,6 +7,7 @@ import {
7
7
  GetRoutePayload,
8
8
  GetSolanaRoutePayload,
9
9
  GetTokenBalancesPayload,
10
+ ImportedTokenData,
10
11
  EVMRoute,
11
12
  SolanaRoute,
12
13
  Token,
@@ -169,3 +170,39 @@ export async function getLeaderboardChange(
169
170
  export async function getBridgeTokens(): Promise<BridgeToken[]> {
170
171
  return _sendRequest<BridgeToken[]>(`/bridgeTokens`);
171
172
  }
173
+
174
+ /**
175
+ * GET a single token balance (in wei) resolved server-side through private
176
+ * RPCs. Native balance when tokenAddress is the zero address.
177
+ * @param payload required
178
+ * @param abortSignal
179
+ */
180
+ export async function getTokenBalance(
181
+ payload: { chainId: string; tokenAddress: string; walletAddress: string },
182
+ abortSignal?: AbortSignal,
183
+ ): Promise<{ balance: string }> {
184
+ return _sendRequest<{ balance: string }>(
185
+ `/tokenBalance?${new URLSearchParams({
186
+ data: JSON.stringify(payload),
187
+ })}`,
188
+ { signal: abortSignal },
189
+ );
190
+ }
191
+
192
+ /**
193
+ * GET ERC20 token metadata (name, symbol, decimals) resolved server-side
194
+ * through private RPCs.
195
+ * @param payload required
196
+ * @param abortSignal
197
+ */
198
+ export async function getTokenMetadata(
199
+ payload: { chainId: string; tokenAddress: string },
200
+ abortSignal?: AbortSignal,
201
+ ): Promise<ImportedTokenData> {
202
+ return _sendRequest<ImportedTokenData>(
203
+ `/tokenMetadata?${new URLSearchParams({
204
+ data: JSON.stringify(payload),
205
+ })}`,
206
+ { signal: abortSignal },
207
+ );
208
+ }
@@ -3,7 +3,7 @@ export * from "./numbers";
3
3
  export * from "./parseWeb3Error";
4
4
  export * from "./strings";
5
5
 
6
- import { Chain } from "@src/models";
6
+ import { BridgeTokensDictionary, Chain, Ecosystem } from "@src/models";
7
7
  import { format } from "date-fns";
8
8
  import { ethers } from "ethers";
9
9
 
@@ -77,6 +77,53 @@ export const resolveTokenAddress = (
77
77
  ?.address?.toLowerCase();
78
78
  };
79
79
 
80
+ /**
81
+ * Checks whether a transfer from `srcChain` can reach `dstChainId`,
82
+ * based on the chain-level support lists.
83
+ */
84
+ export const isChainConnectionSupported = (
85
+ srcChain: Chain | undefined,
86
+ dstChainId: string | undefined,
87
+ bridgeUI: boolean,
88
+ ): boolean => {
89
+ if (!srcChain || !dstChainId) {
90
+ return true;
91
+ }
92
+ if (srcChain.chainId === dstChainId) {
93
+ return true;
94
+ }
95
+ const supportedDst = bridgeUI
96
+ ? srcChain.supportedDstForBridge
97
+ : srcChain.supportedDstForSwap;
98
+ return !!supportedDst?.includes(dstChainId);
99
+ };
100
+
101
+ // Chain-level support (supportedDstForBridge) is not enough for bridging —
102
+ // the specific token must have a route to the destination chain in the
103
+ // bridge tokens dictionary.
104
+ export const isBridgeTokenConnectionSupported = ({
105
+ token,
106
+ tokenChain,
107
+ dstChainId,
108
+ bridgeTokensDictionary,
109
+ }: {
110
+ token: { address: string };
111
+ tokenChain: Chain | undefined;
112
+ dstChainId: string | undefined;
113
+ bridgeTokensDictionary: BridgeTokensDictionary | null;
114
+ }): boolean => {
115
+ if (!tokenChain || !dstChainId || !bridgeTokensDictionary) {
116
+ return true;
117
+ }
118
+ const tokenAddress =
119
+ tokenChain.ecosystem === Ecosystem.SOLANA
120
+ ? token.address
121
+ : token.address.toLowerCase();
122
+ return !!bridgeTokensDictionary[tokenChain.chainId]?.[tokenAddress]?.[
123
+ dstChainId
124
+ ];
125
+ };
126
+
80
127
  export const sortChains = (chains: Chain[], disabledChainIds: string[]) =>
81
128
  [...chains]
82
129
 
@@ -1,11 +1,30 @@
1
1
  import { Chain, Token } from "@src/models";
2
- import { isServer } from "@src/utils";
2
+ import { isETHAddressValid, isServer } from "@src/utils";
3
3
 
4
4
  /**
5
5
  * Retrieves custom tokens from localStorage and adds them to the provided chains
6
6
  * @param chains - The original chains to which custom tokens should be added
7
7
  * @returns A new array of chains with custom tokens added
8
8
  */
9
+ /**
10
+ * Lists tokens imported by the user (kept in localStorage) as chainId+address
11
+ * pairs, EVM only — the shape the balances endpoint accepts.
12
+ */
13
+ export const getCustomTokenRefs = (): {
14
+ chainId: string;
15
+ tokenAddress: string;
16
+ }[] => {
17
+ const customTokensObj = !isServer
18
+ ? JSON.parse(localStorage.getItem("custom-tokens") || "{}")
19
+ : {};
20
+
21
+ return Object.entries(customTokensObj).flatMap(([chainId, tokensObj]) =>
22
+ Object.keys(tokensObj as Record<string, Token>)
23
+ .filter((tokenAddress) => isETHAddressValid(tokenAddress))
24
+ .map((tokenAddress) => ({ chainId, tokenAddress })),
25
+ );
26
+ };
27
+
9
28
  export const addCustomTokensToChains = (chains: Chain[]): Chain[] => {
10
29
  const customTokensObj = !isServer
11
30
  ? JSON.parse(localStorage.getItem("custom-tokens") || "{}")
@@ -96,10 +96,13 @@ export default {
96
96
  x_modal: "var(--modal-width, 340px)",
97
97
  },
98
98
  width: {
99
+ // No fallback on purpose: unless the integrator sets --modal-width,
100
+ // the width resolves to `auto` so the widget can shrink on mobile.
101
+ // The default 620px cap comes from max-width below.
99
102
  x_modal: "var(--modal-width)",
100
103
  },
101
104
  maxWidth: {
102
- x_modal: "var(--modal-width, 480px)",
105
+ x_modal: "var(--modal-width, 620px)",
103
106
  },
104
107
  animation: {
105
108
  "spin-slow": "spin 3s linear infinite",
@@ -1,97 +0,0 @@
1
- import { DotGreenIcon } from "@src/assets/icons";
2
- import { SwapPanelType } from "@src/components/Swap/SwapView";
3
- import { useSwapContext } from "@src/context";
4
- import useNetworks from "@src/hooks/networkManagement/useNetworks";
5
- import { Chain } from "@src/models";
6
- import { useMemo } from "react";
7
-
8
- type Props = {
9
- chain: Chain;
10
- type: SwapPanelType;
11
- disabled: boolean;
12
- onClick: (chainId: string) => void;
13
- };
14
-
15
- export const ChainItem = ({ chain, type, onClick, disabled }: Props) => {
16
- const {
17
- networks: { evm },
18
- } = useNetworks();
19
-
20
- const { srcChain, dstChain, bridgeUI } = useSwapContext();
21
-
22
- const currentChainId = useMemo(
23
- () => (type === "source" ? srcChain?.chainId : dstChain?.chainId),
24
- [dstChain, srcChain, type],
25
- );
26
-
27
- const connected = useMemo(
28
- () => evm.signer && evm.chainId === chain.chainId,
29
- [chain.chainId, evm.chainId, evm.signer],
30
- );
31
-
32
- const sameChainId = useMemo(() => {
33
- if (!bridgeUI) {
34
- return undefined;
35
- }
36
-
37
- return type === "destination" ? srcChain?.chainId : dstChain?.chainId;
38
- }, [bridgeUI, dstChain?.chainId, srcChain?.chainId, type]);
39
-
40
- const getTooltipText = () => {
41
- if (chain.chainId === sameChainId) {
42
- return "Transfers between the same chains are not available";
43
- }
44
- if (disabled) {
45
- return bridgeUI
46
- ? "Token is not available on this chain"
47
- : `Swap from ${
48
- srcChain?.displayName || "the source chain"
49
- } to this chain is currently not supported.`;
50
- }
51
- return "";
52
- };
53
-
54
- const tooltipText = getTooltipText();
55
-
56
- return (
57
- <div className="group relative">
58
- <div
59
- onClick={() => {
60
- if (chain.chainId === sameChainId || disabled) {
61
- return;
62
- }
63
- onClick(chain.chainId);
64
- }}
65
- className={`flex justify-between items-center gap-2 mt-2 mb-2 cursor-pointer p-4 border border-solid border-white border-opacity-0 hover:selected-chain-item ${
66
- currentChainId === chain.chainId ? "selected-chain-item" : ""
67
- } ${
68
- chain.chainId === sameChainId || disabled
69
- ? "disabled-chain-item cursor-default"
70
- : ""
71
- }`}
72
- >
73
- <div className="flex gap-3 items-center w-full">
74
- <img
75
- src={chain.image}
76
- alt={chain.name}
77
- className="w-[34px] h-[34px]"
78
- />
79
- <p>{chain.displayName}</p>
80
- </div>
81
-
82
- {tooltipText && (
83
- <div className="w-full text-center opacity-0 group-hover:opacity-100 text-xs transition-opacity duration-200">
84
- {tooltipText}
85
- </div>
86
- )}
87
-
88
- {connected && (
89
- <div className="flex items-baseline gap-1">
90
- <DotGreenIcon />
91
- <div className="text-t_text_green">connected</div>
92
- </div>
93
- )}
94
- </div>
95
- </div>
96
- );
97
- };