@coin-voyage/swapkit 0.4.0 → 0.5.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 (38) hide show
  1. package/dist/components/currency-panel.d.ts +17 -0
  2. package/dist/components/currency-panel.js +22 -0
  3. package/dist/components/execution-modal.js +28 -5
  4. package/dist/components/execution-modal.test.d.ts +1 -0
  5. package/dist/components/execution-modal.test.js +92 -0
  6. package/dist/components/manual-wallet-modal.d.ts +8 -0
  7. package/dist/components/manual-wallet-modal.js +19 -0
  8. package/dist/components/output-wallet-selector.d.ts +7 -0
  9. package/dist/components/output-wallet-selector.js +10 -0
  10. package/dist/components/quote-breakdown.d.ts +5 -0
  11. package/dist/components/quote-breakdown.js +13 -0
  12. package/dist/components/styles.d.ts +1 -0
  13. package/dist/components/styles.js +14 -2
  14. package/dist/components/swap-recipient.test.js +29 -1
  15. package/dist/components/swap-settings.d.ts +9 -0
  16. package/dist/components/swap-settings.js +24 -0
  17. package/dist/components/swap.d.ts +2 -28
  18. package/dist/components/swap.js +27 -312
  19. package/dist/components/token-picker.test.js +11 -0
  20. package/dist/hooks/use-dismiss.d.ts +3 -0
  21. package/dist/hooks/use-dismiss.js +26 -0
  22. package/dist/hooks/use-swap-notifications.d.ts +10 -0
  23. package/dist/hooks/use-swap-notifications.js +53 -0
  24. package/dist/hooks/use-swap-quote.js +49 -51
  25. package/dist/hooks/use-swap.d.ts +47 -0
  26. package/dist/hooks/use-swap.js +189 -0
  27. package/dist/hooks/use-token-data.js +3 -3
  28. package/dist/hooks/use-token-data.test.js +33 -3
  29. package/dist/lib/currency-selection.js +6 -4
  30. package/dist/lib/currency-selection.test.d.ts +1 -0
  31. package/dist/lib/currency-selection.test.js +16 -0
  32. package/dist/lib/format.d.ts +5 -0
  33. package/dist/lib/format.js +20 -0
  34. package/dist/lib/quote.d.ts +10 -1
  35. package/dist/lib/quote.js +12 -0
  36. package/dist/lib/slippage.d.ts +11 -0
  37. package/dist/lib/slippage.js +17 -0
  38. package/package.json +3 -3
@@ -1,330 +1,45 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useAccount } from "@coin-voyage/crypto/hooks";
3
- import { getChainTypeByChainId, isValidAddress } from "@coin-voyage/shared/chain";
4
- import { formatCurrencyAmount } from "@coin-voyage/shared/currency";
5
- import { currencyID } from "@coin-voyage/shared/types";
6
- import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
7
- import { useSwapExecution } from "../hooks/use-swap-execution";
8
- import { useSwapQuote } from "../hooks/use-swap-quote";
9
- import { useSwapState } from "../hooks/use-swap-state";
10
- import { useTokenData } from "../hooks/use-token-data";
11
- import { balanceFraction, isValidSwapAmountInput } from "../lib/amount-input";
12
- import { getSwapWalletRequirement, shouldShowOutputWalletSelector, shouldUseSeparateOutputWallet, } from "../lib/output-wallet";
13
- import { buildSwapQuoteRequest, isSameSwap, toCurrencyBase } from "../lib/quote";
2
+ import { useState } from "react";
3
+ import { useSwap } from "../hooks/use-swap";
14
4
  import { useSwapKit } from "../providers/swapkit-context";
15
5
  import { SwapKitRoot } from "../styles/root";
16
- import { ExecutionModal } from "./execution-modal";
17
6
  import { ConnectedWallets } from "./connected-wallets";
18
- import { ArrowDownIcon, ChevronDownIcon, FuelIcon, InfoIcon, PasteIcon, SettingsIcon, WalletIcon } from "./icons";
19
- import { Modal } from "./modal";
20
- import { AmountInput, AmountRow, AmountSkeleton, BreakdownBody, BreakdownCard, BreakdownMeta, BreakdownToggle, CardFooter, CardHeaderAction, CardTitle, CardWrap, CompactInput, CurrencyCard, DetailRow, ErrorBox, FieldLabel, FormStack, FreeFeeBadge, Hint, IconButton, InfoButton, InfoWrap, NetworkCost, OutputWalletControl, OutputWalletMenu, OutputWalletOption, OutputWalletTrigger, PrimaryButton, QuoteValueSkeleton, QuickAmountActions, QuickAmountButton, SettingsLabel, SettingsPanel, SettingsRow, SettingsTooltip, SlippageControl, SlippageInput, SlippageInputWrap, SlippageModeButton, Spinner, SwitchButton, TokenButton, Toolbar, ToolbarGroup, WalletRequirementButton, } from "./styles";
21
- import { TokenIcon } from "./token-icon";
7
+ import { CurrencyPanel } from "./currency-panel";
8
+ import { ExecutionModal } from "./execution-modal";
9
+ import { ArrowDownIcon } from "./icons";
10
+ import { ManualWalletModal } from "./manual-wallet-modal";
11
+ import { OutputWalletSelector } from "./output-wallet-selector";
12
+ import { QuoteBreakdown } from "./quote-breakdown";
13
+ import { CardWrap, ErrorBox, FormStack, PrimaryButton, Spinner, SwitchButton, Toolbar, WalletRequirementButton, } from "./styles";
14
+ import { SwapSettings } from "./swap-settings";
22
15
  import { TokenPicker } from "./token-picker";
