@coin-voyage/swapkit 0.1.0-beta.2 → 0.3.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/execution-modal.js +8 -2
- package/dist/components/styles.d.ts +4 -1
- package/dist/components/styles.js +86 -16
- package/dist/components/swap.d.ts +27 -1
- package/dist/components/swap.js +41 -18
- package/dist/components/token-picker.d.ts +3 -1
- package/dist/components/token-picker.js +19 -9
- 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 +3 -1
- package/dist/components/wallet-connector.js +69 -20
- package/dist/components/wallet-connector.test.d.ts +1 -0
- package/dist/components/wallet-connector.test.js +89 -0
- package/dist/hooks/use-swap-execution.d.ts +1 -1
- package/dist/hooks/use-swap-execution.js +69 -35
- package/dist/hooks/use-swap-state.d.ts +4 -1
- package/dist/hooks/use-swap-state.js +3 -3
- package/dist/hooks/use-token-data.d.ts +1 -0
- package/dist/hooks/use-token-data.js +4 -3
- package/dist/lib/currency-selection.js +3 -4
- package/dist/lib/execution.d.ts +12 -5
- package/dist/lib/quote.d.ts +1 -1
- package/dist/lib/quote.js +7 -7
- package/dist/providers/swapkit-provider.d.ts +1 -1
- package/dist/providers/swapkit-provider.js +3 -1
- package/dist/providers/swapkit-provider.test.d.ts +1 -0
- package/dist/providers/swapkit-provider.test.js +20 -0
- package/dist/types.d.ts +2 -1
- package/package.json +4 -4
|
@@ -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
|
-
onClose();
|
|
76
|
+
closePicker();
|
|
67
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 ? "" : 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
|
+
});
|
|
@@ -2,7 +2,9 @@ import type { 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,82 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useInstalledWallets, useUniversalConnect } from "@coin-voyage/crypto/hooks";
|
|
3
|
+
import { getErrorMessage, isWalletConnectionCancelledError } from "@coin-voyage/crypto/utils";
|
|
3
4
|
import { getChainTypeName } from "@coin-voyage/shared/chain";
|
|
4
|
-
import { useMemo, useState } from "react";
|
|
5
|
-
import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta } from "./styles";
|
|
5
|
+
import { useMemo, useRef, useState } from "react";
|
|
6
|
+
import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta, WalletSwitchButton, } from "./styles";
|
|
6
7
|
import { Modal } from "./modal";
|
|
7
8
|
import { WalletIconDisplay } from "./wallet-icon";
|
|
8
|
-
export function WalletConnector(
|
|
9
|
+
export function WalletConnector(props) {
|
|
10
|
+
if (!props.open)
|
|
11
|
+
return null;
|
|
12
|
+
return _jsx(OpenWalletConnector, { ...props });
|
|
13
|
+
}
|
|
14
|
+
function OpenWalletConnector({ chainType, intent = "connect", beforeConnect, onClose }) {
|
|
9
15
|
const installedWallets = useInstalledWallets();
|
|
10
16
|
const wallets = useMemo(() => chainType
|
|
11
17
|
? installedWallets.filter((wallet) => wallet.connectors.some((connector) => connector.chainType === chainType))
|
|
12
18
|
: installedWallets, [chainType, installedWallets]);
|
|
13
19
|
const [error, setError] = useState(null);
|
|
20
|
+
const [pending, setPending] = useState(false);
|
|
21
|
+
const [selectedWallet, setSelectedWallet] = useState(null);
|
|
22
|
+
const pendingRef = useRef(false);
|
|
23
|
+
const selectedConnectors = selectedWallet
|
|
24
|
+
? selectedWallet.connectors.filter((connector, index, connectors) => connectors.findIndex((candidate) => candidate.chainType === connector.chainType) === index)
|
|
25
|
+
: [];
|
|
26
|
+
const close = () => {
|
|
27
|
+
pendingRef.current = false;
|
|
28
|
+
onClose();
|
|
29
|
+
};
|
|
30
|
+
const showError = (error) => {
|
|
31
|
+
if (isWalletConnectionCancelledError(error))
|
|
32
|
+
return;
|
|
33
|
+
setError(getErrorMessage(error) || "Unable to connect wallet");
|
|
34
|
+
};
|
|
14
35
|
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
|
-
},
|
|
36
|
+
onSuccess: close,
|
|
37
|
+
onError: showError,
|
|
21
38
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
const handleConnect = async (connector) => {
|
|
40
|
+
if (pendingRef.current)
|
|
41
|
+
return;
|
|
42
|
+
pendingRef.current = true;
|
|
43
|
+
setPending(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
await beforeConnect?.();
|
|
47
|
+
await connect({ walletConnector: connector });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
showError(error);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
const shouldUpdatePending = pendingRef.current;
|
|
54
|
+
pendingRef.current = false;
|
|
55
|
+
if (shouldUpdatePending)
|
|
56
|
+
setPending(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const chooseWallet = (wallet) => {
|
|
60
|
+
const connectors = wallet.connectors.filter(({ chainType: connectorChainType }) => !chainType || connectorChainType === chainType);
|
|
61
|
+
if (!chainType && new Set(connectors.map((connector) => connector.chainType)).size > 1) {
|
|
62
|
+
setSelectedWallet(wallet);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (connectors[0])
|
|
66
|
+
void handleConnect(connectors[0]);
|
|
67
|
+
};
|
|
68
|
+
const modalTitle = selectedWallet ? "Choose network" : intent === "switch" ? "Switch wallet" : "Connect wallet";
|
|
69
|
+
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
|
|
70
|
+
? selectedConnectors.map((connector) => {
|
|
71
|
+
const chainName = getChainTypeName(connector.chainType);
|
|
72
|
+
return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `Connect ${selectedWallet.name} 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));
|
|
73
|
+
})
|
|
74
|
+
: wallets.map((wallet) => {
|
|
75
|
+
const connector = wallet.connectors.find((candidate) => !chainType || candidate.chainType === chainType);
|
|
76
|
+
if (!connector)
|
|
77
|
+
return null;
|
|
78
|
+
const chainTypes = [...new Set(wallet.connectors.map(({ chainType }) => chainType))];
|
|
79
|
+
const capabilityLabel = chainTypes.map(getChainTypeName).join(" · ");
|
|
80
|
+
return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `${intent === "switch" ? "Switch to" : "Connect"} ${wallet.name} wallet`, onClick: () => chooseWallet(wallet), 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));
|
|
81
|
+
}) }), !selectedWallet && wallets.length === 0 && _jsx(EmptyState, { children: "No compatible installed wallets found" }), error && (_jsx(Hint, { "$tone": "danger", role: "alert", children: error }))] }));
|
|
33
82
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
const connectWallet = vi.fn();
|
|
9
|
+
let connectErrorHandler;
|
|
10
|
+
let installedWallets = [
|
|
11
|
+
{
|
|
12
|
+
id: "phantom",
|
|
13
|
+
name: "Phantom",
|
|
14
|
+
connectors: [{ chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } }],
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
vi.mock("@coin-voyage/crypto/hooks", () => ({
|
|
18
|
+
useInstalledWallets: () => installedWallets,
|
|
19
|
+
useUniversalConnect: ({ onError }) => {
|
|
20
|
+
connectErrorHandler = onError;
|
|
21
|
+
return { connect: connectWallet };
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("./modal", () => ({
|
|
25
|
+
Modal: ({ open, title, closeDisabled, children, }) => open ? (_jsx("div", { role: "dialog", "aria-label": title, "data-close-disabled": closeDisabled || undefined, children: children })) : null,
|
|
26
|
+
}));
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanup();
|
|
29
|
+
connectWallet.mockClear();
|
|
30
|
+
connectErrorHandler = undefined;
|
|
31
|
+
installedWallets = [
|
|
32
|
+
{
|
|
33
|
+
id: "phantom",
|
|
34
|
+
name: "Phantom",
|
|
35
|
+
connectors: [{ chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } }],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
});
|
|
39
|
+
describe("WalletConnector", () => {
|
|
40
|
+
it("uses switch language when replacing a connected wallet", () => {
|
|
41
|
+
render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, intent: "switch", onClose: vi.fn() }));
|
|
42
|
+
expect(screen.getByRole("dialog", { name: "Switch wallet" })).not.toBeNull();
|
|
43
|
+
expect(screen.getByRole("button", { name: "Switch to Phantom wallet" })).not.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
it("ignores repeated selections while a switch is pending", async () => {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
let finishConnect;
|
|
48
|
+
connectWallet.mockImplementation(() => new Promise((resolve) => {
|
|
49
|
+
finishConnect = resolve;
|
|
50
|
+
}));
|
|
51
|
+
render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, intent: "switch", onClose: vi.fn() }));
|
|
52
|
+
const option = screen.getByRole("button", { name: "Switch to Phantom wallet" });
|
|
53
|
+
await user.dblClick(option);
|
|
54
|
+
expect(connectWallet).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(option.hasAttribute("disabled")).toBe(true);
|
|
56
|
+
expect(screen.getByRole("dialog").getAttribute("data-close-disabled")).toBe("true");
|
|
57
|
+
finishConnect?.();
|
|
58
|
+
await waitFor(() => expect(option.hasAttribute("disabled")).toBe(false));
|
|
59
|
+
});
|
|
60
|
+
it("asks which chain to connect for a multichain wallet", async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
const solanaConnector = { chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } };
|
|
63
|
+
installedWallets = [
|
|
64
|
+
{
|
|
65
|
+
id: "phantom",
|
|
66
|
+
name: "Phantom",
|
|
67
|
+
connectors: [{ chainType: ChainType.EVM, connector: { id: "app.phantom", name: "Phantom" } }, solanaConnector],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
|
|
71
|
+
await user.click(screen.getByRole("button", { name: "Connect Phantom wallet" }));
|
|
72
|
+
expect(connectWallet).not.toHaveBeenCalled();
|
|
73
|
+
expect(screen.getByRole("dialog", { name: "Choose network" })).not.toBeNull();
|
|
74
|
+
await user.click(screen.getByRole("button", { name: "Connect Phantom on Solana" }));
|
|
75
|
+
expect(connectWallet).toHaveBeenCalledWith({ walletConnector: solanaConnector });
|
|
76
|
+
});
|
|
77
|
+
it("does not show viem's resource-unavailable error when the wallet prompt is closed", () => {
|
|
78
|
+
render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
|
|
79
|
+
act(() => {
|
|
80
|
+
connectErrorHandler?.(new Error("Requested resource not available. Details: Requested resource not available. Version: viem@2.55.10"));
|
|
81
|
+
});
|
|
82
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
it("still shows actionable connection errors", () => {
|
|
85
|
+
render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
|
|
86
|
+
act(() => connectErrorHandler?.(new Error("Wallet provider unavailable")));
|
|
87
|
+
expect(screen.getByRole("alert").textContent).toBe("Wallet provider unavailable");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChainType, SwapExecuteRequest } from "@coin-voyage/shared/types";
|
|
2
2
|
import { type SwapStage } from "../lib/execution";
|
|
3
|
-
export declare function useSwapExecution(chainType: ChainType | undefined): {
|
|
3
|
+
export declare function useSwapExecution(chainType: ChainType | undefined, currentFromAddress: string | undefined): {
|
|
4
4
|
stage: SwapStage;
|
|
5
5
|
execute: (request: SwapExecuteRequest, fromAddress: string) => Promise<void>;
|
|
6
6
|
reset: () => void;
|
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
import { usePrepareTransaction } from "@coin-voyage/crypto/hooks";
|
|
2
|
+
import { assertActiveWallet, getErrorMessage, isUserRejectedError, prepareForActiveWallet, } from "@coin-voyage/crypto/utils";
|
|
2
3
|
import { WebhookEventType } from "@coin-voyage/shared/types";
|
|
3
|
-
import { useCallback,
|
|
4
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
5
|
import { buildSwapExecuteTransactions } from "../lib/execution";
|
|
5
6
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
6
7
|
import { useOrderStatus } from "./use-order-status";
|
|
7
8
|
function errorMessage(error, fallback) {
|
|
8
|
-
const message = error
|
|
9
|
+
const message = getErrorMessage(error).trim();
|
|
9
10
|
return message || fallback;
|
|
10
11
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function watchedOrderId(stage) {
|
|
13
|
+
if (stage.kind === "awaiting_payment" || stage.kind === "source_confirming" || stage.kind === "executing") {
|
|
14
|
+
return stage.orderId;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
14
17
|
}
|
|
15
|
-
export function useSwapExecution(chainType) {
|
|
18
|
+
export function useSwapExecution(chainType, currentFromAddress) {
|
|
16
19
|
const { api } = useSwapKit();
|
|
17
20
|
const transactionActions = usePrepareTransaction(chainType);
|
|
18
|
-
const [stage,
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
+
const [stage, setStageState] = useState({ kind: "idle" });
|
|
22
|
+
const stageRef = useRef({ kind: "idle" });
|
|
23
|
+
const currentWalletRef = useRef({ address: currentFromAddress, chainType });
|
|
24
|
+
const transactionActionsRef = useRef(transactionActions);
|
|
21
25
|
const runId = useRef(0);
|
|
22
|
-
|
|
23
|
-
stageRef.current =
|
|
24
|
-
|
|
26
|
+
const setStage = useCallback((next) => {
|
|
27
|
+
stageRef.current = next;
|
|
28
|
+
setStageState(next);
|
|
29
|
+
}, []);
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const chainChanged = currentWalletRef.current?.chainType !== chainType;
|
|
32
|
+
currentWalletRef.current = { address: currentFromAddress, chainType };
|
|
33
|
+
transactionActionsRef.current = transactionActions;
|
|
34
|
+
if (chainChanged) {
|
|
35
|
+
runId.current += 1;
|
|
36
|
+
if (stageRef.current.kind !== "idle")
|
|
37
|
+
setStage({ kind: "idle" });
|
|
38
|
+
}
|
|
39
|
+
}, [chainType, currentFromAddress, setStage, transactionActions]);
|
|
25
40
|
const reset = useCallback(() => {
|
|
26
41
|
runId.current += 1;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}, []);
|
|
42
|
+
if (stageRef.current.kind !== "idle")
|
|
43
|
+
setStage({ kind: "idle" });
|
|
44
|
+
}, [setStage]);
|
|
30
45
|
const onOrderEvent = useCallback((event) => {
|
|
31
46
|
const current = stageRef.current;
|
|
47
|
+
if (current.kind === "done" || current.kind === "refunded" || current.kind === "error")
|
|
48
|
+
return;
|
|
32
49
|
if (!("swapData" in current) || !current.swapData || event.order.id !== current.swapData.order_id)
|
|
33
50
|
return;
|
|
34
51
|
const swapData = event.order.payment
|
|
@@ -36,55 +53,71 @@ export function useSwapExecution(chainType) {
|
|
|
36
53
|
: { ...current.swapData, status: event.order.status };
|
|
37
54
|
const sourceTxHash = event.order.payment?.source_tx_hash ?? ("sourceTxHash" in current ? current.sourceTxHash : undefined);
|
|
38
55
|
if (event.event === WebhookEventType.ORDER_COMPLETED) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
const destinationTxHash = event.order.payment?.destination_tx_hash ?? undefined;
|
|
57
|
+
runId.current += 1;
|
|
58
|
+
if (destinationTxHash) {
|
|
59
|
+
setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash, destinationTxHash });
|
|
60
|
+
}
|
|
61
|
+
else if (sourceTxHash) {
|
|
62
|
+
setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
setStage({ kind: "error", swapData, message: "Swap completed without a transaction hash" });
|
|
66
|
+
}
|
|
43
67
|
}
|
|
44
|
-
else if (event.event === WebhookEventType.ORDER_REFUNDED
|
|
68
|
+
else if (event.event === WebhookEventType.ORDER_REFUNDED) {
|
|
69
|
+
const refundTxHash = event.order.payment?.refund_tx_hash ?? undefined;
|
|
70
|
+
runId.current += 1;
|
|
45
71
|
setStage({
|
|
46
72
|
kind: "refunded",
|
|
47
73
|
swapData,
|
|
48
74
|
orderId: event.order.id,
|
|
49
75
|
sourceTxHash,
|
|
50
|
-
refundTxHash:
|
|
76
|
+
...(refundTxHash ? { refundTxHash } : {}),
|
|
51
77
|
});
|
|
52
|
-
setOrderId(undefined);
|
|
53
78
|
}
|
|
54
79
|
else if (event.event === WebhookEventType.ORDER_ERROR) {
|
|
80
|
+
runId.current += 1;
|
|
55
81
|
setStage({ kind: "error", swapData, message: "Swap failed" });
|
|
56
|
-
setOrderId(undefined);
|
|
57
82
|
}
|
|
58
83
|
else if (event.event === WebhookEventType.ORDER_EXECUTING) {
|
|
59
84
|
setStage({ kind: "executing", swapData, orderId: event.order.id, sourceTxHash });
|
|
60
85
|
}
|
|
61
86
|
else if (event.event === WebhookEventType.ORDER_CONFIRMING ||
|
|
62
87
|
event.event === WebhookEventType.ORDER_PARTIAL_PAYMENT) {
|
|
88
|
+
if (current.kind === "executing")
|
|
89
|
+
return;
|
|
63
90
|
setStage({ kind: "source_confirming", swapData, orderId: event.order.id, sourceTxHash });
|
|
64
91
|
}
|
|
65
|
-
}, []);
|
|
66
|
-
useOrderStatus({ api, orderId, onEvent: onOrderEvent });
|
|
92
|
+
}, [setStage]);
|
|
93
|
+
useOrderStatus({ api, orderId: watchedOrderId(stage), onEvent: onOrderEvent });
|
|
67
94
|
const execute = useCallback(async (request, fromAddress) => {
|
|
68
|
-
if (!
|
|
95
|
+
if (!transactionActionsRef.current || !chainType)
|
|
69
96
|
return;
|
|
70
97
|
const currentRun = ++runId.current;
|
|
71
98
|
let swapData = null;
|
|
72
99
|
setStage({ kind: "confirming" });
|
|
73
100
|
try {
|
|
74
|
-
const response = await api.swapExecute(
|
|
101
|
+
const { wallet, value: response } = await prepareForActiveWallet({ address: fromAddress, chainType }, () => currentWalletRef.current, ({ address }) => api.swapExecute({
|
|
102
|
+
...request,
|
|
103
|
+
source: { ...request.source, sender_address: address },
|
|
104
|
+
}));
|
|
75
105
|
if (currentRun !== runId.current)
|
|
76
106
|
return;
|
|
77
107
|
if (response.error || !response.data) {
|
|
78
108
|
setStage({ kind: "error", swapData: null, message: response.error?.message || "Failed to prepare swap" });
|
|
79
109
|
return;
|
|
80
110
|
}
|
|
111
|
+
const paymentTransactionActions = transactionActionsRef.current;
|
|
112
|
+
if (!paymentTransactionActions)
|
|
113
|
+
throw new Error("Transaction actions must be defined");
|
|
81
114
|
swapData = response.data;
|
|
82
|
-
|
|
83
|
-
const transactions = buildSwapExecuteTransactions(swapData, fromAddress);
|
|
115
|
+
const transactions = buildSwapExecuteTransactions(swapData, wallet.address);
|
|
84
116
|
let sourceTxHash;
|
|
85
117
|
for (const [paymentStepIndex, transaction] of transactions.entries()) {
|
|
86
|
-
setStage({ kind: "awaiting_payment", swapData, paymentStepIndex });
|
|
87
|
-
|
|
118
|
+
setStage({ kind: "awaiting_payment", swapData, orderId: swapData.order_id, paymentStepIndex });
|
|
119
|
+
assertActiveWallet(wallet, currentWalletRef.current);
|
|
120
|
+
sourceTxHash = await paymentTransactionActions.execute(transaction);
|
|
88
121
|
if (currentRun !== runId.current)
|
|
89
122
|
return;
|
|
90
123
|
if (!sourceTxHash)
|
|
@@ -93,13 +126,14 @@ export function useSwapExecution(chainType) {
|
|
|
93
126
|
setStage({ kind: "source_confirming", swapData, orderId: swapData.order_id, sourceTxHash });
|
|
94
127
|
}
|
|
95
128
|
catch (error) {
|
|
96
|
-
if (
|
|
129
|
+
if (currentRun !== runId.current)
|
|
130
|
+
return;
|
|
131
|
+
if (isUserRejectedError(error)) {
|
|
97
132
|
reset();
|
|
98
133
|
return;
|
|
99
134
|
}
|
|
100
|
-
setOrderId(undefined);
|
|
101
135
|
setStage({ kind: "error", swapData, message: errorMessage(error, "Transaction failed") });
|
|
102
136
|
}
|
|
103
|
-
}, [api, reset,
|
|
104
|
-
return { stage, execute, reset };
|
|
137
|
+
}, [api, chainType, reset, setStage]);
|
|
138
|
+
return useMemo(() => ({ stage, execute, reset }), [execute, reset, stage]);
|
|
105
139
|
}
|
|
@@ -47,6 +47,7 @@ export type SwapStateAction = {
|
|
|
47
47
|
wallet?: ManualOutputWallet;
|
|
48
48
|
} | {
|
|
49
49
|
type: "reset";
|
|
50
|
+
preserveCurrencies?: boolean;
|
|
50
51
|
};
|
|
51
52
|
export declare function createSwapState(defaultSlippageBps?: number): SwapState;
|
|
52
53
|
export declare function swapStateReducer(state: SwapState, action: SwapStateAction): SwapState;
|
|
@@ -65,6 +66,8 @@ export declare function useSwapState({ currencies, connectedChainTypes, defaultS
|
|
|
65
66
|
setSlippageMode: (mode: SlippageMode) => void;
|
|
66
67
|
setSlippagePercent: (value: string) => void;
|
|
67
68
|
setManualOutputWallet: (wallet?: ManualOutputWallet) => void;
|
|
68
|
-
reset: (
|
|
69
|
+
reset: (options?: {
|
|
70
|
+
preserveCurrencies?: boolean;
|
|
71
|
+
}) => void;
|
|
69
72
|
};
|
|
70
73
|
export {};
|
|
@@ -72,8 +72,8 @@ export function swapStateReducer(state, action) {
|
|
|
72
72
|
case "reset":
|
|
73
73
|
return {
|
|
74
74
|
...state,
|
|
75
|
-
sourceId: state.initialSourceId,
|
|
76
|
-
destinationId: state.initialDestinationId,
|
|
75
|
+
sourceId: action.preserveCurrencies ? state.sourceId : state.initialSourceId,
|
|
76
|
+
destinationId: action.preserveCurrencies ? state.destinationId : state.initialDestinationId,
|
|
77
77
|
inputAmount: "",
|
|
78
78
|
outputAmount: "",
|
|
79
79
|
activeSide: "input",
|
|
@@ -122,7 +122,7 @@ export function useSwapState({ currencies, connectedChainTypes, defaultSourceCur
|
|
|
122
122
|
const setSlippagePercent = useCallback((value) => dispatch({ type: "set_slippage", value }), []);
|
|
123
123
|
const setSlippageMode = useCallback((mode) => dispatch({ type: "set_slippage_mode", mode }), []);
|
|
124
124
|
const setManualOutputWallet = useCallback((wallet) => dispatch({ type: "set_manual_output_wallet", wallet }), []);
|
|
125
|
-
const reset = useCallback(() => dispatch({ type: "reset" }), []);
|
|
125
|
+
const reset = useCallback((options) => dispatch({ type: "reset", ...options }), []);
|
|
126
126
|
const resolvedState = !state.currenciesInitialized && currencies.length > 0
|
|
127
127
|
? swapStateReducer(state, {
|
|
128
128
|
type: "initialize_currencies",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
2
2
|
import { fetchTokenList, tokenToCurrency } from "@coin-voyage/shared/currency";
|
|
3
3
|
import { currencyID } from "@coin-voyage/shared/types";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
4
|
+
import { useEffect, useReducer, useState } from "react";
|
|
5
5
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
6
6
|
function mergeTokens(tokenList, portfolio) {
|
|
7
7
|
const portfolioById = new Map(portfolio.map((token) => [currencyID(token.currency), token]));
|
|
@@ -21,6 +21,7 @@ export function useTokenData() {
|
|
|
21
21
|
const [isLoading, setIsLoading] = useState(true);
|
|
22
22
|
const [tokenError, setTokenError] = useState(null);
|
|
23
23
|
const [portfolioError, setPortfolioError] = useState(null);
|
|
24
|
+
const [portfolioRevision, refreshBalances] = useReducer((revision) => revision + 1, 0);
|
|
24
25
|
useEffect(() => {
|
|
25
26
|
let active = true;
|
|
26
27
|
fetchTokenList()
|
|
@@ -73,8 +74,8 @@ export function useTokenData() {
|
|
|
73
74
|
}
|
|
74
75
|
});
|
|
75
76
|
return () => controller.abort();
|
|
76
|
-
}, [api, walletKey]);
|
|
77
|
+
}, [api, portfolioRevision, walletKey]);
|
|
77
78
|
const activePortfolioTokens = portfolioState.walletKey === walletKey ? portfolioState.tokens : [];
|
|
78
79
|
const tokens = mergeTokens(listedCurrencies, activePortfolioTokens);
|
|
79
|
-
return { tokens, accounts, isLoading, error: tokenError ?? portfolioError };
|
|
80
|
+
return { tokens, accounts, isLoading, error: tokenError ?? portfolioError, refreshBalances };
|
|
80
81
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { addressesMatch } from "@coin-voyage/crypto/utils";
|
|
1
2
|
import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
|
|
2
3
|
import { currencyID } from "@coin-voyage/shared/types";
|
|
3
|
-
function normalizedAddress(currency) {
|
|
4
|
-
return currency.address?.toLowerCase() ?? null;
|
|
5
|
-
}
|
|
6
4
|
export function currenciesMatch(left, right) {
|
|
7
|
-
return left.chain_id === right.chain_id &&
|
|
5
|
+
return (left.chain_id === right.chain_id &&
|
|
6
|
+
addressesMatch(left.address, right.address, getChainTypeByChainId(left.chain_id)));
|
|
8
7
|
}
|
|
9
8
|
export function resolveCurrencySelection({ currencies, connectedChainTypes, defaultSourceCurrency, defaultDestinationCurrency, }) {
|
|
10
9
|
const preferredSource = defaultSourceCurrency
|