@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.
- package/dist/components/connected-wallets.d.ts +1 -1
- package/dist/components/connected-wallets.js +37 -14
- package/dist/components/connected-wallets.test.d.ts +1 -0
- package/dist/components/connected-wallets.test.js +104 -0
- package/dist/components/styles.d.ts +2 -0
- package/dist/components/styles.js +27 -0
- package/dist/components/swap-recipient.test.d.ts +1 -0
- package/dist/components/swap-recipient.test.js +105 -0
- package/dist/components/swap.d.ts +1 -1
- package/dist/components/swap.js +20 -19
- package/dist/components/swap.test.d.ts +1 -0
- package/dist/components/swap.test.js +76 -0
- package/dist/components/token-picker.d.ts +3 -1
- package/dist/components/token-picker.js +20 -10
- package/dist/components/token-picker.test.d.ts +1 -0
- package/dist/components/token-picker.test.js +43 -0
- package/dist/components/wallet-connector.d.ts +4 -2
- package/dist/components/wallet-connector.js +89 -20
- package/dist/components/wallet-connector.test.d.ts +1 -0
- package/dist/components/wallet-connector.test.js +143 -0
- package/dist/hooks/use-order-status.d.ts +1 -1
- package/dist/hooks/use-token-data.d.ts +1 -1
- package/dist/hooks/use-token-data.js +2 -2
- package/dist/hooks/use-token-data.test.d.ts +1 -0
- package/dist/hooks/use-token-data.test.js +36 -0
- package/dist/lib/amount-input.d.ts +1 -0
- package/dist/lib/amount-input.js +7 -0
- package/dist/lib/amount-input.test.d.ts +1 -0
- package/dist/lib/amount-input.test.js +17 -0
- package/dist/lib/output-wallet.d.ts +1 -2
- package/dist/lib/output-wallet.js +2 -2
- package/dist/lib/output-wallet.test.d.ts +1 -0
- package/dist/lib/output-wallet.test.js +26 -0
- package/dist/providers/swapkit-context.d.ts +1 -1
- package/dist/providers/swapkit-provider.d.ts +1 -1
- package/dist/providers/swapkit-provider.js +4 -2
- package/dist/providers/swapkit-provider.test.d.ts +1 -0
- package/dist/providers/swapkit-provider.test.js +20 -0
- package/dist/styles/root.js +1 -1
- package/dist/styles/theme.js +1 -1
- package/dist/types.d.ts +2 -1
- 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 =
|
|
33
|
-
? tokens.filter(({ currency }) => currency.chain_id ===
|
|
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
|
-
}, [
|
|
42
|
-
const selectedNetworkName =
|
|
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:
|
|
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 ===
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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:
|
|
16
|
-
onError:
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,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:
|
|
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;
|
package/dist/lib/amount-input.js
CHANGED
|
@@ -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({
|
|
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({
|
|
6
|
-
return Boolean(outputChainType &&
|
|
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 {
|
|
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 {};
|