@coin-voyage/swapkit 0.1.0-beta.0 → 0.1.0-beta.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.
- package/dist/components/connected-wallets.d.ts +1 -0
- package/dist/components/connected-wallets.js +66 -0
- package/dist/components/execution-modal.d.ts +5 -0
- package/dist/components/execution-modal.js +79 -0
- package/dist/components/icons.d.ts +23 -0
- package/dist/components/icons.js +49 -0
- package/dist/components/modal.d.ts +11 -0
- package/dist/components/modal.js +40 -0
- package/dist/components/styles.d.ts +124 -0
- package/dist/components/styles.js +1239 -0
- package/dist/components/swap.d.ts +2 -0
- package/dist/components/swap.js +295 -0
- package/dist/components/token-icon.d.ts +5 -0
- package/dist/components/token-icon.js +9 -0
- package/dist/components/token-picker.d.ts +11 -0
- package/dist/components/token-picker.js +69 -0
- package/dist/components/wallet-connector.d.ts +8 -0
- package/dist/components/wallet-connector.js +33 -0
- package/dist/components/wallet-icon.d.ts +5 -0
- package/dist/components/wallet-icon.js +6 -0
- package/dist/hooks/use-order-status.d.ts +9 -0
- package/dist/hooks/use-order-status.js +36 -0
- package/dist/hooks/use-swap-execution.d.ts +7 -0
- package/dist/hooks/use-swap-execution.js +105 -0
- package/dist/hooks/use-swap-quote.d.ts +6 -0
- package/dist/hooks/use-swap-quote.js +63 -0
- package/dist/hooks/use-swap-state.d.ts +70 -0
- package/dist/hooks/use-swap-state.js +147 -0
- package/dist/hooks/use-token-data.d.ts +12 -0
- package/dist/hooks/use-token-data.js +80 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/lib/amount-input.d.ts +3 -0
- package/dist/lib/amount-input.js +11 -0
- package/dist/lib/currency-selection.d.ts +13 -0
- package/dist/lib/currency-selection.js +26 -0
- package/dist/lib/execution.d.ts +38 -0
- package/dist/lib/execution.js +26 -0
- package/dist/lib/output-wallet.d.ts +24 -0
- package/dist/lib/output-wallet.js +24 -0
- package/dist/lib/quote.d.ts +13 -0
- package/dist/lib/quote.js +24 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +2 -0
- package/dist/providers/swapkit-context.d.ts +10 -0
- package/dist/providers/swapkit-context.js +8 -0
- package/dist/providers/swapkit-provider.d.ts +2 -0
- package/dist/providers/swapkit-provider.js +18 -0
- package/dist/styles/root.d.ts +5 -0
- package/dist/styles/root.js +75 -0
- package/dist/styles/styled.d.ts +2 -0
- package/dist/styles/styled.js +3 -0
- package/dist/styles/theme.d.ts +3 -0
- package/dist/styles/theme.js +36 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,295 @@
|
|
|
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 { currencyID } from "@coin-voyage/shared/types";
|
|
5
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { useSwapExecution } from "../hooks/use-swap-execution";
|
|
7
|
+
import { useSwapQuote } from "../hooks/use-swap-quote";
|
|
8
|
+
import { useSwapState } from "../hooks/use-swap-state";
|
|
9
|
+
import { useTokenData } from "../hooks/use-token-data";
|
|
10
|
+
import { isValidSwapAmountInput } from "../lib/amount-input";
|
|
11
|
+
import { getSwapWalletRequirement, shouldShowOutputWalletSelector, shouldUseSeparateOutputWallet, } from "../lib/output-wallet";
|
|
12
|
+
import { buildSwapQuoteRequest, isSameSwap, toCurrencyBase } from "../lib/quote";
|
|
13
|
+
import { useSwapKit } from "../providers/swapkit-context";
|
|
14
|
+
import { SwapKitRoot } from "../styles/root";
|
|
15
|
+
import { ExecutionModal } from "./execution-modal";
|
|
16
|
+
import { ConnectedWallets } from "./connected-wallets";
|
|
17
|
+
import { ArrowDownIcon, ChevronDownIcon, FuelIcon, InfoIcon, PasteIcon, SettingsIcon, WalletIcon } from "./icons";
|
|
18
|
+
import { Modal } from "./modal";
|
|
19
|
+
import { AmountInput, AmountRow, AmountSkeleton, BreakdownBody, BreakdownCard, BreakdownMeta, BreakdownToggle, CardFooter, CardTitle, CardWrap, CompactInput, CurrencyCard, DetailRow, ErrorBox, FieldLabel, FormStack, FreeFeeBadge, Hint, IconButton, InlineActions, InfoButton, InfoWrap, NetworkCost, OutputWalletControl, OutputWalletMenu, OutputWalletOption, OutputWalletTrigger, PrimaryButton, QuoteValueSkeleton, SettingsLabel, SettingsPanel, SettingsRow, SettingsTooltip, SlippageControl, SlippageInput, SlippageInputWrap, SlippageModeButton, Spinner, SwitchButton, TextButton, TokenButton, Toolbar, ToolbarGroup, WalletRequirementButton, } from "./styles";
|
|
20
|
+
import { TokenIcon } from "./token-icon";
|
|
21
|
+
import { TokenPicker } from "./token-picker";
|
|
22
|
+
import { WalletConnector } from "./wallet-connector";
|
|
23
|
+
import { truncateAddress } from "@coin-voyage/shared/utils";
|
|
24
|
+
function DismissibleToolbarGroup({ open, onDismiss, pushRight = false, children, }) {
|
|
25
|
+
const groupRef = useRef(null);
|
|
26
|
+
const onDismissRef = useRef(onDismiss);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
onDismissRef.current = onDismiss;
|
|
29
|
+
}, [onDismiss]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open)
|
|
32
|
+
return;
|
|
33
|
+
const onPointerDown = (event) => {
|
|
34
|
+
if (!groupRef.current?.contains(event.target))
|
|
35
|
+
onDismissRef.current();
|
|
36
|
+
};
|
|
37
|
+
const onKeyDown = (event) => {
|
|
38
|
+
if (event.key === "Escape")
|
|
39
|
+
onDismissRef.current();
|
|
40
|
+
};
|
|
41
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
42
|
+
document.addEventListener("keydown", onKeyDown);
|
|
43
|
+
return () => {
|
|
44
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
45
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
46
|
+
};
|
|
47
|
+
}, [open]);
|
|
48
|
+
return (_jsx(ToolbarGroup, { ref: groupRef, "$pushRight": pushRight, children: children }));
|
|
49
|
+
}
|
|
50
|
+
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.";
|
|
51
|
+
function SlippageInfoTooltip({ id }) {
|
|
52
|
+
const [hovered, setHovered] = useState(false);
|
|
53
|
+
const [focused, setFocused] = useState(false);
|
|
54
|
+
const open = hovered || focused;
|
|
55
|
+
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 }))] }));
|
|
56
|
+
}
|
|
57
|
+
function useSwapNotifications({ stage, quoteError, tokenError, onComplete, onError, }) {
|
|
58
|
+
const onCompleteRef = useRef(onComplete);
|
|
59
|
+
const onErrorRef = useRef(onError);
|
|
60
|
+
const lastNotification = useRef(undefined);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
onCompleteRef.current = onComplete;
|
|
63
|
+
onErrorRef.current = onError;
|
|
64
|
+
}, [onComplete, onError]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (stage.kind === "idle") {
|
|
67
|
+
lastNotification.current = undefined;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (stage.kind === "done") {
|
|
71
|
+
const key = `complete:${stage.orderId}:${stage.txHash}`;
|
|
72
|
+
if (lastNotification.current !== key) {
|
|
73
|
+
lastNotification.current = key;
|
|
74
|
+
onCompleteRef.current?.({ orderId: stage.orderId, transactionHash: stage.txHash });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (stage.kind === "error") {
|
|
78
|
+
const key = `error:${stage.message}`;
|
|
79
|
+
if (lastNotification.current !== key) {
|
|
80
|
+
lastNotification.current = key;
|
|
81
|
+
onErrorRef.current?.({ message: stage.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}, [stage]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const message = quoteError ?? tokenError;
|
|
87
|
+
if (!message)
|
|
88
|
+
return;
|
|
89
|
+
const key = `error:${message}`;
|
|
90
|
+
if (lastNotification.current !== key) {
|
|
91
|
+
lastNotification.current = key;
|
|
92
|
+
onErrorRef.current?.({ message });
|
|
93
|
+
}
|
|
94
|
+
}, [quoteError, tokenError]);
|
|
95
|
+
}
|
|
96
|
+
function OutputWalletSelector({ label, open, onOpenChange, onConnect, onPaste, }) {
|
|
97
|
+
const controlRef = useRef(null);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!open)
|
|
100
|
+
return;
|
|
101
|
+
const dismiss = (event) => {
|
|
102
|
+
if (!controlRef.current?.contains(event.target))
|
|
103
|
+
onOpenChange(false);
|
|
104
|
+
};
|
|
105
|
+
const dismissOnEscape = (event) => {
|
|
106
|
+
if (event.key === "Escape")
|
|
107
|
+
onOpenChange(false);
|
|
108
|
+
};
|
|
109
|
+
document.addEventListener("pointerdown", dismiss);
|
|
110
|
+
document.addEventListener("keydown", dismissOnEscape);
|
|
111
|
+
return () => {
|
|
112
|
+
document.removeEventListener("pointerdown", dismiss);
|
|
113
|
+
document.removeEventListener("keydown", dismissOnEscape);
|
|
114
|
+
};
|
|
115
|
+
}, [onOpenChange, open]);
|
|
116
|
+
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: [_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, {})] })] }))] }));
|
|
117
|
+
}
|
|
118
|
+
function formatUsd(value) {
|
|
119
|
+
if (value === undefined || value === null || !Number.isFinite(value))
|
|
120
|
+
return "$0.00";
|
|
121
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(value);
|
|
122
|
+
}
|
|
123
|
+
function formatPriceImpact(value) {
|
|
124
|
+
if (value > 0 && value < 0.01)
|
|
125
|
+
return "<0.01%";
|
|
126
|
+
return `${value.toFixed(2)}%`;
|
|
127
|
+
}
|
|
128
|
+
function tokenUsdValue(token, amount) {
|
|
129
|
+
const numericAmount = Number(amount);
|
|
130
|
+
if (!token?.currency.price_usd || !Number.isFinite(numericAmount))
|
|
131
|
+
return undefined;
|
|
132
|
+
return token.currency.price_usd * numericAmount;
|
|
133
|
+
}
|
|
134
|
+
function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, onMax, footerAction, }) {
|
|
135
|
+
const label = side === "input" ? "SELL" : "BUY";
|
|
136
|
+
return (_jsxs(CurrencyCard, { children: [_jsx(CardTitle, { children: label }), _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) => {
|
|
137
|
+
if (isValidSwapAmountInput(event.target.value))
|
|
138
|
+
onAmountChange(event.target.value);
|
|
139
|
+
} })), _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)) })), _jsxs(InlineActions, { children: [balance !== undefined && _jsxs("span", { children: ["Balance: ", balance.toLocaleString()] }), onMax && token && (_jsx(TextButton, { type: "button", "aria-label": `Use maximum ${token.currency.symbol} balance`, onClick: onMax, children: "Max" })), footerAction] })] })] }));
|
|
140
|
+
}
|
|
141
|
+
function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
|
|
142
|
+
const [value, setValue] = useState(initialValue ?? "");
|
|
143
|
+
const [error, setError] = useState(null);
|
|
144
|
+
const outputAddressId = useId();
|
|
145
|
+
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", onClick: () => {
|
|
146
|
+
if (!chainId || !isValidAddress(value, chainId)) {
|
|
147
|
+
setError("Enter a valid address for the destination network");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
setError(null);
|
|
151
|
+
onSave(value);
|
|
152
|
+
onClose();
|
|
153
|
+
}, children: "Use address" })] }));
|
|
154
|
+
}
|
|
155
|
+
export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, } = {}) {
|
|
156
|
+
const { mode, theme } = useSwapKit();
|
|
157
|
+
const slippageInputId = useId();
|
|
158
|
+
const slippageTooltipId = useId();
|
|
159
|
+
const { tokens, accounts, isLoading, error: tokenError } = useTokenData();
|
|
160
|
+
const { accounts: connectedAccounts } = useAccount();
|
|
161
|
+
const [sourcePickerOpen, setSourcePickerOpen] = useState(false);
|
|
162
|
+
const [destinationPickerOpen, setDestinationPickerOpen] = useState(false);
|
|
163
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
164
|
+
const [connectTarget, setConnectTarget] = useState({
|
|
165
|
+
open: false,
|
|
166
|
+
});
|
|
167
|
+
const [manualWalletOpen, setManualWalletOpen] = useState(false);
|
|
168
|
+
const [outputWalletMenuOpen, setOutputWalletMenuOpen] = useState(false);
|
|
169
|
+
const [breakdownOpen, setBreakdownOpen] = useState(false);
|
|
170
|
+
const connectedChainTypes = new Set(accounts.map((account) => account.chainType));
|
|
171
|
+
const swapState = useSwapState({
|
|
172
|
+
currencies: tokens.map((token) => token.currency),
|
|
173
|
+
connectedChainTypes,
|
|
174
|
+
defaultSourceCurrency,
|
|
175
|
+
defaultDestinationCurrency,
|
|
176
|
+
defaultSlippageBps,
|
|
177
|
+
});
|
|
178
|
+
const { sourceId: resolvedSourceId, destinationId: resolvedDestinationId, inputAmount, outputAmount, activeSide, slippageMode, slippagePercent, manualOutputWallet, } = swapState.state;
|
|
179
|
+
const sourceToken = tokens.find((token) => currencyID(token.currency) === resolvedSourceId);
|
|
180
|
+
const destinationToken = tokens.find((token) => currencyID(token.currency) === resolvedDestinationId);
|
|
181
|
+
const sourceChainType = sourceToken ? getChainTypeByChainId(sourceToken.currency.chain_id) : undefined;
|
|
182
|
+
const destinationChainType = destinationToken ? getChainTypeByChainId(destinationToken.currency.chain_id) : undefined;
|
|
183
|
+
const inputAccount = connectedAccounts.find((account) => account.chainType === sourceChainType);
|
|
184
|
+
const outputAccount = connectedAccounts.find((account) => account.chainType === destinationChainType);
|
|
185
|
+
const separateOutputWallet = shouldUseSeparateOutputWallet(sourceChainType, destinationChainType);
|
|
186
|
+
const manualAddress = manualOutputWallet && manualOutputWallet.chainId === destinationToken?.currency.chain_id
|
|
187
|
+
? manualOutputWallet.address
|
|
188
|
+
: undefined;
|
|
189
|
+
const outputAddress = separateOutputWallet ? (manualAddress ?? outputAccount?.address) : inputAccount?.address;
|
|
190
|
+
const walletRequirement = getSwapWalletRequirement({
|
|
191
|
+
inputWalletAddress: inputAccount?.address,
|
|
192
|
+
inputChainType: sourceChainType,
|
|
193
|
+
shouldRestrictInputChainType: Boolean(sourceToken),
|
|
194
|
+
outputWalletAddress: outputAddress,
|
|
195
|
+
outputChainType: destinationChainType,
|
|
196
|
+
});
|
|
197
|
+
const showOutputWalletSelector = shouldShowOutputWalletSelector({
|
|
198
|
+
requiresSeparateOutputWallet: separateOutputWallet,
|
|
199
|
+
outputChainType: destinationChainType,
|
|
200
|
+
connectionRequirement: walletRequirement,
|
|
201
|
+
});
|
|
202
|
+
const slippage = Number(slippagePercent);
|
|
203
|
+
const invalidSlippage = slippageMode === "custom" &&
|
|
204
|
+
(slippagePercent.length === 0 || !Number.isFinite(slippage) || slippage < 0 || slippage > 100);
|
|
205
|
+
const slippageBps = slippageMode === "custom" && !invalidSlippage ? Math.round(slippage * 100) : undefined;
|
|
206
|
+
const activeAmount = activeSide === "input" ? inputAmount : outputAmount;
|
|
207
|
+
const quoteRequest = useMemo(() => invalidSlippage ||
|
|
208
|
+
isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress)
|
|
209
|
+
? null
|
|
210
|
+
: buildSwapQuoteRequest({
|
|
211
|
+
amount: activeAmount,
|
|
212
|
+
activeSide,
|
|
213
|
+
source: sourceToken?.currency,
|
|
214
|
+
destination: destinationToken?.currency,
|
|
215
|
+
slippageBps,
|
|
216
|
+
}), [
|
|
217
|
+
activeAmount,
|
|
218
|
+
activeSide,
|
|
219
|
+
destinationToken?.currency,
|
|
220
|
+
inputAccount?.address,
|
|
221
|
+
invalidSlippage,
|
|
222
|
+
outputAddress,
|
|
223
|
+
slippageBps,
|
|
224
|
+
sourceToken?.currency,
|
|
225
|
+
]);
|
|
226
|
+
const execution = useSwapExecution(sourceChainType);
|
|
227
|
+
const quoteState = useSwapQuote(quoteRequest, execution.stage.kind === "idle");
|
|
228
|
+
useSwapNotifications({ stage: execution.stage, quoteError: quoteState.error, tokenError, onComplete, onError });
|
|
229
|
+
const displayedInputAmount = activeSide === "output" && quoteState.quote ? quoteState.quote.input.amount.ui : inputAmount;
|
|
230
|
+
const displayedOutputAmount = activeSide === "input" && quoteState.quote ? quoteState.quote.output.amount.ui : outputAmount;
|
|
231
|
+
const sourceBalance = sourceToken?.balance;
|
|
232
|
+
const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > sourceBalance;
|
|
233
|
+
const sameSwap = isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress);
|
|
234
|
+
const canExecute = Boolean(quoteState.quote &&
|
|
235
|
+
quoteRequest &&
|
|
236
|
+
inputAccount?.address &&
|
|
237
|
+
outputAddress &&
|
|
238
|
+
walletRequirement.target === "none" &&
|
|
239
|
+
!insufficientBalance &&
|
|
240
|
+
!sameSwap &&
|
|
241
|
+
!invalidSlippage);
|
|
242
|
+
const reverseTokens = useCallback(() => {
|
|
243
|
+
swapState.reverse(displayedInputAmount, displayedOutputAmount);
|
|
244
|
+
setOutputWalletMenuOpen(false);
|
|
245
|
+
}, [displayedInputAmount, displayedOutputAmount, swapState]);
|
|
246
|
+
const executeSwap = useCallback(async () => {
|
|
247
|
+
if (!quoteRequest || !sourceToken || !destinationToken || !inputAccount?.address || !outputAddress)
|
|
248
|
+
return;
|
|
249
|
+
const request = {
|
|
250
|
+
trade_type: quoteRequest.trade_type,
|
|
251
|
+
amount: quoteRequest.amount,
|
|
252
|
+
source: {
|
|
253
|
+
currency: toCurrencyBase(sourceToken.currency),
|
|
254
|
+
sender_address: inputAccount.address,
|
|
255
|
+
...(slippageBps === undefined ? {} : { slippage_bps: slippageBps }),
|
|
256
|
+
},
|
|
257
|
+
destination: { currency: toCurrencyBase(destinationToken.currency), recipient: outputAddress },
|
|
258
|
+
};
|
|
259
|
+
await execution.execute(request, inputAccount.address);
|
|
260
|
+
}, [destinationToken, execution, inputAccount, outputAddress, quoteRequest, slippageBps, sourceToken]);
|
|
261
|
+
const selectToken = (side, token) => {
|
|
262
|
+
swapState.selectCurrency(side, currencyID(token.currency));
|
|
263
|
+
};
|
|
264
|
+
const quoteSummary = quoteState.quote
|
|
265
|
+
? `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}`
|
|
266
|
+
: "Swap details";
|
|
267
|
+
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) => {
|
|
268
|
+
if (isValidSwapAmountInput(event.target.value)) {
|
|
269
|
+
swapState.setSlippagePercent(event.target.value);
|
|
270
|
+
}
|
|
271
|
+
} }), _jsx("span", { children: "%" })] })] })] }), slippageMode === "custom" &&
|
|
272
|
+
(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) => {
|
|
273
|
+
swapState.setAmount("input", value);
|
|
274
|
+
}, onSelectToken: () => setSourcePickerOpen(true), onMax: sourceBalance === undefined
|
|
275
|
+
? undefined
|
|
276
|
+
: () => {
|
|
277
|
+
swapState.setAmount("input", sourceBalance.toString());
|
|
278
|
+
} }), _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, quoteUsd: quoteState.quote?.output.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "input", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
|
|
279
|
+
swapState.setAmount("output", value);
|
|
280
|
+
}, onSelectToken: () => setDestinationPickerOpen(true), footerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: () => {
|
|
281
|
+
setOutputWalletMenuOpen(false);
|
|
282
|
+
setConnectTarget({ open: true, chainType: destinationChainType });
|
|
283
|
+
}, onPaste: () => {
|
|
284
|
+
setOutputWalletMenuOpen(false);
|
|
285
|
+
setManualWalletOpen(true);
|
|
286
|
+
} })) : 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, 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) => {
|
|
287
|
+
if (destinationToken)
|
|
288
|
+
swapState.setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
|
|
289
|
+
} })), _jsx(ExecutionModal, { stage: execution.stage, onClose: () => {
|
|
290
|
+
const terminal = ["done", "refunded"].includes(execution.stage.kind);
|
|
291
|
+
execution.reset();
|
|
292
|
+
if (terminal)
|
|
293
|
+
swapState.reset();
|
|
294
|
+
} })] }) }));
|
|
295
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChainId } from "@coin-voyage/shared/types";
|
|
3
|
+
import { getChainLogo, getChainName } from "@coin-voyage/shared/chain";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { ChainBadge, TokenFallback, TokenIconWrap, TokenImage } from "./styles";
|
|
6
|
+
export function TokenIcon({ currency, size = 24 }) {
|
|
7
|
+
const [failed, setFailed] = useState(false);
|
|
8
|
+
return (_jsxs(TokenIconWrap, { "$size": size, children: [!currency.image_uri || failed ? (_jsx(TokenFallback, { "aria-hidden": true, children: currency.symbol.slice(0, 3).toUpperCase() })) : (_jsx(TokenImage, { src: currency.image_uri, alt: "", onError: () => setFailed(true) })), _jsx(ChainBadge, { "$size": size >= 40 ? 16 : 12, "$fillBackground": currency.chain_id === ChainId.SUI, role: "img", "aria-label": `${getChainName(currency.chain_id)} network`, children: getChainLogo(currency.chain_id) })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SwapToken } from "../hooks/use-token-data";
|
|
2
|
+
interface TokenPickerProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
title: string;
|
|
5
|
+
tokens: SwapToken[];
|
|
6
|
+
selectedId?: string;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onSelect: (token: SwapToken) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect }: TokenPickerProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getChainLogo, getChainName } from "@coin-voyage/shared/chain";
|
|
3
|
+
import { ChainId, currencyID } from "@coin-voyage/shared/types";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { ChevronDownIcon, SearchIcon } from "./icons";
|
|
6
|
+
import { AllNetworksLogo, EmptyState, NetworkButton, NetworkLogo, NetworkMenu, NetworkOption, PickerBody, PickerControls, SearchInput, SearchField, TokenBalance, TokenList, TokenMeta, TokenOption, } from "./styles";
|
|
7
|
+
import { Modal } from "./modal";
|
|
8
|
+
import { TokenIcon } from "./token-icon";
|
|
9
|
+
function formatUsd(value) {
|
|
10
|
+
if (value === undefined || !Number.isFinite(value))
|
|
11
|
+
return "";
|
|
12
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(value);
|
|
13
|
+
}
|
|
14
|
+
function tokenDescription(token) {
|
|
15
|
+
const address = token.currency.address;
|
|
16
|
+
if (!address)
|
|
17
|
+
return token.currency.symbol;
|
|
18
|
+
const compactAddress = address.length > 14 ? `${address.slice(0, 6)}…${address.slice(-5)}` : address;
|
|
19
|
+
return `${token.currency.symbol} | ${compactAddress}`;
|
|
20
|
+
}
|
|
21
|
+
function AllNetworksMark() {
|
|
22
|
+
return (_jsxs(AllNetworksLogo, { role: "img", "aria-label": "All networks", children: [_jsx("span", { children: getChainLogo(ChainId.BTC) }), _jsx("span", { children: getChainLogo(ChainId.SUI) }), _jsx("span", { children: getChainLogo(ChainId.ROBINHOOD) })] }));
|
|
23
|
+
}
|
|
24
|
+
export function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect }) {
|
|
25
|
+
const [query, setQuery] = useState("");
|
|
26
|
+
const [selectedChainId, setSelectedChainId] = useState();
|
|
27
|
+
const [networkMenuOpen, setNetworkMenuOpen] = useState(false);
|
|
28
|
+
const networkButtonRef = useRef(null);
|
|
29
|
+
const networkMenuRef = useRef(null);
|
|
30
|
+
const networks = useMemo(() => [...new Set(tokens.map(({ currency }) => currency.chain_id))].sort((left, right) => (getChainName(left) ?? "").localeCompare(getChainName(right) ?? "")), [tokens]);
|
|
31
|
+
const filtered = useMemo(() => {
|
|
32
|
+
const onSelectedNetwork = selectedChainId
|
|
33
|
+
? tokens.filter(({ currency }) => currency.chain_id === selectedChainId)
|
|
34
|
+
: tokens;
|
|
35
|
+
const normalized = query.trim().toLowerCase();
|
|
36
|
+
if (!normalized)
|
|
37
|
+
return onSelectedNetwork;
|
|
38
|
+
return onSelectedNetwork.filter(({ currency }) => [currency.symbol, currency.name, getChainName(currency.chain_id), currency.address]
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.some((value) => value.toLowerCase().includes(normalized)));
|
|
41
|
+
}, [query, selectedChainId, tokens]);
|
|
42
|
+
const selectedNetworkName = selectedChainId ? (getChainName(selectedChainId) ?? "Unknown Network") : "All Networks";
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!networkMenuOpen)
|
|
45
|
+
return;
|
|
46
|
+
const closeOnOutsideClick = (event) => {
|
|
47
|
+
const target = event.target;
|
|
48
|
+
if (!networkButtonRef.current?.contains(target) && !networkMenuRef.current?.contains(target)) {
|
|
49
|
+
setNetworkMenuOpen(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener("mousedown", closeOnOutsideClick);
|
|
53
|
+
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
|
|
54
|
+
}, [networkMenuOpen]);
|
|
55
|
+
return (_jsx(Modal, { open: open, title: title, onClose: onClose, children: _jsxs(PickerBody, { children: [_jsxs(PickerControls, { children: [_jsxs(SearchField, { children: [_jsx(SearchIcon, {}), _jsx(SearchInput, { type: "search", value: query, placeholder: "Search", "aria-label": "Search tokens", onChange: (event) => setQuery(event.target.value) })] }), _jsxs(NetworkButton, { ref: networkButtonRef, type: "button", "aria-label": selectedNetworkName, "aria-expanded": networkMenuOpen, onClick: () => setNetworkMenuOpen((current) => !current), children: [selectedChainId ? (_jsx(NetworkLogo, { "aria-hidden": true, children: getChainLogo(selectedChainId) })) : (_jsx(AllNetworksMark, {})), _jsx(ChevronDownIcon, { size: 16, up: networkMenuOpen })] })] }), networkMenuOpen && (_jsxs(NetworkMenu, { ref: networkMenuRef, role: "listbox", "aria-label": "Networks", children: [_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": selectedChainId === undefined, onClick: () => {
|
|
56
|
+
setSelectedChainId(undefined);
|
|
57
|
+
setNetworkMenuOpen(false);
|
|
58
|
+
}, children: [_jsx(AllNetworksMark, {}), "All Networks"] }), networks.map((chainId) => (_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": chainId === selectedChainId, onClick: () => {
|
|
59
|
+
setSelectedChainId(chainId);
|
|
60
|
+
setNetworkMenuOpen(false);
|
|
61
|
+
}, children: [_jsx(NetworkLogo, { "aria-hidden": true, children: getChainLogo(chainId) }), getChainName(chainId)] }, chainId)))] })), _jsxs(TokenList, { role: "listbox", "aria-label": title, children: [filtered.map((token) => {
|
|
62
|
+
const id = currencyID(token.currency);
|
|
63
|
+
return (_jsxs(TokenOption, { type: "button", role: "option", "aria-label": `${token.currency.symbol} ${token.currency.name}`, "aria-selected": id === selectedId, onClick: () => {
|
|
64
|
+
onSelect(token);
|
|
65
|
+
setQuery("");
|
|
66
|
+
onClose();
|
|
67
|
+
}, children: [_jsx(TokenIcon, { currency: token.currency, size: 40 }), _jsxs(TokenMeta, { children: [_jsx("strong", { children: token.currency.name }), _jsx("small", { children: tokenDescription(token) })] }), _jsxs(TokenBalance, { children: [_jsx("strong", { children: formatUsd(token.balanceUsd) }), _jsx("small", { children: token.balance === undefined ? "" : token.balance.toLocaleString() })] })] }, id));
|
|
68
|
+
}), filtered.length === 0 && _jsx(EmptyState, { children: "No matching tokens" })] })] }) }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ChainType } from "@coin-voyage/shared/types";
|
|
2
|
+
interface WalletConnectorProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
chainType?: ChainType;
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function WalletConnector({ open, chainType, onClose }: WalletConnectorProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useInstalledWallets, useUniversalConnect } from "@coin-voyage/crypto/hooks";
|
|
3
|
+
import { getChainTypeName } from "@coin-voyage/shared/chain";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta } from "./styles";
|
|
6
|
+
import { Modal } from "./modal";
|
|
7
|
+
import { WalletIconDisplay } from "./wallet-icon";
|
|
8
|
+
export function WalletConnector({ open, chainType, onClose }) {
|
|
9
|
+
const installedWallets = useInstalledWallets();
|
|
10
|
+
const wallets = useMemo(() => chainType
|
|
11
|
+
? installedWallets.filter((wallet) => wallet.connectors.some((connector) => connector.chainType === chainType))
|
|
12
|
+
: installedWallets, [chainType, installedWallets]);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const { connect } = useUniversalConnect({
|
|
15
|
+
onSuccess: onClose,
|
|
16
|
+
onError: (connectError) => {
|
|
17
|
+
const message = connectError instanceof Error ? connectError.message : "Unable to connect wallet";
|
|
18
|
+
if (!message.toLowerCase().includes("rejected"))
|
|
19
|
+
setError(message);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return (_jsxs(Modal, { open: open, title: "Connect wallet", onClose: onClose, children: [_jsx(WalletList, { children: wallets.map((wallet) => {
|
|
23
|
+
const connector = wallet.connectors.find((candidate) => !chainType || candidate.chainType === chainType);
|
|
24
|
+
if (!connector)
|
|
25
|
+
return null;
|
|
26
|
+
const chainTypes = [...new Set(wallet.connectors.map((candidate) => candidate.chainType))];
|
|
27
|
+
const capabilityLabel = chainTypes.map((candidate) => getChainTypeName(candidate)).join(" · ");
|
|
28
|
+
return (_jsxs(WalletOption, { type: "button", "aria-label": `Connect ${wallet.name} wallet`, onClick: () => {
|
|
29
|
+
setError(null);
|
|
30
|
+
void connect({ walletConnector: connector });
|
|
31
|
+
}, children: [_jsx(WalletIconDisplay, { icon: wallet.icon, name: wallet.name }), _jsxs(WalletOptionMeta, { children: [_jsxs("span", { children: [_jsx("strong", { children: wallet.name }), chainTypes.length > 1 && _jsx(MultichainBadge, { children: "Multichain" })] }), _jsx("small", { children: chainTypes.length > 1 ? capabilityLabel : `${capabilityLabel} wallet` })] })] }, wallet.id));
|
|
32
|
+
}) }), wallets.length === 0 && _jsx(EmptyState, { children: "No compatible installed wallets found" }), error && _jsx(Hint, { "$tone": "danger", children: error })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { WalletIcon } from "./icons";
|
|
3
|
+
import { WalletFallback, WalletIconFrame, WalletImage } from "./styles";
|
|
4
|
+
export function WalletIconDisplay({ icon, name, size = 32 }) {
|
|
5
|
+
return (_jsx(WalletIconFrame, { "$size": size, children: icon ? (_jsx(WalletImage, { src: icon, alt: `${name} wallet` })) : name ? (_jsx(WalletFallback, { "aria-hidden": true, children: name.slice(0, 2).toUpperCase() })) : (_jsx(WalletIcon, { size: Math.max(16, size - 8) })) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ApiClient } from "@coin-voyage/shared/api";
|
|
2
|
+
import type { WebhookEventPayload } from "@coin-voyage/shared/types";
|
|
3
|
+
interface UseOrderStatusProps {
|
|
4
|
+
api: ApiClient;
|
|
5
|
+
orderId?: string;
|
|
6
|
+
onEvent: (event: WebhookEventPayload) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function useOrderStatus({ api, orderId, onEvent }: UseOrderStatusProps): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
export function useOrderStatus({ api, orderId, onEvent }) {
|
|
3
|
+
const onEventRef = useRef(onEvent);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
onEventRef.current = onEvent;
|
|
6
|
+
}, [onEvent]);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!orderId || typeof WebSocket === "undefined")
|
|
9
|
+
return;
|
|
10
|
+
let active = true;
|
|
11
|
+
let socket;
|
|
12
|
+
let reconnectTimer;
|
|
13
|
+
const connect = () => {
|
|
14
|
+
if (!active)
|
|
15
|
+
return;
|
|
16
|
+
socket = api.subscribeOrderStatus();
|
|
17
|
+
socket.onOpen(() => socket?.subscribe(orderId));
|
|
18
|
+
socket.onMessage((message) => {
|
|
19
|
+
if (message.type === "event")
|
|
20
|
+
onEventRef.current(message.data);
|
|
21
|
+
});
|
|
22
|
+
socket.onClose(() => {
|
|
23
|
+
if (active)
|
|
24
|
+
reconnectTimer = setTimeout(connect, 2000);
|
|
25
|
+
});
|
|
26
|
+
socket.onError(() => socket?.close());
|
|
27
|
+
};
|
|
28
|
+
connect();
|
|
29
|
+
return () => {
|
|
30
|
+
active = false;
|
|
31
|
+
if (reconnectTimer)
|
|
32
|
+
clearTimeout(reconnectTimer);
|
|
33
|
+
socket?.close();
|
|
34
|
+
};
|
|
35
|
+
}, [api, orderId]);
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChainType, SwapExecuteRequest } from "@coin-voyage/shared/types";
|
|
2
|
+
import { type SwapStage } from "../lib/execution";
|
|
3
|
+
export declare function useSwapExecution(chainType: ChainType | undefined): {
|
|
4
|
+
stage: SwapStage;
|
|
5
|
+
execute: (request: SwapExecuteRequest, fromAddress: string) => Promise<void>;
|
|
6
|
+
reset: () => void;
|
|
7
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { usePrepareTransaction } from "@coin-voyage/crypto/hooks";
|
|
2
|
+
import { WebhookEventType } from "@coin-voyage/shared/types";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { buildSwapExecuteTransactions } from "../lib/execution";
|
|
5
|
+
import { useSwapKit } from "../providers/swapkit-context";
|
|
6
|
+
import { useOrderStatus } from "./use-order-status";
|
|
7
|
+
function errorMessage(error, fallback) {
|
|
8
|
+
const message = error instanceof Error ? error.message.trim() : "";
|
|
9
|
+
return message || fallback;
|
|
10
|
+
}
|
|
11
|
+
function isRejected(error) {
|
|
12
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
13
|
+
return message.includes("user rejected") || message.includes("rejected the request");
|
|
14
|
+
}
|
|
15
|
+
export function useSwapExecution(chainType) {
|
|
16
|
+
const { api } = useSwapKit();
|
|
17
|
+
const transactionActions = usePrepareTransaction(chainType);
|
|
18
|
+
const [stage, setStage] = useState({ kind: "idle" });
|
|
19
|
+
const [orderId, setOrderId] = useState();
|
|
20
|
+
const stageRef = useRef(stage);
|
|
21
|
+
const runId = useRef(0);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
stageRef.current = stage;
|
|
24
|
+
}, [stage]);
|
|
25
|
+
const reset = useCallback(() => {
|
|
26
|
+
runId.current += 1;
|
|
27
|
+
setOrderId(undefined);
|
|
28
|
+
setStage({ kind: "idle" });
|
|
29
|
+
}, []);
|
|
30
|
+
const onOrderEvent = useCallback((event) => {
|
|
31
|
+
const current = stageRef.current;
|
|
32
|
+
if (!("swapData" in current) || !current.swapData || event.order.id !== current.swapData.order_id)
|
|
33
|
+
return;
|
|
34
|
+
const swapData = event.order.payment
|
|
35
|
+
? { ...current.swapData, status: event.order.status, data: event.order.payment }
|
|
36
|
+
: { ...current.swapData, status: event.order.status };
|
|
37
|
+
const sourceTxHash = event.order.payment?.source_tx_hash ?? ("sourceTxHash" in current ? current.sourceTxHash : undefined);
|
|
38
|
+
if (event.event === WebhookEventType.ORDER_COMPLETED) {
|
|
39
|
+
const txHash = event.order.payment?.destination_tx_hash ?? sourceTxHash;
|
|
40
|
+
if (txHash)
|
|
41
|
+
setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash, txHash });
|
|
42
|
+
setOrderId(undefined);
|
|
43
|
+
}
|
|
44
|
+
else if (event.event === WebhookEventType.ORDER_REFUNDED && event.order.payment?.refund_tx_hash) {
|
|
45
|
+
setStage({
|
|
46
|
+
kind: "refunded",
|
|
47
|
+
swapData,
|
|
48
|
+
orderId: event.order.id,
|
|
49
|
+
sourceTxHash,
|
|
50
|
+
refundTxHash: event.order.payment.refund_tx_hash,
|
|
51
|
+
});
|
|
52
|
+
setOrderId(undefined);
|
|
53
|
+
}
|
|
54
|
+
else if (event.event === WebhookEventType.ORDER_ERROR) {
|
|
55
|
+
setStage({ kind: "error", swapData, message: "Swap failed" });
|
|
56
|
+
setOrderId(undefined);
|
|
57
|
+
}
|
|
58
|
+
else if (event.event === WebhookEventType.ORDER_EXECUTING) {
|
|
59
|
+
setStage({ kind: "executing", swapData, orderId: event.order.id, sourceTxHash });
|
|
60
|
+
}
|
|
61
|
+
else if (event.event === WebhookEventType.ORDER_CONFIRMING ||
|
|
62
|
+
event.event === WebhookEventType.ORDER_PARTIAL_PAYMENT) {
|
|
63
|
+
setStage({ kind: "source_confirming", swapData, orderId: event.order.id, sourceTxHash });
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
useOrderStatus({ api, orderId, onEvent: onOrderEvent });
|
|
67
|
+
const execute = useCallback(async (request, fromAddress) => {
|
|
68
|
+
if (!transactionActions)
|
|
69
|
+
return;
|
|
70
|
+
const currentRun = ++runId.current;
|
|
71
|
+
let swapData = null;
|
|
72
|
+
setStage({ kind: "confirming" });
|
|
73
|
+
try {
|
|
74
|
+
const response = await api.swapExecute(request);
|
|
75
|
+
if (currentRun !== runId.current)
|
|
76
|
+
return;
|
|
77
|
+
if (response.error || !response.data) {
|
|
78
|
+
setStage({ kind: "error", swapData: null, message: response.error?.message || "Failed to prepare swap" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
swapData = response.data;
|
|
82
|
+
setOrderId(swapData.order_id);
|
|
83
|
+
const transactions = buildSwapExecuteTransactions(swapData, fromAddress);
|
|
84
|
+
let sourceTxHash;
|
|
85
|
+
for (const [paymentStepIndex, transaction] of transactions.entries()) {
|
|
86
|
+
setStage({ kind: "awaiting_payment", swapData, paymentStepIndex });
|
|
87
|
+
sourceTxHash = await transactionActions.execute(transaction);
|
|
88
|
+
if (currentRun !== runId.current)
|
|
89
|
+
return;
|
|
90
|
+
if (!sourceTxHash)
|
|
91
|
+
throw new Error("No transaction hash returned");
|
|
92
|
+
}
|
|
93
|
+
setStage({ kind: "source_confirming", swapData, orderId: swapData.order_id, sourceTxHash });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (isRejected(error)) {
|
|
97
|
+
reset();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setOrderId(undefined);
|
|
101
|
+
setStage({ kind: "error", swapData, message: errorMessage(error, "Transaction failed") });
|
|
102
|
+
}
|
|
103
|
+
}, [api, reset, transactionActions]);
|
|
104
|
+
return { stage, execute, reset };
|
|
105
|
+
}
|