@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.
Files changed (38) hide show
  1. package/dist/components/currency-panel.d.ts +17 -0
  2. package/dist/components/currency-panel.js +22 -0
  3. package/dist/components/execution-modal.js +28 -5
  4. package/dist/components/execution-modal.test.d.ts +1 -0
  5. package/dist/components/execution-modal.test.js +92 -0
  6. package/dist/components/manual-wallet-modal.d.ts +8 -0
  7. package/dist/components/manual-wallet-modal.js +19 -0
  8. package/dist/components/output-wallet-selector.d.ts +7 -0
  9. package/dist/components/output-wallet-selector.js +10 -0
  10. package/dist/components/quote-breakdown.d.ts +5 -0
  11. package/dist/components/quote-breakdown.js +13 -0
  12. package/dist/components/styles.d.ts +1 -0
  13. package/dist/components/styles.js +14 -2
  14. package/dist/components/swap-recipient.test.js +29 -1
  15. package/dist/components/swap-settings.d.ts +9 -0
  16. package/dist/components/swap-settings.js +24 -0
  17. package/dist/components/swap.d.ts +2 -28
  18. package/dist/components/swap.js +27 -312
  19. package/dist/components/token-picker.test.js +11 -0
  20. package/dist/hooks/use-dismiss.d.ts +3 -0
  21. package/dist/hooks/use-dismiss.js +26 -0
  22. package/dist/hooks/use-swap-notifications.d.ts +10 -0
  23. package/dist/hooks/use-swap-notifications.js +53 -0
  24. package/dist/hooks/use-swap-quote.js +49 -51
  25. package/dist/hooks/use-swap.d.ts +47 -0
  26. package/dist/hooks/use-swap.js +189 -0
  27. package/dist/hooks/use-token-data.js +3 -3
  28. package/dist/hooks/use-token-data.test.js +33 -3
  29. package/dist/lib/currency-selection.js +6 -4
  30. package/dist/lib/currency-selection.test.d.ts +1 -0
  31. package/dist/lib/currency-selection.test.js +16 -0
  32. package/dist/lib/format.d.ts +5 -0
  33. package/dist/lib/format.js +20 -0
  34. package/dist/lib/quote.d.ts +10 -1
  35. package/dist/lib/quote.js +12 -0
  36. package/dist/lib/slippage.d.ts +11 -0
  37. package/dist/lib/slippage.js +17 -0
  38. 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
- 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: showOutcome || index < swapData.data.steps.length - 1 }, step.id ?? index))), showOutcome && (_jsx(ProgressStep, { title: stage.kind === "refunded"
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,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
+ }
@@ -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
- &:focus,
697
- &:focus-visible {
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 accounts = [{ address: sender, chainType: ChainType.EVM }];
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 { type ReactNode } from "react";
2
- import { useSwapExecution } from "../hooks/use-swap-execution";
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;