23
16
  import { WalletConnector } from "./wallet-connector";
24
- import { truncateAddress } from "@coin-voyage/shared/utils";
25
- function DismissibleToolbarGroup({ open, onDismiss, pushRight = false, children, }) {
26
- const groupRef = useRef(null);
27
- const onDismissRef = useRef(onDismiss);
28
- useEffect(() => {
29
- onDismissRef.current = onDismiss;
30
- }, [onDismiss]);
31
- useEffect(() => {
32
- if (!open)
33
- return;
34
- const onPointerDown = (event) => {
35
- if (!groupRef.current?.contains(event.target))
36
- onDismissRef.current();
37
- };
38
- const onKeyDown = (event) => {
39
- if (event.key === "Escape")
40
- onDismissRef.current();
41
- };
42
- document.addEventListener("pointerdown", onPointerDown);
43
- document.addEventListener("keydown", onKeyDown);
44
- return () => {
45
- document.removeEventListener("pointerdown", onPointerDown);
46
- document.removeEventListener("keydown", onKeyDown);
47
- };
48
- }, [open]);
49
- return (_jsx(ToolbarGroup, { ref: groupRef, "$pushRight": pushRight, children: children }));
50
- }
51
- const SLIPPAGE_TOOLTIP = "Your transaction will revert if the price changes more than the slippage percentage. For crosschain swaps involving multiple steps, this slippage will apply for each swap.";
52
- function SlippageInfoTooltip({ id }) {
53
- const [hovered, setHovered] = useState(false);
54
- const [focused, setFocused] = useState(false);
55
- const open = hovered || focused;
56
- return (_jsxs(InfoWrap, { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [_jsx(InfoButton, { type: "button", "aria-label": "About max slippage", "aria-describedby": open ? id : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: _jsx(InfoIcon, {}) }), open && (_jsx(SettingsTooltip, { id: id, role: "tooltip", children: SLIPPAGE_TOOLTIP }))] }));
57
- }
58
- export function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }) {
59
- const onCompleteRef = useRef(onComplete);
60
- const onErrorRef = useRef(onError);
61
- const onBalancesChangedRef = useRef(onBalancesChanged);
62
- const lastNotification = useRef(undefined);
63
- useEffect(() => {
64
- onCompleteRef.current = onComplete;
65
- onErrorRef.current = onError;
66
- onBalancesChangedRef.current = onBalancesChanged;
67
- }, [onBalancesChanged, onComplete, onError]);
68
- useEffect(() => {
69
- if (stage.kind === "idle") {
70
- lastNotification.current = undefined;
71
- return;
72
- }
73
- if (stage.kind === "done") {
74
- const transactionHash = stage.destinationTxHash ?? stage.sourceTxHash;
75
- if (!transactionHash)
76
- return;
77
- const key = `complete:${stage.orderId}:${transactionHash}`;
78
- if (lastNotification.current !== key) {
79
- lastNotification.current = key;
80
- onBalancesChangedRef.current();
81
- onCompleteRef.current?.({ orderId: stage.orderId, transactionHash });
82
- }
83
- }
84
- else if (stage.kind === "refunded") {
85
- const key = `refunded:${stage.orderId}`;
86
- if (lastNotification.current !== key) {
87
- lastNotification.current = key;
88
- onBalancesChangedRef.current();
89
- }
90
- }
91
- else if (stage.kind === "error") {
92
- const key = `error:${stage.message}`;
93
- if (lastNotification.current !== key) {
94
- lastNotification.current = key;
95
- onErrorRef.current?.({ message: stage.message });
96
- }
97
- }
98
- }, [stage]);
99
- useEffect(() => {
100
- const message = quoteError ?? tokenError;
101
- if (!message)
102
- return;
103
- const key = `error:${message}`;
104
- if (lastNotification.current !== key) {
105
- lastNotification.current = key;
106
- onErrorRef.current?.({ message });
107
- }
108
- }, [quoteError, tokenError]);
109
- }
110
- function OutputWalletSelector({ label, open, onOpenChange, onConnect, onPaste, }) {
111
- const controlRef = useRef(null);
112
- useEffect(() => {
113
- if (!open)
114
- return;
115
- const dismiss = (event) => {
116
- if (!controlRef.current?.contains(event.target))
117
- onOpenChange(false);
118
- };
119
- const dismissOnEscape = (event) => {
120
- if (event.key === "Escape")
121
- onOpenChange(false);
122
- };
123
- document.addEventListener("pointerdown", dismiss);
124
- document.addEventListener("keydown", dismissOnEscape);
125
- return () => {
126
- document.removeEventListener("pointerdown", dismiss);
127
- document.removeEventListener("keydown", dismissOnEscape);
128
- };
129
- }, [onOpenChange, open]);
130
- return (_jsxs(OutputWalletControl, { ref: controlRef, children: [_jsxs(OutputWalletTrigger, { type: "button", "aria-label": `Output wallet: ${label}`, "aria-expanded": open, onClick: () => onOpenChange(!open), children: [_jsx("span", { children: label }), _jsx(ChevronDownIcon, { size: 16, up: open })] }), open && (_jsxs(OutputWalletMenu, { role: "menu", "aria-label": "Destination wallet options", children: [onConnect && (_jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onConnect, children: [_jsx("span", { children: "Connect Wallet" }), _jsx(WalletIcon, {})] })), _jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onPaste, children: [_jsx("span", { children: "Paste Address" }), _jsx(PasteIcon, {})] })] }))] }));
131
- }
132
- function formatUsd(value) {
133
- if (value === undefined || value === null || !Number.isFinite(value))
134
- return "$0.00";
135
- return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(value);
136
- }
137
- function formatPriceImpact(value) {
138
- if (value > 0 && value < 0.01)
139
- return "<0.01%";
140
- return `${value.toFixed(2)}%`;
141
- }
142
- function tokenUsdValue(token, amount) {
143
- const numericAmount = Number(amount);
144
- if (!token?.currency.price_usd || !Number.isFinite(numericAmount))
145
- return undefined;
146
- return token.currency.price_usd * numericAmount;
147
- }
148
- const QUICK_AMOUNTS = [
149
- { label: "25%", fraction: 0.25 },
150
- { label: "50%", fraction: 0.5 },
151
- { label: "75%", fraction: 0.75 },
152
- { label: "Max", fraction: 1 },
153
- ];
154
- export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }) {
155
- const label = side === "input" ? "SELL" : "BUY";
156
- const showQuickAmounts = side === "input" && balance !== undefined && token !== undefined;
157
- return (_jsxs(CurrencyCard, { children: [_jsx(CardTitle, { children: label }), headerAction && _jsx(CardHeaderAction, { children: headerAction }), showQuickAmounts && (_jsx(QuickAmountActions, { "data-quick-amounts": true, children: QUICK_AMOUNTS.map(({ label: amountLabel, fraction }) => (_jsx(QuickAmountButton, { type: "button", disabled: disabled, "aria-label": fraction === 1
158
- ? `Use maximum ${token.currency.symbol} balance`
159
- : `Use ${amountLabel} of ${token.currency.symbol} balance`, onClick: () => onAmountChange(balanceFraction(balance, fraction, token.currency.decimals)), children: amountLabel }, amountLabel))) })), _jsxs(AmountRow, { children: [quoteLoading ? (_jsx(AmountSkeleton, { role: "status", "aria-label": "Loading quoted amount" })) : (_jsx(AmountInput, { "aria-label": side === "input" ? "Sell amount" : "Buy amount", type: "text", inputMode: "decimal", autoComplete: "off", autoCorrect: "off", spellCheck: false, placeholder: "0", value: amount, disabled: disabled, onChange: (event) => {
160
- if (isValidSwapAmountInput(event.target.value))
161
- onAmountChange(event.target.value);
162
- } })), _jsxs(TokenButton, { type: "button", onClick: onSelectToken, "aria-label": `Select ${side} token`, children: [token ? _jsx(TokenIcon, { currency: token.currency }) : null, _jsx("span", { children: token?.currency.symbol ?? "Select" }), _jsx(ChevronDownIcon, {})] })] }), _jsxs(CardFooter, { children: [quoteLoading ? (_jsx(QuoteValueSkeleton, { role: "status", "aria-label": "Loading quoted value" })) : (_jsx("span", { children: formatUsd(quoteUsd ?? tokenUsdValue(token, amount)) })), balance !== undefined && _jsxs("span", { children: ["Balance: ", Number(balance).toLocaleString()] })] })] }));
163
- }
164
- function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
165
- const [value, setValue] = useState(initialValue ?? "");
166
- const [error, setError] = useState(null);
167
- const outputAddressId = useId();
168
- return (_jsxs(Modal, { open: open, title: "Paste destination address", onClose: onClose, children: [_jsx(FieldLabel, { htmlFor: outputAddressId, children: "Wallet address" }), _jsx(CompactInput, { id: outputAddressId, value: value, autoComplete: "off", onChange: (event) => setValue(event.target.value.trim()) }), error && _jsx(Hint, { "$tone": "danger", children: error }), _jsx(PrimaryButton, { type: "button", style: { marginTop: 12 }, onClick: () => {
169
- if (!chainId || !isValidAddress(value, chainId)) {
170
- setError("Enter a valid address for the destination network");
171
- return;
172
- }
173
- setError(null);
174
- onSave(value);
175
- onClose();
176
- }, children: "Use address" })] }));
177
- }
178
- export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, } = {}) {
17
+ export function Swap(props = {}) {
179
18
  const { mode, theme } = useSwapKit();
180
- const slippageInputId = useId();
181
- const slippageTooltipId = useId();
182
- const { tokens, accounts, isLoading, error: tokenError, balancesLoaded, refreshBalances } = useTokenData();
183
- const { accounts: connectedAccounts } = useAccount();
19
+ const swap = useSwap(props);
20
+ const { sourceToken, destinationToken, walletRequirement, quote, quoteLoading } = swap;
184
21
  const [sourcePickerOpen, setSourcePickerOpen] = useState(false);
185
22
  const [destinationPickerOpen, setDestinationPickerOpen] = useState(false);
186
- const [settingsOpen, setSettingsOpen] = useState(false);
187
- const [connectTarget, setConnectTarget] = useState({
188
- open: false,
189
- });
23
+ const [connectTarget, setConnectTarget] = useState({ open: false });
190
24
  const [manualWalletOpen, setManualWalletOpen] = useState(false);
191
25
  const [outputWalletMenuOpen, setOutputWalletMenuOpen] = useState(false);
192
- const [breakdownOpen, setBreakdownOpen] = useState(false);
193
- const connectedChainTypes = new Set(accounts.map((account) => account.chainType));
194
- const swapState = useSwapState({
195
- currencies: tokens.map((token) => token.currency),
196
- connectedChainTypes,
197
- defaultSourceCurrency,
198
- defaultDestinationCurrency,
199
- defaultSlippageBps,
200
- });
201
- const { sourceId: resolvedSourceId, destinationId: resolvedDestinationId, inputAmount, outputAmount, activeSide, slippageMode, slippagePercent, manualOutputWallet, } = swapState.state;
202
- const sourceToken = tokens.find((token) => currencyID(token.currency) === resolvedSourceId);
203
- const destinationToken = tokens.find((token) => currencyID(token.currency) === resolvedDestinationId);
204
- const sourceChainType = sourceToken ? getChainTypeByChainId(sourceToken.currency.chain_id) : undefined;
205
- const destinationChainType = destinationToken ? getChainTypeByChainId(destinationToken.currency.chain_id) : undefined;
206
- const inputAccount = connectedAccounts.find((account) => account.chainType === sourceChainType);
207
- const outputAccount = connectedAccounts.find((account) => account.chainType === destinationChainType);
208
- const separateOutputWallet = shouldUseSeparateOutputWallet(sourceChainType, destinationChainType);
209
- const manualAddress = manualOutputWallet && manualOutputWallet.chainId === destinationToken?.currency.chain_id
210
- ? manualOutputWallet.address
211
- : undefined;
212
- const outputAddress = manualAddress ?? (separateOutputWallet ? outputAccount?.address : inputAccount?.address);
213
- const walletRequirement = getSwapWalletRequirement({
214
- inputWalletAddress: inputAccount?.address,
215
- inputChainType: sourceChainType,
216
- shouldRestrictInputChainType: Boolean(sourceToken),
217
- outputWalletAddress: outputAddress,
218
- outputChainType: destinationChainType,
219
- });
220
- const showOutputWalletSelector = shouldShowOutputWalletSelector({
221
- outputChainType: destinationChainType,
222
- connectionRequirement: walletRequirement,
223
- });
224
- const slippage = Number(slippagePercent);
225
- const invalidSlippage = slippageMode === "custom" &&
226
- (slippagePercent.length === 0 || !Number.isFinite(slippage) || slippage < 0 || slippage > 100);
227
- const slippageBps = slippageMode === "custom" && !invalidSlippage ? Math.round(slippage * 100) : undefined;
228
- const activeAmount = activeSide === "input" ? inputAmount : outputAmount;
229
- const quoteRequest = useMemo(() => invalidSlippage ||
230
- isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress)
231
- ? null
232
- : buildSwapQuoteRequest({
233
- amount: activeAmount,
234
- activeSide,
235
- source: sourceToken?.currency,
236
- destination: destinationToken?.currency,
237
- slippageBps,
238
- }), [
239
- activeAmount,
240
- activeSide,
241
- destinationToken?.currency,
242
- inputAccount?.address,
243
- invalidSlippage,
244
- outputAddress,
245
- slippageBps,
246
- sourceToken?.currency,
247
- ]);
248
- const execution = useSwapExecution(sourceChainType, inputAccount?.address);
249
- const quoteState = useSwapQuote(quoteRequest, execution.stage.kind === "idle");
250
- useSwapNotifications({
251
- stage: execution.stage,
252
- quoteError: quoteState.error,
253
- tokenError,
254
- onBalancesChanged: refreshBalances,
255
- onComplete,
256
- onError,
257
- });
258
- const displayedInputAmount = activeSide === "output" && quoteState.quote
259
- ? formatCurrencyAmount(quoteState.quote.input.amount.raw, quoteState.quote.input.currency.decimals)
260
- : inputAmount;
261
- const displayedOutputAmount = activeSide === "input" && quoteState.quote
262
- ? formatCurrencyAmount(quoteState.quote.output.amount.raw, quoteState.quote.output.currency.decimals)
263
- : outputAmount;
264
- const sourceBalance = sourceToken?.balance;
265
- // A pasted recipient is not one of the connected wallets, so its balance is unknown.
266
- const destinationBalance = manualAddress
267
- ? undefined
268
- : (destinationToken?.balance ?? (balancesLoaded && outputAccount?.address ? "0" : undefined));
269
- const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > Number(sourceBalance);
270
- const sameSwap = isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress);
271
- const canExecute = Boolean(quoteState.quote &&
272
- quoteRequest &&
273
- inputAccount?.address &&
274
- outputAddress &&
275
- walletRequirement.target === "none" &&
276
- !insufficientBalance &&
277
- !sameSwap &&
278
- !invalidSlippage);
279
- const reverseTokens = useCallback(() => {
280
- swapState.reverse(displayedInputAmount, displayedOutputAmount);
281
- setOutputWalletMenuOpen(false);
282
- }, [displayedInputAmount, displayedOutputAmount, swapState]);
283
- const executeSwap = useCallback(async () => {
284
- if (!quoteRequest || !sourceToken || !destinationToken || !inputAccount?.address || !outputAddress)
285
- return;
286
- const request = {
287
- trade_type: quoteRequest.trade_type,
288
- amount: quoteRequest.amount,
289
- source: {
290
- currency: toCurrencyBase(sourceToken.currency),
291
- sender_address: inputAccount.address,
292
- ...(slippageBps === undefined ? {} : { slippage_bps: slippageBps }),
293
- },
294
- destination: { currency: toCurrencyBase(destinationToken.currency), recipient: outputAddress },
295
- };
296
- await execution.execute(request, inputAccount.address);
297
- }, [destinationToken, execution, inputAccount, outputAddress, quoteRequest, slippageBps, sourceToken]);
298
- const selectToken = (side, token) => {
299
- swapState.selectCurrency(side, currencyID(token.currency));
300
- };
301
- const quoteSummary = quoteState.quote
302
- ? `1 ${quoteState.quote.input.currency.symbol} ≈ ${(Number(quoteState.quote.output.amount.ui) / Math.max(Number(quoteState.quote.input.amount.ui), Number.EPSILON)).toLocaleString(undefined, { maximumSignificantDigits: 6 })} ${quoteState.quote.output.currency.symbol}`
303
- : "Swap details";
304
- return (_jsx(SwapKitRoot, { "$mode": mode, "$customTheme": theme, "data-swapkit-root": "", children: _jsxs(FormStack, { children: [_jsxs(Toolbar, { children: [_jsx(ConnectedWallets, {}), _jsxs(DismissibleToolbarGroup, { open: settingsOpen, onDismiss: () => setSettingsOpen(false), pushRight: true, children: [_jsxs(IconButton, { type: "button", "aria-label": "Swap settings", "aria-expanded": settingsOpen, onClick: () => setSettingsOpen((open) => !open), children: [_jsx(SettingsIcon, {}), _jsx("span", { children: slippageMode === "auto" ? "Auto" : `${slippagePercent || "0"} %` })] }), settingsOpen && (_jsxs(SettingsPanel, { role: "dialog", "aria-label": "Swap settings", children: [_jsxs(SettingsRow, { children: [_jsxs(SettingsLabel, { children: ["Max slippage", _jsx(SlippageInfoTooltip, { id: slippageTooltipId })] }), _jsxs(SlippageControl, { children: [_jsx(SlippageModeButton, { type: "button", "aria-label": "Use automatic slippage", "aria-pressed": slippageMode === "auto", "$active": slippageMode === "auto", onClick: () => swapState.setSlippageMode("auto"), children: "Auto" }), _jsxs(SlippageInputWrap, { "$active": slippageMode === "custom", children: [_jsx(SlippageInput, { id: slippageInputId, "aria-label": "Slippage tolerance", inputMode: "decimal", value: slippagePercent, onClick: () => swapState.setSlippageMode("custom"), onFocus: () => swapState.setSlippageMode("custom"), onChange: (event) => {
305
- if (isValidSwapAmountInput(event.target.value)) {
306
- swapState.setSlippagePercent(event.target.value);
307
- }
308
- } }), _jsx("span", { children: "%" })] })] })] }), slippageMode === "custom" &&
309
- (slippage >= 40 ? (_jsx(Hint, { "$tone": "danger", children: "This slippage tolerance may cause a severely unfavorable trade." })) : slippage >= 6 ? (_jsx(Hint, { "$tone": "warning", children: "High slippage can result in a worse execution price." })) : null)] }))] })] }), _jsxs(CardWrap, { children: [_jsx(CurrencyPanel, { side: "input", token: sourceToken, amount: displayedInputAmount, balance: sourceBalance, quoteUsd: quoteState.quote?.input.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "output", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
310
- swapState.setAmount("input", value);
311
- }, onSelectToken: () => setSourcePickerOpen(true) }), _jsx(SwitchButton, { type: "button", "aria-label": "Swap tokens", onClick: reverseTokens, disabled: !sourceToken || !destinationToken, children: _jsx(ArrowDownIcon, { size: 24 }) })] }), _jsx(CurrencyPanel, { side: "output", token: destinationToken, amount: displayedOutputAmount, balance: destinationBalance, quoteUsd: quoteState.quote?.output.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "input", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
312
- swapState.setAmount("output", value);
313
- }, onSelectToken: () => setDestinationPickerOpen(true), headerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: separateOutputWallet
26
+ return (_jsx(SwapKitRoot, { "$mode": mode, "$customTheme": theme, "data-swapkit-root": "", children: _jsxs(FormStack, { children: [_jsxs(Toolbar, { children: [_jsx(ConnectedWallets, {}), _jsx(SwapSettings, { slippageMode: swap.slippageMode, slippagePercent: swap.slippagePercent, warning: swap.slippage.warning, onSlippageModeChange: swap.setSlippageMode, onSlippagePercentChange: swap.setSlippagePercent })] }), _jsxs(CardWrap, { children: [_jsx(CurrencyPanel, { side: "input", token: sourceToken, amount: swap.displayedInputAmount, balance: swap.sourceBalance, quoteUsd: quote?.input.amount.value_usd, quoteLoading: quoteLoading && swap.activeSide === "output", disabled: !swap.idle, onAmountChange: (value) => swap.setAmount("input", value), onSelectToken: () => setSourcePickerOpen(true) }), _jsx(SwitchButton, { type: "button", "aria-label": "Swap tokens", onClick: () => {
27
+ swap.reverse();
28
+ setOutputWalletMenuOpen(false);
29
+ }, disabled: !sourceToken || !destinationToken || !swap.destinationHasWallet, children: _jsx(ArrowDownIcon, { size: 24 }) })] }), _jsx(CurrencyPanel, { side: "output", token: destinationToken, amount: swap.displayedOutputAmount, balance: swap.destinationBalance, quoteUsd: quote?.output.amount.value_usd, quoteLoading: quoteLoading && swap.activeSide === "input", disabled: !swap.idle, onAmountChange: (value) => swap.setAmount("output", value), onSelectToken: () => setDestinationPickerOpen(true), headerAction: swap.showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: swap.outputWalletLabel, open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: swap.separateOutputWallet && swap.destinationHasWallet
314
30
  ? () => {
315
31
  setOutputWalletMenuOpen(false);
316
- setConnectTarget({ open: true, chainType: destinationChainType });
32
+ setConnectTarget({ open: true, chainType: swap.destinationChainType });
317
33
  }
318
34
  : undefined, onPaste: () => {
319
35
  setOutputWalletMenuOpen(false);
320
36
  setManualWalletOpen(true);
321
- } })) : undefined }), quoteState.error && _jsx(ErrorBox, { children: quoteState.error }), tokenError && _jsx(ErrorBox, { children: tokenError }), insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), (quoteState.loading || quoteState.quote) && (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": breakdownOpen, onClick: () => setBreakdownOpen((open) => !open), children: [_jsx("span", { children: quoteState.loading ? "Finding the best route…" : quoteSummary }), _jsxs(BreakdownMeta, { children: [quoteState.quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(quoteState.quote.input.breakdown?.gas?.value_usd) })] })), _jsx(ChevronDownIcon, { size: 16, up: breakdownOpen })] })] }), breakdownOpen && (_jsxs(BreakdownBody, { children: [quoteState.quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quoteState.quote.output.amount_out_minimum.ui, " ", quoteState.quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quoteState.quote ? formatPriceImpact(quoteState.quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.gas?.value_usd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd === 0 ? (_jsx(FreeFeeBadge, { children: "Free" })) : (_jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd) }))] })] }))] })), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => setConnectTarget({ open: true, chainType: walletRequirement.chainType }), children: walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !canExecute || quoteState.loading, onClick: () => void executeSwap(), children: [quoteState.loading ? _jsx(Spinner, {}) : null, isLoading ? "Loading tokens…" : quoteState.loading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedSourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedDestinationId, preferredChainId: sourceToken?.currency.chain_id, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: manualAddress, onClose: () => setManualWalletOpen(false), onSave: (address) => {
322
- if (destinationToken)
323
- swapState.setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
324
- } })), _jsx(ExecutionModal, { stage: execution.stage, onClose: () => {
325
- const terminal = ["done", "refunded"].includes(execution.stage.kind);
326
- execution.reset();
327
- if (terminal)
328
- swapState.reset({ preserveCurrencies: execution.stage.kind === "done" });
329
- } })] }) }));
37
+ } })) : undefined }), swap.quoteError && _jsx(ErrorBox, { children: swap.quoteError }), swap.tokenError && _jsx(ErrorBox, { children: swap.tokenError }), swap.insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), swap.sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), _jsx(QuoteBreakdown, { quote: quote, loading: quoteLoading }), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => {
38
+ if (swap.requiresPastedAddress)
39
+ setManualWalletOpen(true);
40
+ else
41
+ setConnectTarget({ open: true, chainType: walletRequirement.chainType });
42
+ }, children: swap.requiresPastedAddress
43
+ ? `Paste ${swap.destinationChainName ?? "Destination"} Address`
44
+ : walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !swap.canExecute || quoteLoading, onClick: () => void swap.execute(), children: [quoteLoading ? _jsx(Spinner, {}) : null, swap.tokensLoading ? "Loading tokens…" : quoteLoading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: swap.sourceTokens, selectedId: swap.sourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => swap.selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: swap.tokens, selectedId: swap.destinationId, preferredChainId: sourceToken?.currency.chain_id, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => swap.selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: swap.manualAddress, onClose: () => setManualWalletOpen(false), onSave: swap.saveManualAddress })), _jsx(ExecutionModal, { stage: swap.stage, onClose: swap.closeExecution })] }) }));
330
45
  }
