@coin-voyage/swapkit 0.1.0-beta.0 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/components/connected-wallets.d.ts +1 -0
  2. package/dist/components/connected-wallets.js +66 -0
  3. package/dist/components/execution-modal.d.ts +5 -0
  4. package/dist/components/execution-modal.js +79 -0
  5. package/dist/components/icons.d.ts +23 -0
  6. package/dist/components/icons.js +49 -0
  7. package/dist/components/modal.d.ts +11 -0
  8. package/dist/components/modal.js +40 -0
  9. package/dist/components/styles.d.ts +124 -0
  10. package/dist/components/styles.js +1239 -0
  11. package/dist/components/swap.d.ts +2 -0
  12. package/dist/components/swap.js +295 -0
  13. package/dist/components/token-icon.d.ts +5 -0
  14. package/dist/components/token-icon.js +9 -0
  15. package/dist/components/token-picker.d.ts +11 -0
  16. package/dist/components/token-picker.js +69 -0
  17. package/dist/components/wallet-connector.d.ts +8 -0
  18. package/dist/components/wallet-connector.js +33 -0
  19. package/dist/components/wallet-icon.d.ts +5 -0
  20. package/dist/components/wallet-icon.js +6 -0
  21. package/dist/hooks/use-order-status.d.ts +9 -0
  22. package/dist/hooks/use-order-status.js +36 -0
  23. package/dist/hooks/use-swap-execution.d.ts +7 -0
  24. package/dist/hooks/use-swap-execution.js +105 -0
  25. package/dist/hooks/use-swap-quote.d.ts +6 -0
  26. package/dist/hooks/use-swap-quote.js +63 -0
  27. package/dist/hooks/use-swap-state.d.ts +70 -0
  28. package/dist/hooks/use-swap-state.js +147 -0
  29. package/dist/hooks/use-token-data.d.ts +12 -0
  30. package/dist/hooks/use-token-data.js +80 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +3 -0
  33. package/dist/lib/amount-input.d.ts +3 -0
  34. package/dist/lib/amount-input.js +11 -0
  35. package/dist/lib/currency-selection.d.ts +13 -0
  36. package/dist/lib/currency-selection.js +26 -0
  37. package/dist/lib/execution.d.ts +38 -0
  38. package/dist/lib/execution.js +26 -0
  39. package/dist/lib/output-wallet.d.ts +24 -0
  40. package/dist/lib/output-wallet.js +24 -0
  41. package/dist/lib/quote.d.ts +13 -0
  42. package/dist/lib/quote.js +24 -0
  43. package/dist/lib/version.d.ts +1 -0
  44. package/dist/lib/version.js +2 -0
  45. package/dist/providers/swapkit-context.d.ts +10 -0
  46. package/dist/providers/swapkit-context.js +8 -0
  47. package/dist/providers/swapkit-provider.d.ts +2 -0
  48. package/dist/providers/swapkit-provider.js +18 -0
  49. package/dist/styles/root.d.ts +5 -0
  50. package/dist/styles/root.js +75 -0
  51. package/dist/styles/styled.d.ts +2 -0
  52. package/dist/styles/styled.js +3 -0
  53. package/dist/styles/theme.d.ts +3 -0
  54. package/dist/styles/theme.js +36 -0
  55. package/dist/types.d.ts +44 -0
  56. package/dist/types.js +1 -0
  57. package/package.json +2 -2
@@ -0,0 +1,2 @@
1
+ import type { SwapProps } from "../types";
2
+ export declare function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }?: SwapProps): import("react/jsx-runtime").JSX.Element;
@@ -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,5 @@
1
+ import { type Currency } from "@coin-voyage/shared/types";
2
+ export declare function TokenIcon({ currency, size }: {
3
+ currency: Currency;
4
+ size?: number;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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,5 @@
1
+ export declare function WalletIconDisplay({ icon, name, size }: {
2
+ icon?: string;
3
+ name: string;
4
+ size?: number;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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
+ }