@coin-voyage/swapkit 0.2.0 → 0.4.0-beta.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 (42) hide show
  1. package/dist/components/connected-wallets.d.ts +1 -1
  2. package/dist/components/connected-wallets.js +37 -14
  3. package/dist/components/connected-wallets.test.d.ts +1 -0
  4. package/dist/components/connected-wallets.test.js +104 -0
  5. package/dist/components/styles.d.ts +2 -0
  6. package/dist/components/styles.js +27 -0
  7. package/dist/components/swap-recipient.test.d.ts +1 -0
  8. package/dist/components/swap-recipient.test.js +105 -0
  9. package/dist/components/swap.d.ts +1 -1
  10. package/dist/components/swap.js +20 -19
  11. package/dist/components/swap.test.d.ts +1 -0
  12. package/dist/components/swap.test.js +76 -0
  13. package/dist/components/token-picker.d.ts +3 -1
  14. package/dist/components/token-picker.js +20 -10
  15. package/dist/components/token-picker.test.d.ts +1 -0
  16. package/dist/components/token-picker.test.js +43 -0
  17. package/dist/components/wallet-connector.d.ts +4 -2
  18. package/dist/components/wallet-connector.js +89 -20
  19. package/dist/components/wallet-connector.test.d.ts +1 -0
  20. package/dist/components/wallet-connector.test.js +143 -0
  21. package/dist/hooks/use-order-status.d.ts +1 -1
  22. package/dist/hooks/use-token-data.d.ts +1 -1
  23. package/dist/hooks/use-token-data.js +2 -2
  24. package/dist/hooks/use-token-data.test.d.ts +1 -0
  25. package/dist/hooks/use-token-data.test.js +36 -0
  26. package/dist/lib/amount-input.d.ts +1 -0
  27. package/dist/lib/amount-input.js +7 -0
  28. package/dist/lib/amount-input.test.d.ts +1 -0
  29. package/dist/lib/amount-input.test.js +17 -0
  30. package/dist/lib/output-wallet.d.ts +1 -2
  31. package/dist/lib/output-wallet.js +2 -2
  32. package/dist/lib/output-wallet.test.d.ts +1 -0
  33. package/dist/lib/output-wallet.test.js +26 -0
  34. package/dist/providers/swapkit-context.d.ts +1 -1
  35. package/dist/providers/swapkit-provider.d.ts +1 -1
  36. package/dist/providers/swapkit-provider.js +4 -2
  37. package/dist/providers/swapkit-provider.test.d.ts +1 -0
  38. package/dist/providers/swapkit-provider.test.js +20 -0
  39. package/dist/styles/root.js +1 -1
  40. package/dist/styles/theme.js +1 -1
  41. package/dist/types.d.ts +2 -1
  42. package/package.json +8 -8
@@ -21,16 +21,18 @@ function tokenDescription(token) {
21
21
  function AllNetworksMark() {
22
22
  return (_jsxs(AllNetworksLogo, { role: "img", "aria-label": "All networks", children: [_jsx("span", { children: getChainLogo(ChainId.BTC) }), _jsx("span", { children: getChainLogo(ChainId.SUI) }), _jsx("span", { children: getChainLogo(ChainId.ROBINHOOD) })] }));
23
23
  }
24
- export function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect }) {
24
+ export function TokenPicker({ open, title, tokens, selectedId, preferredChainId, onClose, onSelect, }) {
25
25
  const [query, setQuery] = useState("");
26
26
  const [selectedChainId, setSelectedChainId] = useState();
27
+ const [showAllNetworks, setShowAllNetworks] = useState(false);
27
28
  const [networkMenuOpen, setNetworkMenuOpen] = useState(false);
28
29
  const networkButtonRef = useRef(null);
29
30
  const networkMenuRef = useRef(null);
30
31
  const networks = useMemo(() => [...new Set(tokens.map(({ currency }) => currency.chain_id))].sort((left, right) => (getChainName(left) ?? "").localeCompare(getChainName(right) ?? "")), [tokens]);
32
+ const activeChainId = selectedChainId ?? (showAllNetworks ? undefined : preferredChainId);
31
33
  const filtered = useMemo(() => {
32
- const onSelectedNetwork = selectedChainId
33
- ? tokens.filter(({ currency }) => currency.chain_id === selectedChainId)
34
+ const onSelectedNetwork = activeChainId
35
+ ? tokens.filter(({ currency }) => currency.chain_id === activeChainId)
34
36
  : tokens;
35
37
  const normalized = query.trim().toLowerCase();
36
38
  if (!normalized)
@@ -38,8 +40,15 @@ export function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect
38
40
  return onSelectedNetwork.filter(({ currency }) => [currency.symbol, currency.name, getChainName(currency.chain_id), currency.address]
39
41
  .filter(Boolean)
40
42
  .some((value) => value.toLowerCase().includes(normalized)));
41
- }, [query, selectedChainId, tokens]);
42
- const selectedNetworkName = selectedChainId ? (getChainName(selectedChainId) ?? "Unknown Network") : "All Networks";
43
+ }, [activeChainId, query, tokens]);
44
+ const selectedNetworkName = activeChainId ? (getChainName(activeChainId) ?? "Unknown Network") : "All Networks";
45
+ const closePicker = () => {
46
+ setQuery("");
47
+ setSelectedChainId(undefined);
48
+ setShowAllNetworks(false);
49
+ setNetworkMenuOpen(false);
50
+ onClose();
51
+ };
43
52
  useEffect(() => {
44
53
  if (!networkMenuOpen)
45
54
  return;
@@ -52,18 +61,19 @@ export function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect
52
61
  document.addEventListener("mousedown", closeOnOutsideClick);
53
62
  return () => document.removeEventListener("mousedown", closeOnOutsideClick);
54
63
  }, [networkMenuOpen]);