@@ -25,6 +25,17 @@ const tokens = [
25
25
  token({ id: "usdc-base", chain_id: ChainId.BASE, name: "Base USD Coin", symbol: "USDC", decimals: 6 }),
26
26
  ];
27
27
  describe("TokenPicker", () => {
28
+ it("lists Zcash as a BUY network and selects its native token", () => {
29
+ const zec = token({ id: "zec", chain_id: ChainId.ZEC, name: "Zcash", symbol: "ZEC", decimals: 8 });
30
+ const onSelect = vi.fn();
31
+ render(_jsx(TokenPicker, { open: true, title: "Select BUY token", tokens: [...tokens, zec], preferredChainId: ChainId.SOL, onClose: vi.fn(), onSelect: onSelect }));
32
+ fireEvent.click(screen.getByRole("button", { name: "Solana" }));
33
+ fireEvent.click(screen.getByRole("option", { name: "Zcash" }));
34
+ expect(screen.getByRole("button", { name: "Zcash" })).toBeTruthy();
35
+ expect(screen.queryByRole("option", { name: "SOL Solana" })).toBeNull();
36
+ fireEvent.click(screen.getByRole("option", { name: "ZEC Zcash" }));
37
+ expect(onSelect).toHaveBeenCalledWith(zec);
38
+ });
28
39
  it("prefilters BUY tokens to the selected SELL chain", () => {
29
40
  render(_jsx(TokenPicker, { open: true, title: "Select BUY token", tokens: tokens, preferredChainId: ChainId.SOL, onClose: vi.fn(), onSelect: vi.fn() }));
30
41
  expect(screen.getByRole("option", { name: "SOL Solana" })).toBeTruthy();
@@ -0,0 +1,3 @@
1
+ import { type RefObject } from "react";
2
+ /** Calls `onDismiss` on Escape or a pointer press outside `ref` while `open`. */
3
+ export declare function useDismiss(ref: RefObject<HTMLElement | null>, open: boolean, onDismiss: () => void): void;
@@ -0,0 +1,26 @@
1
+ import { useEffect, useRef } from "react";
2
+ /** Calls `onDismiss` on Escape or a pointer press outside `ref` while `open`. */
3
+ export function useDismiss(ref, open, onDismiss) {
4
+ const onDismissRef = useRef(onDismiss);
5
+ useEffect(() => {
6
+ onDismissRef.current = onDismiss;
7
+ }, [onDismiss]);
8
+ useEffect(() => {
9
+ if (!open)
10
+ return;
11
+ const onPointerDown = (event) => {
12
+ if (!ref.current?.contains(event.target))
13
+ onDismissRef.current();
14
+ };
15
+ const onKeyDown = (event) => {
16
+ if (event.key === "Escape")
17
+ onDismissRef.current();
18
+ };
19
+ document.addEventListener("pointerdown", onPointerDown);
20
+ document.addEventListener("keydown", onKeyDown);
21
+ return () => {
22
+ document.removeEventListener("pointerdown", onPointerDown);
23
+ document.removeEventListener("keydown", onKeyDown);
24
+ };
25
+ }, [open, ref]);
26
+ }
@@ -0,0 +1,10 @@
1
+ import type { SwapStage } from "../lib/execution";
2
+ import type { SwapCompleteResult, SwapError } from "../types";
3
+ export declare function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }: {
4
+ stage: SwapStage;
5
+ quoteError?: string | null;
6
+ tokenError?: string | null;
7
+ onBalancesChanged: () => void;
8
+ onComplete?: (result: SwapCompleteResult) => void;
9
+ onError?: (error: SwapError) => void;
10
+ }): void;
@@ -0,0 +1,53 @@
1
+ import { useEffect, useRef } from "react";
2
+ export function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }) {
3
+ const onCompleteRef = useRef(onComplete);
4
+ const onErrorRef = useRef(onError);
5
+ const onBalancesChangedRef = useRef(onBalancesChanged);
6
+ const lastNotification = useRef(undefined);
7
+ useEffect(() => {
8
+ onCompleteRef.current = onComplete;
9
+ onErrorRef.current = onError;
10
+ onBalancesChangedRef.current = onBalancesChanged;
11
+ }, [onBalancesChanged, onComplete, onError]);
12
+ useEffect(() => {
13
+ if (stage.kind === "idle") {
14
+ lastNotification.current = undefined;
15
+ return;
16
+ }
17
+ if (stage.kind === "done") {
18
+ const transactionHash = stage.destinationTxHash ?? stage.sourceTxHash;
19
+ if (!transactionHash)
20
+ return;
21
+ const key = `complete:${stage.orderId}:${transactionHash}`;
22
+ if (lastNotification.current !== key) {
23
+ lastNotification.current = key;
24
+ onBalancesChangedRef.current();
25
+ onCompleteRef.current?.({ orderId: stage.orderId, transactionHash });
26
+ }
27
+ }
28
+ else if (stage.kind === "refunded") {
29
+ const key = `refunded:${stage.orderId}`;
30
+ if (lastNotification.current !== key) {
31
+ lastNotification.current = key;
32
+ onBalancesChangedRef.current();
33
+ }
34
+ }
35
+ else if (stage.kind === "error") {
36
+ const key = `error:${stage.message}`;
37
+ if (lastNotification.current !== key) {
38
+ lastNotification.current = key;
39
+ onErrorRef.current?.({ message: stage.message });
40
+ }
41
+ }
42
+ }, [stage]);
43
+ useEffect(() => {
44
+ const message = quoteError ?? tokenError;
45
+ if (!message)
46
+ return;
47
+ const key = `error:${message}`;
48
+ if (lastNotification.current !== key) {
49
+ lastNotification.current = key;
50
+ onErrorRef.current?.({ message });
51
+ }
52
+ }, [quoteError, tokenError]);
53
+ }
@@ -1,63 +1,61 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { hashKey, useQuery } from "@tanstack/react-query";
2
2
  import { useSwapKit } from "../providers/swapkit-context";
