@xswap-link/sdk 0.14.0 → 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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.d.mts +4 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.global.js +86 -86
  5. package/dist/index.js +2129 -1857
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2225 -1953
  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 +5 -7
  12. package/src/assets/icons/ClockIcon.tsx +3 -7
  13. package/src/assets/icons/CloseIcon.tsx +6 -2
  14. package/src/assets/icons/WarningIcon.tsx +8 -5
  15. package/src/components/Modal/index.tsx +14 -9
  16. package/src/components/PoweredBy/index.tsx +6 -8
  17. package/src/components/Swap/PickerView/index.tsx +151 -0
  18. package/src/components/Swap/SettingsView/Delivery/index.tsx +1 -1
  19. package/src/components/Swap/SwapView/ConfirmationView/TxOverview/index.tsx +50 -4
  20. package/src/components/Swap/SwapView/FeesPanel/DeliveryInfo/index.tsx +1 -1
  21. package/src/components/Swap/SwapView/FeesPanel/index.tsx +1 -1
  22. package/src/components/Swap/SwapView/SwapButton/index.tsx +2 -2
  23. package/src/components/Swap/SwapView/SwapPanel/AmountPanel/Balance/index.tsx +2 -2
  24. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/ChainPicker/index.tsx +143 -0
  25. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/NetworkFilter/index.tsx +8 -6
  26. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/TokenItem/index.tsx +11 -4
  27. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/TokenPicker/index.tsx +102 -73
  28. package/src/components/Swap/SwapView/SwapPanel/TokenPanel/index.tsx +53 -119
  29. package/src/components/Swap/SwapView/SwapPanel/index.tsx +1 -1
  30. package/src/components/Swap/index.tsx +18 -4
  31. package/src/components/TxConfigForm/index.tsx +2 -2
  32. package/src/context/HistoryProvider.tsx +1 -1
  33. package/src/context/SwapProvider.tsx +87 -62
  34. package/src/context/TxUIWrapper.tsx +3 -3
  35. package/src/hooks/useEvmContractApi.ts +28 -4
  36. package/src/models/payloads/GetBalancesPayload.ts +1 -0
  37. package/src/services/api.ts +37 -0
  38. package/src/utils/index.ts +27 -1
  39. package/src/utils/tokens.ts +20 -1
  40. package/tailwind.config.js +4 -1
package/localTheme.ts CHANGED
@@ -50,8 +50,9 @@ const lightThemeStyles: ModalIntegrationThemeStyles = {
50
50
  };
51
51
 