55
- return (_jsx(Modal, { open: open, title: title, onClose: onClose, children: _jsxs(PickerBody, { children: [_jsxs(PickerControls, { children: [_jsxs(SearchField, { children: [_jsx(SearchIcon, {}), _jsx(SearchInput, { type: "search", value: query, placeholder: "Search", "aria-label": "Search tokens", onChange: (event) => setQuery(event.target.value) })] }), _jsxs(NetworkButton, { ref: networkButtonRef, type: "button", "aria-label": selectedNetworkName, "aria-expanded": networkMenuOpen, onClick: () => setNetworkMenuOpen((current) => !current), children: [selectedChainId ? (_jsx(NetworkLogo, { "aria-hidden": true, children: getChainLogo(selectedChainId) })) : (_jsx(AllNetworksMark, {})), _jsx(ChevronDownIcon, { size: 16, up: networkMenuOpen })] })] }), networkMenuOpen && (_jsxs(NetworkMenu, { ref: networkMenuRef, role: "listbox", "aria-label": "Networks", children: [_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": selectedChainId === undefined, onClick: () => {
64
+ return (_jsx(Modal, { open: open, title: title, onClose: closePicker, children: _jsxs(PickerBody, { children: [_jsxs(PickerControls, { children: [_jsxs(SearchField, { children: [_jsx(SearchIcon, {}), _jsx(SearchInput, { type: "search", value: query, placeholder: "Search", "aria-label": "Search tokens", onChange: (event) => setQuery(event.target.value) })] }), _jsxs(NetworkButton, { ref: networkButtonRef, type: "button", "aria-label": selectedNetworkName, "aria-expanded": networkMenuOpen, onClick: () => setNetworkMenuOpen((current) => !current), children: [activeChainId ? _jsx(NetworkLogo, { "aria-hidden": true, children: getChainLogo(activeChainId) }) : _jsx(AllNetworksMark, {}), _jsx(ChevronDownIcon, { size: 16, up: networkMenuOpen })] })] }), networkMenuOpen && (_jsxs(NetworkMenu, { ref: networkMenuRef, role: "listbox", "aria-label": "Networks", children: [_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": activeChainId === undefined, onClick: () => {
56
65
  setSelectedChainId(undefined);
66
+ setShowAllNetworks(true);
57
67
  setNetworkMenuOpen(false);
58
- }, children: [_jsx(AllNetworksMark, {}), "All Networks"] }), networks.map((chainId) => (_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": chainId === selectedChainId, onClick: () => {
68
+ }, children: [_jsx(AllNetworksMark, {}), "All Networks"] }), networks.map((chainId) => (_jsxs(NetworkOption, { type: "button", role: "option", "aria-selected": chainId === activeChainId, onClick: () => {
59
69
  setSelectedChainId(chainId);
70
+ setShowAllNetworks(false);
60
71
  setNetworkMenuOpen(false);
61
72
  }, children: [_jsx(NetworkLogo, { "aria-hidden": true, children: getChainLogo(chainId) }), getChainName(chainId)] }, chainId)))] })), _jsxs(TokenList, { role: "listbox", "aria-label": title, children: [filtered.map((token) => {
62
73
  const id = currencyID(token.currency);
63
74
  return (_jsxs(TokenOption, { type: "button", role: "option", "aria-label": `${token.currency.symbol} ${token.currency.name}`, "aria-selected": id === selectedId, onClick: () => {
64
75
  onSelect(token);
65
- setQuery("");
66
- onClose();
67
- }, children: [_jsx(TokenIcon, { currency: token.currency, size: 40 }), _jsxs(TokenMeta, { children: [_jsx("strong", { children: token.currency.name }), _jsx("small", { children: tokenDescription(token) })] }), _jsxs(TokenBalance, { children: [_jsx("strong", { children: formatUsd(token.balanceUsd) }), _jsx("small", { children: token.balance === undefined ? "" : token.balance.toLocaleString() })] })] }, id));
76
+ closePicker();
77
+ }, children: [_jsx(TokenIcon, { currency: token.currency, size: 40 }), _jsxs(TokenMeta, { children: [_jsx("strong", { children: token.currency.name }), _jsx("small", { children: tokenDescription(token) })] }), _jsxs(TokenBalance, { children: [_jsx("strong", { children: formatUsd(token.balanceUsd) }), _jsx("small", { children: token.balance === undefined ? "" : Number(token.balance).toLocaleString() })] })] }, id));
68
78
  }), filtered.length === 0 && _jsx(EmptyState, { children: "No matching tokens" })] })] }) }));
69
79
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { ChainId } from "@coin-voyage/shared/types";
4
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TokenPicker } from "./token-picker";
7
+ vi.mock("./modal", () => ({
8
+ Modal: ({ open, title, children }) => open ? (_jsx("div", { role: "dialog", "aria-label": title, children: children })) : null,
9
+ }));
10
+ afterEach(cleanup);
11
+ function token(currency) {
12
+ return { currency };
13
+ }
14
+ const tokens = [
15
+ token({ id: "sol", chain_id: ChainId.SOL, name: "Solana", symbol: "SOL", decimals: 9 }),
16
+ token({
17
+ id: "usdc-sol",
18
+ chain_id: ChainId.SOL,
19
+ address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
20
+ name: "USD Coin",
21
+ symbol: "USDC",
22
+ decimals: 6,
23
+ }),
24
+ token({ id: "eth", chain_id: ChainId.ETH, name: "Ether", symbol: "ETH", decimals: 18 }),
25
+ token({ id: "usdc-base", chain_id: ChainId.BASE, name: "Base USD Coin", symbol: "USDC", decimals: 6 }),
26
+ ];
27
+ describe("TokenPicker", () => {
28
+ it("prefilters BUY tokens to the selected SELL chain", () => {
29
+ render(_jsx(TokenPicker, { open: true, title: "Select BUY token", tokens: tokens, preferredChainId: ChainId.SOL, onClose: vi.fn(), onSelect: vi.fn() }));
30
+ expect(screen.getByRole("option", { name: "SOL Solana" })).toBeTruthy();
31
+ expect(screen.getByRole("option", { name: "USDC USD Coin" })).toBeTruthy();
32
+ expect(screen.queryByRole("option", { name: "ETH Ether" })).toBeNull();
33
+ fireEvent.click(screen.getByRole("button", { name: "Solana" }));
34
+ fireEvent.click(screen.getByRole("option", { name: /All Networks/i }));
35
+ expect(screen.getByRole("option", { name: "ETH Ether" })).toBeTruthy();
36
+ });
37
+ it("does not include other networks with the same chain type", () => {
38
+ render(_jsx(TokenPicker, { open: true, title: "Select BUY token", tokens: tokens, preferredChainId: ChainId.ETH, onClose: vi.fn(), onSelect: vi.fn() }));
39
+ expect(screen.getByRole("option", { name: "ETH Ether" })).toBeTruthy();
40
+ expect(screen.queryByRole("option", { name: "USDC Base USD Coin" })).toBeNull();
41
+ expect(screen.queryByRole("option", { name: "SOL Solana" })).toBeNull();
42
+ });
43
+ });
@@ -1,8 +1,10 @@
1
- import type { ChainType } from "@coin-voyage/shared/types";
1
+ import { ChainType } from "@coin-voyage/shared/types";
2
2
  interface WalletConnectorProps {
3
3
  open: boolean;
4
4
  chainType?: ChainType;
5
+ intent?: "connect" | "switch";
6
+ beforeConnect?: () => Promise<unknown>;
5
7
  onClose: () => void;
6
8
  }
7
- export declare function WalletConnector({ open, chainType, onClose }: WalletConnectorProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function WalletConnector(props: WalletConnectorProps): import("react/jsx-runtime").JSX.Element | null;
8
10
  export {};
@@ -1,33 +1,102 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createWalletConnectModalConnector } from "@coin-voyage/crypto/connectors";
2
3
  import { useInstalledWallets, useUniversalConnect } from "@coin-voyage/crypto/hooks";
4
+ import { getConnectorId, getErrorMessage, isWalletConnectionCancelledError } from "@coin-voyage/crypto/utils";
3
5
  import { getChainTypeName } from "@coin-voyage/shared/chain";
4
- import { useMemo, useState } from "react";
5
- import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta } from "./styles";
6
+ import { ChainType } from "@coin-voyage/shared/types";
7
+ import { useMemo, useRef, useState } from "react";
8
+ import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta, WalletSwitchButton, } from "./styles";
6
9
  import { Modal } from "./modal";