3
3
  const QUOTE_DEBOUNCE_MS = 400;
4
4
  const QUOTE_REFRESH_MS = 10000;
5
+ // Scope cache entries by client identity without exposing API keys or session details.
6
+ const clientScopes = new WeakMap();
7
+ let nextClientScope = 0;
8
+ function getClientScope(api) {
9
+ let scope = clientScopes.get(api);
10
+ if (scope === undefined) {
11
+ scope = nextClientScope++;
12
+ clientScopes.set(api, scope);
13
+ }
14
+ return scope;
15
+ }
16
+ const quoteKey = (scope, request, live) => ["swap-quote", scope, request, live];
17
+ function debounce(signal) {
18
+ return new Promise((resolve, reject) => {
19
+ const timeout = setTimeout(resolve, QUOTE_DEBOUNCE_MS);
20
+ signal.addEventListener("abort", () => {
21
+ clearTimeout(timeout);
22
+ reject(signal.reason);
23
+ }, { once: true });
24
+ });
25
+ }
5
26
  export function useSwapQuote(request, enabled = true) {
6
27
  const { api } = useSwapKit();
7
- const [quote, setQuote] = useState(null);
8
- const [loading, setLoading] = useState(false);
9
- const [error, setError] = useState(null);
10
- const requestId = useRef(0);
11
- useEffect(() => {
12
- const id = ++requestId.current;
13
- if (!request || !enabled) {
14
- setLoading(false);
15
- if (!request) {
16
- setQuote(null);
17
- setError(null);
18
- }
19
- return;
20
- }
21
- setQuote(null);
22
- setError(null);
23
- setLoading(true);
24
- const controller = new AbortController();
25
- let interval;
26
- const load = async (initial) => {
28
+ const live = Boolean(request && enabled);
29
+ const scope = getClientScope(api);
30
+ const { data, isFetchedAfterMount } = useQuery({
31
+ queryKey: quoteKey(scope, request, live),
32
+ enabled: live,
33
+ gcTime: 0,
34
+ retry: false,
35
+ networkMode: "always",
36
+ refetchInterval: QUOTE_REFRESH_MS,
37
+ refetchIntervalInBackground: true,
38
+ refetchOnWindowFocus: false,
39
+ placeholderData: (previous, previousQuery) => !live && previousQuery?.queryHash === hashKey(quoteKey(scope, request, true)) ? previous : undefined,
40
+ queryFn: async ({ signal }) => {
41
+ if (!request)
42
+ return { quote: null, error: null };
43
+ await debounce(signal);
27
44
  try {
28
- const response = await api.swapQuote(request, { signal: controller.signal });
29
- if (requestId.current !== id)
30
- return;
45
+ const response = await api.swapQuote(request, { signal });
31
46
  if (response.error || !response.data?.quote) {
32
- setQuote(null);
33
- setError(response.error?.message || "No swap route is available");
34
- }
35
- else {
36
- setQuote(response.data.quote);
37
- setError(null);
47
+ return { quote: null, error: response.error?.message || "No swap route is available" };
38
48
  }
49
+ return { quote: response.data.quote, error: null };
39
50
  }
40
51
  catch (requestError) {
41
- if (requestId.current !== id || requestError.name === "AbortError")
42
- return;
43
- setQuote(null);
44
- setError("Unable to fetch a swap quote");
45
- }
46
- finally {
47
- if (initial && requestId.current === id)
48
- setLoading(false);
52
+ if (signal.aborted)
53
+ throw requestError;
54
+ return { quote: null, error: "Unable to fetch a swap quote" };
49
55
  }
50
- };
51
- const timeout = setTimeout(() => {
52
- void load(true);
53
- interval = setInterval(() => void load(false), QUOTE_REFRESH_MS);
54
- }, QUOTE_DEBOUNCE_MS);
55
- return () => {
56
- clearTimeout(timeout);
57
- if (interval)
58
- clearInterval(interval);
59
- controller.abort();
60
- };
61
- }, [api, request, enabled]);
62
- return { quote, loading, error };
56
+ },
57
+ });
58
+ // A re-enabled query may still be cached. Only expose it after this activation fetches.
59
+ const visibleData = live && !isFetchedAfterMount ? undefined : data;
60
+ return { quote: visibleData?.quote ?? null, loading: live && !visibleData, error: visibleData?.error ?? null };
63
61
  }