@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.
- package/dist/components/currency-panel.d.ts +17 -0
- package/dist/components/currency-panel.js +22 -0
- package/dist/components/execution-modal.js +28 -5
- package/dist/components/execution-modal.test.d.ts +1 -0
- package/dist/components/execution-modal.test.js +92 -0
- package/dist/components/manual-wallet-modal.d.ts +8 -0
- package/dist/components/manual-wallet-modal.js +19 -0
- package/dist/components/output-wallet-selector.d.ts +7 -0
- package/dist/components/output-wallet-selector.js +10 -0
- package/dist/components/quote-breakdown.d.ts +5 -0
- package/dist/components/quote-breakdown.js +13 -0
- package/dist/components/styles.d.ts +1 -0
- package/dist/components/styles.js +14 -2
- package/dist/components/swap-recipient.test.js +29 -1
- package/dist/components/swap-settings.d.ts +9 -0
- package/dist/components/swap-settings.js +24 -0
- package/dist/components/swap.d.ts +2 -28
- package/dist/components/swap.js +27 -312
- package/dist/components/token-picker.test.js +11 -0
- package/dist/hooks/use-dismiss.d.ts +3 -0
- package/dist/hooks/use-dismiss.js +26 -0
- package/dist/hooks/use-swap-notifications.d.ts +10 -0
- package/dist/hooks/use-swap-notifications.js +53 -0
- package/dist/hooks/use-swap-quote.js +49 -51
- package/dist/hooks/use-swap.d.ts +47 -0
- package/dist/hooks/use-swap.js +189 -0
- package/dist/hooks/use-token-data.js +3 -3
- package/dist/hooks/use-token-data.test.js +33 -3
- package/dist/lib/currency-selection.js +6 -4
- package/dist/lib/currency-selection.test.d.ts +1 -0
- package/dist/lib/currency-selection.test.js +16 -0
- package/dist/lib/format.d.ts +5 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/quote.d.ts +10 -1
- package/dist/lib/quote.js +12 -0
- package/dist/lib/slippage.d.ts +11 -0
- package/dist/lib/slippage.js +17 -0
- package/package.json +3 -3
package/dist/components/swap.js
CHANGED
|
@@ -1,330 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
|
181
|
-
const
|
|
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 [
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 }),
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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,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 {
|
|
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
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
29
|
-
if (requestId.current !== id)
|
|
30
|
-
return;
|
|
45
|
+
const response = await api.swapQuote(request, { signal });
|
|
31
46
|
if (response.error || !response.data?.quote) {
|
|
32
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|