@xswap-link/sdk 0.15.1 → 0.15.2

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.
@@ -0,0 +1,421 @@
1
+ import { TxWidget } from "@xswap-link/sdk";
2
+ import { useEffect, useMemo, useState } from "react";
3
+
4
+ type Token = { address: string; symbol: string; quickPick?: boolean };
5
+ type Chain = {
6
+ chainId: string;
7
+ displayName: string;
8
+ ecosystem: string;
9
+ web3Environment: string;
10
+ bridgeSupported: boolean;
11
+ swapSupported: boolean;
12
+ tokens: Token[];
13
+ };
14
+
15
+ type Cfg = Record<string, string | boolean | undefined>;
16
+
17
+ const DEFAULT_API = "https://xswap.link/api";
18
+
19
+ const API_URLS = [
20
+ DEFAULT_API,
21
+ "https://develop-72sp7lmyaa-ew.a.run.app/api/beta",
22
+ "http://localhost:3000/api",
23
+ ];
24
+
25
+ const FONTS = ["Inter", "Roboto", "Poppins", "monospace", "serif"];
26
+
27
+ const BOOL_FIELDS = [
28
+ "lightTheme",
29
+ "defaultWalletPicker",
30
+ "bridge",
31
+ "srcChainLocked",
32
+ "srcTokenLocked",
33
+ "dstChainLocked",
34
+ "dstTokenLocked",
35
+ ];
36
+
37
+ const DEFAULTS: Cfg = {
38
+ integratorId: "d6e438dfa14e80701b9b",
39
+ defaultWalletPicker: true,
40
+ bridge: true,
41
+ };
42
+
43
+ const STORAGE_KEY = "xswap-example-config";
44
+
45
+ const loadCfg = (): Cfg => {
46
+ try {
47
+ return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(STORAGE_KEY)!) };
48
+ } catch {
49
+ return DEFAULTS;
50
+ }
51
+ };
52
+
53
+ const str = (v: Cfg[string]) =>
54
+ typeof v === "string" && v.trim() ? v.trim() : undefined;
55
+
56
+ const log =
57
+ (name: string) =>
58
+ (...args: unknown[]) =>
59
+ console.log(`[${name}]`, ...args);
60
+
61
+ const toProps = (c: Cfg) => ({
62
+ integratorId: String(c.integratorId ?? ""),
63
+ srcChain: str(c.srcChain),
64
+ srcToken: str(c.srcToken),
65
+ dstChain: str(c.dstChain),
66
+ dstToken: str(c.dstToken),
67
+ dstDisplayToken: str(c.dstDisplayToken),
68
+ highlightedDstTokens: str(c.highlightedDstTokens)?.split(","),
69
+ widgetTitle: str(c.widgetTitle),
70
+ desc: str(c.desc),
71
+ integratorFee: str(c.integratorFee) ? Number(c.integratorFee) : undefined,
72
+ integratorFeeReceiverAddress: str(c.integratorFeeReceiverAddress),
73
+ override: str(c.apiUrl) ? { apiUrl: String(c.apiUrl) } : undefined,
74
+ styles: {
75
+ mainAccentLight: str(c.mainAccentLight),
76
+ mainAccentDark: str(c.mainAccentDark),
77
+ textSecondary: str(c.textSecondary),
78
+ fontFamily: str(c.fontFamily),
79
+ width: str(c.width),
80
+ },
81
+ lightTheme: !!c.lightTheme,
82
+ defaultWalletPicker: !!c.defaultWalletPicker,
83
+ bridge: !!c.bridge,
84
+ srcChainLocked: !!c.srcChainLocked,
85
+ srcTokenLocked: !!c.srcTokenLocked,
86
+ dstChainLocked: !!c.dstChainLocked,
87
+ dstTokenLocked: !!c.dstTokenLocked,
88
+ onSrcChainChange: log("onSrcChainChange"),
89
+ onSrcTokenChange: log("onSrcTokenChange"),
90
+ onDstChainChange: log("onDstChainChange"),
91
+ onDstTokenChange: log("onDstTokenChange"),
92
+ onConnectWallet: log("onConnectWallet"),
93
+ onPendingTransactionsChange: log("onPendingTransactionsChange"),
94
+ });
95
+
96
+ const useChains = (apiUrl: string) => {
97
+ const [chains, setChains] = useState<Chain[]>([]);
98
+
99
+ useEffect(() => {
100
+ fetch(`${apiUrl}/chains?data={}`)
101
+ .then((r) => r.json())
102
+ .then((all: Chain[]) =>
103
+ setChains(
104
+ all.filter(
105
+ (c) =>
106
+ c.web3Environment === "mainnet" &&
107
+ (c.bridgeSupported || c.swapSupported),
108
+ ),
109
+ ),
110
+ )
111
+ .catch((e) => console.error("failed to load chains", e));
112
+ }, [apiUrl]);
113
+
114
+ return chains;
115
+ };
116
+
117
+ const sortTokens = (tokens: Token[] = []) =>
118
+ [...tokens].sort((a, b) => Number(!!b.quickPick) - Number(!!a.quickPick));
119
+
120
+ type FieldProps = {
121
+ k: string;
122
+ cfg: Cfg;
123
+ set: (key: string, value: string | boolean) => void;
124
+ };
125
+
126
+ // ponytail: declared outside App, otherwise every render remounts the inputs and typing loses focus
127
+ const Text = ({ k, cfg, set, ...rest }: FieldProps & Record<string, unknown>) => (
128
+ <label>
129
+ <span>{k}</span>
130
+ <input
131
+ value={(cfg[k] as string) ?? ""}
132
+ onChange={(e) => set(k, e.target.value)}
133
+ {...rest}
134
+ />
135
+ </label>
136
+ );
137
+
138
+ const Color = ({ k, cfg, set }: FieldProps) => (
139
+ <label>
140
+ <span>{k}</span>
141
+ <div className="inline">
142
+ <input
143
+ type="color"
144
+ value={(cfg[k] as string) || "#3b82f6"}
145
+ onChange={(e) => set(k, e.target.value)}
146
+ />
147
+ <input
148
+ value={(cfg[k] as string) ?? ""}
149
+ placeholder="unset"
150
+ onChange={(e) => set(k, e.target.value)}
151
+ />
152
+ <button onClick={() => set(k, "")}>×</button>
153
+ </div>
154
+ </label>
155
+ );
156
+
157
+ const ChainSelect = ({
158
+ side,
159
+ cfg,
160
+ chains,
161
+ onChange,
162
+ }: {
163
+ side: "src" | "dst";
164
+ cfg: Cfg;
165
+ chains: Chain[];
166
+ onChange: (chainId: string) => void;
167
+ }) => (
168
+ <label>
169
+ <span>{side}Chain</span>
170
+ <select
171
+ value={(cfg[`${side}Chain`] as string) ?? ""}
172
+ onChange={(e) => onChange(e.target.value)}
173
+ >
174
+ <option value="">— any —</option>
175
+ {chains.map((c) => (
176
+ <option key={c.chainId} value={c.chainId}>
177
+ {c.displayName} ({c.ecosystem} · {c.chainId})
178
+ </option>
179
+ ))}
180
+ </select>
181
+ </label>
182
+ );
183
+
184
+ const TokenSelect = ({
185
+ k,
186
+ cfg,
187
+ set,
188
+ tokens,
189
+ }: FieldProps & { tokens: Token[] }) => (
190
+ <label>
191
+ <span>{k}</span>
192
+ <select
193
+ value={(cfg[k] as string) ?? ""}
194
+ onChange={(e) => set(k, e.target.value)}
195
+ >
196
+ <option value="">— any —</option>
197
+ {tokens.map((t) => (
198
+ <option key={t.address} value={t.address}>
199
+ {t.symbol}
200
+ </option>
201
+ ))}
202
+ </select>
203
+ </label>
204
+ );
205
+
206
+ export const App = () => {
207
+ const [cfg, setCfg] = useState(loadCfg);
208
+ const [mountKey, setMountKey] = useState(0);
209
+
210
+ const chains = useChains(str(cfg.apiUrl) ?? DEFAULT_API);
211
+ const byId = useMemo(
212
+ () => Object.fromEntries(chains.map((c) => [c.chainId, c])),
213
+ [chains],
214
+ );
215
+
216
+ useEffect(() => {
217
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
218
+ }, [cfg]);
219
+
220
+ const set = (key: string, value: string | boolean) =>
221
+ setCfg((c) => ({ ...c, [key]: value }));
222
+
223
+ // ponytail: switching a chain invalidates the token picked on the old one
224
+ const setChain = (side: "src" | "dst", chainId: string) =>
225
+ setCfg((c) => ({
226
+ ...c,
227
+ [`${side}Chain`]: chainId,
228
+ [`${side}Token`]: "",
229
+ ...(side === "dst" ? { dstDisplayToken: "", highlightedDstTokens: "" } : {}),
230
+ }));
231
+
232
+ const reset = () => {
233
+ setCfg(DEFAULTS);
234
+ setMountKey((k) => k + 1);
235
+ };
236
+
237
+ const srcTokens = sortTokens(byId[cfg.srcChain as string]?.tokens);
238
+ const dstTokens = sortTokens(byId[cfg.dstChain as string]?.tokens);
239
+ const highlighted = str(cfg.highlightedDstTokens)?.split(",") ?? [];
240
+
241
+ // ponytail: the widget reads chain/token props only on mount, so remount when they change.
242
+ // Text fields (integratorId, apiUrl) are excluded — they'd remount on every keystroke.
243
+ const remountKey = [
244
+ mountKey,
245
+ cfg.srcChain,
246
+ cfg.srcToken,
247
+ cfg.dstChain,
248
+ cfg.dstToken,
249
+ cfg.dstDisplayToken,
250
+ cfg.highlightedDstTokens,
251
+ ].join("|");
252
+
253
+ return (
254
+ <div className="page">
255
+ <style>{CSS}</style>
256
+
257
+ <aside>
258
+ <h2>Widget config</h2>
259
+
260
+ <Text k="integratorId" cfg={cfg} set={set} />
261
+
262
+ <ChainSelect
263
+ side="src"
264
+ cfg={cfg}
265
+ chains={chains}
266
+ onChange={(id) => setChain("src", id)}
267
+ />
268
+ <TokenSelect k="srcToken" cfg={cfg} set={set} tokens={srcTokens} />
269
+ <ChainSelect
270
+ side="dst"
271
+ cfg={cfg}
272
+ chains={chains}
273
+ onChange={(id) => setChain("dst", id)}
274
+ />
275
+ <TokenSelect k="dstToken" cfg={cfg} set={set} tokens={dstTokens} />
276
+ <TokenSelect
277
+ k="dstDisplayToken"
278
+ cfg={cfg}
279
+ set={set}
280
+ tokens={dstTokens}
281
+ />
282
+
283
+ <label>
284
+ <span>highlightedDstTokens (ctrl/cmd + click)</span>
285
+ <select
286
+ multiple
287
+ size={5}
288
+ value={highlighted}
289
+ onChange={(e) =>
290
+ set(
291
+ "highlightedDstTokens",
292
+ [...e.target.selectedOptions].map((o) => o.value).join(","),
293
+ )
294
+ }
295
+ >
296
+ {dstTokens.map((t) => (
297
+ <option key={t.address} value={t.address}>
298
+ {t.symbol}
299
+ </option>
300
+ ))}
301
+ </select>
302
+ </label>
303
+
304
+ <Text k="widgetTitle" cfg={cfg} set={set} placeholder="Bridge" />
305
+ <Text k="desc" cfg={cfg} set={set} />
306
+ <Text
307
+ k="integratorFee"
308
+ cfg={cfg}
309
+ set={set}
310
+ type="number"
311
+ step="0.001"
312
+ placeholder="0.003"
313
+ />
314
+ <Text
315
+ k="integratorFeeReceiverAddress"
316
+ cfg={cfg}
317
+ set={set}
318
+ placeholder="0x…"
319
+ />
320
+
321
+ <label>
322
+ <span>apiUrl (override)</span>
323
+ <input
324
+ list="api-urls"
325
+ value={(cfg.apiUrl as string) ?? ""}
326
+ placeholder={DEFAULT_API}
327
+ onChange={(e) => set("apiUrl", e.target.value)}
328
+ />
329
+ <datalist id="api-urls">
330
+ {API_URLS.map((u) => (
331
+ <option key={u} value={u} />
332
+ ))}
333
+ </datalist>
334
+ </label>
335
+
336
+ <h3>styles</h3>
337
+ <Color k="mainAccentLight" cfg={cfg} set={set} />
338
+ <Color k="mainAccentDark" cfg={cfg} set={set} />
339
+ <Color k="textSecondary" cfg={cfg} set={set} />
340
+
341
+ <label>
342
+ <span>fontFamily</span>
343
+ <input
344
+ list="fonts"
345
+ value={(cfg.fontFamily as string) ?? ""}
346
+ onChange={(e) => set("fontFamily", e.target.value)}
347
+ />
348
+ <datalist id="fonts">
349
+ {FONTS.map((f) => (
350
+ <option key={f} value={f} />
351
+ ))}
352
+ </datalist>
353
+ </label>
354
+
355
+ <label>
356
+ <span>width — {(cfg.width as string) || "default"}</span>
357
+ <div className="inline">
358
+ <input
359
+ type="range"
360
+ min={300}
361
+ max={600}
362
+ step={10}
363
+ value={parseInt((cfg.width as string) || "420", 10)}
364
+ onChange={(e) => set("width", `${e.target.value}px`)}
365
+ />
366
+ <button onClick={() => set("width", "")}>×</button>
367
+ </div>
368
+ </label>
369
+
370
+ <h3>flags</h3>
371
+ <div className="flags">
372
+ {BOOL_FIELDS.map((f) => (
373
+ <label key={f} className="check">
374
+ <input
375
+ type="checkbox"
376
+ checked={!!cfg[f]}
377
+ onChange={(e) => set(f, e.target.checked)}
378
+ />
379
+ {f}
380
+ </label>
381
+ ))}
382
+ </div>
383
+
384
+ <div className="inline">
385
+ <button onClick={() => setMountKey((k) => k + 1)}>Remount</button>
386
+ <button onClick={reset}>Reset</button>
387
+ </div>
388
+
389
+ <pre>{JSON.stringify(toProps(cfg), null, 2)}</pre>
390
+ </aside>
391
+
392
+ <main>
393
+ <TxWidget key={remountKey} {...toProps(cfg)} />
394
+ </main>
395
+ </div>
396
+ );
397
+ };
398
+
399
+ const CSS = `
400
+ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f6f7f9; }
401
+ .page { display: flex; gap: 32px; align-items: flex-start; padding: 24px; }
402
+ aside { width: 340px; display: flex; flex-direction: column; gap: 10px;
403
+ background: #fff; border: 1px solid #e3e5e8; border-radius: 12px; padding: 16px; }
404
+ main { position: sticky; top: 24px; }
405
+ h2, h3 { margin: 4px 0; }
406
+ h3 { color: #667; text-transform: uppercase; font-size: 11px; letter-spacing: .08em; }
407
+ label { display: flex; flex-direction: column; gap: 3px; }
408
+ label > span { color: #667; font-size: 11px; }
409
+ input, select, button { font: inherit; padding: 5px 6px; border: 1px solid #d3d6da;
410
+ border-radius: 6px; background: #fff; }
411
+ input[type=color] { padding: 0; width: 34px; height: 30px; }
412
+ input[type=range] { padding: 0; flex: 1; }
413
+ button { cursor: pointer; }
414
+ .inline { display: flex; gap: 6px; align-items: center; }
415
+ .inline > input:not([type=color]) { flex: 1; min-width: 0; }
416
+ .flags { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
417
+ .check { flex-direction: row; align-items: center; gap: 5px; font-size: 12px; }
418
+ .check input { margin: 0; }
419
+ pre { font-size: 10px; background: #f2f3f5; border-radius: 8px; padding: 10px;
420
+ max-height: 260px; overflow: auto; margin: 0; }
421
+ `;
@@ -0,0 +1,8 @@
1
+ import { Buffer } from "buffer";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+
5
+ // ponytail: SDK deps (solana/sui) expect a Buffer global in the browser
6
+ globalThis.Buffer ??= Buffer;
7
+
8
+ createRoot(document.getElementById("root")!).render(<App />);
@@ -0,0 +1,9 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineConfig } from "vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ // ponytail: linked package -> force pre-bundling so its deps resolve from ../node_modules
7
+ optimizeDeps: { include: ["@xswap-link/sdk"] },
8
+ define: { global: "globalThis" },
9
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xswap-link/sdk",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "JavaScript SDK for XSwap platform",
5
5
  "homepage": "https://github.com/xswap-link/xswap-sdk",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CCIP_ROUTER_PROGRAM_ID,