52
52
  const darkThemeStyles: ModalIntegrationThemeStyles = {
53
- mainAccentLight: "#3681C6",
54
- mainAccentDark: "#2B4A9D",
53
+ // Lavender CTA gradient from the swap-v2 design
54
+ mainAccentLight: "#D3DAF2",
55
+ mainAccentDark: "#B8C1E9",
55
56
  bgPrimary: "#020202",
56
57
  bgSecondary: "#0F0F0F",
57
58
  bgTertiary: "#ffffff",
@@ -62,7 +63,8 @@ const darkThemeStyles: ModalIntegrationThemeStyles = {
62
63
  textGreen: "#4CAF50",
63
64
  clickSecondary: "#3681C6",
64
65
  clickTertiary: "#FFFFFF",
65
- buttonPrText: "#FFFFFF",
66
+ // Dark text sits on the light lavender primary button
67
+ buttonPrText: "#020202",
66
68
  buttonPrOffBg: "#272727",
67
69
  buttonPrOffText: "#90A4AE",
68
70
  labelText: "#FFFFFF",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xswap-link/sdk",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "JavaScript SDK for XSwap platform",
5
5
  "homepage": "https://github.com/xswap-link/xswap-sdk",
6
6
  "repository": {
@@ -3,16 +3,14 @@ export const AdjustmentsIcon = ({ className }: { className?: string }) => {
3
3
  <svg
4
4
  xmlns="http://www.w3.org/2000/svg"
5
5
  fill="none"
6
- viewBox="0 0 24 24"
7
- strokeWidth={1.5}
6
+ // The 20px-wide artwork is clipped to a 16px box in the design,
7
+ // so the slider lines run edge to edge.
8
+ viewBox="2 0 16 10.4"
8
9
  stroke="currentColor"
10
+ strokeLinejoin="round"
9
11
  className={className}
10
12
  >
11
- <path
12
- strokeLinecap="round"
13
- strokeLinejoin="round"
14
- d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
15
- />
13
+ <path d="M8.4 2.1L20 2.1M5.2 2.1C5.2 1.21634 5.91635 0.5 6.8 0.5C7.68366 0.5 8.4 1.21635 8.4 2.1C8.4 2.98366 7.68366 3.7 6.8 3.7C5.91635 3.7 5.2 2.98365 5.2 2.1ZM0 2.1H5.2M14.8 8.3L20 8.3M11.6 8.3C11.6 7.41634 12.3163 6.7 13.2 6.7C14.0837 6.7 14.8 7.41635 14.8 8.3C14.8 9.18366 14.0837 9.9 13.2 9.9C12.3163 9.9 11.6 9.18366 11.6 8.3ZM6.8 8.3H11.6" />
16
14
  </svg>
17
15
  );
18
16
  };
@@ -3,16 +3,12 @@ export const ClockIcon = ({ className }: { className?: string }) => {
3
3
  <svg
4
4
  xmlns="http://www.w3.org/2000/svg"
5
5
  fill="none"
6
- viewBox="0 0 24 24"
7
- strokeWidth={1.5}
6
+ viewBox="0 0 16 16"
8
7
  stroke="currentColor"
9
8
  className={className}
10
9
  >
11
- <path
12
- strokeLinecap="round"
13
- strokeLinejoin="round"
14
- d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
15
- />
10
+ <path d="M8 0.5C12.1421 0.5 15.5 3.85786 15.5 8C15.5 12.0748 12.2503 15.3875 8.20117 15.4941L4.35352 11.6465C3.67047 10.924 2.67065 9.92446 1.84375 9.10742C1.42752 8.69616 1.05392 8.32896 0.78418 8.06543C0.671658 7.9555 0.576752 7.86393 0.504883 7.79395C0.614072 3.7471 3.92679 0.5 8 0.5Z" />
11
+ <path d="M8 4V8H10.0011" />
16
12
  </svg>
17
13
  );
18
14
  };
@@ -6,10 +6,14 @@ export const CloseIcon: FC<{ className?: string }> = ({ className }) => {
6
6
  height="18"
7
7
  width="18"
8
8
  xmlns="http://www.w3.org/2000/svg"
9
- viewBox="0 0 10.312 8.319"
9
+ viewBox="0 0 10 9"
10
+ fill="none"
11
+ stroke="currentColor"
10
12
  className={className || ""}
11
13
  >
12
- <path d="M6.073 4.078 10.151 0H9.093A1.87 1.87 0 0 0 7.77.548L5.157 3.162 2.543.548A1.87 1.87 0 0 0 1.22 0H.161L4.24 4.078 0 8.318h1.058a1.87 1.87 0 0 0 1.322-.547l2.777-2.777 2.776 2.777a1.869 1.869 0 0 0 1.322.548h1.058Z" />
14
+ {/* Hairline 1px stroke at any render size, as in the design */}
15
+ <path vectorEffect="non-scaling-stroke" d="M0 0.5H1L9 8.5H10" />
16
+ <path vectorEffect="non-scaling-stroke" d="M10 0.5H9L1 8.5H0" />
13
17
  </svg>
14
18
  );
15
19
  };
