@coin-voyage/swapkit 0.4.0-beta.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +8 -8
  2. package/README.md +68 -68
  3. package/dist/components/currency-panel.d.ts +17 -0
  4. package/dist/components/currency-panel.js +22 -0
  5. package/dist/components/execution-modal.d.ts +1 -1
  6. package/dist/components/execution-modal.js +33 -6
  7. package/dist/components/execution-modal.test.d.ts +1 -0
  8. package/dist/components/execution-modal.test.js +92 -0
  9. package/dist/components/manual-wallet-modal.d.ts +8 -0
  10. package/dist/components/manual-wallet-modal.js +19 -0
  11. package/dist/components/output-wallet-selector.d.ts +7 -0
  12. package/dist/components/output-wallet-selector.js +10 -0
  13. package/dist/components/quote-breakdown.d.ts +5 -0
  14. package/dist/components/quote-breakdown.js +13 -0
  15. package/dist/components/styles.d.ts +2 -1
  16. package/dist/components/styles.js +1294 -1281
  17. package/dist/components/swap-recipient.test.js +61 -2
  18. package/dist/components/swap-settings.d.ts +9 -0
  19. package/dist/components/swap-settings.js +24 -0
  20. package/dist/components/swap.d.ts +2 -28
  21. package/dist/components/swap.js +27 -301
  22. package/dist/components/token-picker.test.js +11 -0
  23. package/dist/hooks/use-dismiss.d.ts +3 -0
  24. package/dist/hooks/use-dismiss.js +26 -0
  25. package/dist/hooks/use-swap-notifications.d.ts +10 -0
  26. package/dist/hooks/use-swap-notifications.js +53 -0
  27. package/dist/hooks/use-swap-quote.js +49 -51
  28. package/dist/hooks/use-swap.d.ts +47 -0
  29. package/dist/hooks/use-swap.js +189 -0
  30. package/dist/hooks/use-token-data.d.ts +2 -1
  31. package/dist/hooks/use-token-data.js +42 -13
  32. package/dist/hooks/use-token-data.test.js +78 -3
  33. package/dist/lib/currency-selection.js +6 -4
  34. package/dist/lib/currency-selection.test.d.ts +1 -0
  35. package/dist/lib/currency-selection.test.js +16 -0
  36. package/dist/lib/execution.d.ts +2 -0
  37. package/dist/lib/execution.js +6 -0
  38. package/dist/lib/execution.test.d.ts +1 -0
  39. package/dist/lib/execution.test.js +24 -0
  40. package/dist/lib/format.d.ts +5 -0
  41. package/dist/lib/format.js +20 -0
  42. package/dist/lib/quote.d.ts +10 -1
  43. package/dist/lib/quote.js +12 -0
  44. package/dist/lib/slippage.d.ts +11 -0
  45. package/dist/lib/slippage.js +17 -0
  46. package/dist/styles/root.js +59 -59
  47. package/package.json +4 -4