3
+ EXPRESS_DELIVERY_TIME,
3
4
  FALLBACK_CROSSCHAIN_ESTIMATION_TIME,
4
5
  FEE_QUOTER_PROGRAM_ID,
5
6
  LINK_TOKEN_MINT,
@@ -69,6 +70,7 @@ export const TxOverview = ({ onCloseClick }: Props) => {
69
70
  route,
70
71
  refreshBalance,
71
72
  bridgeUI,
73
+ isExpressDeliveryActive,
72
74
  } = useSwapContext();
73
75
 
74
76
  const {
@@ -107,13 +109,15 @@ export const TxOverview = ({ onCloseClick }: Props) => {
107
109
  tokenOutAddress: dstToken?.address,
108
110
  tokenOutAmount: dstValueWei,
109
111
 
110
- estimatedDeliveryTimestamp:
111
- Number(route.xSwapFees.expressDeliveryFee || "0") > 0 // express delivery fee TODO
112
- ? (Date.now() / 1000) * 1000 + 30 * 1000
113
- : (Date.now() / 1000) * 1000 +
114
- crosschainEstimationTimes?.[srcChain?.chainId]?.[
115
- dstChain?.chainId
116
- ] || FALLBACK_CROSSCHAIN_ESTIMATION_TIME,
112
+ // Read the speed off the quote, never off the express-delivery fee: a fast
113
+ // CCTP transfer pays Circle out of the bridged USDC and charges no express
114
+ // fee, so the fee test used to promise 30 minutes for a 30-second transfer.
115
+ estimatedDeliveryTimestamp: isExpressDeliveryActive
116
+ ? Date.now() + EXPRESS_DELIVERY_TIME
117
+ : Date.now() +
118
+ (crosschainEstimationTimes?.[srcChain?.chainId]?.[
119
+ dstChain?.chainId
120
+ ] || FALLBACK_CROSSCHAIN_ESTIMATION_TIME),
117
121
  status: "IN_PROGRESS",
118
122
  };
119
123
  if (srcChain.chainId === dstChain.chainId) {
@@ -199,6 +203,7 @@ export const TxOverview = ({ onCloseClick }: Props) => {
199
203
  dstValueWei,
200
204
  supportedChains,
201
205
  route,
206
+ isExpressDeliveryActive,
202
207
  ],
203
208
  );
204
209
 
@@ -0,0 +1,136 @@
1
+ import {
2
+ CheckCircleThinIcon,
3
+ ProgressRingThinIcon,
4
+ RedirectThinIcon,
5
+ } from "@src/assets/icons";
6
+ import {
7
+ crosschainEstimationTimes,
8
+ FALLBACK_CROSSCHAIN_ESTIMATION_TIME,
9
+ } from "@src/constants";
10
+ import { useSwapContext, useTxUIWrapper } from "@src/context";
11
+ import { CrossChainDelivery } from "@src/hooks";
12
+ import { useEffect, useMemo, useState } from "react";
13
+ import { TokenTiles } from "../TokenTiles";
14
+
15
+ type Props = {
16
+ onCloseClick: () => void;
17
+ delivery: CrossChainDelivery;
18
+ };
19
+
20
+ /** A fast transfer that has not landed within this much is not "about to" —
21
+ * say so, rather than leave the user watching a spinner that promised seconds. */
22
+ const FAST_DELIVERY_GRACE_MS = 2 * 60 * 1000;
23
+
24
+ const formatElapsed = (ms: number) => {
25
+ const totalSeconds = Math.floor(ms / 1000);
26
+ const minutes = Math.floor(totalSeconds / 60);
27
+ const seconds = totalSeconds % 60;
28
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
29
+ };
30
+
31
+ /**
32
+ * The source tx is mined and the funds are in flight.
33
+ *
34
+ * This is the state a fixed "delivery in ~30 min" note used to paper over: most
35
+ * transfers land in well under a minute, and the only way to know is to ask the API
36
+ * (which also claims a CCTP transfer while it answers — see `useCrossChainDelivery`).
37
+ * So the swap is not reported as finished here; it waits for the destination.
38
+ */
39
+ export const TxProgress = ({ onCloseClick, delivery }: Props) => {
40
+ const { srcChain, dstChain, isExpressDeliveryActive } = useSwapContext();
41
+ const { txExplorerUrl } = useTxUIWrapper();
42
+
43
+ const [now, setNow] = useState(() => Date.now());
44
+ useEffect(() => {
45
+ const ticker = setInterval(() => setNow(Date.now()), 1000);
46
+ return () => clearInterval(ticker);
47
+ }, []);
48
+
49
+ const elapsedMs = delivery.startedAt
50
+ ? Math.max(0, now - delivery.startedAt)
51
+ : 0;
52
+
53
+ // The bridge's own answer once it has one (a fast burn can be attested at the
54
+ // standard tier), the quote's until then.
55
+ const isExpress =
56
+ delivery.response?.expressDelivery ?? isExpressDeliveryActive;
57
+
58
+ const estimatedMs = useMemo(
59
+ () =>
60
+ (srcChain &&
61
+ dstChain &&
62
+ crosschainEstimationTimes?.[srcChain.chainId]?.[dstChain.chainId]) ||
63
+ FALLBACK_CROSSCHAIN_ESTIMATION_TIME,
64
+ [srcChain, dstChain],
65
+ );
66
+
67
+ const isSlow = elapsedMs > (isExpress ? FAST_DELIVERY_GRACE_MS : estimatedMs);
68
+
69
+ const note = isSlow
70
+ ? "This is taking longer than usual. Nothing is lost — the transfer is still on its way."
71
+ : isExpress
72
+ ? "Fast delivery is on: funds usually arrive within a minute."
73
+ : `Funds can take up to ${Math.round(
74
+ estimatedMs / 60000,
75
+ )} minutes to arrive on ${dstChain?.displayName || "the destination chain"}.`;
76
+
77
+ return (
78
+ <div className="flex flex-col gap-8 text-t_text_primary">
79
+ <div className="flex flex-col gap-2">
80
+ <div className="text-2xl font-medium leading-8 tracking-[0.01em]">
81
+ Delivering Funds
82
+ </div>
83
+ <div className="text-sm leading-5 tracking-[0.01em] text-t_text_primary text-opacity-50">
84
+ {note}
85
+ </div>
86
+ <div className="text-sm leading-5 tracking-[0.01em] text-t_text_primary text-opacity-25">
87
+ You can close this window — the transfer completes on its own.
88
+ </div>
89
+ </div>
90
+ <TokenTiles />
91
+ <div className="flex flex-col rounded-xl bg-t_bg_tertiary bg-opacity-[0.02]">
92
+ <div className="flex items-center justify-between gap-4 px-4 sm:px-6 py-4 rounded-lg">
93
+ <div className="text-sm leading-5 tracking-[0.01em] text-t_text_primary text-opacity-50">
94
+ {`Swap confirmed on ${srcChain?.displayName || "the source chain"}`}
95
+ </div>
96
+ <CheckCircleThinIcon className="w-6 h-6 shrink-0 text-[#6AE89B]" />
97
+ </div>
98
+ <div className="flex items-center justify-between gap-4 px-4 sm:px-6 py-4 rounded-lg bg-gradient-to-r from-transparent via-t_bg_tertiary/5 to-transparent">
99
+ <div className="text-sm leading-5 tracking-[0.01em] text-t_text_primary text-opacity-75">
100
+ {`Waiting for funds on ${
101
+ dstChain?.displayName || "the destination chain"
102
+ }`}
103
+ </div>
104
+ <div className="flex items-center gap-3 shrink-0">
105
+ <span className="text-sm leading-5 tracking-[0.01em] tabular-nums text-t_text_primary text-opacity-50">
106
+ {formatElapsed(elapsedMs)}
107
+ </span>
108
+ <ProgressRingThinIcon className="w-6 h-6 shrink-0 animate-spin-slow text-t_text_primary" />
109
+ </div>
110
+ </div>
111
+ </div>
112
+ <div className="h-px w-full gradient-sections-separator-container" />
113
+ <button
114
+ type="button"
115
+ onClick={onCloseClick}
116
+ className="w-full px-6 py-3 rounded-lg border-none cursor-pointer bg-t_bg_tertiary bg-opacity-5 text-sm font-medium leading-6 tracking-[0.01em] text-t_text_primary text-opacity-80 hover:bg-opacity-10 transition-colors"
117
+ >
118
+ Close
119
+ </button>
120
+ <div className="w-full flex gap-3 items-center">
121
+ <a
122
+ href={txExplorerUrl}
123
+ target="_blank"
124
+ rel="noreferrer"
125
+ className="flex items-center gap-1.5 text-sm text-t_text_primary text-opacity-25 hover:text-opacity-50 transition-colors"
126
+ >
127
+ View On Explorer{" "}
128
+ <div className="w-6 h-6 bg-t_bg_tertiary bg-opacity-5 rounded-[4px] flex items-center justify-center">
129
+ <RedirectThinIcon className="w-3 h-3" />
130
+ </div>
131
+ </a>
132
+ <div className="flex-1 h-px bg-t_text_primary bg-opacity-10" />
133
+ </div>
134
+ </div>
135
+ );
136
+ };