@@ -2,14 +2,17 @@ export const WarningIcon = ({ className }: { className?: string }) => {
2
2
  return (
3
3
  <svg
4
4
  xmlns="http://www.w3.org/2000/svg"
5
- viewBox="0 0 24 24"
6
- fill="currentColor"
5
+ viewBox="0 0 14 14"
6
+ fill="none"
7
7
  className={className}
8
8
  >
9
9
  <path
10
- fillRule="evenodd"
11
- d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
12
- clipRule="evenodd"
10
+ d="M11 0C12.6568 6.60986e-05 14 1.34319 14 3V11C14 12.6568 12.6568 13.9999 11 14H3C1.34315 14 1.61073e-08 12.6569 0 11V3C1.28858e-07 1.34315 1.34315 0 3 0H11Z"
11
+ fill="#F46F28"
12
+ />
13
+ <path
14
+ d="M7.66699 9.66699V11H6.33301V9.66699H7.66699ZM8 3V4.33301L7.66699 5.66699V8.33301H6.33301V5.66699L6 4.33301V3H8Z"
15
+ fill="#0B0B0B"
13
16
  />
14
17
  </svg>
15
18
  );
@@ -7,6 +7,11 @@ import { FC, ReactNode } from "react";
7
7
  * - Modals needs to be inside all app's main contexts,
8
8
  * - Modals' need to rerender on props change.
9
9
  *
10
+ * Presentation follows the swap-v2 design:
11
+ * - sm and up: the content replaces the widget in place (fills the widget
12
+ * container with the same radius), or is centered when in overlay mode,
13
+ * - below sm: a bottom sheet pinned to the viewport with a drag handle.
14
+ *
10
15
  * Usage:
11
16
  * ```
12
17
  * const [open, setOpen] = useState(false);
@@ -30,12 +35,14 @@ export const Modal: FC<{
30
35
  return (
31
36
  <div
32
37
  className={`${
33
- overlay ? "fixed bg-[rgba(0,0,0,0.8)]" : "absolute"
34
- } top-0 left-0 right-0 bottom-0 flex items-center justify-center z-10 ${
38
+ overlay
39
+ ? "fixed bg-[rgba(0,0,0,0.8)]"
40
+ : "fixed sm:absolute bg-[rgba(2,2,2,0.8)] sm:bg-transparent"
41
+ } top-0 left-0 right-0 bottom-0 flex flex-col justify-end sm:justify-center items-center z-10 ${
35
42
  className || ""
36
43
  }`}
37
44
  onMouseDown={(e) => {
38
- if (e.target === e.currentTarget && overlay) {
45
+ if (e.target === e.currentTarget) {
39
46
  e.stopPropagation();
40
47
  e.nativeEvent.stopImmediatePropagation();
41
48
  e.preventDefault();
@@ -44,13 +51,11 @@ export const Modal: FC<{
44
51
  }}
45
52
  >
46
53
  <div
47
- className="flex flex-col bg-t_bg_primary rounded-[20px] border border-solid border-t_text_primary border-opacity-10 p-5 sm:p-6 w-full min-w-[340px]"
48
- style={{
49
- height: overlay ? "600px" : "100%",
50
- maxWidth: "636px",
51
- maxHeight: "100%",
52
- }}
54
+ className={`flex flex-col w-full bg-gradient-to-r from-t_bg_secondary/50 to-t_bg_secondary/50 bg-t_bg_primary border-t sm:border border-solid border-t_text_primary border-opacity-10 rounded-t-[24px] rounded-b-none sm:rounded-[32px] p-4 pt-2.5 sm:p-8 h-[85vh] max-h-full overflow-hidden ${
55
+ overlay ? "sm:h-[600px] sm:max-w-[636px]" : "sm:h-full"
56
+ }`}
53
57
  >
58
+ <div className="sm:hidden mx-auto mb-4 h-1 w-8 rounded-full bg-t_bg_tertiary bg-opacity-10 shrink-0" />
54
59
  {children}
55
60
  </div>
56
61
  </div>
@@ -3,17 +3,15 @@ import { ChainlinkMarkIcon } from "@src/assets/icons";
3
3
  export const PoweredBy = () => {
4
4
  return (
5
5
  <div className="flex justify-end items-center">
6
- <div className="flex items-center gap-2 p-2 rounded-lg bg-gradient-to-r from-t_bg_tertiary/0 to-t_bg_tertiary/5">
7
- <div className="flex items-center [&_svg]:h-6 [&_svg]:w-6">
6
+ <div className="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gradient-to-r from-t_bg_tertiary/0 to-t_bg_tertiary/5">
7
+ <div className="flex items-center [&_svg]:h-5 [&_svg]:w-5">
8
8
  <ChainlinkMarkIcon />
9
9
  </div>
10
- <p className="text-sm whitespace-nowrap leading-6">
11
- <span className="text-t_text_primary text-opacity-50">
12
- {`Backed By `}
13
- </span>
14
- <span className="text-t_text_primary text-opacity-75">
15
- Chainlink.
10
+ <p className="text-xs whitespace-nowrap leading-5 tracking-[0.015em]">
11
+ <span className="text-t_text_primary text-opacity-70">
12
+ {`POWERED BY `}
16
13
  </span>
14
+ <span className="text-t_text_primary">CCIP.</span>
17
15
  </p>
18
16
  </div>
19
17
  </div>
@@ -0,0 +1,151 @@
1
+ import { useSwapContext } from "@src/context";
2
+ import { Chain, TokenOption } from "@src/models";
3
+ import {
4
+ isBridgeTokenConnectionSupported,
5
+ isChainConnectionSupported,
6
+ } from "@src/utils";
7
+ import { ChainPicker } from "../SwapView/SwapPanel/TokenPanel/ChainPicker";
8
+ import { TokenPicker } from "../SwapView/SwapPanel/TokenPanel/TokenPicker";
9
+
10
+ /**
11
+ * Token/network picker rendered while `activePicker` is set:
12
+ * - sm and up it replaces the swap view inside the widget container, so it
13
+ * sits on the exact same glass background,
14
+ * - below sm the same element becomes a bottom sheet pinned to the viewport.
15
+ */
16
+ export const PickerView = () => {
17
+ const {
18
+ activePicker,
19
+ setActivePicker,
20
+ bridgeUI,
21
+ srcChain,
22
+ dstChain,
23
+ srcToken,
24
+ dstToken,
25
+ srcChainTokensOptions,
26
+ srcChainOtherTokensOptions,
27
+ dstChainTokensOptions,
28
+ dstChainOtherTokensOptions,
29
+ setSrcToken,
30
+ setSrcChain,
31
+ setDstToken,
32
+ setDstChain,
33
+ supportedChains,
34
+ dstChainOptions,
35
+ bridgeTokensDictionary,
36
+ } = useSwapContext();
37
+
38
+ const type = activePicker ?? "source";
39
+ // In bridge mode the destination token is derived from the source token,
40
+ // so the destination side picks a chain instead of a token.
41
+ const bridgeDestination = bridgeUI && type === "destination";
42
+ const chain = type === "source" ? srcChain : dstChain;
43
+ const token = type === "source" ? srcToken : dstToken;
44
+ const tokens =
45
+ type === "source" ? srcChainTokensOptions : dstChainTokensOptions;
46
+ const otherTokens =
47
+ type === "source" ? srcChainOtherTokensOptions : dstChainOtherTokensOptions;
48
+
49
+ const close = () => setActivePicker(null);
50
+
51
+ const onTokenSelect = (token: TokenOption) => {
52
+ const newChain = supportedChains.find(
53
+ (chain) => chain.chainId === token.chainId,
54
+ );
55
+
56
+ if (type === "source") {
57
+ setSrcToken(token);
58
+ setSrcChain(newChain);
59
+ // The new source token cannot reach the current destination —
60
+ // reset the destination so the user picks a reachable one.
61
+ if (
62
+ dstChain &&
63
+ (!isChainConnectionSupported(newChain, dstChain.chainId, bridgeUI) ||
64
+ (bridgeUI &&
65
+ !isBridgeTokenConnectionSupported({
66
+ token,
67
+ tokenChain: newChain,
68
+ dstChainId: dstChain.chainId,
69
+ bridgeTokensDictionary,
70
+ })))
71
+ ) {
72
+ setDstChain(undefined);
73
+ setDstToken(undefined);
74
+ }
75
+ } else {
76
+ setDstToken(token);
77
+ setDstChain(newChain);
78
+ // The current source cannot reach the new destination chain —
79
+ // reset the source so the user picks a reachable one.
80
+ if (
81
+ srcChain &&
82
+ newChain &&
83
+ !isChainConnectionSupported(srcChain, newChain.chainId, bridgeUI)
84
+ ) {
85
+ setSrcChain(undefined);
86
+ setSrcToken(undefined);
87
+ }
88
+ }
89
+ close();
90
+ };
91
+
92
+ const onDstChainSelect = (newChain: Chain & { disabled?: boolean }) => {
93
+ // The current source cannot reach the new destination chain —
94
+ // reset the source so the user picks a reachable one.
95
+ if (newChain.disabled || newChain.chainId === srcChain?.chainId) {
96
+ setSrcChain(undefined);
97
+ setSrcToken(undefined);
98
+ }
99
+ setDstChain(newChain);
100
+ // The destination token gets derived from the source token and the
101
+ // new chain by the SwapProvider validation effect.
102
+ setDstToken(undefined);
103
+ close();
104
+ };
105
+
106
+ if (!activePicker) {
107
+ return null;
108
+ }
109
+
110
+ return (
111
+ <div
112
+ className="fixed top-0 left-0 right-0 bottom-0 z-10 flex flex-col justify-end bg-[rgba(2,2,2,0.8)] sm:absolute sm:z-[1] sm:justify-start sm:bg-transparent"
113
+ onMouseDown={(e) => {
114
+ if (e.target === e.currentTarget) {
115
+ e.stopPropagation();
116
+ e.nativeEvent.stopImmediatePropagation();
117
+ e.preventDefault();
118
+ close();
119
+ }
120
+ }}
121
+ >
122
+ <div className="flex flex-col w-full h-[85vh] max-h-full overflow-hidden p-4 pt-2.5 rounded-t-[24px] border-t border-solid border-t_text_primary border-opacity-10 bg-gradient-to-r from-t_bg_secondary/50 to-t_bg_secondary/50 bg-t_bg_primary sm:h-full sm:p-0 sm:rounded-none sm:border-0 sm:bg-none sm:bg-transparent">
123
+ <div className="sm:hidden mx-auto mb-4 h-1 w-8 rounded-full bg-t_bg_tertiary bg-opacity-10 shrink-0" />
124
+ {bridgeDestination ? (
125
+ <ChainPicker
126
+ chains={dstChainOptions}
127
+ selectedChainId={dstChain?.chainId}
128
+ sameChainId={srcChain?.chainId}
129
+ onSelect={onDstChainSelect}
130
+ onClose={close}
131
+ />
132
+ ) : (
133
+ <TokenPicker
134
+ type={type}
135
+ chain={chain}
136
+ tokens={tokens}
137
+ otherTokens={otherTokens}
138
+ selectedTokenAddress={token?.address}
139
+ onSelect={onTokenSelect}
140
+ isOpen
141
+ setModalOpen={(open) => {
142
+ if (!open) {
143
+ close();
144
+ }
145
+ }}
146
+ />
147
+ )}
148
+ </div>
149
+ </div>
150
+ );
151
+ };
@@ -15,7 +15,7 @@ export const Delivery = () => {
15
15
  </h2>
16
16
  <div className="w-0.5 h-0.5 bg-t_text_primary opacity-25 shrink-0" />
17
17
  <p className="text-sm leading-6 tracking-[0.01em] text-t_text_primary text-opacity-50 whitespace-nowrap truncate">
18
- {expressDelivery ? "Max. Time 30 Sec." : "Max. Time 30 Min."}
18
+ {expressDelivery ? "Est. Time 30 Sec." : "Est. Time 30 Min."}
19
19
  </p>
20
20
  <Tooltip
21
21
  text={
@@ -11,11 +11,12 @@ import {
11
11
  crosschainEstimationTimes,
12
12
  } from "@src/constants";
13
13
  import { useHistory, useSwapContext, useTxUIWrapper } from "@src/context";
14
- import { ADDRESSES, XSwapRouterAbi } from "@src/contracts";
14
+ import { ADDRESSES, ERC20Abi, XSwapRouterAbi } from "@src/contracts";
15
15
  import { useEvmContractApi } from "@src/hooks";
16
16
  import useNetworks from "@src/hooks/networkManagement/useNetworks";
17
17
  import {
18
18
  Ecosystem,
19
+ TransactionRequest,
19
20
  TxStatus,
20
21
  Transaction as EVMTransaction,
21
22
  } from "@src/models";
@@ -206,7 +207,7 @@ export const TxOverview = ({ onCloseClick }: Props) => {
206
207
  const enqueueSwapTx = useCallback(async () => {
207
208
  if (srcToken) {
208
209
  if (route && route.ecosystem === Ecosystem.EVM) {
209
- const enqueueSwap = () => {
210
+ const enqueueSwap = ({ afterApproval = false } = {}) => {
210
211
  enqueueTransaction({
211
212
  executeTransaction: () => {
212
213
  if (!route.transactions.swap || !signer) {
@@ -216,6 +217,9 @@ export const TxOverview = ({ onCloseClick }: Props) => {
216
217
  return evmContractApi.executeCalldata(
217
218
  signer,
218
219
  route.transactions.swap,
220
+ // right after an approval the RPC may not see the new
221
+ // allowance yet, so let the gas estimation retry
222
+ { retryGasEstimation: afterApproval },
219
223
  );
220
224
  },
221
225
  txStatus: TxStatus.SIGNING_TX,
@@ -258,7 +262,15 @@ export const TxOverview = ({ onCloseClick }: Props) => {
258
262
  },
259
263
  txStatus: TxStatus.SIGNING_APPROVAL,
260
264
  handlers: {
261
- onSuccess: () => enqueueSwap(),
265
+ onSuccess: async () => {
266
+ if (signer && route.transactions.approve) {
267
+ await waitForApprovalAllowance(
268
+ signer,
269
+ route.transactions.approve,
270
+ );
271
+ }
272
+ enqueueSwap({ afterApproval: true });
273
+ },
262
274
  },
263
275
  });
264
276
  } else {
@@ -452,7 +464,7 @@ export const TxOverview = ({ onCloseClick }: Props) => {
452
464
  enqueueSwapTx();
453
465
  }}
454
466
  type="button"
455
- className="text-white border-none h-14 text-sm font-bold w-full py-5 cursor-pointer bg-gradient-to-r from-t_main_accent_light to-t_main_accent_dark rounded-lg"
467
+ className="text-t_button_pr_text border-none h-14 text-sm font-bold w-full py-5 cursor-pointer bg-gradient-to-r from-t_main_accent_light to-t_main_accent_dark rounded-lg"
456
468
  >
457
469
  Sign and swap
458
470
  </Button>
@@ -463,6 +475,40 @@ export const TxOverview = ({ onCloseClick }: Props) => {
463
475
  );
464
476
  };
465
477
 
478
+ const ALLOWANCE_POLL_INTERVAL_MS = 500;
479
+ const ALLOWANCE_POLL_MAX_ATTEMPTS = 10;
480
+
481
+ // After an approval is mined, RPC nodes behind a load balancer may still
482
+ // return the stale allowance for a while, which makes the swap's gas
483
+ // estimation revert. Poll until the approved amount is visible (or give up
484
+ // after ~5s and let the gas estimation retry handle the rest).
485
+ async function waitForApprovalAllowance(
486
+ signer: ethers.Signer,
487
+ approveTx: TransactionRequest,
488
+ ) {
489
+ try {
490
+ const erc20 = new ethers.Contract(approveTx.to, ERC20Abi, signer);
491
+ const [spender, amount] = erc20.interface.decodeFunctionData(
492
+ "approve",
493
+ approveTx.data,
494
+ );
495
+ const owner = await signer.getAddress();
496
+ for (let attempt = 0; attempt < ALLOWANCE_POLL_MAX_ATTEMPTS; attempt++) {
497
+ const currentAllowance = await erc20.allowance(owner, spender);
498
+ if (currentAllowance.gte(amount)) {
499
+ return;
500
+ }
501
+ await new Promise((resolve) =>
502
+ setTimeout(resolve, ALLOWANCE_POLL_INTERVAL_MS),
503
+ );
504
+ }
505
+ } catch (err) {
506
+ // best-effort — non-standard approve calldata or a failing allowance
507
+ // call shouldn't block the swap
508
+ console.error("Failed to poll allowance after approval:", err);
509
+ }
510
+ }
511
+
466
512
  // Add this function to check token delegation and delegate if necessary
467
513
  async function checkAndDelegateTokenAuthority(
468
514
  connection: Connection,
@@ -11,7 +11,7 @@ export const DeliveryInfo = () => {
11
11
  : "text-t_text_primary opacity-50"
12
12
  }`}
