@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.
- package/LICENSE +8 -8
- package/README.md +68 -68
- package/dist/components/currency-panel.d.ts +17 -0
- package/dist/components/currency-panel.js +22 -0
- package/dist/components/execution-modal.d.ts +1 -1
- package/dist/components/execution-modal.js +33 -6
- package/dist/components/execution-modal.test.d.ts +1 -0
- package/dist/components/execution-modal.test.js +92 -0
- package/dist/components/manual-wallet-modal.d.ts +8 -0
- package/dist/components/manual-wallet-modal.js +19 -0
- package/dist/components/output-wallet-selector.d.ts +7 -0
- package/dist/components/output-wallet-selector.js +10 -0
- package/dist/components/quote-breakdown.d.ts +5 -0
- package/dist/components/quote-breakdown.js +13 -0
- package/dist/components/styles.d.ts +2 -1
- package/dist/components/styles.js +1294 -1281
- package/dist/components/swap-recipient.test.js +61 -2
- package/dist/components/swap-settings.d.ts +9 -0
- package/dist/components/swap-settings.js +24 -0
- package/dist/components/swap.d.ts +2 -28
- package/dist/components/swap.js +27 -301
- package/dist/components/token-picker.test.js +11 -0
- package/dist/hooks/use-dismiss.d.ts +3 -0
- package/dist/hooks/use-dismiss.js +26 -0
- package/dist/hooks/use-swap-notifications.d.ts +10 -0
- package/dist/hooks/use-swap-notifications.js +53 -0
- package/dist/hooks/use-swap-quote.js +49 -51
- package/dist/hooks/use-swap.d.ts +47 -0
- package/dist/hooks/use-swap.js +189 -0
- package/dist/hooks/use-token-data.d.ts +2 -1
- package/dist/hooks/use-token-data.js +42 -13
- package/dist/hooks/use-token-data.test.js +78 -3
- package/dist/lib/currency-selection.js +6 -4
- package/dist/lib/currency-selection.test.d.ts +1 -0
- package/dist/lib/currency-selection.test.js +16 -0
- package/dist/lib/execution.d.ts +2 -0
- package/dist/lib/execution.js +6 -0
- package/dist/lib/execution.test.d.ts +1 -0
- package/dist/lib/execution.test.js +24 -0
- package/dist/lib/format.d.ts +5 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/quote.d.ts +10 -1
- package/dist/lib/quote.js +12 -0
- package/dist/lib/slippage.d.ts +11 -0
- package/dist/lib/slippage.js +17 -0
- package/dist/styles/root.js +59 -59
- 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,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
|
-
|
|
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,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";
|