package/LICENSE CHANGED
@@ -1,8 +1,8 @@
1
- Copyright (c) 2026 Coin Voyage
2
-
3
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
-
5
- 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
- 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
7
-
8
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
1
+ Copyright (c) 2026 Coin Voyage
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
7
+
8
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,68 +1,68 @@
1
- # @coin-voyage/swapkit
2
-
3
- Embeddable Coin Voyage swap UI for React applications. SwapKit uses styled-components and does not require Tailwind CSS.
4
-
5
- ## Installation
6
-
7
- ```sh
8
- pnpm add @coin-voyage/swapkit @tanstack/react-query react react-dom styled-components
9
- ```
10
-
11
- Install the wallet peer dependencies listed in this package's `package.json` for the chains your application supports.
12
-
13
- ## Usage
14
-
15
- ```tsx
16
- import { Swap, SwapKitProvider, WalletProvider } from "@coin-voyage/swapkit"
17
-
18
- export function App() {
19
- return (
20
- <WalletProvider>
21
- <SwapKitProvider apiKey={process.env.NEXT_PUBLIC_COIN_VOYAGE_API_KEY!} environment="development" mode="dark">
22
- <Swap
23
- defaultSourceCurrency={{ chain_id: 1 }}
24
- defaultDestinationCurrency={{
25
- chain_id: 30000000000001,
26
- address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
27
- }}
28
- />
29
- </SwapKitProvider>
30
- </WalletProvider>
31
- )
32
- }
33
- ```
34
-
35
- `SwapKitProvider` deliberately uses the public API client and never accepts an admin signing key. It must be rendered
36
- inside `WalletProvider`, which SwapKit re-exports from its canonical implementation in `@coin-voyage/crypto/wallets`.
37
- The wallet provider supplies React Query and the supported chain contexts, while reusing a compatible host Query
38
- Client when one already exists.
39
-
40
- `Swap` optionally accepts `defaultSourceCurrency`, `defaultDestinationCurrency`, and `defaultSlippageBps`, plus
41
- `onComplete` and `onError` callbacks for host notifications. Currency defaults use `{ chain_id, address? }`; token
42
- addresses are matched case-insensitively. Slippage is an integer from `0` to `10000` basis points.
43
-
44
- When `defaultSlippageBps` is omitted, slippage starts in Auto mode and SwapKit lets the quote service choose the
45
- appropriate tolerance. The custom control retains a `1%` draft. Supplying `defaultSlippageBps` starts the widget in
46
- Custom mode and sends that value with quote and execution requests.
47
-
48
- Defaults are read once when a widget mounts. Changing them later does not interrupt an in-progress swap; remount the
49
- widget with a new React `key` to apply a new set of defaults. Unsupported defaults fall back to the normal automatic
50
- selection.
51
-
52
- ### Theming
53
-
54
- Use `mode="light"` or `mode="dark"`. The optional `theme` prop overrides individual widget colors:
55
-
56
- ```tsx
57
- <SwapKitProvider apiKey={apiKey} mode="dark" theme={{ accent: "#7c3aed", radius: "18px" }}>
58
- <Swap />
59
- </SwapKitProvider>
60
- ```
61
-
62
- ## Development
63
-
64
- ```sh
65
- pnpm --filter @coin-voyage/swapkit test
66
- pnpm --filter @coin-voyage/swapkit type-check
67
- pnpm --filter @coin-voyage/swapkit build
68
- ```
1
+ # @coin-voyage/swapkit
2
+
3
+ Embeddable Coin Voyage swap UI for React applications. SwapKit uses styled-components and does not require Tailwind CSS.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @coin-voyage/swapkit @tanstack/react-query react react-dom styled-components
9
+ ```
10
+
11
+ Install the wallet peer dependencies listed in this package's `package.json` for the chains your application supports.
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import { Swap, SwapKitProvider, WalletProvider } from "@coin-voyage/swapkit"
17
+
18
+ export function App() {
19
+ return (
20
+ <WalletProvider>
21
+ <SwapKitProvider apiKey={process.env.NEXT_PUBLIC_COIN_VOYAGE_API_KEY!} environment="development" mode="dark">
22
+ <Swap
23
+ defaultSourceCurrency={{ chain_id: 1 }}
24
+ defaultDestinationCurrency={{
25
+ chain_id: 30000000000001,
26
+ address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
27
+ }}
28
+ />
29
+ </SwapKitProvider>
30
+ </WalletProvider>
31
+ )
32
+ }
33
+ ```
34
+
35
+ `SwapKitProvider` deliberately uses the public API client and never accepts an admin signing key. It must be rendered
36
+ inside `WalletProvider`, which SwapKit re-exports from its canonical implementation in `@coin-voyage/crypto/wallets`.
37
+ The wallet provider supplies React Query and the supported chain contexts, while reusing a compatible host Query
38
+ Client when one already exists.
39
+
40
+ `Swap` optionally accepts `defaultSourceCurrency`, `defaultDestinationCurrency`, and `defaultSlippageBps`, plus
41
+ `onComplete` and `onError` callbacks for host notifications. Currency defaults use `{ chain_id, address? }`; token
42
+ addresses are matched case-insensitively. Slippage is an integer from `0` to `10000` basis points.
43
+
44
+ When `defaultSlippageBps` is omitted, slippage starts in Auto mode and SwapKit lets the quote service choose the
45
+ appropriate tolerance. The custom control retains a `1%` draft. Supplying `defaultSlippageBps` starts the widget in
46
+ Custom mode and sends that value with quote and execution requests.
47
+
48
+ Defaults are read once when a widget mounts. Changing them later does not interrupt an in-progress swap; remount the
49
+ widget with a new React `key` to apply a new set of defaults. Unsupported defaults fall back to the normal automatic
50
+ selection.
51
+
52
+ ### Theming
53
+
54
+ Use `mode="light"` or `mode="dark"`. The optional `theme` prop overrides individual widget colors:
55
+
56
+ ```tsx
57
+ <SwapKitProvider apiKey={apiKey} mode="dark" theme={{ accent: "#7c3aed", radius: "18px" }}>
58
+ <Swap />
59
+ </SwapKitProvider>
60
+ ```
61
+
62
+ ## Development
63
+
64
+ ```sh
65
+ pnpm --filter @coin-voyage/swapkit test
66
+ pnpm --filter @coin-voyage/swapkit type-check
67
+ pnpm --filter @coin-voyage/swapkit build
68
+ ```
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+ import type { SwapToken } from "../hooks/use-token-data";
3
+ import type { SwapSide } from "../lib/quote";
4
+ interface CurrencyPanelProps {
5
+ side: SwapSide;
6
+ token?: SwapToken;
7
+ amount: string;
8
+ balance?: string;
9
+ quoteUsd?: number | null;
10
+ quoteLoading?: boolean;
11
+ disabled?: boolean;
12
+ onAmountChange: (value: string) => void;
13
+ onSelectToken: () => void;
14
+ headerAction?: ReactNode;
15
+ }
16
+ export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }: CurrencyPanelProps): import("react/jsx-runtime").JSX.Element;
17
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { balanceFraction, isValidSwapAmountInput } from "../lib/amount-input";
3
+ import { formatUsd, usdValue } from "../lib/format";
4
+ import { ChevronDownIcon } from "./icons";
5
+ import { AmountInput, AmountRow, AmountSkeleton, CardFooter, CardHeaderAction, CardTitle, CurrencyCard, QuickAmountActions, QuickAmountButton, QuoteValueSkeleton, TokenButton, } from "./styles";
6
+ import { TokenIcon } from "./token-icon";
7
+ const QUICK_AMOUNTS = [
8
+ { label: "25%", fraction: 0.25 },
9
+ { label: "50%", fraction: 0.5 },
10
+ { label: "75%", fraction: 0.75 },
11
+ { label: "Max", fraction: 1 },
12
+ ];
13
+ export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }) {
14
+ const label = side === "input" ? "SELL" : "BUY";
15
+ const showQuickAmounts = side === "input" && balance !== undefined && token !== undefined;
16
+ 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
17
+ ? `Use maximum ${token.currency.symbol} balance`
18
+ : `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) => {
19
+ if (isValidSwapAmountInput(event.target.value))
20
+ onAmountChange(event.target.value);
21
+ } })), _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 ?? usdValue(token?.currency.price_usd, amount)) })), balance !== undefined && _jsxs("span", { children: ["Balance: ", Number(balance).toLocaleString()] })] })] }));
22
+ }
@@ -1,4 +1,4 @@
1
- import type { SwapStage } from "../lib/execution";
1
+ import { type SwapStage } from "../lib/execution";
2
2
  export declare function ExecutionModal({ stage, onClose }: {
3
3
  stage: SwapStage;
4
4
  onClose: () => void;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { getChainExplorerTxUrl, getChainName } from "@coin-voyage/shared/chain";
3
+ import { deliversInSwapTransaction } from "../lib/execution";
3
4
  import { AlertCircleIcon, ArrowRightIcon, CheckIcon, StatusDot, WarningIcon } from "./icons";
4
5
  import { Modal } from "./modal";
5
- import { ExecutionContent, ExecutionState, ExplorerLink, ProgressConnector, ProgressItem, ProgressLabel, ProgressList, ProgressMarker, ProgressMarkerColumn, ProgressText, RouteArrow, Spinner, TokenAmount, TokenRoute, } from "./styles";
6
+ import { ExecutionContent, ExecutionEstimate, ExecutionState, ExplorerLink, ProgressConnector, ProgressItem, ProgressLabel, ProgressList, ProgressMarker, ProgressMarkerColumn, ProgressText, RouteArrow, Spinner, TokenAmount, TokenRoute, } from "./styles";
6
7
  import { TokenIcon } from "./token-icon";
7
8
  function completedLabel(step) {
8
9
  return step.action === "approve" ? "Approved" : "Sent";
@@ -13,6 +14,10 @@ function paymentStepState(stage, step, index, total) {
13
14
  const explorerUrl = sourceTxHash && sourceChainId ? getChainExplorerTxUrl(sourceChainId, sourceTxHash) : undefined;
14
15
  const completed = { tone: "complete", label: completedLabel(step) };
15
16
  const isLast = index === total - 1;
17
+ const crossChain = "swapData" in stage &&
18
+ stage.swapData &&
19
+ stage.swapData.data.input.currency.chain_id !== stage.swapData.data.output.currency.chain_id;
20
+ const confirmed = { tone: "complete", label: "Confirmed", explorerUrl };
16
21
  switch (stage.kind) {
17
22
  case "awaiting_payment":
18
23
  if (index < stage.paymentStepIndex)
@@ -23,15 +28,32 @@ function paymentStepState(stage, step, index, total) {
23
28
  case "source_confirming":
24
29
  return isLast ? { tone: "active", label: "Confirming...", explorerUrl } : completed;
25
30
  case "executing":
26
- return isLast ? { tone: "active", label: "Processing...", explorerUrl } : completed;
31
+ return isLast ? (crossChain ? confirmed : { tone: "active", label: "Processing...", explorerUrl }) : completed;
27
32
  case "done":
28
- return isLast ? { tone: "complete", explorerUrl } : completed;
33
+ return isLast ? (crossChain ? confirmed : { tone: "complete", explorerUrl }) : completed;
29
34
  case "refunded":
30
- return isLast ? { ...completed, explorerUrl } : completed;
35
+ return isLast ? (crossChain ? confirmed : { ...completed, explorerUrl }) : completed;
31
36
  default:
32
37
  return { tone: "muted", label: "Awaiting" };
33
38
  }
34
39
  }
40
+ function executionState(stage) {
41
+ if (stage.kind === "executing")
42
+ return { tone: "active", label: "Processing..." };
43
+ if (stage.kind === "done")
44
+ return { tone: "complete", label: "Completed" };
45
+ if (stage.kind === "refunded")
46
+ return { tone: "warning", label: "Stopped" };
47
+ return { tone: "muted", label: "Awaiting" };
48
+ }
49
+ function formatDuration(seconds) {
50
+ if (seconds == null || !Number.isFinite(seconds) || seconds <= 0)
51
+ return undefined;
52
+ const totalSeconds = Math.ceil(seconds);
53
+ const minutes = Math.floor(totalSeconds / 60);
54
+ const remainder = totalSeconds % 60;
55
+ return [minutes ? `${minutes} min` : "", remainder ? `${remainder} sec` : ""].filter(Boolean).join(" ");
56
+ }
35
57
  function outcomeState(stage) {
36
58
  if (!("swapData" in stage) || !stage.swapData)
37
59
  return { tone: "muted", label: "Awaiting" };
@@ -79,7 +101,12 @@ export function ExecutionModal({ stage, onClose }) {
79
101
  const open = stage.kind !== "idle";
80
102
  const swapData = "swapData" in stage ? stage.swapData : null;
81
103
  const preventDismiss = stage.kind !== "done" && stage.kind !== "refunded" && stage.kind !== "error";
82
- return (_jsx(Modal, { open: open, title: "Transaction Details", onClose: onClose, preventDismiss: preventDismiss, maxWidth: 384, children: stage.kind === "error" ? (_jsxs(ExecutionState, { "$tone": "danger", children: [_jsx(AlertCircleIcon, {}), _jsx("p", { children: stage.message })] })) : !swapData ? (_jsxs(ExecutionState, { children: [_jsx(Spinner, {}), _jsx("p", { children: "Loading swap data..." })] })) : (_jsxs(ExecutionContent, { children: [_jsxs(TokenRoute, { children: [_jsxs(TokenAmount, { children: [_jsx(TokenIcon, { currency: swapData.data.input.currency, size: 40 }), _jsxs("strong", { children: [swapData.data.input.amount.ui, " ", swapData.data.input.currency.symbol] })] }), _jsx(RouteArrow, { children: _jsx(ArrowRightIcon, {}) }), _jsxs(TokenAmount, { "$align": "end", children: [_jsx(TokenIcon, { currency: swapData.data.output.currency, size: 40 }), _jsxs("strong", { children: [swapData.data.output.amount.ui, " ", swapData.data.output.currency.symbol] })] })] }), _jsxs(ProgressList, { "aria-label": "Swap progress", children: [swapData.data.steps.map((step, index) => (_jsx(ProgressStep, { title: stepTitle(step, swapData.data.input.currency.symbol, getChainName(swapData.data.input.currency.chain_id) ?? "Unknown network"), state: paymentStepState(stage, step, index, swapData.data.steps.length), connected: true }, step.id ?? index))), _jsx(ProgressStep, { title: stage.kind === "refunded"
104
+ // A same-chain swap settles in the user's own transaction, so a receive step would only repeat the swap step.
105
+ const hasSeparateDelivery = stage.kind === "done" && stage.destinationTxHash !== undefined && stage.destinationTxHash !== stage.sourceTxHash;
106
+ const showOutcome = stage.kind === "refunded" || hasSeparateDelivery || !swapData || !deliversInSwapTransaction(swapData);
107
+ const crossChain = swapData && swapData.data.input.currency.chain_id !== swapData.data.output.currency.chain_id;
108
+ const estimatedDuration = preventDismiss ? formatDuration(swapData?.data.estimated_duration_seconds) : undefined;
109
+ return (_jsx(Modal, { open: open, title: "Transaction Details", onClose: onClose, preventDismiss: preventDismiss, maxWidth: 384, children: stage.kind === "error" ? (_jsxs(ExecutionState, { "$tone": "danger", children: [_jsx(AlertCircleIcon, {}), _jsx("p", { children: stage.message })] })) : !swapData ? (_jsxs(ExecutionState, { children: [_jsx(Spinner, {}), _jsx("p", { children: "Loading swap data..." })] })) : (_jsxs(ExecutionContent, { children: [_jsxs(TokenRoute, { children: [_jsxs(TokenAmount, { children: [_jsx(TokenIcon, { currency: swapData.data.input.currency, size: 40 }), _jsxs("strong", { children: [swapData.data.input.amount.ui, " ", swapData.data.input.currency.symbol] })] }), _jsx(RouteArrow, { children: _jsx(ArrowRightIcon, {}) }), _jsxs(TokenAmount, { "$align": "end", children: [_jsx(TokenIcon, { currency: swapData.data.output.currency, size: 40 }), _jsxs("strong", { children: [swapData.data.output.amount.ui, " ", swapData.data.output.currency.symbol] })] })] }), estimatedDuration && _jsxs(ExecutionEstimate, { children: ["Estimated duration: ", estimatedDuration] }), _jsxs(ProgressList, { "aria-label": "Swap progress", children: [swapData.data.steps.map((step, index) => (_jsx(ProgressStep, { title: stepTitle(step, swapData.data.input.currency.symbol, getChainName(swapData.data.input.currency.chain_id) ?? "Unknown network"), state: paymentStepState(stage, step, index, swapData.data.steps.length), connected: crossChain || showOutcome || index < swapData.data.steps.length - 1 }, step.id ?? index))), crossChain && (_jsx(ProgressStep, { title: "Execute cross-chain swap", state: executionState(stage), connected: showOutcome })), showOutcome && (_jsx(ProgressStep, { title: stage.kind === "refunded"
83
110
  ? `Refund ${swapData.data.input.currency.symbol} on ${getChainName(swapData.data.input.currency.chain_id) ?? "Unknown network"}`
84
- : `Receive ${swapData.data.output.currency.symbol} on ${getChainName(swapData.data.output.currency.chain_id) ?? "Unknown network"}`, state: outcomeState(stage), connected: false })] })] })) }));
111
+ : `Receive ${swapData.data.output.currency.symbol} on ${getChainName(swapData.data.output.currency.chain_id) ?? "Unknown network"}`, state: outcomeState(stage), connected: false }))] })] })) }));
85
112
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { ChainId } from "@coin-voyage/shared/types";
4
+ import { cleanup, render, screen, within } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ExecutionModal } from "./execution-modal";
7
+ vi.mock("./modal", () => ({
8
+ Modal: ({ open, children }) => (open ? _jsx("div", { children: children }) : null),
9
+ }));
10
+ afterEach(cleanup);
11
+ function swapData(outputChain = ChainId.ZEC, duration = 125) {
12
+ return {
13
+ order_id: "order-1",
14
+ data: {
15
+ input: { currency: { chain_id: ChainId.BASE, symbol: "ETH", name: "Ether" }, amount: { ui: "0.001" } },
16
+ output: { currency: { chain_id: outputChain, symbol: "ZEC", name: "Zcash" }, amount: { ui: "0.001638" } },
17
+ estimated_duration_seconds: duration,
18
+ steps: [{ action: "send", data: { type: "EVM" } }],
19
+ },
20
+ };
21
+ }
22
+ function renderStage(stage) {
23
+ render(_jsx(ExecutionModal, { stage: stage, onClose: vi.fn() }));
24
+ return screen.getAllByRole("listitem").map((item) => within(item));
25
+ }
26
+ describe("ExecutionModal", () => {
27
+ it("separates the confirmed source transaction from cross-chain execution", () => {
28
+ const [send, execute, receive] = renderStage({
29
+ kind: "executing",
30
+ swapData: swapData(),
31
+ orderId: "order-1",
32
+ sourceTxHash: "0xsource",
33
+ });
34
+ expect(send.getByText("Confirmed")).toBeTruthy();
35
+ expect(send.getByRole("link").getAttribute("href")).toContain("0xsource");
36
+ expect(execute.getByText("Execute cross-chain swap")).toBeTruthy();
37
+ expect(execute.getByText("Processing...")).toBeTruthy();
38
+ expect(receive.getByText("Awaiting")).toBeTruthy();
39
+ expect(screen.getByText("Estimated duration: 2 min 5 sec")).toBeTruthy();
40
+ });
41
+ it("waits for source confirmation before activating execution", () => {
42
+ const [send, execute, receive] = renderStage({
43
+ kind: "source_confirming",
44
+ swapData: swapData(),
45
+ orderId: "order-1",
46
+ sourceTxHash: "0xsource",
47
+ });
48
+ expect(send.getByText("Confirming...")).toBeTruthy();
49
+ expect(execute.getByText("Awaiting")).toBeTruthy();
50
+ expect(receive.getByText("Awaiting")).toBeTruthy();
51
+ });
52
+ it("completes all cross-chain steps and hides the estimate after delivery", () => {
53
+ const [send, execute, receive] = renderStage({
54
+ kind: "done",
55
+ swapData: swapData(),
56
+ orderId: "order-1",
57
+ sourceTxHash: "0xsource",
58
+ destinationTxHash: "destination",
59
+ });
60
+ expect(send.getByText("Confirmed")).toBeTruthy();
61
+ expect(execute.getByText("Completed")).toBeTruthy();
62
+ expect(receive.getByRole("link").getAttribute("href")).toContain("destination");
63
+ expect(screen.queryByText(/Estimated duration/)).toBeNull();
64
+ });
65
+ it("shows a stopped execution and refund outcome when refunded", () => {
66
+ const [, execute, refund] = renderStage({
67
+ kind: "refunded",
68
+ swapData: swapData(),
69
+ orderId: "order-1",
70
+ sourceTxHash: "0xsource",
71
+ });
72
+ expect(execute.getByText("Stopped")).toBeTruthy();
73
+ expect(refund.getByText("Refunded")).toBeTruthy();
74
+ });
75
+ it("keeps a same-chain wallet swap as a single processing step", () => {
76
+ const steps = renderStage({
77
+ kind: "executing",
78
+ swapData: swapData(ChainId.BASE, 30),
79
+ orderId: "order-1",
80
+ sourceTxHash: "0xsource",
81
+ });
82
+ expect(steps).toHaveLength(1);
83
+ expect(steps[0].getByText("Processing...")).toBeTruthy();
84
+ expect(screen.getByText("Estimated duration: 30 sec")).toBeTruthy();
85
+ });
86
+ it.each([null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])("omits invalid duration %s", (duration) => {
87
+ const data = swapData();
88
+ data.data.estimated_duration_seconds = duration;
89
+ renderStage({ kind: "executing", swapData: data, orderId: "order-1" });
90
+ expect(screen.queryByText(/Estimated duration/)).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,8 @@
1
+ import type { SwapToken } from "../hooks/use-token-data";
2
+ export declare function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }: {
3
+ open: boolean;
4
+ chainId?: SwapToken["currency"]["chain_id"];
5
+ initialValue?: string;
6
+ onClose: () => void;
7
+ onSave: (address: string) => void;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isValidAddress } from "@coin-voyage/shared/chain";
3
+ import { useId, useState } from "react";
4
+ import { Modal } from "./modal";
5
+ import { CompactInput, FieldLabel, Hint, PrimaryButton } from "./styles";
6
+ export function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
7
+ const [value, setValue] = useState(initialValue ?? "");
8
+ const [error, setError] = useState(null);
9
+ const outputAddressId = useId();
10
+ 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: () => {
11
+ if (!chainId || !isValidAddress(value, chainId)) {
12
+ setError("Enter a valid address for the destination network");
13
+ return;
14
+ }
15
+ setError(null);
16
+ onSave(value);
17
+ onClose();
18
+ }, children: "Use address" })] }));
19
+ }
@@ -0,0 +1,7 @@
1
+ export declare function OutputWalletSelector({ label, open, onOpenChange, onConnect, onPaste, }: {
2
+ label: string;
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ onConnect?: () => void;
6
+ onPaste: () => void;
7
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { useDismiss } from "../hooks/use-dismiss";
4
+ import { ChevronDownIcon, PasteIcon, WalletIcon } from "./icons";
5
+ import { OutputWalletControl, OutputWalletMenu, OutputWalletOption, OutputWalletTrigger } from "./styles";
6
+ export function OutputWalletSelector({ label, open, onOpenChange, onConnect, onPaste, }) {
7
+ const controlRef = useRef(null);
8
+ useDismiss(controlRef, open, () => onOpenChange(false));
9
+ 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, {})] })] }))] }));
10
+ }
@@ -0,0 +1,5 @@
1
+ import type { SwapQuote } from "@coin-voyage/shared/types";
2
+ export declare function QuoteBreakdown({ quote, loading }: {
3
+ quote: SwapQuote | null;
4
+ loading: boolean;
5
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { formatPriceImpact, formatQuoteRate, formatUsd } from "../lib/format";
4
+ import { ChevronDownIcon, FuelIcon } from "./icons";
5
+ import { BreakdownBody, BreakdownCard, BreakdownMeta, BreakdownToggle, DetailRow, FreeFeeBadge, NetworkCost, } from "./styles";
6
+ export function QuoteBreakdown({ quote, loading }) {
7
+ const [open, setOpen] = useState(false);
8
+ if (!loading && !quote)
9
+ return null;
10
+ const gasUsd = quote?.input.breakdown?.gas?.value_usd;
11
+ const totalFeeUsd = quote?.input.breakdown?.fees?.total_fee.value_usd;
12
+ return (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx("span", { children: loading ? "Finding the best route…" : quote ? formatQuoteRate(quote) : "Swap details" }), _jsxs(BreakdownMeta, { children: [quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(gasUsd) })] })), _jsx(ChevronDownIcon, { size: 16, up: open })] })] }), open && (_jsxs(BreakdownBody, { children: [quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quote.output.amount_out_minimum.ui, " ", quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quote ? formatPriceImpact(quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(gasUsd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), totalFeeUsd === 0 ? _jsx(FreeFeeBadge, { children: "Free" }) : _jsx("strong", { children: formatUsd(totalFeeUsd) })] })] }))] }));
13
+ }
@@ -28,6 +28,7 @@ export declare const Hint: import("styled-components").StyledComponent<"p", any,
28
28
  export declare const CardWrap: import("styled-components").StyledComponent<"div", any, {}, never>;
29
29
  export declare const CurrencyCard: import("styled-components").StyledComponent<"section", any, {}, never>;
30
30
  export declare const CardTitle: import("styled-components").StyledComponent<"div", any, {}, never>;
31
+ export declare const CardHeaderAction: import("styled-components").StyledComponent<"div", any, {}, never>;
31
32
  export declare const QuickAmountActions: import("styled-components").StyledComponent<"div", any, {}, never>;
32
33
  export declare const QuickAmountButton: import("styled-components").StyledComponent<"button", any, {}, never>;
33
34
  export declare const AmountRow: import("styled-components").StyledComponent<"div", any, {}, never>;
@@ -45,7 +46,6 @@ export declare const ChainBadge: import("styled-components").StyledComponent<"sp
45
46
  $size: number;
46
47
  }, never>;
47
48
  export declare const CardFooter: import("styled-components").StyledComponent<"div", any, {}, never>;
48
- export declare const InlineActions: import("styled-components").StyledComponent<"div", any, {}, never>;
49
49
  export declare const OutputWalletControl: import("styled-components").StyledComponent<"span", any, {}, never>;
50
50
  export declare const OutputWalletTrigger: import("styled-components").StyledComponent<"button", any, {}, never>;
51
51
  export declare const OutputWalletMenu: import("styled-components").StyledComponent<"span", any, {}, never>;
@@ -101,6 +101,7 @@ export declare const WalletDisconnectButton: import("styled-components").StyledC
101
101
  export declare const DisconnectAllButton: import("styled-components").StyledComponent<"button", any, {}, never>;
102
102
  export declare const TriggerWalletIcons: import("styled-components").StyledComponent<"span", any, {}, never>;
103
103
  export declare const ExecutionContent: import("styled-components").StyledComponent<"div", any, {}, never>;
104
+ export declare const ExecutionEstimate: import("styled-components").StyledComponent<"p", any, {}, never>;
104
105
  export declare const TokenRoute: import("styled-components").StyledComponent<"div", any, {}, never>;
105
106
  export declare const TokenAmount: import("styled-components").StyledComponent<"div", any, {
106
107
  $align?: "start" | "end";