13
13
  >
14
- {isExpressDeliveryActive ? "Max. Time 30 Sec." : "Max. Time 30 Min."}
14
+ {isExpressDeliveryActive ? "Est. Time 30 Sec." : "Est. Time 30 Min."}
15
15
  </span>
16
16
  );
17
17
  };
@@ -195,7 +195,7 @@ export const FeesPanel = () => {
195
195
  )}
196
196
  {route && !isLoading && srcChain?.chainId !== dstChain?.chainId && (
197
197
  <>
198
- <div className="w-0.5 h-0.5 bg-t_text_primary opacity-25" />
198
+ <span className="text-t_text_primary opacity-50">/</span>
199
199
  <DeliveryInfo />
200
200
  </>
201
201
  )}
@@ -342,10 +342,10 @@ export const SwapButton = () => {
342
342
  type="button"
343
343
  disabled={button.disabled}
344
344
  onClick={button.fn}
345
- className={`text-sm font-medium leading-6 tracking-[0.01em] w-full px-6 py-3 cursor-pointer disabled:cursor-not-allowed rounded-[10px] border-none transition-opacity ${
345
+ className={`text-base font-medium leading-6 tracking-[0.01em] w-full px-6 py-3 cursor-pointer disabled:cursor-not-allowed rounded-lg border-none transition-[filter] ${
346
346
  button.disabled
347
347
  ? "bg-t_button_pr_off_bg text-t_button_pr_off_text"
348
- : "bg-gradient-to-br from-t_main_accent_light to-t_main_accent_dark text-t_button_pr_text hover:opacity-90"
348
+ : "bg-gradient-to-b from-t_main_accent_light from-50% to-t_main_accent_dark text-t_button_pr_text hover:brightness-105"
349
349
  }`}
350
350
  >
351
351
  {button.text}
@@ -55,9 +55,9 @@ export const Balance = ({ type }: Props) => {
55
55
  ) : (
56
56
  <p className="text-[10px] leading-4 text-right whitespace-nowrap">
57
57
  <span className="text-t_text_primary text-opacity-50">
58
- {`Balance `}
58
+ {`Balance. `}
59
59
  </span>
60
- <span className="text-t_text_primary text-opacity-75">
60
+ <span className="text-t_text_primary text-opacity-75 tracking-[0.032em]">
61
61
  {`${balanceInfo} ${token.symbol}`}
62
62
  </span>
63
63
  </p>
@@ -0,0 +1,143 @@
1
+ import { CloseIcon, SearchIcon, WarningIcon } from "@src/assets/icons";
2
+ import { Chain } from "@src/models";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Virtuoso } from "react-virtuoso";
5
+
6
+ type ChainOption = Chain & {
7
+ disabled?: boolean;
8
+ };
9
+
10
+ type Props = {
11
+ chains: ChainOption[];
12
+ selectedChainId?: string;
13
+ /**
14
+ * Chain occupied by the other side of the bridge — picking it requires
15
+ * resetting the source, same as picking an unreachable chain.
16
+ */
17
+ sameChainId?: string;
18
+ onSelect: (chain: ChainOption) => void;
19
+ onClose: () => void;
20
+ };
21
+
22
+ /**
23
+ * Network picker used in bridge mode, where the destination token is
24
+ * derived from the source token and only the chain gets picked by the user.
25
+ */
26
+ export const ChainPicker = ({
27
+ chains,
28
+ selectedChainId,
29
+ sameChainId,
30
+ onSelect,
31
+ onClose,
32
+ }: Props) => {
33
+ const [searchValue, setSearchValue] = useState("");
34
+ const chainSearchRef = useRef<HTMLInputElement | null>(null);
35
+
36
+ useEffect(() => {
37
+ setTimeout(() => {
38
+ chainSearchRef.current?.focus();
39
+ }, 10); // delay focus a tiny bit to ensure it works
40
+ }, []);
41
+
42
+ const filteredChains = useMemo(() => {
43
+ if (!searchValue) return chains;
44
+ const searchLower = searchValue.toLowerCase();
45
+ return chains.filter(
46
+ (chain) =>
47
+ chain.displayName.toLowerCase().includes(searchLower) ||
48
+ chain.chainId.toLowerCase().includes(searchLower),
49
+ );
50
+ }, [chains, searchValue]);
51
+
52
+ // The current source cannot reach this chain — picking it resets the source.
53
+ const requiresSourceReset = (chain: ChainOption) =>
54
+ !!chain.disabled || chain.chainId === sameChainId;
55
+
56
+ return (
57
+ <>
58
+ <div className="flex justify-between items-center mb-6">
59
+ <div className="text-2xl leading-8 tracking-[0.01em] text-t_text_primary">
60
+ Select Network
61
+ </div>
62
+ <button
63
+ type="button"
64
+ aria-label="Close"
65
+ className="flex items-center justify-center w-8 h-8 rounded-md bg-t_bg_tertiary bg-opacity-5 hover:bg-opacity-10 transition-colors"
66
+ onClick={onClose}
67
+ >
68
+ <div className="w-4 h-4 opacity-50 fill-t_text_primary flex items-center justify-center">
69
+ <CloseIcon />
70
+ </div>
71
+ </button>
72
+ </div>
73
+
74
+ <div className="flex items-center gap-2 h-14 px-4 rounded-xl bg-t_bg_tertiary bg-opacity-[0.03] border border-solid border-t_text_primary border-opacity-5 mb-6">
75
+ <div className="fill-t_text_primary opacity-50 [&_svg]:w-4 [&_svg]:h-4 flex items-center shrink-0">
76
+ <SearchIcon />
77
+ </div>
78
+ <input
79
+ ref={chainSearchRef}
80
+ value={searchValue}
81
+ onChange={(e) => setSearchValue(e.target.value)}
82
+ placeholder="Search network name"
83
+ className="bg-transparent border-none outline-none w-full text-sm leading-6 text-t_text_primary placeholder:text-t_text_primary placeholder:text-opacity-40"
84
+ />
85
+ </div>
86
+
87
+ <div className="flex flex-col mx-0 h-full min-h-0 flex-1">
88
+ {filteredChains.length ? (
89
+ <Virtuoso
90
+ style={{ maxHeight: "700px", height: "100%" }}
91
+ data={filteredChains}
92
+ itemContent={(_, chain) => {
93
+ const resetWarning = requiresSourceReset(chain);
94
+ const isSelected = chain.chainId === selectedChainId;
95
+ return (
96
+ <div className="pb-2">
97
+ <div
98
+ onClick={() => onSelect(chain)}
99
+ className={`flex gap-3 items-center p-3 sm:p-4 rounded-xl cursor-pointer bg-gradient-to-r from-t_bg_tertiary/[0.02] to-t_bg_tertiary/[0.02] bg-[rgba(40,40,40,0.15)] hover:bg-t_bg_tertiary hover:bg-opacity-10 transition-colors ${
100
+ isSelected ? "!bg-t_bg_tertiary !bg-opacity-10" : ""
101
+ }`}
102
+ >
103
+ <img
104
+ src={chain.image}
105
+ alt={chain.displayName}
106
+ className={`w-10 h-10 rounded-full shrink-0 ${
107
+ resetWarning ? "opacity-40" : ""
108
+ }`}
109
+ />
110
+ <div className="flex justify-between items-center gap-2 w-full min-w-0">
111
+ <p
112
+ className={`text-sm font-medium leading-5 tracking-[0.01em] text-t_text_primary ${
113
+ resetWarning ? "opacity-40" : ""
114
+ }`}
115
+ >
116
+ {chain.displayName}
117
+ </p>
118
+ {resetWarning && (
119
+ <div className="flex items-center justify-end gap-1.5 text-[#D77F02]">
120
+ <WarningIcon className="w-3.5 h-3.5 shrink-0" />
121
+ <span className="text-xs leading-[18px] tracking-[0.01em] whitespace-nowrap">
122
+ <span className="sm:hidden">Source will reset.</span>
123
+ <span className="hidden sm:inline">
124
+ Source will reset after selection.
125
+ </span>
126
+ </span>
127
+ </div>
128
+ )}
129
+ </div>
130
+ </div>
131
+ </div>
132
+ );
133
+ }}
134
+ />
135
+ ) : (
136
+ <div className="text-center py-1 text-sm text-t_text_primary text-opacity-50">
137
+ No network found
138
+ </div>
139
+ )}
140
+ </div>
141
+ </>
142
+ );
143
+ };