@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,5 +1,7 @@
1
1
  import { TxConfigFormPayload } from "@src/components";
2
2
  import {
3
+ DEFAULT_BRIDGE_DESTINATION_CHAIN_ID,
4
+ DEFAULT_BRIDGE_SOURCE_CHAIN_ID,
3
5
  DEFAULT_DESTINATION_CHAIN_ID,
4
6
  DEFAULT_REQUEST_ABORT_MSG,
5
7
  DEFAULT_SOURCE_CHAIN_ID,
@@ -28,10 +30,11 @@ import {
28
30
  getPrices,
29
31
  getRoute,
30
32
  getSolanaRoute,
33
+ getTokenBalance,
31
34
  } from "@src/services";
35
+ import { getCustomTokenRefs } from "@src/utils/tokens";
32
36
  import {
33
37
  deepMergeObjects,
34
- getBalanceOf,
35
38
  humanReadableToWei,
36
39
  isServer,
37
40
  resolveTokenAddress,
@@ -129,6 +132,10 @@ type SwapContext = {
129
132
  chainId: string | undefined,
130
133
  token: TokenOption,
131
134
  ) => void;
135
+ // Which side's token/network picker is open. The picker renders in place
136
+ // of the swap view (desktop) or as a bottom sheet (mobile).
137
+ activePicker: "source" | "destination" | null;
138
+ setActivePicker: Dispatch<SetStateAction<"source" | "destination" | null>>;
132
139
  };
133
140
 
134
141
  const functionNotImplemented = () => {
@@ -142,6 +149,8 @@ const init: SwapContext = {
142
149
  tab: "Swap",
143
150
  overlay: false,
144
151
  error: "",
152
+ activePicker: null,
153
+ setActivePicker: functionNotImplemented,
145
154
  feeToken: undefined,
146
155
  srcChain: undefined,
147
156
  srcChainTokensOptions: [],
@@ -224,6 +233,7 @@ export const SwapProvider = ({
224
233
  // Settings
225
234
  const [tab, setTab] = useState(init.tab);
226
235
  const [error, setError] = useState(init.error);
236
+ const [activePicker, setActivePicker] = useState(init.activePicker);
227
237
  const [expressDelivery, setExpressDelivery] = useState(init.expressDelivery);
228
238
  const [slippage, setSlippage] = useState(init.slippage);
229
239
  const [infiniteApproval, setInfiniteApproval] = useState(
@@ -240,6 +250,9 @@ export const SwapProvider = ({
240
250
  const [tokenBalances, setTokenBalances] = useState<
241
251
  Record<string, Record<string, string>>
242
252
  >({});
253
+ // bumped when the user imports a token, so the supported tokens mapping and
254
+ // the balances list get rebuilt with the new custom token included
255
+ const [customTokensVersion, setCustomTokensVersion] = useState(0);
243
256
  const [bridgeTokens, setBridgeTokens] = useState<BridgeToken[]>([]);
244
257
  // Values
245
258
  const [srcValue, setSrcValue] = useState(init.srcValue);
@@ -280,6 +293,19 @@ export const SwapProvider = ({
280
293
  const routeAbortController = useRef<AbortController | null>(null);
281
294
  const refreshSelectedTokenBalanceNonce = useRef(crypto.randomUUID());
282
295
  const prevWalletAddresses = useRef<string | undefined>(undefined);
296
+ // last wallet chain id seen by the follow-the-wallet effect below
297
+ const prevFollowedWalletChainId = useRef<string | undefined>(undefined);
298
+ // True between the bridgeUI-change reset and the re-init that follows it.
299
+ // Effects in the reset's commit still close over the pre-reset state (with
300
+ // the new bridgeUI), so acting on that mix would remap the old mode's route
301
+ // as the new mode's — this ref lets them bail out synchronously.
302
+ const initPending = useRef(false);
303
+ // True while a bridgeUI-change re-init is pending. The web-component
304
+ // wrapper delivers attribute updates one by one, so at re-init time the
305
+ // route config may still hold the previous mode's values (there is no way
306
+ // to tell them apart from intentional ones) — the re-init therefore
307
+ // ignores route config entirely and starts from the new mode's defaults.
308
+ const modeSwitchReinit = useRef(false);
283
309
 
284
310
  // =============================================================================
285
311
  // Memoized Values
@@ -334,8 +360,9 @@ export const SwapProvider = ({
334
360
  });
335
361
  });
336
362
  return deepMergeObjects(chainIdToTokenAddressToData, customTokens);
363
+ // customTokensVersion re-reads localStorage after an in-session import
337
364
  // eslint-disable-next-line react-hooks/exhaustive-deps
338
- }, [supportedChains]);
365
+ }, [supportedChains, customTokensVersion]);
339
366
 
340
367
  // useMemo: creates a mapping from each chain ID to available token options sorted by token priority.
341
368
  const chainToTokenOptionsMap = useMemo(() => {
@@ -679,6 +706,12 @@ export const SwapProvider = ({
679
706
  !srcToken ||
680
707
  !srcToken?.address
681
708
  ) {
709
+ // Nothing to read a balance for — drop the previous selection's
710
+ // balance (and invalidate any in-flight read) so the MAX button
711
+ // never acts on a stale value.
712
+ refreshSelectedTokenBalanceNonce.current = crypto.randomUUID();
713
+ setSrcTokenBalanceWei("");
714
+ setIsFetchingBalance(false);
682
715
  return;
683
716
  }
684
717
 
@@ -686,68 +719,70 @@ export const SwapProvider = ({
686
719
  refreshSelectedTokenBalanceNonce.current = nonce;
687
720
  setIsFetchingBalance(true);
688
721
  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
- },
722
+ try {
723
+ if (srcChain?.ecosystem === Ecosystem.EVM && evmAddress) {
724
+ const balanceFromBackend =
725
+ tokenBalances[srcChain?.chainId]?.[srcToken.address];
726
+ if (options?.forceRpc || balanceFromBackend === undefined) {
727
+ // single-token read resolved server-side through private RPCs
728
+ ({ balance } = await getTokenBalance({
729
+ chainId: srcChain.chainId,
730
+ tokenAddress: srcToken.address,
731
+ walletAddress: evmAddress,
714
732
  }));
733
+ if (options?.forceRpc && balanceFromBackend !== undefined) {
734
+ // keep the backend cache in sync so token pickers and future
735
+ // non-forced refreshes see the fresh value
736
+ const chainId = srcChain.chainId;
737
+ const tokenAddress = srcToken.address;
738
+ const freshBalance = balance;
739
+ setTokenBalances((prev) => ({
740
+ ...prev,
741
+ [chainId]: {
742
+ ...prev[chainId],
743
+ [tokenAddress]: freshBalance,
744
+ },
745
+ }));
746
+ }
747
+ } else {
748
+ balance = balanceFromBackend;
715
749
  }
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";
750
+ } else if (srcChain?.ecosystem === Ecosystem.SOLANA && solanaAddress) {
751
+ const solanaPublicKey = new PublicKey(solanaAddress);
752
+ const connection = new Connection(srcChain.publicRpcUrls[0]!);
753
+ if (srcToken.address === SOLANA_NATIVE_TOKEN_ADDRESS.toBase58()) {
754
+ balance = new BigNumberJS(
755
+ await connection.getBalance(solanaPublicKey),
756
+ ).toString();
747
757
  } else {
748
- balance = tokenAccount.account.data.parsed.info.tokenAmount.amount;
758
+ const tokenAccounts =
759
+ await connection.getParsedTokenAccountsByOwner(solanaPublicKey, {
760
+ programId: SOLANA_TOKEN_PROGRAM_ID,
761
+ });
762
+ const token2022Accounts =
763
+ await connection.getParsedTokenAccountsByOwner(solanaPublicKey, {
764
+ programId: SOLANA_TOKEN_2022_PROGRAM_ID,
765
+ });
766
+ const allTokenAccount = [
767
+ ...tokenAccounts.value,
768
+ ...token2022Accounts.value,
769
+ ];
770
+ const tokenAccount = allTokenAccount.find(
771
+ (account) =>
772
+ account.account.data.parsed.info.mint === srcToken.address,
773
+ );
774
+ if (!tokenAccount) {
775
+ balance = "0";
776
+ } else {
777
+ balance =
778
+ tokenAccount.account.data.parsed.info.tokenAmount.amount;
779
+ }
749
780
  }
750
781
  }
782
+ } catch (_) {
783
+ // failed read — keep the loader on: the balance must be loaded before
784
+ // a swap is allowed, so we never show a stale value as loaded
785
+ return;
751
786
  }
752
787
  if (nonce !== refreshSelectedTokenBalanceNonce.current) {
753
788
  return;
@@ -790,6 +825,10 @@ export const SwapProvider = ({
790
825
  ? chain
791
826
  : { ...chain, tokens: [...chain.tokens, token] };
792
827
 
828
+ if (!tokenExists) {
829
+ setCustomTokensVersion((version) => version + 1);
830
+ }
831
+
793
832
  setSrcChain(updated);
794
833
  setSrcToken(token);
795
834
  },
@@ -821,8 +860,9 @@ export const SwapProvider = ({
821
860
  quoteRoute();
822
861
  }, [quoteRoute]);
823
862
  // 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.
863
+ // when the connected wallet changed, force a fresh single-token read — the
864
+ // cached balances list still belongs to the previous wallet until
865
+ // getBalances resolves.
826
866
  useEffect(() => {
827
867
  const walletAddresses = `${evmAddress ?? ""}:${solanaAddress ?? ""}`;
828
868
  const walletChanged =
@@ -832,6 +872,8 @@ export const SwapProvider = ({
832
872
  refreshBalance(walletChanged ? { forceRpc: true } : undefined);
833
873
  }, [refreshBalance, evmAddress, solanaAddress]);
834
874
  // fetches user all token balances when a wallet address is available and supported chains are loaded.
875
+ // imported tokens from localStorage are passed along so their balances get
876
+ // resolved server-side too.
835
877
  useEffect(() => {
836
878
  let isCancelled = false;
837
879
  if ((evmAddress || solanaAddress) && supportedChains.length > 0) {
@@ -839,6 +881,7 @@ export const SwapProvider = ({
839
881
  getBalances({
840
882
  walletAddress: evmAddress,
841
883
  solanaWalletAddress: solanaAddress,
884
+ customTokens: getCustomTokenRefs(),
842
885
  }).then((balances) => {
843
886
  if (!isCancelled) {
844
887
  setTokenBalances(balances);
@@ -850,7 +893,7 @@ export const SwapProvider = ({
850
893
  return () => {
851
894
  isCancelled = true; // prevent state update from stale async call
852
895
  };
853
- }, [evmAddress, solanaAddress, supportedChains.length]);
896
+ }, [evmAddress, solanaAddress, supportedChains.length, customTokensVersion]);
854
897
  // sets the fee token from the source chain's tokens by selecting the native token (AddressZero).
855
898
  useEffect(() => {
856
899
  if (srcChain?.chainId === "mainnet-beta") {
@@ -1055,9 +1098,23 @@ export const SwapProvider = ({
1055
1098
  // useEffects responsible for state updates and validations
1056
1099
  // =============================================================================
1057
1100
 
1058
- // sets the initial source chain based on the wallet's chain ID if not already initialized.
1101
+ // Follows a real in-session wallet network switch with the source chain.
1102
+ // Only a defined -> defined change counts: the chain id first appearing
1103
+ // during reconnect is ignored, since wagmi restores a persisted (possibly
1104
+ // stale) chain id before the connector reports the actual one. Bridge keeps
1105
+ // its fixed default route instead of following the wallet chain.
1059
1106
  useEffect(() => {
1060
- if (!walletChainId || !isInitialStateSet || integrationConfig?.srcChainId) {
1107
+ const prevWalletChainId = prevFollowedWalletChainId.current;
1108
+ prevFollowedWalletChainId.current = walletChainId;
1109
+
1110
+ if (
1111
+ !walletChainId ||
1112
+ !prevWalletChainId ||
1113
+ prevWalletChainId === walletChainId ||
1114
+ !isInitialStateSet ||
1115
+ integrationConfig?.srcChainId ||
1116
+ bridgeUI
1117
+ ) {
1061
1118
  return;
1062
1119
  }
1063
1120
  const initSrcChain = supportedChains.find(
@@ -1071,18 +1128,29 @@ export const SwapProvider = ({
1071
1128
  isInitialStateSet,
1072
1129
  supportedChains,
1073
1130
  integrationConfig?.srcChainId,
1131
+ bridgeUI,
1074
1132
  ]);
1075
1133
 
1076
- // resets source and destination tokens and chains when bridgeUI changes and initial state is not yet set.
1134
+ // resets the widget state when bridgeUI changes at runtime: back to the
1135
+ // main view (leaving Settings/History), cleared selection and amount, and
1136
+ // re-arms the init effect below so the new mode gets its default route
1137
+ // (config -> wallet chain -> mode defaults) applied again.
1077
1138
  useEffect(() => {
1078
1139
  if (!isInitialStateSet) {
1079
1140
  return;
1080
1141
  }
1081
1142
 
1143
+ initPending.current = true;
1144
+ modeSwitchReinit.current = true;
1145
+ setTab(init.tab);
1146
+ setActivePicker(null);
1147
+ setError(init.error);
1148
+ setSrcValue(init.srcValue);
1082
1149
  setSrcToken(undefined);
1083
1150
  setDstToken(undefined);
1084
1151
  setSrcChain(undefined);
1085
1152
  setDstChain(undefined);
1153
+ setIsInitialStateSet(false);
1086
1154
 
1087
1155
  // eslint-disable-next-line react-hooks/exhaustive-deps
1088
1156
  }, [bridgeUI]);
@@ -1098,21 +1166,51 @@ export const SwapProvider = ({
1098
1166
  return;
1099
1167
  }
1100
1168
 
1169
+ // During a bridgeUI-change re-init the route config may still hold the
1170
+ // previous mode's values (web-component attribute updates arrive one by
1171
+ // one), so it is ignored — the new mode starts from its own defaults.
1172
+ // Route config still seeds the very first init (deep links, integrator
1173
+ // presets).
1174
+ const ignoreRouteConfig = modeSwitchReinit.current;
1175
+ const cfgSrcChainId = ignoreRouteConfig
1176
+ ? undefined
1177
+ : integrationConfig?.srcChainId;
1178
+ const cfgDstChainId = ignoreRouteConfig
1179
+ ? undefined
1180
+ : integrationConfig?.dstChainId;
1181
+ const cfgSrcTokenAddr = ignoreRouteConfig
1182
+ ? undefined
1183
+ : integrationConfig?.srcTokenAddr;
1184
+ const cfgDstTokenAddr = ignoreRouteConfig
1185
+ ? undefined
1186
+ : integrationConfig?.dstTokenAddr;
1187
+
1101
1188
  // find chains
1189
+ // Default routes are fixed (swap: ETH -> ETH, bridge: ETH -> Base) and do
1190
+ // not follow the wallet's chain — on load wagmi can still report a stale,
1191
+ // persisted chain id, so it can't be trusted here. Integrator config
1192
+ // still takes precedence, and a real in-session network switch is
1193
+ // followed by the effect below.
1102
1194
  const searchedSrcChainId =
1103
- srcChain ||
1104
- integrationConfig?.srcChainId ||
1105
- (walletChainId ? `${walletChainId}` : undefined) ||
1106
- (bridgeUI ? undefined : DEFAULT_SOURCE_CHAIN_ID);
1195
+ srcChain?.chainId ||
1196
+ cfgSrcChainId ||
1197
+ (bridgeUI ? DEFAULT_BRIDGE_SOURCE_CHAIN_ID : DEFAULT_SOURCE_CHAIN_ID);
1107
1198
 
1108
1199
  const initSrcChain = supportedChains.find(
1109
1200
  ({ chainId }) => chainId === searchedSrcChainId,
1110
1201
  );
1111
1202
 
1203
+ // Bridge can't route within one chain, so when the source already sits on
1204
+ // the default bridge destination, fall back to the reversed default route.
1205
+ const bridgeDefaultDstChainId =
1206
+ initSrcChain?.chainId === DEFAULT_BRIDGE_DESTINATION_CHAIN_ID
1207
+ ? DEFAULT_BRIDGE_SOURCE_CHAIN_ID
1208
+ : DEFAULT_BRIDGE_DESTINATION_CHAIN_ID;
1209
+
1112
1210
  const searchedDstChainId =
1113
- dstChain ||
1114
- integrationConfig?.dstChainId ||
1115
- (bridgeUI ? undefined : DEFAULT_DESTINATION_CHAIN_ID);
1211
+ dstChain?.chainId ||
1212
+ cfgDstChainId ||
1213
+ (bridgeUI ? bridgeDefaultDstChainId : DEFAULT_DESTINATION_CHAIN_ID);
1116
1214
 
1117
1215
  const initDstChain = supportedChains.find(
1118
1216
  ({ chainId }) => chainId === searchedDstChainId,
@@ -1123,13 +1221,13 @@ export const SwapProvider = ({
1123
1221
  let initSrcToken: Token | undefined;
1124
1222
 
1125
1223
  const srcTokenAddress = resolveTokenAddress(
1126
- integrationConfig.srcTokenAddr,
1127
- integrationConfig?.srcChainId,
1224
+ cfgSrcTokenAddr,
1225
+ cfgSrcChainId,
1128
1226
  supportedChains,
1129
1227
  );
1130
1228
  const dstTokenAddress = resolveTokenAddress(
1131
- integrationConfig.dstTokenAddr,
1132
- integrationConfig?.dstChainId,
1229
+ cfgDstTokenAddr,
1230
+ cfgDstChainId,
1133
1231
  supportedChains,
1134
1232
  );
1135
1233
 
@@ -1159,6 +1257,8 @@ export const SwapProvider = ({
1159
1257
  setSrcToken(source.token);
1160
1258
  setDstToken(target.token);
1161
1259
  // Mark initial state as set
1260
+ modeSwitchReinit.current = false;
1261
+ initPending.current = false;
1162
1262
  setIsInitialStateSet(true);
1163
1263
  // eslint-disable-next-line react-hooks/exhaustive-deps
1164
1264
  }, [
@@ -1177,7 +1277,11 @@ export const SwapProvider = ({
1177
1277
  if (
1178
1278
  !bridgeTokensDictionary ||
1179
1279
  supportedChains.length === 0 ||
1180
- !isInitialStateSet
1280
+ !isInitialStateSet ||
1281
+ // A bridgeUI-change reset is in flight: this closure still holds the
1282
+ // previous mode's route, so mapping it against the new mode would
1283
+ // resurrect it. The re-init applies the new mode's defaults instead.
1284
+ initPending.current
1181
1285
  ) {
1182
1286
  return;
1183
1287
  }
@@ -1218,9 +1322,15 @@ export const SwapProvider = ({
1218
1322
  bridgeTokensDictionary,
1219
1323
  isInitialStateSet,
1220
1324
  ]);
1325
+ // Notify effects below also re-run whenever the integrator recreates the
1326
+ // callback props (inline arrows give them a new identity every render).
1327
+ // During a bridgeUI-change reset their closures still hold the previous
1328
+ // mode's route, and re-emitting it would feed it back to the integrator as
1329
+ // config — the initPending guard keeps them quiet until the re-init lands.
1330
+
1221
1331
  // notifies integration or global callback when the destination chain changes.
1222
1332
  useEffect(() => {
1223
- if (!isInitialStateSet) {
1333
+ if (!isInitialStateSet || initPending.current) {
1224
1334
  return;
1225
1335
  }
1226
1336
  if (integrationConfig.onDstChainChange) {
@@ -1232,7 +1342,7 @@ export const SwapProvider = ({
1232
1342
  }, [integrationConfig.onDstChainChange, dstChain]);
1233
1343
  // notifies integration or global callback when the destination token changes.
1234
1344
  useEffect(() => {
1235
- if (!isInitialStateSet) {
1345
+ if (!isInitialStateSet || initPending.current) {
1236
1346
  return;
1237
1347
  }
1238
1348
  const token = dstToken
@@ -1248,7 +1358,7 @@ export const SwapProvider = ({
1248
1358
  }, [integrationConfig.onDstTokenChange, dstToken]);
1249
1359
  // notifies integration or global callback when the source chain changes.
1250
1360
  useEffect(() => {
1251
- if (!isInitialStateSet) {
1361
+ if (!isInitialStateSet || initPending.current) {
1252
1362
  return;
1253
1363
  }
1254
1364
  if (integrationConfig.onSrcChainChange) {
@@ -1260,7 +1370,7 @@ export const SwapProvider = ({
1260
1370
  }, [integrationConfig.onSrcChainChange, srcChain]);
1261
1371
  // notifies integration or global callback when the source token changes.
1262
1372
  useEffect(() => {
1263
- if (!isInitialStateSet) {
1373
+ if (!isInitialStateSet || initPending.current) {
1264
1374
  return;
1265
1375
  }
1266
1376
  const token = srcToken
@@ -1337,6 +1447,8 @@ export const SwapProvider = ({
1337
1447
  onConnectWallet,
1338
1448
  hasOnConnectWalletCallback,
1339
1449
  addTokenToChainIfNotExists,
1450
+ activePicker,
1451
+ setActivePicker,
1340
1452
  }}
1341
1453
  >
1342
1454
  {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(
@@ -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
  };
@@ -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
 
@@ -98,6 +98,32 @@ export const isChainConnectionSupported = (
98
98
  return !!supportedDst?.includes(dstChainId);
99
99
  };
100
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
+
101
127
  export const sortChains = (chains: Chain[], disabledChainIds: string[]) =>
102
128
  [...chains]
103
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") || "{}")