7
10
  import { WalletIconDisplay } from "./wallet-icon";
8
- export function WalletConnector({ open, chainType, onClose }) {
11
+ const getWalletDisplayName = (wallet) => wallet.id.toLowerCase() === "walletconnect" ? "Mobile Wallets" : wallet.name;
12
+ const addWalletConnectModalStyle = () => {
13
+ const style = document.createElement("style");
14
+ style.textContent = "w3m-modal, wcm-modal { --wcm-z-index: 2147483647; --w3m-z-index: 2147483647; }";
15
+ document.head.appendChild(style);
16
+ return style;
17
+ };
18
+ export function WalletConnector(props) {
19
+ if (!props.open)
20
+ return null;
21
+ return _jsx(OpenWalletConnector, { ...props });
22
+ }
23
+ function OpenWalletConnector({ chainType, intent = "connect", beforeConnect, onClose }) {
9
24
  const installedWallets = useInstalledWallets();
10
25
  const wallets = useMemo(() => chainType
11
26
  ? installedWallets.filter((wallet) => wallet.connectors.some((connector) => connector.chainType === chainType))
12
27
  : installedWallets, [chainType, installedWallets]);
13
28
  const [error, setError] = useState(null);
29
+ const [pending, setPending] = useState(false);
30
+ const [selectedWallet, setSelectedWallet] = useState(null);
31
+ const pendingRef = useRef(false);
32
+ const selectedConnectors = selectedWallet
33
+ ? selectedWallet.connectors.filter((connector, index, connectors) => connectors.findIndex((candidate) => candidate.chainType === connector.chainType) === index)
34
+ : [];
35
+ const close = () => {
36
+ pendingRef.current = false;
37
+ onClose();
38
+ };
39
+ const showError = (error) => {
40
+ if (isWalletConnectionCancelledError(error))
41
+ return;
42
+ setError(getErrorMessage(error) || "Unable to connect wallet");
43
+ };
14
44
  const { connect } = useUniversalConnect({
15
- onSuccess: onClose,
16
- onError: (connectError) => {
17
- const message = connectError instanceof Error ? connectError.message : "Unable to connect wallet";
18
- if (!message.toLowerCase().includes("rejected"))
19
- setError(message);
20
- },
45
+ onSuccess: close,
46
+ onError: showError,
21
47
  });
22
- return (_jsxs(Modal, { open: open, title: "Connect wallet", onClose: onClose, children: [_jsx(WalletList, { children: wallets.map((wallet) => {
23
- const connector = wallet.connectors.find((candidate) => !chainType || candidate.chainType === chainType);
24
- if (!connector)
25
- return null;
26
- const chainTypes = [...new Set(wallet.connectors.map((candidate) => candidate.chainType))];
27
- const capabilityLabel = chainTypes.map((candidate) => getChainTypeName(candidate)).join(" · ");
28
- return (_jsxs(WalletOption, { type: "button", "aria-label": `Connect ${wallet.name} wallet`, onClick: () => {
29
- setError(null);
30
- void connect({ walletConnector: connector });
31
- }, children: [_jsx(WalletIconDisplay, { icon: wallet.icon, name: wallet.name }), _jsxs(WalletOptionMeta, { children: [_jsxs("span", { children: [_jsx("strong", { children: wallet.name }), chainTypes.length > 1 && _jsx(MultichainBadge, { children: "Multichain" })] }), _jsx("small", { children: chainTypes.length > 1 ? capabilityLabel : `${capabilityLabel} wallet` })] })] }, wallet.id));
32
- }) }), wallets.length === 0 && _jsx(EmptyState, { children: "No compatible installed wallets found" }), error && _jsx(Hint, { "$tone": "danger", children: error })] }));
48
+ const handleConnect = async (connector) => {
49
+ if (pendingRef.current)
50
+ return;
51
+ pendingRef.current = true;
52
+ setPending(true);
53
+ setError(null);
54
+ let walletConnectModalStyle;
55
+ try {
56
+ await beforeConnect?.();
57
+ const connectorId = getConnectorId(connector.connector);
58
+ const isWalletConnect = connectorId?.toLowerCase().includes("walletconnect");
59
+ if (isWalletConnect)
60
+ walletConnectModalStyle = addWalletConnectModalStyle();
61
+ const walletConnector = connector.chainType === ChainType.EVM && isWalletConnect
62
+ ? { ...connector, connector: await createWalletConnectModalConnector(connector.connector) }
63
+ : connector;
64
+ await connect({ walletConnector });
65
+ }
66
+ catch (error) {
67
+ showError(error);
68
+ }
69
+ finally {
70
+ walletConnectModalStyle?.remove();
71
+ const shouldUpdatePending = pendingRef.current;
72
+ pendingRef.current = false;
73
+ if (shouldUpdatePending)
74
+ setPending(false);
75
+ }
76
+ };
77
+ const chooseWallet = (wallet) => {
78
+ const connectors = wallet.connectors.filter(({ chainType: connectorChainType }) => !chainType || connectorChainType === chainType);
79
+ if (!chainType && new Set(connectors.map((connector) => connector.chainType)).size > 1) {
80
+ setSelectedWallet(wallet);
81
+ return;
82
+ }
83
+ if (connectors[0])
84
+ void handleConnect(connectors[0]);
85
+ };
86
+ const modalTitle = selectedWallet ? "Choose network" : intent === "switch" ? "Switch wallet" : "Connect wallet";
87
+ return (_jsxs(Modal, { open: true, title: modalTitle, onClose: close, closeDisabled: pending, children: [selectedWallet && (_jsx(WalletSwitchButton, { type: "button", disabled: pending, onClick: () => setSelectedWallet(null), children: "Back" })), _jsx(WalletList, { "aria-busy": pending, children: selectedWallet
88
+ ? selectedConnectors.map((connector) => {
89
+ const chainName = getChainTypeName(connector.chainType);
90
+ const walletName = getWalletDisplayName(selectedWallet);
91
+ return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `Connect ${walletName} on ${chainName}`, onClick: () => void handleConnect(connector), children: [_jsx(WalletIconDisplay, { icon: selectedWallet.icon, name: chainName }), _jsxs(WalletOptionMeta, { children: [_jsx("span", { children: _jsx("strong", { children: chainName }) }), _jsxs("small", { children: [chainName, " network"] })] })] }, connector.chainType));
92
+ })
93
+ : wallets.map((wallet) => {
94
+ const connector = wallet.connectors.find((candidate) => !chainType || candidate.chainType === chainType);
95
+ if (!connector)
96
+ return null;
97
+ const walletName = getWalletDisplayName(wallet);
98
+ const chainTypes = [...new Set(wallet.connectors.map(({ chainType }) => chainType))];
99
+ const capabilityLabel = chainTypes.map(getChainTypeName).join(" · ");
100
+ return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `${intent === "switch" ? "Switch to" : "Connect"} ${walletName} wallet`, onClick: () => chooseWallet(wallet), children: [_jsx(WalletIconDisplay, { icon: wallet.icon, name: walletName }), _jsxs(WalletOptionMeta, { children: [_jsxs("span", { children: [_jsx("strong", { children: walletName }), chainTypes.length > 1 && _jsx(MultichainBadge, { children: "Multichain" })] }), _jsx("small", { children: chainTypes.length > 1 ? capabilityLabel : `${capabilityLabel} wallet` })] })] }, wallet.id));
101
+ }) }), !selectedWallet && wallets.length === 0 && _jsx(EmptyState, { children: "No compatible installed wallets found" }), error && (_jsx(Hint, { "$tone": "danger", role: "alert", children: error }))] }));
33
102
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { ChainType } from "@coin-voyage/shared/types";
4
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { WalletConnector } from "./wallet-connector";
8
+ import { Overlay } from "./styles";
9
+ const connectWallet = vi.fn();
10
+ const createWalletConnectModalConnector = vi.fn();
11
+ let connectErrorHandler;
12
+ let installedWallets = [
13
+ {
14
+ id: "phantom",
15
+ name: "Phantom",
16
+ connectors: [{ chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } }],
17
+ },
18
+ ];
19
+ vi.mock("@coin-voyage/crypto/hooks", () => ({
20
+ useInstalledWallets: () => installedWallets,
21
+ useUniversalConnect: ({ onError }) => {
22
+ connectErrorHandler = onError;
23
+ return { connect: connectWallet };
24
+ },
25
+ }));
26
+ vi.mock("@coin-voyage/crypto/connectors", () => ({
27
+ createWalletConnectModalConnector: (...args) => createWalletConnectModalConnector(...args),
28
+ }));
29
+ vi.mock("./modal", () => ({
30
+ Modal: ({ open, title, closeDisabled, children, }) => open ? (_jsx("div", { role: "dialog", "aria-label": title, "data-close-disabled": closeDisabled || undefined, children: children })) : null,
31
+ }));
32
+ afterEach(() => {
33
+ cleanup();
34
+ connectWallet.mockReset();
35
+ createWalletConnectModalConnector.mockReset();
36
+ connectErrorHandler = undefined;
37
+ installedWallets = [
38
+ {
39
+ id: "phantom",
40
+ name: "Phantom",
41
+ connectors: [{ chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } }],
42
+ },
43
+ ];
44
+ });
45
+ describe("WalletConnector", () => {
46
+ it("keeps the wallet selector below the WalletConnect modal layer", () => {
47
+ render(_jsx(Overlay, { "data-testid": "wallet-selector-overlay" }));
48
+ expect(Number(getComputedStyle(screen.getByTestId("wallet-selector-overlay")).zIndex)).toBeLessThan(2147483647);
49
+ });
50
+ it("uses switch language when replacing a connected wallet", () => {
51
+ render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, intent: "switch", onClose: vi.fn() }));
52
+ expect(screen.getByRole("dialog", { name: "Switch wallet" })).not.toBeNull();
53
+ expect(screen.getByRole("button", { name: "Switch to Phantom wallet" })).not.toBeNull();
54
+ });
55
+ it("ignores repeated selections while a switch is pending", async () => {
56
+ const user = userEvent.setup();
57
+ let finishConnect;
58
+ connectWallet.mockImplementation(() => new Promise((resolve) => {
59
+ finishConnect = resolve;
60
+ }));
61
+ render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, intent: "switch", onClose: vi.fn() }));
62
+ const option = screen.getByRole("button", { name: "Switch to Phantom wallet" });
63
+ await user.dblClick(option);
64
+ expect(connectWallet).toHaveBeenCalledTimes(1);
65
+ expect(option.hasAttribute("disabled")).toBe(true);
66
+ expect(screen.getByRole("dialog").getAttribute("data-close-disabled")).toBe("true");
67
+ finishConnect?.();
68
+ await waitFor(() => expect(option.hasAttribute("disabled")).toBe(false));
69
+ });
70
+ it("asks which chain to connect for a multichain wallet", async () => {
71
+ const user = userEvent.setup();
72
+ const solanaConnector = { chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } };
73
+ installedWallets = [
74
+ {
75
+ id: "phantom",
76
+ name: "Phantom",
77
+ connectors: [{ chainType: ChainType.EVM, connector: { id: "app.phantom", name: "Phantom" } }, solanaConnector],
78
+ },
79
+ ];
80
+ render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
81
+ await user.click(screen.getByRole("button", { name: "Connect Phantom wallet" }));
82
+ expect(connectWallet).not.toHaveBeenCalled();
83
+ expect(screen.getByRole("dialog", { name: "Choose network" })).not.toBeNull();
84
+ await user.click(screen.getByRole("button", { name: "Connect Phantom on Solana" }));
85
+ expect(connectWallet).toHaveBeenCalledWith({ walletConnector: solanaConnector });
86
+ });
87
+ it("opens the WalletConnect modal before connecting an EVM wallet", async () => {
88
+ const user = userEvent.setup();
89
+ const headlessConnector = { id: "walletConnect", name: "WalletConnect" };
90
+ const modalConnector = { id: "walletConnect", name: "WalletConnect Modal" };
91
+ createWalletConnectModalConnector.mockResolvedValue(modalConnector);
92
+ installedWallets = [
93
+ {
94
+ id: "walletconnect",
95
+ name: "WalletConnect",
96
+ connectors: [{ chainType: ChainType.EVM, connector: headlessConnector }],
97
+ },
98
+ ];
99
+ render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
100
+ expect(screen.getByText("Mobile Wallets")).not.toBeNull();
101
+ await user.click(screen.getByRole("button", { name: "Connect Mobile Wallets wallet" }));
102
+ expect(createWalletConnectModalConnector).toHaveBeenCalledWith(headlessConnector);
103
+ expect(connectWallet).toHaveBeenCalledWith({
104
+ walletConnector: { chainType: ChainType.EVM, connector: modalConnector },
105
+ });
106
+ expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(false);
107
+ });
108
+ it("keeps the Solana WalletConnect modal above the wallet selector and cleans up after failure", async () => {
109
+ const user = userEvent.setup();
110
+ const solanaConnector = { id: "WalletConnect", name: "WalletConnect" };
111
+ let failConnect;
112
+ connectWallet.mockImplementation(() => new Promise((_resolve, reject) => {
113
+ failConnect = () => reject(new Error("WalletConnect failed"));
114
+ }));
115
+ installedWallets = [
116
+ {
117
+ id: "walletconnect",
118
+ name: "WalletConnect",
119
+ connectors: [{ chainType: ChainType.SOL, connector: solanaConnector }],
120
+ },
121
+ ];
122
+ render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, onClose: vi.fn() }));
123
+ await user.click(screen.getByRole("button", { name: "Connect Mobile Wallets wallet" }));
124
+ expect(connectWallet).toHaveBeenCalledWith({
125
+ walletConnector: { chainType: ChainType.SOL, connector: solanaConnector },
126
+ });
127
+ expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(true);
128
+ failConnect?.();
129
+ await waitFor(() => expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(false));
130
+ });
131
+ it("does not show viem's resource-unavailable error when the wallet prompt is closed", () => {
132
+ render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
133
+ act(() => {
134
+ connectErrorHandler?.(new Error("Requested resource not available. Details: Requested resource not available. Version: viem@2.55.10"));
135
+ });
136
+ expect(screen.queryByRole("alert")).toBeNull();
137
+ });
138
+ it("still shows actionable connection errors", () => {
139
+ render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
140
+ act(() => connectErrorHandler?.(new Error("Wallet provider unavailable")));
141
+ expect(screen.getByRole("alert").textContent).toBe("Wallet provider unavailable");
142
+ });
143
+ });
@@ -1,4 +1,4 @@
1
- import type { ApiClient } from "@coin-voyage/shared/api";
1
+ import type { ApiClient } from "@coin-voyage/shared/api/browser";
2
2
  import type { WebhookEventPayload } from "@coin-voyage/shared/types";
