@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.
Files changed (33) 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/execution-modal.js +8 -2
  6. package/dist/components/styles.d.ts +4 -1
  7. package/dist/components/styles.js +86 -16
  8. package/dist/components/swap.d.ts +27 -1
  9. package/dist/components/swap.js +41 -18
  10. package/dist/components/token-picker.d.ts +3 -1
  11. package/dist/components/token-picker.js +19 -9
  12. package/dist/components/token-picker.test.d.ts +1 -0
  13. package/dist/components/token-picker.test.js +43 -0
  14. package/dist/components/wallet-connector.d.ts +3 -1
  15. package/dist/components/wallet-connector.js +69 -20
  16. package/dist/components/wallet-connector.test.d.ts +1 -0
  17. package/dist/components/wallet-connector.test.js +89 -0
  18. package/dist/hooks/use-swap-execution.d.ts +1 -1
  19. package/dist/hooks/use-swap-execution.js +69 -35
  20. package/dist/hooks/use-swap-state.d.ts +4 -1
  21. package/dist/hooks/use-swap-state.js +3 -3
  22. package/dist/hooks/use-token-data.d.ts +1 -0
  23. package/dist/hooks/use-token-data.js +4 -3
  24. package/dist/lib/currency-selection.js +3 -4
  25. package/dist/lib/execution.d.ts +12 -5
  26. package/dist/lib/quote.d.ts +1 -1
  27. package/dist/lib/quote.js +7 -7
  28. package/dist/providers/swapkit-provider.d.ts +1 -1
  29. package/dist/providers/swapkit-provider.js +3 -1
  30. package/dist/providers/swapkit-provider.test.d.ts +1 -0
  31. package/dist/providers/swapkit-provider.test.js +20 -0
  32. package/dist/types.d.ts +2 -1
  33. 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 = 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();
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({ 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,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({ open, chainType, onClose }) {
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: 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
- },
36
+ onSuccess: close,
37
+ onError: showError,
21
38
  });
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 })] }));
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, useEffect, useRef, useState } from "react";
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 instanceof Error ? error.message.trim() : "";
9
+ const message = getErrorMessage(error).trim();
9
10
  return message || fallback;
10
11
  }
11
- function isRejected(error) {
12
- const message = error instanceof Error ? error.message.toLowerCase() : "";
13
- return message.includes("user rejected") || message.includes("rejected the request");
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, setStage] = useState({ kind: "idle" });
19
- const [orderId, setOrderId] = useState();
20
- const stageRef = useRef(stage);
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
- useEffect(() => {
23
- stageRef.current = stage;
24
- }, [stage]);
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
- setOrderId(undefined);
28
- setStage({ kind: "idle" });
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 txHash = event.order.payment?.destination_tx_hash ?? sourceTxHash;
40
- if (txHash)
41
- setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash, txHash });
42
- setOrderId(undefined);
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 && event.order.payment?.refund_tx_hash) {
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: event.order.payment.refund_tx_hash,
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 (!transactionActions)
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(request);
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
- setOrderId(swapData.order_id);
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
- sourceTxHash = await transactionActions.execute(transaction);
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 (isRejected(error)) {
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, transactionActions]);
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: () => void;
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",
@@ -9,4 +9,5 @@ export declare function useTokenData(): {
9
9
  accounts: import("@coin-voyage/crypto/hooks").Account[];
10
10
  isLoading: boolean;
11
11
  error: string | null;
12
+ refreshBalances: import("react").ActionDispatch<[]>;
12
13
  };
@@ -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 && normalizedAddress(left) === normalizedAddress(right);
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