@coin-voyage/swapkit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/currency-panel.d.ts +17 -0
- package/dist/components/currency-panel.js +22 -0
- package/dist/components/execution-modal.js +28 -5
- package/dist/components/execution-modal.test.d.ts +1 -0
- package/dist/components/execution-modal.test.js +92 -0
- package/dist/components/manual-wallet-modal.d.ts +8 -0
- package/dist/components/manual-wallet-modal.js +19 -0
- package/dist/components/output-wallet-selector.d.ts +7 -0
- package/dist/components/output-wallet-selector.js +10 -0
- package/dist/components/quote-breakdown.d.ts +5 -0
- package/dist/components/quote-breakdown.js +13 -0
- package/dist/components/styles.d.ts +1 -0
- package/dist/components/styles.js +14 -2
- package/dist/components/swap-recipient.test.js +29 -1
- package/dist/components/swap-settings.d.ts +9 -0
- package/dist/components/swap-settings.js +24 -0
- package/dist/components/swap.d.ts +2 -28
- package/dist/components/swap.js +27 -312
- package/dist/components/token-picker.test.js +11 -0
- package/dist/hooks/use-dismiss.d.ts +3 -0
- package/dist/hooks/use-dismiss.js +26 -0
- package/dist/hooks/use-swap-notifications.d.ts +10 -0
- package/dist/hooks/use-swap-notifications.js +53 -0
- package/dist/hooks/use-swap-quote.js +49 -51
- package/dist/hooks/use-swap.d.ts +47 -0
- package/dist/hooks/use-swap.js +189 -0
- package/dist/hooks/use-token-data.js +3 -3
- package/dist/hooks/use-token-data.test.js +33 -3
- package/dist/lib/currency-selection.js +6 -4
- package/dist/lib/currency-selection.test.d.ts +1 -0
- package/dist/lib/currency-selection.test.js +16 -0
- package/dist/lib/format.d.ts +5 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/quote.d.ts +10 -1
- package/dist/lib/quote.js +12 -0
- package/dist/lib/slippage.d.ts +11 -0
- package/dist/lib/slippage.js +17 -0
- package/package.json +3 -3
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import { getChainExplorerTxUrl, getChainName } from "@coin-voyage/shared/chain";
|
|
|
3
3
|
import { deliversInSwapTransaction } from "../lib/execution";
|
|
4
4
|
import { AlertCircleIcon, ArrowRightIcon, CheckIcon, StatusDot, WarningIcon } from "./icons";
|
|
5
5
|
import { Modal } from "./modal";
|
|
6
|
-
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";
|
|
7
7
|
import { TokenIcon } from "./token-icon";
|
|
8
8
|
function completedLabel(step) {
|
|
9
9
|
return step.action === "approve" ? "Approved" : "Sent";
|
|
@@ -14,6 +14,10 @@ function paymentStepState(stage, step, index, total) {
|
|
|
14
14
|
const explorerUrl = sourceTxHash && sourceChainId ? getChainExplorerTxUrl(sourceChainId, sourceTxHash) : undefined;
|
|
15
15
|
const completed = { tone: "complete", label: completedLabel(step) };
|
|
16
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 };
|
|
17
21
|
switch (stage.kind) {
|
|
18
22
|
case "awaiting_payment":
|
|
19
23
|
if (index < stage.paymentStepIndex)
|
|
@@ -24,15 +28,32 @@ function paymentStepState(stage, step, index, total) {
|
|
|
24
28
|
case "source_confirming":
|
|
25
29
|
return isLast ? { tone: "active", label: "Confirming...", explorerUrl } : completed;
|
|
26
30
|
case "executing":
|
|
27
|
-
return isLast ? { tone: "active", label: "Processing...", explorerUrl } : completed;
|
|
31
|
+
return isLast ? (crossChain ? confirmed : { tone: "active", label: "Processing...", explorerUrl }) : completed;
|
|
28
32
|
case "done":
|
|
29
|
-
return isLast ? { tone: "complete", explorerUrl } : completed;
|
|
33
|
+
return isLast ? (crossChain ? confirmed : { tone: "complete", explorerUrl }) : completed;
|
|
30
34
|
case "refunded":
|
|
31
|
-
return isLast ? { ...completed, explorerUrl } : completed;
|
|
35
|
+
return isLast ? (crossChain ? confirmed : { ...completed, explorerUrl }) : completed;
|
|
32
36
|
default:
|
|
33
37
|
return { tone: "muted", label: "Awaiting" };
|
|
34
38
|
}
|
|
35
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
|
+
}
|
|
36
57
|
function outcomeState(stage) {
|
|
37
58
|
if (!("swapData" in stage) || !stage.swapData)
|
|
38
59
|
return { tone: "muted", label: "Awaiting" };
|
|
@@ -83,7 +104,9 @@ export function ExecutionModal({ stage, onClose }) {
|
|
|
83
104
|
// A same-chain swap settles in the user's own transaction, so a receive step would only repeat the swap step.
|
|
84
105
|
const hasSeparateDelivery = stage.kind === "done" && stage.destinationTxHash !== undefined && stage.destinationTxHash !== stage.sourceTxHash;
|
|
85
106
|
const showOutcome = stage.kind === "refunded" || hasSeparateDelivery || !swapData || !deliversInSwapTransaction(swapData);
|
|
86
|
-
|
|
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"
|
|
87
110
|
? `Refund ${swapData.data.input.currency.symbol} on ${getChainName(swapData.data.input.currency.chain_id) ?? "Unknown network"}`
|
|
88
111
|
: `Receive ${swapData.data.output.currency.symbol} on ${getChainName(swapData.data.output.currency.chain_id) ?? "Unknown network"}`, state: outcomeState(stage), connected: false }))] })] })) }));
|
|
89
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
|
+
}
|
|
@@ -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";
|
|
@@ -693,8 +693,8 @@ export const SearchInput = styled(CompactInput) `
|
|
|
693
693
|
opacity: 1;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
-
|
|
697
|
-
|
|
696
|
+
&&:focus,
|
|
697
|
+
&&:focus-visible {
|
|
698
698
|
outline: none;
|
|
699
699
|
box-shadow: none;
|
|
700
700
|
}
|
|
@@ -711,6 +711,11 @@ export const SearchField = styled.div `
|
|
|
711
711
|
border-radius: 8px;
|
|
712
712
|
background: var(--sk-background);
|
|
713
713
|
|
|
714
|
+
&:focus-within {
|
|
715
|
+
outline: 2px solid var(--sk-accent);
|
|
716
|
+
outline-offset: -1px;
|
|
717
|
+
}
|
|
718
|
+
|
|
714
719
|
> svg {
|
|
715
720
|
flex: 0 0 16px;
|
|
716
721
|
color: var(--sk-text-muted);
|
|
@@ -1147,6 +1152,13 @@ export const ExecutionContent = styled.div `
|
|
|
1147
1152
|
display: grid;
|
|
1148
1153
|
gap: 16px;
|
|
1149
1154
|
`;
|
|
1155
|
+
export const ExecutionEstimate = styled.p `
|
|
1156
|
+
margin: 0;
|
|
1157
|
+
color: var(--sk-text-muted);
|
|
1158
|
+
font-size: 12px;
|
|
1159
|
+
line-height: 16px;
|
|
1160
|
+
text-align: center;
|
|
1161
|
+
`;
|
|
1150
1162
|
export const TokenRoute = styled.div `
|
|
1151
1163
|
display: flex;
|
|
1152
1164
|
align-items: center;
|
|
@@ -17,6 +17,7 @@ vi.mock("../hooks/use-token-data", () => ({
|
|
|
17
17
|
{ currency: destination, balance: "7.5" },
|
|
18
18
|
{ currency: unheld },
|
|
19
19
|
{ currency: solana },
|
|
20
|
+
{ currency: zcash },
|
|
20
21
|
],
|
|
21
22
|
accounts,
|
|
22
23
|
isLoading: false,
|
|
@@ -40,7 +41,12 @@ vi.mock("./wallet-connector", () => ({ WalletConnector: () => null }));
|
|
|
40
41
|
vi.mock("./execution-modal", () => ({ ExecutionModal: () => null }));
|
|
41
42
|
const sender = "0x1111111111111111111111111111111111111111";
|
|
42
43
|
const recipient = "0x2222222222222222222222222222222222222222";
|
|
43
|
-
const
|
|
44
|
+
const bitcoinWallet = "bc1q048uhadg63tlua07htxaufxgqe5yz798vlwqxw";
|
|
45
|
+
const zcashRecipient = "t1QidWX3ojKEXAYJSr7S8pUDUQ2T81dgYEc";
|
|
46
|
+
const accounts = [
|
|
47
|
+
{ address: sender, chainType: ChainType.EVM },
|
|
48
|
+
{ address: bitcoinWallet, chainType: ChainType.UTXO },
|
|
49
|
+
];
|
|
44
50
|
const source = {
|
|
45
51
|
id: "eth",
|
|
46
52
|
chain_id: ChainId.ETH,
|
|
@@ -71,6 +77,13 @@ const solana = {
|
|
|
71
77
|
symbol: "SOL",
|
|
72
78
|
decimals: 9,
|
|
73
79
|
};
|
|
80
|
+
const zcash = {
|
|
81
|
+
id: "zec",
|
|
82
|
+
chain_id: ChainId.ZEC,
|
|
83
|
+
name: "Zcash",
|
|
84
|
+
symbol: "ZEC",
|
|
85
|
+
decimals: 8,
|
|
86
|
+
};
|
|
74
87
|
beforeEach(() => execute.mockClear());
|
|
75
88
|
afterEach(cleanup);
|
|
76
89
|
function renderSwap(output = destination) {
|
|
@@ -133,4 +146,19 @@ describe("Swap destination recipient", () => {
|
|
|
133
146
|
expect(screen.getByRole("menuitem", { name: "Connect Wallet" })).toBeTruthy();
|
|
134
147
|
expect(screen.getByRole("menuitem", { name: "Paste Address" })).toBeTruthy();
|
|
135
148
|
});
|
|
149
|
+
it("requires a pasted address for Zcash instead of the connected Bitcoin wallet", () => {
|
|
150
|
+
renderSwap(zcash);
|
|
151
|
+
expect(screen.queryByRole("button", { name: "Swap" })).toBeNull();
|
|
152
|
+
expect(screen.getByRole("button", { name: "Swap tokens" }).hasAttribute("disabled")).toBe(true);
|
|
153
|
+
fireEvent.click(screen.getByRole("button", { name: /^Output wallet:/ }));
|
|
154
|
+
expect(screen.queryByRole("menuitem", { name: "Connect Wallet" })).toBeNull();
|
|
155
|
+
fireEvent.click(screen.getByRole("button", { name: "Paste Zcash Address" }));
|
|
156
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Wallet address" }), { target: { value: bitcoinWallet } });
|
|
157
|
+
fireEvent.click(screen.getByRole("button", { name: "Use address" }));
|
|
158
|
+
expect(screen.getByText("Enter a valid address for the destination network")).toBeTruthy();
|
|
159
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Wallet address" }), { target: { value: zcashRecipient } });
|
|
160
|
+
fireEvent.click(screen.getByRole("button", { name: "Use address" }));
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: "Swap" }));
|
|
162
|
+
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ destination: expect.objectContaining({ recipient: zcashRecipient }) }), sender);
|
|
163
|
+
});
|
|
136
164
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SlippageMode } from "../hooks/use-swap-state";
|
|
2
|
+
import type { ResolvedSlippage } from "../lib/slippage";
|
|
3
|
+
export declare function SwapSettings({ slippageMode, slippagePercent, warning, onSlippageModeChange, onSlippagePercentChange, }: {
|
|
4
|
+
slippageMode: SlippageMode;
|
|
5
|
+
slippagePercent: string;
|
|
6
|
+
warning?: ResolvedSlippage["warning"];
|
|
7
|
+
onSlippageModeChange: (mode: SlippageMode) => void;
|
|
8
|
+
onSlippagePercentChange: (value: string) => void;
|
|
9
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useRef, useState } from "react";
|
|
3
|
+
import { useDismiss } from "../hooks/use-dismiss";
|
|
4
|
+
import { isValidSwapAmountInput } from "../lib/amount-input";
|
|
5
|
+
import { InfoIcon, SettingsIcon } from "./icons";
|
|
6
|
+
import { Hint, IconButton, InfoButton, InfoWrap, SettingsLabel, SettingsPanel, SettingsRow, SettingsTooltip, SlippageControl, SlippageInput, SlippageInputWrap, SlippageModeButton, ToolbarGroup, } from "./styles";
|
|
7
|
+
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.";
|
|
8
|
+
function SlippageInfoTooltip({ id }) {
|
|
9
|
+
const [hovered, setHovered] = useState(false);
|
|
10
|
+
const [focused, setFocused] = useState(false);
|
|
11
|
+
const open = hovered || focused;
|
|
12
|
+
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 }))] }));
|
|
13
|
+
}
|
|
14
|
+
export function SwapSettings({ slippageMode, slippagePercent, warning, onSlippageModeChange, onSlippagePercentChange, }) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const groupRef = useRef(null);
|
|
17
|
+
const slippageInputId = useId();
|
|
18
|
+
const slippageTooltipId = useId();
|
|
19
|
+
useDismiss(groupRef, open, () => setOpen(false));
|
|
20
|
+
return (_jsxs(ToolbarGroup, { ref: groupRef, "$pushRight": true, children: [_jsxs(IconButton, { type: "button", "aria-label": "Swap settings", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(SettingsIcon, {}), _jsx("span", { children: slippageMode === "auto" ? "Auto" : `${slippagePercent || "0"} %` })] }), open && (_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: () => onSlippageModeChange("auto"), children: "Auto" }), _jsxs(SlippageInputWrap, { "$active": slippageMode === "custom", children: [_jsx(SlippageInput, { id: slippageInputId, "aria-label": "Slippage tolerance", inputMode: "decimal", value: slippagePercent, onClick: () => onSlippageModeChange("custom"), onFocus: () => onSlippageModeChange("custom"), onChange: (event) => {
|
|
21
|
+
if (isValidSwapAmountInput(event.target.value))
|
|
22
|
+
onSlippagePercentChange(event.target.value);
|
|
23
|
+
} }), _jsx("span", { children: "%" })] })] })] }), warning && _jsx(Hint, { "$tone": warning.tone, children: warning.message })] }))] }));
|
|
24
|
+
}
|
|
@@ -1,28 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { type SwapToken } from "../hooks/use-token-data";
|
|
4
|
-
import { type SwapSide } from "../lib/quote";
|
|
5
|
-
import type { SwapCompleteResult, SwapError, SwapProps } from "../types";
|
|
6
|
-
export declare function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }: {
|
|
7
|
-
stage: ReturnType<typeof useSwapExecution>["stage"];
|
|
8
|
-
quoteError?: string | null;
|
|
9
|
-
tokenError?: string | null;
|
|
10
|
-
onBalancesChanged: () => void;
|
|
11
|
-
onComplete?: (result: SwapCompleteResult) => void;
|
|
12
|
-
onError?: (error: SwapError) => void;
|
|
13
|
-
}): void;
|
|
14
|
-
interface CurrencyPanelProps {
|
|
15
|
-
side: SwapSide;
|
|
16
|
-
token?: SwapToken;
|
|
17
|
-
amount: string;
|
|
18
|
-
balance?: string;
|
|
19
|
-
quoteUsd?: number | null;
|
|
20
|
-
quoteLoading?: boolean;
|
|
21
|
-
disabled?: boolean;
|
|
22
|
-
onAmountChange: (value: string) => void;
|
|
23
|
-
onSelectToken: () => void;
|
|
24
|
-
headerAction?: ReactNode;
|
|
25
|
-
}
|
|
26
|
-
export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }: CurrencyPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
27
|
-
export declare function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }?: SwapProps): import("react/jsx-runtime").JSX.Element;
|
|
28
|
-
export {};
|
|
1
|
+
import type { SwapProps } from "../types";
|
|
2
|
+
export declare function Swap(props?: SwapProps): import("react/jsx-runtime").JSX.Element;
|