3
3
  interface UseOrderStatusProps {
4
4
  api: ApiClient;
@@ -1,7 +1,7 @@
1
1
  import { type Currency } from "@coin-voyage/shared/types";
2
2
  export interface SwapToken {
3
3
  currency: Currency;
4
- balance?: number;
4
+ balance?: string;
5
5
  balanceUsd?: number;
6
6
  }
7
7
  export declare function useTokenData(): {
@@ -1,5 +1,5 @@
1
1
  import { useAccount } from "@coin-voyage/crypto/hooks";
2
- import { fetchTokenList, tokenToCurrency } from "@coin-voyage/shared/currency";
2
+ import { fetchTokenList, formatCurrencyAmount, tokenToCurrency } from "@coin-voyage/shared/currency";
3
3
  import { currencyID } from "@coin-voyage/shared/types";
4
4
  import { useEffect, useReducer, useState } from "react";
5
5
  import { useSwapKit } from "../providers/swapkit-context";
@@ -63,7 +63,7 @@ export function useTokenData() {
63
63
  walletKey,
64
64
  tokens: data.portfolio.flatMap((result) => result.currencies.map(({ balance, currency }) => ({
65
65
  currency,
66
- balance: Number(balance.ui),
66
+ balance: formatCurrencyAmount(balance.raw, currency.decimals),
67
67
  balanceUsd: balance.value_usd ?? undefined,
68
68
  }))),
69
69
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import { ChainId, ChainType } from "@coin-voyage/shared/types";
3
+ import { cleanup, renderHook, waitFor } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useTokenData } from "./use-token-data";
6
+ const { api } = vi.hoisted(() => ({ api: { portfolio: vi.fn() } }));
7
+ vi.mock("@coin-voyage/crypto/hooks", () => ({
8
+ useAccount: () => ({ accounts: [{ address: "0x123", chainType: ChainType.EVM }] }),
9
+ }));
10
+ vi.mock("@coin-voyage/shared/currency", async (importOriginal) => ({
11
+ ...(await importOriginal()),
12
+ fetchTokenList: vi.fn().mockResolvedValue({ chains: [] }),
13
+ }));
14
+ vi.mock("../providers/swapkit-context", () => ({
15
+ useSwapKit: () => ({ api }),
16
+ }));
17
+ afterEach(() => {
18
+ cleanup();
19
+ vi.clearAllMocks();
20
+ });
21
+ describe("useTokenData", () => {
22
+ it.each([
23
+ { decimals: 6, raw: "1234567", ui: "1.23", expected: "1.234567" },
24
+ { decimals: 9, raw: "1239999999", ui: "1.24", expected: "1.239999999" },
25
+ { decimals: 18, raw: "1123456789012345678", ui: "1.12", expected: "1.123456789012345678" },
26
+ { decimals: 18, raw: "1", ui: "0.00", expected: "0.000000000000000001" },
27
+ ])("preserves the raw balance at $decimals decimals ($raw)", async ({ decimals, raw, ui, expected }) => {
28
+ const currency = { id: "eth", chain_id: ChainId.ETH, name: "Ether", symbol: "ETH", decimals };
29
+ api.portfolio.mockResolvedValue({
30
+ data: { portfolio: [{ currencies: [{ currency, balance: { raw, ui, value_usd: 12.34 } }] }] },
31
+ });
32
+ const { result } = renderHook(() => useTokenData());
33
+ await waitFor(() => expect(result.current.tokens).toHaveLength(1));
34
+ expect(result.current.tokens[0]).toEqual({ currency, balance: expected, balanceUsd: 12.34 });
35
+ });
36
+ });
@@ -1,3 +1,4 @@
1
+ export declare function balanceFraction(balance: string, fraction: number, decimals: number): string;
1
2
  export declare function isValidSwapAmountInput(value: string): boolean;
2
3
  export declare function toSwapAmountInputValue(amount?: string | null): string;
3
4
  export declare function shouldValidateSwapBalance(amount: string): boolean;
@@ -1,4 +1,11 @@
1
+ import { formatCurrencyAmount, parseCurrencyAmount } from "@coin-voyage/shared/currency";
1
2
  const DECIMAL_INPUT = /^(?:\d+(?:\.\d*)?|\.\d+)?$/;
3
+ export function balanceFraction(balance, fraction, decimals) {
4
+ if (fraction === 1)
5
+ return balance;
6
+ const raw = BigInt(parseCurrencyAmount(balance, decimals));
7
+ return formatCurrencyAmount((raw * BigInt(fraction * 100)) / 100n, decimals);
8
+ }
2
9
  export function isValidSwapAmountInput(value) {
3
10
  return DECIMAL_INPUT.test(value);
4
11
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { balanceFraction } from "./amount-input";
3
+ describe("balanceFraction", () => {
4
+ it.each(["1.239999999999999999", "0.000000000000000001", "9007199254740993.123456789012345678", "0"])("preserves the full MAX balance %s", (balance) => {
5
+ expect(balanceFraction(balance, 1, 18)).toBe(balance);
6
+ });
7
+ it.each([
8
+ [0.25, "0.309999999999999999"],
9
+ [0.5, "0.619999999999999999"],
10
+ [0.75, "0.929999999999999999"],
11
+ ])("floors a %s fraction to spendable base units", (fraction, expected) => {
12
+ expect(balanceFraction("1.239999999999999999", fraction, 18)).toBe(expected);
13
+ });
14
+ it("does not round a fraction up for indivisible tokens", () => {
15
+ expect(balanceFraction("3", 0.5, 0)).toBe("1");
16
+ });
17
+ });
@@ -15,10 +15,9 @@ interface GetSwapWalletRequirementProps {
15
15
  }
16
16
  export declare function shouldUseSeparateOutputWallet(sourceChainType: ChainType | undefined, destinationChainType: ChainType | undefined): boolean;
17
17
  interface ShouldShowOutputWalletSelectorProps {
18
- requiresSeparateOutputWallet: boolean;
19
18
  outputChainType?: ChainType;
20
19
  connectionRequirement: SwapWalletRequirement;
21
20
  }
22
- export declare function shouldShowOutputWalletSelector({ requiresSeparateOutputWallet, outputChainType, connectionRequirement, }: ShouldShowOutputWalletSelectorProps): boolean;
21
+ export declare function shouldShowOutputWalletSelector({ outputChainType, connectionRequirement, }: ShouldShowOutputWalletSelectorProps): boolean;
23
22
  export declare function getSwapWalletRequirement({ inputWalletAddress, inputChainType, shouldRestrictInputChainType, outputWalletAddress, outputChainType, }: GetSwapWalletRequirementProps): SwapWalletRequirement;
24
23
  export {};
@@ -2,8 +2,8 @@ import { getChainTypeName } from "@coin-voyage/shared/chain";
2
2
  export function shouldUseSeparateOutputWallet(sourceChainType, destinationChainType) {
3
3
  return Boolean(sourceChainType && destinationChainType && sourceChainType !== destinationChainType);
4
4
  }
5
- export function shouldShowOutputWalletSelector({ requiresSeparateOutputWallet, outputChainType, connectionRequirement, }) {
6
- return Boolean(outputChainType && requiresSeparateOutputWallet && connectionRequirement.target !== "input");
5
+ export function shouldShowOutputWalletSelector({ outputChainType, connectionRequirement, }) {
6
+ return Boolean(outputChainType && connectionRequirement.target !== "input");
7
7
  }
8
8
  export function getSwapWalletRequirement({ inputWalletAddress, inputChainType, shouldRestrictInputChainType = true, outputWalletAddress, outputChainType, }) {
9
9
  if (!inputWalletAddress) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { ChainType } from "@coin-voyage/shared/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import { shouldShowOutputWalletSelector } from "./output-wallet";
4
+ describe("shouldShowOutputWalletSelector", () => {
5
+ it("allows changing the recipient when the input wallet is connected", () => {
6
+ expect(shouldShowOutputWalletSelector({
7
+ outputChainType: ChainType.EVM,
8
+ connectionRequirement: { target: "none" },
9
+ })).toBe(true);
10
+ });
11
+ it("allows choosing an address when an output wallet is required", () => {
12
+ expect(shouldShowOutputWalletSelector({
13
+ outputChainType: ChainType.SOL,
14
+ connectionRequirement: { target: "output", label: "Connect Wallet" },
15
+ })).toBe(true);
16
+ });
17
+ it("hides the selector until the input wallet is connected", () => {
18
+ expect(shouldShowOutputWalletSelector({
19
+ outputChainType: ChainType.EVM,
20
+ connectionRequirement: { target: "input", label: "Connect Wallet" },
21
+ })).toBe(false);
22
+ });
23
+ it("hides the selector without a destination chain", () => {
24
+ expect(shouldShowOutputWalletSelector({ connectionRequirement: { target: "none" } })).toBe(false);
25
+ });
26
+ });
@@ -1,4 +1,4 @@
1
- import type { ApiClient, APIEnvironment } from "@coin-voyage/shared/api";
1
+ import type { ApiClient, APIEnvironment } from "@coin-voyage/shared/api/browser";
2
2
  import type { SwapKitMode, SwapKitTheme } from "../types";
3
3
  export interface SwapKitContextValue {
4
4
  api: ApiClient;
@@ -1,2 +1,2 @@
1
1
  import type { SwapKitProviderProps } from "../types";
2
- export declare function SwapKitProvider({ apiKey, environment, mode, theme, children, }: SwapKitProviderProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function SwapKitProvider({ apiKey, environment, mode, theme, onConnect, onDisconnect, children, }: SwapKitProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useInWagmiContext } from "@coin-voyage/crypto/evm";
3
- import { ApiClient } from "@coin-voyage/shared/api";
3
+ import { useConnectCallback } from "@coin-voyage/crypto/hooks";
4
+ import { ApiClient } from "@coin-voyage/shared/api/browser";
4
5
  import { generateSessionId } from "@coin-voyage/shared/utils";
5
6
  import { useMemo, useState } from "react";
6
7
  import { SwapKitContext } from "./swapkit-context";
7
8
  import { swapkitVersion } from "../lib/version";
8
- export function SwapKitProvider({ apiKey, environment = "production", mode = "auto", theme = {}, children, }) {
9
+ export function SwapKitProvider({ apiKey, environment = "production", mode = "auto", theme = {}, onConnect, onDisconnect, children, }) {
9
10
  const inWalletContext = useInWagmiContext();
10
11
  if (!inWalletContext)
11
12
  throw new Error("SwapKitProvider must be used within a WalletProvider");
12
13
  if (!apiKey.trim())
13
14
  throw new Error("SwapKitProvider requires a public API key");
15
+ useConnectCallback({ onConnect, onDisconnect });
14
16
  const [sessionId] = useState(generateSessionId);
15
17
  const api = useMemo(() => new ApiClient({ apiKey, environment, sessionId, version: `swapkit/${swapkitVersion}` }), [apiKey, environment, sessionId]);
16
18
  const context = useMemo(() => ({ api, environment, mode, theme }), [api, environment, mode, theme]);
@@ -0,0 +1 @@
1
+ export {};